mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 00:42:06 +08:00
Compare commits
33 Commits
v1.18.1
...
bc52d9ea28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc52d9ea28 | ||
|
|
503c384685 | ||
|
|
506d43144b | ||
|
|
9e22f42a3d | ||
|
|
9c0e864532 | ||
|
|
d19b1e949f | ||
|
|
b359c51527 | ||
|
|
8339b8e624 | ||
|
|
061f0fd297 | ||
|
|
a367edd515 | ||
|
|
917c353115 | ||
|
|
4ab94fce42 | ||
|
|
e3b689214f | ||
|
|
1f903baf2c | ||
|
|
5e3a7b69d7 | ||
|
|
23801e9cc1 | ||
|
|
4f6ff9260d | ||
|
|
49061bc7a3 | ||
|
|
50e097d4ac | ||
|
|
f986004319 | ||
|
|
f488a346ef | ||
|
|
82efac4704 | ||
|
|
81697d0e73 | ||
|
|
27bf09b0c1 | ||
|
|
7ccbc8d92d | ||
|
|
597e9c54b7 | ||
|
|
cccd915c33 | ||
|
|
0207a1f3a3 | ||
|
|
38a593c97f | ||
|
|
fc369d047e | ||
|
|
9d8e92b5a4 | ||
|
|
07f1d9a4a8 | ||
|
|
e853d29584 |
@@ -21,6 +21,10 @@ PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
|
||||
# Host/IP to bind servers to (default: 0.0.0.0 for all interfaces)
|
||||
# Use 127.0.0.1 to restrict to localhost only
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
|
||||
@@ -2,20 +2,39 @@
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
"requireBranch": "main",
|
||||
"requireCleanWorkingDir": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "CloudCLI UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
"releaseName": "CloudCLI UI v${version}"
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"infile": "CHANGELOG.md",
|
||||
"header": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n",
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{ "type": "feat", "section": "New Features" },
|
||||
{ "type": "feature", "section": "New Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance" },
|
||||
{ "type": "refactor", "section": "Refactoring" },
|
||||
{ "type": "docs", "section": "Documentation" },
|
||||
{ "type": "style", "section": "Styling" },
|
||||
{ "type": "chore", "section": "Maintenance" },
|
||||
{ "type": "ci", "section": "CI/CD" },
|
||||
{ "type": "test", "section": "Tests" },
|
||||
{ "type": "build", "section": "Build" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
54
CHANGELOG.md
Normal file
54
CHANGELOG.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)
|
||||
|
||||
### New Features
|
||||
|
||||
* add copy icon for user messages ([#449](https://github.com/siteboon/claudecodeui/issues/449)) ([b359c51](https://github.com/siteboon/claudecodeui/commit/b359c515277b4266fde2fb9a29b5356949c07c4f))
|
||||
* Google's gemini-cli integration ([#422](https://github.com/siteboon/claudecodeui/issues/422)) ([a367edd](https://github.com/siteboon/claudecodeui/commit/a367edd51578608b3281373cb4a95169dbf17f89))
|
||||
* persist active tab across reloads via localStorage ([#414](https://github.com/siteboon/claudecodeui/issues/414)) ([e3b6892](https://github.com/siteboon/claudecodeui/commit/e3b689214f11d549ffe1b3a347476d58f25c5aca)), closes [#387](https://github.com/siteboon/claudecodeui/issues/387)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add support for Codex in the shell ([#424](https://github.com/siteboon/claudecodeui/issues/424)) ([23801e9](https://github.com/siteboon/claudecodeui/commit/23801e9cc15d2b8d1bfc6e39aee2fae93226d1ad))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging ([#446](https://github.com/siteboon/claudecodeui/issues/446)) ([917c353](https://github.com/siteboon/claudecodeui/commit/917c353115653ee288bf97be01f62fad24123cbc))
|
||||
* upgrade better-sqlite to latest version to support node 25 ([#445](https://github.com/siteboon/claudecodeui/issues/445)) ([4ab94fc](https://github.com/siteboon/claudecodeui/commit/4ab94fce4257e1e20370fa83fa4c0f6fadbb8a2b))
|
||||
|
||||
## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))
|
||||
* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))
|
||||
|
||||
## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))
|
||||
|
||||
## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)
|
||||
|
||||
### New Features
|
||||
|
||||
* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))
|
||||
* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)
|
||||
* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))
|
||||
156
CONTRIBUTING.md
Normal file
156
CONTRIBUTING.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Contributing to CloudCLI UI
|
||||
|
||||
Thanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.
|
||||
- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.
|
||||
- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) 22 or later
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
4. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
5. Create a branch for your changes:
|
||||
```bash
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
claudecodeui/
|
||||
├── src/ # React frontend (Vite + Tailwind)
|
||||
│ ├── components/ # UI components
|
||||
│ ├── contexts/ # React context providers
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── i18n/ # Internationalization and translations
|
||||
│ ├── lib/ # Shared frontend libraries
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ └── utils/ # Frontend utilities
|
||||
├── server/ # Express backend
|
||||
│ ├── routes/ # API route handlers
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ ├── database/ # SQLite database layer
|
||||
│ └── tools/ # CLI tool integrations
|
||||
├── shared/ # Code shared between client and server
|
||||
└── public/ # Static assets, icons, PWA manifest
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- `npm run dev` — Start both the frontend and backend in development mode
|
||||
- `npm run build` — Create a production build
|
||||
- `npm run server` — Start only the backend server
|
||||
- `npm run client` — Start only the Vite dev server
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Reference the issue number in your PR if one exists
|
||||
- Describe how to reproduce the bug in your PR description
|
||||
- Add a screenshot or recording for visual bugs
|
||||
|
||||
### New Features
|
||||
|
||||
- Keep the scope focused — one feature per PR
|
||||
- Include screenshots or recordings for UI changes
|
||||
|
||||
### Documentation
|
||||
|
||||
- Documentation improvements are always welcome
|
||||
- Keep language clear and concise
|
||||
|
||||
## Commit Convention
|
||||
|
||||
We follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:
|
||||
|
||||
```
|
||||
<type>(optional scope): <description>
|
||||
```
|
||||
|
||||
Use imperative, present tense: "add feature" not "added feature" or "adds feature".
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `feat` | A new feature |
|
||||
| `fix` | A bug fix |
|
||||
| `perf` | A performance improvement |
|
||||
| `refactor` | Code change that neither fixes a bug nor adds a feature |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | CSS, formatting, visual changes |
|
||||
| `chore` | Maintenance, dependencies, config |
|
||||
| `ci` | CI/CD pipeline changes |
|
||||
| `test` | Adding or updating tests |
|
||||
| `build` | Build system changes |
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
feat: add conversation search
|
||||
feat(i18n): add Japanese language support
|
||||
fix: redirect unauthenticated users to login
|
||||
fix(editor): syntax highlighting for .env files
|
||||
perf: lazy load code editor component
|
||||
refactor(chat): extract message list component
|
||||
docs: update API configuration guide
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
Add `!` after the type or include `BREAKING CHANGE:` in the commit footer:
|
||||
|
||||
```bash
|
||||
feat!: redesign settings page layout
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
- Give your PR a clear, descriptive title following the commit convention above
|
||||
- Fill in the PR description with what changed and why
|
||||
- Link any related issues
|
||||
- Include screenshots for UI changes
|
||||
- Make sure the build passes (`npm run build`)
|
||||
- Keep PRs focused — avoid unrelated changes
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).
|
||||
|
||||
```bash
|
||||
npm run release # interactive (prompts for version bump)
|
||||
npm run release -- patch # patch release
|
||||
npm run release -- minor # minor release
|
||||
```
|
||||
|
||||
This automatically:
|
||||
- Bumps the version based on commit types (`feat` = minor, `fix` = patch)
|
||||
- Generates categorized release notes
|
||||
- Updates `CHANGELOG.md`
|
||||
- Creates a git tag and GitHub Release
|
||||
- Publishes to npm
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE).
|
||||
346
README.ja.md
Normal file
346
README.ja.md
Normal file
@@ -0,0 +1,346 @@
|
||||
<div align="center">
|
||||
<img src="public/logo.svg" alt="Claude Code UI" width="64" height="64">
|
||||
<h1>Cloud CLI (別名 Claude Code UI)</h1>
|
||||
</div>
|
||||
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
|
||||
|
||||
## スクリーンショット
|
||||
|
||||
<div align="center">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<h3>デスクトップビュー</h3>
|
||||
<img src="public/screenshots/desktop-main.png" alt="Desktop Interface" width="400">
|
||||
<br>
|
||||
<em>プロジェクト概要とチャットを表示するメインインターフェース</em>
|
||||
</td>
|
||||
<td align="center">
|
||||
<h3>モバイル体験</h3>
|
||||
<img src="public/screenshots/mobile-chat.png" alt="Mobile Interface" width="250">
|
||||
<br>
|
||||
<em>タッチナビゲーション対応のレスポンシブモバイルデザイン</em>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<h3>CLI 選択</h3>
|
||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||
<br>
|
||||
<em>Claude Code、Cursor CLI、Codex から選択</em>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
## 機能
|
||||
|
||||
- **レスポンシブデザイン** - デスクトップ、タブレット、モバイルでシームレスに動作し、モバイルからも Claude Code、Cursor、Codex を使用可能
|
||||
- **インタラクティブチャットインターフェース** - Claude Code、Cursor、Codex とシームレスに通信する組み込みチャットインターフェース
|
||||
- **統合シェルターミナル** - 組み込みシェル機能による Claude Code、Cursor CLI、Codex への直接アクセス
|
||||
- **ファイルエクスプローラー** - シンタックスハイライトとライブ編集対応のインタラクティブファイルツリー
|
||||
- **Git エクスプローラー** - 変更の確認、ステージング、コミット。ブランチの切り替えも可能
|
||||
- **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡
|
||||
- **TaskMaster AI 統合** *(オプション)* - AI 駆動のタスク計画、PRD 解析、ワークフロー自動化による高度なプロジェクト管理
|
||||
- **モデル互換性** - Claude Sonnet 4.5、Opus 4.5、GPT-5.2 に対応
|
||||
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### 前提条件
|
||||
|
||||
- [Node.js](https://nodejs.org/) v22 以上
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) のインストールと設定、および/または
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) のインストールと設定、および/または
|
||||
- [Codex](https://developers.openai.com/codex) のインストールと設定
|
||||
|
||||
### ワンクリック実行(推奨)
|
||||
|
||||
インストール不要、直接実行:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
サーバーが起動し、`http://localhost:3001`(または設定した PORT)でアクセスできます。
|
||||
|
||||
**再起動**: サーバーを停止した後、同じ `npx` コマンドを再度実行するだけです
|
||||
### グローバルインストール(定期的に使用する場合)
|
||||
|
||||
頻繁に使用する場合は、一度だけグローバルインストール:
|
||||
|
||||
```bash
|
||||
npm install -g @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
シンプルなコマンドで起動:
|
||||
|
||||
```bash
|
||||
claude-code-ui
|
||||
```
|
||||
|
||||
|
||||
**再起動**: Ctrl+C で停止し、`claude-code-ui` を再度実行します。
|
||||
|
||||
**アップデート**:
|
||||
```bash
|
||||
cloudcli update
|
||||
```
|
||||
|
||||
### CLI の使い方
|
||||
|
||||
グローバルインストール後、`claude-code-ui` と `cloudcli` コマンドが使用できます:
|
||||
|
||||
| コマンド / オプション | 短縮形 | 説明 |
|
||||
|------------------|-------|-------------|
|
||||
| `cloudcli` または `claude-code-ui` | | サーバーを起動(デフォルト) |
|
||||
| `cloudcli start` | | サーバーを明示的に起動 |
|
||||
| `cloudcli status` | | 設定とデータの場所を表示 |
|
||||
| `cloudcli update` | | 最新バージョンに更新 |
|
||||
| `cloudcli help` | | ヘルプ情報を表示 |
|
||||
| `cloudcli version` | | バージョン情報を表示 |
|
||||
| `--port <port>` | `-p` | サーバーポートを設定(デフォルト: 3001) |
|
||||
| `--database-path <path>` | | カスタムデータベースの場所を設定 |
|
||||
|
||||
**例:**
|
||||
```bash
|
||||
cloudcli # デフォルト設定で起動
|
||||
cloudcli -p 8080 # カスタムポートで起動
|
||||
cloudcli status # 現在の設定を表示
|
||||
```
|
||||
|
||||
### バックグラウンドサービスとして実行(本番環境推奨)
|
||||
|
||||
本番環境では、PM2(Process Manager 2)を使用して Claude Code UI をバックグラウンドサービスとして実行します:
|
||||
|
||||
#### PM2 のインストール
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### バックグラウンドサービスとして起動
|
||||
|
||||
```bash
|
||||
# バックグラウンドでサーバーを起動
|
||||
pm2 start claude-code-ui --name "claude-code-ui"
|
||||
|
||||
# または短いエイリアスを使用
|
||||
pm2 start cloudcli --name "claude-code-ui"
|
||||
|
||||
# カスタムポートで起動
|
||||
pm2 start cloudcli --name "claude-code-ui" -- --port 8080
|
||||
```
|
||||
|
||||
|
||||
#### システム起動時の自動起動
|
||||
|
||||
システム起動時に Claude Code UI を自動的に起動するには:
|
||||
|
||||
```bash
|
||||
# プラットフォーム用の起動スクリプトを生成
|
||||
pm2 startup
|
||||
|
||||
# 現在のプロセスリストを保存
|
||||
pm2 save
|
||||
```
|
||||
|
||||
|
||||
### ローカル開発インストール
|
||||
|
||||
1. **リポジトリをクローン:**
|
||||
```bash
|
||||
git clone https://github.com/siteboon/claudecodeui.git
|
||||
cd claudecodeui
|
||||
```
|
||||
|
||||
2. **依存関係をインストール:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **環境を設定:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# お好みの設定で .env を編集
|
||||
```
|
||||
|
||||
4. **アプリケーションを起動:**
|
||||
```bash
|
||||
# 開発モード(ホットリロード付き)
|
||||
npm run dev
|
||||
|
||||
```
|
||||
アプリケーションは .env で指定したポートで起動します
|
||||
|
||||
5. **ブラウザを開く:**
|
||||
- 開発: `http://localhost:3001`
|
||||
|
||||
## セキュリティとツール設定
|
||||
|
||||
**重要なお知らせ**: すべての Claude Code ツールは**デフォルトで無効**になっています。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。
|
||||
|
||||
### ツールの有効化
|
||||
|
||||
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
|
||||
|
||||
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
||||
3. **選択的に有効化** - 必要なツールのみを有効にする
|
||||
4. **設定を適用** - 環境設定はローカルに保存されます
|
||||
|
||||
<div align="center">
|
||||
|
||||

|
||||
*ツール設定インターフェース - 必要なものだけを有効にしましょう*
|
||||
|
||||
</div>
|
||||
|
||||
**推奨アプローチ**: 基本的なツールから有効にし、必要に応じて追加してください。これらの設定はいつでも調整できます。
|
||||
|
||||
## TaskMaster AI 統合 *(オプション)*
|
||||
|
||||
Claude Code UI は、高度なプロジェクト管理と AI 駆動のタスク計画のための **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)**(別名 claude-task-master)統合をサポートしています。
|
||||
|
||||
提供機能
|
||||
- PRD(製品要件ドキュメント)からの AI 駆動タスク生成
|
||||
- スマートなタスク分解と依存関係管理
|
||||
- ビジュアルタスクボードと進捗追跡
|
||||
|
||||
**セットアップとドキュメント**: インストール手順、設定ガイド、使用例は [TaskMaster AI GitHub リポジトリ](https://github.com/eyaltoledano/claude-task-master)をご覧ください。
|
||||
インストール後、設定から有効にできます
|
||||
|
||||
|
||||
## 使用ガイド
|
||||
|
||||
### 主要機能
|
||||
|
||||
#### プロジェクト管理
|
||||
Claude Code、Cursor、Codex のセッションが利用可能な場合、自動的に検出しプロジェクトとしてグループ化します
|
||||
- **プロジェクト操作** - プロジェクトの名前変更、削除、整理
|
||||
- **スマートナビゲーション** - 最近のプロジェクトやセッションへのクイックアクセス
|
||||
- **MCP サポート** - UI から独自の MCP サーバーを追加
|
||||
|
||||
#### チャットインターフェース
|
||||
- **レスポンシブチャットまたは Claude Code/Cursor CLI/Codex CLI を使用** - アダプティブチャットインターフェースを使用するか、シェルボタンで選択した CLI に接続できます
|
||||
- **リアルタイム通信** - WebSocket 接続で選択した CLI(Claude Code/Cursor/Codex)からレスポンスをストリーミング
|
||||
- **セッション管理** - 以前の会話を再開、または新しいセッションを開始
|
||||
- **メッセージ履歴** - タイムスタンプとメタデータ付きの完全な会話履歴
|
||||
- **マルチフォーマット対応** - テキスト、コードブロック、ファイル参照
|
||||
|
||||
#### ファイルエクスプローラーとエディター
|
||||
- **インタラクティブファイルツリー** - 展開/折りたたみナビゲーションでプロジェクト構造を閲覧
|
||||
- **ライブファイル編集** - インターフェースで直接ファイルの読み取り、変更、保存
|
||||
- **シンタックスハイライト** - 複数のプログラミング言語に対応
|
||||
- **ファイル操作** - ファイルやディレクトリの作成、名前変更、削除
|
||||
|
||||
#### Git エクスプローラー
|
||||
|
||||
|
||||
#### TaskMaster AI 統合 *(オプション)*
|
||||
- **ビジュアルタスクボード** - 開発タスク管理のためのカンバンスタイルインターフェース
|
||||
- **PRD パーサー** - 製品要件ドキュメントを作成し、構造化されたタスクに変換
|
||||
- **進捗追跡** - リアルタイムのステータス更新と完了追跡
|
||||
|
||||
#### セッション管理
|
||||
- **セッション永続化** - すべての会話を自動保存
|
||||
- **セッション整理** - プロジェクトとタイムスタンプでセッションをグループ化
|
||||
- **セッション操作** - 会話履歴の名前変更、削除、エクスポート
|
||||
- **クロスデバイス同期** - どのデバイスからでもセッションにアクセス
|
||||
|
||||
### モバイルアプリ
|
||||
- **レスポンシブデザイン** - すべての画面サイズに最適化
|
||||
- **タッチフレンドリーインターフェース** - スワイプジェスチャーとタッチナビゲーション
|
||||
- **モバイルナビゲーション** - 親指で操作しやすいボトムタブバー
|
||||
- **アダプティブレイアウト** - 折りたたみ可能なサイドバーとスマートコンテンツ優先順位
|
||||
- **ホーム画面にショートカットを追加** - ホーム画面にショートカットを追加すると、アプリが PWA のように動作します
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
### システム概要
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ Agent │
|
||||
│ (React/Vite) │◄──►│ (Express/WS) │◄──►│ Integration │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### バックエンド (Node.js + Express)
|
||||
- **Express サーバー** - 静的ファイル配信付きの RESTful API
|
||||
- **WebSocket サーバー** - チャットとプロジェクト更新のための通信
|
||||
- **エージェント統合 (Claude Code / Cursor CLI / Codex)** - プロセスの生成と管理
|
||||
- **ファイルシステム API** - プロジェクト向けファイルブラウザの公開
|
||||
|
||||
### フロントエンド (React + Vite)
|
||||
- **React 18** - hooks を使用したモダンなコンポーネントアーキテクチャ
|
||||
- **CodeMirror** - シンタックスハイライト対応の高度なコードエディター
|
||||
|
||||
|
||||
|
||||
|
||||
### コントリビューション
|
||||
|
||||
コントリビューションを歓迎します!コミット規約、開発ワークフロー、リリースプロセスの詳細は [Contributing Guide](CONTRIBUTING.md) をご覧ください。
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### よくある問題と解決方法
|
||||
|
||||
|
||||
#### 「Claude プロジェクトが見つかりません」
|
||||
**問題**: UI にプロジェクトが表示されない、またはプロジェクトリストが空
|
||||
**解決方法**:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) が正しくインストールされていることを確認
|
||||
- 少なくとも1つのプロジェクトディレクトリで `claude` コマンドを実行して初期化
|
||||
- `~/.claude/projects/` ディレクトリが存在し、適切な権限があることを確認
|
||||
|
||||
#### ファイルエクスプローラーの問題
|
||||
**問題**: ファイルが読み込まれない、権限エラー、空のディレクトリ
|
||||
**解決方法**:
|
||||
- プロジェクトディレクトリの権限を確認(ターミナルで `ls -la`)
|
||||
- プロジェクトパスが存在しアクセス可能であることを確認
|
||||
- 詳細なエラーメッセージについてはサーバーコンソールログを確認
|
||||
- プロジェクト範囲外のシステムディレクトリにアクセスしていないことを確認
|
||||
|
||||
|
||||
## ライセンス
|
||||
|
||||
GNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルをご覧ください。
|
||||
|
||||
このプロジェクトはオープンソースであり、GPL v3 ライセンスの下で自由に使用、変更、配布できます。
|
||||
|
||||
## 謝辞
|
||||
|
||||
### 使用技術
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[React](https://react.dev/)** - ユーザーインターフェースライブラリ
|
||||
- **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファースト CSS フレームワーク
|
||||
- **[CodeMirror](https://codemirror.net/)** - 高度なコードエディター
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(オプション)* - AI 駆動のプロジェクト管理とタスク計画
|
||||
|
||||
## サポートとコミュニティ
|
||||
|
||||
### 最新情報を入手
|
||||
- このリポジトリに **Star** をつけてサポートを表明
|
||||
- **Watch** で更新や新リリースを確認
|
||||
- プロジェクトを **Follow** してお知らせを受け取る
|
||||
|
||||
### スポンサー
|
||||
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong>
|
||||
</div>
|
||||
28
README.ko.md
28
README.ko.md
@@ -6,7 +6,7 @@
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 스크린샷
|
||||
|
||||
@@ -288,31 +288,7 @@ Claude Code, Cursor 또는 Codex 세션을 사용할 수 있을 때 자동으로
|
||||
|
||||
### 기여하기
|
||||
|
||||
기여를 환영합니다! 다음 가이드라인을 따라주세요:
|
||||
|
||||
#### 시작하기
|
||||
1. 리포지토리를 **Fork**합니다
|
||||
2. Fork를 **클론**: `git clone <your-fork-url>`
|
||||
3. 의존성 **설치**: `npm install`
|
||||
4. 기능 브랜치 **생성**: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### 개발 프로세스
|
||||
1. 기존 코드 스타일을 따라 **변경 사항을 적용**합니다
|
||||
2. **철저하게 테스트** - 모든 기능이 올바르게 작동하는지 확인
|
||||
3. **품질 검사 실행**: `npm run lint && npm run format`
|
||||
4. [Conventional Commits](https://conventionalcommits.org/)를 따르는 설명적 메시지로 **커밋**
|
||||
5. 브랜치에 **푸시**: `git push origin feature/amazing-feature`
|
||||
6. 다음을 포함하여 **풀 리퀘스트를 제출**:
|
||||
- 변경 사항에 대한 명확한 설명
|
||||
- UI 변경 시 스크린샷
|
||||
- 해당되는 경우 테스트 결과
|
||||
|
||||
#### 기여 내용
|
||||
- **버그 수정** - 안정성 향상에 도움
|
||||
- **새로운 기능** - 기능 향상 (먼저 이슈에서 논의)
|
||||
- **문서** - 가이드 및 API 문서 개선
|
||||
- **UI/UX 개선** - 더 나은 사용자 경험
|
||||
- **성능 최적화** - 더 빠르게 만들기
|
||||
기여를 환영합니다! 커밋 규칙, 개발 워크플로우, 릴리스 프로세스에 대한 자세한 내용은 [Contributing Guide](CONTRIBUTING.md)를 참조해주세요.
|
||||
|
||||
## 문제 해결
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -4,9 +4,10 @@
|
||||
</div>
|
||||
|
||||
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) and [Codex](https://developers.openai.com/codex). You can use it locally or remotely to view your active projects and sessions in Claude Code, Cursor, or Codex and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
|
||||
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a></i></div>
|
||||
<a href="https://trendshift.io/repositories/15586" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15586" alt="siteboon%2Fclaudecodeui | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
<div align="right"><i><b>English</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -43,14 +44,14 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
|
||||
## Features
|
||||
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Claude Code, Cursor, or Codex from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with Claude Code, Cursor, or Codex
|
||||
- **Integrated Shell Terminal** - Direct access to Claude Code, Cursor CLI, or Codex through built-in shell functionality
|
||||
- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile
|
||||
- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents
|
||||
- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, and GPT-5.2
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
|
||||
|
||||
|
||||
## Quick Start
|
||||
@@ -60,7 +61,8 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- [Node.js](https://nodejs.org/) v22 or higher
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured, and/or
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured
|
||||
- [Codex](https://developers.openai.com/codex) installed and configured, and/or
|
||||
- [Gemini-CLI](https://geminicli.com/) installed and configured
|
||||
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
@@ -278,7 +280,7 @@ session counts
|
||||
### Backend (Node.js + Express)
|
||||
- **Express Server** - RESTful API with static file serving
|
||||
- **WebSocket Server** - Communication for chats and project refresh
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex)** - Process spawning and management
|
||||
- **Agent Integration (Claude Code / Cursor CLI / Codex / Gemini CLI)** - Process spawning and management
|
||||
- **File System API** - Exposing file browser for projects
|
||||
|
||||
### Frontend (React + Vite)
|
||||
@@ -291,31 +293,7 @@ session counts
|
||||
|
||||
### Contributing
|
||||
|
||||
We welcome contributions! Please follow these guidelines:
|
||||
|
||||
#### Getting Started
|
||||
1. **Fork** the repository
|
||||
2. **Clone** your fork: `git clone <your-fork-url>`
|
||||
3. **Install** dependencies: `npm install`
|
||||
4. **Create** a feature branch: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### Development Process
|
||||
1. **Make your changes** following the existing code style
|
||||
2. **Test thoroughly** - ensure all features work correctly
|
||||
3. **Run quality checks**: `npm run lint && npm run format`
|
||||
4. **Commit** with descriptive messages following [Conventional Commits](https://conventionalcommits.org/)
|
||||
5. **Push** to your branch: `git push origin feature/amazing-feature`
|
||||
6. **Submit** a Pull Request with:
|
||||
- Clear description of changes
|
||||
- Screenshots for UI changes
|
||||
- Test results if applicable
|
||||
|
||||
#### What to Contribute
|
||||
- **Bug fixes** - Help us improve stability
|
||||
- **New features** - Enhance functionality (discuss in issues first)
|
||||
- **Documentation** - Improve guides and API docs
|
||||
- **UI/UX improvements** - Better user experience
|
||||
- **Performance optimizations** - Make it faster
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on commit conventions, development workflow, and release process.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -350,6 +328,7 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI
|
||||
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI
|
||||
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
|
||||
- **[React](https://react.dev/)** - User interface library
|
||||
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
|
||||
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a></i></div>
|
||||
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||
|
||||
## 截图
|
||||
|
||||
@@ -290,31 +290,7 @@ Claude Code UI 支持 **[TaskMaster AI](https://github.com/eyaltoledano/claude-t
|
||||
|
||||
### 贡献
|
||||
|
||||
我们欢迎贡献!请遵循以下指南:
|
||||
|
||||
#### 入门
|
||||
1. **Fork** 仓库
|
||||
2. **克隆** 您的 fork: `git clone <your-fork-url>`
|
||||
3. **安装** 依赖: `npm install`
|
||||
4. **创建** 特性分支: `git checkout -b feature/amazing-feature`
|
||||
|
||||
#### 开发流程
|
||||
1. **进行更改**,遵循现有代码风格
|
||||
2. **彻底测试** - 确保所有功能正常工作
|
||||
3. **运行质量检查**: `npm run lint && npm run format`
|
||||
4. **提交**,遵循 [Conventional Commits](https://conventionalcommits.org/)的描述性消息
|
||||
5. **推送** 到您的分支: `git push origin feature/amazing-feature`
|
||||
6. **提交** 拉取请求,包括:
|
||||
- 更改的清晰描述
|
||||
- UI 更改的截图
|
||||
- 适用时的测试结果
|
||||
|
||||
#### 贡献内容
|
||||
- **错误修复** - 帮助我们提高稳定性
|
||||
- **新功能** - 增强功能(先在 issue 中讨论)
|
||||
- **文档** - 改进指南和 API 文档
|
||||
- **UI/UX 改进** - 更好的用户体验
|
||||
- **性能优化** - 让它更快
|
||||
我们欢迎贡献!有关提交规范、开发流程和发布流程的详细信息,请参阅 [Contributing Guide](CONTRIBUTING.md)。
|
||||
|
||||
## 故障排除
|
||||
|
||||
|
||||
781
package-lock.json
generated
781
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.18.1",
|
||||
"version": "1.21.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
@@ -12,6 +12,7 @@
|
||||
"server/",
|
||||
"shared/",
|
||||
"dist/",
|
||||
"scripts/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://cloudcli.ai",
|
||||
@@ -30,7 +31,9 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "./release.sh"
|
||||
"release": "./release.sh",
|
||||
"prepublishOnly": "npm run build",
|
||||
"postinstall": "node scripts/fix-node-pty.js"
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
@@ -42,7 +45,7 @@
|
||||
"author": "CloudCLI UI Contributors",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.71",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.59",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
@@ -63,7 +66,7 @@
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -84,6 +87,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-i18next": "^16.5.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.8.1",
|
||||
@@ -95,9 +99,11 @@
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
|
||||
1
public/icons/gemini-ai-icon.svg
Normal file
1
public/icons/gemini-ai-icon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
53
public/sw.js
53
public/sw.js
@@ -22,11 +22,9 @@ self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return cached response if found
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// Otherwise fetch from network
|
||||
return fetch(event.request);
|
||||
}
|
||||
)
|
||||
@@ -46,4 +44,53 @@ self.addEventListener('activate', event => {
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Push notification event
|
||||
self.addEventListener('push', event => {
|
||||
if (!event.data) return;
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Claude Code UI', body: event.data.text() };
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: payload.body || '',
|
||||
icon: '/logo.png',
|
||||
badge: '/logo.png',
|
||||
data: payload.data || {},
|
||||
tag: payload.data?.code || 'default',
|
||||
renotify: true
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(payload.title || 'Claude Code UI', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click event
|
||||
self.addEventListener('notificationclick', event => {
|
||||
event.notification.close();
|
||||
|
||||
const sessionId = event.notification.data?.sessionId;
|
||||
const urlPath = sessionId ? `/session/${sessionId}` : '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin)) {
|
||||
client.focus();
|
||||
if (sessionId) {
|
||||
client.navigate(self.location.origin + urlPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(urlPath);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
67
scripts/fix-node-pty.js
Normal file
67
scripts/fix-node-pty.js
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix node-pty spawn-helper permissions on macOS
|
||||
*
|
||||
* This script fixes a known issue with node-pty where the spawn-helper
|
||||
* binary is shipped without execute permissions, causing "posix_spawnp failed" errors.
|
||||
*
|
||||
* @see https://github.com/microsoft/node-pty/issues/850
|
||||
* @module scripts/fix-node-pty
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Fixes the spawn-helper binary permissions for node-pty on macOS.
|
||||
*
|
||||
* The node-pty package ships the spawn-helper binary without execute permissions
|
||||
* (644 instead of 755), which causes "posix_spawnp failed" errors when trying
|
||||
* to spawn terminal processes.
|
||||
*
|
||||
* This function:
|
||||
* 1. Checks if running on macOS (darwin)
|
||||
* 2. Locates spawn-helper binaries for both arm64 and x64 architectures
|
||||
* 3. Sets execute permissions (755) on each binary found
|
||||
*
|
||||
* @async
|
||||
* @function fixSpawnHelper
|
||||
* @returns {Promise<void>} Resolves when permissions are fixed or skipped
|
||||
* @example
|
||||
* // Run as postinstall script
|
||||
* await fixSpawnHelper();
|
||||
*/
|
||||
async function fixSpawnHelper() {
|
||||
const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');
|
||||
|
||||
// Only run on macOS
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const darwinDirs = ['darwin-arm64', 'darwin-x64'];
|
||||
|
||||
for (const dir of darwinDirs) {
|
||||
const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');
|
||||
|
||||
try {
|
||||
// Check if file exists
|
||||
await fs.access(spawnHelperPath);
|
||||
|
||||
// Make it executable (755)
|
||||
await fs.chmod(spawnHelperPath, 0o755);
|
||||
console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);
|
||||
} catch (err) {
|
||||
// File doesn't exist or other error - ignore
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fixSpawnHelper().catch(console.error);
|
||||
@@ -18,6 +18,7 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
||||
import { createNotificationEvent, notifyUserIfEnabled } from './services/notification-orchestrator.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -250,7 +251,13 @@ function getAllSessions() {
|
||||
* @returns {Object} Transformed message ready for WebSocket
|
||||
*/
|
||||
function transformMessage(sdkMessage) {
|
||||
// Pass-through; SDK messages match frontend format.
|
||||
// Extract parent_tool_use_id for subagent tool grouping
|
||||
if (sdkMessage.parent_tool_use_id) {
|
||||
return {
|
||||
...sdkMessage,
|
||||
parentToolUseId: sdkMessage.parent_tool_use_id
|
||||
};
|
||||
}
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
@@ -455,6 +462,14 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
let tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
|
||||
const emitNotification = (event) => {
|
||||
notifyUserIfEnabled({
|
||||
userId: ws?.userId || null,
|
||||
writer: ws,
|
||||
event
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
// Map CLI options to SDK format
|
||||
const sdkOptions = mapCliOptionsToSDK(options);
|
||||
@@ -471,6 +486,42 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
tempImagePaths = imageResult.tempImagePaths;
|
||||
tempDir = imageResult.tempDir;
|
||||
|
||||
sdkOptions.hooks = {
|
||||
Notification: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'agent.notification',
|
||||
meta: { message },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}],
|
||||
Stop: [{
|
||||
matcher: '',
|
||||
hooks: [async (input) => {
|
||||
const stopReason = typeof input?.stop_reason === 'string' ? input.stop_reason : 'completed';
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'stop',
|
||||
code: 'run.stopped',
|
||||
meta: { stopReason },
|
||||
severity: 'info',
|
||||
dedupeKey: `claude:hook:stop:${capturedSessionId || sessionId || 'none'}:${stopReason}`
|
||||
}));
|
||||
return {};
|
||||
}]
|
||||
}]
|
||||
};
|
||||
|
||||
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||
const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
|
||||
|
||||
@@ -502,6 +553,16 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
input,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'action_required',
|
||||
code: 'permission.required',
|
||||
meta: { toolName },
|
||||
severity: 'warning',
|
||||
requiresUserAction: true,
|
||||
dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
|
||||
}));
|
||||
|
||||
const decision = await waitForToolApproval(requestId, {
|
||||
timeoutMs: requiresInteraction ? 0 : undefined,
|
||||
@@ -542,10 +603,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
|
||||
process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
|
||||
|
||||
const queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
let queryInstance;
|
||||
try {
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
} catch (hookError) {
|
||||
// Older/newer SDK versions may not accept hook shapes yet.
|
||||
// Keep notification behavior operational via runtime events even if hook registration fails.
|
||||
console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
|
||||
delete sdkOptions.hooks;
|
||||
queryInstance = query({
|
||||
prompt: finalCommand,
|
||||
options: sdkOptions
|
||||
});
|
||||
}
|
||||
|
||||
// Restore immediately — Query constructor already captured the value
|
||||
if (prevStreamTimeout !== undefined) {
|
||||
@@ -597,6 +670,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
if (models.length > 0) {
|
||||
console.log("---> Model was sent using:", models);
|
||||
}
|
||||
const tokenBudget = extractTokenBudget(message);
|
||||
if (tokenBudget) {
|
||||
console.log('Token budget from modelUsage:', tokenBudget);
|
||||
@@ -644,6 +721,15 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
error: error.message,
|
||||
sessionId: capturedSessionId || sessionId || null
|
||||
});
|
||||
emitNotification(createNotificationEvent({
|
||||
provider: 'claude',
|
||||
sessionId: capturedSessionId || sessionId || null,
|
||||
kind: 'error',
|
||||
code: 'run.failed',
|
||||
meta: { error: error.message },
|
||||
severity: 'error',
|
||||
dedupeKey: `claude:error:${capturedSessionId || sessionId || 'none'}:${error.message}`
|
||||
}));
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,22 @@ if (process.env.DATABASE_PATH) {
|
||||
}
|
||||
}
|
||||
|
||||
// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location
|
||||
const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
|
||||
if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
|
||||
try {
|
||||
fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
|
||||
console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
|
||||
for (const suffix of ['-wal', '-shm']) {
|
||||
if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
|
||||
fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create database connection
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
@@ -75,6 +91,36 @@ const runMigrations = () => {
|
||||
db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error running migrations:', error.message);
|
||||
@@ -128,12 +174,12 @@ const userDb = {
|
||||
}
|
||||
},
|
||||
|
||||
// Update last login time
|
||||
// Update last login time (non-fatal — logged but not thrown)
|
||||
updateLastLogin: (userId) => {
|
||||
try {
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
console.warn('Failed to update last login:', err.message);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -332,6 +378,116 @@ const credentialsDb = {
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_NOTIFICATION_PREFERENCES = {
|
||||
channels: {
|
||||
inApp: false,
|
||||
webPush: true
|
||||
},
|
||||
events: {
|
||||
actionRequired: true,
|
||||
stop: true,
|
||||
error: true
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeNotificationPreferences = (value) => {
|
||||
const source = value && typeof value === 'object' ? value : {};
|
||||
|
||||
return {
|
||||
channels: {
|
||||
inApp: source.channels?.inApp === true,
|
||||
webPush: source.channels?.webPush !== false
|
||||
},
|
||||
events: {
|
||||
actionRequired: source.events?.actionRequired !== false,
|
||||
stop: source.events?.stop !== false,
|
||||
error: source.events?.error !== false
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const notificationPreferencesDb = {
|
||||
getPreferences: (userId) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);
|
||||
if (!row) {
|
||||
const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);
|
||||
db.prepare(
|
||||
'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'
|
||||
).run(userId, JSON.stringify(defaults));
|
||||
return defaults;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(row.preferences_json);
|
||||
} catch {
|
||||
parsed = DEFAULT_NOTIFICATION_PREFERENCES;
|
||||
}
|
||||
return normalizeNotificationPreferences(parsed);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
updatePreferences: (userId, preferences) => {
|
||||
try {
|
||||
const normalized = normalizeNotificationPreferences(preferences);
|
||||
db.prepare(
|
||||
`INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
preferences_json = excluded.preferences_json,
|
||||
updated_at = CURRENT_TIMESTAMP`
|
||||
).run(userId, JSON.stringify(normalized));
|
||||
return normalized;
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pushSubscriptionsDb = {
|
||||
saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(endpoint) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
keys_p256dh = excluded.keys_p256dh,
|
||||
keys_auth = excluded.keys_auth`
|
||||
).run(userId, endpoint, keysP256dh, keysAuth);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getSubscriptions: (userId) => {
|
||||
try {
|
||||
return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeSubscription: (endpoint) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
removeAllForUser: (userId) => {
|
||||
try {
|
||||
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Backward compatibility - keep old names pointing to new system
|
||||
const githubTokensDb = {
|
||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||
@@ -357,5 +513,7 @@ export {
|
||||
userDb,
|
||||
apiKeysDb,
|
||||
credentialsDb,
|
||||
notificationPreferencesDb,
|
||||
pushSubscriptionsDb,
|
||||
githubTokensDb // Backward compatibility
|
||||
};
|
||||
};
|
||||
|
||||
@@ -49,4 +49,31 @@ CREATE TABLE IF NOT EXISTS user_credentials (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);
|
||||
|
||||
-- User notification preferences (backend-owned, provider-agnostic)
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
preferences_json TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- VAPID key pair for Web Push notifications
|
||||
CREATE TABLE IF NOT EXISTS vapid_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Browser push subscriptions
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
keys_p256dh TEXT NOT NULL,
|
||||
keys_auth TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
455
server/gemini-cli.js
Normal file
455
server/gemini-cli.js
Normal file
@@ -0,0 +1,455 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
|
||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { getSessions, getSessionMessages } from './projects.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
|
||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
async function spawnGemini(command, options = {}, ws) {
|
||||
const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options;
|
||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||
let assistantBlocks = []; // Accumulate the full response blocks including tools
|
||||
|
||||
// Use tools settings passed from frontend, or defaults
|
||||
const settings = toolsSettings || {
|
||||
allowedTools: [],
|
||||
disallowedTools: [],
|
||||
skipPermissions: false
|
||||
};
|
||||
|
||||
// Build Gemini CLI command - start with print/resume flags first
|
||||
const args = [];
|
||||
|
||||
// Add prompt flag with command if we have a command
|
||||
if (command && command.trim()) {
|
||||
args.push('--prompt', command);
|
||||
}
|
||||
|
||||
// If we have a sessionId, we want to resume
|
||||
if (sessionId) {
|
||||
const session = sessionManager.getSession(sessionId);
|
||||
if (session && session.cliSessionId) {
|
||||
args.push('--resume', session.cliSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)
|
||||
// Clean the path by removing any non-printable characters
|
||||
const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\x20-\x7E]/g, '').trim();
|
||||
const workingDir = cleanPath;
|
||||
|
||||
// Handle images by saving them to temporary files and passing paths to Gemini
|
||||
const tempImagePaths = [];
|
||||
let tempDir = null;
|
||||
if (images && images.length > 0) {
|
||||
try {
|
||||
// Create temp directory in the project directory so Gemini can access it
|
||||
tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
|
||||
await fs.mkdir(tempDir, { recursive: true });
|
||||
|
||||
// Save each image to a temp file
|
||||
for (const [index, image] of images.entries()) {
|
||||
// Extract base64 data and mime type
|
||||
const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, mimeType, base64Data] = matches;
|
||||
const extension = mimeType.split('/')[1] || 'png';
|
||||
const filename = `image_${index}.${extension}`;
|
||||
const filepath = path.join(tempDir, filename);
|
||||
|
||||
// Write base64 data to file
|
||||
await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
|
||||
tempImagePaths.push(filepath);
|
||||
}
|
||||
|
||||
// Include the full image paths in the prompt for Gemini to reference
|
||||
// Gemini CLI can read images from file paths in the prompt
|
||||
if (tempImagePaths.length > 0 && command && command.trim()) {
|
||||
const imageNote = `\n\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
|
||||
const modifiedCommand = command + imageNote;
|
||||
|
||||
// Update the command in args
|
||||
const promptIndex = args.indexOf('--prompt');
|
||||
if (promptIndex !== -1 && args[promptIndex + 1] === command) {
|
||||
args[promptIndex + 1] = modifiedCommand;
|
||||
} else if (promptIndex !== -1) {
|
||||
// If we're using context, update the full prompt
|
||||
args[promptIndex + 1] = args[promptIndex + 1] + imageNote;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing images for Gemini:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add basic flags for Gemini
|
||||
if (options.debug) {
|
||||
args.push('--debug');
|
||||
}
|
||||
|
||||
// Add MCP config flag only if MCP servers are configured
|
||||
try {
|
||||
const geminiConfigPath = path.join(os.homedir(), '.gemini.json');
|
||||
let hasMcpServers = false;
|
||||
|
||||
try {
|
||||
await fs.access(geminiConfigPath);
|
||||
const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');
|
||||
const geminiConfig = JSON.parse(geminiConfigRaw);
|
||||
|
||||
// Check global MCP servers
|
||||
if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {
|
||||
hasMcpServers = true;
|
||||
}
|
||||
|
||||
// Check project-specific MCP servers
|
||||
if (!hasMcpServers && geminiConfig.geminiProjects) {
|
||||
const currentProjectPath = process.cwd();
|
||||
const projectConfig = geminiConfig.geminiProjects[currentProjectPath];
|
||||
if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {
|
||||
hasMcpServers = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore if file doesn't exist or isn't parsable
|
||||
}
|
||||
|
||||
if (hasMcpServers) {
|
||||
args.push('--mcp-config', geminiConfigPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore outer errors
|
||||
}
|
||||
|
||||
// Add model for all sessions (both new and resumed)
|
||||
let modelToUse = options.model || 'gemini-2.5-flash';
|
||||
args.push('--model', modelToUse);
|
||||
args.push('--output-format', 'stream-json');
|
||||
|
||||
// Handle approval modes and allowed tools
|
||||
if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {
|
||||
args.push('--yolo');
|
||||
} else if (permissionMode === 'auto_edit') {
|
||||
args.push('--approval-mode', 'auto_edit');
|
||||
} else if (permissionMode === 'plan') {
|
||||
args.push('--approval-mode', 'plan');
|
||||
}
|
||||
|
||||
if (settings.allowedTools && settings.allowedTools.length > 0) {
|
||||
args.push('--allowed-tools', settings.allowedTools.join(','));
|
||||
}
|
||||
|
||||
// Try to find gemini in PATH first, then fall back to environment variable
|
||||
const geminiPath = process.env.GEMINI_PATH || 'gemini';
|
||||
console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));
|
||||
console.log('Working directory:', workingDir);
|
||||
|
||||
let spawnCmd = geminiPath;
|
||||
let spawnArgs = args;
|
||||
|
||||
// On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC
|
||||
// which happens when the target is a script lacking a shebang.
|
||||
if (os.platform() !== 'win32') {
|
||||
spawnCmd = 'sh';
|
||||
// Use exec to replace the shell process, ensuring signals hit gemini directly
|
||||
spawnArgs = ['-c', 'exec "$0" "$@"', geminiPath, ...args];
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
});
|
||||
|
||||
// Attach temp file info to process for cleanup later
|
||||
geminiProcess.tempImagePaths = tempImagePaths;
|
||||
geminiProcess.tempDir = tempDir;
|
||||
|
||||
// Store process reference for potential abort
|
||||
const processKey = capturedSessionId || sessionId || Date.now().toString();
|
||||
activeGeminiProcesses.set(processKey, geminiProcess);
|
||||
|
||||
// Store sessionId on the process object for debugging
|
||||
geminiProcess.sessionId = processKey;
|
||||
|
||||
// Close stdin to signal we're done sending input
|
||||
geminiProcess.stdin.end();
|
||||
|
||||
// Add timeout handler
|
||||
let hasReceivedOutput = false;
|
||||
const timeoutMs = 120000; // 120 seconds for slower models
|
||||
let timeout;
|
||||
|
||||
const startTimeout = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);
|
||||
ws.send({
|
||||
type: 'gemini-error',
|
||||
sessionId: socketSessionId,
|
||||
error: `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`
|
||||
});
|
||||
try {
|
||||
geminiProcess.kill('SIGTERM');
|
||||
} catch (e) { }
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
startTimeout();
|
||||
|
||||
// Save user message to session when starting
|
||||
if (command && capturedSessionId) {
|
||||
sessionManager.addMessage(capturedSessionId, 'user', command);
|
||||
}
|
||||
|
||||
// Create response handler for NDJSON buffering
|
||||
let responseHandler;
|
||||
if (ws) {
|
||||
responseHandler = new GeminiResponseHandler(ws, {
|
||||
onContentFragment: (content) => {
|
||||
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
|
||||
assistantBlocks[assistantBlocks.length - 1].text += content;
|
||||
} else {
|
||||
assistantBlocks.push({ type: 'text', text: content });
|
||||
}
|
||||
},
|
||||
onToolUse: (event) => {
|
||||
assistantBlocks.push({
|
||||
type: 'tool_use',
|
||||
id: event.tool_id,
|
||||
name: event.tool_name,
|
||||
input: event.parameters
|
||||
});
|
||||
},
|
||||
onToolResult: (event) => {
|
||||
if (capturedSessionId) {
|
||||
if (assistantBlocks.length > 0) {
|
||||
sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);
|
||||
assistantBlocks = [];
|
||||
}
|
||||
sessionManager.addMessage(capturedSessionId, 'user', [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: event.tool_id,
|
||||
content: event.output === undefined ? null : event.output,
|
||||
is_error: event.status === 'error'
|
||||
}]);
|
||||
}
|
||||
},
|
||||
onInit: (event) => {
|
||||
if (capturedSessionId) {
|
||||
const sess = sessionManager.getSession(capturedSessionId);
|
||||
if (sess && !sess.cliSessionId) {
|
||||
sess.cliSessionId = event.session_id;
|
||||
sessionManager.saveSession(capturedSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle stdout
|
||||
geminiProcess.stdout.on('data', (data) => {
|
||||
const rawOutput = data.toString();
|
||||
hasReceivedOutput = true;
|
||||
startTimeout(); // Re-arm the timeout
|
||||
|
||||
// For new sessions, create a session ID FIRST
|
||||
if (!sessionId && !sessionCreatedSent && !capturedSessionId) {
|
||||
capturedSessionId = `gemini_${Date.now()}`;
|
||||
sessionCreatedSent = true;
|
||||
|
||||
// Create session in session manager
|
||||
sessionManager.createSession(capturedSessionId, cwd || process.cwd());
|
||||
|
||||
// Save the user message now that we have a session ID
|
||||
if (command) {
|
||||
sessionManager.addMessage(capturedSessionId, 'user', command);
|
||||
}
|
||||
|
||||
// Update process key with captured session ID
|
||||
if (processKey !== capturedSessionId) {
|
||||
activeGeminiProcesses.delete(processKey);
|
||||
activeGeminiProcesses.set(capturedSessionId, geminiProcess);
|
||||
}
|
||||
|
||||
ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);
|
||||
|
||||
ws.send({
|
||||
type: 'session-created',
|
||||
sessionId: capturedSessionId
|
||||
});
|
||||
|
||||
// Emit fake system init so the frontend immediately navigates and saves the session
|
||||
ws.send({
|
||||
type: 'claude-response',
|
||||
sessionId: capturedSessionId,
|
||||
data: {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
session_id: capturedSessionId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (responseHandler) {
|
||||
responseHandler.processData(rawOutput);
|
||||
} else if (rawOutput) {
|
||||
// Fallback to direct sending for raw CLI mode without WS
|
||||
if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {
|
||||
assistantBlocks[assistantBlocks.length - 1].text += rawOutput;
|
||||
} else {
|
||||
assistantBlocks.push({ type: 'text', text: rawOutput });
|
||||
}
|
||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
||||
ws.send({
|
||||
type: 'gemini-response',
|
||||
sessionId: socketSessionId,
|
||||
data: {
|
||||
type: 'message',
|
||||
content: rawOutput
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stderr
|
||||
geminiProcess.stderr.on('data', (data) => {
|
||||
const errorMsg = data.toString();
|
||||
|
||||
// Filter out deprecation warnings and "Loaded cached credentials" message
|
||||
if (errorMsg.includes('[DEP0040]') ||
|
||||
errorMsg.includes('DeprecationWarning') ||
|
||||
errorMsg.includes('--trace-deprecation') ||
|
||||
errorMsg.includes('Loaded cached credentials')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);
|
||||
ws.send({
|
||||
type: 'gemini-error',
|
||||
sessionId: socketSessionId,
|
||||
error: errorMsg
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process completion
|
||||
geminiProcess.on('close', async (code) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Flush any remaining buffered content
|
||||
if (responseHandler) {
|
||||
responseHandler.forceFlush();
|
||||
responseHandler.destroy();
|
||||
}
|
||||
|
||||
// Clean up process reference
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeGeminiProcesses.delete(finalSessionId);
|
||||
|
||||
// Save assistant response to session if we have one
|
||||
if (finalSessionId && assistantBlocks.length > 0) {
|
||||
sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);
|
||||
}
|
||||
|
||||
ws.send({
|
||||
type: 'claude-complete', // Use claude-complete for compatibility with UI
|
||||
sessionId: finalSessionId,
|
||||
exitCode: code,
|
||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||
});
|
||||
|
||||
// Clean up temporary image files if any
|
||||
if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {
|
||||
for (const imagePath of geminiProcess.tempImagePaths) {
|
||||
await fs.unlink(imagePath).catch(err => { });
|
||||
}
|
||||
if (geminiProcess.tempDir) {
|
||||
await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle process errors
|
||||
geminiProcess.on('error', (error) => {
|
||||
// Clean up process reference on error
|
||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||
activeGeminiProcesses.delete(finalSessionId);
|
||||
|
||||
const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;
|
||||
ws.send({
|
||||
type: 'gemini-error',
|
||||
sessionId: errorSessionId,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function abortGeminiSession(sessionId) {
|
||||
let geminiProc = activeGeminiProcesses.get(sessionId);
|
||||
let processKey = sessionId;
|
||||
|
||||
if (!geminiProc) {
|
||||
for (const [key, proc] of activeGeminiProcesses.entries()) {
|
||||
if (proc.sessionId === sessionId) {
|
||||
geminiProc = proc;
|
||||
processKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (geminiProc) {
|
||||
try {
|
||||
geminiProc.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (activeGeminiProcesses.has(processKey)) {
|
||||
try {
|
||||
geminiProc.kill('SIGKILL');
|
||||
} catch (e) { }
|
||||
}
|
||||
}, 2000); // Wait 2 seconds before force kill
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGeminiSessionActive(sessionId) {
|
||||
return activeGeminiProcesses.has(sessionId);
|
||||
}
|
||||
|
||||
function getActiveGeminiSessions() {
|
||||
return Array.from(activeGeminiProcesses.keys());
|
||||
}
|
||||
|
||||
export {
|
||||
spawnGemini,
|
||||
abortGeminiSession,
|
||||
isGeminiSessionActive,
|
||||
getActiveGeminiSessions
|
||||
};
|
||||
140
server/gemini-response-handler.js
Normal file
140
server/gemini-response-handler.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// Gemini Response Handler - JSON Stream processing
|
||||
class GeminiResponseHandler {
|
||||
constructor(ws, options = {}) {
|
||||
this.ws = ws;
|
||||
this.buffer = '';
|
||||
this.onContentFragment = options.onContentFragment || null;
|
||||
this.onInit = options.onInit || null;
|
||||
this.onToolUse = options.onToolUse || null;
|
||||
this.onToolResult = options.onToolResult || null;
|
||||
}
|
||||
|
||||
// Process incoming raw data from Gemini stream-json
|
||||
processData(data) {
|
||||
this.buffer += data;
|
||||
|
||||
// Split by newline
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// Keep the last incomplete line in the buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
this.handleEvent(event);
|
||||
} catch (err) {
|
||||
// Not a JSON line, probably debug output or CLI warnings
|
||||
// console.error('[Gemini Handler] Non-JSON line ignored:', line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
const socketSessionId = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;
|
||||
|
||||
if (event.type === 'init') {
|
||||
if (this.onInit) {
|
||||
this.onInit(event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'message' && event.role === 'assistant') {
|
||||
const content = event.content || '';
|
||||
|
||||
// Notify the parent CLI handler of accumulated text
|
||||
if (this.onContentFragment && content) {
|
||||
this.onContentFragment(content);
|
||||
}
|
||||
|
||||
let payload = {
|
||||
type: 'gemini-response',
|
||||
data: {
|
||||
type: 'message',
|
||||
content: content,
|
||||
isPartial: event.delta === true
|
||||
}
|
||||
};
|
||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
||||
this.ws.send(payload);
|
||||
}
|
||||
else if (event.type === 'tool_use') {
|
||||
if (this.onToolUse) {
|
||||
this.onToolUse(event);
|
||||
}
|
||||
let payload = {
|
||||
type: 'gemini-tool-use',
|
||||
toolName: event.tool_name,
|
||||
toolId: event.tool_id,
|
||||
parameters: event.parameters || {}
|
||||
};
|
||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
||||
this.ws.send(payload);
|
||||
}
|
||||
else if (event.type === 'tool_result') {
|
||||
if (this.onToolResult) {
|
||||
this.onToolResult(event);
|
||||
}
|
||||
let payload = {
|
||||
type: 'gemini-tool-result',
|
||||
toolId: event.tool_id,
|
||||
status: event.status,
|
||||
output: event.output || ''
|
||||
};
|
||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
||||
this.ws.send(payload);
|
||||
}
|
||||
else if (event.type === 'result') {
|
||||
// Send a finalize message string
|
||||
let payload = {
|
||||
type: 'gemini-response',
|
||||
data: {
|
||||
type: 'message',
|
||||
content: '',
|
||||
isPartial: false
|
||||
}
|
||||
};
|
||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
||||
this.ws.send(payload);
|
||||
|
||||
if (event.stats && event.stats.total_tokens) {
|
||||
let statsPayload = {
|
||||
type: 'claude-status',
|
||||
data: {
|
||||
status: 'Complete',
|
||||
tokens: event.stats.total_tokens
|
||||
}
|
||||
};
|
||||
if (socketSessionId) statsPayload.sessionId = socketSessionId;
|
||||
this.ws.send(statsPayload);
|
||||
}
|
||||
}
|
||||
else if (event.type === 'error') {
|
||||
let payload = {
|
||||
type: 'gemini-error',
|
||||
error: event.error || event.message || 'Unknown Gemini streaming error'
|
||||
};
|
||||
if (socketSessionId) payload.sessionId = socketSessionId;
|
||||
this.ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
forceFlush() {
|
||||
// If the buffer has content, try to parse it one last time
|
||||
if (this.buffer.trim()) {
|
||||
try {
|
||||
const event = JSON.parse(this.buffer);
|
||||
this.handleEvent(event);
|
||||
} catch (err) { }
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.buffer = '';
|
||||
}
|
||||
}
|
||||
|
||||
export default GeminiResponseHandler;
|
||||
560
server/index.js
560
server/index.js
@@ -9,6 +9,8 @@ import { dirname } from 'path';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';
|
||||
|
||||
// ANSI color codes for terminal output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
@@ -46,6 +48,8 @@ import { getProjects, getSessions, getSessionMessages, renameProject, deleteSess
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import gitRoutes from './routes/git.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import mcpRoutes from './routes/mcp.js';
|
||||
@@ -59,7 +63,9 @@ import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
import { IS_PLATFORM } from './constants/config.js';
|
||||
|
||||
@@ -67,7 +73,9 @@ import { IS_PLATFORM } from './constants/config.js';
|
||||
const PROVIDER_WATCH_PATHS = [
|
||||
{ provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
|
||||
{ provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
|
||||
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
|
||||
{ provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },
|
||||
{ provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },
|
||||
{ provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }
|
||||
];
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
'**/node_modules/**',
|
||||
@@ -317,24 +325,25 @@ app.locals.wss = wss;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json({
|
||||
limit: '50mb',
|
||||
type: (req) => {
|
||||
// Skip multipart/form-data requests (for file uploads like images)
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
return false;
|
||||
limit: '50mb',
|
||||
type: (req) => {
|
||||
// Skip multipart/form-data requests (for file uploads like images)
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
return false;
|
||||
}
|
||||
return contentType.includes('json');
|
||||
}
|
||||
return contentType.includes('json');
|
||||
}
|
||||
}));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// Public health check endpoint (no authentication required)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
installMode
|
||||
});
|
||||
});
|
||||
|
||||
// Optional API key validation (if configured)
|
||||
@@ -376,6 +385,9 @@ app.use('/api/user', authenticateToken, userRoutes);
|
||||
// Codex API Routes (protected)
|
||||
app.use('/api/codex', authenticateToken, codexRoutes);
|
||||
|
||||
// Gemini API Routes (protected)
|
||||
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
@@ -385,17 +397,17 @@ app.use(express.static(path.join(__dirname, '../public')));
|
||||
// Static files served after API routes
|
||||
// Add cache control: HTML files should not be cached, but assets can be cached
|
||||
app.use(express.static(path.join(__dirname, '../dist'), {
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
// Prevent HTML caching to avoid service worker issues after builds
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
||||
// Cache static assets for 1 year (they have hashed names)
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
setHeaders: (res, filePath) => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
// Prevent HTML caching to avoid service worker issues after builds
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
} else if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
|
||||
// Cache static assets for 1 year (they have hashed names)
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// API Routes (protected)
|
||||
@@ -410,11 +422,13 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
||||
|
||||
console.log('Starting system update from directory:', projectRoot);
|
||||
|
||||
// Run the update command
|
||||
const updateCommand = 'git checkout main && git pull && npm install';
|
||||
// Run the update command based on install mode
|
||||
const updateCommand = installMode === 'git'
|
||||
? 'git checkout main && git pull && npm install'
|
||||
: 'npm install -g @siteboon/claude-code-ui@latest';
|
||||
|
||||
const child = spawn('sh', ['-c', updateCommand], {
|
||||
cwd: projectRoot,
|
||||
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
@@ -491,13 +505,13 @@ app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateT
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
|
||||
// Parse limit and offset if provided
|
||||
const parsedLimit = limit ? parseInt(limit, 10) : null;
|
||||
const parsedOffset = offset ? parseInt(offset, 10) : 0;
|
||||
|
||||
|
||||
const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset);
|
||||
|
||||
|
||||
// Handle both old and new response formats
|
||||
if (Array.isArray(result)) {
|
||||
// Backward compatibility: no pagination parameters were provided
|
||||
@@ -580,13 +594,13 @@ const expandWorkspacePath = (inputPath) => {
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.query;
|
||||
|
||||
|
||||
console.log('[API] Browse filesystem request for path:', dirPath);
|
||||
console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);
|
||||
// Default to home directory if no path provided
|
||||
const defaultRoot = WORKSPACES_ROOT;
|
||||
let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;
|
||||
|
||||
|
||||
// Resolve and normalize the path
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
@@ -596,22 +610,22 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
return res.status(403).json({ error: validation.error });
|
||||
}
|
||||
const resolvedPath = validation.resolvedPath || targetPath;
|
||||
|
||||
|
||||
// Security check - ensure path is accessible
|
||||
try {
|
||||
await fs.promises.access(resolvedPath);
|
||||
const stats = await fs.promises.stat(resolvedPath);
|
||||
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(404).json({ error: 'Directory not accessible' });
|
||||
}
|
||||
|
||||
|
||||
// Use existing getFileTree function with shallow depth (only direct children)
|
||||
const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
|
||||
|
||||
// Filter only directories and format for suggestions
|
||||
const directories = fileTree
|
||||
.filter(item => item.type === 'directory')
|
||||
@@ -627,7 +641,7 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
if (!aHidden && bHidden) return -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
|
||||
// Add common directories if browsing home directory
|
||||
const suggestions = [];
|
||||
let resolvedWorkspaceRoot = defaultRoot;
|
||||
@@ -640,17 +654,17 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||
|
||||
|
||||
suggestions.push(...existingCommon, ...otherDirs);
|
||||
} else {
|
||||
suggestions.push(...directories);
|
||||
}
|
||||
|
||||
|
||||
res.json({
|
||||
path: resolvedPath,
|
||||
suggestions: suggestions
|
||||
});
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error browsing filesystem:', error);
|
||||
res.status(500).json({ error: 'Failed to browse filesystem' });
|
||||
@@ -883,7 +897,7 @@ wss.on('connection', (ws, request) => {
|
||||
if (pathname === '/shell') {
|
||||
handleShellConnection(ws);
|
||||
} else if (pathname === '/ws') {
|
||||
handleChatConnection(ws);
|
||||
handleChatConnection(ws, request);
|
||||
} else {
|
||||
console.log('[WARN] Unknown WebSocket path:', pathname);
|
||||
ws.close();
|
||||
@@ -894,37 +908,38 @@ wss.on('connection', (ws, request) => {
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*/
|
||||
class WebSocketWriter {
|
||||
constructor(ws) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.isWebSocketWriter = true; // Marker for transport detection
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||
// Providers send raw objects, we stringify for WebSocket
|
||||
this.ws.send(JSON.stringify(data));
|
||||
constructor(ws, userId = null) {
|
||||
this.ws = ws;
|
||||
this.sessionId = null;
|
||||
this.userId = userId;
|
||||
this.isWebSocketWriter = true; // Marker for transport detection
|
||||
}
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
send(data) {
|
||||
if (this.ws.readyState === 1) { // WebSocket.OPEN
|
||||
// Providers send raw objects, we stringify for WebSocket
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
setSessionId(sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
getSessionId() {
|
||||
return this.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle chat WebSocket connections
|
||||
function handleChatConnection(ws) {
|
||||
function handleChatConnection(ws, request) {
|
||||
console.log('[INFO] Chat WebSocket connected');
|
||||
|
||||
// Add to connected clients for project updates
|
||||
connectedClients.add(ws);
|
||||
|
||||
// Wrap WebSocket with writer for consistent interface with SSEStreamWriter
|
||||
const writer = new WebSocketWriter(ws);
|
||||
const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
@@ -949,6 +964,12 @@ function handleChatConnection(ws) {
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
console.log('🤖 Model:', data.options?.model || 'default');
|
||||
await queryCodex(data.command, data.options, writer);
|
||||
} else if (data.type === 'gemini-command') {
|
||||
console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');
|
||||
console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
|
||||
console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');
|
||||
console.log('🤖 Model:', data.options?.model || 'default');
|
||||
await spawnGemini(data.command, data.options, writer);
|
||||
} else if (data.type === 'cursor-resume') {
|
||||
// Backward compatibility: treat as cursor-command with resume and no prompt
|
||||
console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);
|
||||
@@ -966,6 +987,8 @@ function handleChatConnection(ws) {
|
||||
success = abortCursorSession(data.sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
success = abortCodexSession(data.sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
success = abortGeminiSession(data.sessionId);
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
success = await abortClaudeSDKSession(data.sessionId);
|
||||
@@ -1008,6 +1031,8 @@ function handleChatConnection(ws) {
|
||||
isActive = isCursorSessionActive(sessionId);
|
||||
} else if (provider === 'codex') {
|
||||
isActive = isCodexSessionActive(sessionId);
|
||||
} else if (provider === 'gemini') {
|
||||
isActive = isGeminiSessionActive(sessionId);
|
||||
} else {
|
||||
// Use Claude Agents SDK
|
||||
isActive = isClaudeSDKSessionActive(sessionId);
|
||||
@@ -1024,7 +1049,8 @@ function handleChatConnection(ws) {
|
||||
const activeSessions = {
|
||||
claude: getActiveClaudeSDKSessions(),
|
||||
cursor: getActiveCursorSessions(),
|
||||
codex: getActiveCodexSessions()
|
||||
codex: getActiveCodexSessions(),
|
||||
gemini: getActiveGeminiSessions()
|
||||
};
|
||||
writer.send({
|
||||
type: 'active-sessions',
|
||||
@@ -1133,7 +1159,7 @@ function handleShellConnection(ws) {
|
||||
if (isPlainShell) {
|
||||
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
||||
} else {
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));
|
||||
welcomeMsg = hasSession ?
|
||||
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
@@ -1169,6 +1195,55 @@ function handleShellConnection(ws) {
|
||||
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (provider === 'codex') {
|
||||
// Use codex command
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
||||
}
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to a new session if it fails
|
||||
shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && codex`;
|
||||
}
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Use gemini command
|
||||
const command = initialCommand || 'gemini';
|
||||
let resumeId = sessionId;
|
||||
if (hasSession && sessionId) {
|
||||
try {
|
||||
// Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.
|
||||
// The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).
|
||||
// We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.
|
||||
const sess = sessionManager.getSession(sessionId);
|
||||
if (sess && sess.cliSessionId) {
|
||||
resumeId = sess.cliSessionId;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get Gemini CLI session ID:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && resumeId) {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
||||
}
|
||||
} else {
|
||||
if (hasSession && resumeId) {
|
||||
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use claude command (default) or initialCommand if provided
|
||||
const command = initialCommand || 'claude';
|
||||
@@ -1602,203 +1677,214 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
||||
|
||||
// Get token usage for a specific session
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Codex sessions
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
// Handle Gemini sessions - they are raw logs in our current setup
|
||||
if (provider === 'gemini') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Gemini sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
// Handle Codex sessions
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
// Read and parse the Codex JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
let totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
// Find the latest token_count event with info (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Codex stores token info in event_msg with type: "token_count"
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
}
|
||||
break; // Stop after finding the latest token count
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Claude sessions (default)
|
||||
// Extract actual project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
console.error('Error extracting project directory:', error);
|
||||
return res.status(500).json({ error: 'Failed to determine project path' });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
// Construct the JSONL file path
|
||||
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
||||
// The encoding replaces any non-alphanumeric character (except -) with -
|
||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
||||
}
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
|
||||
// Read and parse the Codex JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
||||
// Constrain to projectDir
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
let totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
// Find the latest token_count event with info (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
// Read and parse the JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Codex stores token info in event_msg with type: "token_count"
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
throw error; // Re-throw other errors to be caught by outer try-catch
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
|
||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Only count assistant messages which have usage data
|
||||
if (entry.type === 'assistant' && entry.message?.usage) {
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
break; // Stop after finding the latest token count
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow
|
||||
});
|
||||
}
|
||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
|
||||
// Handle Claude sessions (default)
|
||||
// Extract actual project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
cacheCreation: cacheCreationTokens,
|
||||
cacheRead: cacheReadTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error extracting project directory:', error);
|
||||
return res.status(500).json({ error: 'Failed to determine project path' });
|
||||
console.error('Error reading session token usage:', error);
|
||||
res.status(500).json({ error: 'Failed to read session token usage' });
|
||||
}
|
||||
|
||||
// Construct the JSONL file path
|
||||
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
||||
// The encoding replaces /, spaces, ~, and _ with -
|
||||
const encodedPath = projectPath.replace(/[\\/:\s~_]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
|
||||
// Constrain to projectDir
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// Read and parse the JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
||||
}
|
||||
throw error; // Re-throw other errors to be caught by outer try-catch
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
|
||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Only count assistant messages which have usage data
|
||||
if (entry.type === 'assistant' && entry.message?.usage) {
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
cacheCreation: cacheCreationTokens,
|
||||
cacheRead: cacheReadTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading session token usage:', error);
|
||||
res.status(500).json({ error: 'Failed to read session token usage' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React app for all other routes (excluding static files)
|
||||
app.get('*', (req, res) => {
|
||||
// Skip requests for static assets (files with extensions)
|
||||
if (path.extname(req.path)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
// Skip requests for static assets (files with extensions)
|
||||
if (path.extname(req.path)) {
|
||||
return res.status(404).send('Not found');
|
||||
}
|
||||
|
||||
// Only serve index.html for HTML routes, not for static assets
|
||||
// Static assets should already be handled by express.static middleware above
|
||||
const indexPath = path.join(__dirname, '../dist/index.html');
|
||||
// Only serve index.html for HTML routes, not for static assets
|
||||
// Static assets should already be handled by express.static middleware above
|
||||
const indexPath = path.join(__dirname, '../dist/index.html');
|
||||
|
||||
// Check if dist/index.html exists (production build available)
|
||||
if (fs.existsSync(indexPath)) {
|
||||
// Set no-cache headers for HTML to prevent service worker issues
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
// In development, redirect to Vite dev server only if dist doesn't exist
|
||||
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
||||
}
|
||||
// Check if dist/index.html exists (production build available)
|
||||
if (fs.existsSync(indexPath)) {
|
||||
// Set no-cache headers for HTML to prevent service worker issues
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
res.sendFile(indexPath);
|
||||
} else {
|
||||
// In development, redirect to Vite dev server only if dist doesn't exist
|
||||
res.redirect(`http://localhost:${process.env.VITE_PORT || 5173}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to convert permissions to rwx format
|
||||
@@ -1886,6 +1972,9 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
// Show localhost in URL when binding to all interfaces (0.0.0.0 isn't a connectable address)
|
||||
const DISPLAY_HOST = HOST === '0.0.0.0' ? 'localhost' : HOST;
|
||||
|
||||
// Initialize database and start server
|
||||
async function startServer() {
|
||||
@@ -1893,6 +1982,9 @@ async function startServer() {
|
||||
// Initialize authentication database
|
||||
await initializeDatabase();
|
||||
|
||||
// Configure Web Push (VAPID keys)
|
||||
configureWebPush();
|
||||
|
||||
// Check if running in production mode (dist folder exists)
|
||||
const distIndexPath = path.join(__dirname, '../dist/index.html');
|
||||
const isProduction = fs.existsSync(distIndexPath);
|
||||
@@ -1905,7 +1997,7 @@ async function startServer() {
|
||||
console.log(`${c.warn('[WARN]')} Note: Requests will be proxied to Vite dev server at ${c.dim('http://localhost:' + (process.env.VITE_PORT || 5173))}`);
|
||||
}
|
||||
|
||||
server.listen(PORT, '0.0.0.0', async () => {
|
||||
server.listen(PORT, HOST, async () => {
|
||||
const appInstallPath = path.join(__dirname, '..');
|
||||
|
||||
console.log('');
|
||||
@@ -1913,7 +2005,7 @@ async function startServer() {
|
||||
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
|
||||
console.log(c.dim('═'.repeat(63)));
|
||||
console.log('');
|
||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://0.0.0.0:' + PORT)}`);
|
||||
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + DISPLAY_HOST + ':' + PORT)}`);
|
||||
console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);
|
||||
console.log(`${c.tip('[TIP]')} Run "cloudcli status" for full configuration details`);
|
||||
console.log('');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Load environment variables from .env before other imports execute.
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
@@ -22,3 +23,7 @@ try {
|
||||
} catch (e) {
|
||||
console.log('No .env file found or error reading it:', e.message);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_PATH) {
|
||||
process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ const authenticateWebSocket = (token) => {
|
||||
try {
|
||||
const user = userDb.getFirstUser();
|
||||
if (user) {
|
||||
return { userId: user.id, username: user.username };
|
||||
return { id: user.id, userId: user.id, username: user.username };
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -101,7 +101,10 @@ const authenticateWebSocket = (token) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
return decoded;
|
||||
return {
|
||||
...decoded,
|
||||
id: decoded.userId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('WebSocket token verification error:', error);
|
||||
return null;
|
||||
@@ -114,4 +117,4 @@ export {
|
||||
generateToken,
|
||||
authenticateWebSocket,
|
||||
JWT_SECRET
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { addProjectManually } from '../projects.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { spawnGemini } from '../gemini-cli.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||
import { IS_PLATFORM } from '../constants/config.js';
|
||||
@@ -629,7 +630,7 @@ class ResponseCollector {
|
||||
* - Source for auto-generated branch names (if createBranch=true and no branchName)
|
||||
* - Fallback for PR title if no commits are made
|
||||
*
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor'
|
||||
* @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'
|
||||
* Default: 'claude'
|
||||
*
|
||||
* @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.
|
||||
@@ -747,7 +748,7 @@ class ResponseCollector {
|
||||
* Input Validations (400 Bad Request):
|
||||
* - Either githubUrl OR projectPath must be provided (not neither)
|
||||
* - message must be non-empty string
|
||||
* - provider must be 'claude' or 'cursor'
|
||||
* - provider must be 'claude', 'cursor', 'codex', or 'gemini'
|
||||
* - createBranch/createPR requires githubUrl OR projectPath (not neither)
|
||||
* - branchName must pass Git naming rules (if provided)
|
||||
*
|
||||
@@ -855,8 +856,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor', 'codex'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
|
||||
if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", "codex", or "gemini"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -971,6 +972,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
model: model || CODEX_MODELS.DEFAULT,
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
} else if (provider === 'gemini') {
|
||||
console.log('✨ Starting Gemini CLI session');
|
||||
|
||||
await spawnGemini(message.trim(), {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: null,
|
||||
model: model,
|
||||
skipPermissions: true // CLI mode bypasses permissions
|
||||
}, writer);
|
||||
}
|
||||
|
||||
// Handle GitHub branch and PR creation after successful agent completion
|
||||
|
||||
@@ -53,11 +53,11 @@ router.post('/register', async (req, res) => {
|
||||
// Generate token
|
||||
const token = generateToken(user);
|
||||
|
||||
// Update last login
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
// Update last login (non-fatal, outside transaction)
|
||||
userDb.updateLastLogin(user.id);
|
||||
|
||||
db.prepare('COMMIT').run();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: { id: user.id, username: user.username },
|
||||
|
||||
@@ -74,6 +74,46 @@ router.get('/codex/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gemini/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkGeminiCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Gemini auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials using two methods with priority order:
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*
|
||||
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
|
||||
* This matching behavior ensures consistency with how the SDK authenticates.
|
||||
*
|
||||
* References:
|
||||
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
|
||||
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
|
||||
* - https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
* SDK authentication documentation
|
||||
*
|
||||
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
|
||||
* - authenticated: boolean indicating if valid credentials exist
|
||||
* - email: user email or auth method identifier
|
||||
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
|
||||
*/
|
||||
async function checkClaudeCredentials() {
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
@@ -260,4 +300,78 @@ async function checkCodexCredentials() {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGeminiCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
// Validate token against Google API
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
// Token invalid and no refresh token available
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error, fallback to checking local accounts file
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: email
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found in oauth_creds'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI not configured'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
46
server/routes/gemini.js
Normal file
46
server/routes/gemini.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import express from 'express';
|
||||
import sessionManager from '../sessionManager.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
||||
}
|
||||
|
||||
const messages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
messages: messages,
|
||||
total: messages.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: messages.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching Gemini session messages:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
|
||||
if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid session ID format' });
|
||||
}
|
||||
|
||||
await sessionManager.deleteSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import { apiKeysDb, credentialsDb } from '../database/db.js';
|
||||
import { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
import { getPublicKey } from '../services/vapid-keys.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -175,4 +176,70 @@ router.patch('/credentials/:credentialId/toggle', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Notification Preferences
|
||||
// ===============================
|
||||
|
||||
router.get('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.getPreferences(req.user.id);
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/notification-preferences', async (req, res) => {
|
||||
try {
|
||||
const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});
|
||||
res.json({ success: true, preferences });
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
res.status(500).json({ error: 'Failed to save notification preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// ===============================
|
||||
// Push Subscription Management
|
||||
// ===============================
|
||||
|
||||
router.get('/push/vapid-public-key', async (req, res) => {
|
||||
try {
|
||||
const publicKey = getPublicKey();
|
||||
res.json({ publicKey });
|
||||
} catch (error) {
|
||||
console.error('Error fetching VAPID public key:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch VAPID public key' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/subscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return res.status(400).json({ error: 'Missing subscription fields' });
|
||||
}
|
||||
pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error saving push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to save push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/push/unsubscribe', async (req, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body;
|
||||
if (!endpoint) {
|
||||
return res.status(400).json({ error: 'Missing endpoint' });
|
||||
}
|
||||
pushSubscriptionsDb.removeSubscription(endpoint);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing push subscription:', error);
|
||||
res.status(500).json({ error: 'Failed to remove push subscription' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
149
server/services/notification-orchestrator.js
Normal file
149
server/services/notification-orchestrator.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import webPush from 'web-push';
|
||||
import { notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';
|
||||
|
||||
const KIND_TO_PREF_KEY = {
|
||||
action_required: 'actionRequired',
|
||||
stop: 'stop',
|
||||
error: 'error'
|
||||
};
|
||||
|
||||
const recentEventKeys = new Map();
|
||||
const DEDUPE_WINDOW_MS = 20000;
|
||||
|
||||
const cleanupOldEventKeys = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentEventKeys.entries()) {
|
||||
if (now - timestamp > DEDUPE_WINDOW_MS) {
|
||||
recentEventKeys.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function shouldSendPush(preferences, event) {
|
||||
const webPushEnabled = Boolean(preferences?.channels?.webPush);
|
||||
const prefEventKey = KIND_TO_PREF_KEY[event.kind];
|
||||
const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;
|
||||
|
||||
return webPushEnabled && eventEnabled;
|
||||
}
|
||||
|
||||
function isDuplicate(event) {
|
||||
cleanupOldEventKeys();
|
||||
const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;
|
||||
if (recentEventKeys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
recentEventKeys.set(key, Date.now());
|
||||
return false;
|
||||
}
|
||||
|
||||
function createNotificationEvent({
|
||||
provider,
|
||||
sessionId = null,
|
||||
kind = 'info',
|
||||
code = 'generic.info',
|
||||
meta = {},
|
||||
severity = 'info',
|
||||
dedupeKey = null,
|
||||
requiresUserAction = false
|
||||
}) {
|
||||
return {
|
||||
provider,
|
||||
sessionId,
|
||||
kind,
|
||||
code,
|
||||
meta,
|
||||
severity,
|
||||
requiresUserAction,
|
||||
dedupeKey,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function buildPushBody(event) {
|
||||
const CODE_MAP = {
|
||||
'permission.required': {
|
||||
title: 'Action Required',
|
||||
body: event.meta?.toolName
|
||||
? `Tool "${event.meta.toolName}" needs approval`
|
||||
: 'A tool needs your approval'
|
||||
},
|
||||
'run.stopped': {
|
||||
title: 'Run Stopped',
|
||||
body: event.meta?.stopReason || 'The run has stopped'
|
||||
},
|
||||
'run.failed': {
|
||||
title: 'Run Failed',
|
||||
body: event.meta?.error ? String(event.meta.error) : 'The run encountered an error'
|
||||
},
|
||||
'agent.notification': {
|
||||
title: 'Agent Notification',
|
||||
body: event.meta?.message ? String(event.meta.message) : 'You have a new notification'
|
||||
}
|
||||
};
|
||||
|
||||
const mapped = CODE_MAP[event.code];
|
||||
return {
|
||||
title: mapped?.title || 'Claude Code UI',
|
||||
body: mapped?.body || 'You have a new notification',
|
||||
data: {
|
||||
sessionId: event.sessionId || null,
|
||||
code: event.code
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function sendWebPush(userId, event) {
|
||||
const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);
|
||||
if (!subscriptions.length) return;
|
||||
|
||||
const payload = JSON.stringify(buildPushBody(event));
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
webPush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: sub.keys_p256dh,
|
||||
auth: sub.keys_auth
|
||||
}
|
||||
},
|
||||
payload
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Clean up gone subscriptions (410 Gone or 404)
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'rejected') {
|
||||
const statusCode = result.reason?.statusCode;
|
||||
if (statusCode === 410 || statusCode === 404) {
|
||||
pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function notifyUserIfEnabled({ userId, event }) {
|
||||
if (!userId || !event) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferences = notificationPreferencesDb.getPreferences(userId);
|
||||
if (!shouldSendPush(preferences, event)) {
|
||||
return;
|
||||
}
|
||||
if (isDuplicate(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendWebPush(userId, event).catch((err) => {
|
||||
console.error('Web push send error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
createNotificationEvent,
|
||||
notifyUserIfEnabled
|
||||
};
|
||||
35
server/services/vapid-keys.js
Normal file
35
server/services/vapid-keys.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import webPush from 'web-push';
|
||||
import { db } from '../database/db.js';
|
||||
|
||||
let cachedKeys = null;
|
||||
|
||||
function ensureVapidKeys() {
|
||||
if (cachedKeys) return cachedKeys;
|
||||
|
||||
const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();
|
||||
if (row) {
|
||||
cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
const keys = webPush.generateVAPIDKeys();
|
||||
db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);
|
||||
cachedKeys = keys;
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
function getPublicKey() {
|
||||
return ensureVapidKeys().publicKey;
|
||||
}
|
||||
|
||||
function configureWebPush() {
|
||||
const keys = ensureVapidKeys();
|
||||
webPush.setVapidDetails(
|
||||
'mailto:noreply@claudecodeui.local',
|
||||
keys.publicKey,
|
||||
keys.privateKey
|
||||
);
|
||||
console.log('Web Push notifications configured');
|
||||
}
|
||||
|
||||
export { ensureVapidKeys, getPublicKey, configureWebPush };
|
||||
226
server/sessionManager.js
Normal file
226
server/sessionManager.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
class SessionManager {
|
||||
constructor() {
|
||||
// Store sessions in memory with conversation history
|
||||
this.sessions = new Map();
|
||||
this.maxSessions = 100;
|
||||
this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
|
||||
this.ready = this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.initSessionsDir();
|
||||
await this.loadSessions();
|
||||
}
|
||||
|
||||
async initSessionsDir() {
|
||||
try {
|
||||
await fs.mkdir(this.sessionsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
// console.error('Error creating sessions directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new session
|
||||
createSession(sessionId, projectPath) {
|
||||
const session = {
|
||||
id: sessionId,
|
||||
projectPath: projectPath,
|
||||
messages: [],
|
||||
createdAt: new Date(),
|
||||
lastActivity: new Date()
|
||||
};
|
||||
|
||||
// Evict oldest session from memory if we exceed limit
|
||||
if (this.sessions.size >= this.maxSessions) {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey) this.sessions.delete(oldestKey);
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
this.saveSession(sessionId);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Add a message to session
|
||||
addMessage(sessionId, role, content) {
|
||||
let session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
// Create session if it doesn't exist
|
||||
session = this.createSession(sessionId, '');
|
||||
}
|
||||
|
||||
const message = {
|
||||
role: role, // 'user' or 'assistant'
|
||||
content: content,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
session.messages.push(message);
|
||||
session.lastActivity = new Date();
|
||||
|
||||
this.saveSession(sessionId);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Get session by ID
|
||||
getSession(sessionId) {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
// Get all sessions for a project
|
||||
getProjectSessions(projectPath) {
|
||||
const sessions = [];
|
||||
|
||||
for (const [id, session] of this.sessions) {
|
||||
if (session.projectPath === projectPath) {
|
||||
sessions.push({
|
||||
id: session.id,
|
||||
summary: this.getSessionSummary(session),
|
||||
messageCount: session.messages.length,
|
||||
lastActivity: session.lastActivity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sessions.sort((a, b) =>
|
||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
||||
);
|
||||
}
|
||||
|
||||
// Get session summary
|
||||
getSessionSummary(session) {
|
||||
if (session.messages.length === 0) {
|
||||
return 'New Session';
|
||||
}
|
||||
|
||||
// Find first user message
|
||||
const firstUserMessage = session.messages.find(m => m.role === 'user');
|
||||
if (firstUserMessage) {
|
||||
const content = firstUserMessage.content;
|
||||
return content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||
}
|
||||
|
||||
return 'New Session';
|
||||
}
|
||||
|
||||
// Build conversation context for Gemini
|
||||
buildConversationContext(sessionId, maxMessages = 10) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session || session.messages.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get last N messages for context
|
||||
const recentMessages = session.messages.slice(-maxMessages);
|
||||
|
||||
let context = 'Here is the conversation history:\n\n';
|
||||
|
||||
for (const msg of recentMessages) {
|
||||
if (msg.role === 'user') {
|
||||
context += `User: ${msg.content}\n`;
|
||||
} else {
|
||||
context += `Assistant: ${msg.content}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
context += '\nBased on the conversation history above, please answer the following:\n';
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
_safeFilePath(sessionId) {
|
||||
const safeId = String(sessionId).replace(/[/\\]|\.\./g, '');
|
||||
return path.join(this.sessionsDir, `${safeId}.json`);
|
||||
}
|
||||
|
||||
// Save session to disk
|
||||
async saveSession(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
const filePath = this._safeFilePath(sessionId);
|
||||
await fs.writeFile(filePath, JSON.stringify(session, null, 2));
|
||||
} catch (error) {
|
||||
// console.error('Error saving session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load sessions from disk
|
||||
async loadSessions() {
|
||||
try {
|
||||
const files = await fs.readdir(this.sessionsDir);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.json')) {
|
||||
try {
|
||||
const filePath = path.join(this.sessionsDir, file);
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
const session = JSON.parse(data);
|
||||
|
||||
// Convert dates
|
||||
session.createdAt = new Date(session.createdAt);
|
||||
session.lastActivity = new Date(session.lastActivity);
|
||||
session.messages.forEach(msg => {
|
||||
msg.timestamp = new Date(msg.timestamp);
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
} catch (error) {
|
||||
// console.error(`Error loading session ${file}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce eviction after loading to prevent massive memory usage
|
||||
while (this.sessions.size > this.maxSessions) {
|
||||
const oldestKey = this.sessions.keys().next().value;
|
||||
if (oldestKey) this.sessions.delete(oldestKey);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('Error loading sessions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a session
|
||||
async deleteSession(sessionId) {
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
try {
|
||||
const filePath = this._safeFilePath(sessionId);
|
||||
await fs.unlink(filePath);
|
||||
} catch (error) {
|
||||
// console.error('Error deleting session file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get session messages for display
|
||||
getSessionMessages(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return [];
|
||||
|
||||
return session.messages.map(msg => ({
|
||||
type: 'message',
|
||||
message: {
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
},
|
||||
timestamp: msg.timestamp.toISOString()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const sessionManager = new SessionManager();
|
||||
|
||||
export const ready = sessionManager.ready;
|
||||
export default sessionManager;
|
||||
@@ -63,5 +63,24 @@ export const CODEX_MODELS = {
|
||||
{ value: 'o4-mini', label: 'O4-mini' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gpt-5.2'
|
||||
DEFAULT: 'gpt-5.3-codex'
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini Models
|
||||
*/
|
||||
export const GEMINI_MODELS = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
||||
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
||||
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
|
||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
||||
{ value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
|
||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
|
||||
],
|
||||
|
||||
DEFAULT: 'gemini-2.5-flash'
|
||||
};
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function ApiKeysSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubTokens, setGithubTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
|
||||
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newTokenName, setNewTokenName] = useState('');
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
const [showToken, setShowToken] = useState({});
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub tokens
|
||||
const githubRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const githubData = await githubRes.json();
|
||||
setGithubTokens(githubData.credentials || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewlyCreatedKey(data.apiKey);
|
||||
setNewKeyName('');
|
||||
setShowNewKeyForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm(t('apiKeys.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGithubToken = async () => {
|
||||
if (!newTokenName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialName: newTokenName,
|
||||
credentialType: 'github_token',
|
||||
credentialValue: newGithubToken
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewTokenName('');
|
||||
setNewGithubToken('');
|
||||
setShowNewTokenForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGithubToken = async (tokenId) => {
|
||||
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGithubToken = async (tokenId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling GitHub token:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, id) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(id);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t('apiKeys.newKey.alertMessage')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
{newlyCreatedKey.apiKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
|
||||
>
|
||||
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
{t('apiKeys.newKey.iveSavedIt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('apiKeys.newButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('apiKeys.description')}
|
||||
</p>
|
||||
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder={t('apiKeys.form.placeholder')}
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
{t('apiKeys.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Tokens Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('apiKeys.github.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('apiKeys.github.description')}
|
||||
</p>
|
||||
|
||||
{showNewTokenForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder={t('apiKeys.github.form.namePlaceholder')}
|
||||
value={newTokenName}
|
||||
onChange={(e) => setNewTokenName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="mb-2 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubToken}>{t('apiKeys.github.form.addButton')}</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewTokenForm(false);
|
||||
setNewTokenName('');
|
||||
setNewGithubToken('');
|
||||
}}>
|
||||
{t('apiKeys.github.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubTokens.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
|
||||
) : (
|
||||
githubTokens.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{token.credential_name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('apiKeys.github.added')} {new Date(token.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={token.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubToken(token.id, token.is_active)}
|
||||
>
|
||||
{token.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteGithubToken(token.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Link */}
|
||||
<div className="p-4 bg-muted/50 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">{t('apiKeys.documentation.title')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t('apiKeys.documentation.description')}
|
||||
</p>
|
||||
<a
|
||||
href="/EXTERNAL_API.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('apiKeys.documentation.viewLink')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ApiKeysSettings;
|
||||
@@ -1,875 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { EditorView, showPanel, ViewPlugin } from '@codemirror/view';
|
||||
import { unifiedMergeView, getChunks } from '@codemirror/merge';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Settings as SettingsIcon } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Eye, Code2 } from 'lucide-react';
|
||||
|
||||
// Custom .env file syntax highlighting
|
||||
const envLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.match(/^#.*/)) return 'comment';
|
||||
// Key (before =)
|
||||
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
|
||||
// Equals sign
|
||||
if (stream.match(/^=/)) return 'operator';
|
||||
// Double-quoted string
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
|
||||
// Single-quoted string
|
||||
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
|
||||
// Variable interpolation ${...}
|
||||
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
|
||||
// Variable reference $VAR
|
||||
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
|
||||
// Numbers
|
||||
if (stream.match(/^\d+/)) return 'number';
|
||||
// Skip other characters
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
function MarkdownCodeBlock({ inline, className, children, ...props }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(raw);
|
||||
const shouldInline = inline || !looksMultiline;
|
||||
|
||||
if (shouldInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : 'text';
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language && language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard?.writeText(raw).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
{raw}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const markdownPreviewComponents = {
|
||||
code: MarkdownCodeBlock,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
function MarkdownPreview({ content }) {
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeRaw, rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeEditor({ file, onClose, projectPath, isSidebar = false, isExpanded = false, onToggleExpand = null, onPopOut = null }) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
const savedTheme = localStorage.getItem('codeEditorTheme');
|
||||
return savedTheme ? savedTheme === 'dark' : true;
|
||||
});
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(!!file.diffInfo);
|
||||
const [wordWrap, setWordWrap] = useState(() => {
|
||||
return localStorage.getItem('codeEditorWordWrap') === 'true';
|
||||
});
|
||||
const [minimapEnabled, setMinimapEnabled] = useState(() => {
|
||||
return localStorage.getItem('codeEditorShowMinimap') !== 'false';
|
||||
});
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(() => {
|
||||
return localStorage.getItem('codeEditorLineNumbers') !== 'false';
|
||||
});
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
return localStorage.getItem('codeEditorFontSize') || '12';
|
||||
});
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Check if file is markdown
|
||||
const isMarkdownFile = useMemo(() => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
return ext === 'md' || ext === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
// Create minimap extension with chunk-based gutters
|
||||
const minimapExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff || !minimapEnabled) return [];
|
||||
|
||||
const gutters = {};
|
||||
|
||||
return [
|
||||
showMinimap.compute(['doc'], (state) => {
|
||||
// Get actual chunks from merge view
|
||||
const chunksData = getChunks(state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
// Clear previous gutters
|
||||
Object.keys(gutters).forEach(key => delete gutters[key]);
|
||||
|
||||
// Mark lines that are part of chunks
|
||||
chunks.forEach(chunk => {
|
||||
// Mark the lines in the B side (current document)
|
||||
const fromLine = state.doc.lineAt(chunk.fromB).number;
|
||||
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
|
||||
|
||||
for (let lineNum = fromLine; lineNum <= toLine; lineNum++) {
|
||||
gutters[lineNum] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
create: () => ({ dom: document.createElement('div') }),
|
||||
displayText: 'blocks',
|
||||
showOverlay: 'always',
|
||||
gutters: [gutters]
|
||||
};
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff, minimapEnabled, isDarkMode]);
|
||||
|
||||
// Create extension to scroll to first chunk on mount
|
||||
const scrollToFirstChunkExtension = useMemo(() => {
|
||||
if (!file.diffInfo || !showDiff) return [];
|
||||
|
||||
return [
|
||||
ViewPlugin.fromClass(class {
|
||||
constructor(view) {
|
||||
// Delay to ensure merge view is fully initialized
|
||||
setTimeout(() => {
|
||||
const chunksData = getChunks(view.state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const firstChunk = chunks[0];
|
||||
|
||||
// Scroll to the first chunk
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
update() {}
|
||||
destroy() {}
|
||||
})
|
||||
];
|
||||
}, [file.diffInfo, showDiff]);
|
||||
|
||||
// Whether toolbar has any buttons worth showing
|
||||
const hasToolbarButtons = !!(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
|
||||
|
||||
// Create editor toolbar panel - only when there are buttons to show
|
||||
const editorToolbarPanel = useMemo(() => {
|
||||
if (!hasToolbarButtons) return [];
|
||||
|
||||
const createPanel = (view) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-editor-toolbar-panel';
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updatePanel = () => {
|
||||
// Check if we have diff info and it's enabled
|
||||
const hasDiff = file.diffInfo && showDiff;
|
||||
const chunksData = hasDiff ? getChunks(view.state) : null;
|
||||
const chunks = chunksData?.chunks || [];
|
||||
const chunkCount = chunks.length;
|
||||
|
||||
// Build the toolbar HTML
|
||||
let toolbarHTML = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
|
||||
|
||||
// Left side - diff navigation (if applicable)
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||
if (hasDiff) {
|
||||
toolbarHTML += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${t('toolbar.changes')}</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${t('toolbar.previousChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${t('toolbar.nextChange')}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
// Right side - action buttons
|
||||
toolbarHTML += '<div style="display: flex; align-items: center; gap: 4px;">';
|
||||
|
||||
// Show/hide diff button (only if there's diff info)
|
||||
if (file.diffInfo) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? t('toolbar.hideDiff') : t('toolbar.showDiff')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${showDiff ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Pop out button (only in sidebar mode with onPopOut)
|
||||
if (isSidebar && onPopOut) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
// Expand button (only in sidebar mode)
|
||||
if (isSidebar && onToggleExpand) {
|
||||
toolbarHTML += `
|
||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? t('toolbar.collapse') : t('toolbar.expand')}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${isExpanded ?
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />' :
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />'
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHTML += '</div>';
|
||||
toolbarHTML += '</div>';
|
||||
|
||||
dom.innerHTML = toolbarHTML;
|
||||
|
||||
if (hasDiff) {
|
||||
const prevBtn = dom.querySelector('.cm-diff-nav-prev');
|
||||
const nextBtn = dom.querySelector('.cm-diff-nav-next');
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) return;
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
|
||||
const chunk = chunks[currentIndex];
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' })
|
||||
});
|
||||
}
|
||||
updatePanel();
|
||||
});
|
||||
}
|
||||
|
||||
if (file.diffInfo) {
|
||||
const toggleDiffBtn = dom.querySelector('.cm-toggle-diff-btn');
|
||||
toggleDiffBtn?.addEventListener('click', () => {
|
||||
setShowDiff(!showDiff);
|
||||
});
|
||||
}
|
||||
|
||||
if (isSidebar && onPopOut) {
|
||||
const popoutBtn = dom.querySelector('.cm-popout-btn');
|
||||
popoutBtn?.addEventListener('click', () => {
|
||||
onPopOut();
|
||||
});
|
||||
}
|
||||
|
||||
if (isSidebar && onToggleExpand) {
|
||||
const expandBtn = dom.querySelector('.cm-expand-btn');
|
||||
expandBtn?.addEventListener('click', () => {
|
||||
onToggleExpand();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
|
||||
return {
|
||||
top: true,
|
||||
dom,
|
||||
update: updatePanel
|
||||
};
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
}, [file.diffInfo, showDiff, isSidebar, isExpanded, onToggleExpand, onPopOut]);
|
||||
|
||||
// Get language extension based on file extension
|
||||
const getLanguageExtension = (filename) => {
|
||||
const lowerName = filename.toLowerCase();
|
||||
// Handle dotfiles like .env, .env.local, .env.production, etc.
|
||||
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
|
||||
return [envLanguage];
|
||||
}
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
|
||||
case 'py':
|
||||
return [python()];
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return [html()];
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return [css()];
|
||||
case 'json':
|
||||
return [json()];
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return [markdown()];
|
||||
case 'env':
|
||||
return [envLanguage];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Load file content
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// If we have diffInfo with both old and new content, we can show the diff directly
|
||||
// This handles both GitPanel (full content) and ChatInterface (full content from API)
|
||||
if (file.diffInfo && file.diffInfo.new_string !== undefined && file.diffInfo.old_string !== undefined) {
|
||||
// Use the new_string as the content to display
|
||||
// The unifiedMergeView will compare it against old_string
|
||||
setContent(file.diffInfo.new_string);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, load from disk
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setContent(data.content);
|
||||
} catch (error) {
|
||||
console.error('Error loading file:', error);
|
||||
setContent(`// Error loading file: ${error.message}\n// File: ${file.name}\n// Path: ${file.path}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [file, projectPath]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
console.log('Saving file:', {
|
||||
projectName: file.projectName,
|
||||
path: file.path,
|
||||
contentLength: content?.length
|
||||
});
|
||||
|
||||
const response = await api.saveFile(file.projectName, file.path, content);
|
||||
|
||||
console.log('Save response:', {
|
||||
status: response.status,
|
||||
ok: response.ok,
|
||||
contentType: response.headers.get('content-type')
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
} else {
|
||||
const textError = await response.text();
|
||||
console.error('Non-JSON error response:', textError);
|
||||
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Save successful:', result);
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving file:', error);
|
||||
alert(`Error saving file: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Save theme preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorTheme', isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
// Save word wrap preference to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('codeEditorWordWrap', wordWrap.toString());
|
||||
}, [wordWrap]);
|
||||
|
||||
// Listen for settings changes from the Settings modal
|
||||
useEffect(() => {
|
||||
const handleStorageChange = () => {
|
||||
const newTheme = localStorage.getItem('codeEditorTheme');
|
||||
if (newTheme) {
|
||||
setIsDarkMode(newTheme === 'dark');
|
||||
}
|
||||
|
||||
const newWordWrap = localStorage.getItem('codeEditorWordWrap');
|
||||
if (newWordWrap !== null) {
|
||||
setWordWrap(newWordWrap === 'true');
|
||||
}
|
||||
|
||||
const newShowMinimap = localStorage.getItem('codeEditorShowMinimap');
|
||||
if (newShowMinimap !== null) {
|
||||
setMinimapEnabled(newShowMinimap !== 'false');
|
||||
}
|
||||
|
||||
const newShowLineNumbers = localStorage.getItem('codeEditorLineNumbers');
|
||||
if (newShowLineNumbers !== null) {
|
||||
setShowLineNumbers(newShowLineNumbers !== 'false');
|
||||
}
|
||||
|
||||
const newFontSize = localStorage.getItem('codeEditorFontSize');
|
||||
if (newFontSize) {
|
||||
setFontSize(newFontSize);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for storage events (changes from other tabs/windows)
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
|
||||
// Custom event for same-window updates
|
||||
window.addEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
window.removeEventListener('codeEditorSettingsChanged', handleStorageChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [content]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.code-editor-loading {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
.code-editor-loading:hover {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">{t('loading', { fileName: file.name })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
/* Light background for full line changes */
|
||||
.cm-deletedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.cm-insertedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
/* Override linear-gradient underline and use solid darker background for partial changes */
|
||||
.cm-editor.cm-merge-b .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-deletedChunk .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
/* Minimap gutter styling */
|
||||
.cm-gutter.cm-gutter-minimap {
|
||||
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
|
||||
}
|
||||
|
||||
/* Editor toolbar panel styling */
|
||||
.cm-editor-toolbar-panel {
|
||||
padding: 4px 10px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
color: ${isDarkMode ? '#d1d5db' : '#374151'};
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn,
|
||||
.cm-toolbar-btn {
|
||||
padding: 3px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:hover,
|
||||
.cm-toolbar-btn:hover {
|
||||
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className={isSidebar ?
|
||||
'w-full h-full flex flex-col' :
|
||||
`fixed inset-0 z-[9999] ${
|
||||
// Mobile: native fullscreen, Desktop: modal with backdrop
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={isSidebar ?
|
||||
'bg-background flex flex-col w-full h-full' :
|
||||
`bg-background shadow-2xl flex flex-col ${
|
||||
// Mobile: always fullscreen, Desktop: modal sizing
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl' +
|
||||
(isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]')
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{t('header.showingChanges')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
onClick={() => setMarkdownPreview(!markdownPreview)}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={markdownPreview ? t('actions.editMarkdown') : t('actions.previewMarkdown')}
|
||||
>
|
||||
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => window.openSettings?.('appearance')}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('toolbar.settings')}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('actions.download')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={saveSuccess ? t('actions.saved') : saving ? t('actions.saving') : t('actions.save')}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? t('actions.exitFullscreen') : t('actions.fullscreen')}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={t('actions.close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor / Markdown Preview */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{markdownPreview && isMarkdownFile ? (
|
||||
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
...getLanguageExtension(file.name),
|
||||
// Always show the toolbar
|
||||
...editorToolbarPanel,
|
||||
// Only show diff-related extensions when diff is enabled
|
||||
...(file.diffInfo && showDiff && file.diffInfo.old_string !== undefined
|
||||
? [
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true
|
||||
// NOTE: NO collapseUnchanged - this shows the full file!
|
||||
}),
|
||||
...minimapExtension,
|
||||
...scrollToFirstChunkExtension
|
||||
]
|
||||
: []),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{t('footer.lines')} {content.split('\n').length}</span>
|
||||
<span>{t('footer.characters')} {content.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('footer.shortcuts')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditor;
|
||||
@@ -1,367 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* CommandMenu - Autocomplete dropdown for slash commands
|
||||
*
|
||||
* @param {Array} commands - Array of command objects to display
|
||||
* @param {number} selectedIndex - Currently selected command index (index in `commands`)
|
||||
* @param {Function} onSelect - Callback when a command is selected
|
||||
* @param {Function} onClose - Callback when menu should close
|
||||
* @param {Object} position - Position object { top, left } for absolute positioning
|
||||
* @param {boolean} isOpen - Whether the menu is open
|
||||
* @param {Array} frequentCommands - Array of frequently used command objects
|
||||
*/
|
||||
const CommandMenu = ({
|
||||
commands = [],
|
||||
selectedIndex = -1,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
isOpen = false,
|
||||
frequentCommands = [],
|
||||
}) => {
|
||||
const menuRef = useRef(null);
|
||||
const selectedItemRef = useRef(null);
|
||||
|
||||
// Calculate responsive menu positioning.
|
||||
// Mobile: dock above chat input. Desktop: clamp to viewport.
|
||||
const getMenuPosition = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, calculate bottom position dynamically to appear above the input.
|
||||
// Use the bottom value calculated as: window.innerHeight - textarea.top + spacing.
|
||||
const inputBottom = position.bottom || 90;
|
||||
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${inputBottom}px`, // Position above the input with spacing already included.
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px.
|
||||
};
|
||||
}
|
||||
|
||||
// On desktop, use provided position but ensure it stays on screen.
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
const menuPosition = getMenuPosition();
|
||||
|
||||
// Close menu when clicking outside.
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Keep selected keyboard item visible while navigating.
|
||||
useEffect(() => {
|
||||
if (selectedItemRef.current && menuRef.current) {
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
||||
|
||||
if (itemRect.bottom > menuRect.bottom) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
} else if (itemRect.top < menuRect.top) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show a message if no commands are available.
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add frequent commands as a special group if provided.
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
|
||||
const getCommandKey = (command) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
|
||||
// Group commands by namespace for section rendering.
|
||||
// When frequent commands are shown, avoid duplicate rows in other sections.
|
||||
const groupedCommands = commands.reduce((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
const namespace = command.namespace || command.type || 'other';
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Add frequent commands as a separate group.
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
// Order: frequent, builtin, project, user, other.
|
||||
const namespaceOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]);
|
||||
|
||||
const namespaceLabels = {
|
||||
frequent: '\u2B50 Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
// Keep all selection indices aligned to `commands` (filteredCommands from the hook).
|
||||
// This prevents mismatches between mouse selection (rendered list) and keyboard selection.
|
||||
const commandIndexByKey = new Map();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
}}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: '#6b7280',
|
||||
padding: '8px 12px 4px',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedCommands[namespace].map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => {
|
||||
if (onSelect && commandIndex >= 0) {
|
||||
onSelect(command, commandIndex, true);
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (onSelect) {
|
||||
onSelect(command, commandIndex, false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? '#eff6ff' : 'transparent',
|
||||
transition: 'background-color 100ms ease-in-out',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
// Prevent textarea blur when clicking a menu item.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: command.description ? '4px' : 0,
|
||||
}}
|
||||
>
|
||||
{/* Command icon based on namespace */}
|
||||
<span style={{ fontSize: '16px', flexShrink: 0 }}>
|
||||
{namespace === 'builtin' && '\u26A1'}
|
||||
{namespace === 'project' && '\uD83D\uDCC1'}
|
||||
{namespace === 'user' && '\uD83D\uDC64'}
|
||||
{namespace === 'other' && '\uD83D\uDCDD'}
|
||||
{namespace === 'frequent' && '\u2B50'}
|
||||
</span>
|
||||
|
||||
{/* Command name */}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#111827',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
|
||||
{/* Command metadata badge */}
|
||||
{command.metadata?.type && (
|
||||
<span
|
||||
className="command-metadata-badge"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command description */}
|
||||
{command.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginLeft: '24px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
color: '#3b82f6',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{'\u21B5'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Default light mode styles */}
|
||||
<style>{`
|
||||
.command-menu {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.command-menu {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #374151 !important;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.command-item[aria-selected="true"] {
|
||||
background-color: #1e40af !important;
|
||||
}
|
||||
.command-item span:not(.command-metadata-badge) {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.command-metadata-badge {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.command-item div {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
.command-group > div:first-child {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandMenu;
|
||||
@@ -1,421 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
|
||||
import { useVersionCheck } from '../hooks/useVersionCheck';
|
||||
import { version } from '../../package.json';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function CredentialsSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [githubCredentials, setGithubCredentials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
|
||||
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newGithubName, setNewGithubName] = useState('');
|
||||
const [newGithubToken, setNewGithubToken] = useState('');
|
||||
const [newGithubDescription, setNewGithubDescription] = useState('');
|
||||
const [showToken, setShowToken] = useState({});
|
||||
const [copiedKey, setCopiedKey] = useState(null);
|
||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
|
||||
|
||||
// Version check hook
|
||||
const { updateAvailable, latestVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch API keys
|
||||
const apiKeysRes = await authenticatedFetch('/api/settings/api-keys');
|
||||
const apiKeysData = await apiKeysRes.json();
|
||||
setApiKeys(apiKeysData.apiKeys || []);
|
||||
|
||||
// Fetch GitHub credentials only
|
||||
const credentialsRes = await authenticatedFetch('/api/settings/credentials?type=github_token');
|
||||
const credentialsData = await credentialsRes.json();
|
||||
setGithubCredentials(credentialsData.credentials || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ keyName: newKeyName })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewlyCreatedKey(data.apiKey);
|
||||
setNewKeyName('');
|
||||
setShowNewKeyForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApiKey = async (keyId) => {
|
||||
if (!confirm(t('apiKeys.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleApiKey = async (keyId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createGithubCredential = async () => {
|
||||
if (!newGithubName.trim() || !newGithubToken.trim()) return;
|
||||
|
||||
try {
|
||||
const res = await authenticatedFetch('/api/settings/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialName: newGithubName,
|
||||
credentialType: 'github_token',
|
||||
credentialValue: newGithubToken,
|
||||
description: newGithubDescription
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setNewGithubName('');
|
||||
setNewGithubToken('');
|
||||
setNewGithubDescription('');
|
||||
setShowNewGithubForm(false);
|
||||
fetchData();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGithubCredential = async (credentialId) => {
|
||||
if (!confirm(t('apiKeys.github.confirmDelete'))) return;
|
||||
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGithubCredential = async (credentialId, isActive) => {
|
||||
try {
|
||||
await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ isActive: !isActive })
|
||||
});
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Error toggling GitHub credential:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text, id) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedKey(id);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-muted-foreground">{t('apiKeys.loading')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* New API Key Alert */}
|
||||
{newlyCreatedKey && (
|
||||
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<h4 className="font-semibold text-yellow-500 mb-2">{t('apiKeys.newKey.alertTitle')}</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{t('apiKeys.newKey.alertMessage')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
|
||||
{newlyCreatedKey.apiKey}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
|
||||
>
|
||||
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-3"
|
||||
onClick={() => setNewlyCreatedKey(null)}
|
||||
>
|
||||
{t('apiKeys.newKey.iveSavedIt')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('apiKeys.newButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t('apiKeys.description')}
|
||||
</p>
|
||||
<a
|
||||
href="/api-docs.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t('apiKeys.apiDocsLink')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{showNewKeyForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card">
|
||||
<Input
|
||||
placeholder={t('apiKeys.form.placeholder')}
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
className="mb-2"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createApiKey}>{t('apiKeys.form.createButton')}</Button>
|
||||
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
|
||||
{t('apiKeys.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{apiKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.empty')}</p>
|
||||
) : (
|
||||
apiKeys.map((key) => (
|
||||
<div
|
||||
key={key.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{key.key_name}</div>
|
||||
<code className="text-xs text-muted-foreground">{key.api_key}</code>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}
|
||||
{key.last_used && ` • ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={key.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleApiKey(key.id, key.is_active)}
|
||||
>
|
||||
{key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(key.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Credentials Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('apiKeys.github.title')}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('apiKeys.github.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('apiKeys.github.descriptionAlt')}
|
||||
</p>
|
||||
|
||||
{showNewGithubForm && (
|
||||
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
|
||||
<Input
|
||||
placeholder={t('apiKeys.github.form.namePlaceholder')}
|
||||
value={newGithubName}
|
||||
onChange={(e) => setNewGithubName(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken['new'] ? 'text' : 'password'}
|
||||
placeholder={t('apiKeys.github.form.tokenPlaceholder')}
|
||||
value={newGithubToken}
|
||||
onChange={(e) => setNewGithubToken(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t('apiKeys.github.form.descriptionPlaceholder')}
|
||||
value={newGithubDescription}
|
||||
onChange={(e) => setNewGithubDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={createGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setShowNewGithubForm(false);
|
||||
setNewGithubName('');
|
||||
setNewGithubToken('');
|
||||
setNewGithubDescription('');
|
||||
}}>
|
||||
{t('apiKeys.github.form.cancelButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline block"
|
||||
>
|
||||
{t('apiKeys.github.form.howToCreate')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{githubCredentials.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">{t('apiKeys.github.empty')}</p>
|
||||
) : (
|
||||
githubCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{credential.credential_name}</div>
|
||||
{credential.description && (
|
||||
<div className="text-xs text-muted-foreground">{credential.description}</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={credential.is_active ? 'outline' : 'secondary'}
|
||||
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
|
||||
>
|
||||
{credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteGithubCredential(credential.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Information */}
|
||||
<div className="pt-6 border-t border-border/50">
|
||||
<div className="flex items-center justify-between text-xs italic text-muted-foreground/60">
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
v{version}
|
||||
</a>
|
||||
{updateAvailable && latestVersion && (
|
||||
<a
|
||||
href={releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-600 dark:text-green-400 rounded-full hover:bg-green-500/20 transition-colors not-italic font-medium"
|
||||
>
|
||||
<span className="text-[10px]">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CredentialsSettings;
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
function DarkModeToggle() {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
role="switch"
|
||||
aria-checked={isDarkMode}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<span className="sr-only">Toggle dark mode</span>
|
||||
<span
|
||||
className={`${
|
||||
isDarkMode ? 'translate-x-7' : 'translate-x-1'
|
||||
} inline-block h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<svg className="w-3.5 h-3.5 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DarkModeToggle;
|
||||
48
src/components/DarkModeToggle.tsx
Normal file
48
src/components/DarkModeToggle.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
type DarkModeToggleProps = {
|
||||
checked?: boolean;
|
||||
onToggle?: (nextValue: boolean) => void;
|
||||
ariaLabel?: string;
|
||||
};
|
||||
|
||||
function DarkModeToggle({ checked, onToggle, ariaLabel = 'Toggle dark mode' }: DarkModeToggleProps) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';
|
||||
const isEnabled = isControlled ? checked : isDarkMode;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isControlled) {
|
||||
onToggle(!isEnabled);
|
||||
return;
|
||||
}
|
||||
|
||||
toggleDarkMode();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 dark:bg-gray-700 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
role="switch"
|
||||
aria-checked={isEnabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
<span
|
||||
className={`${
|
||||
isEnabled ? 'translate-x-7' : 'translate-x-1'
|
||||
} h-6 w-6 transform rounded-full bg-white shadow-lg transition-transform duration-200 flex items-center justify-center`}
|
||||
>
|
||||
{isEnabled ? (
|
||||
<Moon className="h-3.5 w-3.5 text-gray-700" />
|
||||
) : (
|
||||
<Sun className="h-3.5 w-3.5 text-yellow-500" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default DarkModeToggle;
|
||||
@@ -1,73 +1,77 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null, errorInfo: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// Log the error details
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
|
||||
// You can also log the error to an error reporting service here
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Fallback UI
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{this.props.showDetails && this.state.error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
this.setState({ hasError: false, error: null, errorInfo: null });
|
||||
if (this.props.onRetry) this.props.onRetry();
|
||||
}}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
function ErrorFallback({ error, resetErrorBoundary, showDetails, componentStack }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="ml-3 text-sm font-medium text-red-800">
|
||||
Something went wrong
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="mb-2">An error occurred while loading the chat interface.</p>
|
||||
{showDetails && error && (
|
||||
<details className="mt-4">
|
||||
<summary className="cursor-pointer text-xs font-mono">Error Details</summary>
|
||||
<pre className="mt-2 text-xs bg-red-100 p-2 rounded overflow-auto max-h-40">
|
||||
{error.toString()}
|
||||
{componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={resetErrorBoundary}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
function ErrorBoundary({ children, showDetails = false, onRetry = undefined, resetKeys = undefined }) {
|
||||
const [componentStack, setComponentStack] = useState(null);
|
||||
|
||||
const handleError = useCallback((error, errorInfo) => {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
setComponentStack(errorInfo?.componentStack || null);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setComponentStack(null);
|
||||
onRetry?.();
|
||||
}, [onRetry]);
|
||||
|
||||
const renderFallback = useCallback(({ error, resetErrorBoundary }) => (
|
||||
<ErrorFallback
|
||||
error={error}
|
||||
resetErrorBoundary={resetErrorBoundary}
|
||||
showDetails={showDetails}
|
||||
componentStack={componentStack}
|
||||
/>
|
||||
), [showDetails, componentStack]);
|
||||
|
||||
return (
|
||||
<ReactErrorBoundary
|
||||
fallbackRender={renderFallback}
|
||||
onError={handleError}
|
||||
onReset={handleReset}
|
||||
resetKeys={resetKeys}
|
||||
>
|
||||
{children}
|
||||
</ReactErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
||||
@@ -1,729 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import {
|
||||
Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X,
|
||||
ChevronRight,
|
||||
FileJson, FileType, FileSpreadsheet, FileArchive,
|
||||
Hash, Braces, Terminal, Database, Globe, Palette, Music2, Video, Archive,
|
||||
Lock, Shield, Settings, Image, BookOpen, Cpu, Box, Gem, Coffee,
|
||||
Flame, Hexagon, FileCode2, Code2, Cog, FileWarning, Binary, SquareFunction,
|
||||
Scroll, FlaskConical, NotebookPen, FileCheck, Workflow, Blocks
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ImageViewer from './ImageViewer';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
// ─── File Icon Registry ──────────────────────────────────────────────
|
||||
// Maps file extensions (and special filenames) to { icon, colorClass } pairs.
|
||||
// Uses lucide-react icons mapped semantically to file types.
|
||||
|
||||
const ICON_SIZE = 'w-4 h-4 flex-shrink-0';
|
||||
|
||||
const FILE_ICON_MAP = {
|
||||
// ── JavaScript / TypeScript ──
|
||||
js: { icon: FileCode, color: 'text-yellow-500' },
|
||||
jsx: { icon: FileCode, color: 'text-yellow-500' },
|
||||
mjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
cjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
ts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
tsx: { icon: FileCode2, color: 'text-blue-500' },
|
||||
mts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
|
||||
// ── Python ──
|
||||
py: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyw: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyi: { icon: Code2, color: 'text-emerald-400' },
|
||||
ipynb:{ icon: NotebookPen, color: 'text-orange-500' },
|
||||
|
||||
// ── Rust ──
|
||||
rs: { icon: Cog, color: 'text-orange-600' },
|
||||
toml: { icon: Settings, color: 'text-gray-500' },
|
||||
|
||||
// ── Go ──
|
||||
go: { icon: Hexagon, color: 'text-cyan-500' },
|
||||
|
||||
// ── Ruby ──
|
||||
rb: { icon: Gem, color: 'text-red-500' },
|
||||
erb: { icon: Gem, color: 'text-red-400' },
|
||||
|
||||
// ── PHP ──
|
||||
php: { icon: Blocks, color: 'text-violet-500' },
|
||||
|
||||
// ── Java / Kotlin ──
|
||||
java: { icon: Coffee, color: 'text-red-600' },
|
||||
jar: { icon: Coffee, color: 'text-red-500' },
|
||||
kt: { icon: Hexagon, color: 'text-violet-500' },
|
||||
kts: { icon: Hexagon, color: 'text-violet-400' },
|
||||
|
||||
// ── C / C++ ──
|
||||
c: { icon: Cpu, color: 'text-blue-600' },
|
||||
h: { icon: Cpu, color: 'text-blue-400' },
|
||||
cpp: { icon: Cpu, color: 'text-blue-700' },
|
||||
hpp: { icon: Cpu, color: 'text-blue-500' },
|
||||
cc: { icon: Cpu, color: 'text-blue-700' },
|
||||
|
||||
// ── C# ──
|
||||
cs: { icon: Hexagon, color: 'text-purple-600' },
|
||||
|
||||
// ── Swift ──
|
||||
swift:{ icon: Flame, color: 'text-orange-500' },
|
||||
|
||||
// ── Lua ──
|
||||
lua: { icon: SquareFunction, color: 'text-blue-500' },
|
||||
|
||||
// ── R ──
|
||||
r: { icon: FlaskConical, color: 'text-blue-600' },
|
||||
|
||||
// ── Web ──
|
||||
html: { icon: Globe, color: 'text-orange-600' },
|
||||
htm: { icon: Globe, color: 'text-orange-600' },
|
||||
css: { icon: Hash, color: 'text-blue-500' },
|
||||
scss: { icon: Hash, color: 'text-pink-500' },
|
||||
sass: { icon: Hash, color: 'text-pink-400' },
|
||||
less: { icon: Hash, color: 'text-indigo-500' },
|
||||
vue: { icon: FileCode2, color: 'text-emerald-500' },
|
||||
svelte:{ icon: FileCode2, color: 'text-orange-500' },
|
||||
|
||||
// ── Data / Config ──
|
||||
json: { icon: Braces, color: 'text-yellow-600' },
|
||||
jsonc:{ icon: Braces, color: 'text-yellow-500' },
|
||||
json5:{ icon: Braces, color: 'text-yellow-500' },
|
||||
yaml: { icon: Settings, color: 'text-purple-400' },
|
||||
yml: { icon: Settings, color: 'text-purple-400' },
|
||||
xml: { icon: FileCode, color: 'text-orange-500' },
|
||||
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
|
||||
sql: { icon: Database, color: 'text-blue-500' },
|
||||
graphql:{ icon: Workflow, color: 'text-pink-500' },
|
||||
gql: { icon: Workflow, color: 'text-pink-500' },
|
||||
proto:{ icon: Box, color: 'text-green-500' },
|
||||
env: { icon: Shield, color: 'text-yellow-600' },
|
||||
|
||||
// ── Documents ──
|
||||
md: { icon: BookOpen, color: 'text-blue-500' },
|
||||
mdx: { icon: BookOpen, color: 'text-blue-400' },
|
||||
txt: { icon: FileText, color: 'text-gray-500' },
|
||||
doc: { icon: FileText, color: 'text-blue-600' },
|
||||
docx: { icon: FileText, color: 'text-blue-600' },
|
||||
pdf: { icon: FileCheck, color: 'text-red-600' },
|
||||
rtf: { icon: FileText, color: 'text-gray-500' },
|
||||
tex: { icon: Scroll, color: 'text-teal-600' },
|
||||
rst: { icon: FileText, color: 'text-gray-400' },
|
||||
|
||||
// ── Shell / Scripts ──
|
||||
sh: { icon: Terminal, color: 'text-green-500' },
|
||||
bash: { icon: Terminal, color: 'text-green-500' },
|
||||
zsh: { icon: Terminal, color: 'text-green-400' },
|
||||
fish: { icon: Terminal, color: 'text-green-400' },
|
||||
ps1: { icon: Terminal, color: 'text-blue-400' },
|
||||
bat: { icon: Terminal, color: 'text-gray-500' },
|
||||
cmd: { icon: Terminal, color: 'text-gray-500' },
|
||||
|
||||
// ── Images ──
|
||||
png: { icon: Image, color: 'text-purple-500' },
|
||||
jpg: { icon: Image, color: 'text-purple-500' },
|
||||
jpeg: { icon: Image, color: 'text-purple-500' },
|
||||
gif: { icon: Image, color: 'text-purple-400' },
|
||||
webp: { icon: Image, color: 'text-purple-400' },
|
||||
ico: { icon: Image, color: 'text-purple-400' },
|
||||
bmp: { icon: Image, color: 'text-purple-400' },
|
||||
tiff: { icon: Image, color: 'text-purple-400' },
|
||||
svg: { icon: Palette, color: 'text-amber-500' },
|
||||
|
||||
// ── Audio ──
|
||||
mp3: { icon: Music2, color: 'text-pink-500' },
|
||||
wav: { icon: Music2, color: 'text-pink-500' },
|
||||
ogg: { icon: Music2, color: 'text-pink-400' },
|
||||
flac: { icon: Music2, color: 'text-pink-400' },
|
||||
aac: { icon: Music2, color: 'text-pink-400' },
|
||||
m4a: { icon: Music2, color: 'text-pink-400' },
|
||||
|
||||
// ── Video ──
|
||||
mp4: { icon: Video, color: 'text-rose-500' },
|
||||
mov: { icon: Video, color: 'text-rose-500' },
|
||||
avi: { icon: Video, color: 'text-rose-500' },
|
||||
webm: { icon: Video, color: 'text-rose-400' },
|
||||
mkv: { icon: Video, color: 'text-rose-400' },
|
||||
|
||||
// ── Fonts ──
|
||||
ttf: { icon: FileType, color: 'text-red-500' },
|
||||
otf: { icon: FileType, color: 'text-red-500' },
|
||||
woff: { icon: FileType, color: 'text-red-400' },
|
||||
woff2:{ icon: FileType, color: 'text-red-400' },
|
||||
eot: { icon: FileType, color: 'text-red-400' },
|
||||
|
||||
// ── Archives ──
|
||||
zip: { icon: Archive, color: 'text-amber-600' },
|
||||
tar: { icon: Archive, color: 'text-amber-600' },
|
||||
gz: { icon: Archive, color: 'text-amber-600' },
|
||||
bz2: { icon: Archive, color: 'text-amber-600' },
|
||||
rar: { icon: Archive, color: 'text-amber-500' },
|
||||
'7z': { icon: Archive, color: 'text-amber-500' },
|
||||
|
||||
// ── Lock files ──
|
||||
lock: { icon: Lock, color: 'text-gray-500' },
|
||||
|
||||
// ── Binary / Executable ──
|
||||
exe: { icon: Binary, color: 'text-gray-500' },
|
||||
bin: { icon: Binary, color: 'text-gray-500' },
|
||||
dll: { icon: Binary, color: 'text-gray-400' },
|
||||
so: { icon: Binary, color: 'text-gray-400' },
|
||||
dylib:{ icon: Binary, color: 'text-gray-400' },
|
||||
wasm: { icon: Binary, color: 'text-purple-500' },
|
||||
|
||||
// ── Misc config ──
|
||||
ini: { icon: Settings, color: 'text-gray-500' },
|
||||
cfg: { icon: Settings, color: 'text-gray-500' },
|
||||
conf: { icon: Settings, color: 'text-gray-500' },
|
||||
log: { icon: Scroll, color: 'text-gray-400' },
|
||||
map: { icon: File, color: 'text-gray-400' },
|
||||
};
|
||||
|
||||
// Special full-filename matches (highest priority)
|
||||
const FILENAME_ICON_MAP = {
|
||||
'Dockerfile': { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
|
||||
'.dockerignore': { icon: Box, color: 'text-gray-500' },
|
||||
'.gitignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
|
||||
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
|
||||
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
|
||||
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.mjs':{ icon: Settings, color: 'text-violet-500' },
|
||||
'.env': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.local': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.development': { icon: Shield, color: 'text-yellow-500' },
|
||||
'.env.production': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.example': { icon: Shield, color: 'text-yellow-400' },
|
||||
'package.json': { icon: Braces, color: 'text-green-500' },
|
||||
'package-lock.json':{ icon: Lock, color: 'text-gray-500' },
|
||||
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
|
||||
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
|
||||
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
|
||||
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
|
||||
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
|
||||
'Gemfile': { icon: Gem, color: 'text-red-500' },
|
||||
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
|
||||
'Makefile': { icon: Terminal, color: 'text-gray-500' },
|
||||
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
|
||||
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
|
||||
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
|
||||
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
|
||||
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
|
||||
'webpack.config.js':{ icon: Cog, color: 'text-blue-500' },
|
||||
'tailwind.config.js':{ icon: Hash, color: 'text-cyan-500' },
|
||||
'tailwind.config.ts':{ icon: Hash, color: 'text-cyan-500' },
|
||||
'postcss.config.js':{ icon: Cog, color: 'text-red-400' },
|
||||
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
|
||||
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
|
||||
'README.md': { icon: BookOpen, color: 'text-blue-500' },
|
||||
'LICENSE': { icon: FileCheck, color: 'text-gray-500' },
|
||||
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
|
||||
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
|
||||
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
|
||||
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
|
||||
'go.sum': { icon: Lock, color: 'text-cyan-400' },
|
||||
};
|
||||
|
||||
function getFileIconData(filename) {
|
||||
// 1. Exact filename match
|
||||
if (FILENAME_ICON_MAP[filename]) {
|
||||
return FILENAME_ICON_MAP[filename];
|
||||
}
|
||||
|
||||
// 2. Check for .env prefix pattern
|
||||
if (filename.startsWith('.env')) {
|
||||
return { icon: Shield, color: 'text-yellow-600' };
|
||||
}
|
||||
|
||||
// 3. Extension-based lookup
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (ext && FILE_ICON_MAP[ext]) {
|
||||
return FILE_ICON_MAP[ext];
|
||||
}
|
||||
|
||||
// 4. Fallback
|
||||
return { icon: File, color: 'text-muted-foreground' };
|
||||
}
|
||||
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────
|
||||
|
||||
function FileTree({ selectedProject, onFileOpen }) {
|
||||
const { t } = useTranslation();
|
||||
const [files, setFiles] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedDirs, setExpandedDirs] = useState(new Set());
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, [selectedProject]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedViewMode = localStorage.getItem('file-tree-view-mode');
|
||||
if (savedViewMode && ['simple', 'detailed', 'compact'].includes(savedViewMode)) {
|
||||
setViewMode(savedViewMode);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFiles(files);
|
||||
} else {
|
||||
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||
setFilteredFiles(filtered);
|
||||
|
||||
const expandMatches = (items) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||
setExpandedDirs(prev => new Set(prev.add(item.path)));
|
||||
expandMatches(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandMatches(filtered);
|
||||
}
|
||||
}, [files, searchQuery]);
|
||||
|
||||
const filterFiles = (items, query) => {
|
||||
return items.reduce((filtered, item) => {
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
let filteredChildren = [];
|
||||
|
||||
if (item.type === 'directory' && item.children) {
|
||||
filteredChildren = filterFiles(item.children, query);
|
||||
}
|
||||
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
filtered.push({
|
||||
...item,
|
||||
children: filteredChildren
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.getFiles(selectedProject.name);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ File fetch failed:', response.status, errorText);
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setFiles(data);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching files:', error);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDirectory = (path) => {
|
||||
const newExpanded = new Set(expandedDirs);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedDirs(newExpanded);
|
||||
};
|
||||
|
||||
const changeViewMode = (mode) => {
|
||||
setViewMode(mode);
|
||||
localStorage.setItem('file-tree-view-mode', mode);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes || bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatRelativeTime = (date) => {
|
||||
if (!date) return '-';
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - past) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return t('fileTree.justNow');
|
||||
if (diffInSeconds < 3600) return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });
|
||||
if (diffInSeconds < 86400) return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });
|
||||
if (diffInSeconds < 2592000) return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });
|
||||
return past.toLocaleDateString();
|
||||
};
|
||||
|
||||
const isImageFile = (filename) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'];
|
||||
return imageExtensions.includes(ext);
|
||||
};
|
||||
|
||||
const getFileIcon = (filename) => {
|
||||
const { icon: Icon, color } = getFileIconData(filename);
|
||||
return <Icon className={cn(ICON_SIZE, color)} />;
|
||||
};
|
||||
|
||||
// ── Click handler shared across all view modes ──
|
||||
const handleItemClick = (item) => {
|
||||
if (item.type === 'directory') {
|
||||
toggleDirectory(item.path);
|
||||
} else if (isImageFile(item.name)) {
|
||||
setSelectedImage({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
projectPath: selectedProject.path,
|
||||
projectName: selectedProject.name
|
||||
});
|
||||
} else if (onFileOpen) {
|
||||
onFileOpen(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Indent guide + folder/file icon rendering ──
|
||||
const renderIndentGuides = (level) => {
|
||||
if (level === 0) return null;
|
||||
return (
|
||||
<span className="flex items-center flex-shrink-0" aria-hidden="true">
|
||||
{Array.from({ length: level }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block w-4 h-full border-l border-border/50"
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItemIcons = (item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = expandedDirs.has(item.path);
|
||||
|
||||
if (isDir) {
|
||||
return (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',
|
||||
isOpen && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex items-center flex-shrink-0 ml-[18px]">
|
||||
{getFileIcon(item.name)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Simple (Tree) View ────────────────────────────────────────────
|
||||
const renderFileTree = (items, level = 0) => {
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm',
|
||||
'hover:bg-accent/60 transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && item.children.length > 0 && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderFileTree(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Detailed View ────────────────────────────────────────────────
|
||||
const renderDetailedView = (items, level = 0) => {
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="col-span-5 flex items-center gap-1.5 min-w-0">
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground tabular-nums">
|
||||
{item.type === 'file' ? formatFileSize(item.size) : ''}
|
||||
</div>
|
||||
<div className="col-span-3 text-sm text-muted-foreground">
|
||||
{formatRelativeTime(item.modified)}
|
||||
</div>
|
||||
<div className="col-span-2 text-sm text-muted-foreground font-mono">
|
||||
{item.permissionsRwx || ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderDetailedView(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Compact View ──────────────────────────────────────────────────
|
||||
const renderCompactView = (items, level = 0) => {
|
||||
return items.map((item) => {
|
||||
const isDir = item.type === 'directory';
|
||||
const isOpen = isDir && expandedDirs.has(item.path);
|
||||
|
||||
return (
|
||||
<div key={item.path} className="select-none">
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100',
|
||||
isDir && isOpen && 'border-l-2 border-primary/30',
|
||||
isDir && !isOpen && 'border-l-2 border-transparent',
|
||||
!isDir && 'border-l-2 border-transparent',
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 16 + 4}px` }}
|
||||
onClick={() => handleItemClick(item)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{renderItemIcons(item)}
|
||||
<span className={cn(
|
||||
'text-[13px] leading-tight truncate',
|
||||
isDir ? 'font-medium text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-shrink-0 ml-2">
|
||||
{item.type === 'file' && (
|
||||
<>
|
||||
<span className="tabular-nums">{formatFileSize(item.size)}</span>
|
||||
<span className="font-mono">{item.permissionsRwx}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDir && isOpen && item.children && (
|
||||
<div className="relative">
|
||||
<span
|
||||
className="absolute top-0 bottom-0 border-l border-border/40"
|
||||
style={{ left: `${level * 16 + 14}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderCompactView(item.children, level + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// ─── Loading state ─────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t('fileTree.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background">
|
||||
{/* Header */}
|
||||
<div className="px-3 pt-3 pb-2 border-b border-border space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{t('fileTree.files')}
|
||||
</h3>
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title={t('fileTree.simpleView')}
|
||||
>
|
||||
<List className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title={t('fileTree.compactView')}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title={t('fileTree.detailedView')}
|
||||
>
|
||||
<TableProperties className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t('fileTree.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0.5 top-1/2 transform -translate-y-1/2 h-5 w-5 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title={t('fileTree.clearSearch')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-3 pt-1.5 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
||||
<div className="col-span-5">{t('fileTree.name')}</div>
|
||||
<div className="col-span-2">{t('fileTree.size')}</div>
|
||||
<div className="col-span-3">{t('fileTree.modified')}</div>
|
||||
<div className="col-span-2">{t('fileTree.permissions')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-1">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Folder className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noFilesFound')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('fileTree.checkProjectPath')}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">{t('fileTree.noMatchesFound')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('fileTree.tryDifferentSearch')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Image Viewer Modal */}
|
||||
{selectedImage && (
|
||||
<ImageViewer
|
||||
file={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileTree;
|
||||
9
src/components/GeminiLogo.jsx
Normal file
9
src/components/GeminiLogo.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const GeminiLogo = ({className = 'w-5 h-5'}) => {
|
||||
return (
|
||||
<img src="/icons/gemini-ai-icon.svg" alt="Gemini" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default GeminiLogo;
|
||||
90
src/components/GeminiStatus.jsx
Normal file
90
src/components/GeminiStatus.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
function GeminiStatus({ status, onAbort, isLoading }) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Animate the status indicator
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setAnimationPhase(prev => (prev + 1) % 4);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
// Clever action words that cycle
|
||||
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
|
||||
// Parse status data
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
|
||||
// Animation characters
|
||||
const spinners = ['✻', '✹', '✸', '✶'];
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gradient-to-r from-cyan-900 to-blue-900 dark:from-cyan-950 dark:to-blue-950 text-white rounded-lg shadow-lg px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
animationPhase % 2 === 0 ? "text-cyan-400 scale-110" : "text-cyan-300"
|
||||
)}>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - first line */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{statusText}...</span>
|
||||
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeminiStatus;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,131 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { GitBranch, Check } from 'lucide-react';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function GitSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const [gitName, setGitName] = useState('');
|
||||
const [gitEmail, setGitEmail] = useState('');
|
||||
const [gitConfigLoading, setGitConfigLoading] = useState(false);
|
||||
const [gitConfigSaving, setGitConfigSaving] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadGitConfig();
|
||||
}, []);
|
||||
|
||||
const loadGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigLoading(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGitName(data.gitName || '');
|
||||
setGitEmail(data.gitEmail || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading git config:', error);
|
||||
} finally {
|
||||
setGitConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveGitConfig = async () => {
|
||||
try {
|
||||
setGitConfigSaving(true);
|
||||
const response = await authenticatedFetch('/api/user/git-config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ gitName, gitEmail })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('success');
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setSaveStatus('error');
|
||||
console.error('Failed to save git config:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving git config:', error);
|
||||
setSaveStatus('error');
|
||||
} finally {
|
||||
setGitConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('git.description')}
|
||||
</p>
|
||||
|
||||
<div className="p-4 border rounded-lg bg-card space-y-3">
|
||||
<div>
|
||||
<label htmlFor="settings-git-name" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('git.name.label')}
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-name"
|
||||
type="text"
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('git.name.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="settings-git-email" className="block text-sm font-medium text-foreground mb-2">
|
||||
{t('git.email.label')}
|
||||
</label>
|
||||
<Input
|
||||
id="settings-git-email"
|
||||
type="email"
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
disabled={gitConfigLoading}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('git.email.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={saveGitConfig}
|
||||
disabled={gitConfigSaving || !gitName || !gitEmail}
|
||||
>
|
||||
{gitConfigSaving ? t('git.actions.saving') : t('git.actions.save')}
|
||||
</Button>
|
||||
|
||||
{saveStatus === 'success' && (
|
||||
<div className="text-sm text-green-600 dark:text-green-400 flex items-center gap-2">
|
||||
<Check className="w-4 h-4" />
|
||||
{t('git.status.success')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettings;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { X } from 'lucide-react';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import { X, ExternalLink, KeyRound } from 'lucide-react';
|
||||
import StandaloneShell from './standalone-shell/view/StandaloneShell';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
/**
|
||||
* Reusable login modal component for Claude, Cursor, and Codex CLI authentication
|
||||
* Reusable login modal component for Claude, Cursor, Codex, and Gemini CLI authentication
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isOpen - Whether the modal is visible
|
||||
* @param {Function} props.onClose - Callback when modal is closed
|
||||
* @param {'claude'|'cursor'|'codex'} props.provider - Which CLI provider to authenticate with
|
||||
* @param {'claude'|'cursor'|'codex'|'gemini'} props.provider - Which CLI provider to authenticate with
|
||||
* @param {Object} props.project - Project object containing name and path information
|
||||
* @param {Function} props.onComplete - Callback when login process completes (receives exitCode)
|
||||
* @param {string} props.customCommand - Optional custom command to override defaults
|
||||
@@ -36,6 +36,9 @@ function LoginModal({
|
||||
return 'cursor-agent login';
|
||||
case 'codex':
|
||||
return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';
|
||||
case 'gemini':
|
||||
// No explicit interactive login command for gemini CLI exists yet similar to Claude, so we'll just check status or instruct the user to configure `.gemini.json`
|
||||
return 'gemini status';
|
||||
default:
|
||||
return isAuthenticated ? 'claude setup-token --dangerously-skip-permissions' : isOnboarding ? 'claude /exit --dangerously-skip-permissions' : 'claude /login --dangerously-skip-permissions';
|
||||
}
|
||||
@@ -49,6 +52,8 @@ function LoginModal({
|
||||
return 'Cursor CLI Login';
|
||||
case 'codex':
|
||||
return 'Codex CLI Login';
|
||||
case 'gemini':
|
||||
return 'Gemini CLI Configuration';
|
||||
default:
|
||||
return 'CLI Login';
|
||||
}
|
||||
@@ -77,12 +82,68 @@ function LoginModal({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
{provider === 'gemini' ? (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center bg-gray-50 dark:bg-gray-900/50">
|
||||
<div className="w-16 h-16 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center mb-6">
|
||||
<KeyRound className="w-8 h-8 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
|
||||
<h4 className="text-xl font-medium text-gray-900 dark:text-white mb-3">
|
||||
Setup Gemini API Access
|
||||
</h4>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 max-w-md mb-8">
|
||||
The Gemini CLI requires an API key to function. Unlike Claude, you'll need to configure this directly in your terminal first.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 max-w-lg w-full text-left shadow-sm">
|
||||
<ol className="space-y-4">
|
||||
<li className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Get your API Key</p>
|
||||
<a
|
||||
href="https://aistudio.google.com/app/apikey"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 inline-flex"
|
||||
>
|
||||
Google AI Studio <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 flex items-center justify-center text-sm font-medium">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">Run configuration</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">Open your terminal and run:</p>
|
||||
<code className="block bg-gray-100 dark:bg-gray-900 px-3 py-2 rounded text-sm text-pink-600 dark:text-pink-400 font-mono">
|
||||
gemini config set api_key YOUR_KEY
|
||||
</code>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-8 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<StandaloneShell
|
||||
project={project}
|
||||
command={getCommand()}
|
||||
onComplete={handleComplete}
|
||||
minimal={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Mic, Loader2, Brain } from 'lucide-react';
|
||||
import { transcribeWithWhisper } from '../utils/whisper';
|
||||
|
||||
export function MicButton({ onTranscript, className = '' }) {
|
||||
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
|
||||
const [error, setError] = useState(null);
|
||||
const [isSupported, setIsSupported] = useState(true);
|
||||
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const lastTapRef = useRef(0);
|
||||
|
||||
// Check microphone support on mount
|
||||
useEffect(() => {
|
||||
const checkSupport = () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
setIsSupported(false);
|
||||
setError('Microphone not supported. Please use HTTPS or a modern browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional check for secure context
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
setIsSupported(false);
|
||||
setError('Microphone requires HTTPS. Please use a secure connection.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSupported(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
checkSupport();
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
console.log('Starting recording...');
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
// Check if getUserMedia is available
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
console.log('Recording stopped, creating blob...');
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
|
||||
// Clean up stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Start transcribing
|
||||
setState('transcribing');
|
||||
|
||||
// Check if we're in an enhancement mode
|
||||
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
|
||||
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
|
||||
|
||||
// Set up a timer to switch to processing state for enhancement modes
|
||||
let processingTimer;
|
||||
if (isEnhancementMode) {
|
||||
processingTimer = setTimeout(() => {
|
||||
setState('processing');
|
||||
}, 2000); // Switch to processing after 2 seconds
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await transcribeWithWhisper(blob);
|
||||
if (text && onTranscript) {
|
||||
onTranscript(text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transcription error:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (processingTimer) {
|
||||
clearTimeout(processingTimer);
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
console.log('Recording started successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
|
||||
// Provide specific error messages based on error type
|
||||
let errorMessage = 'Microphone access failed';
|
||||
|
||||
if (err.name === 'NotAllowedError') {
|
||||
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
errorMessage = 'No microphone found. Please check your audio devices.';
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
errorMessage = 'Microphone not supported by this browser.';
|
||||
} else if (err.name === 'NotReadableError') {
|
||||
errorMessage = 'Microphone is being used by another application.';
|
||||
} else if (err.message.includes('HTTPS')) {
|
||||
errorMessage = err.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = () => {
|
||||
console.log('Stopping recording...');
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
// Don't set state here - let the onstop handler do it
|
||||
} else {
|
||||
// If recorder isn't in recording state, force cleanup
|
||||
console.log('Recorder not in recording state, forcing cleanup');
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle button click
|
||||
const handleClick = (e) => {
|
||||
// Prevent double firing on mobile
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Don't proceed if microphone is not supported
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce for mobile double-tap issue
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < 300) {
|
||||
console.log('Ignoring rapid tap');
|
||||
return;
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
console.log('Button clicked, current state:', state);
|
||||
|
||||
if (state === 'idle') {
|
||||
startRecording();
|
||||
} else if (state === 'recording') {
|
||||
stopRecording();
|
||||
}
|
||||
// Do nothing if transcribing or processing
|
||||
};
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Button appearance based on state
|
||||
const getButtonAppearance = () => {
|
||||
if (!isSupported) {
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-400 cursor-not-allowed',
|
||||
disabled: true
|
||||
};
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5 text-white" />,
|
||||
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
|
||||
disabled: false
|
||||
};
|
||||
case 'transcribing':
|
||||
return {
|
||||
icon: <Loader2 className="w-5 h-5 animate-spin" />,
|
||||
className: 'bg-blue-500 hover:bg-blue-600',
|
||||
disabled: true
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
icon: <Brain className="w-5 h-5 animate-pulse" />,
|
||||
className: 'bg-purple-500 hover:bg-purple-600',
|
||||
disabled: true
|
||||
};
|
||||
default: // idle
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-700 hover:bg-gray-600',
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, className: buttonClass, disabled } = getButtonAppearance();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
backgroundColor: state === 'recording' ? '#ef4444' :
|
||||
state === 'transcribing' ? '#3b82f6' :
|
||||
state === 'processing' ? '#a855f7' :
|
||||
'#374151'
|
||||
}}
|
||||
className={`
|
||||
flex items-center justify-center
|
||||
w-12 h-12 rounded-full
|
||||
text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||
dark:ring-offset-gray-800
|
||||
touch-action-manipulation
|
||||
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
|
||||
${state === 'recording' ? 'animate-pulse' : ''}
|
||||
hover:opacity-90
|
||||
${className}
|
||||
`}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
|
||||
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
|
||||
animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'recording' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause,
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { api } from '../utils/api';
|
||||
import Shell from './Shell';
|
||||
import Shell from './shell/view/Shell';
|
||||
import TaskDetail from './TaskDetail';
|
||||
|
||||
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ChevronRight, ChevronLeft, Check, GitBranch, User, Mail, LogIn, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import CodexLogo from './CodexLogo';
|
||||
import SessionProviderLogo from './llm-logo-provider/SessionProviderLogo';
|
||||
import LoginModal from './LoginModal';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -39,6 +37,13 @@ const Onboarding = ({ onComplete }) => {
|
||||
error: null
|
||||
});
|
||||
|
||||
const [geminiAuthStatus, setGeminiAuthStatus] = useState({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const prevActiveLoginProviderRef = useRef(undefined);
|
||||
@@ -71,22 +76,23 @@ const Onboarding = ({ onComplete }) => {
|
||||
checkClaudeAuthStatus();
|
||||
checkCursorAuthStatus();
|
||||
checkCodexAuthStatus();
|
||||
checkGeminiAuthStatus();
|
||||
}
|
||||
}, [activeLoginProvider]);
|
||||
|
||||
const checkClaudeAuthStatus = async () => {
|
||||
const checkProviderAuthStatus = async (provider, setter) => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/claude/status');
|
||||
const response = await authenticatedFetch(`/api/cli/${provider}/status`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setClaudeAuthStatus({
|
||||
setter({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setClaudeAuthStatus({
|
||||
setter({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
@@ -94,8 +100,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
setClaudeAuthStatus({
|
||||
console.error(`Error checking ${provider} auth status:`, error);
|
||||
setter({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
@@ -104,69 +110,15 @@ const Onboarding = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkCursorAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/cursor/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCursorAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
setCursorAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const checkCodexAuthStatus = async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cli/codex/status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCodexAuthStatus({
|
||||
authenticated: data.authenticated,
|
||||
email: data.email,
|
||||
loading: false,
|
||||
error: data.error || null
|
||||
});
|
||||
} else {
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: 'Failed to check authentication status'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
setCodexAuthStatus({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
loading: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
const checkClaudeAuthStatus = () => checkProviderAuthStatus('claude', setClaudeAuthStatus);
|
||||
const checkCursorAuthStatus = () => checkProviderAuthStatus('cursor', setCursorAuthStatus);
|
||||
const checkCodexAuthStatus = () => checkProviderAuthStatus('codex', setCodexAuthStatus);
|
||||
const checkGeminiAuthStatus = () => checkProviderAuthStatus('gemini', setGeminiAuthStatus);
|
||||
|
||||
const handleClaudeLogin = () => setActiveLoginProvider('claude');
|
||||
const handleCursorLogin = () => setActiveLoginProvider('cursor');
|
||||
const handleCodexLogin = () => setActiveLoginProvider('codex');
|
||||
const handleGeminiLogin = () => setActiveLoginProvider('gemini');
|
||||
|
||||
const handleLoginComplete = (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
@@ -176,6 +128,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
checkCursorAuthStatus();
|
||||
} else if (activeLoginProvider === 'codex') {
|
||||
checkCodexAuthStatus();
|
||||
} else if (activeLoginProvider === 'gemini') {
|
||||
checkGeminiAuthStatus();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -339,15 +293,14 @@ const Onboarding = ({ onComplete }) => {
|
||||
{/* Agent Cards Grid */}
|
||||
<div className="space-y-3">
|
||||
{/* Claude */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${claudeAuthStatus.authenticated
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center">
|
||||
<ClaudeLogo size={20} />
|
||||
<SessionProviderLogo provider="claude" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
@@ -356,7 +309,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{claudeAuthStatus.loading ? 'Checking...' :
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
claudeAuthStatus.authenticated ? claudeAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,15 +325,14 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
|
||||
{/* Cursor */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${cursorAuthStatus.authenticated
|
||||
? 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
|
||||
<CursorLogo size={20} />
|
||||
<SessionProviderLogo provider="cursor" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
@@ -389,7 +341,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cursorAuthStatus.loading ? 'Checking...' :
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
cursorAuthStatus.authenticated ? cursorAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -405,15 +357,14 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
|
||||
{/* Codex */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${
|
||||
codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className={`border rounded-lg p-4 transition-colors ${codexAuthStatus.authenticated
|
||||
? 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<CodexLogo className="w-5 h-5" />
|
||||
<SessionProviderLogo provider="codex" className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
@@ -422,7 +373,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{codexAuthStatus.loading ? 'Checking...' :
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
codexAuthStatus.authenticated ? codexAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,6 +387,38 @@ const Onboarding = ({ onComplete }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gemini */}
|
||||
<div className={`border rounded-lg p-4 transition-colors ${geminiAuthStatus.authenticated
|
||||
? 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800'
|
||||
: 'border-border bg-card'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-teal-100 dark:bg-teal-900/30 rounded-full flex items-center justify-center">
|
||||
<SessionProviderLogo provider="gemini" className="w-5 h-5 text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-foreground flex items-center gap-2">
|
||||
Gemini
|
||||
{geminiAuthStatus.authenticated && <Check className="w-4 h-4 text-green-500" />}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{geminiAuthStatus.loading ? 'Checking...' :
|
||||
geminiAuthStatus.authenticated ? geminiAuthStatus.email || 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!geminiAuthStatus.authenticated && !geminiAuthStatus.loading && (
|
||||
<button
|
||||
onClick={handleGeminiLogin}
|
||||
className="bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground pt-2">
|
||||
@@ -454,7 +437,7 @@ const Onboarding = ({ onComplete }) => {
|
||||
case 0:
|
||||
return gitName.trim() && gitEmail.trim() && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(gitEmail);
|
||||
case 1:
|
||||
return true;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -470,11 +453,10 @@ const Onboarding = ({ onComplete }) => {
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center border-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500 border-green-500 text-white' :
|
||||
index === currentStep ? 'bg-blue-600 border-blue-600 text-white' :
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
'bg-background border-border text-muted-foreground'
|
||||
}`}>
|
||||
{index < currentStep ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : typeof step.icon === 'function' ? (
|
||||
@@ -484,9 +466,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className={`text-sm font-medium ${
|
||||
index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
<p className={`text-sm font-medium ${index === currentStep ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
{step.required && (
|
||||
@@ -495,9 +476,8 @@ const Onboarding = ({ onComplete }) => {
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
<div className={`flex-1 h-0.5 mx-2 transition-colors duration-200 ${index < currentStep ? 'bg-green-500' : 'bg-border'
|
||||
}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
const SetupForm = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -48,7 +46,7 @@ const SetupForm = () => {
|
||||
{/* Logo and Title */}
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ClaudeLogo size={64} />
|
||||
<img src="/logo.svg" alt="CloudCLI" className="w-16 h-16" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Welcome to Claude Code UI</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
|
||||
@@ -1,692 +0,0 @@
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IS_PLATFORM } from '../constants/config';
|
||||
|
||||
const xtermStyles = `
|
||||
.xterm .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm:focus .xterm-screen {
|
||||
outline: none !important;
|
||||
}
|
||||
.xterm-screen:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.type = 'text/css';
|
||||
styleSheet.innerText = xtermStyles;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
function fallbackCopyToClipboard(text) {
|
||||
if (!text || typeof document === 'undefined') return false;
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
||||
|
||||
function isCodexLoginCommand(command) {
|
||||
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
||||
}
|
||||
|
||||
function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
const ws = useRef(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [lastSessionId, setLastSessionId] = useState(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState('');
|
||||
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle');
|
||||
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
||||
|
||||
const selectedProjectRef = useRef(selectedProject);
|
||||
const selectedSessionRef = useRef(selectedSession);
|
||||
const initialCommandRef = useRef(initialCommand);
|
||||
const isPlainShellRef = useRef(isPlainShell);
|
||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||
const authUrlRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
selectedProjectRef.current = selectedProject;
|
||||
selectedSessionRef.current = selectedSession;
|
||||
initialCommandRef.current = initialCommand;
|
||||
isPlainShellRef.current = isPlainShell;
|
||||
onProcessCompleteRef.current = onProcessComplete;
|
||||
});
|
||||
|
||||
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
const popup = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (popup) {
|
||||
try {
|
||||
popup.opener = null;
|
||||
} catch {
|
||||
// Ignore cross-origin restrictions when trying to null opener
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
||||
if (!url) return false;
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
}
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
copied = fallbackCopyToClipboard(url);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}, []);
|
||||
|
||||
const connectWebSocket = useCallback(async () => {
|
||||
if (isConnecting || isConnected) return;
|
||||
|
||||
try {
|
||||
let wsUrl;
|
||||
|
||||
if (IS_PLATFORM) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell`;
|
||||
} else {
|
||||
const token = localStorage.getItem('auth-token');
|
||||
if (!token) {
|
||||
console.error('No authentication token found for Shell WebSocket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
ws.current = new WebSocket(wsUrl);
|
||||
|
||||
ws.current.onopen = () => {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
fitAddon.current.fit();
|
||||
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'init',
|
||||
projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path,
|
||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id,
|
||||
hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current,
|
||||
provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommandRef.current,
|
||||
isPlainShell: isPlainShellRef.current
|
||||
}));
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
ws.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'output') {
|
||||
let output = data.data;
|
||||
|
||||
if (isPlainShellRef.current && onProcessCompleteRef.current) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessCompleteRef.current(0);
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessCompleteRef.current(exitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.write(output);
|
||||
}
|
||||
} else if (data.type === 'auth_url' && data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
} else if (data.type === 'url_open') {
|
||||
if (data.url) {
|
||||
authUrlRef.current = data.url;
|
||||
setAuthUrl(data.url);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Shell] Error handling WebSocket message:', error, event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onclose = (event) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
};
|
||||
|
||||
ws.current.onerror = (error) => {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
};
|
||||
} catch (error) {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [isConnecting, isConnected, openAuthUrlInBrowser]);
|
||||
|
||||
const connectToShell = useCallback(() => {
|
||||
if (!isInitialized || isConnected || isConnecting) return;
|
||||
setIsConnecting(true);
|
||||
connectWebSocket();
|
||||
}, [isInitialized, isConnected, isConnecting, connectWebSocket]);
|
||||
|
||||
const disconnectFromShell = useCallback(() => {
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.clear();
|
||||
terminal.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
}, []);
|
||||
|
||||
const sessionDisplayName = useMemo(() => {
|
||||
if (!selectedSession) return null;
|
||||
return selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
}, [selectedSession]);
|
||||
|
||||
const sessionDisplayNameShort = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 30);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
const sessionDisplayNameLong = useMemo(() => {
|
||||
if (!sessionDisplayName) return null;
|
||||
return sessionDisplayName.slice(0, 50);
|
||||
}, [sessionDisplayName]);
|
||||
|
||||
const restartShell = () => {
|
||||
setIsRestarting(true);
|
||||
|
||||
if (ws.current) {
|
||||
ws.current.close();
|
||||
ws.current = null;
|
||||
}
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
fitAddon.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsInitialized(false);
|
||||
authUrlRef.current = '';
|
||||
setAuthUrl('');
|
||||
setAuthUrlCopyStatus('idle');
|
||||
setIsAuthPanelHidden(false);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsRestarting(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const currentSessionId = selectedSession?.id || null;
|
||||
|
||||
if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) {
|
||||
disconnectFromShell();
|
||||
}
|
||||
|
||||
setLastSessionId(currentSessionId);
|
||||
}, [selectedSession?.id, isInitialized, disconnectFromShell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
terminal.current = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
allowTransparency: false,
|
||||
convertEol: true,
|
||||
scrollback: 10000,
|
||||
tabStopWidth: 4,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: true,
|
||||
macOptionClickForcesSelection: true,
|
||||
theme: {
|
||||
background: '#1e1e1e',
|
||||
foreground: '#d4d4d4',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#1e1e1e',
|
||||
selection: '#264f78',
|
||||
selectionForeground: '#ffffff',
|
||||
black: '#000000',
|
||||
red: '#cd3131',
|
||||
green: '#0dbc79',
|
||||
yellow: '#e5e510',
|
||||
blue: '#2472c8',
|
||||
magenta: '#bc3fbc',
|
||||
cyan: '#11a8cd',
|
||||
white: '#e5e5e5',
|
||||
brightBlack: '#666666',
|
||||
brightRed: '#f14c4c',
|
||||
brightGreen: '#23d18b',
|
||||
brightYellow: '#f5f543',
|
||||
brightBlue: '#3b8eea',
|
||||
brightMagenta: '#d670d6',
|
||||
brightCyan: '#29b8db',
|
||||
brightWhite: '#ffffff',
|
||||
extendedAnsi: [
|
||||
'#000000', '#800000', '#008000', '#808000',
|
||||
'#000080', '#800080', '#008080', '#c0c0c0',
|
||||
'#808080', '#ff0000', '#00ff00', '#ffff00',
|
||||
'#0000ff', '#ff00ff', '#00ffff', '#ffffff'
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
fitAddon.current = new FitAddon();
|
||||
const webglAddon = new WebglAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
// Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links.
|
||||
if (!minimal) {
|
||||
terminal.current.loadAddon(webLinksAddon);
|
||||
}
|
||||
// Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler
|
||||
|
||||
try {
|
||||
terminal.current.loadAddon(webglAddon);
|
||||
} catch (error) {
|
||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||
}
|
||||
|
||||
terminal.current.open(terminalRef.current);
|
||||
|
||||
terminal.current.attachCustomKeyEventHandler((event) => {
|
||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
: authUrlRef.current;
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
minimal &&
|
||||
isPlainShellRef.current &&
|
||||
activeAuthUrl &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
event.key?.toLowerCase() === 'c'
|
||||
) {
|
||||
copyAuthUrlToClipboard(activeAuthUrl).catch(() => {});
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'c' &&
|
||||
terminal.current.hasSelection()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === 'keydown' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key?.toLowerCase() === 'v'
|
||||
) {
|
||||
// Block native browser/xterm paste so clipboard data is only sent after
|
||||
// the explicit clipboard-read flow resolves (avoids duplicate pastes).
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
navigator.clipboard.readText().then(text => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: text
|
||||
}));
|
||||
}
|
||||
}).catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddon.current) {
|
||||
fitAddon.current.fit();
|
||||
if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setIsInitialized(true);
|
||||
terminal.current.onData((data) => {
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'input',
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon.current && terminal.current) {
|
||||
setTimeout(() => {
|
||||
fitAddon.current.fit();
|
||||
if (ws.current && ws.current.readyState === WebSocket.OPEN) {
|
||||
ws.current.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
}));
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
if (terminalRef.current) {
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
|
||||
if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) {
|
||||
ws.current.close();
|
||||
}
|
||||
ws.current = null;
|
||||
|
||||
if (terminal.current) {
|
||||
terminal.current.dispose();
|
||||
terminal.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoConnect || !isInitialized || isConnecting || isConnected) return;
|
||||
connectToShell();
|
||||
}, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]);
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('shell.selectProject.title')}</h3>
|
||||
<p>{t('shell.selectProject.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (minimal) {
|
||||
const displayAuthUrl = isCodexLoginCommand(initialCommand)
|
||||
? CODEX_DEVICE_AUTH_URL
|
||||
: authUrl;
|
||||
const hasAuthUrl = Boolean(displayAuthUrl);
|
||||
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
||||
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-900 relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
{showMobileAuthPanel && (
|
||||
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(true)}
|
||||
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={displayAuthUrl}
|
||||
readOnly
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
aria-label="Authentication URL"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
openAuthUrlInBrowser(displayAuthUrl);
|
||||
}}
|
||||
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Open URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
||||
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
||||
}}
|
||||
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
||||
>
|
||||
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showMobileAuthPanelToggle && (
|
||||
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAuthPanelHidden(false)}
|
||||
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
||||
>
|
||||
Show login URL
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-gray-900 w-full">
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
{selectedSession && (
|
||||
<span className="text-xs text-blue-300">
|
||||
({sessionDisplayNameShort}...)
|
||||
</span>
|
||||
)}
|
||||
{!selectedSession && (
|
||||
<span className="text-xs text-gray-400">{t('shell.status.newSession')}</span>
|
||||
)}
|
||||
{!isInitialized && (
|
||||
<span className="text-xs text-yellow-400">{t('shell.status.initializing')}</span>
|
||||
)}
|
||||
{isRestarting && (
|
||||
<span className="text-xs text-blue-400">{t('shell.status.restarting')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{isConnected && (
|
||||
<button
|
||||
onClick={disconnectFromShell}
|
||||
className="px-3 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 flex items-center space-x-1"
|
||||
title={t('shell.actions.disconnectTitle')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>{t('shell.actions.disconnect')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={restartShell}
|
||||
disabled={isRestarting || isConnected}
|
||||
className="text-xs text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
|
||||
title={t('shell.actions.restartTitle')}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{t('shell.actions.restart')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-2 overflow-hidden relative">
|
||||
<div ref={terminalRef} className="h-full w-full focus:outline-none" style={{ outline: 'none' }} />
|
||||
|
||||
{!isInitialized && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-white">{t('shell.loading')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isInitialized && !isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<button
|
||||
onClick={connectToShell}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||
title={t('shell.actions.connectTitle')}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>{t('shell.actions.connect')}</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
selectedSession ?
|
||||
t('shell.resumeSession', { displayName: sessionDisplayNameLong }) :
|
||||
t('shell.startSession')
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<span className="text-base font-medium">{t('shell.connecting')}</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{isPlainShell ?
|
||||
t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) :
|
||||
t('shell.startCli', { projectName: selectedProject.displayName })
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Shell;
|
||||
@@ -1,105 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import Shell from './Shell.jsx';
|
||||
|
||||
/**
|
||||
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||
* Provides a flexible API for both standalone and session-based usage.
|
||||
*
|
||||
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||
* @param {Object} session - Session object (optional, for tab usage)
|
||||
* @param {string} command - Initial command to run (optional)
|
||||
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||
* @param {function} onClose - Callback for close button (optional)
|
||||
* @param {string} title - Custom header title (optional)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||
* @param {boolean} compact - Use compact layout (default: false)
|
||||
* @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false)
|
||||
*/
|
||||
function StandaloneShell({
|
||||
project,
|
||||
session = null,
|
||||
command = null,
|
||||
isPlainShell = null,
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
title = null,
|
||||
className = "",
|
||||
showHeader = true,
|
||||
compact = false,
|
||||
minimal = false
|
||||
}) {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||
|
||||
const handleProcessComplete = useCallback((exitCode) => {
|
||||
setIsCompleted(true);
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
}, [onComplete]);
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
|
||||
<p>A project is required to open a shell</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{!minimal && showHeader && title && (
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||
{isCompleted && (
|
||||
<span className="text-xs text-green-400">(Completed)</span>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shell component wrapper */}
|
||||
<div className="flex-1 w-full min-h-0">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
minimal={minimal}
|
||||
autoConnect={minimal ? true : autoConnect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StandaloneShell;
|
||||
@@ -4,6 +4,7 @@ import { cn } from '../lib/utils';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const TaskDetail = ({
|
||||
task,
|
||||
@@ -79,7 +80,7 @@ const TaskDetail = ({
|
||||
};
|
||||
|
||||
const copyTaskId = () => {
|
||||
navigator.clipboard.writeText(task.id.toString());
|
||||
copyTextToClipboard(task.id.toString());
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import TaskCard from './TaskCard';
|
||||
import CreateTaskModal from './CreateTaskModal';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import Shell from './Shell';
|
||||
import Shell from './shell/view/Shell';
|
||||
import { api } from '../utils/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -32,6 +32,7 @@ const TaskList = ({
|
||||
const [showHelpGuide, setShowHelpGuide] = useState(false);
|
||||
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
|
||||
const [showPRDDropdown, setShowPRDDropdown] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { t } = useTranslation('tasks');
|
||||
@@ -39,7 +40,11 @@ const TaskList = ({
|
||||
// Close PRD dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (showPRDDropdown && !event.target.closest('.relative')) {
|
||||
if (
|
||||
showPRDDropdown &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target)
|
||||
) {
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
};
|
||||
@@ -48,6 +53,31 @@ const TaskList = ({
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showPRDDropdown]);
|
||||
|
||||
const loadPRDOptions = async (prd, closeDropdown = false) => {
|
||||
if (!currentProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
if (closeDropdown) {
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to load PRD:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Get unique status values from tasks
|
||||
const statuses = useMemo(() => {
|
||||
const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
|
||||
@@ -309,23 +339,8 @@ const TaskList = ({
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={async () => {
|
||||
try {
|
||||
// Load the PRD content from the API
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
} else {
|
||||
console.error('Failed to load PRD:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
onClick={() => {
|
||||
void loadPRDOptions(prd);
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
@@ -589,7 +604,7 @@ const TaskList = ({
|
||||
</button>
|
||||
|
||||
{/* PRD Management */}
|
||||
<div className="relative">
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{existingPRDs.length > 0 ? (
|
||||
// Dropdown when PRDs exist
|
||||
<div className="relative">
|
||||
@@ -624,21 +639,8 @@ const TaskList = ({
|
||||
{existingPRDs.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
|
||||
if (response.ok) {
|
||||
const prdData = await response.json();
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: prdData.content,
|
||||
isExisting: true
|
||||
});
|
||||
setShowPRDDropdown(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD:', error);
|
||||
}
|
||||
onClick={() => {
|
||||
void loadPRDOptions(prd, true);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
|
||||
title={t('prd.modified', { date: new Date(prd.modified).toLocaleDateString() })}
|
||||
@@ -1050,4 +1052,4 @@ const TaskList = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
export default TaskList;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api } from '../utils/api';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const TaskMasterSetupWizard = ({
|
||||
isOpen = true,
|
||||
@@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({
|
||||
}
|
||||
}
|
||||
}`;
|
||||
navigator.clipboard.writeText(mcpConfig);
|
||||
copyTextToClipboard(mcpConfig);
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
|
||||
@@ -1,109 +1,3 @@
|
||||
import { Zap } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TasksSettingsTab from './settings/view/tabs/tasks-settings/TasksSettingsTab';
|
||||
|
||||
function TasksSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isCheckingInstallation
|
||||
} = useTasksSettings();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Installation Status Check */}
|
||||
{isCheckingInstallation ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TaskMaster Not Installed Warning */}
|
||||
{!isTaskMasterInstalled && (
|
||||
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
|
||||
{t('tasks.notInstalled.title')}
|
||||
</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
|
||||
<p>{t('tasks.notInstalled.description')}</p>
|
||||
|
||||
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
|
||||
<code>{t('tasks.notInstalled.installCommand')}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('tasks.notInstalled.viewOnGitHub')}
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||
<li>{t('tasks.notInstalled.steps.restart')}</li>
|
||||
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
|
||||
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TaskMaster Settings */}
|
||||
{isTaskMasterInstalled && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
{t('tasks.settings.enableLabel')}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
{t('tasks.settings.enableDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tasksEnabled}
|
||||
onChange={(e) => setTasksEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TasksSettings;
|
||||
export default TasksSettingsTab;
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function AppContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-mobile-nav' : ''}`}>
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile ? 'pb-mobile-nav' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
|
||||
@@ -41,12 +41,14 @@ interface UseChatComposerStateArgs {
|
||||
cursorModel: string;
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
onSessionProcessing?: (sessionId?: string | null) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
onFileOpen?: (filePath: string, diffInfo?: unknown) => void;
|
||||
onShowSettings?: () => void;
|
||||
@@ -92,12 +94,14 @@ export function useChatComposerState({
|
||||
cursorModel,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
@@ -271,13 +275,14 @@ export function useChatComposerState({
|
||||
}, [setChatMessages]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (command: SlashCommand) => {
|
||||
async (command: SlashCommand, rawInput?: string) => {
|
||||
if (!command || !selectedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const commandMatch = input.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
|
||||
const effectiveInput = rawInput ?? input;
|
||||
const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\s*(.*)`));
|
||||
const args =
|
||||
commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\s+/) : [];
|
||||
|
||||
@@ -286,7 +291,7 @@ export function useChatComposerState({
|
||||
projectName: selectedProject.name,
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : claudeModel,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
@@ -340,6 +345,7 @@ export function useChatComposerState({
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
geminiModel,
|
||||
handleBuiltInCommand,
|
||||
handleCustomCommand,
|
||||
input,
|
||||
@@ -351,6 +357,7 @@ export function useChatComposerState({
|
||||
);
|
||||
|
||||
const {
|
||||
slashCommands,
|
||||
slashCommandsCount,
|
||||
filteredCommands,
|
||||
frequentCommands,
|
||||
@@ -473,6 +480,28 @@ export function useChatComposerState({
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept slash commands: if input starts with /commandName, execute as command with args
|
||||
const trimmedInput = currentInput.trim();
|
||||
if (trimmedInput.startsWith('/')) {
|
||||
const firstSpace = trimmedInput.indexOf(' ');
|
||||
const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;
|
||||
const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);
|
||||
if (matchedCommand) {
|
||||
executeCommand(matchedCommand, trimmedInput);
|
||||
setInput('');
|
||||
inputValueRef.current = '';
|
||||
setAttachedImages([]);
|
||||
setUploadingImages(new Map());
|
||||
setImageErrors(new Map());
|
||||
resetCommandMenuState();
|
||||
setIsTextareaExpanded(false);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let messageContent = currentInput;
|
||||
const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);
|
||||
if (selectedThinkingMode && selectedThinkingMode.prefix) {
|
||||
@@ -545,6 +574,9 @@ export function useChatComposerState({
|
||||
pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };
|
||||
}
|
||||
onSessionActive?.(sessionToActivate);
|
||||
if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {
|
||||
onSessionProcessing?.(effectiveSessionId);
|
||||
}
|
||||
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
@@ -552,8 +584,10 @@ export function useChatComposerState({
|
||||
provider === 'cursor'
|
||||
? 'cursor-tools-settings'
|
||||
: provider === 'codex'
|
||||
? 'codex-settings'
|
||||
: 'claude-settings';
|
||||
? 'codex-settings'
|
||||
: provider === 'gemini'
|
||||
? 'gemini-settings'
|
||||
: 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
@@ -601,6 +635,21 @@ export function useChatComposerState({
|
||||
permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,
|
||||
},
|
||||
});
|
||||
} else if (provider === 'gemini') {
|
||||
sendMessage({
|
||||
type: 'gemini-command',
|
||||
command: messageContent,
|
||||
sessionId: effectiveSessionId,
|
||||
options: {
|
||||
cwd: resolvedProjectPath,
|
||||
projectPath: resolvedProjectPath,
|
||||
sessionId: effectiveSessionId,
|
||||
resume: Boolean(effectiveSessionId),
|
||||
model: geminiModel,
|
||||
permissionMode,
|
||||
toolsSettings,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendMessage({
|
||||
type: 'claude-command',
|
||||
@@ -639,8 +688,11 @@ export function useChatComposerState({
|
||||
codexModel,
|
||||
currentSessionId,
|
||||
cursorModel,
|
||||
executeCommand,
|
||||
geminiModel,
|
||||
isLoading,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
pendingViewSessionRef,
|
||||
permissionMode,
|
||||
provider,
|
||||
@@ -654,6 +706,7 @@ export function useChatComposerState({
|
||||
setClaudeStatus,
|
||||
setIsLoading,
|
||||
setIsUserScrolledUp,
|
||||
slashCommands,
|
||||
thinkingMode,
|
||||
],
|
||||
);
|
||||
@@ -903,8 +956,11 @@ export function useChatComposerState({
|
||||
[sendMessage, setClaudeStatus, setPendingPermissionRequests],
|
||||
);
|
||||
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const handleInputFocusChange = useCallback(
|
||||
(focused: boolean) => {
|
||||
setIsInputFocused(focused);
|
||||
onInputFocusChange?.(focused);
|
||||
},
|
||||
[onInputFocusChange],
|
||||
@@ -953,5 +1009,6 @@ export function useChatComposerState({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS } from '../../../../shared/modelConstants';
|
||||
import { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';
|
||||
import type { PendingPermissionRequest, PermissionMode, Provider } from '../types/types';
|
||||
import type { ProjectSession, SessionProvider } from '../../../types/app';
|
||||
|
||||
@@ -23,6 +23,9 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
const [codexModel, setCodexModel] = useState<string>(() => {
|
||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||
});
|
||||
const [geminiModel, setGeminiModel] = useState<string>(() => {
|
||||
return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;
|
||||
});
|
||||
|
||||
const lastProviderRef = useRef(provider);
|
||||
|
||||
@@ -105,6 +108,8 @@ export function useChatProviderState({ selectedSession }: UseChatProviderStateAr
|
||||
setClaudeModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
permissionMode,
|
||||
setPermissionMode,
|
||||
pendingPermissionRequests,
|
||||
|
||||
@@ -145,6 +145,7 @@ export function useChatRealtimeHandlers({
|
||||
'claude-error',
|
||||
'cursor-error',
|
||||
'codex-error',
|
||||
'gemini-error',
|
||||
]);
|
||||
|
||||
const isClaudeSystemInit =
|
||||
@@ -162,8 +163,8 @@ export function useChatRealtimeHandlers({
|
||||
const systemInitSessionId = isClaudeSystemInit
|
||||
? structuredMessageData?.session_id
|
||||
: isCursorSystemInit
|
||||
? rawStructuredData?.session_id
|
||||
: null;
|
||||
? rawStructuredData?.session_id
|
||||
: null;
|
||||
|
||||
const activeViewSessionId =
|
||||
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
||||
@@ -176,7 +177,8 @@ export function useChatRealtimeHandlers({
|
||||
!pendingViewSessionRef.current.sessionId &&
|
||||
(latestMessage.type === 'claude-error' ||
|
||||
latestMessage.type === 'cursor-error' ||
|
||||
latestMessage.type === 'codex-error');
|
||||
latestMessage.type === 'codex-error' ||
|
||||
latestMessage.type === 'gemini-error');
|
||||
|
||||
const handleBackgroundLifecycle = (sessionId?: string) => {
|
||||
if (!sessionId) {
|
||||
@@ -225,12 +227,6 @@ export function useChatRealtimeHandlers({
|
||||
if (latestMessage.sessionId && lifecycleMessageTypes.has(String(latestMessage.type))) {
|
||||
handleBackgroundLifecycle(latestMessage.sessionId);
|
||||
}
|
||||
console.log(
|
||||
'Skipping message for different session:',
|
||||
latestMessage.sessionId,
|
||||
'current:',
|
||||
activeViewSessionId,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -297,11 +293,6 @@ export function useChatRealtimeHandlers({
|
||||
structuredMessageData.session_id !== currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('Claude CLI session duplication detected:', {
|
||||
originalSession: currentSessionId,
|
||||
newSession: structuredMessageData.session_id,
|
||||
});
|
||||
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(structuredMessageData.session_id);
|
||||
return;
|
||||
@@ -314,10 +305,6 @@ export function useChatRealtimeHandlers({
|
||||
!currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('New session init detected:', {
|
||||
newSession: structuredMessageData.session_id,
|
||||
});
|
||||
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(structuredMessageData.session_id);
|
||||
return;
|
||||
@@ -331,14 +318,47 @@ export function useChatRealtimeHandlers({
|
||||
structuredMessageData.session_id === currentSessionId &&
|
||||
isSystemInitForView
|
||||
) {
|
||||
console.log('System init message for current session, ignoring');
|
||||
return;
|
||||
}
|
||||
|
||||
if (structuredMessageData && Array.isArray(structuredMessageData.content)) {
|
||||
const parentToolUseId = rawStructuredData?.parentToolUseId;
|
||||
|
||||
structuredMessageData.content.forEach((part: any) => {
|
||||
if (part.type === 'tool_use') {
|
||||
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
|
||||
|
||||
// Check if this is a child tool from a subagent
|
||||
if (parentToolUseId) {
|
||||
setChatMessages((previous) =>
|
||||
previous.map((message) => {
|
||||
if (message.toolId === parentToolUseId && message.isSubagentContainer) {
|
||||
const childTool = {
|
||||
toolId: part.id,
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolResult: null,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const existingChildren = message.subagentState?.childTools || [];
|
||||
return {
|
||||
...message,
|
||||
subagentState: {
|
||||
childTools: [...existingChildren, childTool],
|
||||
currentToolIndex: existingChildren.length,
|
||||
isComplete: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a Task tool (subagent container)
|
||||
const isSubagentContainer = part.name === 'Task';
|
||||
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
@@ -350,6 +370,10 @@ export function useChatRealtimeHandlers({
|
||||
toolInput,
|
||||
toolId: part.id,
|
||||
toolResult: null,
|
||||
isSubagentContainer,
|
||||
subagentState: isSubagentContainer
|
||||
? { childTools: [], currentToolIndex: -1, isComplete: false }
|
||||
: undefined,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
@@ -382,6 +406,8 @@ export function useChatRealtimeHandlers({
|
||||
}
|
||||
|
||||
if (structuredMessageData?.role === 'user' && Array.isArray(structuredMessageData.content)) {
|
||||
const parentToolUseId = rawStructuredData?.parentToolUseId;
|
||||
|
||||
structuredMessageData.content.forEach((part: any) => {
|
||||
if (part.type !== 'tool_result') {
|
||||
return;
|
||||
@@ -389,8 +415,32 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
setChatMessages((previous) =>
|
||||
previous.map((message) => {
|
||||
if (message.isToolUse && message.toolId === part.tool_use_id) {
|
||||
// Handle child tool results (route to parent's subagentState)
|
||||
if (parentToolUseId && message.toolId === parentToolUseId && message.isSubagentContainer) {
|
||||
return {
|
||||
...message,
|
||||
subagentState: {
|
||||
...message.subagentState!,
|
||||
childTools: message.subagentState!.childTools.map((child) => {
|
||||
if (child.toolId === part.tool_use_id) {
|
||||
return {
|
||||
...child,
|
||||
toolResult: {
|
||||
content: part.content,
|
||||
isError: part.is_error,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return child;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Handle normal tool results (including parent Task tool completion)
|
||||
if (message.isToolUse && message.toolId === part.tool_use_id) {
|
||||
const result = {
|
||||
...message,
|
||||
toolResult: {
|
||||
content: part.content,
|
||||
@@ -398,6 +448,14 @@ export function useChatRealtimeHandlers({
|
||||
timestamp: new Date(),
|
||||
},
|
||||
};
|
||||
// Mark subagent as complete when parent Task receives its result
|
||||
if (message.isSubagentContainer && message.subagentState) {
|
||||
result.subagentState = {
|
||||
...message.subagentState,
|
||||
isComplete: true,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return message;
|
||||
}),
|
||||
@@ -511,17 +569,12 @@ export function useChatRealtimeHandlers({
|
||||
}
|
||||
|
||||
if (currentSessionId && cursorData.session_id !== currentSessionId) {
|
||||
console.log('Cursor session switch detected:', {
|
||||
originalSession: currentSessionId,
|
||||
newSession: cursorData.session_id,
|
||||
});
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(cursorData.session_id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSessionId) {
|
||||
console.log('Cursor new session init detected:', { newSession: cursorData.session_id });
|
||||
setIsSystemSessionChange(true);
|
||||
onNavigateToSession?.(cursorData.session_id);
|
||||
return;
|
||||
@@ -540,9 +593,8 @@ export function useChatRealtimeHandlers({
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: `Using tool: ${latestMessage.tool} ${
|
||||
latestMessage.input ? `with ${latestMessage.input}` : ''
|
||||
}`,
|
||||
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''
|
||||
}`,
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: latestMessage.tool,
|
||||
@@ -825,7 +877,6 @@ export function useChatRealtimeHandlers({
|
||||
onNavigateToSession?.(codexActualSessionId);
|
||||
}
|
||||
sessionStorage.removeItem('pendingSessionId');
|
||||
console.log('Codex session complete, ID set to:', codexPendingSessionId);
|
||||
}
|
||||
|
||||
if (selectedProject) {
|
||||
@@ -847,6 +898,91 @@ export function useChatRealtimeHandlers({
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'gemini-response': {
|
||||
const geminiData = latestMessage.data;
|
||||
|
||||
if (geminiData && geminiData.type === 'message' && typeof geminiData.content === 'string') {
|
||||
const content = decodeHtmlEntities(geminiData.content);
|
||||
|
||||
if (content) {
|
||||
streamBufferRef.current += streamBufferRef.current ? `\n${content}` : content;
|
||||
}
|
||||
|
||||
if (!geminiData.isPartial) {
|
||||
// Immediate flush and finalization for the last chunk
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current);
|
||||
streamTimerRef.current = null;
|
||||
}
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
|
||||
if (chunk) {
|
||||
appendStreamingChunk(setChatMessages, chunk, true);
|
||||
}
|
||||
finalizeStreamingMessage(setChatMessages);
|
||||
} else if (!streamTimerRef.current && streamBufferRef.current) {
|
||||
streamTimerRef.current = window.setTimeout(() => {
|
||||
const chunk = streamBufferRef.current;
|
||||
streamBufferRef.current = '';
|
||||
streamTimerRef.current = null;
|
||||
|
||||
if (chunk) {
|
||||
appendStreamingChunk(setChatMessages, chunk, true);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gemini-error':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'error',
|
||||
content: latestMessage.error || 'An error occurred with Gemini',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'gemini-tool-use':
|
||||
setChatMessages((previous) => [
|
||||
...previous,
|
||||
{
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
isToolUse: true,
|
||||
toolName: latestMessage.toolName,
|
||||
toolInput: latestMessage.parameters ? JSON.stringify(latestMessage.parameters, null, 2) : '',
|
||||
toolId: latestMessage.toolId,
|
||||
toolResult: null,
|
||||
}
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'gemini-tool-result':
|
||||
setChatMessages((previous) =>
|
||||
previous.map((message) => {
|
||||
if (message.isToolUse && message.toolId === latestMessage.toolId) {
|
||||
return {
|
||||
...message,
|
||||
toolResult: {
|
||||
content: latestMessage.output || `Status: ${latestMessage.status}`,
|
||||
isError: latestMessage.status === 'error',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
}),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'session-aborted': {
|
||||
const pendingSessionId =
|
||||
typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;
|
||||
@@ -884,12 +1020,26 @@ export function useChatRealtimeHandlers({
|
||||
|
||||
case 'session-status': {
|
||||
const statusSessionId = latestMessage.sessionId;
|
||||
if (!statusSessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const isCurrentSession =
|
||||
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
||||
if (isCurrentSession && latestMessage.isProcessing) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
|
||||
if (latestMessage.isProcessing) {
|
||||
onSessionProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
onSessionInactive?.(statusSessionId);
|
||||
onSessionNotProcessing?.(statusSessionId);
|
||||
if (isCurrentSession) {
|
||||
clearLoadingIndicators();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface UseSlashCommandsOptions {
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
textareaRef: RefObject<HTMLTextAreaElement>;
|
||||
onExecuteCommand: (command: SlashCommand) => void | Promise<void>;
|
||||
onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { getToolConfig } from './configs/toolConfigs';
|
||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent } from './components';
|
||||
import { OneLineDisplay, CollapsibleDisplay, DiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { SubagentChildTool } from '../types/types';
|
||||
|
||||
type DiffLine = {
|
||||
type: string;
|
||||
@@ -21,6 +22,12 @@ interface ToolRendererProps {
|
||||
autoExpandTools?: boolean;
|
||||
showRawParameters?: boolean;
|
||||
rawToolInput?: string;
|
||||
isSubagentContainer?: boolean;
|
||||
subagentState?: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function getToolCategory(toolName: string): string {
|
||||
@@ -50,8 +57,24 @@ export const ToolRenderer: React.FC<ToolRendererProps> = memo(({
|
||||
selectedProject,
|
||||
autoExpandTools = false,
|
||||
showRawParameters = false,
|
||||
rawToolInput
|
||||
rawToolInput,
|
||||
isSubagentContainer,
|
||||
subagentState
|
||||
}) => {
|
||||
// Route subagent containers to dedicated component
|
||||
if (isSubagentContainer && subagentState) {
|
||||
if (mode === 'result') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SubagentContainer
|
||||
toolInput={toolInput}
|
||||
toolResult={toolResult}
|
||||
subagentState={subagentState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const config = getToolConfig(toolName);
|
||||
const displayConfig: any = mode === 'input' ? config.input : config.result;
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<details className={`relative group/details ${className}`} open={open}>
|
||||
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none">
|
||||
<summary className="flex items-center gap-1.5 text-xs cursor-pointer py-0.5 select-none group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:bg-background group-open/details:-mx-1 group-open/details:px-1">
|
||||
<svg
|
||||
className="w-3 h-3 text-gray-400 dark:text-gray-500 transition-transform duration-150 group-open/details:rotate-90 flex-shrink-0"
|
||||
fill="none"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
|
||||
type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';
|
||||
|
||||
interface OneLineDisplayProps {
|
||||
|
||||
toolName: string;
|
||||
icon?: string;
|
||||
label?: string;
|
||||
@@ -25,52 +25,6 @@ interface OneLineDisplayProps {
|
||||
toolId?: string;
|
||||
}
|
||||
|
||||
// Fallback for environments where the async Clipboard API is unavailable or blocked.
|
||||
const copyWithLegacyExecCommand = (text: string): boolean => {
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
return copied;
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text: string): Promise<boolean> => {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.isSecureContext &&
|
||||
navigator.clipboard?.writeText
|
||||
) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back below when writeText is rejected (permissions/insecure contexts/browser limits).
|
||||
}
|
||||
}
|
||||
|
||||
return copyWithLegacyExecCommand(text);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified one-line display for simple tool inputs and results
|
||||
* Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.
|
||||
@@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC<OneLineDisplayProps> = ({
|
||||
border: 'border-gray-300 dark:border-gray-600',
|
||||
icon: 'text-gray-500 dark:text-gray-400'
|
||||
},
|
||||
resultId,
|
||||
toolResult,
|
||||
toolId
|
||||
}) => {
|
||||
|
||||
180
src/components/chat/tools/components/SubagentContainer.tsx
Normal file
180
src/components/chat/tools/components/SubagentContainer.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import type { SubagentChildTool } from '../../types/types';
|
||||
|
||||
interface SubagentContainerProps {
|
||||
toolInput: unknown;
|
||||
toolResult?: { content?: unknown; isError?: boolean } | null;
|
||||
subagentState: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {
|
||||
const input = typeof toolInput === 'string' ? (() => {
|
||||
try { return JSON.parse(toolInput); } catch { return {}; }
|
||||
})() : (toolInput || {});
|
||||
|
||||
switch (toolName) {
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'Edit':
|
||||
case 'ApplyPatch':
|
||||
return input.file_path?.split('/').pop() || input.file_path || '';
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return input.pattern || '';
|
||||
case 'Bash':
|
||||
const cmd = input.command || '';
|
||||
return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;
|
||||
case 'Task':
|
||||
return input.description || input.subagent_type || '';
|
||||
case 'WebFetch':
|
||||
case 'WebSearch':
|
||||
return input.url || input.query || '';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const SubagentContainer: React.FC<SubagentContainerProps> = ({
|
||||
toolInput,
|
||||
toolResult,
|
||||
subagentState,
|
||||
}) => {
|
||||
const parsedInput = typeof toolInput === 'string' ? (() => {
|
||||
try { return JSON.parse(toolInput); } catch { return {}; }
|
||||
})() : (toolInput || {});
|
||||
|
||||
const subagentType = parsedInput?.subagent_type || 'Agent';
|
||||
const description = parsedInput?.description || 'Running task';
|
||||
const prompt = parsedInput?.prompt || '';
|
||||
const { childTools, currentToolIndex, isComplete } = subagentState;
|
||||
const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;
|
||||
|
||||
const title = `Subagent / ${subagentType}: ${description}`;
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-l-purple-500 dark:border-l-purple-400 pl-3 py-0.5 my-1">
|
||||
<CollapsibleSection
|
||||
title={title}
|
||||
toolName="Task"
|
||||
open={false}
|
||||
>
|
||||
{/* Prompt/request to the subagent */}
|
||||
{prompt && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2 whitespace-pre-wrap break-words line-clamp-4">
|
||||
{prompt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current tool indicator (while running) */}
|
||||
{currentTool && !isComplete && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<span className="animate-pulse w-1.5 h-1.5 rounded-full bg-purple-500 dark:bg-purple-400 flex-shrink-0" />
|
||||
<span className="text-gray-400 dark:text-gray-500">Currently:</span>
|
||||
<span className="font-medium text-gray-600 dark:text-gray-300">{currentTool.toolName}</span>
|
||||
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (
|
||||
<>
|
||||
<span className="text-gray-300 dark:text-gray-600">/</span>
|
||||
<span className="font-mono truncate text-gray-500 dark:text-gray-400">
|
||||
{getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Completion status */}
|
||||
{isComplete && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool history (collapsed) */}
|
||||
{childTools.length > 0 && (
|
||||
<details className="mt-2 group/history">
|
||||
<summary className="cursor-pointer text-[11px] text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 flex items-center gap-1">
|
||||
<svg
|
||||
className="w-2.5 h-2.5 transition-transform duration-150 group-open/history:rotate-90 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span>View tool history ({childTools.length})</span>
|
||||
</summary>
|
||||
<div className="mt-1 pl-3 border-l border-gray-200 dark:border-gray-700 space-y-0.5">
|
||||
{childTools.map((child, index) => (
|
||||
<div key={child.toolId} className="flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
<span className="text-gray-400 dark:text-gray-500 w-4 text-right flex-shrink-0">{index + 1}.</span>
|
||||
<span className="font-medium">{child.toolName}</span>
|
||||
{getCompactToolDisplay(child.toolName, child.toolInput) && (
|
||||
<span className="font-mono truncate text-gray-400 dark:text-gray-500">
|
||||
{getCompactToolDisplay(child.toolName, child.toolInput)}
|
||||
</span>
|
||||
)}
|
||||
{child.toolResult?.isError && (
|
||||
<span className="text-red-500 flex-shrink-0">(error)</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Final result */}
|
||||
{isComplete && toolResult && (
|
||||
<div className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
{(() => {
|
||||
let content = toolResult.content;
|
||||
|
||||
// Handle JSON string that needs parsing
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (Array.isArray(parsed)) {
|
||||
// Extract text from array format like [{"type":"text","text":"..."}]
|
||||
const textParts = parsed
|
||||
.filter((p: any) => p.type === 'text' && p.text)
|
||||
.map((p: any) => p.text);
|
||||
if (textParts.length > 0) {
|
||||
content = textParts.join('\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, use as-is
|
||||
}
|
||||
} else if (Array.isArray(content)) {
|
||||
// Direct array format
|
||||
const textParts = content
|
||||
.filter((p: any) => p.type === 'text' && p.text)
|
||||
.map((p: any) => p.text);
|
||||
if (textParts.length > 0) {
|
||||
content = textParts.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return typeof content === 'string' ? (
|
||||
<div className="whitespace-pre-wrap break-words line-clamp-6">
|
||||
{content}
|
||||
</div>
|
||||
) : content ? (
|
||||
<pre className="whitespace-pre-wrap break-words line-clamp-6 font-mono text-[11px]">
|
||||
{JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,5 +2,6 @@ export { CollapsibleSection } from './CollapsibleSection';
|
||||
export { DiffViewer } from './DiffViewer';
|
||||
export { OneLineDisplay } from './OneLineDisplay';
|
||||
export { CollapsibleDisplay } from './CollapsibleDisplay';
|
||||
export { SubagentContainer } from './SubagentContainer';
|
||||
export * from './ContentRenderers';
|
||||
export * from './InteractiveRenderers';
|
||||
|
||||
@@ -383,7 +383,7 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
const description = input.description || 'Running task';
|
||||
return `Subagent / ${subagentType}: ${description}`;
|
||||
},
|
||||
defaultOpen: true,
|
||||
defaultOpen: false,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (input) => {
|
||||
// If only prompt exists (and required fields), show just the prompt
|
||||
@@ -424,14 +424,8 @@ export const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {
|
||||
},
|
||||
result: {
|
||||
type: 'collapsible',
|
||||
title: (result) => {
|
||||
// Check if result has content with type array (agent results often have this structure)
|
||||
if (result && result.content && Array.isArray(result.content)) {
|
||||
return 'Subagent Response';
|
||||
}
|
||||
return 'Subagent Result';
|
||||
},
|
||||
defaultOpen: true,
|
||||
title: 'Subagent result',
|
||||
defaultOpen: false,
|
||||
contentType: 'markdown',
|
||||
getContentProps: (result) => {
|
||||
// Handle agent results which may have complex structure
|
||||
|
||||
@@ -17,6 +17,14 @@ export interface ToolResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SubagentChildTool {
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
toolResult?: ToolResult | null;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
type: string;
|
||||
content?: string;
|
||||
@@ -32,6 +40,12 @@ export interface ChatMessage {
|
||||
toolResult?: ToolResult | null;
|
||||
toolId?: string;
|
||||
toolCallId?: string;
|
||||
isSubagentContainer?: boolean;
|
||||
subagentState?: {
|
||||
childTools: SubagentChildTool[];
|
||||
currentToolIndex: number;
|
||||
isComplete: boolean;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
const converted: ChatMessage[] = [];
|
||||
const toolResults = new Map<
|
||||
string,
|
||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown }
|
||||
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
|
||||
>();
|
||||
|
||||
rawMessages.forEach((message) => {
|
||||
@@ -368,6 +368,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: new Date(message.timestamp || Date.now()),
|
||||
toolUseResult: message.toolUseResult || null,
|
||||
subagentTools: message.subagentTools,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -484,6 +485,22 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
|
||||
if (part.type === 'tool_use') {
|
||||
const toolResult = toolResults.get(part.id);
|
||||
const isSubagentContainer = part.name === 'Task';
|
||||
|
||||
// Build child tools from server-provided subagentTools data
|
||||
const childTools: import('../types/types').SubagentChildTool[] = [];
|
||||
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
|
||||
for (const tool of toolResult.subagentTools as any[]) {
|
||||
childTools.push({
|
||||
toolId: tool.toolId,
|
||||
toolName: tool.toolName,
|
||||
toolInput: tool.toolInput,
|
||||
toolResult: tool.toolResult || null,
|
||||
timestamp: new Date(tool.timestamp || Date.now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
converted.push({
|
||||
type: 'assistant',
|
||||
content: '',
|
||||
@@ -491,6 +508,7 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
isToolUse: true,
|
||||
toolName: part.name,
|
||||
toolInput: normalizeToolInput(part.input),
|
||||
toolId: part.id,
|
||||
toolResult: toolResult
|
||||
? {
|
||||
content:
|
||||
@@ -503,6 +521,14 @@ export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
|
||||
: null,
|
||||
toolError: toolResult?.isError || false,
|
||||
toolResultTimestamp: toolResult?.timestamp || new Date(),
|
||||
isSubagentContainer,
|
||||
subagentState: isSubagentContainer
|
||||
? {
|
||||
childTools,
|
||||
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
|
||||
isComplete: Boolean(toolResult),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,6 +64,8 @@ function ChatInterface({
|
||||
setClaudeModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
permissionMode,
|
||||
pendingPermissionRequests,
|
||||
setPendingPermissionRequests,
|
||||
@@ -163,6 +165,7 @@ function ChatInterface({
|
||||
handlePermissionDecision,
|
||||
handleGrantToolPermission,
|
||||
handleInputFocusChange,
|
||||
isInputFocused,
|
||||
} = useChatComposerState({
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
@@ -173,12 +176,14 @@ function ChatInterface({
|
||||
cursorModel,
|
||||
claudeModel,
|
||||
codexModel,
|
||||
geminiModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
onSessionProcessing,
|
||||
onInputFocusChange,
|
||||
onFileOpen,
|
||||
onShowSettings,
|
||||
@@ -237,13 +242,6 @@ function ChatInterface({
|
||||
};
|
||||
}, [canAbortSession, handleAbortSession, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const processingSessionId = selectedSession?.id || currentSessionId;
|
||||
if (processingSessionId && isLoading && onSessionProcessing) {
|
||||
onSessionProcessing(processingSessionId);
|
||||
}
|
||||
}, [currentSessionId, isLoading, onSessionProcessing, selectedSession?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetStreamingState();
|
||||
@@ -256,7 +254,9 @@ function ChatInterface({
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: t('messageTypes.claude');
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: t('messageTypes.claude');
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -292,6 +292,8 @@ function ChatInterface({
|
||||
setCursorModel={setCursorModel}
|
||||
codexModel={codexModel}
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
@@ -373,13 +375,16 @@ function ChatInterface({
|
||||
onTextareaScrollSync={syncInputOverlayScroll}
|
||||
onTextareaInput={handleTextareaInput}
|
||||
onInputFocusChange={handleInputFocusChange}
|
||||
isInputFocused={isInputFocused}
|
||||
placeholder={t('input.placeholder', {
|
||||
provider:
|
||||
provider === 'cursor'
|
||||
? t('messageTypes.cursor')
|
||||
: provider === 'codex'
|
||||
? t('messageTypes.codex')
|
||||
: t('messageTypes.claude'),
|
||||
? t('messageTypes.codex')
|
||||
: provider === 'gemini'
|
||||
? t('messageTypes.gemini')
|
||||
: t('messageTypes.claude'),
|
||||
})}
|
||||
isTextareaExpanded={isTextareaExpanded}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionProvider } from '../../../../types/app';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type { Provider } from '../../types/types';
|
||||
|
||||
type AssistantThinkingIndicatorProps = {
|
||||
@@ -16,7 +16,7 @@ export default function AssistantThinkingIndicator({ selectedProvider }: Assista
|
||||
<SessionProviderLogo provider={selectedProvider} className="w-full h-full" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : 'Claude'}
|
||||
{selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import CommandMenu from '../../../CommandMenu';
|
||||
import ClaudeStatus from '../../../ClaudeStatus';
|
||||
import { MicButton } from '../../../MicButton.jsx';
|
||||
import CommandMenu from './CommandMenu';
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import ImageAttachment from './ImageAttachment';
|
||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||
import ChatInputControls from './ChatInputControls';
|
||||
@@ -87,6 +87,7 @@ interface ChatComposerProps {
|
||||
onTextareaScrollSync: (target: HTMLTextAreaElement) => void;
|
||||
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
|
||||
onInputFocusChange?: (focused: boolean) => void;
|
||||
isInputFocused?: boolean;
|
||||
placeholder: string;
|
||||
isTextareaExpanded: boolean;
|
||||
sendByCtrlEnter?: boolean;
|
||||
@@ -143,13 +144,13 @@ export default function ChatComposer({
|
||||
onTextareaScrollSync,
|
||||
onTextareaInput,
|
||||
onInputFocusChange,
|
||||
isInputFocused,
|
||||
placeholder,
|
||||
isTextareaExpanded,
|
||||
sendByCtrlEnter,
|
||||
onTranscript,
|
||||
}: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const AnyCommandMenu = CommandMenu as any;
|
||||
const textareaRect = textareaRef.current?.getBoundingClientRect();
|
||||
const commandMenuPosition = {
|
||||
top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,
|
||||
@@ -162,8 +163,13 @@ export default function ChatComposer({
|
||||
(r) => r.toolName === 'AskUserQuestion'
|
||||
);
|
||||
|
||||
// On mobile, when input is focused, float the input box at the bottom
|
||||
const mobileFloatingClass = isInputFocused
|
||||
? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6">
|
||||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 pb-2 sm:pb-4 md:pb-6 ${mobileFloatingClass}`}>
|
||||
{!hasQuestionPanel && (
|
||||
<div className="flex-1">
|
||||
<ClaudeStatus
|
||||
@@ -259,7 +265,7 @@ export default function ChatComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnyCommandMenu
|
||||
<CommandMenu
|
||||
commands={filteredCommands}
|
||||
selectedIndex={selectedCommandIndex}
|
||||
onSelect={onCommandSelect}
|
||||
|
||||
@@ -26,6 +26,8 @@ interface ChatMessagesPaneProps {
|
||||
setCursorModel: (model: string) => void;
|
||||
codexModel: string;
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -70,6 +72,8 @@ export default function ChatMessagesPane({
|
||||
setCursorModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -152,6 +156,8 @@ export default function ChatMessagesPane({
|
||||
setCursorModel={setCursorModel}
|
||||
codexModel={codexModel}
|
||||
setCodexModel={setCodexModel}
|
||||
geminiModel={geminiModel}
|
||||
setGeminiModel={setGeminiModel}
|
||||
tasksEnabled={tasksEnabled}
|
||||
isTaskMasterInstalled={isTaskMasterInstalled}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
|
||||
function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
can_interrupt?: boolean;
|
||||
} | null;
|
||||
onAbort?: () => void;
|
||||
isLoading: boolean;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const SPINNER_CHARS = ['*', '+', 'x', '.'];
|
||||
|
||||
export default function ClaudeStatus({
|
||||
status,
|
||||
onAbort,
|
||||
isLoading,
|
||||
provider: _provider = 'claude',
|
||||
}: ClaudeStatusProps) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
@@ -15,81 +33,76 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
// Calculate random token rate once (30-50 tokens per second)
|
||||
const tokenRate = 30 + Math.random() * 20;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
// Simulate token count increasing over time
|
||||
setFakeTokens(Math.floor(elapsed * tokenRate));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Animate the status indicator
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
if (!isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setAnimationPhase(prev => (prev + 1) % 4);
|
||||
const timer = window.setInterval(() => {
|
||||
setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
// Don't show if loading is false
|
||||
if (!isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note: showThinking only controls the reasoning accordion in messages, not this processing indicator
|
||||
if (!isLoading) return null;
|
||||
|
||||
// Clever action words that cycle
|
||||
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
|
||||
// Parse status data
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length;
|
||||
const statusText = status?.text || ACTION_WORDS[actionIndex];
|
||||
const tokens = status?.tokens || fakeTokens;
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
|
||||
// Animation characters
|
||||
const spinners = ['✻', '✹', '✸', '✶'];
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
const currentSpinner = SPINNER_CHARS[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-3 sm:mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-800 dark:bg-gray-900 text-white rounded-lg shadow-lg px-2.5 py-2 sm:px-4 sm:py-3 border border-gray-700 dark:border-gray-800">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-base sm:text-xl transition-all duration-500 flex-shrink-0",
|
||||
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-base sm:text-xl transition-all duration-500 flex-shrink-0',
|
||||
animationPhase % 2 === 0 ? 'text-blue-400 scale-110' : 'text-blue-300',
|
||||
)}
|
||||
>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - compact for mobile */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<span className="font-medium text-xs sm:text-sm truncate">{statusText}...</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm flex-shrink-0">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">⚒ {tokens.toLocaleString()}</span>
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-300 text-xs sm:text-sm hidden sm:inline flex-shrink-0">
|
||||
tokens {tokens.toLocaleString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 hidden sm:inline">·</span>
|
||||
<span className="text-gray-500 hidden sm:inline">|</span>
|
||||
<span className="text-gray-400 text-xs sm:text-sm hidden sm:inline">esc to stop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAbort}
|
||||
className="ml-2 sm:ml-3 text-xs bg-red-600 hover:bg-red-700 active:bg-red-800 text-white px-2 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1 sm:gap-1.5 flex-shrink-0 font-medium"
|
||||
>
|
||||
@@ -103,5 +116,3 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaudeStatus;
|
||||
224
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal file
224
src/components/chat/view/subcomponents/CommandMenu.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
type CommandMenuCommand = {
|
||||
name: string;
|
||||
description?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
type?: string;
|
||||
metadata?: { type?: string; [key: string]: unknown };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type CommandMenuProps = {
|
||||
commands?: CommandMenuCommand[];
|
||||
selectedIndex?: number;
|
||||
onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;
|
||||
onClose: () => void;
|
||||
position?: { top: number; left: number; bottom?: number };
|
||||
isOpen?: boolean;
|
||||
frequentCommands?: CommandMenuCommand[];
|
||||
};
|
||||
|
||||
const menuBaseStyle: CSSProperties = {
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
};
|
||||
|
||||
const namespaceLabels: Record<string, string> = {
|
||||
frequent: 'Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands',
|
||||
};
|
||||
|
||||
const namespaceIcons: Record<string, string> = {
|
||||
frequent: '[*]',
|
||||
builtin: '[B]',
|
||||
project: '[P]',
|
||||
user: '[U]',
|
||||
other: '[O]',
|
||||
};
|
||||
|
||||
const getCommandKey = (command: CommandMenuCommand) =>
|
||||
`${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;
|
||||
|
||||
const getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';
|
||||
|
||||
const getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { position: 'fixed', top: '16px', left: '16px' };
|
||||
}
|
||||
if (window.innerWidth < 640) {
|
||||
return {
|
||||
position: 'fixed',
|
||||
bottom: `${position.bottom ?? 90}px`,
|
||||
left: '16px',
|
||||
right: '16px',
|
||||
width: 'auto',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: 'min(50vh, 300px)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,
|
||||
left: `${position.left}px`,
|
||||
width: 'min(400px, calc(100vw - 32px))',
|
||||
maxWidth: 'calc(100vw - 32px)',
|
||||
maxHeight: '300px',
|
||||
};
|
||||
};
|
||||
|
||||
export default function CommandMenu({
|
||||
commands = [],
|
||||
selectedIndex = -1,
|
||||
onSelect,
|
||||
onClose,
|
||||
position = { top: 0, left: 0 },
|
||||
isOpen = false,
|
||||
frequentCommands = [],
|
||||
}: CommandMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuPosition = getMenuPosition(position);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!menuRef.current || !(event.target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (!menuRef.current.contains(event.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedItemRef.current || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
const menuRect = menuRef.current.getBoundingClientRect();
|
||||
const itemRect = selectedItemRef.current.getBoundingClientRect();
|
||||
if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {
|
||||
selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));
|
||||
const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {
|
||||
if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {
|
||||
return groups;
|
||||
}
|
||||
const namespace = getNamespace(command);
|
||||
if (!groups[namespace]) {
|
||||
groups[namespace] = [];
|
||||
}
|
||||
groups[namespace].push(command);
|
||||
return groups;
|
||||
}, {});
|
||||
if (hasFrequentCommands) {
|
||||
groupedCommands.frequent = frequentCommands;
|
||||
}
|
||||
|
||||
const preferredOrder = hasFrequentCommands
|
||||
? ['frequent', 'builtin', 'project', 'user', 'other']
|
||||
: ['builtin', 'project', 'user', 'other'];
|
||||
const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));
|
||||
const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);
|
||||
|
||||
const commandIndexByKey = new Map<string, number>();
|
||||
commands.forEach((command, index) => {
|
||||
const key = getCommandKey(command);
|
||||
if (!commandIndexByKey.has(key)) {
|
||||
commandIndexByKey.set(key, index);
|
||||
}
|
||||
});
|
||||
|
||||
if (commands.length === 0) {
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(groupedCommands[namespace] || []).map((command) => {
|
||||
const commandKey = getCommandKey(command);
|
||||
const commandIndex = commandIndexByKey.get(commandKey) ?? -1;
|
||||
const isSelected = commandIndex === selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}-${command.path || ''}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'
|
||||
}`}
|
||||
onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}
|
||||
onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>
|
||||
<span className="shrink-0 text-xs text-gray-500 dark:text-gray-300">{namespaceIcons[namespace] || namespaceIcons.other}</span>
|
||||
<span className="font-mono text-sm font-semibold text-gray-900 dark:text-gray-100">{command.name}</span>
|
||||
{command.metadata?.type && (
|
||||
<span className="command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300">
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{command.description && (
|
||||
<div className="ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300">
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <span className="ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300">{'<-'}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { normalizeInlineCodeFences } from '../../utils/chatFormatting';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
|
||||
type MarkdownProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -31,9 +32,8 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
||||
if (shouldInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
|
||||
className || ''
|
||||
}`}
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''
|
||||
}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -43,43 +43,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : 'text';
|
||||
const textToCopy = raw;
|
||||
|
||||
const handleCopy = () => {
|
||||
const doSet = () => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
try {
|
||||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
});
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = textToCopy;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
doSet();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
@@ -89,7 +52,14 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
onClick={() =>
|
||||
copyTextToClipboard(raw).then((success) => {
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
})
|
||||
}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ClaudePermissionSuggestion,
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { Markdown } from './Markdown';
|
||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||
|
||||
@@ -45,14 +46,15 @@ type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
|
||||
const [messageCopied, setMessageCopied] = React.useState(false);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -100,7 +102,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
{message.type === 'user' ? (
|
||||
/* User message bubble on the right */
|
||||
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||||
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
|
||||
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial group">
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
@@ -117,8 +119,45 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-blue-100 mt-1 text-right">
|
||||
{formattedTime}
|
||||
<div className="flex items-center justify-end gap-1 mt-1 text-xs text-blue-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const text = String(message.content || '');
|
||||
if (!text) return;
|
||||
|
||||
copyTextToClipboard(text).then((success) => {
|
||||
if (!success) return;
|
||||
setMessageCopied(true);
|
||||
});
|
||||
}}
|
||||
title={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
|
||||
aria-label={messageCopied ? t('copyMessage.copied') : t('copyMessage.copy')}
|
||||
>
|
||||
{messageCopied ? (
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<span>{formattedTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isGrouped && (
|
||||
@@ -154,11 +193,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
|
||||
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
{message.isToolUse ? (
|
||||
@@ -184,9 +223,11 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}
|
||||
isSubagentContainer={message.isSubagentContainer}
|
||||
subagentState={message.subagentState}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Tool Result Section */}
|
||||
{message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (
|
||||
message.toolResult.isError ? (
|
||||
@@ -220,11 +261,10 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
}
|
||||
}}
|
||||
disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${
|
||||
permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
|
||||
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? 'bg-green-100 dark:bg-green-900/30 border-green-300/70 dark:border-green-800/60 text-green-800 dark:text-green-200 cursor-default'
|
||||
: 'bg-white/80 dark:bg-gray-900/40 border-red-300/70 dark:border-red-800/60 text-red-700 dark:text-red-200 hover:bg-white dark:hover:bg-gray-900/70'
|
||||
}`}
|
||||
>
|
||||
{permissionSuggestion.isAllowed || permissionGrantState === 'granted'
|
||||
? t('permissions.added')
|
||||
@@ -292,7 +332,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
const lines = (message.content || '').split('\n').filter((line) => line.trim());
|
||||
const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';
|
||||
const options: InteractiveOption[] = [];
|
||||
|
||||
|
||||
// Parse the menu options
|
||||
lines.forEach((line) => {
|
||||
// Match lines like "❯ 1. Yes" or " 2. No"
|
||||
@@ -306,31 +346,29 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||
{questionLine}
|
||||
</p>
|
||||
|
||||
|
||||
{/* Option buttons */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.number}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
disabled
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
{option.number}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium flex-1">
|
||||
@@ -343,7 +381,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||
{t('interactive.waiting')}
|
||||
@@ -397,7 +435,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
// Detect if content is pure JSON (starts with { or [)
|
||||
const trimmedContent = content.trim();
|
||||
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
|
||||
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
||||
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent);
|
||||
const formatted = JSON.stringify(parsed, null, 2);
|
||||
@@ -437,7 +475,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!isGrouped && (
|
||||
<div className="text-[11px] text-gray-400 dark:text-gray-500 mt-1">
|
||||
{formattedTime}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SessionProviderLogo from '../../../SessionProviderLogo';
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import NextTaskBanner from '../../../NextTaskBanner.jsx';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../../../../shared/modelConstants';
|
||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
|
||||
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
||||
|
||||
interface ProviderSelectionEmptyStateProps {
|
||||
@@ -18,6 +18,8 @@ interface ProviderSelectionEmptyStateProps {
|
||||
setCursorModel: (model: string) => void;
|
||||
codexModel: string;
|
||||
setCodexModel: (model: string) => void;
|
||||
geminiModel: string;
|
||||
setGeminiModel: (model: string) => void;
|
||||
tasksEnabled: boolean;
|
||||
isTaskMasterInstalled: boolean | null;
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
@@ -58,17 +60,27 @@ const PROVIDERS: ProviderDef[] = [
|
||||
ring: 'ring-emerald-600/15',
|
||||
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
infoKey: 'providerSelection.providerInfo.google',
|
||||
accent: 'border-blue-500 dark:border-blue-400',
|
||||
ring: 'ring-blue-500/15',
|
||||
check: 'bg-blue-500 text-white',
|
||||
},
|
||||
];
|
||||
|
||||
function getModelConfig(p: SessionProvider) {
|
||||
if (p === 'claude') return CLAUDE_MODELS;
|
||||
if (p === 'codex') return CODEX_MODELS;
|
||||
if (p === 'gemini') return GEMINI_MODELS;
|
||||
return CURSOR_MODELS;
|
||||
}
|
||||
|
||||
function getModelValue(p: SessionProvider, c: string, cu: string, co: string) {
|
||||
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
|
||||
if (p === 'claude') return c;
|
||||
if (p === 'codex') return co;
|
||||
if (p === 'gemini') return g;
|
||||
return cu;
|
||||
}
|
||||
|
||||
@@ -84,6 +96,8 @@ export default function ProviderSelectionEmptyState({
|
||||
setCursorModel,
|
||||
codexModel,
|
||||
setCodexModel,
|
||||
geminiModel,
|
||||
setGeminiModel,
|
||||
tasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
onShowAllTasks,
|
||||
@@ -101,11 +115,12 @@ export default function ProviderSelectionEmptyState({
|
||||
const handleModelChange = (value: string) => {
|
||||
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
|
||||
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
|
||||
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
|
||||
else { setCursorModel(value); localStorage.setItem('cursor-model', value); }
|
||||
};
|
||||
|
||||
const modelConfig = getModelConfig(provider);
|
||||
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel);
|
||||
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
|
||||
|
||||
/* ── New session — provider picker ── */
|
||||
if (!selectedSession && !currentSessionId) {
|
||||
@@ -123,7 +138,7 @@ export default function ProviderSelectionEmptyState({
|
||||
</div>
|
||||
|
||||
{/* Provider cards — horizontal row, equal width */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-2.5 mb-6">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-2.5 mb-6">
|
||||
{PROVIDERS.map((p) => {
|
||||
const active = provider === p.id;
|
||||
return (
|
||||
@@ -179,13 +194,14 @@ export default function ProviderSelectionEmptyState({
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground/70">
|
||||
{provider === 'claude'
|
||||
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
|
||||
: provider === 'cursor'
|
||||
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
|
||||
: provider === 'codex'
|
||||
? t('providerSelection.readyPrompt.codex', { model: codexModel })
|
||||
: t('providerSelection.readyPrompt.default')}
|
||||
{
|
||||
{
|
||||
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
|
||||
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
|
||||
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
|
||||
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
|
||||
}[provider]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
17
src/components/code-editor/constants/settings.ts
Normal file
17
src/components/code-editor/constants/settings.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const CODE_EDITOR_STORAGE_KEYS = {
|
||||
theme: 'codeEditorTheme',
|
||||
wordWrap: 'codeEditorWordWrap',
|
||||
showMinimap: 'codeEditorShowMinimap',
|
||||
lineNumbers: 'codeEditorLineNumbers',
|
||||
fontSize: 'codeEditorFontSize',
|
||||
} as const;
|
||||
|
||||
export const CODE_EDITOR_DEFAULTS = {
|
||||
isDarkMode: true,
|
||||
wordWrap: false,
|
||||
minimapEnabled: true,
|
||||
showLineNumbers: true,
|
||||
fontSize: '12',
|
||||
} as const;
|
||||
|
||||
export const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';
|
||||
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal file
126
src/components/code-editor/hooks/useCodeEditorDocument.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
|
||||
type UseCodeEditorDocumentParams = {
|
||||
file: CodeEditorFile;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return String(error);
|
||||
};
|
||||
|
||||
export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const fileProjectName = file.projectName ?? projectPath;
|
||||
const filePath = file.path;
|
||||
const fileName = file.name;
|
||||
const fileDiffNewString = file.diffInfo?.new_string;
|
||||
const fileDiffOldString = file.diffInfo?.old_string;
|
||||
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Diff payload may already include full old/new snapshots, so avoid disk read.
|
||||
if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {
|
||||
setContent(fileDiffNewString);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileProjectName) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.readFile(fileProjectName, filePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setContent(data.content);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error('Error loading file:', error);
|
||||
setContent(`// Error loading file: ${message}\n// File: ${fileName}\n// Path: ${filePath}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (!fileProjectName) {
|
||||
throw new Error('Missing project identifier');
|
||||
}
|
||||
|
||||
const response = await api.saveFile(fileProjectName, filePath, content);
|
||||
|
||||
if (!response.ok) {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Save failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const textError = await response.text();
|
||||
console.error('Non-JSON error response:', textError);
|
||||
throw new Error(`Save failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
await response.json();
|
||||
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
console.error('Error saving file:', error);
|
||||
setSaveError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, fileProjectName]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
|
||||
anchor.href = url;
|
||||
anchor.download = file.name;
|
||||
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}, [content, file.name]);
|
||||
|
||||
return {
|
||||
content,
|
||||
setContent,
|
||||
loading,
|
||||
saving,
|
||||
saveSuccess,
|
||||
saveError,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
};
|
||||
};
|
||||
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal file
85
src/components/code-editor/hooks/useCodeEditorSettings.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
CODE_EDITOR_DEFAULTS,
|
||||
CODE_EDITOR_SETTINGS_CHANGED_EVENT,
|
||||
CODE_EDITOR_STORAGE_KEYS,
|
||||
} from '../constants/settings';
|
||||
|
||||
const readTheme = () => {
|
||||
const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);
|
||||
if (!savedTheme) {
|
||||
return CODE_EDITOR_DEFAULTS.isDarkMode;
|
||||
}
|
||||
|
||||
return savedTheme === 'dark';
|
||||
};
|
||||
|
||||
const readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {
|
||||
const value = localStorage.getItem(storageKey);
|
||||
if (value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return value !== falseValue;
|
||||
};
|
||||
|
||||
const readWordWrap = () => {
|
||||
return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';
|
||||
};
|
||||
|
||||
const readFontSize = () => {
|
||||
const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);
|
||||
return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);
|
||||
};
|
||||
|
||||
export const useCodeEditorSettings = () => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(readTheme);
|
||||
const [wordWrap, setWordWrap] = useState(readWordWrap);
|
||||
const [minimapEnabled, setMinimapEnabled] = useState(() => (
|
||||
readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)
|
||||
));
|
||||
const [showLineNumbers, setShowLineNumbers] = useState(() => (
|
||||
readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)
|
||||
));
|
||||
const [fontSize, setFontSize] = useState(readFontSize);
|
||||
|
||||
// Keep legacy behavior where the editor writes theme and wrap settings directly.
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));
|
||||
}, [wordWrap]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshFromStorage = () => {
|
||||
setIsDarkMode(readTheme());
|
||||
setWordWrap(readWordWrap());
|
||||
setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));
|
||||
setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));
|
||||
setFontSize(readFontSize());
|
||||
};
|
||||
|
||||
window.addEventListener('storage', refreshFromStorage);
|
||||
window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', refreshFromStorage);
|
||||
window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
wordWrap,
|
||||
setWordWrap,
|
||||
minimapEnabled,
|
||||
setMinimapEnabled,
|
||||
showLineNumbers,
|
||||
setShowLineNumbers,
|
||||
fontSize,
|
||||
setFontSize,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type UseEditorKeyboardShortcutsParams = {
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
dependency: string;
|
||||
};
|
||||
|
||||
export const useEditorKeyboardShortcuts = ({
|
||||
onSave,
|
||||
onClose,
|
||||
dependency,
|
||||
}: UseEditorKeyboardShortcutsParams) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(event.ctrlKey || event.metaKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [dependency, onClose, onSave]);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { DiffInfo, EditingFile } from '../types/types';
|
||||
import type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';
|
||||
|
||||
type UseEditorSidebarOptions = {
|
||||
selectedProject: Project | null;
|
||||
@@ -9,19 +9,20 @@ type UseEditorSidebarOptions = {
|
||||
initialWidth?: number;
|
||||
};
|
||||
|
||||
export function useEditorSidebar({
|
||||
export const useEditorSidebar = ({
|
||||
selectedProject,
|
||||
isMobile,
|
||||
initialWidth = 600,
|
||||
}: UseEditorSidebarOptions) {
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
}: UseEditorSidebarOptions) => {
|
||||
const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);
|
||||
const [editorWidth, setEditorWidth] = useState(initialWidth);
|
||||
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [hasManualWidth, setHasManualWidth] = useState(false);
|
||||
const resizeHandleRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleFileOpen = useCallback(
|
||||
(filePath: string, diffInfo: DiffInfo | null = null) => {
|
||||
(filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const fileName = normalizedPath.split('/').pop() || filePath;
|
||||
|
||||
@@ -41,7 +42,7 @@ export function useEditorSidebar({
|
||||
}, []);
|
||||
|
||||
const handleToggleEditorExpand = useCallback(() => {
|
||||
setEditorExpanded((prev) => !prev);
|
||||
setEditorExpanded((previous) => !previous);
|
||||
}, []);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
@@ -50,6 +51,8 @@ export function useEditorSidebar({
|
||||
return;
|
||||
}
|
||||
|
||||
// After first drag interaction, the editor width is user-controlled.
|
||||
setHasManualWidth(true);
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
},
|
||||
@@ -101,10 +104,11 @@ export function useEditorSidebar({
|
||||
editingFile,
|
||||
editorWidth,
|
||||
editorExpanded,
|
||||
hasManualWidth,
|
||||
resizeHandleRef,
|
||||
handleFileOpen,
|
||||
handleCloseEditor,
|
||||
handleToggleEditorExpand,
|
||||
handleResizeStart,
|
||||
};
|
||||
}
|
||||
};
|
||||
21
src/components/code-editor/types/types.ts
Normal file
21
src/components/code-editor/types/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type CodeEditorDiffInfo = {
|
||||
old_string?: string;
|
||||
new_string?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CodeEditorFile = {
|
||||
name: string;
|
||||
path: string;
|
||||
projectName?: string;
|
||||
diffInfo?: CodeEditorDiffInfo | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CodeEditorSettingsState = {
|
||||
isDarkMode: boolean;
|
||||
wordWrap: boolean;
|
||||
minimapEnabled: boolean;
|
||||
showLineNumbers: boolean;
|
||||
fontSize: string;
|
||||
};
|
||||
141
src/components/code-editor/utils/editorExtensions.ts
Normal file
141
src/components/code-editor/utils/editorExtensions.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { getChunks } from '@codemirror/merge';
|
||||
import { EditorView, ViewPlugin } from '@codemirror/view';
|
||||
import { showMinimap } from '@replit/codemirror-minimap';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
|
||||
// Lightweight lexer for `.env` files (including `.env.*` variants).
|
||||
const envLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
if (stream.match(/^#.*/)) return 'comment';
|
||||
if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';
|
||||
if (stream.match(/^=/)) return 'operator';
|
||||
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string';
|
||||
if (stream.match(/^'(?:[^'\\]|\\.)*'?/)) return 'string';
|
||||
if (stream.match(/^\$\{[^}]*\}?/)) return 'variableName.special';
|
||||
if (stream.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';
|
||||
if (stream.match(/^\d+/)) return 'number';
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const getLanguageExtensions = (filename: string) => {
|
||||
const lowerName = filename.toLowerCase();
|
||||
if (lowerName === '.env' || lowerName.startsWith('.env.')) {
|
||||
return [envLanguage];
|
||||
}
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return [javascript({ jsx: true, typescript: ext.includes('ts') })];
|
||||
case 'py':
|
||||
return [python()];
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return [html()];
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return [css()];
|
||||
case 'json':
|
||||
return [json()];
|
||||
case 'md':
|
||||
case 'markdown':
|
||||
return [markdown()];
|
||||
case 'env':
|
||||
return [envLanguage];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const createMinimapExtension = ({
|
||||
file,
|
||||
showDiff,
|
||||
minimapEnabled,
|
||||
isDarkMode,
|
||||
}: {
|
||||
file: CodeEditorFile;
|
||||
showDiff: boolean;
|
||||
minimapEnabled: boolean;
|
||||
isDarkMode: boolean;
|
||||
}) => {
|
||||
if (!file.diffInfo || !showDiff || !minimapEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const gutters: Record<number, string> = {};
|
||||
|
||||
return [
|
||||
showMinimap.compute(['doc'], (state) => {
|
||||
const chunksData = getChunks(state);
|
||||
const chunks = chunksData?.chunks || [];
|
||||
|
||||
Object.keys(gutters).forEach((key) => {
|
||||
delete gutters[Number(key)];
|
||||
});
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const fromLine = state.doc.lineAt(chunk.fromB).number;
|
||||
const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;
|
||||
|
||||
for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {
|
||||
gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
create: () => ({ dom: document.createElement('div') }),
|
||||
displayText: 'blocks',
|
||||
showOverlay: 'always',
|
||||
gutters: [gutters],
|
||||
};
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
export const createScrollToFirstChunkExtension = ({
|
||||
file,
|
||||
showDiff,
|
||||
}: {
|
||||
file: CodeEditorFile;
|
||||
showDiff: boolean;
|
||||
}) => {
|
||||
if (!file.diffInfo || !showDiff) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
ViewPlugin.fromClass(class {
|
||||
constructor(view: EditorView) {
|
||||
// Wait for merge decorations so the first chunk location is stable.
|
||||
setTimeout(() => {
|
||||
const chunksData = getChunks(view.state);
|
||||
const firstChunk = chunksData?.chunks?.[0];
|
||||
|
||||
if (firstChunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
update() {}
|
||||
|
||||
destroy() {}
|
||||
}),
|
||||
];
|
||||
};
|
||||
79
src/components/code-editor/utils/editorStyles.ts
Normal file
79
src/components/code-editor/utils/editorStyles.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export const getEditorLoadingStyles = (isDarkMode: boolean) => {
|
||||
return `
|
||||
.code-editor-loading {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
|
||||
.code-editor-loading:hover {
|
||||
background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
export const getEditorStyles = (isDarkMode: boolean) => {
|
||||
return `
|
||||
.cm-deletedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.cm-insertedChunk {
|
||||
background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;
|
||||
border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;
|
||||
padding-left: 4px !important;
|
||||
}
|
||||
|
||||
.cm-editor.cm-merge-b .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
.cm-editor .cm-deletedChunk .cm-changedText {
|
||||
background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;
|
||||
padding-top: 2px !important;
|
||||
padding-bottom: 2px !important;
|
||||
margin-top: -2px !important;
|
||||
margin-bottom: -2px !important;
|
||||
}
|
||||
|
||||
.cm-gutter.cm-gutter-minimap {
|
||||
background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};
|
||||
}
|
||||
|
||||
.cm-editor-toolbar-panel {
|
||||
padding: 4px 10px;
|
||||
background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};
|
||||
border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};
|
||||
color: ${isDarkMode ? '#d1d5db' : '#374151'};
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn,
|
||||
.cm-toolbar-btn {
|
||||
padding: 3px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:hover,
|
||||
.cm-toolbar-btn:hover {
|
||||
background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};
|
||||
}
|
||||
|
||||
.cm-diff-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
};
|
||||
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal file
212
src/components/code-editor/utils/editorToolbarPanel.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { getChunks } from '@codemirror/merge';
|
||||
import { EditorView, showPanel } from '@codemirror/view';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
|
||||
type EditorToolbarLabels = {
|
||||
changes: string;
|
||||
previousChange: string;
|
||||
nextChange: string;
|
||||
hideDiff: string;
|
||||
showDiff: string;
|
||||
collapse: string;
|
||||
expand: string;
|
||||
};
|
||||
|
||||
type CreateEditorToolbarPanelParams = {
|
||||
file: CodeEditorFile;
|
||||
showDiff: boolean;
|
||||
isSidebar: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleDiff: () => void;
|
||||
onPopOut: (() => void) | null;
|
||||
onToggleExpand: (() => void) | null;
|
||||
labels: EditorToolbarLabels;
|
||||
};
|
||||
|
||||
const getDiffVisibilityIcon = (showDiff: boolean) => {
|
||||
if (showDiff) {
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />';
|
||||
}
|
||||
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />';
|
||||
};
|
||||
|
||||
const getExpandIcon = (isExpanded: boolean) => {
|
||||
if (isExpanded) {
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />';
|
||||
}
|
||||
|
||||
return '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />';
|
||||
};
|
||||
|
||||
const escapeHtml = (value: string): string => (
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
);
|
||||
|
||||
export const createEditorToolbarPanelExtension = ({
|
||||
file,
|
||||
showDiff,
|
||||
isSidebar,
|
||||
isExpanded,
|
||||
onToggleDiff,
|
||||
onPopOut,
|
||||
onToggleExpand,
|
||||
labels,
|
||||
}: CreateEditorToolbarPanelParams) => {
|
||||
const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));
|
||||
if (!hasToolbarButtons) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const createPanel = (view: EditorView) => {
|
||||
const dom = document.createElement('div');
|
||||
dom.className = 'cm-editor-toolbar-panel';
|
||||
|
||||
let currentIndex = 0;
|
||||
|
||||
const updatePanel = () => {
|
||||
const hasDiff = Boolean(file.diffInfo && showDiff);
|
||||
const chunksData = hasDiff ? getChunks(view.state) : null;
|
||||
const chunks = chunksData?.chunks || [];
|
||||
const chunkCount = chunks.length;
|
||||
const maxChunkIndex = Math.max(0, chunkCount - 1);
|
||||
currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));
|
||||
const escapedLabels = {
|
||||
changes: escapeHtml(labels.changes),
|
||||
previousChange: escapeHtml(labels.previousChange),
|
||||
nextChange: escapeHtml(labels.nextChange),
|
||||
hideDiff: escapeHtml(labels.hideDiff),
|
||||
showDiff: escapeHtml(labels.showDiff),
|
||||
collapse: escapeHtml(labels.collapse),
|
||||
expand: escapeHtml(labels.expand),
|
||||
};
|
||||
// Icons are static SVG path fragments controlled by this module.
|
||||
const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);
|
||||
const expandIcon = getExpandIcon(isExpanded);
|
||||
|
||||
let toolbarHtml = '<div style="display: flex; align-items: center; justify-content: space-between; width: 100%;">';
|
||||
toolbarHtml += '<div style="display: flex; align-items: center; gap: 8px;">';
|
||||
|
||||
if (hasDiff) {
|
||||
toolbarHtml += `
|
||||
<span style="font-weight: 500;">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-prev" title="${escapedLabels.previousChange}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="cm-diff-nav-btn cm-diff-nav-next" title="${escapedLabels.nextChange}" ${chunkCount === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHtml += '</div>';
|
||||
toolbarHtml += '<div style="display: flex; align-items: center; gap: 4px;">';
|
||||
|
||||
if (file.diffInfo) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-toggle-diff-btn" title="${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${diffVisibilityIcon}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isSidebar && onPopOut) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-popout-btn" title="Open in modal">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isSidebar && onToggleExpand) {
|
||||
toolbarHtml += `
|
||||
<button class="cm-toolbar-btn cm-expand-btn" title="${isExpanded ? escapedLabels.collapse : escapedLabels.expand}">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
${expandIcon}
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
toolbarHtml += '</div>';
|
||||
toolbarHtml += '</div>';
|
||||
|
||||
dom.innerHTML = toolbarHtml;
|
||||
|
||||
if (hasDiff) {
|
||||
const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');
|
||||
const nextButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-next');
|
||||
|
||||
previousButton?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;
|
||||
const chunk = chunks[currentIndex];
|
||||
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
|
||||
updatePanel();
|
||||
});
|
||||
|
||||
nextButton?.addEventListener('click', () => {
|
||||
if (chunks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;
|
||||
const chunk = chunks[currentIndex];
|
||||
|
||||
if (chunk) {
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
|
||||
updatePanel();
|
||||
});
|
||||
}
|
||||
|
||||
const toggleDiffButton = dom.querySelector<HTMLButtonElement>('.cm-toggle-diff-btn');
|
||||
toggleDiffButton?.addEventListener('click', onToggleDiff);
|
||||
|
||||
const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');
|
||||
popOutButton?.addEventListener('click', () => {
|
||||
onPopOut?.();
|
||||
});
|
||||
|
||||
const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');
|
||||
expandButton?.addEventListener('click', () => {
|
||||
onToggleExpand?.();
|
||||
});
|
||||
};
|
||||
|
||||
updatePanel();
|
||||
|
||||
return {
|
||||
top: true,
|
||||
dom,
|
||||
update: updatePanel,
|
||||
};
|
||||
};
|
||||
|
||||
return [showPanel.of(createPanel)];
|
||||
};
|
||||
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
234
src/components/code-editor/view/CodeEditor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { unifiedMergeView } from '@codemirror/merge';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||
import { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||
import { getEditorStyles } from '../utils/editorStyles';
|
||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||
|
||||
type CodeEditorProps = {
|
||||
file: CodeEditorFile;
|
||||
onClose: () => void;
|
||||
projectPath?: string;
|
||||
isSidebar?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onToggleExpand?: (() => void) | null;
|
||||
onPopOut?: (() => void) | null;
|
||||
};
|
||||
|
||||
export default function CodeEditor({
|
||||
file,
|
||||
onClose,
|
||||
projectPath,
|
||||
isSidebar = false,
|
||||
isExpanded = false,
|
||||
onToggleExpand = null,
|
||||
onPopOut = null,
|
||||
}: CodeEditorProps) {
|
||||
const { t } = useTranslation('codeEditor');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));
|
||||
const [markdownPreview, setMarkdownPreview] = useState(false);
|
||||
|
||||
const {
|
||||
isDarkMode,
|
||||
wordWrap,
|
||||
minimapEnabled,
|
||||
showLineNumbers,
|
||||
fontSize,
|
||||
} = useCodeEditorSettings();
|
||||
|
||||
const {
|
||||
content,
|
||||
setContent,
|
||||
loading,
|
||||
saving,
|
||||
saveSuccess,
|
||||
saveError,
|
||||
handleSave,
|
||||
handleDownload,
|
||||
} = useCodeEditorDocument({
|
||||
file,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
const isMarkdownFile = useMemo(() => {
|
||||
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||
return extension === 'md' || extension === 'markdown';
|
||||
}, [file.name]);
|
||||
|
||||
const minimapExtension = useMemo(
|
||||
() => (
|
||||
createMinimapExtension({
|
||||
file,
|
||||
showDiff,
|
||||
minimapEnabled,
|
||||
isDarkMode,
|
||||
})
|
||||
),
|
||||
[file, isDarkMode, minimapEnabled, showDiff],
|
||||
);
|
||||
|
||||
const scrollToFirstChunkExtension = useMemo(
|
||||
() => createScrollToFirstChunkExtension({ file, showDiff }),
|
||||
[file, showDiff],
|
||||
);
|
||||
|
||||
const toolbarPanelExtension = useMemo(
|
||||
() => (
|
||||
createEditorToolbarPanelExtension({
|
||||
file,
|
||||
showDiff,
|
||||
isSidebar,
|
||||
isExpanded,
|
||||
onToggleDiff: () => setShowDiff((previous) => !previous),
|
||||
onPopOut,
|
||||
onToggleExpand,
|
||||
labels: {
|
||||
changes: t('toolbar.changes'),
|
||||
previousChange: t('toolbar.previousChange'),
|
||||
nextChange: t('toolbar.nextChange'),
|
||||
hideDiff: t('toolbar.hideDiff'),
|
||||
showDiff: t('toolbar.showDiff'),
|
||||
collapse: t('toolbar.collapse'),
|
||||
expand: t('toolbar.expand'),
|
||||
},
|
||||
})
|
||||
),
|
||||
[file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],
|
||||
);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const allExtensions: Extension[] = [
|
||||
...getLanguageExtensions(file.name),
|
||||
...toolbarPanelExtension,
|
||||
];
|
||||
|
||||
if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {
|
||||
allExtensions.push(
|
||||
unifiedMergeView({
|
||||
original: file.diffInfo.old_string,
|
||||
mergeControls: false,
|
||||
highlightChanges: true,
|
||||
syntaxHighlightDeletions: false,
|
||||
gutter: true,
|
||||
}),
|
||||
);
|
||||
allExtensions.push(...minimapExtension);
|
||||
allExtensions.push(...scrollToFirstChunkExtension);
|
||||
}
|
||||
|
||||
if (wordWrap) {
|
||||
allExtensions.push(EditorView.lineWrapping);
|
||||
}
|
||||
|
||||
return allExtensions;
|
||||
}, [
|
||||
file.diffInfo,
|
||||
file.name,
|
||||
minimapExtension,
|
||||
scrollToFirstChunkExtension,
|
||||
showDiff,
|
||||
toolbarPanelExtension,
|
||||
wordWrap,
|
||||
]);
|
||||
|
||||
useEditorKeyboardShortcuts({
|
||||
onSave: handleSave,
|
||||
onClose,
|
||||
dependency: content,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CodeEditorLoadingState
|
||||
isDarkMode={isDarkMode}
|
||||
isSidebar={isSidebar}
|
||||
loadingText={t('loading', { fileName: file.name })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const outerContainerClassName = isSidebar
|
||||
? 'w-full h-full flex flex-col'
|
||||
: `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;
|
||||
|
||||
const innerContainerClassName = isSidebar
|
||||
? 'bg-background flex flex-col w-full h-full'
|
||||
: `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${
|
||||
isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{getEditorStyles(isDarkMode)}</style>
|
||||
<div className={outerContainerClassName}>
|
||||
<div className={innerContainerClassName}>
|
||||
<CodeEditorHeader
|
||||
file={file}
|
||||
isSidebar={isSidebar}
|
||||
isFullscreen={isFullscreen}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
markdownPreview={markdownPreview}
|
||||
saving={saving}
|
||||
saveSuccess={saveSuccess}
|
||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||
onOpenSettings={() => window.openSettings?.('appearance')}
|
||||
onDownload={handleDownload}
|
||||
onSave={handleSave}
|
||||
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||
onClose={onClose}
|
||||
labels={{
|
||||
showingChanges: t('header.showingChanges'),
|
||||
editMarkdown: t('actions.editMarkdown'),
|
||||
previewMarkdown: t('actions.previewMarkdown'),
|
||||
settings: t('toolbar.settings'),
|
||||
download: t('actions.download'),
|
||||
save: t('actions.save'),
|
||||
saving: t('actions.saving'),
|
||||
saved: t('actions.saved'),
|
||||
fullscreen: t('actions.fullscreen'),
|
||||
exitFullscreen: t('actions.exitFullscreen'),
|
||||
close: t('actions.close'),
|
||||
}}
|
||||
/>
|
||||
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 text-xs text-red-700 bg-red-50 border-b border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CodeEditorSurface
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
markdownPreview={markdownPreview}
|
||||
isMarkdownFile={isMarkdownFile}
|
||||
isDarkMode={isDarkMode}
|
||||
fontSize={fontSize}
|
||||
showLineNumbers={showLineNumbers}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CodeEditorFooter
|
||||
content={content}
|
||||
linesLabel={t('footer.lines')}
|
||||
charactersLabel={t('footer.characters')}
|
||||
shortcutsLabel={t('footer.shortcuts')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import CodeEditor from '../../../CodeEditor';
|
||||
import type { EditorSidebarProps } from '../../types/types';
|
||||
import type { MouseEvent, MutableRefObject } from 'react';
|
||||
import type { CodeEditorFile } from '../types/types';
|
||||
import CodeEditor from './CodeEditor';
|
||||
|
||||
const AnyCodeEditor = CodeEditor as any;
|
||||
type EditorSidebarProps = {
|
||||
editingFile: CodeEditorFile | null;
|
||||
isMobile: boolean;
|
||||
editorExpanded: boolean;
|
||||
editorWidth: number;
|
||||
hasManualWidth: boolean;
|
||||
resizeHandleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onCloseEditor: () => void;
|
||||
onToggleEditorExpand: () => void;
|
||||
projectPath?: string;
|
||||
fillSpace?: boolean;
|
||||
};
|
||||
|
||||
export default function EditorSidebar({
|
||||
editingFile,
|
||||
isMobile,
|
||||
editorExpanded,
|
||||
editorWidth,
|
||||
hasManualWidth,
|
||||
resizeHandleRef,
|
||||
onResizeStart,
|
||||
onCloseEditor,
|
||||
@@ -24,7 +38,7 @@ export default function EditorSidebar({
|
||||
|
||||
if (isMobile || poppedOut) {
|
||||
return (
|
||||
<AnyCodeEditor
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={() => {
|
||||
setPoppedOut(false);
|
||||
@@ -36,7 +50,8 @@ export default function EditorSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const useFlex = editorExpanded || fillSpace;
|
||||
// In files tab, fill the remaining width unless user has dragged manually.
|
||||
const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -52,10 +67,10 @@ export default function EditorSidebar({
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlex ? 'flex-1' : ''}`}
|
||||
style={useFlex ? undefined : { width: `${editorWidth}px` }}
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${useFlexLayout ? 'flex-1' : ''}`}
|
||||
style={useFlexLayout ? undefined : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<AnyCodeEditor
|
||||
<CodeEditor
|
||||
file={editingFile}
|
||||
onClose={onCloseEditor}
|
||||
projectPath={projectPath}
|
||||
@@ -0,0 +1,28 @@
|
||||
type CodeEditorFooterProps = {
|
||||
content: string;
|
||||
linesLabel: string;
|
||||
charactersLabel: string;
|
||||
shortcutsLabel: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorFooter({
|
||||
content,
|
||||
linesLabel,
|
||||
charactersLabel,
|
||||
shortcutsLabel,
|
||||
}: CodeEditorFooterProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted flex-shrink-0">
|
||||
<div className="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
{linesLabel} {content.split('\n').length}
|
||||
</span>
|
||||
<span>
|
||||
{charactersLabel} {content.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{shortcutsLabel}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||
import type { CodeEditorFile } from '../../types/types';
|
||||
|
||||
type CodeEditorHeaderProps = {
|
||||
file: CodeEditorFile;
|
||||
isSidebar: boolean;
|
||||
isFullscreen: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
markdownPreview: boolean;
|
||||
saving: boolean;
|
||||
saveSuccess: boolean;
|
||||
onToggleMarkdownPreview: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onDownload: () => void;
|
||||
onSave: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
showingChanges: string;
|
||||
editMarkdown: string;
|
||||
previewMarkdown: string;
|
||||
settings: string;
|
||||
download: string;
|
||||
save: string;
|
||||
saving: string;
|
||||
saved: string;
|
||||
fullscreen: string;
|
||||
exitFullscreen: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function CodeEditorHeader({
|
||||
file,
|
||||
isSidebar,
|
||||
isFullscreen,
|
||||
isMarkdownFile,
|
||||
markdownPreview,
|
||||
saving,
|
||||
saveSuccess,
|
||||
onToggleMarkdownPreview,
|
||||
onOpenSettings,
|
||||
onDownload,
|
||||
onSave,
|
||||
onToggleFullscreen,
|
||||
onClose,
|
||||
labels,
|
||||
}: CodeEditorHeaderProps) {
|
||||
const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">{file.name}</h3>
|
||||
{file.diffInfo && (
|
||||
<span className="text-[10px] bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||
{labels.showingChanges}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{file.path}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleMarkdownPreview}
|
||||
className={`p-1.5 rounded-md min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors ${
|
||||
markdownPreview
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}
|
||||
>
|
||||
{markdownPreview ? <Code2 className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.settings}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.download}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className={`p-1.5 rounded-md disabled:opacity-50 flex items-center justify-center transition-colors min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 ${
|
||||
saveSuccess
|
||||
? 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
title={saveTitle}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
className="hidden md:flex p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[36px] min-h-[36px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title={labels.close}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getEditorLoadingStyles } from '../../utils/editorStyles';
|
||||
|
||||
type CodeEditorLoadingStateProps = {
|
||||
isDarkMode: boolean;
|
||||
isSidebar: boolean;
|
||||
loadingText: string;
|
||||
};
|
||||
|
||||
export default function CodeEditorLoadingState({
|
||||
isDarkMode,
|
||||
isSidebar,
|
||||
loadingText,
|
||||
}: CodeEditorLoadingStateProps) {
|
||||
return (
|
||||
<>
|
||||
<style>{getEditorLoadingStyles(isDarkMode)}</style>
|
||||
{isSidebar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="code-editor-loading w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600" />
|
||||
<span className="text-gray-900 dark:text-white">{loadingText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import MarkdownPreview from './markdown/MarkdownPreview';
|
||||
|
||||
type CodeEditorSurfaceProps = {
|
||||
content: string;
|
||||
onChange: (value: string) => void;
|
||||
markdownPreview: boolean;
|
||||
isMarkdownFile: boolean;
|
||||
isDarkMode: boolean;
|
||||
fontSize: number;
|
||||
showLineNumbers: boolean;
|
||||
extensions: Extension[];
|
||||
};
|
||||
|
||||
export default function CodeEditorSurface({
|
||||
content,
|
||||
onChange,
|
||||
markdownPreview,
|
||||
isMarkdownFile,
|
||||
isDarkMode,
|
||||
fontSize,
|
||||
showLineNumbers,
|
||||
extensions,
|
||||
}: CodeEditorSurfaceProps) {
|
||||
if (markdownPreview && isMarkdownFile) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-white dark:bg-gray-900">
|
||||
<div className="max-w-4xl mx-auto px-8 py-6 prose prose-sm dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg max-w-none">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
value={content}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: showLineNumbers,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { copyTextToClipboard } from '../../../../../utils/clipboard';
|
||||
|
||||
type MarkdownCodeBlockProps = {
|
||||
inline?: boolean;
|
||||
node?: unknown;
|
||||
} & ComponentProps<'code'>;
|
||||
|
||||
export default function MarkdownCodeBlock({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownCodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');
|
||||
const looksMultiline = /[\r\n]/.test(rawContent);
|
||||
const shouldRenderInline = inline || !looksMultiline;
|
||||
|
||||
if (shouldRenderInline) {
|
||||
return (
|
||||
<code
|
||||
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const languageMatch = /language-(\w+)/.exec(className || '');
|
||||
const language = languageMatch ? languageMatch[1] : 'text';
|
||||
|
||||
return (
|
||||
<div className="relative group my-2">
|
||||
{language !== 'text' && (
|
||||
<div className="absolute top-2 left-3 z-10 text-xs text-gray-400 font-medium uppercase">{language}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyTextToClipboard(rawContent).then((success) => {
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
})}
|
||||
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={prismOneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',
|
||||
}}
|
||||
>
|
||||
{rawContent}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Components } from 'react-markdown';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import MarkdownCodeBlock from './MarkdownCodeBlock';
|
||||
|
||||
type MarkdownPreviewProps = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
const markdownPreviewComponents: Components = {
|
||||
code: MarkdownCodeBlock,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-2">
|
||||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||||
),
|
||||
};
|
||||
|
||||
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
||||
const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);
|
||||
const rehypePlugins = useMemo(() => [rehypeKatex], []);
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
18
src/components/file-tree/constants/constants.ts
Normal file
18
src/components/file-tree/constants/constants.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
export const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';
|
||||
|
||||
export const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';
|
||||
|
||||
export const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];
|
||||
|
||||
export const IMAGE_FILE_EXTENSIONS = new Set([
|
||||
'png',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'gif',
|
||||
'svg',
|
||||
'webp',
|
||||
'ico',
|
||||
'bmp',
|
||||
]);
|
||||
224
src/components/file-tree/constants/fileIcons.ts
Normal file
224
src/components/file-tree/constants/fileIcons.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
Archive,
|
||||
Binary,
|
||||
Blocks,
|
||||
BookOpen,
|
||||
Box,
|
||||
Braces,
|
||||
Code2,
|
||||
Cog,
|
||||
Coffee,
|
||||
Cpu,
|
||||
Database,
|
||||
File,
|
||||
FileCheck,
|
||||
FileCode,
|
||||
FileCode2,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Gem,
|
||||
Globe,
|
||||
Hash,
|
||||
Hexagon,
|
||||
Image,
|
||||
Lock,
|
||||
Music2,
|
||||
NotebookPen,
|
||||
Palette,
|
||||
Scroll,
|
||||
Settings,
|
||||
Shield,
|
||||
SquareFunction,
|
||||
Terminal,
|
||||
Video,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
import type { FileIconData, FileIconMap } from '../types/types';
|
||||
|
||||
export const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';
|
||||
|
||||
const FILE_ICON_MAP: FileIconMap = {
|
||||
js: { icon: FileCode, color: 'text-yellow-500' },
|
||||
jsx: { icon: FileCode, color: 'text-yellow-500' },
|
||||
mjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
cjs: { icon: FileCode, color: 'text-yellow-500' },
|
||||
ts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
tsx: { icon: FileCode2, color: 'text-blue-500' },
|
||||
mts: { icon: FileCode2, color: 'text-blue-500' },
|
||||
py: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyw: { icon: Code2, color: 'text-emerald-500' },
|
||||
pyi: { icon: Code2, color: 'text-emerald-400' },
|
||||
ipynb: { icon: NotebookPen, color: 'text-orange-500' },
|
||||
rs: { icon: Cog, color: 'text-orange-600' },
|
||||
toml: { icon: Settings, color: 'text-gray-500' },
|
||||
go: { icon: Hexagon, color: 'text-cyan-500' },
|
||||
rb: { icon: Gem, color: 'text-red-500' },
|
||||
erb: { icon: Gem, color: 'text-red-400' },
|
||||
php: { icon: Blocks, color: 'text-violet-500' },
|
||||
java: { icon: Coffee, color: 'text-red-600' },
|
||||
jar: { icon: Coffee, color: 'text-red-500' },
|
||||
kt: { icon: Hexagon, color: 'text-violet-500' },
|
||||
kts: { icon: Hexagon, color: 'text-violet-400' },
|
||||
c: { icon: Cpu, color: 'text-blue-600' },
|
||||
h: { icon: Cpu, color: 'text-blue-400' },
|
||||
cpp: { icon: Cpu, color: 'text-blue-700' },
|
||||
hpp: { icon: Cpu, color: 'text-blue-500' },
|
||||
cc: { icon: Cpu, color: 'text-blue-700' },
|
||||
cs: { icon: Hexagon, color: 'text-purple-600' },
|
||||
swift: { icon: Flame, color: 'text-orange-500' },
|
||||
lua: { icon: SquareFunction, color: 'text-blue-500' },
|
||||
r: { icon: FlaskConical, color: 'text-blue-600' },
|
||||
html: { icon: Globe, color: 'text-orange-600' },
|
||||
htm: { icon: Globe, color: 'text-orange-600' },
|
||||
css: { icon: Hash, color: 'text-blue-500' },
|
||||
scss: { icon: Hash, color: 'text-pink-500' },
|
||||
sass: { icon: Hash, color: 'text-pink-400' },
|
||||
less: { icon: Hash, color: 'text-indigo-500' },
|
||||
vue: { icon: FileCode2, color: 'text-emerald-500' },
|
||||
svelte: { icon: FileCode2, color: 'text-orange-500' },
|
||||
json: { icon: Braces, color: 'text-yellow-600' },
|
||||
jsonc: { icon: Braces, color: 'text-yellow-500' },
|
||||
json5: { icon: Braces, color: 'text-yellow-500' },
|
||||
yaml: { icon: Settings, color: 'text-purple-400' },
|
||||
yml: { icon: Settings, color: 'text-purple-400' },
|
||||
xml: { icon: FileCode, color: 'text-orange-500' },
|
||||
csv: { icon: FileSpreadsheet, color: 'text-green-600' },
|
||||
tsv: { icon: FileSpreadsheet, color: 'text-green-500' },
|
||||
sql: { icon: Database, color: 'text-blue-500' },
|
||||
graphql: { icon: Workflow, color: 'text-pink-500' },
|
||||
gql: { icon: Workflow, color: 'text-pink-500' },
|
||||
proto: { icon: Box, color: 'text-green-500' },
|
||||
env: { icon: Shield, color: 'text-yellow-600' },
|
||||
md: { icon: BookOpen, color: 'text-blue-500' },
|
||||
mdx: { icon: BookOpen, color: 'text-blue-400' },
|
||||
txt: { icon: FileText, color: 'text-gray-500' },
|
||||
doc: { icon: FileText, color: 'text-blue-600' },
|
||||
docx: { icon: FileText, color: 'text-blue-600' },
|
||||
pdf: { icon: FileCheck, color: 'text-red-600' },
|
||||
rtf: { icon: FileText, color: 'text-gray-500' },
|
||||
tex: { icon: Scroll, color: 'text-teal-600' },
|
||||
rst: { icon: FileText, color: 'text-gray-400' },
|
||||
sh: { icon: Terminal, color: 'text-green-500' },
|
||||
bash: { icon: Terminal, color: 'text-green-500' },
|
||||
zsh: { icon: Terminal, color: 'text-green-400' },
|
||||
fish: { icon: Terminal, color: 'text-green-400' },
|
||||
ps1: { icon: Terminal, color: 'text-blue-400' },
|
||||
bat: { icon: Terminal, color: 'text-gray-500' },
|
||||
cmd: { icon: Terminal, color: 'text-gray-500' },
|
||||
png: { icon: Image, color: 'text-purple-500' },
|
||||
jpg: { icon: Image, color: 'text-purple-500' },
|
||||
jpeg: { icon: Image, color: 'text-purple-500' },
|
||||
gif: { icon: Image, color: 'text-purple-400' },
|
||||
webp: { icon: Image, color: 'text-purple-400' },
|
||||
ico: { icon: Image, color: 'text-purple-400' },
|
||||
bmp: { icon: Image, color: 'text-purple-400' },
|
||||
tiff: { icon: Image, color: 'text-purple-400' },
|
||||
svg: { icon: Palette, color: 'text-amber-500' },
|
||||
mp3: { icon: Music2, color: 'text-pink-500' },
|
||||
wav: { icon: Music2, color: 'text-pink-500' },
|
||||
ogg: { icon: Music2, color: 'text-pink-400' },
|
||||
flac: { icon: Music2, color: 'text-pink-400' },
|
||||
aac: { icon: Music2, color: 'text-pink-400' },
|
||||
m4a: { icon: Music2, color: 'text-pink-400' },
|
||||
mp4: { icon: Video, color: 'text-rose-500' },
|
||||
mov: { icon: Video, color: 'text-rose-500' },
|
||||
avi: { icon: Video, color: 'text-rose-500' },
|
||||
webm: { icon: Video, color: 'text-rose-400' },
|
||||
mkv: { icon: Video, color: 'text-rose-400' },
|
||||
ttf: { icon: FileType, color: 'text-red-500' },
|
||||
otf: { icon: FileType, color: 'text-red-500' },
|
||||
woff: { icon: FileType, color: 'text-red-400' },
|
||||
woff2: { icon: FileType, color: 'text-red-400' },
|
||||
eot: { icon: FileType, color: 'text-red-400' },
|
||||
zip: { icon: Archive, color: 'text-amber-600' },
|
||||
tar: { icon: Archive, color: 'text-amber-600' },
|
||||
gz: { icon: Archive, color: 'text-amber-600' },
|
||||
bz2: { icon: Archive, color: 'text-amber-600' },
|
||||
rar: { icon: Archive, color: 'text-amber-500' },
|
||||
'7z': { icon: Archive, color: 'text-amber-500' },
|
||||
lock: { icon: Lock, color: 'text-gray-500' },
|
||||
exe: { icon: Binary, color: 'text-gray-500' },
|
||||
bin: { icon: Binary, color: 'text-gray-500' },
|
||||
dll: { icon: Binary, color: 'text-gray-400' },
|
||||
so: { icon: Binary, color: 'text-gray-400' },
|
||||
dylib: { icon: Binary, color: 'text-gray-400' },
|
||||
wasm: { icon: Binary, color: 'text-purple-500' },
|
||||
ini: { icon: Settings, color: 'text-gray-500' },
|
||||
cfg: { icon: Settings, color: 'text-gray-500' },
|
||||
conf: { icon: Settings, color: 'text-gray-500' },
|
||||
log: { icon: Scroll, color: 'text-gray-400' },
|
||||
map: { icon: File, color: 'text-gray-400' },
|
||||
};
|
||||
|
||||
const FILENAME_ICON_MAP: FileIconMap = {
|
||||
Dockerfile: { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yml': { icon: Box, color: 'text-blue-500' },
|
||||
'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },
|
||||
'.dockerignore': { icon: Box, color: 'text-gray-500' },
|
||||
'.gitignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitmodules': { icon: Settings, color: 'text-gray-500' },
|
||||
'.gitattributes': { icon: Settings, color: 'text-gray-500' },
|
||||
'.editorconfig': { icon: Settings, color: 'text-gray-500' },
|
||||
'.prettierrc': { icon: Settings, color: 'text-pink-400' },
|
||||
'.prettierignore': { icon: Settings, color: 'text-gray-500' },
|
||||
'.eslintrc': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.json': { icon: Settings, color: 'text-violet-500' },
|
||||
'.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.js': { icon: Settings, color: 'text-violet-500' },
|
||||
'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },
|
||||
'.env': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.local': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.development': { icon: Shield, color: 'text-yellow-500' },
|
||||
'.env.production': { icon: Shield, color: 'text-yellow-600' },
|
||||
'.env.example': { icon: Shield, color: 'text-yellow-400' },
|
||||
'package.json': { icon: Braces, color: 'text-green-500' },
|
||||
'package-lock.json': { icon: Lock, color: 'text-gray-500' },
|
||||
'yarn.lock': { icon: Lock, color: 'text-blue-400' },
|
||||
'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },
|
||||
'bun.lockb': { icon: Lock, color: 'text-gray-400' },
|
||||
'Cargo.toml': { icon: Cog, color: 'text-orange-600' },
|
||||
'Cargo.lock': { icon: Lock, color: 'text-orange-400' },
|
||||
Gemfile: { icon: Gem, color: 'text-red-500' },
|
||||
'Gemfile.lock': { icon: Lock, color: 'text-red-400' },
|
||||
Makefile: { icon: Terminal, color: 'text-gray-500' },
|
||||
'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },
|
||||
'tsconfig.json': { icon: Braces, color: 'text-blue-500' },
|
||||
'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },
|
||||
'vite.config.ts': { icon: Flame, color: 'text-purple-500' },
|
||||
'vite.config.js': { icon: Flame, color: 'text-purple-500' },
|
||||
'webpack.config.js': { icon: Cog, color: 'text-blue-500' },
|
||||
'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },
|
||||
'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },
|
||||
'postcss.config.js': { icon: Cog, color: 'text-red-400' },
|
||||
'babel.config.js': { icon: Settings, color: 'text-yellow-500' },
|
||||
'.babelrc': { icon: Settings, color: 'text-yellow-500' },
|
||||
'README.md': { icon: BookOpen, color: 'text-blue-500' },
|
||||
LICENSE: { icon: FileCheck, color: 'text-gray-500' },
|
||||
'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },
|
||||
'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },
|
||||
'requirements.txt': { icon: FileText, color: 'text-emerald-400' },
|
||||
'go.mod': { icon: Hexagon, color: 'text-cyan-500' },
|
||||
'go.sum': { icon: Lock, color: 'text-cyan-400' },
|
||||
};
|
||||
|
||||
// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.
|
||||
export function getFileIconData(filename: string): FileIconData {
|
||||
if (FILENAME_ICON_MAP[filename]) {
|
||||
return FILENAME_ICON_MAP[filename];
|
||||
}
|
||||
|
||||
if (filename.startsWith('.env')) {
|
||||
return { icon: Shield, color: 'text-yellow-600' };
|
||||
}
|
||||
|
||||
const extension = filename.split('.').pop()?.toLowerCase();
|
||||
if (extension && FILE_ICON_MAP[extension]) {
|
||||
return FILE_ICON_MAP[extension];
|
||||
}
|
||||
|
||||
return { icon: File, color: 'text-muted-foreground' };
|
||||
}
|
||||
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal file
44
src/components/file-tree/hooks/useExpandedDirectories.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
type UseExpandedDirectoriesResult = {
|
||||
expandedDirs: Set<string>;
|
||||
toggleDirectory: (path: string) => void;
|
||||
expandDirectories: (paths: string[]) => void;
|
||||
};
|
||||
|
||||
export function useExpandedDirectories(): UseExpandedDirectoriesResult {
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const toggleDirectory = useCallback((path: string) => {
|
||||
setExpandedDirs((previous) => {
|
||||
const next = new Set(previous);
|
||||
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const expandDirectories = useCallback((paths: string[]) => {
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpandedDirs((previous) => {
|
||||
const next = new Set(previous);
|
||||
paths.forEach((path) => next.add(path));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
expandedDirs,
|
||||
toggleDirectory,
|
||||
expandDirectories,
|
||||
};
|
||||
}
|
||||
|
||||
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal file
76
src/components/file-tree/hooks/useFileTreeData.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { Project } from '../../../types/app';
|
||||
import type { FileTreeNode } from '../types/types';
|
||||
|
||||
type UseFileTreeDataResult = {
|
||||
files: FileTreeNode[];
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {
|
||||
const [files, setFiles] = useState<FileTreeNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const projectName = selectedProject?.name;
|
||||
|
||||
if (!projectName) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
// Track mount state so aborted or late responses do not enqueue stale state updates.
|
||||
let isActive = true;
|
||||
|
||||
const fetchFiles = async () => {
|
||||
if (isActive) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const response = await api.getFiles(projectName, { signal: abortController.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('File fetch failed:', response.status, errorText);
|
||||
if (isActive) {
|
||||
setFiles([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FileTreeNode[];
|
||||
if (isActive) {
|
||||
setFiles(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error fetching files:', error);
|
||||
if (isActive) {
|
||||
setFiles([]);
|
||||
}
|
||||
} finally {
|
||||
if (isActive) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchFiles();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [selectedProject?.name]);
|
||||
|
||||
return {
|
||||
files,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal file
42
src/components/file-tree/hooks/useFileTreeSearch.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';
|
||||
import type { FileTreeNode } from '../types/types';
|
||||
|
||||
type UseFileTreeSearchArgs = {
|
||||
files: FileTreeNode[];
|
||||
expandDirectories: (paths: string[]) => void;
|
||||
};
|
||||
|
||||
type UseFileTreeSearchResult = {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
filteredFiles: FileTreeNode[];
|
||||
};
|
||||
|
||||
export function useFileTreeSearch({
|
||||
files,
|
||||
expandDirectories,
|
||||
}: UseFileTreeSearchArgs): UseFileTreeSearchResult {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState<FileTreeNode[]>(files);
|
||||
|
||||
useEffect(() => {
|
||||
const query = searchQuery.trim().toLowerCase();
|
||||
|
||||
if (!query) {
|
||||
setFilteredFiles(files);
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterFileTree(files, query);
|
||||
setFilteredFiles(filtered);
|
||||
// Keep search results visible by opening every matching ancestor directory once per query update.
|
||||
expandDirectories(collectExpandedDirectoryPaths(filtered));
|
||||
}, [files, searchQuery, expandDirectories]);
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredFiles,
|
||||
};
|
||||
}
|
||||
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal file
43
src/components/file-tree/hooks/useFileTreeViewMode.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
FILE_TREE_DEFAULT_VIEW_MODE,
|
||||
FILE_TREE_VIEW_MODES,
|
||||
FILE_TREE_VIEW_MODE_STORAGE_KEY,
|
||||
} from '../constants/constants';
|
||||
import type { FileTreeViewMode } from '../types/types';
|
||||
|
||||
type UseFileTreeViewModeResult = {
|
||||
viewMode: FileTreeViewMode;
|
||||
changeViewMode: (mode: FileTreeViewMode) => void;
|
||||
};
|
||||
|
||||
export function useFileTreeViewMode(): UseFileTreeViewModeResult {
|
||||
const [viewMode, setViewMode] = useState<FileTreeViewMode>(FILE_TREE_DEFAULT_VIEW_MODE);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);
|
||||
if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {
|
||||
setViewMode(savedViewMode as FileTreeViewMode);
|
||||
}
|
||||
} catch {
|
||||
// Keep default view mode when storage is unavailable.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changeViewMode = useCallback((mode: FileTreeViewMode) => {
|
||||
setViewMode(mode);
|
||||
|
||||
try {
|
||||
localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
// Keep runtime state even when persistence fails.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
viewMode,
|
||||
changeViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user