mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-31 17:35:30 +08:00
Merge branch 'main' into feat/notifications
This commit is contained in:
@@ -42,4 +42,4 @@ HOST=0.0.0.0
|
|||||||
VITE_CONTEXT_WINDOW=160000
|
VITE_CONTEXT_WINDOW=160000
|
||||||
CONTEXT_WINDOW=160000
|
CONTEXT_WINDOW=160000
|
||||||
|
|
||||||
# VITE_IS_PLATFORM=false
|
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -108,7 +108,7 @@ temp/
|
|||||||
.serena/
|
.serena/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
.mcp.json
|
.mcp.json
|
||||||
|
.gemini/
|
||||||
|
|
||||||
# Database files
|
# Database files
|
||||||
*.db
|
*.db
|
||||||
@@ -130,3 +130,11 @@ dev-debug.log
|
|||||||
# Task files
|
# Task files
|
||||||
tasks.json
|
tasks.json
|
||||||
tasks/
|
tasks/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
!src/i18n/locales/en/tasks.json
|
||||||
|
!src/i18n/locales/ja/tasks.json
|
||||||
|
!src/i18n/locales/ru/tasks.json
|
||||||
|
|
||||||
|
# Git worktrees
|
||||||
|
.worktrees/
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "plugins/starter"]
|
||||||
|
path = plugins/starter
|
||||||
|
url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git
|
||||||
@@ -1 +1 @@
|
|||||||
npx --no -- commitlint --edit $1
|
npx commitlint --edit $1
|
||||||
|
|||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -3,6 +3,50 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* **i18n:** localize plugin settings for all languages ([#515](https://github.com/siteboon/claudecodeui/issues/515)) ([621853c](https://github.com/siteboon/claudecodeui/commit/621853cbfb4233b34cb8cc2e1ed10917ba424352))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* codeql user value provided path validation ([aaa14b9](https://github.com/siteboon/claudecodeui/commit/aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356))
|
||||||
|
* numerous bugs ([#528](https://github.com/siteboon/claudecodeui/issues/528)) ([a77f213](https://github.com/siteboon/claudecodeui/commit/a77f213dd5d0b2538dea091ab8da6e55d2002f2f))
|
||||||
|
* **security:** disable executable gray-matter frontmatter in commands ([b9c902b](https://github.com/siteboon/claudecodeui/commit/b9c902b016f411a942c8707dd07d32b60bad087c))
|
||||||
|
* session reconnect catch-up, always-on input, frozen session recovery ([#524](https://github.com/siteboon/claudecodeui/issues/524)) ([4d8fb6e](https://github.com/siteboon/claudecodeui/commit/4d8fb6e30aa03d7cdb92bd62b7709422f9d08e32))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* new settings page design and new pill component ([8ddeeb0](https://github.com/siteboon/claudecodeui/commit/8ddeeb0ce8d0642560bd3fa149236011dc6e3707))
|
||||||
|
|
||||||
|
## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))
|
||||||
|
* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))
|
||||||
|
* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))
|
||||||
|
|
||||||
|
## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04))
|
||||||
|
* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee))
|
||||||
|
|
||||||
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
|
## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
@@ -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 のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
|
[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="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <b>日本語</b></i></div>
|
||||||
|
|
||||||
## スクリーンショット
|
## スクリーンショット
|
||||||
|
|
||||||
@@ -193,8 +193,8 @@ npm run dev
|
|||||||
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
|
Claude Code の全機能を使用するには、手動でツールを有効にする必要があります:
|
||||||
|
|
||||||
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
||||||
3. **選択的に有効化** - 必要なツールのみを有効にする
|
2. **選択的に有効化** - 必要なツールのみを有効にする
|
||||||
4. **設定を適用** - 環境設定はローカルに保存されます
|
3. **設定を適用** - 環境設定はローカルに保存されます
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
@@ -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의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
|
[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> · <a href="./README.ja.md">日本語</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <b>한국어</b> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||||
|
|
||||||
## 스크린샷
|
## 스크린샷
|
||||||
|
|
||||||
@@ -193,8 +193,8 @@ npm run dev
|
|||||||
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
|
Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
|
||||||
|
|
||||||
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
|
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
|
||||||
3. **선택적으로 활성화** - 필요한 도구만 활성화
|
2. **선택적으로 활성화** - 필요한 도구만 활성화
|
||||||
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
3. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -1,21 +1,23 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||||
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||||
|
<p>A desktop and mobile UI for <a href="https://docs.anthropic.com/en/docs/claude-code">Claude Code</a>, <a href="https://docs.cursor.com/en/cli/overview">Cursor CLI</a>, <a href="https://developers.openai.com/codex">Codex</a>, and <a href="https://geminicli.com/">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>
|
||||||
</div>
|
</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), [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.
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
|
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://cloudcli.ai/docs">Documentation</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Bug Reports</a> · <a href="CONTRIBUTING.md">Contributing</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
|
<a href="https://cloudcli.ai"><img src="https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge" alt="CloudCLI Cloud"></a>
|
||||||
|
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord"></a>
|
||||||
|
<br><br>
|
||||||
<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>
|
<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>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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>
|
<div align="right"><i><b>English</b> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.md">日本語</a></i></div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
|||||||
<h3>CLI Selection</h3>
|
<h3>CLI Selection</h3>
|
||||||
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
<img src="public/screenshots/cli-selection.png" alt="CLI Selection" width="400">
|
||||||
<br>
|
<br>
|
||||||
<em>Select between Claude Code, Cursor CLI and Codex</em>
|
<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -58,8 +60,9 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
|||||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||||
|
- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
|
||||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
- **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, GPT-5.2, and Gemini.
|
- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
|
||||||
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -127,8 +130,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
|
|||||||
To use Claude Code's full functionality, you'll need to manually enable tools:
|
To use Claude Code's full functionality, you'll need to manually enable tools:
|
||||||
|
|
||||||
1. **Open Tools Settings** - Click the gear icon in the sidebar
|
1. **Open Tools Settings** - Click the gear icon in the sidebar
|
||||||
3. **Enable Selectively** - Turn on only the tools you need
|
2. **Enable Selectively** - Turn on only the tools you need
|
||||||
4. **Apply Settings** - Your preferences are saved locally
|
3. **Apply Settings** - Your preferences are saved locally
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -139,6 +142,24 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
|
|||||||
|
|
||||||
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
|
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
CloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own.
|
||||||
|
|
||||||
|
### Available Plugins
|
||||||
|
|
||||||
|
| Plugin | Description |
|
||||||
|
|---|---|
|
||||||
|
| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
|
||||||
|
|
||||||
|
### Build Your Own
|
||||||
|
|
||||||
|
**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
|
||||||
|
|
||||||
|
**[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more.
|
||||||
|
|
||||||
---
|
---
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
|||||||
218
README.ru.md
Normal file
218
README.ru.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="public/logo.svg" alt="CloudCLI UI" width="64" height="64">
|
||||||
|
<h1>Cloud CLI (aka Claude Code UI)</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
Десктопный и мобильный UI для [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) и [Gemini-CLI](https://geminicli.com/). Его можно использовать локально или удаленно, чтобы просматривать активные проекты и сессии и вносить изменения откуда угодно, с мобильного или десктопа. Это дает полноценный интерфейс, который работает везде.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cloudcli.ai">CloudCLI Cloud</a> · <a href="https://discord.gg/buxwujPNRE">Discord</a> · <a href="https://github.com/siteboon/claudecodeui/issues">Сообщить об ошибке</a> · <a href="CONTRIBUTING.md">Участие в разработке</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://discord.gg/buxwujPNRE"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Join our Discord"></a>
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div align="right"><i><a href="./README.md">English</a> · <b>Русский</b> · <a href="./README.ko.md">한국어</a> · <a href="./README.zh-CN.md">中文</a> · <a href="./README.ja.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 и Gemini CLI</em>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому пользоваться агентами можно и с мобильных устройств
|
||||||
|
- **Интерактивный чат-интерфейс** - встроенный чат для удобного взаимодействия с агентами
|
||||||
|
- **Встроенный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку
|
||||||
|
- **Файловый менеджер** - интерактивное дерево файлов с подсветкой синтаксиса и live-редактированием
|
||||||
|
- **Git Explorer** - просмотр, stage и commit изменений, а также переключение веток
|
||||||
|
- **Управление сессиями** - возобновление диалогов, работа с несколькими сессиями и история
|
||||||
|
- **Интеграция с TaskMaster AI** *(опционально)* - расширенное управление проектами с AI-планированием задач, разбором PRD и автоматизацией workflows
|
||||||
|
- **Совместимость с моделями** - работает с Claude Sonnet 4.5, Opus 4.5, GPT-5.2 и Gemini.
|
||||||
|
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### CloudCLI Cloud (рекомендуется)
|
||||||
|
|
||||||
|
Самый быстрый способ начать работу: локальная настройка не требуется. Вы получаете полностью управляемую контейнеризированную среду разработки с доступом из браузера, мобильного приложения, API или любимой IDE.
|
||||||
|
|
||||||
|
**[Начать с CloudCLI Cloud](https://cloudcli.ai)**
|
||||||
|
|
||||||
|
|
||||||
|
### Self-Hosted (open source)
|
||||||
|
|
||||||
|
Попробовать CloudCLI UI можно сразу через **npx** (нужен **Node.js** v22+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @siteboon/claude-code-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Или установить **глобально** для постоянного использования:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @siteboon/claude-code-ui
|
||||||
|
cloudcli
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте `http://localhost:3001` — все существующие сессии будут обнаружены автоматически.
|
||||||
|
|
||||||
|
Больше вариантов настройки, PM2, удаленный сервер и остальное описаны в **[документации →](https://cloudcli.ai/docs)**
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Какой вариант подойдет вам?
|
||||||
|
|
||||||
|
CloudCLI UI - это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его у себя на машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции.
|
||||||
|
|
||||||
|
| | CloudCLI UI (self-hosted) | CloudCLI Cloud |
|
||||||
|
|---|---|---|
|
||||||
|
| **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно |
|
||||||
|
| **Способ доступа** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n |
|
||||||
|
| **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется |
|
||||||
|
| **Машина должна оставаться включенной** | Да | Нет |
|
||||||
|
| **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке |
|
||||||
|
| **Доступные сессии** | Все сессии автоматически обнаруживаются в `~/.claude` | Все сессии внутри вашей облачной среды |
|
||||||
|
| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||||
|
| **Файловый менеджер и Git** | Да, встроены в UI | Да, встроены в UI |
|
||||||
|
| **Конфигурация MCP** | Управляется через UI, синхронизируется с локальным `~/.claude` | Управляется через UI |
|
||||||
|
| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к облачной среде |
|
||||||
|
| **REST API** | Да | Да |
|
||||||
|
| **Узел n8n** | Нет | Да |
|
||||||
|
| **Совместная работа в команде** | Нет | Да |
|
||||||
|
| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
|
||||||
|
|
||||||
|
> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Безопасность и настройка инструментов
|
||||||
|
|
||||||
|
**🔒 Важно**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций.
|
||||||
|
|
||||||
|
### Включение инструментов
|
||||||
|
|
||||||
|
Чтобы использовать всю функциональность Claude Code, инструменты нужно включить вручную:
|
||||||
|
|
||||||
|
1. **Откройте настройки инструментов** - нажмите на иконку шестеренки в боковой панели
|
||||||
|
2. **Включайте выборочно** - активируйте только те инструменты, которые действительно нужны
|
||||||
|
3. **Примените настройки** - предпочтения сохраняются локально
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
*Окно настройки инструментов - включайте только то, что вам нужно*
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно поменять позже.
|
||||||
|
|
||||||
|
---
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Чем это отличается от Claude Code Remote Control?</summary>
|
||||||
|
|
||||||
|
Claude Code Remote Control позволяет отправлять сообщения в сессию, уже запущенную в локальном терминале. При этом ваша машина должна оставаться включенной, терминал должен быть открыт, а сессии завершаются примерно через 10 минут без сетевого соединения.
|
||||||
|
|
||||||
|
CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно.
|
||||||
|
|
||||||
|
Вот что это означает на практике:
|
||||||
|
|
||||||
|
- **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать ее доступной в мобильном приложении Claude.
|
||||||
|
- **Ваши настройки остаются вашими** — MCP-серверы, права инструментов и конфигурация проекта, измененные в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот.
|
||||||
|
- **Поддержка большего числа агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code.
|
||||||
|
- **Полноценный UI, а не просто окно чата** — встроены файловый менеджер, Git-интеграция, управление MCP и shell-терминал.
|
||||||
|
- **CloudCLI Cloud работает в облаке** — можно закрыть ноутбук, а агент продолжит работу. Не нужно держать терминал открытым и машину в активном состоянии.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Нужно ли отдельно платить за AI-подписку?</summary>
|
||||||
|
|
||||||
|
Да. CloudCLI предоставляет среду, а не сам AI. Вы используете собственную подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud стоит от $7/месяц за хостируемую среду сверх этого.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Можно ли пользоваться CloudCLI UI с телефона?</summary>
|
||||||
|
|
||||||
|
Да. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере внутри вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже разрабатывается.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code?</summary>
|
||||||
|
|
||||||
|
Да, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который нативно использует Claude Code. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сообщество и поддержка
|
||||||
|
|
||||||
|
- **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок
|
||||||
|
- **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями
|
||||||
|
- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — баг-репорты и запросы новых функций
|
||||||
|
- **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
GNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
Этот проект открыт и может свободно использоваться, изменяться и распространяться по лицензии GPL v3.
|
||||||
|
|
||||||
|
## Благодарности
|
||||||
|
|
||||||
|
### Используется
|
||||||
|
- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic
|
||||||
|
- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor
|
||||||
|
- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
|
||||||
|
- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
|
||||||
|
- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов
|
||||||
|
- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер
|
||||||
|
- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework
|
||||||
|
- **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода
|
||||||
|
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач
|
||||||
|
|
||||||
|
|
||||||
|
### Спонсоры
|
||||||
|
- [Siteboon - AI powered website builder](https://siteboon.ai)
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<strong>Сделано с любовью к сообществу Claude Code, Cursor и Codex.</strong>
|
||||||
|
</div>
|
||||||
@@ -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 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
|
[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> · <a href="./README.ja.md">日本語</a></i></div>
|
<div align="right"><i><a href="./README.md">English</a> · <a href="./README.ru.md">Русский</a> · <a href="./README.ko.md">한국어</a> · <b>中文</b> · <a href="./README.ja.md">日本語</a></i></div>
|
||||||
|
|
||||||
## 截图
|
## 截图
|
||||||
|
|
||||||
@@ -194,8 +194,8 @@ npm run dev
|
|||||||
要使用 Claude Code 的完整功能,您需要手动启用工具:
|
要使用 Claude Code 的完整功能,您需要手动启用工具:
|
||||||
|
|
||||||
1. **打开工具设置** - 点击侧边栏中的齿轮图标
|
1. **打开工具设置** - 点击侧边栏中的齿轮图标
|
||||||
3. **选择性启用** - 仅打开您需要的工具
|
2. **选择性启用** - 仅打开您需要的工具
|
||||||
4. **应用设置** - 您的偏好设置将保存在本地
|
3. **应用设置** - 您的偏好设置将保存在本地
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
@@ -344,4 +344,4 @@ GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
|
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.25.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.25.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@siteboon/claude-code-ui",
|
"name": "@siteboon/claude-code-ui",
|
||||||
"version": "1.23.2",
|
"version": "1.25.2",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
|
|||||||
1
plugins/starter
Submodule
1
plugins/starter
Submodule
Submodule plugins/starter added at bfa6332810
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 340 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 506 KiB |
@@ -1,84 +1,124 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
import { promises as fs } from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// Use cross-spawn on Windows for better command execution
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
|
const WORKSPACE_TRUST_PATTERNS = [
|
||||||
|
/workspace trust required/i,
|
||||||
|
/do you trust the contents of this directory/i,
|
||||||
|
/working with untrusted contents/i,
|
||||||
|
/pass --trust,\s*--yolo,\s*or -f/i
|
||||||
|
];
|
||||||
|
|
||||||
|
function isWorkspaceTrustPrompt(text = '') {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
async function spawnCursor(command, options = {}, ws) {
|
async function spawnCursor(command, options = {}, ws) {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
|
const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
|
||||||
let capturedSessionId = sessionId; // Track session ID throughout the process
|
let capturedSessionId = sessionId; // Track session ID throughout the process
|
||||||
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
let sessionCreatedSent = false; // Track if we've already sent session-created event
|
||||||
let messageBuffer = ''; // Buffer for accumulating assistant messages
|
let hasRetriedWithTrust = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
// Use tools settings passed from frontend, or defaults
|
// Use tools settings passed from frontend, or defaults
|
||||||
const settings = toolsSettings || {
|
const settings = toolsSettings || {
|
||||||
allowedShellCommands: [],
|
allowedShellCommands: [],
|
||||||
skipPermissions: false
|
skipPermissions: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build Cursor CLI command
|
// Build Cursor CLI command
|
||||||
const args = [];
|
const baseArgs = [];
|
||||||
|
|
||||||
// Build flags allowing both resume and prompt together (reply in existing session)
|
// Build flags allowing both resume and prompt together (reply in existing session)
|
||||||
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
// Treat presence of sessionId as intention to resume, regardless of resume flag
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
args.push('--resume=' + sessionId);
|
baseArgs.push('--resume=' + sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command && command.trim()) {
|
if (command && command.trim()) {
|
||||||
// Provide a prompt (works for both new and resumed sessions)
|
// Provide a prompt (works for both new and resumed sessions)
|
||||||
args.push('-p', command);
|
baseArgs.push('-p', command);
|
||||||
|
|
||||||
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
|
||||||
if (!sessionId && model) {
|
if (!sessionId && model) {
|
||||||
args.push('--model', model);
|
baseArgs.push('--model', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request streaming JSON when we are providing a prompt
|
// Request streaming JSON when we are providing a prompt
|
||||||
args.push('--output-format', 'stream-json');
|
baseArgs.push('--output-format', 'stream-json');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add skip permissions flag if enabled
|
// Add skip permissions flag if enabled
|
||||||
if (skipPermissions || settings.skipPermissions) {
|
if (skipPermissions || settings.skipPermissions) {
|
||||||
args.push('-f');
|
baseArgs.push('-f');
|
||||||
console.log('⚠️ Using -f flag (skip permissions)');
|
console.log('Using -f flag (skip permissions)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cwd (actual project directory) instead of projectPath
|
// Use cwd (actual project directory) instead of projectPath
|
||||||
const workingDir = cwd || projectPath || process.cwd();
|
const workingDir = cwd || projectPath || process.cwd();
|
||||||
|
|
||||||
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
|
||||||
console.log('Working directory:', workingDir);
|
|
||||||
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
|
||||||
|
|
||||||
const cursorProcess = spawnFunction('cursor-agent', args, {
|
|
||||||
cwd: workingDir,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env } // Inherit all environment variables
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store process reference for potential abort
|
// Store process reference for potential abort
|
||||||
const processKey = capturedSessionId || Date.now().toString();
|
const processKey = capturedSessionId || Date.now().toString();
|
||||||
activeCursorProcesses.set(processKey, cursorProcess);
|
|
||||||
|
const settleOnce = (callback) => {
|
||||||
// Handle stdout (streaming JSON responses)
|
if (settled) {
|
||||||
cursorProcess.stdout.on('data', (data) => {
|
return;
|
||||||
const rawOutput = data.toString();
|
}
|
||||||
console.log('📤 Cursor CLI stdout:', rawOutput);
|
settled = true;
|
||||||
|
callback();
|
||||||
const lines = rawOutput.split('\n').filter(line => line.trim());
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
const runCursorProcess = (args, runReason = 'initial') => {
|
||||||
|
const isTrustRetry = runReason === 'trust-retry';
|
||||||
|
let runSawWorkspaceTrustPrompt = false;
|
||||||
|
let stdoutLineBuffer = '';
|
||||||
|
|
||||||
|
if (isTrustRetry) {
|
||||||
|
console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
|
||||||
|
console.log('Working directory:', workingDir);
|
||||||
|
console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
|
||||||
|
|
||||||
|
const cursorProcess = spawnFunction('cursor-agent', args, {
|
||||||
|
cwd: workingDir,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env } // Inherit all environment variables
|
||||||
|
});
|
||||||
|
|
||||||
|
activeCursorProcesses.set(processKey, cursorProcess);
|
||||||
|
|
||||||
|
const shouldSuppressForTrustRetry = (text) => {
|
||||||
|
if (hasRetriedWithTrust || args.includes('--trust')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isWorkspaceTrustPrompt(text)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
runSawWorkspaceTrustPrompt = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCursorOutputLine = (line) => {
|
||||||
|
if (!line || !line.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = JSON.parse(line);
|
const response = JSON.parse(line);
|
||||||
console.log('📄 Parsed JSON response:', response);
|
console.log('Parsed JSON response:', response);
|
||||||
|
|
||||||
// Handle different message types
|
// Handle different message types
|
||||||
switch (response.type) {
|
switch (response.type) {
|
||||||
case 'system':
|
case 'system':
|
||||||
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
// Capture session ID
|
// Capture session ID
|
||||||
if (response.session_id && !capturedSessionId) {
|
if (response.session_id && !capturedSessionId) {
|
||||||
capturedSessionId = response.session_id;
|
capturedSessionId = response.session_id;
|
||||||
console.log('📝 Captured session ID:', capturedSessionId);
|
console.log('Captured session ID:', capturedSessionId);
|
||||||
|
|
||||||
// Update process key with captured session ID
|
// Update process key with captured session ID
|
||||||
if (processKey !== capturedSessionId) {
|
if (processKey !== capturedSessionId) {
|
||||||
activeCursorProcesses.delete(processKey);
|
activeCursorProcesses.delete(processKey);
|
||||||
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
activeCursorProcesses.set(capturedSessionId, cursorProcess);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set session ID on writer (for API endpoint compatibility)
|
// Set session ID on writer (for API endpoint compatibility)
|
||||||
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
|
||||||
ws.setSessionId(capturedSessionId);
|
ws.setSessionId(capturedSessionId);
|
||||||
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send system info to frontend
|
// Send system info to frontend
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-system',
|
type: 'cursor-system',
|
||||||
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'user':
|
case 'user':
|
||||||
// Forward user message
|
// Forward user message
|
||||||
ws.send({
|
ws.send({
|
||||||
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
// Accumulate assistant message chunks
|
// Accumulate assistant message chunks
|
||||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||||
const textContent = response.message.content[0].text;
|
const textContent = response.message.content[0].text;
|
||||||
messageBuffer += textContent;
|
|
||||||
|
|
||||||
// Send as Claude-compatible format for frontend
|
// Send as Claude-compatible format for frontend
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'claude-response',
|
type: 'claude-response',
|
||||||
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'result':
|
case 'result':
|
||||||
// Session complete
|
// Session complete
|
||||||
console.log('Cursor session result:', response);
|
console.log('Cursor session result:', response);
|
||||||
|
|
||||||
// Send final message if we have buffered content
|
// Do not emit an extra content_block_stop here.
|
||||||
if (messageBuffer) {
|
// The UI already finalizes the streaming message in cursor-result handling,
|
||||||
ws.send({
|
// and emitting both can produce duplicate assistant messages.
|
||||||
type: 'claude-response',
|
|
||||||
data: {
|
|
||||||
type: 'content_block_stop'
|
|
||||||
},
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send completion event
|
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-result',
|
type: 'cursor-result',
|
||||||
sessionId: capturedSessionId || sessionId,
|
sessionId: capturedSessionId || sessionId,
|
||||||
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
success: response.subtype === 'success'
|
success: response.subtype === 'success'
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Forward any other message types
|
// Forward any other message types
|
||||||
ws.send({
|
ws.send({
|
||||||
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.log('📄 Non-JSON response:', line);
|
console.log('Non-JSON response:', line);
|
||||||
|
|
||||||
|
if (shouldSuppressForTrustRetry(line)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If not JSON, send as raw text
|
// If not JSON, send as raw text
|
||||||
ws.send({
|
ws.send({
|
||||||
type: 'cursor-output',
|
type: 'cursor-output',
|
||||||
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
|
|||||||
sessionId: capturedSessionId || sessionId || null
|
sessionId: capturedSessionId || sessionId || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
|
||||||
|
|
||||||
// Handle stderr
|
|
||||||
cursorProcess.stderr.on('data', (data) => {
|
|
||||||
console.error('Cursor CLI stderr:', data.toString());
|
|
||||||
ws.send({
|
|
||||||
type: 'cursor-error',
|
|
||||||
error: data.toString(),
|
|
||||||
sessionId: capturedSessionId || sessionId || null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle process completion
|
|
||||||
cursorProcess.on('close', async (code) => {
|
|
||||||
console.log(`Cursor CLI process exited with code ${code}`);
|
|
||||||
|
|
||||||
// Clean up process reference
|
|
||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
|
||||||
|
|
||||||
ws.send({
|
// Handle stdout (streaming JSON responses)
|
||||||
type: 'claude-complete',
|
cursorProcess.stdout.on('data', (data) => {
|
||||||
sessionId: finalSessionId,
|
const rawOutput = data.toString();
|
||||||
exitCode: code,
|
console.log('Cursor CLI stdout:', rawOutput);
|
||||||
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
|
||||||
});
|
|
||||||
|
|
||||||
if (code === 0) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Cursor CLI exited with code ${code}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle process errors
|
|
||||||
cursorProcess.on('error', (error) => {
|
|
||||||
console.error('Cursor CLI process error:', error);
|
|
||||||
|
|
||||||
// Clean up process reference on error
|
|
||||||
const finalSessionId = capturedSessionId || sessionId || processKey;
|
|
||||||
activeCursorProcesses.delete(finalSessionId);
|
|
||||||
|
|
||||||
ws.send({
|
// Stream chunks can split JSON objects across packets; keep trailing partial line.
|
||||||
type: 'cursor-error',
|
stdoutLineBuffer += rawOutput;
|
||||||
error: error.message,
|
const completeLines = stdoutLineBuffer.split(/\r?\n/);
|
||||||
sessionId: capturedSessionId || sessionId || null
|
stdoutLineBuffer = completeLines.pop() || '';
|
||||||
|
|
||||||
|
completeLines.forEach((line) => {
|
||||||
|
processCursorOutputLine(line.trim());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
reject(error);
|
// Handle stderr
|
||||||
});
|
cursorProcess.stderr.on('data', (data) => {
|
||||||
|
const stderrText = data.toString();
|
||||||
// Close stdin since Cursor doesn't need interactive input
|
console.error('Cursor CLI stderr:', stderrText);
|
||||||
cursorProcess.stdin.end();
|
|
||||||
|
if (shouldSuppressForTrustRetry(stderrText)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-error',
|
||||||
|
error: stderrText,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process completion
|
||||||
|
cursorProcess.on('close', async (code) => {
|
||||||
|
console.log(`Cursor CLI process exited with code ${code}`);
|
||||||
|
|
||||||
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
|
activeCursorProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
|
// Flush any final unterminated stdout line before completion handling.
|
||||||
|
if (stdoutLineBuffer.trim()) {
|
||||||
|
processCursorOutputLine(stdoutLineBuffer.trim());
|
||||||
|
stdoutLineBuffer = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
runSawWorkspaceTrustPrompt &&
|
||||||
|
code !== 0 &&
|
||||||
|
!hasRetriedWithTrust &&
|
||||||
|
!args.includes('--trust')
|
||||||
|
) {
|
||||||
|
hasRetriedWithTrust = true;
|
||||||
|
runCursorProcess([...args, '--trust'], 'trust-retry');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.send({
|
||||||
|
type: 'claude-complete',
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
exitCode: code,
|
||||||
|
isNewSession: !sessionId && !!command // Flag to indicate this was a new session
|
||||||
|
});
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
settleOnce(() => resolve());
|
||||||
|
} else {
|
||||||
|
settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle process errors
|
||||||
|
cursorProcess.on('error', (error) => {
|
||||||
|
console.error('Cursor CLI process error:', error);
|
||||||
|
|
||||||
|
// Clean up process reference on error
|
||||||
|
const finalSessionId = capturedSessionId || sessionId || processKey;
|
||||||
|
activeCursorProcesses.delete(finalSessionId);
|
||||||
|
|
||||||
|
ws.send({
|
||||||
|
type: 'cursor-error',
|
||||||
|
error: error.message,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
settleOnce(() => reject(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close stdin since Cursor doesn't need interactive input
|
||||||
|
cursorProcess.stdin.end();
|
||||||
|
};
|
||||||
|
|
||||||
|
runCursorProcess(baseArgs, 'initial');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortCursorSession(sessionId) {
|
function abortCursorSession(sessionId) {
|
||||||
const process = activeCursorProcesses.get(sessionId);
|
const process = activeCursorProcesses.get(sessionId);
|
||||||
if (process) {
|
if (process) {
|
||||||
console.log(`🛑 Aborting Cursor session: ${sessionId}`);
|
console.log(`Aborting Cursor session: ${sessionId}`);
|
||||||
process.kill('SIGTERM');
|
process.kill('SIGTERM');
|
||||||
activeCursorProcesses.delete(sessionId);
|
activeCursorProcesses.delete(sessionId);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
|
|||||||
// Create database connection
|
// Create database connection
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
|
||||||
|
// runMigrations() also creates this table, but it runs too late for existing installations
|
||||||
|
// where auth.js is imported before initializeDatabase() is called.
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
// Show app installation path prominently
|
// Show app installation path prominently
|
||||||
const appInstallPath = path.join(__dirname, '../..');
|
const appInstallPath = path.join(__dirname, '../..');
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -120,6 +129,12 @@ const runMigrations = () => {
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
// Create app_config table if it doesn't exist (for existing installations)
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`);
|
||||||
|
|
||||||
// Create session_names table if it doesn't exist (for existing installations)
|
// Create session_names table if it doesn't exist (for existing installations)
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
|
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
|
||||||
@@ -554,6 +569,33 @@ function applyCustomSessionNames(sessions, provider) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App config database operations
|
||||||
|
const appConfigDb = {
|
||||||
|
get: (key) => {
|
||||||
|
try {
|
||||||
|
const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
|
||||||
|
return row?.value || null;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
set: (key, value) => {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run(key, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
getOrCreateJwtSecret: () => {
|
||||||
|
let secret = appConfigDb.get('jwt_secret');
|
||||||
|
if (!secret) {
|
||||||
|
secret = crypto.randomBytes(64).toString('hex');
|
||||||
|
appConfigDb.set('jwt_secret', secret);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Backward compatibility - keep old names pointing to new system
|
// Backward compatibility - keep old names pointing to new system
|
||||||
const githubTokensDb = {
|
const githubTokensDb = {
|
||||||
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
createGithubToken: (userId, tokenName, githubToken, description = null) => {
|
||||||
@@ -583,5 +625,6 @@ export {
|
|||||||
pushSubscriptionsDb,
|
pushSubscriptionsDb,
|
||||||
sessionNamesDb,
|
sessionNamesDb,
|
||||||
applyCustomSessionNames,
|
applyCustomSessionNames,
|
||||||
|
appConfigDb,
|
||||||
githubTokensDb // Backward compatibility
|
githubTokensDb // Backward compatibility
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -90,3 +90,10 @@ CREATE TABLE IF NOT EXISTS session_names (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
|
||||||
|
|
||||||
|
-- App configuration table (auto-generated secrets, settings, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|||||||
122
server/index.js
122
server/index.js
@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
|
|||||||
import userRoutes from './routes/user.js';
|
import userRoutes from './routes/user.js';
|
||||||
import codexRoutes from './routes/codex.js';
|
import codexRoutes from './routes/codex.js';
|
||||||
import geminiRoutes from './routes/gemini.js';
|
import geminiRoutes from './routes/gemini.js';
|
||||||
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
|
import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
@@ -325,7 +327,7 @@ const wss = new WebSocketServer({
|
|||||||
// Make WebSocket server available to routes
|
// Make WebSocket server available to routes
|
||||||
app.locals.wss = wss;
|
app.locals.wss = wss;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
|
||||||
app.use(express.json({
|
app.use(express.json({
|
||||||
limit: '50mb',
|
limit: '50mb',
|
||||||
type: (req) => {
|
type: (req) => {
|
||||||
@@ -390,6 +392,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
|
|||||||
// Gemini API Routes (protected)
|
// Gemini API Routes (protected)
|
||||||
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||||
|
|
||||||
|
// Plugins API Routes (protected)
|
||||||
|
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||||
|
|
||||||
// Agent API Routes (uses API key authentication)
|
// Agent API Routes (uses API key authentication)
|
||||||
app.use('/api/agent', agentRoutes);
|
app.use('/api/agent', agentRoutes);
|
||||||
|
|
||||||
@@ -1696,50 +1701,49 @@ function handleShellConnection(ws) {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Prepare the shell command adapted to the platform and provider
|
// Validate projectPath — resolve to absolute and verify it exists
|
||||||
|
const resolvedProjectPath = path.resolve(projectPath);
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(resolvedProjectPath);
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error('Not a directory');
|
||||||
|
}
|
||||||
|
} catch (pathErr) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sessionId — only allow safe characters
|
||||||
|
const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
|
||||||
|
if (sessionId && !safeSessionIdPattern.test(sessionId)) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build shell command — use cwd for project path (never interpolate into shell string)
|
||||||
let shellCommand;
|
let shellCommand;
|
||||||
if (isPlainShell) {
|
if (isPlainShell) {
|
||||||
// Plain shell mode - just run the initial command in the project directory
|
// Plain shell mode - run the initial command in the project directory
|
||||||
if (os.platform() === 'win32') {
|
shellCommand = initialCommand;
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
|
||||||
}
|
|
||||||
} else if (provider === 'cursor') {
|
} else if (provider === 'cursor') {
|
||||||
// Use cursor-agent command
|
if (hasSession && sessionId) {
|
||||||
if (os.platform() === 'win32') {
|
shellCommand = `cursor-agent --resume="${sessionId}"`;
|
||||||
if (hasSession && sessionId) {
|
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (hasSession && sessionId) {
|
shellCommand = 'cursor-agent';
|
||||||
shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `cd "${projectPath}" && cursor-agent`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (provider === 'codex') {
|
} else if (provider === 'codex') {
|
||||||
// Use codex command
|
// Use codex command; attempt to resume and fall back to a new session when the resume fails.
|
||||||
if (os.platform() === 'win32') {
|
if (hasSession && sessionId) {
|
||||||
if (hasSession && sessionId) {
|
if (os.platform() === 'win32') {
|
||||||
// Try to resume session, but with fallback to a new session if it fails
|
// PowerShell syntax for fallback
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
|
||||||
} else {
|
} else {
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; codex`;
|
shellCommand = `codex resume "${sessionId}" || codex`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (hasSession && sessionId) {
|
shellCommand = 'codex';
|
||||||
// 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') {
|
} else if (provider === 'gemini') {
|
||||||
// Use gemini command
|
|
||||||
const command = initialCommand || 'gemini';
|
const command = initialCommand || 'gemini';
|
||||||
let resumeId = sessionId;
|
let resumeId = sessionId;
|
||||||
if (hasSession && sessionId) {
|
if (hasSession && sessionId) {
|
||||||
@@ -1750,41 +1754,32 @@ function handleShellConnection(ws) {
|
|||||||
const sess = sessionManager.getSession(sessionId);
|
const sess = sessionManager.getSession(sessionId);
|
||||||
if (sess && sess.cliSessionId) {
|
if (sess && sess.cliSessionId) {
|
||||||
resumeId = sess.cliSessionId;
|
resumeId = sess.cliSessionId;
|
||||||
|
// Validate the looked-up CLI session ID too
|
||||||
|
if (!safeSessionIdPattern.test(resumeId)) {
|
||||||
|
resumeId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to get Gemini CLI session ID:', err);
|
console.error('Failed to get Gemini CLI session ID:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (os.platform() === 'win32') {
|
if (hasSession && resumeId) {
|
||||||
if (hasSession && resumeId) {
|
shellCommand = `${command} --resume "${resumeId}"`;
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (hasSession && resumeId) {
|
shellCommand = command;
|
||||||
shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use claude command (default) or initialCommand if provided
|
// Claude (default provider)
|
||||||
const command = initialCommand || 'claude';
|
const command = initialCommand || 'claude';
|
||||||
if (os.platform() === 'win32') {
|
if (hasSession && sessionId) {
|
||||||
if (hasSession && sessionId) {
|
if (os.platform() === 'win32') {
|
||||||
// Try to resume session, but with fallback to new session if it fails
|
shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
|
||||||
} else {
|
} else {
|
||||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
shellCommand = `claude --resume "${sessionId}" || claude`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (hasSession && sessionId) {
|
shellCommand = command;
|
||||||
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
|
||||||
} else {
|
|
||||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1803,7 +1798,7 @@ function handleShellConnection(ws) {
|
|||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: termCols,
|
cols: termCols,
|
||||||
rows: termRows,
|
rows: termRows,
|
||||||
cwd: os.homedir(),
|
cwd: resolvedProjectPath,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
@@ -2537,7 +2532,20 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start watching the projects folder for changes
|
// Start watching the projects folder for changes
|
||||||
await setupProjectsWatcher();
|
await setupProjectsWatcher();
|
||||||
|
|
||||||
|
// Start server-side plugin processes for enabled plugins
|
||||||
|
startEnabledPluginServers().catch(err => {
|
||||||
|
console.error('[Plugins] Error during startup:', err.message);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clean up plugin processes on shutdown
|
||||||
|
const shutdownPlugins = async () => {
|
||||||
|
await stopAllPlugins();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', () => void shutdownPlugins());
|
||||||
|
process.on('SIGINT', () => void shutdownPlugins());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ERROR] Failed to start server:', error);
|
console.error('[ERROR] Failed to start server:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { userDb } from '../database/db.js';
|
import { userDb, appConfigDb } from '../database/db.js';
|
||||||
import { IS_PLATFORM } from '../constants/config.js';
|
import { IS_PLATFORM } from '../constants/config.js';
|
||||||
|
|
||||||
// Get JWT secret from environment or use default (for development)
|
// Use env var if set, otherwise auto-generate a unique secret per installation
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
|
||||||
|
|
||||||
// Optional API key middleware
|
// Optional API key middleware
|
||||||
const validateApiKey = (req, res, next) => {
|
const validateApiKey = (req, res, next) => {
|
||||||
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
return res.status(401).json({ error: 'Invalid token. User not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-refresh: if token is past halfway through its lifetime, issue a new one
|
||||||
|
if (decoded.exp && decoded.iat) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const halfLife = (decoded.exp - decoded.iat) / 2;
|
||||||
|
if (now > decoded.iat + halfLife) {
|
||||||
|
const newToken = generateToken(user);
|
||||||
|
res.setHeader('X-Refreshed-Token', newToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Generate JWT token (never expires)
|
// Generate JWT token
|
||||||
const generateToken = (user) => {
|
const generateToken = (user) => {
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username
|
username: user.username
|
||||||
},
|
},
|
||||||
JWT_SECRET
|
JWT_SECRET,
|
||||||
// No expiration - token lasts forever
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,10 +111,12 @@ const authenticateWebSocket = (token) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
return {
|
// Verify user actually exists in database (matches REST authenticateToken behavior)
|
||||||
...decoded,
|
const user = userDb.getUserById(decoded.userId);
|
||||||
id: decoded.userId
|
if (!user) {
|
||||||
};
|
return null;
|
||||||
|
}
|
||||||
|
return { userId: user.id, username: user.username };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('WebSocket token verification error:', error);
|
console.error('WebSocket token verification error:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import matter from 'gray-matter';
|
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
|
||||||
|
import { parseFrontmatter } from '../utils/frontmatter.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -38,7 +38,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
|
|||||||
// Parse markdown file for metadata
|
// Parse markdown file for metadata
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(fullPath, 'utf8');
|
const content = await fs.readFile(fullPath, 'utf8');
|
||||||
const { data: frontmatter, content: commandContent } = matter(content);
|
const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
|
||||||
|
|
||||||
// Calculate relative path from baseDir for command name
|
// Calculate relative path from baseDir for command name
|
||||||
const relativePath = path.relative(baseDir, fullPath);
|
const relativePath = path.relative(baseDir, fullPath);
|
||||||
@@ -475,7 +475,7 @@ router.post('/load', async (req, res) => {
|
|||||||
|
|
||||||
// Read and parse the command file
|
// Read and parse the command file
|
||||||
const content = await fs.readFile(commandPath, 'utf8');
|
const content = await fs.readFile(commandPath, 'utf8');
|
||||||
const { data: metadata, content: commandContent } = matter(content);
|
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
path: commandPath,
|
path: commandPath,
|
||||||
@@ -560,7 +560,7 @@ router.post('/execute', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const content = await fs.readFile(commandPath, 'utf8');
|
const content = await fs.readFile(commandPath, 'utf8');
|
||||||
const { data: metadata, content: commandContent } = matter(content);
|
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||||
// Basic argument replacement (will be enhanced in command parser utility)
|
// Basic argument replacement (will be enhanced in command parser utility)
|
||||||
let processedContent = commandContent;
|
let processedContent = commandContent;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { exec, spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { promisify } from 'util';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { extractProjectDirectory } from '../projects.js';
|
import { extractProjectDirectory } from '../projects.js';
|
||||||
@@ -8,7 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
|||||||
import { spawnCursor } from '../cursor-cli.js';
|
import { spawnCursor } from '../cursor-cli.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const execAsync = promisify(exec);
|
const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
|
||||||
|
|
||||||
function spawnAsync(command, args, options = {}) {
|
function spawnAsync(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -47,15 +46,71 @@ function spawnAsync(command, args, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Input validation helpers (defense-in-depth)
|
||||||
|
function validateCommitRef(commit) {
|
||||||
|
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
|
||||||
|
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
|
||||||
|
throw new Error('Invalid commit reference');
|
||||||
|
}
|
||||||
|
return commit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBranchName(branch) {
|
||||||
|
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
|
||||||
|
throw new Error('Invalid branch name');
|
||||||
|
}
|
||||||
|
return branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFilePath(file, projectPath) {
|
||||||
|
if (!file || file.includes('\0')) {
|
||||||
|
throw new Error('Invalid file path');
|
||||||
|
}
|
||||||
|
// Prevent path traversal: resolve the file relative to the project root
|
||||||
|
// and ensure the result stays within the project directory
|
||||||
|
if (projectPath) {
|
||||||
|
const resolved = path.resolve(projectPath, file);
|
||||||
|
const normalizedRoot = path.resolve(projectPath) + path.sep;
|
||||||
|
if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
|
||||||
|
throw new Error('Invalid file path: path traversal detected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRemoteName(remote) {
|
||||||
|
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
|
||||||
|
throw new Error('Invalid remote name');
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProjectPath(projectPath) {
|
||||||
|
if (!projectPath || projectPath.includes('\0')) {
|
||||||
|
throw new Error('Invalid project path');
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(projectPath);
|
||||||
|
// Must be an absolute path after resolution
|
||||||
|
if (!path.isAbsolute(resolved)) {
|
||||||
|
throw new Error('Invalid project path: must be absolute');
|
||||||
|
}
|
||||||
|
// Block obviously dangerous paths
|
||||||
|
if (resolved === '/' || resolved === path.sep) {
|
||||||
|
throw new Error('Invalid project path: root directory not allowed');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to get the actual project path from the encoded project name
|
// Helper function to get the actual project path from the encoded project name
|
||||||
async function getActualProjectPath(projectName) {
|
async function getActualProjectPath(projectName) {
|
||||||
|
let projectPath;
|
||||||
try {
|
try {
|
||||||
return await extractProjectDirectory(projectName);
|
projectPath = await extractProjectDirectory(projectName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error extracting project directory for ${projectName}:`, error);
|
console.error(`Error extracting project directory for ${projectName}:`, error);
|
||||||
// Fallback to the old method
|
throw new Error(`Unable to resolve project path for "${projectName}"`);
|
||||||
return projectName.replace(/-/g, '/');
|
|
||||||
}
|
}
|
||||||
|
return validateProjectPath(projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to strip git diff headers
|
// Helper function to strip git diff headers
|
||||||
@@ -98,19 +153,140 @@ async function validateGitRepository(projectPath) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Allow any directory that is inside a work tree (repo root or nested folder).
|
// Allow any directory that is inside a work tree (repo root or nested folder).
|
||||||
const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
|
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
|
||||||
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
|
||||||
if (!isInsideWorkTree) {
|
if (!isInsideWorkTree) {
|
||||||
throw new Error('Not inside a git work tree');
|
throw new Error('Not inside a git work tree');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure git can resolve the repository root for this directory.
|
// Ensure git can resolve the repository root for this directory.
|
||||||
await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
|
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getGitErrorDetails(error) {
|
||||||
|
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingHeadRevisionError(error) {
|
||||||
|
const errorDetails = getGitErrorDetails(error).toLowerCase();
|
||||||
|
return errorDetails.includes('unknown revision')
|
||||||
|
|| errorDetails.includes('ambiguous argument')
|
||||||
|
|| errorDetails.includes('needed a single revision')
|
||||||
|
|| errorDetails.includes('bad revision');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentBranchName(projectPath) {
|
||||||
|
try {
|
||||||
|
// symbolic-ref works even when the repository has no commits.
|
||||||
|
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
|
||||||
|
const branchName = stdout.trim();
|
||||||
|
if (branchName) {
|
||||||
|
return branchName;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Fall back to rev-parse for detached HEAD and older git edge cases.
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repositoryHasCommits(projectPath) {
|
||||||
|
try {
|
||||||
|
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingHeadRevisionError(error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRepositoryRootPath(projectPath) {
|
||||||
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRepositoryRelativeFilePath(filePath) {
|
||||||
|
return String(filePath)
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\.\/+/, '')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatusFilePaths(statusOutput) {
|
||||||
|
return statusOutput
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trimEnd())
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => {
|
||||||
|
const statusPath = line.substring(3);
|
||||||
|
const renamedFilePath = statusPath.split(' -> ')[1];
|
||||||
|
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
|
||||||
|
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||||
|
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
|
||||||
|
const candidates = [normalizedFilePath];
|
||||||
|
|
||||||
|
if (
|
||||||
|
projectRelativePath
|
||||||
|
&& projectRelativePath !== '.'
|
||||||
|
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
|
||||||
|
) {
|
||||||
|
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(new Set(candidates.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepositoryFilePath(projectPath, filePath) {
|
||||||
|
validateFilePath(filePath);
|
||||||
|
|
||||||
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||||
|
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
|
||||||
|
|
||||||
|
for (const candidateFilePath of candidateFilePaths) {
|
||||||
|
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
|
||||||
|
if (stdout.trim()) {
|
||||||
|
return {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath: candidateFilePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
|
||||||
|
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
|
||||||
|
if (!normalizedFilePath.includes('/')) {
|
||||||
|
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
|
||||||
|
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
|
||||||
|
const suffixMatches = changedFilePaths.filter(
|
||||||
|
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (suffixMatches.length === 1) {
|
||||||
|
return {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath: suffixMatches[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath: candidateFilePaths[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Get git status for a project
|
// Get git status for a project
|
||||||
router.get('/status', async (req, res) => {
|
router.get('/status', async (req, res) => {
|
||||||
const { project } = req.query;
|
const { project } = req.query;
|
||||||
@@ -125,24 +301,11 @@ router.get('/status', async (req, res) => {
|
|||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch - handle case where there are no commits yet
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
let branch = 'main';
|
const hasCommits = await repositoryHasCommits(projectPath);
|
||||||
let hasCommits = true;
|
|
||||||
try {
|
|
||||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
|
||||||
branch = branchOutput.trim();
|
|
||||||
} catch (error) {
|
|
||||||
// No HEAD exists - repository has no commits yet
|
|
||||||
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
|
||||||
hasCommits = false;
|
|
||||||
branch = 'main';
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get git status
|
// Get git status
|
||||||
const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
|
||||||
|
|
||||||
const modified = [];
|
const modified = [];
|
||||||
const added = [];
|
const added = [];
|
||||||
@@ -200,44 +363,65 @@ router.get('/diff', async (req, res) => {
|
|||||||
|
|
||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
const {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check if file is untracked or deleted
|
// Check if file is untracked or deleted
|
||||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
const isUntracked = statusOutput.startsWith('??');
|
const isUntracked = statusOutput.startsWith('??');
|
||||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||||
|
|
||||||
let diff;
|
let diff;
|
||||||
if (isUntracked) {
|
if (isUntracked) {
|
||||||
// For untracked files, show the entire file content as additions
|
// For untracked files, show the entire file content as additions
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// For directories, show a simple message
|
// For directories, show a simple message
|
||||||
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
|
||||||
} else {
|
} else {
|
||||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||||
const lines = fileContent.split('\n');
|
const lines = fileContent.split('\n');
|
||||||
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
|
||||||
lines.map(line => `+${line}`).join('\n');
|
lines.map(line => `+${line}`).join('\n');
|
||||||
}
|
}
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
// For deleted files, show the entire file content from HEAD as deletions
|
// For deleted files, show the entire file content from HEAD as deletions
|
||||||
const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
const { stdout: fileContent } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
const lines = fileContent.split('\n');
|
const lines = fileContent.split('\n');
|
||||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||||
lines.map(line => `-${line}`).join('\n');
|
lines.map(line => `-${line}`).join('\n');
|
||||||
} else {
|
} else {
|
||||||
// Get diff for tracked files
|
// Get diff for tracked files
|
||||||
// First check for unstaged changes (working tree vs index)
|
// First check for unstaged changes (working tree vs index)
|
||||||
const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
|
const { stdout: unstagedDiff } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
|
|
||||||
if (unstagedDiff) {
|
if (unstagedDiff) {
|
||||||
// Show unstaged changes if they exist
|
// Show unstaged changes if they exist
|
||||||
diff = stripDiffHeaders(unstagedDiff);
|
diff = stripDiffHeaders(unstagedDiff);
|
||||||
} else {
|
} else {
|
||||||
// If no unstaged changes, check for staged changes (index vs HEAD)
|
// If no unstaged changes, check for staged changes (index vs HEAD)
|
||||||
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
|
const { stdout: stagedDiff } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['diff', '--cached', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
diff = stripDiffHeaders(stagedDiff) || '';
|
diff = stripDiffHeaders(stagedDiff) || '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,8 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
const {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check file status
|
// Check file status
|
||||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
const isUntracked = statusOutput.startsWith('??');
|
const isUntracked = statusOutput.startsWith('??');
|
||||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||||
|
|
||||||
@@ -273,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
|
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
// For deleted files, get content from HEAD
|
// For deleted files, get content from HEAD
|
||||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
const { stdout: headContent } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
oldContent = headContent;
|
oldContent = headContent;
|
||||||
currentContent = headContent; // Show the deleted content in editor
|
currentContent = headContent; // Show the deleted content in editor
|
||||||
} else {
|
} else {
|
||||||
// Get current file content
|
// Get current file content
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
@@ -291,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
|
|||||||
if (!isUntracked) {
|
if (!isUntracked) {
|
||||||
// Get the old content from HEAD for tracked files
|
// Get the old content from HEAD for tracked files
|
||||||
try {
|
try {
|
||||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
const { stdout: headContent } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['show', `HEAD:${repositoryRelativeFilePath}`],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
oldContent = headContent;
|
oldContent = headContent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File might be newly added to git (staged but not committed)
|
// File might be newly added to git (staged but not committed)
|
||||||
@@ -328,17 +529,17 @@ router.post('/initial-commit', async (req, res) => {
|
|||||||
|
|
||||||
// Check if there are already commits
|
// Check if there are already commits
|
||||||
try {
|
try {
|
||||||
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
|
||||||
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No HEAD - this is good, we can create initial commit
|
// No HEAD - this is good, we can create initial commit
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all files
|
// Add all files
|
||||||
await execAsync('git add .', { cwd: projectPath });
|
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
|
||||||
|
|
||||||
// Create initial commit
|
// Create initial commit
|
||||||
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
|
||||||
|
|
||||||
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -369,14 +570,16 @@ router.post('/commit', async (req, res) => {
|
|||||||
|
|
||||||
// Validate git repository
|
// Validate git repository
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||||
|
|
||||||
// Stage selected files
|
// Stage selected files
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await execAsync(`git add "${file}"`, { cwd: projectPath });
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commit with message
|
// Commit with message
|
||||||
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
|
||||||
|
|
||||||
res.json({ success: true, output: stdout });
|
res.json({ success: true, output: stdout });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -385,6 +588,53 @@ router.post('/commit', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Revert latest local commit (keeps changes staged)
|
||||||
|
router.post('/revert-local-commit', async (req, res) => {
|
||||||
|
const { project } = req.body;
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return res.status(400).json({ error: 'Project name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'No local commit to revert',
|
||||||
|
details: 'This repository has no commit yet.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Soft reset rewinds one commit while preserving all file changes in the index.
|
||||||
|
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
|
||||||
|
} catch (error) {
|
||||||
|
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
|
||||||
|
const isInitialCommit = errorDetails.includes('HEAD~1') &&
|
||||||
|
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
|
||||||
|
|
||||||
|
if (!isInitialCommit) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
|
||||||
|
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
output: 'Latest local commit reverted successfully. Changes were kept staged.',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Git revert local commit error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get list of branches
|
// Get list of branches
|
||||||
router.get('/branches', async (req, res) => {
|
router.get('/branches', async (req, res) => {
|
||||||
const { project } = req.query;
|
const { project } = req.query;
|
||||||
@@ -400,7 +650,7 @@ router.get('/branches', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get all branches
|
// Get all branches
|
||||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
||||||
|
|
||||||
// Parse branches
|
// Parse branches
|
||||||
const branches = stdout
|
const branches = stdout
|
||||||
@@ -439,7 +689,8 @@ router.post('/checkout', async (req, res) => {
|
|||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
|
||||||
// Checkout the branch
|
// Checkout the branch
|
||||||
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
|
validateBranchName(branch);
|
||||||
|
const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
|
||||||
|
|
||||||
res.json({ success: true, output: stdout });
|
res.json({ success: true, output: stdout });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -460,7 +711,8 @@ router.post('/create-branch', async (req, res) => {
|
|||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
|
||||||
// Create and checkout new branch
|
// Create and checkout new branch
|
||||||
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
|
validateBranchName(branch);
|
||||||
|
const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
|
||||||
|
|
||||||
res.json({ success: true, output: stdout });
|
res.json({ success: true, output: stdout });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -509,8 +761,8 @@ router.get('/commits', async (req, res) => {
|
|||||||
// Get stats for each commit
|
// Get stats for each commit
|
||||||
for (const commit of commits) {
|
for (const commit of commits) {
|
||||||
try {
|
try {
|
||||||
const { stdout: stats } = await execAsync(
|
const { stdout: stats } = await spawnAsync(
|
||||||
`git show --stat --format='' ${commit.hash}`,
|
'git', ['show', '--stat', '--format=', commit.hash],
|
||||||
{ cwd: projectPath }
|
{ cwd: projectPath }
|
||||||
);
|
);
|
||||||
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
||||||
@@ -536,14 +788,22 @@ router.get('/commit-diff', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
|
||||||
|
// Validate commit reference (defense-in-depth)
|
||||||
|
validateCommitRef(commit);
|
||||||
|
|
||||||
// Get diff for the commit
|
// Get diff for the commit
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await spawnAsync(
|
||||||
`git show ${commit}`,
|
'git', ['show', commit],
|
||||||
{ cwd: projectPath }
|
{ cwd: projectPath }
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ diff: stdout });
|
const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
|
||||||
|
const diff = isTruncated
|
||||||
|
? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
|
||||||
|
: stdout;
|
||||||
|
|
||||||
|
res.json({ diff, isTruncated });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git commit diff error:', error);
|
console.error('Git commit diff error:', error);
|
||||||
res.json({ error: error.message });
|
res.json({ error: error.message });
|
||||||
@@ -565,17 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
|
await validateGitRepository(projectPath);
|
||||||
|
const repositoryRootPath = await getRepositoryRootPath(projectPath);
|
||||||
|
|
||||||
// Get diff for selected files
|
// Get diff for selected files
|
||||||
let diffContext = '';
|
let diffContext = '';
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||||
`git diff HEAD -- "${file}"`,
|
const { stdout } = await spawnAsync(
|
||||||
{ cwd: projectPath }
|
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath }
|
||||||
);
|
);
|
||||||
if (stdout) {
|
if (stdout) {
|
||||||
diffContext += `\n--- ${file} ---\n${stdout}`;
|
diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error getting diff for ${file}:`, error);
|
console.error(`Error getting diff for ${file}:`, error);
|
||||||
@@ -587,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
|
|||||||
// Try to get content of untracked files
|
// Try to get content of untracked files
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(projectPath, file);
|
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
const content = await fs.readFile(filePath, 'utf-8');
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
|
||||||
} else {
|
} else {
|
||||||
diffContext += `\n--- ${file} (new directory) ---\n`;
|
diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading file ${file}:`, error);
|
console.error(`Error reading file ${file}:`, error);
|
||||||
@@ -763,44 +1027,51 @@ router.get('/remote-status', async (req, res) => {
|
|||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
const hasCommits = await repositoryHasCommits(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||||
|
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
|
||||||
|
const hasRemote = remotes.length > 0;
|
||||||
|
const fallbackRemoteName = hasRemote
|
||||||
|
? (remotes.includes('origin') ? 'origin' : remotes[0])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Repositories initialized with `git init` can have a branch but no commits.
|
||||||
|
// Return a non-error state so the UI can show the initial-commit workflow.
|
||||||
|
if (!hasCommits) {
|
||||||
|
return res.json({
|
||||||
|
hasRemote,
|
||||||
|
hasUpstream: false,
|
||||||
|
branch,
|
||||||
|
remoteName: fallbackRemoteName,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
isUpToDate: false,
|
||||||
|
message: 'Repository has no commits yet'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if there's a remote tracking branch (smart detection)
|
// Check if there's a remote tracking branch (smart detection)
|
||||||
let trackingBranch;
|
let trackingBranch;
|
||||||
let remoteName;
|
let remoteName;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||||
trackingBranch = stdout.trim();
|
trackingBranch = stdout.trim();
|
||||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No upstream branch configured - but check if we have remotes
|
return res.json({
|
||||||
let hasRemote = false;
|
|
||||||
let remoteName = null;
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
|
||||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
|
||||||
if (remotes.length > 0) {
|
|
||||||
hasRemote = true;
|
|
||||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
||||||
}
|
|
||||||
} catch (remoteError) {
|
|
||||||
// No remotes configured
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
hasRemote,
|
hasRemote,
|
||||||
hasUpstream: false,
|
hasUpstream: false,
|
||||||
branch,
|
branch,
|
||||||
remoteName,
|
remoteName: fallbackRemoteName,
|
||||||
message: 'No remote tracking branch configured'
|
message: 'No remote tracking branch configured'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get ahead/behind counts
|
// Get ahead/behind counts
|
||||||
const { stdout: countOutput } = await execAsync(
|
const { stdout: countOutput } = await spawnAsync(
|
||||||
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
|
||||||
{ cwd: projectPath }
|
{ cwd: projectPath }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -835,20 +1106,20 @@ router.post('/fetch', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||||
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
remoteName = stdout.trim().split('/')[0]; // Extract remote name
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// No upstream, try to fetch from origin anyway
|
// No upstream, try to fetch from origin anyway
|
||||||
console.log('No upstream configured, using origin as fallback');
|
console.log('No upstream configured, using origin as fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
|
validateRemoteName(remoteName);
|
||||||
|
const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
|
||||||
|
|
||||||
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git fetch error:', error);
|
console.error('Git fetch error:', error);
|
||||||
@@ -876,13 +1147,12 @@ router.post('/pull', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
let remoteBranch = branch; // fallback
|
let remoteBranch = branch; // fallback
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||||
const tracking = stdout.trim();
|
const tracking = stdout.trim();
|
||||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||||
@@ -891,17 +1161,19 @@ router.post('/pull', async (req, res) => {
|
|||||||
console.log('No upstream configured, using origin/branch as fallback');
|
console.log('No upstream configured, using origin/branch as fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
validateRemoteName(remoteName);
|
||||||
|
validateBranchName(remoteBranch);
|
||||||
res.json({
|
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
|
||||||
success: true,
|
|
||||||
output: stdout || 'Pull completed successfully',
|
res.json({
|
||||||
|
success: true,
|
||||||
|
output: stdout || 'Pull completed successfully',
|
||||||
remoteName,
|
remoteName,
|
||||||
remoteBranch
|
remoteBranch
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git pull error:', error);
|
console.error('Git pull error:', error);
|
||||||
|
|
||||||
// Enhanced error handling for common pull scenarios
|
// Enhanced error handling for common pull scenarios
|
||||||
let errorMessage = 'Pull failed';
|
let errorMessage = 'Pull failed';
|
||||||
let details = error.message;
|
let details = error.message;
|
||||||
@@ -943,13 +1215,12 @@ router.post('/push', async (req, res) => {
|
|||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
// Get current branch and its upstream remote
|
// Get current branch and its upstream remote
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
const branch = await getCurrentBranchName(projectPath);
|
||||||
const branch = currentBranch.trim();
|
|
||||||
|
|
||||||
let remoteName = 'origin'; // fallback
|
let remoteName = 'origin'; // fallback
|
||||||
let remoteBranch = branch; // fallback
|
let remoteBranch = branch; // fallback
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
|
||||||
const tracking = stdout.trim();
|
const tracking = stdout.trim();
|
||||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||||
@@ -958,11 +1229,13 @@ router.post('/push', async (req, res) => {
|
|||||||
console.log('No upstream configured, using origin/branch as fallback');
|
console.log('No upstream configured, using origin/branch as fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
validateRemoteName(remoteName);
|
||||||
|
validateBranchName(remoteBranch);
|
||||||
res.json({
|
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
|
||||||
success: true,
|
|
||||||
output: stdout || 'Push completed successfully',
|
res.json({
|
||||||
|
success: true,
|
||||||
|
output: stdout || 'Push completed successfully',
|
||||||
remoteName,
|
remoteName,
|
||||||
remoteBranch
|
remoteBranch
|
||||||
});
|
});
|
||||||
@@ -1012,35 +1285,38 @@ router.post('/publish', async (req, res) => {
|
|||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
|
||||||
|
// Validate branch name
|
||||||
|
validateBranchName(branch);
|
||||||
|
|
||||||
// Get current branch to verify it matches the requested branch
|
// Get current branch to verify it matches the requested branch
|
||||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
const currentBranchName = await getCurrentBranchName(projectPath);
|
||||||
const currentBranchName = currentBranch.trim();
|
|
||||||
|
|
||||||
if (currentBranchName !== branch) {
|
if (currentBranchName !== branch) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if remote exists
|
// Check if remote exists
|
||||||
let remoteName = 'origin';
|
let remoteName = 'origin';
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync('git remote', { cwd: projectPath });
|
const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
|
||||||
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
const remotes = stdout.trim().split('\n').filter(r => r.trim());
|
||||||
if (remotes.length === 0) {
|
if (remotes.length === 0) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish the branch (set upstream and push)
|
// Publish the branch (set upstream and push)
|
||||||
const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
|
validateRemoteName(remoteName);
|
||||||
|
const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -1087,10 +1363,18 @@ router.post('/discard', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check file status to determine correct discard command
|
// Check file status to determine correct discard command
|
||||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
|
|
||||||
if (!statusOutput.trim()) {
|
if (!statusOutput.trim()) {
|
||||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||||
}
|
}
|
||||||
@@ -1099,7 +1383,7 @@ router.post('/discard', async (req, res) => {
|
|||||||
|
|
||||||
if (status === '??') {
|
if (status === '??') {
|
||||||
// Untracked file or directory - delete it
|
// Untracked file or directory - delete it
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
@@ -1109,13 +1393,13 @@ router.post('/discard', async (req, res) => {
|
|||||||
}
|
}
|
||||||
} else if (status.includes('M') || status.includes('D')) {
|
} else if (status.includes('M') || status.includes('D')) {
|
||||||
// Modified or deleted file - restore from HEAD
|
// Modified or deleted file - restore from HEAD
|
||||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||||
} else if (status.includes('A')) {
|
} else if (status.includes('A')) {
|
||||||
// Added file - unstage it
|
// Added file - unstage it
|
||||||
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
|
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: `Changes discarded for ${file}` });
|
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git discard error:', error);
|
console.error('Git discard error:', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -1133,9 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const projectPath = await getActualProjectPath(project);
|
const projectPath = await getActualProjectPath(project);
|
||||||
await validateGitRepository(projectPath);
|
await validateGitRepository(projectPath);
|
||||||
|
const {
|
||||||
|
repositoryRootPath,
|
||||||
|
repositoryRelativeFilePath,
|
||||||
|
} = await resolveRepositoryFilePath(projectPath, file);
|
||||||
|
|
||||||
// Check if file is actually untracked
|
// Check if file is actually untracked
|
||||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
const { stdout: statusOutput } = await spawnAsync(
|
||||||
|
'git',
|
||||||
|
['status', '--porcelain', '--', repositoryRelativeFilePath],
|
||||||
|
{ cwd: repositoryRootPath },
|
||||||
|
);
|
||||||
|
|
||||||
if (!statusOutput.trim()) {
|
if (!statusOutput.trim()) {
|
||||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||||
@@ -1148,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete the untracked file or directory
|
// Delete the untracked file or directory
|
||||||
const filePath = path.join(projectPath, file);
|
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
// Use rm with recursive option for directories
|
// Use rm with recursive option for directories
|
||||||
await fs.rm(filePath, { recursive: true, force: true });
|
await fs.rm(filePath, { recursive: true, force: true });
|
||||||
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
|
||||||
} else {
|
} else {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
|
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git delete untracked error:', error);
|
console.error('Git delete untracked error:', error);
|
||||||
|
|||||||
303
server/routes/plugins.js
Normal file
303
server/routes/plugins.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import http from 'http';
|
||||||
|
import mime from 'mime-types';
|
||||||
|
import fs from 'fs';
|
||||||
|
import {
|
||||||
|
scanPlugins,
|
||||||
|
getPluginsConfig,
|
||||||
|
getPluginsDir,
|
||||||
|
savePluginsConfig,
|
||||||
|
getPluginDir,
|
||||||
|
resolvePluginAssetPath,
|
||||||
|
installPluginFromGit,
|
||||||
|
updatePluginFromGit,
|
||||||
|
uninstallPlugin,
|
||||||
|
} from '../utils/plugin-loader.js';
|
||||||
|
import {
|
||||||
|
startPluginServer,
|
||||||
|
stopPluginServer,
|
||||||
|
getPluginPort,
|
||||||
|
isPluginRunning,
|
||||||
|
} from '../utils/plugin-process-manager.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET / — List all installed plugins (includes server running status)
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const plugins = scanPlugins().map(p => ({
|
||||||
|
...p,
|
||||||
|
serverRunning: p.server ? isPluginRunning(p.name) : false,
|
||||||
|
}));
|
||||||
|
res.json({ plugins });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:name/manifest — Get single plugin manifest
|
||||||
|
router.get('/:name/manifest', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||||
|
}
|
||||||
|
const plugins = scanPlugins();
|
||||||
|
const plugin = plugins.find(p => p.name === req.params.name);
|
||||||
|
if (!plugin) {
|
||||||
|
return res.status(404).json({ error: 'Plugin not found' });
|
||||||
|
}
|
||||||
|
res.json(plugin);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:name/assets/* — Serve plugin static files
|
||||||
|
router.get('/:name/assets/*', (req, res) => {
|
||||||
|
const pluginName = req.params.name;
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||||
|
}
|
||||||
|
const assetPath = req.params[0];
|
||||||
|
|
||||||
|
if (!assetPath) {
|
||||||
|
return res.status(400).json({ error: 'No asset path specified' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
|
||||||
|
if (!resolvedPath) {
|
||||||
|
return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(resolvedPath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
const stream = fs.createReadStream(resolvedPath);
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Failed to read asset' });
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
|
||||||
|
router.put('/:name/enable', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { enabled } = req.body;
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: '"enabled" must be a boolean' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = scanPlugins();
|
||||||
|
const plugin = plugins.find(p => p.name === req.params.name);
|
||||||
|
if (!plugin) {
|
||||||
|
return res.status(404).json({ error: 'Plugin not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getPluginsConfig();
|
||||||
|
config[req.params.name] = { ...config[req.params.name], enabled };
|
||||||
|
savePluginsConfig(config);
|
||||||
|
|
||||||
|
// Start or stop the plugin server as needed
|
||||||
|
if (plugin.server) {
|
||||||
|
if (enabled && !isPluginRunning(plugin.name)) {
|
||||||
|
const pluginDir = getPluginDir(plugin.name);
|
||||||
|
if (pluginDir) {
|
||||||
|
try {
|
||||||
|
await startPluginServer(plugin.name, pluginDir, plugin.server);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!enabled && isPluginRunning(plugin.name)) {
|
||||||
|
await stopPluginServer(plugin.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, name: req.params.name, enabled });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Failed to update plugin', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /install — Install plugin from git URL
|
||||||
|
router.post('/install', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url || typeof url !== 'string') {
|
||||||
|
return res.status(400).json({ error: '"url" is required and must be a string' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic URL validation
|
||||||
|
if (!url.startsWith('https://') && !url.startsWith('git@')) {
|
||||||
|
return res.status(400).json({ error: 'URL must start with https:// or git@' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await installPluginFromGit(url);
|
||||||
|
|
||||||
|
// Auto-start the server if the plugin has one (enabled by default)
|
||||||
|
if (manifest.server) {
|
||||||
|
const pluginDir = getPluginDir(manifest.name);
|
||||||
|
if (pluginDir) {
|
||||||
|
try {
|
||||||
|
await startPluginServer(manifest.name, pluginDir, manifest.server);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, plugin: manifest });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: 'Failed to install plugin', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /:name/update — Pull latest from git (restarts server if running)
|
||||||
|
router.post('/:name/update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pluginName = req.params.name;
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasRunning = isPluginRunning(pluginName);
|
||||||
|
if (wasRunning) {
|
||||||
|
await stopPluginServer(pluginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await updatePluginFromGit(pluginName);
|
||||||
|
|
||||||
|
// Restart server if it was running before the update
|
||||||
|
if (wasRunning && manifest.server) {
|
||||||
|
const pluginDir = getPluginDir(pluginName);
|
||||||
|
if (pluginDir) {
|
||||||
|
try {
|
||||||
|
await startPluginServer(pluginName, pluginDir, manifest.server);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, plugin: manifest });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: 'Failed to update plugin', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
|
||||||
|
router.all('/:name/rpc/*', async (req, res) => {
|
||||||
|
const pluginName = req.params.name;
|
||||||
|
const rpcPath = req.params[0] || '';
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = getPluginPort(pluginName);
|
||||||
|
if (!port) {
|
||||||
|
// Lazily start the plugin server if it exists and is enabled
|
||||||
|
const plugins = scanPlugins();
|
||||||
|
const plugin = plugins.find(p => p.name === pluginName);
|
||||||
|
if (!plugin || !plugin.server) {
|
||||||
|
return res.status(503).json({ error: 'Plugin server is not running' });
|
||||||
|
}
|
||||||
|
if (!plugin.enabled) {
|
||||||
|
return res.status(503).json({ error: 'Plugin is disabled' });
|
||||||
|
}
|
||||||
|
const pluginDir = path.join(getPluginsDir(), plugin.dirName);
|
||||||
|
try {
|
||||||
|
port = await startPluginServer(pluginName, pluginDir, plugin.server);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject configured secrets as headers
|
||||||
|
const config = getPluginsConfig();
|
||||||
|
const pluginConfig = config[pluginName] || {};
|
||||||
|
const secrets = pluginConfig.secrets || {};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'content-type': req.headers['content-type'] || 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add per-plugin secrets as X-Plugin-Secret-* headers
|
||||||
|
for (const [key, value] of Object.entries(secrets)) {
|
||||||
|
headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct query string
|
||||||
|
const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: `/${rpcPath}${qs}`,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = http.request(options, (proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (err) => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forward body (already parsed by express JSON middleware, so re-stringify).
|
||||||
|
// Check content-length to detect whether a body was actually sent, since
|
||||||
|
// req.body can be falsy for valid payloads like 0, false, null, or {}.
|
||||||
|
const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
|
||||||
|
if (hasBody && req.body !== undefined) {
|
||||||
|
const bodyStr = JSON.stringify(req.body);
|
||||||
|
proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
|
||||||
|
proxyReq.write(bodyStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /:name — Uninstall plugin (stops server first)
|
||||||
|
router.delete('/:name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pluginName = req.params.name;
|
||||||
|
|
||||||
|
// Validate name format to prevent path traversal
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid plugin name' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop server and wait for the process to fully exit before deleting files
|
||||||
|
if (isPluginRunning(pluginName)) {
|
||||||
|
await stopPluginServer(pluginName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await uninstallPlugin(pluginName);
|
||||||
|
res.json({ success: true, name: pluginName });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -311,13 +311,11 @@ router.post('/create-workspace', async (req, res) => {
|
|||||||
* Helper function to get GitHub token from database
|
* Helper function to get GitHub token from database
|
||||||
*/
|
*/
|
||||||
async function getGithubTokenById(tokenId, userId) {
|
async function getGithubTokenById(tokenId, userId) {
|
||||||
const { getDatabase } = await import('../database/db.js');
|
const { db } = await import('../database/db.js');
|
||||||
const db = await getDatabase();
|
|
||||||
|
|
||||||
const credential = await db.get(
|
const credential = db.prepare(
|
||||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
|
||||||
[tokenId, userId, 'github_token']
|
).get(tokenId, userId, 'github_token');
|
||||||
);
|
|
||||||
|
|
||||||
// Return in the expected format (github_token field for compatibility)
|
// Return in the expected format (github_token field for compatibility)
|
||||||
if (credential) {
|
if (credential) {
|
||||||
|
|||||||
@@ -2,12 +2,29 @@ import express from 'express';
|
|||||||
import { userDb } from '../database/db.js';
|
import { userDb } from '../database/db.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||||
import { exec } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function spawnAsync(command, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { ...options, shell: false });
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||||
|
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
child.on('error', (error) => { reject(error); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) { resolve({ stdout, stderr }); return; }
|
||||||
|
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||||
|
error.code = code;
|
||||||
|
error.stdout = stdout;
|
||||||
|
error.stderr = stderr;
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/git-config', authenticateToken, async (req, res) => {
|
router.get('/git-config', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
@@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
|
|||||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
|
||||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
|
||||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||||
} catch (gitError) {
|
} catch (gitError) {
|
||||||
console.error('Error applying git config:', gitError);
|
console.error('Error applying git config:', gitError);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import matter from 'gray-matter';
|
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { parse as parseShellCommand } from 'shell-quote';
|
import { parse as parseShellCommand } from 'shell-quote';
|
||||||
|
import { parseFrontmatter } from './frontmatter.js';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
|
|||||||
*/
|
*/
|
||||||
export function parseCommand(content) {
|
export function parseCommand(content) {
|
||||||
try {
|
try {
|
||||||
const parsed = matter(content);
|
const parsed = parseFrontmatter(content);
|
||||||
return {
|
return {
|
||||||
data: parsed.data || {},
|
data: parsed.data || {},
|
||||||
content: parsed.content || '',
|
content: parsed.content || '',
|
||||||
|
|||||||
18
server/utils/frontmatter.js
Normal file
18
server/utils/frontmatter.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import matter from 'gray-matter';
|
||||||
|
|
||||||
|
const disabledFrontmatterEngine = () => ({});
|
||||||
|
|
||||||
|
const frontmatterOptions = {
|
||||||
|
language: 'yaml',
|
||||||
|
// Disable JS/JSON frontmatter parsing to avoid executable project content.
|
||||||
|
// Mirrors Gatsby's mitigation for gray-matter.
|
||||||
|
engines: {
|
||||||
|
js: disabledFrontmatterEngine,
|
||||||
|
javascript: disabledFrontmatterEngine,
|
||||||
|
json: disabledFrontmatterEngine
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseFrontmatter(content) {
|
||||||
|
return matter(content, frontmatterOptions);
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import { exec } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
function spawnAsync(command, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { shell: false });
|
||||||
|
let stdout = '';
|
||||||
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||||
|
child.on('error', (error) => { reject(error); });
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code === 0) { resolve({ stdout }); return; }
|
||||||
|
reject(new Error(`Command failed with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read git configuration from system's global git config
|
* Read git configuration from system's global git config
|
||||||
@@ -10,8 +20,8 @@ const execAsync = promisify(exec);
|
|||||||
export async function getSystemGitConfig() {
|
export async function getSystemGitConfig() {
|
||||||
try {
|
try {
|
||||||
const [nameResult, emailResult] = await Promise.all([
|
const [nameResult, emailResult] = await Promise.all([
|
||||||
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
|
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
|
||||||
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
|
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
408
server/utils/plugin-loader.js
Normal file
408
server/utils/plugin-loader.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
|
||||||
|
const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
|
||||||
|
|
||||||
|
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
|
||||||
|
|
||||||
|
/** Strip embedded credentials from a repo URL before exposing it to the client. */
|
||||||
|
function sanitizeRepoUrl(raw) {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
u.username = '';
|
||||||
|
u.password = '';
|
||||||
|
return u.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
// Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
|
||||||
|
return raw.replace(/\/\/[^@/]+@/, '//');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ALLOWED_TYPES = ['react', 'module'];
|
||||||
|
const ALLOWED_SLOTS = ['tab'];
|
||||||
|
|
||||||
|
export function getPluginsDir() {
|
||||||
|
if (!fs.existsSync(PLUGINS_DIR)) {
|
||||||
|
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
return PLUGINS_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginsConfig() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupted config, start fresh
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePluginsConfig(config) {
|
||||||
|
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateManifest(manifest) {
|
||||||
|
if (!manifest || typeof manifest !== 'object') {
|
||||||
|
return { valid: false, error: 'Manifest must be a JSON object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
||||||
|
if (!manifest[field] || typeof manifest[field] !== 'string') {
|
||||||
|
return { valid: false, error: `Missing or invalid required field: ${field}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize name — only allow alphanumeric, hyphens, underscores
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
|
||||||
|
return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
|
||||||
|
return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
|
||||||
|
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entry is a relative path without traversal
|
||||||
|
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
|
||||||
|
return { valid: false, error: 'Entry must be a relative path without ".."' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.server !== undefined && manifest.server !== null) {
|
||||||
|
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
|
||||||
|
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.permissions !== undefined) {
|
||||||
|
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
|
||||||
|
return { valid: false, error: 'Permissions must be an array of strings' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanPlugins() {
|
||||||
|
const pluginsDir = getPluginsDir();
|
||||||
|
const config = getPluginsConfig();
|
||||||
|
const plugins = [];
|
||||||
|
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenNames = new Set();
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
// Skip transient temp directories from in-progress installs
|
||||||
|
if (entry.name.startsWith('.tmp-')) continue;
|
||||||
|
|
||||||
|
const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
|
const validation = validateManifest(manifest);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip duplicate manifest names
|
||||||
|
if (seenNames.has(manifest.name)) {
|
||||||
|
console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seenNames.add(manifest.name);
|
||||||
|
|
||||||
|
// Try to read git remote URL
|
||||||
|
let repoUrl = null;
|
||||||
|
try {
|
||||||
|
const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
|
||||||
|
if (fs.existsSync(gitConfigPath)) {
|
||||||
|
const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
|
||||||
|
const match = gitConfig.match(/url\s*=\s*(.+)/);
|
||||||
|
if (match) {
|
||||||
|
repoUrl = match[1].trim().replace(/\.git$/, '');
|
||||||
|
// Convert SSH URLs to HTTPS
|
||||||
|
if (repoUrl.startsWith('git@')) {
|
||||||
|
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
||||||
|
}
|
||||||
|
// Strip embedded credentials (e.g. https://user:pass@host/...)
|
||||||
|
repoUrl = sanitizeRepoUrl(repoUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
plugins.push({
|
||||||
|
name: manifest.name,
|
||||||
|
displayName: manifest.displayName,
|
||||||
|
version: manifest.version || '0.0.0',
|
||||||
|
description: manifest.description || '',
|
||||||
|
author: manifest.author || '',
|
||||||
|
icon: manifest.icon || 'Puzzle',
|
||||||
|
type: manifest.type || 'module',
|
||||||
|
slot: manifest.slot || 'tab',
|
||||||
|
entry: manifest.entry,
|
||||||
|
server: manifest.server || null,
|
||||||
|
permissions: manifest.permissions || [],
|
||||||
|
enabled: config[manifest.name]?.enabled !== false, // enabled by default
|
||||||
|
dirName: entry.name,
|
||||||
|
repoUrl,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginDir(name) {
|
||||||
|
const plugins = scanPlugins();
|
||||||
|
const plugin = plugins.find(p => p.name === name);
|
||||||
|
if (!plugin) return null;
|
||||||
|
return path.join(getPluginsDir(), plugin.dirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePluginAssetPath(name, assetPath) {
|
||||||
|
const pluginDir = getPluginDir(name);
|
||||||
|
if (!pluginDir) return null;
|
||||||
|
|
||||||
|
const resolved = path.resolve(pluginDir, assetPath);
|
||||||
|
|
||||||
|
// Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
|
||||||
|
if (!fs.existsSync(resolved)) return null;
|
||||||
|
|
||||||
|
const realResolved = fs.realpathSync(resolved);
|
||||||
|
const realPluginDir = fs.realpathSync(pluginDir);
|
||||||
|
if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return realResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installPluginFromGit(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof url !== 'string' || !url.trim()) {
|
||||||
|
return reject(new Error('Invalid URL: must be a non-empty string'));
|
||||||
|
}
|
||||||
|
if (url.startsWith('-')) {
|
||||||
|
return reject(new Error('Invalid URL: must not start with "-"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract repo name from URL for directory name
|
||||||
|
const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
|
||||||
|
const repoName = urlClean.split('/').pop();
|
||||||
|
|
||||||
|
if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
|
||||||
|
return reject(new Error('Could not determine a valid directory name from the URL'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginsDir = getPluginsDir();
|
||||||
|
const targetDir = path.resolve(pluginsDir, repoName);
|
||||||
|
|
||||||
|
// Ensure the resolved target directory stays within the plugins directory
|
||||||
|
if (!targetDir.startsWith(pluginsDir + path.sep)) {
|
||||||
|
return reject(new Error('Invalid plugin directory path'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(targetDir)) {
|
||||||
|
return reject(new Error(`Plugin directory "${repoName}" already exists`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
|
||||||
|
|
||||||
|
const cleanupTemp = () => {
|
||||||
|
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalize = (manifest) => {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tempDir, targetDir);
|
||||||
|
} catch (err) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error(`Failed to move plugin into place: ${err.message}`));
|
||||||
|
}
|
||||||
|
resolve(manifest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
|
gitProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate manifest exists
|
||||||
|
const manifestPath = path.join(tempDir, 'manifest.json');
|
||||||
|
if (!fs.existsSync(manifestPath)) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error('Cloned repository does not contain a manifest.json'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error('manifest.json is not valid JSON'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateManifest(manifest);
|
||||||
|
if (!validation.valid) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error(`Invalid manifest: ${validation.error}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if another installed plugin already uses this name
|
||||||
|
const existing = scanPlugins().find(p => p.name === manifest.name);
|
||||||
|
if (existing) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run npm install if package.json exists.
|
||||||
|
// --ignore-scripts prevents postinstall hooks from executing arbitrary code.
|
||||||
|
const packageJsonPath = path.join(tempDir, 'package.json');
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
||||||
|
cwd: tempDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
npmProcess.on('close', (npmCode) => {
|
||||||
|
if (npmCode !== 0) {
|
||||||
|
cleanupTemp();
|
||||||
|
return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
|
||||||
|
}
|
||||||
|
finalize(manifest);
|
||||||
|
});
|
||||||
|
|
||||||
|
npmProcess.on('error', (err) => {
|
||||||
|
cleanupTemp();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
finalize(manifest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('error', (err) => {
|
||||||
|
cleanupTemp();
|
||||||
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePluginFromGit(name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const pluginDir = getPluginDir(name);
|
||||||
|
if (!pluginDir) {
|
||||||
|
return reject(new Error(`Plugin "${name}" not found`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fast-forward to avoid silent divergence
|
||||||
|
const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
|
||||||
|
cwd: pluginDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||||
|
|
||||||
|
gitProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate manifest after update
|
||||||
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
||||||
|
let manifest;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||||
|
} catch {
|
||||||
|
return reject(new Error('manifest.json is not valid JSON after update'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateManifest(manifest);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reject(new Error(`Invalid manifest after update: ${validation.error}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-run npm install if package.json exists
|
||||||
|
const packageJsonPath = path.join(pluginDir, 'package.json');
|
||||||
|
if (fs.existsSync(packageJsonPath)) {
|
||||||
|
const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
|
||||||
|
cwd: pluginDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
npmProcess.on('close', (npmCode) => {
|
||||||
|
if (npmCode !== 0) {
|
||||||
|
return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
|
||||||
|
}
|
||||||
|
resolve(manifest);
|
||||||
|
});
|
||||||
|
npmProcess.on('error', (err) => reject(err));
|
||||||
|
} else {
|
||||||
|
resolve(manifest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
gitProcess.on('error', (err) => {
|
||||||
|
reject(new Error(`Failed to spawn git: ${err.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uninstallPlugin(name) {
|
||||||
|
const pluginDir = getPluginDir(name);
|
||||||
|
if (!pluginDir) {
|
||||||
|
throw new Error(`Plugin "${name}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On Windows, file handles may be released slightly after process exit.
|
||||||
|
// Retry a few times with a short delay before giving up.
|
||||||
|
const MAX_RETRIES = 5;
|
||||||
|
const RETRY_DELAY_MS = 500;
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(pluginDir, { recursive: true, force: true });
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from config
|
||||||
|
const config = getPluginsConfig();
|
||||||
|
delete config[name];
|
||||||
|
savePluginsConfig(config);
|
||||||
|
}
|
||||||
184
server/utils/plugin-process-manager.js
Normal file
184
server/utils/plugin-process-manager.js
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
|
||||||
|
|
||||||
|
// Map<pluginName, { process, port }>
|
||||||
|
const runningPlugins = new Map();
|
||||||
|
// Map<pluginName, Promise<port>> — in-flight start operations
|
||||||
|
const startingPlugins = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a plugin's server subprocess.
|
||||||
|
* The plugin's server entry must print a JSON line with { ready: true, port: <number> }
|
||||||
|
* to stdout within 10 seconds.
|
||||||
|
*/
|
||||||
|
export function startPluginServer(name, pluginDir, serverEntry) {
|
||||||
|
if (runningPlugins.has(name)) {
|
||||||
|
return Promise.resolve(runningPlugins.get(name).port);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce concurrent starts for the same plugin
|
||||||
|
if (startingPlugins.has(name)) {
|
||||||
|
return startingPlugins.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPromise = new Promise((resolve, reject) => {
|
||||||
|
|
||||||
|
const serverPath = path.join(pluginDir, serverEntry);
|
||||||
|
|
||||||
|
// Restricted env — only essentials, no host secrets
|
||||||
|
const pluginProcess = spawn('node', [serverPath], {
|
||||||
|
cwd: pluginDir,
|
||||||
|
env: {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'production',
|
||||||
|
PLUGIN_NAME: name,
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
let stdout = '';
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
pluginProcess.kill();
|
||||||
|
reject(new Error('Plugin server did not report ready within 10 seconds'));
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
pluginProcess.stdout.on('data', (data) => {
|
||||||
|
if (resolved) return;
|
||||||
|
stdout += data.toString();
|
||||||
|
|
||||||
|
// Look for the JSON ready line
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line.trim());
|
||||||
|
if (msg.ready && typeof msg.port === 'number') {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolved = true;
|
||||||
|
runningPlugins.set(name, { process: pluginProcess, port: msg.port });
|
||||||
|
|
||||||
|
pluginProcess.on('exit', () => {
|
||||||
|
runningPlugins.delete(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
|
||||||
|
resolve(msg.port);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON yet, keep buffering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginProcess.stderr.on('data', (data) => {
|
||||||
|
console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginProcess.on('error', (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error(`Failed to start plugin server: ${err.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pluginProcess.on('exit', (code) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
runningPlugins.delete(name);
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
startingPlugins.delete(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
startingPlugins.set(name, startPromise);
|
||||||
|
return startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a plugin's server subprocess.
|
||||||
|
* Returns a Promise that resolves when the process has fully exited.
|
||||||
|
*/
|
||||||
|
export function stopPluginServer(name) {
|
||||||
|
const entry = runningPlugins.get(name);
|
||||||
|
if (!entry) return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(forceKillTimer);
|
||||||
|
runningPlugins.delete(name);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.process.once('exit', cleanup);
|
||||||
|
|
||||||
|
entry.process.kill('SIGTERM');
|
||||||
|
|
||||||
|
// Force kill after 5 seconds if still running
|
||||||
|
const forceKillTimer = setTimeout(() => {
|
||||||
|
if (runningPlugins.has(name)) {
|
||||||
|
entry.process.kill('SIGKILL');
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
console.log(`[Plugins] Server stopped for "${name}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the port a running plugin server is listening on.
|
||||||
|
*/
|
||||||
|
export function getPluginPort(name) {
|
||||||
|
return runningPlugins.get(name)?.port ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plugin's server is running.
|
||||||
|
*/
|
||||||
|
export function isPluginRunning(name) {
|
||||||
|
return runningPlugins.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop all running plugin servers (called on host shutdown).
|
||||||
|
*/
|
||||||
|
export function stopAllPlugins() {
|
||||||
|
const stops = [];
|
||||||
|
for (const [name] of runningPlugins) {
|
||||||
|
stops.push(stopPluginServer(name));
|
||||||
|
}
|
||||||
|
return Promise.all(stops);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start servers for all enabled plugins that have a server entry.
|
||||||
|
* Called once on host server boot.
|
||||||
|
*/
|
||||||
|
export async function startEnabledPluginServers() {
|
||||||
|
const plugins = scanPlugins();
|
||||||
|
const config = getPluginsConfig();
|
||||||
|
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
if (!plugin.server) continue;
|
||||||
|
if (config[plugin.name]?.enabled === false) continue;
|
||||||
|
|
||||||
|
const pluginDir = getPluginDir(plugin.name);
|
||||||
|
if (!pluginDir) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startPluginServer(plugin.name, pluginDir, plugin.server);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
export const CLAUDE_MODELS = {
|
export const CLAUDE_MODELS = {
|
||||||
// Models in SDK format (what the actual SDK accepts)
|
// Models in SDK format (what the actual SDK accepts)
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'sonnet', label: 'Sonnet' },
|
{ value: "sonnet", label: "Sonnet" },
|
||||||
{ value: 'opus', label: 'Opus' },
|
{ value: "opus", label: "Opus" },
|
||||||
{ value: 'haiku', label: 'Haiku' },
|
{ value: "haiku", label: "Haiku" },
|
||||||
{ value: 'opusplan', label: 'Opus Plan' },
|
{ value: "opusplan", label: "Opus Plan" },
|
||||||
{ value: 'sonnet[1m]', label: 'Sonnet [1M]' }
|
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: 'sonnet'
|
DEFAULT: "sonnet",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,28 +28,28 @@ export const CLAUDE_MODELS = {
|
|||||||
*/
|
*/
|
||||||
export const CURSOR_MODELS = {
|
export const CURSOR_MODELS = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' },
|
{ value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
|
||||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3' },
|
{ value: "gpt-5.3-codex", label: "GPT-5.3" },
|
||||||
{ value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
|
{ value: "gpt-5.2-high", label: "GPT-5.2 High" },
|
||||||
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
|
{ value: "gemini-3-pro", label: "Gemini 3 Pro" },
|
||||||
{ value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
|
{ value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
|
||||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
{ value: "gpt-5.1", label: "GPT-5.1" },
|
||||||
{ value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
|
{ value: "gpt-5.1-high", label: "GPT-5.1 High" },
|
||||||
{ value: 'composer-1', label: 'Composer 1' },
|
{ value: "composer-1", label: "Composer 1" },
|
||||||
{ value: 'auto', label: 'Auto' },
|
{ value: "auto", label: "Auto" },
|
||||||
{ value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
|
{ value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
|
||||||
{ value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
|
{ value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
|
||||||
{ value: 'opus-4.5', label: 'Claude 4.5 Opus' },
|
{ value: "opus-4.5", label: "Claude 4.5 Opus" },
|
||||||
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
|
{ value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
|
||||||
{ value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
|
{ value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
|
||||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||||
{ value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
|
{ value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
|
||||||
{ value: 'opus-4.1', label: 'Claude 4.1 Opus' },
|
{ value: "opus-4.1", label: "Claude 4.1 Opus" },
|
||||||
{ value: 'grok', label: 'Grok' }
|
{ value: "grok", label: "Grok" },
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: 'gpt-5-3-codex'
|
DEFAULT: "gpt-5-3-codex",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,17 +57,16 @@ export const CURSOR_MODELS = {
|
|||||||
*/
|
*/
|
||||||
export const CODEX_MODELS = {
|
export const CODEX_MODELS = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'gpt-5.4', label: 'GPT-5.4' },
|
{ value: "gpt-5.4", label: "GPT-5.4" },
|
||||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
{ value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
|
||||||
{ value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
|
{ value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
|
||||||
{ value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
|
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||||
{ value: 'gpt-5.2', label: 'GPT-5.2' },
|
{ value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
|
||||||
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
|
{ value: "o3", label: "O3" },
|
||||||
{ value: 'o3', label: 'O3' },
|
{ value: "o4-mini", label: "O4-mini" },
|
||||||
{ value: 'o4-mini', label: 'O4-mini' }
|
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: 'gpt-5.4'
|
DEFAULT: "gpt-5.4",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,16 +74,19 @@ export const CODEX_MODELS = {
|
|||||||
*/
|
*/
|
||||||
export const GEMINI_MODELS = {
|
export const GEMINI_MODELS = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
|
{ 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-pro-preview", label: "Gemini 3 Pro Preview" },
|
||||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash 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-flash", label: "Gemini 2.5 Flash" },
|
||||||
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
{ 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-lite", label: "Gemini 2.0 Flash Lite" },
|
||||||
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
|
{ 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-pro-exp", label: "Gemini 2.0 Pro Experimental" },
|
||||||
{ value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
|
{
|
||||||
|
value: "gemini-2.0-flash-thinking-exp",
|
||||||
|
label: "Gemini 2.0 Flash Thinking",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: 'gemini-2.5-flash'
|
DEFAULT: "gemini-2.5-flash",
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
|
|||||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||||
import { WebSocketProvider } from './contexts/WebSocketContext';
|
import { WebSocketProvider } from './contexts/WebSocketContext';
|
||||||
|
import { PluginsProvider } from './contexts/PluginsContext';
|
||||||
import AppContent from './components/app/AppContent';
|
import AppContent from './components/app/AppContent';
|
||||||
import i18n from './i18n/config.js';
|
import i18n from './i18n/config.js';
|
||||||
|
|
||||||
@@ -14,8 +15,9 @@ export default function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<TasksSettingsProvider>
|
<PluginsProvider>
|
||||||
<TaskMasterProvider>
|
<TasksSettingsProvider>
|
||||||
|
<TaskMasterProvider>
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
<Router basename={window.__ROUTER_BASENAME__ || ''}>
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -24,8 +26,9 @@ export default function App() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
</TaskMasterProvider>
|
</TaskMasterProvider>
|
||||||
</TasksSettingsProvider>
|
</TasksSettingsProvider>
|
||||||
|
</PluginsProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function AppContent() {
|
|||||||
setIsInputFocused,
|
setIsInputFocused,
|
||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
} = useProjectsState({
|
} = useProjectsState({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -51,14 +51,16 @@ export default function AppContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.refreshProjects = fetchProjects;
|
// Expose a non-blocking refresh for chat/session flows.
|
||||||
|
// Full loading refreshes are still available through direct fetchProjects calls.
|
||||||
|
window.refreshProjects = refreshProjectsSilently;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (window.refreshProjects === fetchProjects) {
|
if (window.refreshProjects === refreshProjectsSilently) {
|
||||||
delete window.refreshProjects;
|
delete window.refreshProjects;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [fetchProjects]);
|
}, [refreshProjectsSilently]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.openSettings = openSettings;
|
window.openSettings = openSettings;
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
|
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Folder,
|
||||||
|
Terminal,
|
||||||
|
GitBranch,
|
||||||
|
ClipboardCheck,
|
||||||
|
Ellipsis,
|
||||||
|
Puzzle,
|
||||||
|
Box,
|
||||||
|
Database,
|
||||||
|
Globe,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
BarChart3,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
|
||||||
|
import { usePlugins } from '../../contexts/PluginsContext';
|
||||||
import { AppTab } from '../../types/app';
|
import { AppTab } from '../../types/app';
|
||||||
|
|
||||||
|
const PLUGIN_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
|
||||||
|
};
|
||||||
|
|
||||||
|
type CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;
|
||||||
|
type CoreNavItem = {
|
||||||
|
id: CoreTabId;
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
type MobileNavProps = {
|
type MobileNavProps = {
|
||||||
activeTab: AppTab;
|
activeTab: AppTab;
|
||||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||||
@@ -10,41 +38,46 @@ type MobileNavProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
|
||||||
|
const { t } = useTranslation(['common', 'settings']);
|
||||||
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
|
||||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||||
|
const { plugins } = usePlugins();
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
const moreRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const navItems = [
|
const enabledPlugins = plugins.filter((p) => p.enabled);
|
||||||
{
|
const hasPlugins = enabledPlugins.length > 0;
|
||||||
id: 'chat',
|
const isPluginActive = activeTab.startsWith('plugin:');
|
||||||
icon: MessageSquare,
|
|
||||||
label: 'Chat',
|
// Close the menu on outside tap
|
||||||
onClick: () => setActiveTab('chat')
|
useEffect(() => {
|
||||||
},
|
if (!moreOpen) return;
|
||||||
{
|
const handleTap = (e: PointerEvent) => {
|
||||||
id: 'shell',
|
const target = e.target;
|
||||||
icon: Terminal,
|
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
|
||||||
label: 'Shell',
|
setMoreOpen(false);
|
||||||
onClick: () => setActiveTab('shell')
|
}
|
||||||
},
|
};
|
||||||
{
|
document.addEventListener('pointerdown', handleTap);
|
||||||
id: 'files',
|
return () => document.removeEventListener('pointerdown', handleTap);
|
||||||
icon: Folder,
|
}, [moreOpen]);
|
||||||
label: 'Files',
|
|
||||||
onClick: () => setActiveTab('files')
|
// Close menu when a plugin tab is selected
|
||||||
},
|
const selectPlugin = (name: string) => {
|
||||||
{
|
const pluginTab = `plugin:${name}` as AppTab;
|
||||||
id: 'git',
|
setActiveTab(pluginTab);
|
||||||
icon: GitBranch,
|
setMoreOpen(false);
|
||||||
label: 'Git',
|
};
|
||||||
onClick: () => setActiveTab('git')
|
|
||||||
},
|
const baseCoreItems: CoreNavItem[] = [
|
||||||
...(shouldShowTasksTab ? [{
|
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
|
||||||
id: 'tasks',
|
{ id: 'shell', icon: Terminal, label: 'Shell' },
|
||||||
icon: ClipboardCheck,
|
{ id: 'files', icon: Folder, label: 'Files' },
|
||||||
label: 'Tasks',
|
{ id: 'git', icon: GitBranch, label: 'Git' },
|
||||||
onClick: () => setActiveTab('tasks')
|
|
||||||
}] : [])
|
|
||||||
];
|
];
|
||||||
|
const coreItems: CoreNavItem[] = shouldShowTasksTab
|
||||||
|
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
|
||||||
|
: baseCoreItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -53,17 +86,17 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
|||||||
>
|
>
|
||||||
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
<div className="nav-glass mobile-nav-float rounded-2xl border border-border/30">
|
||||||
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
|
<div className="flex items-center justify-around gap-0.5 px-1 py-1.5">
|
||||||
{navItems.map((item) => {
|
{coreItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = activeTab === item.id;
|
const isActive = activeTab === item.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={item.onClick}
|
onClick={() => setActiveTab(item.id)}
|
||||||
onTouchStart={(e) => {
|
onTouchStart={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
item.onClick();
|
setActiveTab(item.id);
|
||||||
}}
|
}}
|
||||||
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
|
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
@@ -85,6 +118,60 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* "More" button — only shown when there are enabled plugins */}
|
||||||
|
{hasPlugins && (
|
||||||
|
<div ref={moreRef} className="relative flex-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setMoreOpen((v) => !v)}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setMoreOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
aria-label="More plugins"
|
||||||
|
aria-expanded={moreOpen}
|
||||||
|
>
|
||||||
|
{(isPluginActive && !moreOpen) && (
|
||||||
|
<div className="bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl" />
|
||||||
|
)}
|
||||||
|
<Ellipsis
|
||||||
|
className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}
|
||||||
|
strokeWidth={isPluginActive ? 2.4 : 1.8}
|
||||||
|
/>
|
||||||
|
<span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>
|
||||||
|
{t('settings:pluginSettings.morePlugins')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Popover menu */}
|
||||||
|
{moreOpen && (
|
||||||
|
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150">
|
||||||
|
{enabledPlugins.map((p) => {
|
||||||
|
const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
|
||||||
|
const isActive = activeTab === `plugin:${p.name}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.name}
|
||||||
|
onClick={() => selectPlugin(p.name)}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
|
||||||
|
? 'bg-primary/8 text-primary'
|
||||||
|
: 'text-foreground hover:bg-muted/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 flex-shrink-0" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||||
|
<span className="truncate">{p.displayName}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
|
|||||||
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
||||||
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
||||||
onNavigateToSession?: (sessionId: string) => void;
|
onNavigateToSession?: (sessionId: string) => void;
|
||||||
|
onWebSocketReconnect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appendStreamingChunk = (
|
const appendStreamingChunk = (
|
||||||
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect,
|
||||||
}: UseChatRealtimeHandlersArgs) {
|
}: UseChatRealtimeHandlersArgs) {
|
||||||
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
|
|||||||
: null;
|
: null;
|
||||||
const messageType = String(latestMessage.type);
|
const messageType = String(latestMessage.type);
|
||||||
|
|
||||||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
|
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
|
||||||
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
const isGlobalMessage = globalMessageTypes.includes(messageType);
|
||||||
const lifecycleMessageTypes = new Set([
|
const lifecycleMessageTypes = new Set([
|
||||||
'claude-complete',
|
'claude-complete',
|
||||||
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'websocket-reconnected':
|
||||||
|
// WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
|
||||||
|
onWebSocketReconnect?.();
|
||||||
|
break;
|
||||||
|
|
||||||
case 'token-budget':
|
case 'token-budget':
|
||||||
if (latestMessage.data) {
|
if (latestMessage.data) {
|
||||||
setTokenBudget(latestMessage.data);
|
setTokenBudget(latestMessage.data);
|
||||||
@@ -692,14 +699,28 @@ export function useChatRealtimeHandlers({
|
|||||||
const updated = [...previous];
|
const updated = [...previous];
|
||||||
const lastIndex = updated.length - 1;
|
const lastIndex = updated.length - 1;
|
||||||
const last = updated[lastIndex];
|
const last = updated[lastIndex];
|
||||||
|
const normalizedTextResult = textResult.trim();
|
||||||
|
|
||||||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||||||
const finalContent =
|
const finalContent =
|
||||||
textResult && textResult.trim()
|
normalizedTextResult
|
||||||
? textResult
|
? textResult
|
||||||
: `${last.content || ''}${pendingChunk || ''}`;
|
: `${last.content || ''}${pendingChunk || ''}`;
|
||||||
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
// Clone the message instead of mutating in place so React can reliably detect state updates.
|
||||||
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
|
||||||
} else if (textResult && textResult.trim()) {
|
} else if (normalizedTextResult) {
|
||||||
|
const lastAssistantText =
|
||||||
|
last && last.type === 'assistant' && !last.isToolUse
|
||||||
|
? String(last.content || '').trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Cursor can emit the same final text through both streaming and result payloads.
|
||||||
|
// Skip adding a second assistant bubble when the final text is unchanged.
|
||||||
|
const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
|
||||||
|
if (isDuplicateFinalText) {
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
updated.push({
|
updated.push({
|
||||||
type: resultData.is_error ? 'error' : 'assistant',
|
type: resultData.is_error ? 'error' : 'assistant',
|
||||||
content: textResult,
|
content: textResult,
|
||||||
|
|||||||
@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
|
||||||
|
/<user_info>[\s\S]*?<\/user_info>/gi,
|
||||||
|
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
|
||||||
|
/<available_skills>[\s\S]*?<\/available_skills>/gi,
|
||||||
|
/<environment_context>[\s\S]*?<\/environment_context>/gi,
|
||||||
|
/<environment_info>[\s\S]*?<\/environment_info>/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
const extractCursorUserQuery = (rawText: string): string => {
|
||||||
|
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
|
||||||
|
if (userQueryMatches.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return userQueryMatches
|
||||||
|
.map((match) => (match[1] || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeCursorUserMessageText = (rawText: string): string => {
|
||||||
|
const decodedText = decodeHtmlEntities(rawText || '').trim();
|
||||||
|
if (!decodedText) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
|
||||||
|
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
|
||||||
|
const extractedUserQuery = extractCursorUserQuery(decodedText);
|
||||||
|
if (extractedUserQuery) {
|
||||||
|
return extractedUserQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitizedText = decodedText;
|
||||||
|
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
|
||||||
|
sanitizedText = sanitizedText.replace(pattern, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizedText.trim();
|
||||||
|
};
|
||||||
|
|
||||||
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
const toAbsolutePath = (projectPath: string, filePath?: string) => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
return filePath;
|
return filePath;
|
||||||
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
|
|||||||
console.log('Error parsing blob content:', error);
|
console.log('Error parsing blob content:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (role === 'user') {
|
||||||
|
text = sanitizeCursorUserMessageText(text);
|
||||||
|
}
|
||||||
|
|
||||||
if (text && text.trim()) {
|
if (text && text.trim()) {
|
||||||
const message: ChatMessage = {
|
const message: ChatMessage = {
|
||||||
type: role,
|
type: role,
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function ChatInterface({
|
|||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
scrollToBottomAndReset,
|
scrollToBottomAndReset,
|
||||||
handleScroll,
|
handleScroll,
|
||||||
|
loadSessionMessages,
|
||||||
} = useChatSessionState({
|
} = useChatSessionState({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -197,6 +198,23 @@ function ChatInterface({
|
|||||||
setPendingPermissionRequests,
|
setPendingPermissionRequests,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
|
||||||
|
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
|
||||||
|
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
|
||||||
|
// would be stuck in "Processing..." forever without this reset.
|
||||||
|
const handleWebSocketReconnect = useCallback(async () => {
|
||||||
|
if (!selectedProject || !selectedSession) return;
|
||||||
|
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
|
||||||
|
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
|
||||||
|
if (messages && messages.length > 0) {
|
||||||
|
setChatMessages(messages);
|
||||||
|
}
|
||||||
|
// Reset loading state — if the session is still active, new WebSocket messages will
|
||||||
|
// set it back to true. If it died, this clears the permanent frozen state.
|
||||||
|
setIsLoading(false);
|
||||||
|
setCanAbortSession(false);
|
||||||
|
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
|
||||||
|
|
||||||
useChatRealtimeHandlers({
|
useChatRealtimeHandlers({
|
||||||
latestMessage,
|
latestMessage,
|
||||||
provider,
|
provider,
|
||||||
@@ -219,6 +237,7 @@ function ChatInterface({
|
|||||||
onSessionNotProcessing,
|
onSessionNotProcessing,
|
||||||
onReplaceTemporarySession,
|
onReplaceTemporarySession,
|
||||||
onNavigateToSession,
|
onNavigateToSession,
|
||||||
|
onWebSocketReconnect: handleWebSocketReconnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -301,8 +301,7 @@ export default function ChatComposer({
|
|||||||
onBlur={() => onInputFocusChange?.(false)}
|
onBlur={() => onInputFocusChange?.(false)}
|
||||||
onInput={onTextareaInput}
|
onInput={onTextareaInput}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={isLoading}
|
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
||||||
className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
|
|
||||||
style={{ height: '50px' }}
|
style={{ height: '50px' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { memo, useMemo } from 'react';
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type {
|
import type {
|
||||||
@@ -9,10 +9,10 @@ import type {
|
|||||||
} from '../../types/types';
|
} from '../../types/types';
|
||||||
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
import { formatUsageLimitText } from '../../utils/chatFormatting';
|
||||||
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
|
||||||
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
import { ToolRenderer, shouldHideToolResult } from '../../tools';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
import MessageCopyControl from './MessageCopyControl';
|
||||||
|
|
||||||
type DiffLine = {
|
type DiffLine = {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -20,7 +20,7 @@ type DiffLine = {
|
|||||||
lineNum: number;
|
lineNum: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MessageComponentProps {
|
type MessageComponentProps = {
|
||||||
message: ChatMessage;
|
message: ChatMessage;
|
||||||
prevMessage: ChatMessage | null;
|
prevMessage: ChatMessage | null;
|
||||||
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
createDiff: (oldStr: string, newStr: string) => DiffLine[];
|
||||||
@@ -32,7 +32,7 @@ interface MessageComponentProps {
|
|||||||
showThinking?: boolean;
|
showThinking?: boolean;
|
||||||
selectedProject?: Project | null;
|
selectedProject?: Project | null;
|
||||||
provider: Provider | string;
|
provider: Provider | string;
|
||||||
}
|
};
|
||||||
|
|
||||||
type InteractiveOption = {
|
type InteractiveOption = {
|
||||||
number: string;
|
number: string;
|
||||||
@@ -41,6 +41,7 @@ type InteractiveOption = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
type PermissionGrantState = 'idle' | 'granted' | 'error';
|
||||||
|
const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
|
||||||
|
|
||||||
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
@@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
(prevMessage.type === 'user') ||
|
(prevMessage.type === 'user') ||
|
||||||
(prevMessage.type === 'tool') ||
|
(prevMessage.type === 'tool') ||
|
||||||
(prevMessage.type === 'error'));
|
(prevMessage.type === 'error'));
|
||||||
const messageRef = React.useRef<HTMLDivElement | null>(null);
|
const messageRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
|
||||||
const [permissionGrantState, setPermissionGrantState] = React.useState<PermissionGrantState>('idle');
|
const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');
|
||||||
const [messageCopied, setMessageCopied] = React.useState(false);
|
const userCopyContent = String(message.content || '');
|
||||||
|
const formattedMessageContent = useMemo(
|
||||||
|
() => formatUsageLimitText(String(message.content || '')),
|
||||||
|
[message.content]
|
||||||
|
);
|
||||||
|
const assistantCopyContent = message.isToolUse
|
||||||
|
? String(message.displayText || message.content || '')
|
||||||
|
: formattedMessageContent;
|
||||||
|
const isCommandOrFileEditToolResponse = Boolean(
|
||||||
|
message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
|
||||||
|
);
|
||||||
|
const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
|
||||||
|
const shouldShowAssistantCopyControl = message.type === 'assistant' &&
|
||||||
|
assistantCopyContent.trim().length > 0 &&
|
||||||
|
!isCommandOrFileEditToolResponse;
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
setPermissionGrantState('idle');
|
setPermissionGrantState('idle');
|
||||||
}, [permissionSuggestion?.entry, message.toolId]);
|
}, [permissionSuggestion?.entry, message.toolId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
const node = messageRef.current;
|
const node = messageRef.current;
|
||||||
if (!autoExpandTools || !node || !message.isToolUse) return;
|
if (!autoExpandTools || !node || !message.isToolUse) return;
|
||||||
|
|
||||||
@@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
|
||||||
<button
|
{shouldShowUserCopyControl && (
|
||||||
type="button"
|
<MessageCopyControl content={userCopyContent} messageType="user" />
|
||||||
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="h-3.5 w-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="h-3.5 w-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>
|
<span>{formattedTime}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -430,7 +411,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const content = formatUsageLimitText(String(message.content || ''));
|
const content = formattedMessageContent;
|
||||||
|
|
||||||
// Detect if content is pure JSON (starts with { or [)
|
// Detect if content is pure JSON (starts with { or [)
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
@@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isGrouped && (
|
{(shouldShowAssistantCopyControl || !isGrouped) && (
|
||||||
<div className="mt-1 text-[11px] text-gray-400 dark:text-gray-500">
|
<div className="mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500">
|
||||||
{formattedTime}
|
{shouldShowAssistantCopyControl && (
|
||||||
|
<MessageCopyControl content={assistantCopyContent} messageType="assistant" />
|
||||||
|
)}
|
||||||
|
{!isGrouped && <span>{formattedTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
215
src/components/chat/view/subcomponents/MessageCopyControl.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { copyTextToClipboard } from '../../../../utils/clipboard';
|
||||||
|
|
||||||
|
const COPY_SUCCESS_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
type CopyFormat = 'text' | 'markdown';
|
||||||
|
|
||||||
|
type CopyFormatOption = {
|
||||||
|
format: CopyFormat;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts markdown into readable plain text for "Copy as text".
|
||||||
|
const convertMarkdownToPlainText = (markdown: string): string => {
|
||||||
|
let plainText = markdown.replace(/\r\n/g, '\n');
|
||||||
|
const codeBlocks: string[] = [];
|
||||||
|
plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => {
|
||||||
|
const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;
|
||||||
|
codeBlocks.push(code.replace(/\n$/, ''));
|
||||||
|
return placeholder;
|
||||||
|
});
|
||||||
|
plainText = plainText.replace(/`([^`]+)`/g, '$1');
|
||||||
|
plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1');
|
||||||
|
plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
|
||||||
|
plainText = plainText.replace(/^>\s?/gm, '');
|
||||||
|
plainText = plainText.replace(/^#{1,6}\s+/gm, '');
|
||||||
|
plainText = plainText.replace(/^[-*+]\s+/gm, '');
|
||||||
|
plainText = plainText.replace(/^\d+\.\s+/gm, '');
|
||||||
|
plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2');
|
||||||
|
plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2');
|
||||||
|
plainText = plainText.replace(/~~(.*?)~~/g, '$1');
|
||||||
|
plainText = plainText.replace(/<\/?[^>]+(>|$)/g, '');
|
||||||
|
plainText = plainText.replace(/\n{3,}/g, '\n\n');
|
||||||
|
plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');
|
||||||
|
return plainText.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const MessageCopyControl = ({
|
||||||
|
content,
|
||||||
|
messageType,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
messageType: 'user' | 'assistant';
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('chat');
|
||||||
|
const canSelectCopyFormat = messageType === 'assistant';
|
||||||
|
const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text';
|
||||||
|
const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const copyFormatOptions: CopyFormatOption[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
format: 'markdown',
|
||||||
|
label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: 'text',
|
||||||
|
label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedFormatTag = selectedFormat === 'markdown'
|
||||||
|
? t('copyMessage.markdownShort', { defaultValue: 'MD' })
|
||||||
|
: t('copyMessage.textShort', { defaultValue: 'TXT' });
|
||||||
|
|
||||||
|
const copyPayload = useMemo(() => {
|
||||||
|
if (selectedFormat === 'markdown') {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
return convertMarkdownToPlainText(content);
|
||||||
|
}, [content, selectedFormat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedFormat(defaultFormat);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}, [defaultFormat]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Close the dropdown when clicking anywhere outside this control.
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (!isDropdownOpen) return;
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousedown', closeOnOutsideClick);
|
||||||
|
};
|
||||||
|
}, [isDropdownOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyFeedbackTimerRef.current) {
|
||||||
|
clearTimeout(copyFeedbackTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCopyClick = async () => {
|
||||||
|
if (!copyPayload.trim()) return;
|
||||||
|
const didCopy = await copyTextToClipboard(copyPayload);
|
||||||
|
if (!didCopy) return;
|
||||||
|
|
||||||
|
setCopied(true);
|
||||||
|
if (copyFeedbackTimerRef.current) {
|
||||||
|
clearTimeout(copyFeedbackTimerRef.current);
|
||||||
|
}
|
||||||
|
copyFeedbackTimerRef.current = setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, COPY_SUCCESS_TIMEOUT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormatChange = (format: CopyFormat) => {
|
||||||
|
setSelectedFormat(format);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toneClass = messageType === 'user'
|
||||||
|
? 'text-blue-100 hover:text-white'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300';
|
||||||
|
const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy');
|
||||||
|
const rootClassName = canSelectCopyFormat
|
||||||
|
? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto'
|
||||||
|
: 'relative flex items-center gap-0.5';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className={rootClassName}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyClick}
|
||||||
|
title={copyTitle}
|
||||||
|
aria-label={copyTitle}
|
||||||
|
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<svg className="h-3.5 w-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="h-3.5 w-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" />
|
||||||
|
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide">{selectedFormatTag}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canSelectCopyFormat && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
||||||
|
className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}
|
||||||
|
aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
|
title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
{copyFormatOptions.map((option) => {
|
||||||
|
const isSelected = option.format === selectedFormat;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.format}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleFormatChange(option.format)}
|
||||||
|
className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected
|
||||||
|
? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-medium">{option.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageCopyControl;
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import React from 'react';
|
import React from "react";
|
||||||
import { Check, ChevronDown } from 'lucide-react';
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||||
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants';
|
import {
|
||||||
import type { ProjectSession, SessionProvider } from '../../../../types/app';
|
CLAUDE_MODELS,
|
||||||
import { NextTaskBanner } from '../../../task-master';
|
CURSOR_MODELS,
|
||||||
|
CODEX_MODELS,
|
||||||
|
GEMINI_MODELS,
|
||||||
|
} from "../../../../../shared/modelConstants";
|
||||||
|
import type { ProjectSession, SessionProvider } from "../../../../types/app";
|
||||||
|
import { NextTaskBanner } from "../../../task-master";
|
||||||
|
|
||||||
interface ProviderSelectionEmptyStateProps {
|
type ProviderSelectionEmptyStateProps = {
|
||||||
selectedSession: ProjectSession | null;
|
selectedSession: ProjectSession | null;
|
||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
provider: SessionProvider;
|
provider: SessionProvider;
|
||||||
@@ -24,7 +29,7 @@ interface ProviderSelectionEmptyStateProps {
|
|||||||
isTaskMasterInstalled: boolean | null;
|
isTaskMasterInstalled: boolean | null;
|
||||||
onShowAllTasks?: (() => void) | null;
|
onShowAllTasks?: (() => void) | null;
|
||||||
setInput: React.Dispatch<React.SetStateAction<string>>;
|
setInput: React.Dispatch<React.SetStateAction<string>>;
|
||||||
}
|
};
|
||||||
|
|
||||||
type ProviderDef = {
|
type ProviderDef = {
|
||||||
id: SessionProvider;
|
id: SessionProvider;
|
||||||
@@ -37,50 +42,56 @@ type ProviderDef = {
|
|||||||
|
|
||||||
const PROVIDERS: ProviderDef[] = [
|
const PROVIDERS: ProviderDef[] = [
|
||||||
{
|
{
|
||||||
id: 'claude',
|
id: "claude",
|
||||||
name: 'Claude Code',
|
name: "Claude Code",
|
||||||
infoKey: 'providerSelection.providerInfo.anthropic',
|
infoKey: "providerSelection.providerInfo.anthropic",
|
||||||
accent: 'border-primary',
|
accent: "border-primary",
|
||||||
ring: 'ring-primary/15',
|
ring: "ring-primary/15",
|
||||||
check: 'bg-primary text-primary-foreground',
|
check: "bg-primary text-primary-foreground",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cursor',
|
id: "cursor",
|
||||||
name: 'Cursor',
|
name: "Cursor",
|
||||||
infoKey: 'providerSelection.providerInfo.cursorEditor',
|
infoKey: "providerSelection.providerInfo.cursorEditor",
|
||||||
accent: 'border-violet-500 dark:border-violet-400',
|
accent: "border-violet-500 dark:border-violet-400",
|
||||||
ring: 'ring-violet-500/15',
|
ring: "ring-violet-500/15",
|
||||||
check: 'bg-violet-500 text-white',
|
check: "bg-violet-500 text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'codex',
|
id: "codex",
|
||||||
name: 'Codex',
|
name: "Codex",
|
||||||
infoKey: 'providerSelection.providerInfo.openai',
|
infoKey: "providerSelection.providerInfo.openai",
|
||||||
accent: 'border-emerald-600 dark:border-emerald-400',
|
accent: "border-emerald-600 dark:border-emerald-400",
|
||||||
ring: 'ring-emerald-600/15',
|
ring: "ring-emerald-600/15",
|
||||||
check: 'bg-emerald-600 dark:bg-emerald-500 text-white',
|
check: "bg-emerald-600 dark:bg-emerald-500 text-white",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini',
|
id: "gemini",
|
||||||
name: 'Gemini',
|
name: "Gemini",
|
||||||
infoKey: 'providerSelection.providerInfo.google',
|
infoKey: "providerSelection.providerInfo.google",
|
||||||
accent: 'border-blue-500 dark:border-blue-400',
|
accent: "border-blue-500 dark:border-blue-400",
|
||||||
ring: 'ring-blue-500/15',
|
ring: "ring-blue-500/15",
|
||||||
check: 'bg-blue-500 text-white',
|
check: "bg-blue-500 text-white",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getModelConfig(p: SessionProvider) {
|
function getModelConfig(p: SessionProvider) {
|
||||||
if (p === 'claude') return CLAUDE_MODELS;
|
if (p === "claude") return CLAUDE_MODELS;
|
||||||
if (p === 'codex') return CODEX_MODELS;
|
if (p === "codex") return CODEX_MODELS;
|
||||||
if (p === 'gemini') return GEMINI_MODELS;
|
if (p === "gemini") return GEMINI_MODELS;
|
||||||
return CURSOR_MODELS;
|
return CURSOR_MODELS;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) {
|
function getModelValue(
|
||||||
if (p === 'claude') return c;
|
p: SessionProvider,
|
||||||
if (p === 'codex') return co;
|
c: string,
|
||||||
if (p === 'gemini') return g;
|
cu: string,
|
||||||
|
co: string,
|
||||||
|
g: string,
|
||||||
|
) {
|
||||||
|
if (p === "claude") return c;
|
||||||
|
if (p === "codex") return co;
|
||||||
|
if (p === "gemini") return g;
|
||||||
return cu;
|
return cu;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,24 +114,41 @@ export default function ProviderSelectionEmptyState({
|
|||||||
onShowAllTasks,
|
onShowAllTasks,
|
||||||
setInput,
|
setInput,
|
||||||
}: ProviderSelectionEmptyStateProps) {
|
}: ProviderSelectionEmptyStateProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation("chat");
|
||||||
const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' });
|
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||||
|
defaultValue: "Start the next task",
|
||||||
|
});
|
||||||
|
|
||||||
const selectProvider = (next: SessionProvider) => {
|
const selectProvider = (next: SessionProvider) => {
|
||||||
setProvider(next);
|
setProvider(next);
|
||||||
localStorage.setItem('selected-provider', next);
|
localStorage.setItem("selected-provider", next);
|
||||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModelChange = (value: string) => {
|
const handleModelChange = (value: string) => {
|
||||||
if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); }
|
if (provider === "claude") {
|
||||||
else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); }
|
setClaudeModel(value);
|
||||||
else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); }
|
localStorage.setItem("claude-model", value);
|
||||||
else { setCursorModel(value); localStorage.setItem('cursor-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 modelConfig = getModelConfig(provider);
|
||||||
const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel);
|
const currentModel = getModelValue(
|
||||||
|
provider,
|
||||||
|
claudeModel,
|
||||||
|
cursorModel,
|
||||||
|
codexModel,
|
||||||
|
geminiModel,
|
||||||
|
);
|
||||||
|
|
||||||
/* ── New session — provider picker ── */
|
/* ── New session — provider picker ── */
|
||||||
if (!selectedSession && !currentSessionId) {
|
if (!selectedSession && !currentSessionId) {
|
||||||
@@ -130,10 +158,10 @@ export default function ProviderSelectionEmptyState({
|
|||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
<h2 className="text-lg font-semibold tracking-tight text-foreground sm:text-xl">
|
||||||
{t('providerSelection.title')}
|
{t("providerSelection.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">
|
<p className="mt-1 text-[13px] text-muted-foreground">
|
||||||
{t('providerSelection.description')}
|
{t("providerSelection.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,23 +177,30 @@ export default function ProviderSelectionEmptyState({
|
|||||||
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
|
relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2
|
||||||
pb-4 pt-5 transition-all duration-150
|
pb-4 pt-5 transition-all duration-150
|
||||||
active:scale-[0.97]
|
active:scale-[0.97]
|
||||||
${active
|
${
|
||||||
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
|
active
|
||||||
: 'border-border bg-card/60 hover:border-border/80 hover:bg-card'
|
? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`
|
||||||
|
: "border-border bg-card/60 hover:border-border/80 hover:bg-card"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<SessionProviderLogo
|
<SessionProviderLogo
|
||||||
provider={p.id}
|
provider={p.id}
|
||||||
className={`h-9 w-9 transition-transform duration-150 ${active ? 'scale-110' : ''}`}
|
className={`h-9 w-9 transition-transform duration-150 ${active ? "scale-110" : ""}`}
|
||||||
/>
|
/>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-[13px] font-semibold leading-none text-foreground">{p.name}</p>
|
<p className="text-[13px] font-semibold leading-none text-foreground">
|
||||||
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">{t(p.infoKey)}</p>
|
{p.name}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10px] leading-tight text-muted-foreground">
|
||||||
|
{t(p.infoKey)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Check badge */}
|
{/* Check badge */}
|
||||||
{active && (
|
{active && (
|
||||||
<div className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}>
|
<div
|
||||||
|
className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}
|
||||||
|
>
|
||||||
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
<Check className="h-2.5 w-2.5" strokeWidth={3} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -175,9 +210,13 @@ export default function ProviderSelectionEmptyState({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model picker — appears after provider is chosen */}
|
{/* Model picker — appears after provider is chosen */}
|
||||||
<div className={`transition-all duration-200 ${provider ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-1 opacity-0'}`}>
|
<div
|
||||||
|
className={`transition-all duration-200 ${provider ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1 opacity-0"}`}
|
||||||
|
>
|
||||||
<div className="mb-5 flex items-center justify-center gap-2">
|
<div className="mb-5 flex items-center justify-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">{t('providerSelection.selectModel')}</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("providerSelection.selectModel")}
|
||||||
|
</span>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<select
|
<select
|
||||||
value={currentModel}
|
value={currentModel}
|
||||||
@@ -185,9 +224,13 @@ export default function ProviderSelectionEmptyState({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
|
className="cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
>
|
>
|
||||||
{modelConfig.OPTIONS.map(({ value, label }: { value: string; label: string }) => (
|
{modelConfig.OPTIONS.map(
|
||||||
<option key={value} value={value}>{label}</option>
|
({ value, label }: { value: string; label: string }) => (
|
||||||
))}
|
<option key={value + label} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -196,10 +239,18 @@ export default function ProviderSelectionEmptyState({
|
|||||||
<p className="text-center text-sm text-muted-foreground/70">
|
<p className="text-center text-sm text-muted-foreground/70">
|
||||||
{
|
{
|
||||||
{
|
{
|
||||||
claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }),
|
claude: t("providerSelection.readyPrompt.claude", {
|
||||||
cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }),
|
model: claudeModel,
|
||||||
codex: t('providerSelection.readyPrompt.codex', { model: codexModel }),
|
}),
|
||||||
gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }),
|
cursor: t("providerSelection.readyPrompt.cursor", {
|
||||||
|
model: cursorModel,
|
||||||
|
}),
|
||||||
|
codex: t("providerSelection.readyPrompt.codex", {
|
||||||
|
model: codexModel,
|
||||||
|
}),
|
||||||
|
gemini: t("providerSelection.readyPrompt.gemini", {
|
||||||
|
model: geminiModel,
|
||||||
|
}),
|
||||||
}[provider]
|
}[provider]
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
@@ -208,7 +259,10 @@ export default function ProviderSelectionEmptyState({
|
|||||||
{/* Task banner */}
|
{/* Task banner */}
|
||||||
{provider && tasksEnabled && isTaskMasterInstalled && (
|
{provider && tasksEnabled && isTaskMasterInstalled && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
<NextTaskBanner
|
||||||
|
onStartTask={() => setInput(nextTaskPrompt)}
|
||||||
|
onShowAllTasks={onShowAllTasks}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,12 +275,19 @@ export default function ProviderSelectionEmptyState({
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="max-w-md px-6 text-center">
|
<div className="max-w-md px-6 text-center">
|
||||||
<p className="mb-1.5 text-lg font-semibold text-foreground">{t('session.continue.title')}</p>
|
<p className="mb-1.5 text-lg font-semibold text-foreground">
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">{t('session.continue.description')}</p>
|
{t("session.continue.title")}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{t("session.continue.description")}
|
||||||
|
</p>
|
||||||
|
|
||||||
{tasksEnabled && isTaskMasterInstalled && (
|
{tasksEnabled && isTaskMasterInstalled && (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<NextTaskBanner onStartTask={() => setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} />
|
<NextTaskBanner
|
||||||
|
onStartTask={() => setInput(nextTaskPrompt)}
|
||||||
|
onShowAllTasks={onShowAllTasks}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'Confirm Pull',
|
pull: 'Confirm Pull',
|
||||||
push: 'Confirm Push',
|
push: 'Confirm Push',
|
||||||
publish: 'Publish Branch',
|
publish: 'Publish Branch',
|
||||||
|
revertLocalCommit: 'Revert Local Commit',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
||||||
@@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'Pull',
|
pull: 'Pull',
|
||||||
push: 'Push',
|
push: 'Push',
|
||||||
publish: 'Publish',
|
publish: 'Publish',
|
||||||
|
revertLocalCommit: 'Revert Commit',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
@@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'bg-green-600 hover:bg-green-700',
|
pull: 'bg-green-600 hover:bg-green-700',
|
||||||
push: 'bg-orange-600 hover:bg-orange-700',
|
push: 'bg-orange-600 hover:bg-orange-700',
|
||||||
publish: 'bg-purple-600 hover:bg-purple-700',
|
publish: 'bg-purple-600 hover:bg-purple-700',
|
||||||
|
revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
@@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, stri
|
|||||||
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
pull: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
push: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
publish: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
|
revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
||||||
@@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {
|
|||||||
pull: 'text-yellow-600 dark:text-yellow-400',
|
pull: 'text-yellow-600 dark:text-yellow-400',
|
||||||
push: 'text-yellow-600 dark:text-yellow-400',
|
push: 'text-yellow-600 dark:text-yellow-400',
|
||||||
publish: 'text-yellow-600 dark:text-yellow-400',
|
publish: 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',
|
||||||
};
|
};
|
||||||
|
|||||||
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
48
src/components/git-panel/hooks/useRevertLocalCommit.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import type { GitOperationResponse } from '../types/types';
|
||||||
|
|
||||||
|
type UseRevertLocalCommitOptions = {
|
||||||
|
projectName: string | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readJson<T>(response: Response): Promise<T> {
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {
|
||||||
|
const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);
|
||||||
|
|
||||||
|
const revertLatestLocalCommit = useCallback(async () => {
|
||||||
|
if (!projectName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRevertingLocalCommit(true);
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch('/api/git/revert-local-commit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project: projectName }),
|
||||||
|
});
|
||||||
|
const data = await readJson<GitOperationResponse>(response);
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reverting local commit:', error);
|
||||||
|
} finally {
|
||||||
|
setIsRevertingLocalCommit(false);
|
||||||
|
}
|
||||||
|
}, [onSuccess, projectName]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRevertingLocalCommit,
|
||||||
|
revertLatestLocalCommit,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { Project } from '../../../types/app';
|
|||||||
export type GitPanelView = 'changes' | 'history';
|
export type GitPanelView = 'changes' | 'history';
|
||||||
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
export type FileStatusCode = 'M' | 'A' | 'D' | 'U';
|
||||||
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';
|
||||||
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish';
|
export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit';
|
||||||
|
|
||||||
export type FileDiffInfo = {
|
export type FileDiffInfo = {
|
||||||
old_string: string;
|
old_string: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useGitPanelController } from '../hooks/useGitPanelController';
|
import { useGitPanelController } from '../hooks/useGitPanelController';
|
||||||
|
import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';
|
||||||
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';
|
||||||
import ChangesView from '../view/changes/ChangesView';
|
import ChangesView from '../view/changes/ChangesView';
|
||||||
import HistoryView from '../view/history/HistoryView';
|
import HistoryView from '../view/history/HistoryView';
|
||||||
@@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
onFileOpen,
|
onFileOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({
|
||||||
|
projectName: selectedProject?.name ?? null,
|
||||||
|
onSuccess: refreshAll,
|
||||||
|
});
|
||||||
|
|
||||||
const executeConfirmedAction = useCallback(async () => {
|
const executeConfirmedAction = useCallback(async () => {
|
||||||
if (!confirmAction) {
|
if (!confirmAction) {
|
||||||
return;
|
return;
|
||||||
@@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
isPulling={isPulling}
|
isPulling={isPulling}
|
||||||
isPushing={isPushing}
|
isPushing={isPushing}
|
||||||
isPublishing={isPublishing}
|
isPublishing={isPublishing}
|
||||||
|
isRevertingLocalCommit={isRevertingLocalCommit}
|
||||||
onRefresh={refreshAll}
|
onRefresh={refreshAll}
|
||||||
|
onRevertLocalCommit={revertLatestLocalCommit}
|
||||||
onSwitchBranch={switchBranch}
|
onSwitchBranch={switchBranch}
|
||||||
onCreateBranch={createBranch}
|
onCreateBranch={createBranch}
|
||||||
onFetch={handleFetch}
|
onFetch={handleFetch}
|
||||||
@@ -107,7 +115,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
|||||||
|
|
||||||
{activeView === 'changes' && (
|
{activeView === 'changes' && (
|
||||||
<ChangesView
|
<ChangesView
|
||||||
|
key={selectedProject.fullPath}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
projectPath={selectedProject.fullPath}
|
||||||
gitStatus={gitStatus}
|
gitStatus={gitStatus}
|
||||||
gitDiff={gitDiff}
|
gitDiff={gitDiff}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, Upload } from 'lucide-react';
|
import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
import type { ConfirmationRequest, GitRemoteStatus } from '../types/types';
|
||||||
import NewBranchModal from './modals/NewBranchModal';
|
import NewBranchModal from './modals/NewBranchModal';
|
||||||
@@ -14,7 +14,9 @@ type GitPanelHeaderProps = {
|
|||||||
isPulling: boolean;
|
isPulling: boolean;
|
||||||
isPushing: boolean;
|
isPushing: boolean;
|
||||||
isPublishing: boolean;
|
isPublishing: boolean;
|
||||||
|
isRevertingLocalCommit: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRevertLocalCommit: () => Promise<void>;
|
||||||
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
onSwitchBranch: (branchName: string) => Promise<boolean>;
|
||||||
onCreateBranch: (branchName: string) => Promise<boolean>;
|
onCreateBranch: (branchName: string) => Promise<boolean>;
|
||||||
onFetch: () => Promise<void>;
|
onFetch: () => Promise<void>;
|
||||||
@@ -35,7 +37,9 @@ export default function GitPanelHeader({
|
|||||||
isPulling,
|
isPulling,
|
||||||
isPushing,
|
isPushing,
|
||||||
isPublishing,
|
isPublishing,
|
||||||
|
isRevertingLocalCommit,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onRevertLocalCommit,
|
||||||
onSwitchBranch,
|
onSwitchBranch,
|
||||||
onCreateBranch,
|
onCreateBranch,
|
||||||
onFetch,
|
onFetch,
|
||||||
@@ -88,6 +92,14 @@ export default function GitPanelHeader({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestRevertLocalCommitConfirmation = () => {
|
||||||
|
onRequestConfirmation({
|
||||||
|
type: 'revertLocalCommit',
|
||||||
|
message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',
|
||||||
|
onConfirm: onRevertLocalCommit,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSwitchBranch = async (branchName: string) => {
|
const handleSwitchBranch = async (branchName: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await onSwitchBranch(branchName);
|
const success = await onSwitchBranch(branchName);
|
||||||
@@ -240,6 +252,17 @@ export default function GitPanelHeader({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={requestRevertLocalCommitConfirmation}
|
||||||
|
disabled={isRevertingLocalCommit}
|
||||||
|
className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}
|
||||||
|
title="Revert latest local commit"
|
||||||
|
>
|
||||||
|
<RotateCcw
|
||||||
|
className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
|
|||||||
|
|
||||||
type ChangesViewProps = {
|
type ChangesViewProps = {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
projectPath: string;
|
||||||
gitStatus: GitStatusResponse | null;
|
gitStatus: GitStatusResponse | null;
|
||||||
gitDiff: GitDiffMap;
|
gitDiff: GitDiffMap;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -27,6 +28,7 @@ type ChangesViewProps = {
|
|||||||
|
|
||||||
export default function ChangesView({
|
export default function ChangesView({
|
||||||
isMobile,
|
isMobile,
|
||||||
|
projectPath,
|
||||||
gitStatus,
|
gitStatus,
|
||||||
gitDiff,
|
gitDiff,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -131,6 +133,7 @@ export default function ChangesView({
|
|||||||
<>
|
<>
|
||||||
<CommitComposer
|
<CommitComposer
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
projectPath={projectPath}
|
||||||
selectedFileCount={selectedFiles.size}
|
selectedFileCount={selectedFiles.size}
|
||||||
isHidden={hasExpandedFiles}
|
isHidden={hasExpandedFiles}
|
||||||
onCommit={commitSelectedFiles}
|
onCommit={commitSelectedFiles}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { useState } from 'react';
|
|||||||
import MicButton from '../../../mic-button/view/MicButton';
|
import MicButton from '../../../mic-button/view/MicButton';
|
||||||
import type { ConfirmationRequest } from '../../types/types';
|
import type { ConfirmationRequest } from '../../types/types';
|
||||||
|
|
||||||
|
// Persists commit messages across unmount/remount, keyed by project path
|
||||||
|
const commitMessageCache = new Map<string, string>();
|
||||||
|
|
||||||
type CommitComposerProps = {
|
type CommitComposerProps = {
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
projectPath: string;
|
||||||
selectedFileCount: number;
|
selectedFileCount: number;
|
||||||
isHidden: boolean;
|
isHidden: boolean;
|
||||||
onCommit: (message: string) => Promise<boolean>;
|
onCommit: (message: string) => Promise<boolean>;
|
||||||
@@ -14,13 +18,24 @@ type CommitComposerProps = {
|
|||||||
|
|
||||||
export default function CommitComposer({
|
export default function CommitComposer({
|
||||||
isMobile,
|
isMobile,
|
||||||
|
projectPath,
|
||||||
selectedFileCount,
|
selectedFileCount,
|
||||||
isHidden,
|
isHidden,
|
||||||
onCommit,
|
onCommit,
|
||||||
onGenerateMessage,
|
onGenerateMessage,
|
||||||
onRequestConfirmation,
|
onRequestConfirmation,
|
||||||
}: CommitComposerProps) {
|
}: CommitComposerProps) {
|
||||||
const [commitMessage, setCommitMessage] = useState('');
|
const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');
|
||||||
|
|
||||||
|
const setCommitMessage = (msg: string) => {
|
||||||
|
setCommitMessageRaw(msg);
|
||||||
|
if (msg) {
|
||||||
|
commitMessageCache.set(projectPath, msg);
|
||||||
|
} else {
|
||||||
|
commitMessageCache.delete(projectPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [isCommitting, setIsCommitting] = useState(false);
|
const [isCommitting, setIsCommitting] = useState(false);
|
||||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||||
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Check, Download, Trash2, Upload } from 'lucide-react';
|
import { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
CONFIRMATION_ACTION_LABELS,
|
CONFIRMATION_ACTION_LABELS,
|
||||||
CONFIRMATION_BUTTON_CLASSES,
|
CONFIRMATION_BUTTON_CLASSES,
|
||||||
@@ -27,6 +27,10 @@ function renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {
|
|||||||
return <Download className="h-4 w-4" />;
|
return <Download className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actionType === 'revertLocalCommit') {
|
||||||
|
return <RotateCcw className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <Upload className="h-4 w-4" />;
|
return <Upload className="h-4 w-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,38 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
type GitDiffViewerProps = {
|
type GitDiffViewerProps = {
|
||||||
diff: string | null;
|
diff: string | null;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
wrapText: boolean;
|
wrapText: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PREVIEW_CHARACTER_LIMIT = 200_000;
|
||||||
|
const PREVIEW_LINE_LIMIT = 1_500;
|
||||||
|
|
||||||
|
type DiffPreview = {
|
||||||
|
lines: string[];
|
||||||
|
isCharacterTruncated: boolean;
|
||||||
|
isLineTruncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildDiffPreview(diff: string): DiffPreview {
|
||||||
|
const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;
|
||||||
|
const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;
|
||||||
|
const previewLines = previewText.split('\n');
|
||||||
|
const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,
|
||||||
|
isCharacterTruncated,
|
||||||
|
isLineTruncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
|
export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {
|
||||||
|
// Render a bounded preview to keep huge commit diffs from freezing the UI thread.
|
||||||
|
const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);
|
||||||
|
const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;
|
||||||
|
|
||||||
if (!diff) {
|
if (!diff) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
@@ -35,7 +63,12 @@ export default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diff-viewer">
|
<div className="diff-viewer">
|
||||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
{isPreviewTruncated && (
|
||||||
|
<div className="mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Large diff preview: rendering is limited to keep the tab responsive.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{preview.lines.map((line, index) => renderDiffLine(line, index))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
|
|||||||
import FileTree from '../../file-tree/view/FileTree';
|
import FileTree from '../../file-tree/view/FileTree';
|
||||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||||
import GitPanel from '../../git-panel/view/GitPanel';
|
import GitPanel from '../../git-panel/view/GitPanel';
|
||||||
|
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||||
import type { MainContentProps } from '../types/types';
|
import type { MainContentProps } from '../types/types';
|
||||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||||
@@ -145,7 +146,12 @@ function MainContent({
|
|||||||
|
|
||||||
{activeTab === 'shell' && (
|
{activeTab === 'shell' && (
|
||||||
<div className="h-full w-full overflow-hidden">
|
<div className="h-full w-full overflow-hidden">
|
||||||
<StandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
|
<StandaloneShell
|
||||||
|
project={selectedProject}
|
||||||
|
session={selectedSession}
|
||||||
|
showHeader={false}
|
||||||
|
isActive={activeTab === 'shell'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -158,6 +164,16 @@ function MainContent({
|
|||||||
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||||
|
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
||||||
|
|
||||||
|
{activeTab.startsWith('plugin:') && (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<PluginTabContent
|
||||||
|
pluginName={activeTab.replace('plugin:', '')}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditorSidebar
|
<EditorSidebar
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback, useRef, useState, useEffect } from 'react';
|
||||||
import type { MainContentHeaderProps } from '../../types/types';
|
import type { MainContentHeaderProps } from '../../types/types';
|
||||||
import MobileMenuButton from './MobileMenuButton';
|
import MobileMenuButton from './MobileMenuButton';
|
||||||
import MainContentTabSwitcher from './MainContentTabSwitcher';
|
import MainContentTabSwitcher from './MainContentTabSwitcher';
|
||||||
@@ -12,6 +13,26 @@ export default function MainContentHeader({
|
|||||||
isMobile,
|
isMobile,
|
||||||
onMenuClick,
|
onMenuClick,
|
||||||
}: MainContentHeaderProps) {
|
}: MainContentHeaderProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanScrollLeft(el.scrollLeft > 2);
|
||||||
|
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 2);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
updateScrollState();
|
||||||
|
const observer = new ResizeObserver(updateScrollState);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [updateScrollState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pwa-header-safe flex-shrink-0 border-b border-border/60 bg-background px-3 py-1.5 sm:px-4 sm:py-2">
|
<div className="pwa-header-safe flex-shrink-0 border-b border-border/60 bg-background px-3 py-1.5 sm:px-4 sm:py-2">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -25,12 +46,24 @@ export default function MainContentHeader({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden flex-shrink-0 sm:block">
|
<div className="relative min-w-0 flex-shrink overflow-hidden sm:flex-shrink-0">
|
||||||
<MainContentTabSwitcher
|
{canScrollLeft && (
|
||||||
activeTab={activeTab}
|
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-background to-transparent" />
|
||||||
setActiveTab={setActiveTab}
|
)}
|
||||||
shouldShowTasksTab={shouldShowTasksTab}
|
<div
|
||||||
/>
|
ref={scrollRef}
|
||||||
|
onScroll={updateScrollState}
|
||||||
|
className="scrollbar-hide overflow-x-auto"
|
||||||
|
>
|
||||||
|
<MainContentTabSwitcher
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canScrollRight && (
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-background to-transparent" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tooltip } from '../../../../shared/view/ui';
|
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
|
||||||
import type { AppTab } from '../../../../types/app';
|
import type { AppTab } from '../../../../types/app';
|
||||||
|
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||||
|
import PluginIcon from '../../../plugins/view/PluginIcon';
|
||||||
|
|
||||||
type MainContentTabSwitcherProps = {
|
type MainContentTabSwitcherProps = {
|
||||||
activeTab: AppTab;
|
activeTab: AppTab;
|
||||||
@@ -10,20 +12,32 @@ type MainContentTabSwitcherProps = {
|
|||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabDefinition = {
|
type BuiltInTab = {
|
||||||
|
kind: 'builtin';
|
||||||
id: AppTab;
|
id: AppTab;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_TABS: TabDefinition[] = [
|
type PluginTab = {
|
||||||
{ id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
kind: 'plugin';
|
||||||
{ id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
id: AppTab;
|
||||||
{ id: 'files', labelKey: 'tabs.files', icon: Folder },
|
label: string;
|
||||||
{ id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
pluginName: string;
|
||||||
|
iconFile: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabDefinition = BuiltInTab | PluginTab;
|
||||||
|
|
||||||
|
const BASE_TABS: BuiltInTab[] = [
|
||||||
|
{ kind: 'builtin', id: 'chat', labelKey: 'tabs.chat', icon: MessageSquare },
|
||||||
|
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||||
|
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||||
|
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TASKS_TAB: TabDefinition = {
|
const TASKS_TAB: BuiltInTab = {
|
||||||
|
kind: 'builtin',
|
||||||
id: 'tasks',
|
id: 'tasks',
|
||||||
labelKey: 'tabs.tasks',
|
labelKey: 'tabs.tasks',
|
||||||
icon: ClipboardCheck,
|
icon: ClipboardCheck,
|
||||||
@@ -35,31 +49,49 @@ export default function MainContentTabSwitcher({
|
|||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
}: MainContentTabSwitcherProps) {
|
}: MainContentTabSwitcherProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { plugins } = usePlugins();
|
||||||
|
|
||||||
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||||
|
|
||||||
|
const pluginTabs: PluginTab[] = plugins
|
||||||
|
.filter((p) => p.enabled)
|
||||||
|
.map((p) => ({
|
||||||
|
kind: 'plugin',
|
||||||
|
id: `plugin:${p.name}` as AppTab,
|
||||||
|
label: p.displayName,
|
||||||
|
pluginName: p.name,
|
||||||
|
iconFile: p.icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]">
|
<PillBar>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
|
||||||
const isActive = tab.id === activeTab;
|
const isActive = tab.id === activeTab;
|
||||||
|
const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
|
<Tooltip key={tab.id} content={displayLabel} position="bottom">
|
||||||
<button
|
<Pill
|
||||||
|
isActive={isActive}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`relative flex items-center gap-1.5 rounded-md px-2.5 py-[5px] text-sm font-medium transition-all duration-150 ${
|
className="px-2.5 py-[5px]"
|
||||||
isActive
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
{tab.kind === 'builtin' ? (
|
||||||
<span className="hidden lg:inline">{t(tab.labelKey)}</span>
|
<tab.icon className="h-3.5 w-3.5" strokeWidth={isActive ? 2.2 : 1.8} />
|
||||||
</button>
|
) : (
|
||||||
|
<PluginIcon
|
||||||
|
pluginName={tab.pluginName}
|
||||||
|
iconFile={tab.iconFile}
|
||||||
|
className="flex h-3.5 w-3.5 items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="hidden lg:inline">{displayLabel}</span>
|
||||||
|
</Pill>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</PillBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||||
|
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||||
|
|
||||||
type MainContentTitleProps = {
|
type MainContentTitleProps = {
|
||||||
activeTab: AppTab;
|
activeTab: AppTab;
|
||||||
@@ -9,7 +10,11 @@ type MainContentTitleProps = {
|
|||||||
shouldShowTasksTab: boolean;
|
shouldShowTasksTab: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
|
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {
|
||||||
|
if (activeTab.startsWith('plugin:') && pluginDisplayName) {
|
||||||
|
return pluginDisplayName;
|
||||||
|
}
|
||||||
|
|
||||||
if (activeTab === 'files') {
|
if (activeTab === 'files') {
|
||||||
return t('mainContent.projectFiles');
|
return t('mainContent.projectFiles');
|
||||||
}
|
}
|
||||||
@@ -40,6 +45,11 @@ export default function MainContentTitle({
|
|||||||
shouldShowTasksTab,
|
shouldShowTasksTab,
|
||||||
}: MainContentTitleProps) {
|
}: MainContentTitleProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { plugins } = usePlugins();
|
||||||
|
|
||||||
|
const pluginDisplayName = activeTab.startsWith('plugin:')
|
||||||
|
? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
|
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
|
||||||
const showChatNewSession = activeTab === 'chat' && !selectedSession;
|
const showChatNewSession = activeTab === 'chat' && !selectedSession;
|
||||||
@@ -68,7 +78,7 @@ export default function MainContentTitle({
|
|||||||
) : (
|
) : (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-sm font-semibold leading-tight text-foreground">
|
<h2 className="text-sm font-semibold leading-tight text-foreground">
|
||||||
{getTabTitle(activeTab, shouldShowTasksTab, t)}
|
{getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
<div className="truncate text-[11px] leading-tight text-muted-foreground">{selectedProject.displayName}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
44
src/components/plugins/view/PluginIcon.tsx
Normal file
44
src/components/plugins/view/PluginIcon.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pluginName: string;
|
||||||
|
iconFile: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Module-level cache so repeated renders don't re-fetch
|
||||||
|
const svgCache = new Map<string, string>();
|
||||||
|
|
||||||
|
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||||
|
const url = iconFile
|
||||||
|
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||||
|
: '';
|
||||||
|
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || svgCache.has(url)) return;
|
||||||
|
authenticatedFetch(url)
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
return r.text();
|
||||||
|
})
|
||||||
|
.then((text) => {
|
||||||
|
if (text && text.trimStart().startsWith('<svg')) {
|
||||||
|
svgCache.set(url, text);
|
||||||
|
setSvg(text);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
if (!svg) return <span className={className} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
// SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself
|
||||||
|
dangerouslySetInnerHTML={{ __html: svg }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
456
src/components/plugins/view/PluginSettingsTab.tsx
Normal file
456
src/components/plugins/view/PluginSettingsTab.tsx
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';
|
||||||
|
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||||
|
import type { Plugin } from '../../../contexts/PluginsContext';
|
||||||
|
import PluginIcon from './PluginIcon';
|
||||||
|
|
||||||
|
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
|
||||||
|
|
||||||
|
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */
|
||||||
|
function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
|
||||||
|
return (
|
||||||
|
<label className="relative inline-flex cursor-pointer select-none items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="peer sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative h-5 w-9 rounded-full bg-muted transition-colors
|
||||||
|
duration-200 after:absolute
|
||||||
|
after:left-[2px] after:top-[2px] after:h-4 after:w-4
|
||||||
|
after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200
|
||||||
|
after:content-[''] peer-checked:bg-emerald-500
|
||||||
|
peer-checked:after:translate-x-4
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Server Dot ────────────────────────────────────────────────────────── */
|
||||||
|
function ServerDot({ running, t }: { running: boolean; t: any }) {
|
||||||
|
if (!running) return null;
|
||||||
|
return (
|
||||||
|
<span className="relative flex items-center gap-1.5">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t('pluginSettings.runningStatus')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Plugin Card ───────────────────────────────────────────────────────── */
|
||||||
|
type PluginCardProps = {
|
||||||
|
plugin: Plugin;
|
||||||
|
index: number;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onUpdate: () => void;
|
||||||
|
onUninstall: () => void;
|
||||||
|
updating: boolean;
|
||||||
|
confirmingUninstall: boolean;
|
||||||
|
onCancelUninstall: () => void;
|
||||||
|
updateError: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PluginCard({
|
||||||
|
plugin,
|
||||||
|
index,
|
||||||
|
onToggle,
|
||||||
|
onUpdate,
|
||||||
|
onUninstall,
|
||||||
|
updating,
|
||||||
|
confirmingUninstall,
|
||||||
|
onCancelUninstall,
|
||||||
|
updateError,
|
||||||
|
}: PluginCardProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const accentColor = plugin.enabled
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: 'bg-muted-foreground/20';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex overflow-hidden rounded-lg border border-border bg-card transition-opacity duration-200"
|
||||||
|
style={{
|
||||||
|
opacity: plugin.enabled ? 1 : 0.65,
|
||||||
|
animationDelay: `${index * 40}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left accent bar */}
|
||||||
|
<div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1 p-4">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="h-5 w-5 flex-shrink-0 text-foreground/80">
|
||||||
|
<PluginIcon
|
||||||
|
pluginName={plugin.name}
|
||||||
|
iconFile={plugin.icon}
|
||||||
|
className="h-5 w-5 [&>svg]:h-full [&>svg]:w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold leading-none text-foreground">
|
||||||
|
{plugin.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
v{plugin.version}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{plugin.slot}
|
||||||
|
</span>
|
||||||
|
<ServerDot running={!!plugin.serverRunning} t={t} />
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
|
{plugin.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-3">
|
||||||
|
{plugin.author && (
|
||||||
|
<span className="text-xs text-muted-foreground/60">
|
||||||
|
{plugin.author}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{plugin.repoUrl && (
|
||||||
|
<a
|
||||||
|
href={plugin.repoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
<span className="max-w-[200px] truncate">
|
||||||
|
{plugin.repoUrl.replace(/^https?:\/\/(www\.)?github\.com\//, '')}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={updating || !plugin.repoUrl}
|
||||||
|
title={plugin.repoUrl ? t('pluginSettings.pullLatest') : t('pluginSettings.noGitRemote')}
|
||||||
|
aria-label={t('pluginSettings.pullLatest')}
|
||||||
|
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{updating ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onUninstall}
|
||||||
|
title={confirmingUninstall ? t('pluginSettings.confirmUninstall') : t('pluginSettings.uninstallPlugin')}
|
||||||
|
aria-label={t('pluginSettings.uninstallPlugin')}
|
||||||
|
className={`rounded p-1.5 transition-colors ${confirmingUninstall
|
||||||
|
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
|
||||||
|
: 'text-muted-foreground hover:bg-muted hover:text-red-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? t('pluginSettings.disable') : t('pluginSettings.enable')} ${plugin.displayName}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm uninstall banner */}
|
||||||
|
{confirmingUninstall && (
|
||||||
|
<div className="mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30">
|
||||||
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{t('pluginSettings.confirmUninstallMessage', { name: plugin.displayName })}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={onCancelUninstall}
|
||||||
|
className="rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('pluginSettings.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onUninstall}
|
||||||
|
className="rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||||
|
>
|
||||||
|
{t('pluginSettings.remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Update error */}
|
||||||
|
{updateError && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-sm text-red-500">
|
||||||
|
<ServerCrash className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
<span>{updateError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Starter Plugin Card ───────────────────────────────────────────────── */
|
||||||
|
function StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500">
|
||||||
|
<div className="w-[3px] flex-shrink-0 bg-blue-500/30" />
|
||||||
|
<div className="min-w-0 flex-1 p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<div className="h-5 w-5 flex-shrink-0 text-blue-500">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold leading-none text-foreground">
|
||||||
|
{t('pluginSettings.starterPlugin.name')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400">
|
||||||
|
{t('pluginSettings.starterPlugin.badge')}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{t('pluginSettings.tab')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm leading-snug text-muted-foreground">
|
||||||
|
{t('pluginSettings.starterPlugin.description')}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={STARTER_PLUGIN_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
cloudcli-ai/cloudcli-plugin-starter
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onInstall}
|
||||||
|
disabled={installing}
|
||||||
|
className="flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Main Component ────────────────────────────────────────────────────── */
|
||||||
|
export default function PluginSettingsTab() {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =
|
||||||
|
usePlugins();
|
||||||
|
|
||||||
|
const [gitUrl, setGitUrl] = useState('');
|
||||||
|
const [installing, setInstalling] = useState(false);
|
||||||
|
const [installingStarter, setInstallingStarter] = useState(false);
|
||||||
|
const [installError, setInstallError] = useState<string | null>(null);
|
||||||
|
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||||
|
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||||
|
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const handleUpdate = async (name: string) => {
|
||||||
|
setUpdatingPlugins((prev) => new Set(prev).add(name));
|
||||||
|
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
|
||||||
|
const result = await updatePlugin(name);
|
||||||
|
if (!result.success) {
|
||||||
|
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || t('pluginSettings.updateFailed') }));
|
||||||
|
}
|
||||||
|
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
if (!gitUrl.trim()) return;
|
||||||
|
setInstalling(true);
|
||||||
|
setInstallError(null);
|
||||||
|
const result = await installPlugin(gitUrl.trim());
|
||||||
|
if (result.success) {
|
||||||
|
setGitUrl('');
|
||||||
|
} else {
|
||||||
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
|
}
|
||||||
|
setInstalling(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallStarter = async () => {
|
||||||
|
setInstallingStarter(true);
|
||||||
|
setInstallError(null);
|
||||||
|
const result = await installPlugin(STARTER_PLUGIN_URL);
|
||||||
|
if (!result.success) {
|
||||||
|
setInstallError(result.error || t('pluginSettings.installFailed'));
|
||||||
|
}
|
||||||
|
setInstallingStarter(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUninstall = async (name: string) => {
|
||||||
|
if (confirmUninstall !== name) {
|
||||||
|
setConfirmUninstall(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await uninstallPlugin(name);
|
||||||
|
if (result.success) {
|
||||||
|
setConfirmUninstall(null);
|
||||||
|
} else {
|
||||||
|
setInstallError(result.error || t('pluginSettings.uninstallFailed'));
|
||||||
|
setConfirmUninstall(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-base font-semibold text-foreground">
|
||||||
|
{t('pluginSettings.title')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('pluginSettings.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Install from Git — compact */}
|
||||||
|
<div className="flex items-center gap-0 overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<span className="flex-shrink-0 pl-3 pr-1 text-muted-foreground/40">
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gitUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGitUrl(e.target.value);
|
||||||
|
setInstallError(null);
|
||||||
|
}}
|
||||||
|
placeholder={t('pluginSettings.installPlaceholder')}
|
||||||
|
aria-label={t('pluginSettings.installAriaLabel')}
|
||||||
|
className="flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') void handleInstall();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleInstall}
|
||||||
|
disabled={installing || !gitUrl.trim()}
|
||||||
|
className="flex-shrink-0 border-l border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{installing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
t('pluginSettings.installButton')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{installError && (
|
||||||
|
<p className="-mt-4 text-sm text-red-500">{installError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50">
|
||||||
|
<ShieldAlert className="mt-px h-3 w-3 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t('pluginSettings.securityWarning')}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Starter plugin suggestion — above the list */}
|
||||||
|
{!loading && !hasStarterInstalled && (
|
||||||
|
<StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Plugin List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t('pluginSettings.scanningPlugins')}
|
||||||
|
</div>
|
||||||
|
) : plugins.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">{t('pluginSettings.noPluginsInstalled')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{plugins.map((plugin, index) => {
|
||||||
|
const handleToggle = async (enabled: boolean) => {
|
||||||
|
const r = await togglePlugin(plugin.name, enabled);
|
||||||
|
if (!r.success) {
|
||||||
|
setInstallError(r.error || t('pluginSettings.toggleFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.name}
|
||||||
|
plugin={plugin}
|
||||||
|
index={index}
|
||||||
|
onToggle={(enabled) => void handleToggle(enabled)}
|
||||||
|
onUpdate={() => void handleUpdate(plugin.name)}
|
||||||
|
onUninstall={() => void handleUninstall(plugin.name)}
|
||||||
|
updating={updatingPlugins.has(plugin.name)}
|
||||||
|
confirmingUninstall={confirmUninstall === plugin.name}
|
||||||
|
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||||
|
updateError={updateErrors[plugin.name] ?? null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Build your own */}
|
||||||
|
<div className="flex items-center justify-between gap-4 border-t border-border/50 pt-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<BookOpen className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs text-muted-foreground/60">
|
||||||
|
{t('pluginSettings.buildYourOwn')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={STARTER_PLUGIN_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('pluginSettings.starter')} <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
<span className="text-muted-foreground/20">·</span>
|
||||||
|
<a
|
||||||
|
href="https://cloudcli.ai/docs/plugin-overview"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('pluginSettings.docs')} <ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/plugins/view/PluginTabContent.tsx
Normal file
141
src/components/plugins/view/PluginTabContent.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
import { authenticatedFetch } from '../../../utils/api';
|
||||||
|
import { usePlugins } from '../../../contexts/PluginsContext';
|
||||||
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
|
type PluginTabContentProps = {
|
||||||
|
pluginName: string;
|
||||||
|
selectedProject: Project | null;
|
||||||
|
selectedSession: ProjectSession | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginContext = {
|
||||||
|
theme: 'dark' | 'light';
|
||||||
|
project: { name: string; path: string } | null;
|
||||||
|
session: { id: string; title: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildContext(
|
||||||
|
isDarkMode: boolean,
|
||||||
|
selectedProject: Project | null,
|
||||||
|
selectedSession: ProjectSession | null,
|
||||||
|
): PluginContext {
|
||||||
|
return {
|
||||||
|
theme: isDarkMode ? 'dark' : 'light',
|
||||||
|
project: selectedProject
|
||||||
|
? {
|
||||||
|
name: selectedProject.name,
|
||||||
|
path: selectedProject.fullPath || selectedProject.path || '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
session: selectedSession
|
||||||
|
? {
|
||||||
|
id: selectedSession.id,
|
||||||
|
title: selectedSession.title || selectedSession.name || selectedSession.id,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PluginTabContent({
|
||||||
|
pluginName,
|
||||||
|
selectedProject,
|
||||||
|
selectedSession,
|
||||||
|
}: PluginTabContentProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { isDarkMode } = useTheme();
|
||||||
|
const { plugins } = usePlugins();
|
||||||
|
|
||||||
|
// Stable refs so effects don't need context values in their dep arrays
|
||||||
|
const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));
|
||||||
|
const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());
|
||||||
|
|
||||||
|
const moduleRef = useRef<any>(null);
|
||||||
|
|
||||||
|
const plugin = plugins.find(p => p.name === pluginName);
|
||||||
|
|
||||||
|
// Keep contextRef current and notify the mounted plugin on every context change
|
||||||
|
useEffect(() => {
|
||||||
|
const ctx = buildContext(isDarkMode, selectedProject, selectedSession);
|
||||||
|
contextRef.current = ctx;
|
||||||
|
|
||||||
|
for (const cb of contextCallbacksRef.current) {
|
||||||
|
try { cb(ctx); } catch { /* plugin error — ignore */ }
|
||||||
|
}
|
||||||
|
}, [isDarkMode, selectedProject, selectedSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !plugin?.enabled) return;
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
const container = containerRef.current;
|
||||||
|
const entryFile = plugin?.entry ?? 'index.js';
|
||||||
|
const contextCallbacks = contextCallbacksRef.current;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).
|
||||||
|
// Then import it via a Blob URL so the browser never makes an unauthenticated request.
|
||||||
|
const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;
|
||||||
|
const res = await authenticatedFetch(assetUrl);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);
|
||||||
|
const jsText = await res.text();
|
||||||
|
const blob = new Blob([jsText], { type: 'application/javascript' });
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
// @vite-ignore
|
||||||
|
const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
|
||||||
|
if (!active || !containerRef.current) return;
|
||||||
|
|
||||||
|
moduleRef.current = mod;
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
get context(): PluginContext { return contextRef.current; },
|
||||||
|
|
||||||
|
onContextChange(cb: (ctx: PluginContext) => void): () => void {
|
||||||
|
contextCallbacks.add(cb);
|
||||||
|
return () => contextCallbacks.delete(cb);
|
||||||
|
},
|
||||||
|
|
||||||
|
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||||
|
const cleanPath = String(path).replace(/^\//, '');
|
||||||
|
const res = await authenticatedFetch(
|
||||||
|
`/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,
|
||||||
|
{
|
||||||
|
method: method || 'GET',
|
||||||
|
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`RPC error ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.mount?.(container, api);
|
||||||
|
if (!active) {
|
||||||
|
try { mod.unmount?.(container); } catch { /* ignore */ }
|
||||||
|
moduleRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!active) return;
|
||||||
|
console.error(`[Plugin:${pluginName}] Failed to load:`, err);
|
||||||
|
if (containerRef.current) {
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';
|
||||||
|
errDiv.textContent = `Plugin failed to load: ${String(err)}`;
|
||||||
|
containerRef.current.replaceChildren(errDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
|
||||||
|
contextCallbacks.clear();
|
||||||
|
moduleRef.current = null;
|
||||||
|
};
|
||||||
|
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
||||||
|
|
||||||
|
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ type NotificationPreferencesResponse = {
|
|||||||
|
|
||||||
type ActiveLoginProvider = AgentProvider | '';
|
type ActiveLoginProvider = AgentProvider | '';
|
||||||
|
|
||||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications'];
|
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];
|
||||||
|
|
||||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||||
// Keep backwards compatibility with older callers that still pass "tools".
|
// Keep backwards compatibility with older callers that still pass "tools".
|
||||||
@@ -209,7 +209,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||||
@@ -778,16 +777,9 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSaveStatus('success');
|
setSaveStatus('success');
|
||||||
if (closeTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(closeTimerRef.current);
|
|
||||||
closeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
closeTimerRef.current = window.setTimeout(() => onClose(), 1000);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving settings:', error);
|
console.error('Error saving settings:', error);
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
claudePermissions.allowedTools,
|
claudePermissions.allowedTools,
|
||||||
@@ -799,6 +791,7 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
cursorPermissions.skipPermissions,
|
cursorPermissions.skipPermissions,
|
||||||
notificationPreferences,
|
notificationPreferences,
|
||||||
onClose,
|
onClose,
|
||||||
|
geminiPermissionMode,
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -851,11 +844,58 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
|
window.dispatchEvent(new Event('codeEditorSettingsChanged'));
|
||||||
}, [codeEditorSettings]);
|
}, [codeEditorSettings]);
|
||||||
|
|
||||||
|
// Auto-save permissions and sort order with debounce
|
||||||
|
const autoSaveTimerRef = useRef<number | null>(null);
|
||||||
|
const isInitialLoadRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip auto-save on initial load (settings are being loaded from localStorage)
|
||||||
|
if (isInitialLoadRef.current) {
|
||||||
|
isInitialLoadRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoSaveTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSaveTimerRef.current = window.setTimeout(() => {
|
||||||
|
saveSettings();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(autoSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [saveSettings]);
|
||||||
|
|
||||||
|
// Clear save status after 2 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveStatus === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [saveStatus]);
|
||||||
|
|
||||||
|
// Reset initial load flag when settings dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
isInitialLoadRef.current = true;
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (closeTimerRef.current !== null) {
|
if (closeTimerRef.current !== null) {
|
||||||
window.clearTimeout(closeTimerRef.current);
|
window.clearTimeout(closeTimerRef.current);
|
||||||
closeTimerRef.current = null;
|
closeTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (autoSaveTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(autoSaveTimerRef.current);
|
||||||
|
autoSaveTimerRef.current = null;
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -863,7 +903,6 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
setActiveTab,
|
setActiveTab,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
isSaving,
|
|
||||||
saveStatus,
|
saveStatus,
|
||||||
deleteError,
|
deleteError,
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
@@ -910,6 +949,5 @@ export function useSettingsController({ isOpen, initialTab, projects, onClose }:
|
|||||||
loginProvider,
|
loginProvider,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
handleLoginComplete,
|
handleLoginComplete,
|
||||||
saveSettings,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
|
|
||||||
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications';
|
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';
|
||||||
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
export type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';
|
||||||
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
export type AgentCategory = 'account' | 'permissions' | 'mcp';
|
||||||
export type ProjectSortOrder = 'name' | 'date';
|
export type ProjectSortOrder = 'name' | 'date';
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { Settings as SettingsIcon, X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
||||||
import { Button } from '../../../shared/view/ui';
|
import { Button } from '../../../shared/view/ui';
|
||||||
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
|
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
|
||||||
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
|
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
|
||||||
import SettingsMainTabs from '../view/SettingsMainTabs';
|
import SettingsSidebar from '../view/SettingsSidebar';
|
||||||
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||||
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
import NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';
|
||||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||||
|
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||||
import { useSettingsController } from '../hooks/useSettingsController';
|
import { useSettingsController } from '../hooks/useSettingsController';
|
||||||
import { useWebPush } from '../../../hooks/useWebPush';
|
import { useWebPush } from '../../../hooks/useWebPush';
|
||||||
import type { SettingsProps } from '../types/types';
|
import type { SettingsProps } from '../types/types';
|
||||||
@@ -20,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
isSaving,
|
|
||||||
saveStatus,
|
saveStatus,
|
||||||
deleteError,
|
deleteError,
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
@@ -67,7 +67,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
loginProvider,
|
loginProvider,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
handleLoginComplete,
|
handleLoginComplete,
|
||||||
saveSettings,
|
|
||||||
} = useSettingsController({
|
} = useSettingsController({
|
||||||
isOpen,
|
isOpen,
|
||||||
initialTab,
|
initialTab,
|
||||||
@@ -114,81 +113,83 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/95 md:p-4">
|
<div className="modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4">
|
||||||
<div className="flex h-full w-full flex-col border border-border bg-background shadow-xl md:h-[90vh] md:max-w-4xl md:rounded-lg">
|
<div className="flex h-full w-full flex-col overflow-hidden border border-border bg-background shadow-2xl md:h-[90vh] md:max-w-4xl md:rounded-xl">
|
||||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-border p-4 md:p-6">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 md:px-5">
|
||||||
<SettingsIcon className="h-5 w-5 text-blue-600 md:h-6 md:w-6" />
|
<h2 className="text-base font-semibold text-foreground">{t('title')}</h2>
|
||||||
<h2 className="text-lg font-semibold text-foreground md:text-xl">{t('title')}</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<span className="text-xs text-muted-foreground animate-in fade-in">{t('saveStatus.success')}</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-10 w-10 touch-manipulation p-0 text-muted-foreground hover:text-foreground active:bg-accent/50"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
className="touch-manipulation text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
{/* Body: sidebar + content */}
|
||||||
<SettingsMainTabs activeTab={activeTab} onChange={setActiveTab} />
|
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||||
|
<SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
<div className="space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
|
{/* Content */}
|
||||||
{activeTab === 'appearance' && (
|
<main className="flex-1 overflow-y-auto">
|
||||||
<AppearanceSettingsTab
|
<div key={activeTab} className="settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
|
||||||
projectSortOrder={projectSortOrder}
|
{activeTab === 'appearance' && (
|
||||||
onProjectSortOrderChange={setProjectSortOrder}
|
<AppearanceSettingsTab
|
||||||
codeEditorSettings={codeEditorSettings}
|
projectSortOrder={projectSortOrder}
|
||||||
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
|
onProjectSortOrderChange={setProjectSortOrder}
|
||||||
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
codeEditorSettings={codeEditorSettings}
|
||||||
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}
|
||||||
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}
|
||||||
onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
|
onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}
|
||||||
/>
|
onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}
|
||||||
)}
|
onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'git' && <GitSettingsTab />}
|
{activeTab === 'git' && <GitSettingsTab />}
|
||||||
|
|
||||||
{activeTab === 'agents' && (
|
{activeTab === 'agents' && (
|
||||||
<AgentsSettingsTab
|
<AgentsSettingsTab
|
||||||
claudeAuthStatus={claudeAuthStatus}
|
claudeAuthStatus={claudeAuthStatus}
|
||||||
cursorAuthStatus={cursorAuthStatus}
|
cursorAuthStatus={cursorAuthStatus}
|
||||||
codexAuthStatus={codexAuthStatus}
|
codexAuthStatus={codexAuthStatus}
|
||||||
geminiAuthStatus={geminiAuthStatus}
|
geminiAuthStatus={geminiAuthStatus}
|
||||||
onClaudeLogin={() => openLoginForProvider('claude')}
|
onClaudeLogin={() => openLoginForProvider('claude')}
|
||||||
onCursorLogin={() => openLoginForProvider('cursor')}
|
onCursorLogin={() => openLoginForProvider('cursor')}
|
||||||
onCodexLogin={() => openLoginForProvider('codex')}
|
onCodexLogin={() => openLoginForProvider('codex')}
|
||||||
onGeminiLogin={() => openLoginForProvider('gemini')}
|
onGeminiLogin={() => openLoginForProvider('gemini')}
|
||||||
claudePermissions={claudePermissions}
|
claudePermissions={claudePermissions}
|
||||||
onClaudePermissionsChange={setClaudePermissions}
|
onClaudePermissionsChange={setClaudePermissions}
|
||||||
cursorPermissions={cursorPermissions}
|
cursorPermissions={cursorPermissions}
|
||||||
onCursorPermissionsChange={setCursorPermissions}
|
onCursorPermissionsChange={setCursorPermissions}
|
||||||
codexPermissionMode={codexPermissionMode}
|
codexPermissionMode={codexPermissionMode}
|
||||||
onCodexPermissionModeChange={setCodexPermissionMode}
|
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||||
geminiPermissionMode={geminiPermissionMode}
|
geminiPermissionMode={geminiPermissionMode}
|
||||||
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
||||||
mcpServers={mcpServers}
|
mcpServers={mcpServers}
|
||||||
cursorMcpServers={cursorMcpServers}
|
cursorMcpServers={cursorMcpServers}
|
||||||
codexMcpServers={codexMcpServers}
|
codexMcpServers={codexMcpServers}
|
||||||
mcpTestResults={mcpTestResults}
|
mcpTestResults={mcpTestResults}
|
||||||
mcpServerTools={mcpServerTools}
|
mcpServerTools={mcpServerTools}
|
||||||
mcpToolsLoading={mcpToolsLoading}
|
mcpToolsLoading={mcpToolsLoading}
|
||||||
onOpenMcpForm={openMcpForm}
|
onOpenMcpForm={openMcpForm}
|
||||||
onDeleteMcpServer={handleMcpDelete}
|
onDeleteMcpServer={handleMcpDelete}
|
||||||
onTestMcpServer={handleMcpTest}
|
onTestMcpServer={handleMcpTest}
|
||||||
onDiscoverMcpTools={handleMcpToolsDiscovery}
|
onDiscoverMcpTools={handleMcpToolsDiscovery}
|
||||||
onOpenCodexMcpForm={openCodexMcpForm}
|
onOpenCodexMcpForm={openCodexMcpForm}
|
||||||
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
||||||
deleteError={deleteError}
|
deleteError={deleteError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'tasks' && (
|
{activeTab === 'tasks' && <TasksSettingsTab />}
|
||||||
<div className="space-y-6 md:space-y-8">
|
|
||||||
<TasksSettingsTab />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'notifications' && (
|
{activeTab === 'notifications' && (
|
||||||
<NotificationsSettingsTab
|
<NotificationsSettingsTab
|
||||||
@@ -202,57 +203,11 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'api' && (
|
{activeTab === 'api' && <CredentialsSettingsTab />}
|
||||||
<div className="space-y-6 md:space-y-8">
|
|
||||||
<CredentialsSettingsTab />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 flex-col gap-3 border-t border-border p-4 pb-safe-area-inset-bottom sm:flex-row sm:items-center sm:justify-between md:p-6">
|
{activeTab === 'plugins' && <PluginSettingsTab />}
|
||||||
<div className="order-2 flex items-center justify-center gap-2 sm:order-1 sm:justify-start">
|
</div>
|
||||||
{saveStatus === 'success' && (
|
</main>
|
||||||
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<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>
|
|
||||||
{t('saveStatus.success')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{saveStatus === 'error' && (
|
|
||||||
<div className="flex items-center gap-1 text-sm text-red-600 dark:text-red-400">
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
{t('saveStatus.error')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="order-1 flex items-center gap-3 sm:order-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="h-10 flex-1 touch-manipulation sm:flex-none"
|
|
||||||
>
|
|
||||||
{t('footerActions.cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={saveSettings}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="h-10 flex-1 touch-manipulation bg-blue-600 hover:bg-blue-700 disabled:opacity-50 sm:flex-none"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
||||||
{t('saveStatus.saving')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t('footerActions.save')
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
22
src/components/settings/view/SettingsCard.tsx
Normal file
22
src/components/settings/view/SettingsCard.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
|
type SettingsCardProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
divided?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsCard({ children, className, divided }: SettingsCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border bg-card/50',
|
||||||
|
divided && 'divide-y divide-border',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GitBranch, Key } from 'lucide-react';
|
import { GitBranch, Key, Puzzle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { SettingsMainTab } from '../types/types';
|
import type { SettingsMainTab } from '../types/types';
|
||||||
|
|
||||||
@@ -9,7 +9,8 @@ type SettingsMainTabsProps = {
|
|||||||
|
|
||||||
type MainTabConfig = {
|
type MainTabConfig = {
|
||||||
id: SettingsMainTab;
|
id: SettingsMainTab;
|
||||||
labelKey: string;
|
labelKey?: string;
|
||||||
|
label?: string;
|
||||||
icon?: typeof GitBranch;
|
icon?: typeof GitBranch;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
|||||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||||
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
{ id: 'notifications', labelKey: 'mainTabs.notifications' },
|
||||||
|
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||||
@@ -45,7 +47,7 @@ export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTa
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className="mr-2 inline h-4 w-4" />}
|
{Icon && <Icon className="mr-2 inline h-4 w-4" />}
|
||||||
{t(tab.labelKey)}
|
{tab.labelKey ? t(tab.labelKey) : tab.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
23
src/components/settings/view/SettingsRow.tsx
Normal file
23
src/components/settings/view/SettingsRow.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
|
type SettingsRowProps = {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsRow({ label, description, children, className }: SettingsRowProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-between gap-4 px-4 py-4', className)}>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||||
|
{description && (
|
||||||
|
<div className="mt-0.5 text-sm text-muted-foreground">{description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/components/settings/view/SettingsSection.tsx
Normal file
25
src/components/settings/view/SettingsSection.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
|
type SettingsSectionProps = {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsSection({ title, description, children, className }: SettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/settings/view/SettingsSidebar.tsx
Normal file
80
src/components/settings/view/SettingsSidebar.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
import { PillBar, Pill } from '../../../shared/view/ui';
|
||||||
|
import type { SettingsMainTab } from '../types/types';
|
||||||
|
|
||||||
|
type SettingsSidebarProps = {
|
||||||
|
activeTab: SettingsMainTab;
|
||||||
|
onChange: (tab: SettingsMainTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
id: SettingsMainTab;
|
||||||
|
labelKey: string;
|
||||||
|
icon: typeof Bot;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ id: 'agents', labelKey: 'mainTabs.agents', icon: Bot },
|
||||||
|
{ id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },
|
||||||
|
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||||
|
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||||
|
{ id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },
|
||||||
|
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {
|
||||||
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<aside className="hidden w-56 flex-shrink-0 border-r border-border bg-muted/30 md:flex md:flex-col">
|
||||||
|
<nav className="flex flex-col gap-1 p-3">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = activeTab === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150',
|
||||||
|
isActive
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground active:bg-accent/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile horizontal nav — pill bar */}
|
||||||
|
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:hidden">
|
||||||
|
<PillBar className="scrollbar-hide w-full overflow-x-auto">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pill
|
||||||
|
key={item.id}
|
||||||
|
isActive={activeTab === item.id}
|
||||||
|
onClick={() => onChange(item.id)}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PillBar>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/settings/view/SettingsToggle.tsx
Normal file
34
src/components/settings/view/SettingsToggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from '../../../lib/utils';
|
||||||
|
|
||||||
|
type SettingsToggleProps = {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsToggle({ checked, onChange, ariaLabel, disabled }: SettingsToggleProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-7 w-12 flex-shrink-0 touch-manipulation cursor-pointer items-center rounded-full border-2 transition-colors duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
checked ? 'border-primary bg-primary' : 'border-border bg-muted',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200',
|
||||||
|
checked ? 'translate-x-[22px] bg-white' : 'translate-x-[2px] bg-foreground/60 dark:bg-foreground/80',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { DarkModeToggle } from '../../../../shared/view/ui';
|
import { DarkModeToggle } from '../../../../shared/view/ui';
|
||||||
import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
|
import type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';
|
||||||
import LanguageSelector from '../../../../shared/view/ui/LanguageSelector';
|
import LanguageSelector from '../../../../shared/view/ui/LanguageSelector';
|
||||||
|
import SettingsCard from '../SettingsCard';
|
||||||
|
import SettingsRow from '../SettingsRow';
|
||||||
|
import SettingsSection from '../SettingsSection';
|
||||||
|
import SettingsToggle from '../SettingsToggle';
|
||||||
|
|
||||||
type AppearanceSettingsTabProps = {
|
type AppearanceSettingsTabProps = {
|
||||||
projectSortOrder: ProjectSortOrder;
|
projectSortOrder: ProjectSortOrder;
|
||||||
@@ -15,52 +18,6 @@ type AppearanceSettingsTabProps = {
|
|||||||
onCodeEditorFontSizeChange: (value: string) => void;
|
onCodeEditorFontSizeChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToggleCardProps = {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (value: boolean) => void;
|
|
||||||
onIcon?: ReactNode;
|
|
||||||
offIcon?: ReactNode;
|
|
||||||
ariaLabel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ToggleCard({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
onIcon,
|
|
||||||
offIcon,
|
|
||||||
ariaLabel,
|
|
||||||
}: ToggleCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-foreground">{label}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{description}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className="relative inline-flex h-8 w-14 items-center rounded-full bg-gray-200 transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-700 dark:focus:ring-offset-gray-900"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{ariaLabel}</span>
|
|
||||||
<span
|
|
||||||
className={`${checked ? 'translate-x-7' : 'translate-x-1'
|
|
||||||
} flex h-6 w-6 transform items-center justify-center rounded-full bg-white shadow-lg transition-transform duration-200`}
|
|
||||||
>
|
|
||||||
{checked ? onIcon : offIcon}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppearanceSettingsTab({
|
export default function AppearanceSettingsTab({
|
||||||
projectSortOrder,
|
projectSortOrder,
|
||||||
onProjectSortOrderChange,
|
onProjectSortOrderChange,
|
||||||
@@ -72,108 +29,98 @@ export default function AppearanceSettingsTab({
|
|||||||
onCodeEditorFontSizeChange,
|
onCodeEditorFontSizeChange,
|
||||||
}: AppearanceSettingsTabProps) {
|
}: AppearanceSettingsTabProps) {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
const codeEditorThemeLabel = t('appearanceSettings.codeEditor.theme.label');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 md:space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-4">
|
<SettingsSection title={t('appearanceSettings.darkMode.label')}>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<SettingsCard>
|
||||||
<div className="flex items-center justify-between">
|
<SettingsRow
|
||||||
<div>
|
label={t('appearanceSettings.darkMode.label')}
|
||||||
<div className="font-medium text-foreground">{t('appearanceSettings.darkMode.label')}</div>
|
description={t('appearanceSettings.darkMode.description')}
|
||||||
<div className="text-sm text-muted-foreground">
|
>
|
||||||
{t('appearanceSettings.darkMode.description')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DarkModeToggle ariaLabel={t('appearanceSettings.darkMode.label')} />
|
<DarkModeToggle ariaLabel={t('appearanceSettings.darkMode.label')} />
|
||||||
</div>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<SettingsSection title={t('mainTabs.appearance')}>
|
||||||
<LanguageSelector />
|
<SettingsCard>
|
||||||
</div>
|
<LanguageSelector />
|
||||||
|
</SettingsCard>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<SettingsSection title={t('appearanceSettings.projectSorting.label')}>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<SettingsCard>
|
||||||
<div className="flex items-center justify-between">
|
<SettingsRow
|
||||||
<div>
|
label={t('appearanceSettings.projectSorting.label')}
|
||||||
<div className="font-medium text-foreground">
|
description={t('appearanceSettings.projectSorting.description')}
|
||||||
{t('appearanceSettings.projectSorting.label')}
|
>
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t('appearanceSettings.projectSorting.description')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
value={projectSortOrder}
|
value={projectSortOrder}
|
||||||
onChange={(event) => onProjectSortOrderChange(event.target.value as ProjectSortOrder)}
|
onChange={(event) => onProjectSortOrderChange(event.target.value as ProjectSortOrder)}
|
||||||
className="w-32 rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-36"
|
||||||
>
|
>
|
||||||
<option value="name">{t('appearanceSettings.projectSorting.alphabetical')}</option>
|
<option value="name">{t('appearanceSettings.projectSorting.alphabetical')}</option>
|
||||||
<option value="date">{t('appearanceSettings.projectSorting.recentActivity')}</option>
|
<option value="date">{t('appearanceSettings.projectSorting.recentActivity')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</div>
|
</SettingsSection>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<SettingsSection title={t('appearanceSettings.codeEditor.title')}>
|
||||||
<h3 className="text-lg font-semibold text-foreground">{t('appearanceSettings.codeEditor.title')}</h3>
|
<SettingsCard divided>
|
||||||
|
<SettingsRow
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
label={t('appearanceSettings.codeEditor.theme.label')}
|
||||||
<div className="flex items-center justify-between">
|
description={t('appearanceSettings.codeEditor.theme.description')}
|
||||||
<div>
|
>
|
||||||
<div className="font-medium text-foreground">{codeEditorThemeLabel}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t('appearanceSettings.codeEditor.theme.description')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DarkModeToggle
|
<DarkModeToggle
|
||||||
checked={codeEditorSettings.theme === 'dark'}
|
checked={codeEditorSettings.theme === 'dark'}
|
||||||
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
|
onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}
|
||||||
ariaLabel={codeEditorThemeLabel}
|
ariaLabel={t('appearanceSettings.codeEditor.theme.label')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SettingsRow>
|
||||||
</div>
|
|
||||||
|
|
||||||
<ToggleCard
|
<SettingsRow
|
||||||
label={t('appearanceSettings.codeEditor.wordWrap.label')}
|
label={t('appearanceSettings.codeEditor.wordWrap.label')}
|
||||||
description={t('appearanceSettings.codeEditor.wordWrap.description')}
|
description={t('appearanceSettings.codeEditor.wordWrap.description')}
|
||||||
checked={codeEditorSettings.wordWrap}
|
>
|
||||||
onChange={onCodeEditorWordWrapChange}
|
<SettingsToggle
|
||||||
ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}
|
checked={codeEditorSettings.wordWrap}
|
||||||
/>
|
onChange={onCodeEditorWordWrapChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<ToggleCard
|
<SettingsRow
|
||||||
label={t('appearanceSettings.codeEditor.showMinimap.label')}
|
label={t('appearanceSettings.codeEditor.showMinimap.label')}
|
||||||
description={t('appearanceSettings.codeEditor.showMinimap.description')}
|
description={t('appearanceSettings.codeEditor.showMinimap.description')}
|
||||||
checked={codeEditorSettings.showMinimap}
|
>
|
||||||
onChange={onCodeEditorShowMinimapChange}
|
<SettingsToggle
|
||||||
ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}
|
checked={codeEditorSettings.showMinimap}
|
||||||
/>
|
onChange={onCodeEditorShowMinimapChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<ToggleCard
|
<SettingsRow
|
||||||
label={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
label={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
||||||
description={t('appearanceSettings.codeEditor.lineNumbers.description')}
|
description={t('appearanceSettings.codeEditor.lineNumbers.description')}
|
||||||
checked={codeEditorSettings.lineNumbers}
|
>
|
||||||
onChange={onCodeEditorLineNumbersChange}
|
<SettingsToggle
|
||||||
ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
checked={codeEditorSettings.lineNumbers}
|
||||||
/>
|
onChange={onCodeEditorLineNumbersChange}
|
||||||
|
ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<SettingsRow
|
||||||
<div className="flex items-center justify-between">
|
label={t('appearanceSettings.codeEditor.fontSize.label')}
|
||||||
<div>
|
description={t('appearanceSettings.codeEditor.fontSize.description')}
|
||||||
<div className="font-medium text-foreground">
|
>
|
||||||
{t('appearanceSettings.codeEditor.fontSize.label')}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{t('appearanceSettings.codeEditor.fontSize.description')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<select
|
<select
|
||||||
value={codeEditorSettings.fontSize}
|
value={codeEditorSettings.fontSize}
|
||||||
onChange={(event) => onCodeEditorFontSizeChange(event.target.value)}
|
onChange={(event) => onCodeEditorFontSizeChange(event.target.value)}
|
||||||
className="w-24 rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
className="w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-28"
|
||||||
>
|
>
|
||||||
<option value="10">10px</option>
|
<option value="10">10px</option>
|
||||||
<option value="11">11px</option>
|
<option value="11">11px</option>
|
||||||
@@ -185,9 +132,9 @@ export default function AppearanceSettingsTab({
|
|||||||
<option value="18">18px</option>
|
<option value="18">18px</option>
|
||||||
<option value="20">20px</option>
|
<option value="20">20px</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsCard>
|
||||||
</div>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { cn } from '../../../../../lib/utils';
|
||||||
import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';
|
import SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type { AgentProvider, AuthStatus } from '../../../types/types';
|
import type { AgentProvider, AuthStatus } from '../../../types/types';
|
||||||
|
|
||||||
@@ -36,27 +36,15 @@ const agentConfig: Record<AgentProvider, AgentConfig> = {
|
|||||||
|
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
blue: {
|
blue: {
|
||||||
border: 'border-l-blue-500 md:border-l-blue-500',
|
|
||||||
borderBottom: 'border-b-blue-500',
|
|
||||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
|
||||||
dot: 'bg-blue-500',
|
dot: 'bg-blue-500',
|
||||||
},
|
},
|
||||||
purple: {
|
purple: {
|
||||||
border: 'border-l-purple-500 md:border-l-purple-500',
|
|
||||||
borderBottom: 'border-b-purple-500',
|
|
||||||
bg: 'bg-purple-50 dark:bg-purple-900/20',
|
|
||||||
dot: 'bg-purple-500',
|
dot: 'bg-purple-500',
|
||||||
},
|
},
|
||||||
gray: {
|
gray: {
|
||||||
border: 'border-l-gray-700 dark:border-l-gray-300',
|
dot: 'bg-foreground/60',
|
||||||
borderBottom: 'border-b-gray-700 dark:border-b-gray-300',
|
|
||||||
bg: 'bg-gray-100 dark:bg-gray-800/50',
|
|
||||||
dot: 'bg-gray-700 dark:bg-gray-300',
|
|
||||||
},
|
},
|
||||||
indigo: {
|
indigo: {
|
||||||
border: 'border-l-indigo-500 md:border-l-indigo-500',
|
|
||||||
borderBottom: 'border-b-indigo-500',
|
|
||||||
bg: 'bg-indigo-50 dark:bg-indigo-900/20',
|
|
||||||
dot: 'bg-indigo-500',
|
dot: 'bg-indigo-500',
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
@@ -68,7 +56,6 @@ export default function AgentListItem({
|
|||||||
onClick,
|
onClick,
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: AgentListItemProps) {
|
}: AgentListItemProps) {
|
||||||
const { t } = useTranslation('settings');
|
|
||||||
const config = agentConfig[agentId];
|
const config = agentConfig[agentId];
|
||||||
const colors = colorClasses[config.color];
|
const colors = colorClasses[config.color];
|
||||||
|
|
||||||
@@ -76,16 +63,18 @@ export default function AgentListItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`flex-1 border-b-2 px-2 py-3 text-center transition-colors ${isSelected
|
className={cn(
|
||||||
? `${colors.borderBottom} ${colors.bg}`
|
'min-w-0 flex-1 touch-manipulation rounded-md px-2 py-2 text-center transition-all duration-150',
|
||||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
isSelected
|
||||||
}`}
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground active:bg-background/50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex items-center justify-center gap-1.5">
|
||||||
<SessionProviderLogo provider={agentId} className="h-5 w-5" />
|
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="text-xs font-medium text-foreground">{config.name}</span>
|
<span className="truncate text-xs font-medium">{config.name}</span>
|
||||||
{authStatus.authenticated && (
|
{authStatus.authenticated && (
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -95,32 +84,20 @@ export default function AgentListItem({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`w-full border-l-4 p-3 text-left transition-colors ${isSelected
|
className={cn(
|
||||||
? `${colors.border} ${colors.bg}`
|
'flex touch-manipulation items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',
|
||||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-gray-800'
|
isSelected
|
||||||
}`}
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground active:bg-background/50',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<SessionProviderLogo provider={agentId} className="h-4 w-4 flex-shrink-0" />
|
||||||
<SessionProviderLogo provider={agentId} className="h-4 w-4" />
|
<span>{config.name}</span>
|
||||||
<span className="font-medium text-foreground">{config.name}</span>
|
{authStatus.authenticated ? (
|
||||||
</div>
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />
|
||||||
<div className="pl-6 text-xs text-muted-foreground">
|
) : authStatus.loading ? (
|
||||||
{authStatus.loading ? (
|
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse" />
|
||||||
<span className="text-gray-400">{t('agents.authStatus.checking')}</span>
|
) : null}
|
||||||
) : authStatus.authenticated ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className={`h-1.5 w-1.5 rounded-full ${colors.dot}`} />
|
|
||||||
<span className="max-w-[120px] truncate" title={authStatus.email ?? undefined}>
|
|
||||||
{authStatus.email || t('agents.authStatus.connected')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-gray-400" />
|
|
||||||
<span>{t('agents.authStatus.notConnected')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default function AgentsSettingsTab({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[400px] flex-col md:min-h-[500px] md:flex-row">
|
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
|
||||||
<AgentSelectorSection
|
<AgentSelectorSection
|
||||||
selectedAgent={selectedAgent}
|
selectedAgent={selectedAgent}
|
||||||
onSelectAgent={setSelectedAgent}
|
onSelectAgent={setSelectedAgent}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { cn } from '../../../../../../lib/utils';
|
||||||
import type { AgentCategory } from '../../../../types/types';
|
import type { AgentCategory } from '../../../../types/types';
|
||||||
import type { AgentCategoryTabsSectionProps } from '../types';
|
import type { AgentCategoryTabsSectionProps } from '../types';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export default function AgentCategoryTabsSection({
|
|||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex-shrink-0 border-b border-border">
|
||||||
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
|
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
|
||||||
{AGENT_CATEGORIES.map((category) => (
|
{AGENT_CATEGORIES.map((category) => (
|
||||||
<button
|
<button
|
||||||
@@ -19,11 +20,12 @@ export default function AgentCategoryTabsSection({
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={selectedCategory === category}
|
aria-selected={selectedCategory === category}
|
||||||
onClick={() => onSelectCategory(category)}
|
onClick={() => onSelectCategory(category)}
|
||||||
className={`whitespace-nowrap border-b-2 px-3 py-2 text-xs font-medium transition-colors md:px-4 md:py-3 md:text-sm ${
|
className={cn(
|
||||||
|
'whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium touch-manipulation transition-colors duration-150',
|
||||||
selectedCategory === category
|
selectedCategory === category
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
? 'border-primary text-primary'
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{category === 'account' && t('tabs.account')}
|
{category === 'account' && t('tabs.account')}
|
||||||
{category === 'permissions' && t('tabs.permissions')}
|
{category === 'permissions' && t('tabs.permissions')}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { PillBar, Pill } from '../../../../../../shared/view/ui';
|
||||||
|
import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProviderLogo';
|
||||||
import type { AgentProvider } from '../../../../types/types';
|
import type { AgentProvider } from '../../../../types/types';
|
||||||
import AgentListItem from '../AgentListItem';
|
|
||||||
import type { AgentSelectorSectionProps } from '../types';
|
import type { AgentSelectorSectionProps } from '../types';
|
||||||
|
|
||||||
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||||
|
|
||||||
|
const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||||
|
claude: 'Claude',
|
||||||
|
cursor: 'Cursor',
|
||||||
|
codex: 'Codex',
|
||||||
|
gemini: 'Gemini',
|
||||||
|
};
|
||||||
|
|
||||||
export default function AgentSelectorSection({
|
export default function AgentSelectorSection({
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
@@ -10,35 +18,30 @@ export default function AgentSelectorSection({
|
|||||||
agentContextById,
|
agentContextById,
|
||||||
}: AgentSelectorSectionProps) {
|
}: AgentSelectorSectionProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
|
||||||
<div className="flex-shrink-0 border-b border-gray-200 dark:border-gray-700 md:hidden">
|
<PillBar className="w-full md:w-auto">
|
||||||
<div className="flex">
|
{AGENT_PROVIDERS.map((agent) => {
|
||||||
{AGENT_PROVIDERS.map((agent) => (
|
const dotColor =
|
||||||
<AgentListItem
|
agent === 'claude' ? 'bg-blue-500' :
|
||||||
key={`mobile-${agent}`}
|
agent === 'cursor' ? 'bg-purple-500' :
|
||||||
agentId={agent}
|
agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';
|
||||||
authStatus={agentContextById[agent].authStatus}
|
|
||||||
isSelected={selectedAgent === agent}
|
|
||||||
onClick={() => onSelectAgent(agent)}
|
|
||||||
isMobile
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden w-48 flex-shrink-0 border-r border-gray-200 dark:border-gray-700 md:block">
|
return (
|
||||||
<div className="p-2">
|
<Pill
|
||||||
{AGENT_PROVIDERS.map((agent) => (
|
key={agent}
|
||||||
<AgentListItem
|
isActive={selectedAgent === agent}
|
||||||
key={`desktop-${agent}`}
|
|
||||||
agentId={agent}
|
|
||||||
authStatus={agentContextById[agent].authStatus}
|
|
||||||
isSelected={selectedAgent === agent}
|
|
||||||
onClick={() => onSelectAgent(agent)}
|
onClick={() => onSelectAgent(agent)}
|
||||||
/>
|
className="min-w-0 flex-1 justify-center md:flex-initial"
|
||||||
))}
|
>
|
||||||
</div>
|
<SessionProviderLogo provider={agent} className="h-4 w-4 flex-shrink-0" />
|
||||||
</div>
|
<span className="truncate">{AGENT_NAMES[agent]}</span>
|
||||||
</>
|
{agentContextById[agent].authStatus.authenticated && (
|
||||||
|
<span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotColor}`} />
|
||||||
|
)}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PillBar>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
|||||||
borderClass: 'border-blue-200 dark:border-blue-800',
|
borderClass: 'border-blue-200 dark:border-blue-800',
|
||||||
textClass: 'text-blue-900 dark:text-blue-100',
|
textClass: 'text-blue-900 dark:text-blue-100',
|
||||||
subtextClass: 'text-blue-700 dark:text-blue-300',
|
subtextClass: 'text-blue-700 dark:text-blue-300',
|
||||||
buttonClass: 'bg-blue-600 hover:bg-blue-700',
|
buttonClass: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800',
|
||||||
},
|
},
|
||||||
cursor: {
|
cursor: {
|
||||||
name: 'Cursor',
|
name: 'Cursor',
|
||||||
@@ -35,15 +35,15 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
|||||||
borderClass: 'border-purple-200 dark:border-purple-800',
|
borderClass: 'border-purple-200 dark:border-purple-800',
|
||||||
textClass: 'text-purple-900 dark:text-purple-100',
|
textClass: 'text-purple-900 dark:text-purple-100',
|
||||||
subtextClass: 'text-purple-700 dark:text-purple-300',
|
subtextClass: 'text-purple-700 dark:text-purple-300',
|
||||||
buttonClass: 'bg-purple-600 hover:bg-purple-700',
|
buttonClass: 'bg-purple-600 hover:bg-purple-700 active:bg-purple-800',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
name: 'Codex',
|
name: 'Codex',
|
||||||
bgClass: 'bg-gray-100 dark:bg-gray-800/50',
|
bgClass: 'bg-muted/50',
|
||||||
borderClass: 'border-gray-300 dark:border-gray-600',
|
borderClass: 'border-gray-300 dark:border-gray-600',
|
||||||
textClass: 'text-gray-900 dark:text-gray-100',
|
textClass: 'text-gray-900 dark:text-gray-100',
|
||||||
subtextClass: 'text-gray-700 dark:text-gray-300',
|
subtextClass: 'text-gray-700 dark:text-gray-300',
|
||||||
buttonClass: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
buttonClass: 'bg-gray-800 hover:bg-gray-900 active:bg-gray-950 dark:bg-gray-700 dark:hover:bg-gray-600 dark:active:bg-gray-500',
|
||||||
},
|
},
|
||||||
gemini: {
|
gemini: {
|
||||||
name: 'Gemini',
|
name: 'Gemini',
|
||||||
@@ -52,7 +52,7 @@ const agentConfig: Record<AgentProvider, AgentVisualConfig> = {
|
|||||||
borderClass: 'border-indigo-200 dark:border-indigo-800',
|
borderClass: 'border-indigo-200 dark:border-indigo-800',
|
||||||
textClass: 'text-indigo-900 dark:text-indigo-100',
|
textClass: 'text-indigo-900 dark:text-indigo-100',
|
||||||
subtextClass: 'text-indigo-700 dark:text-indigo-300',
|
subtextClass: 'text-indigo-700 dark:text-indigo-300',
|
||||||
buttonClass: 'bg-indigo-600 hover:bg-indigo-700',
|
buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{authStatus.loading ? (
|
{authStatus.loading ? (
|
||||||
<Badge variant="secondary" className="bg-gray-100 dark:bg-gray-800">
|
<Badge variant="secondary" className="bg-muted">
|
||||||
{t('agents.authStatus.checking')}
|
{t('agents.authStatus.checking')}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : authStatus.authenticated ? (
|
) : authStatus.authenticated ? (
|
||||||
@@ -107,7 +107,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authStatus.method !== 'api_key' && (
|
{authStatus.method !== 'api_key' && (
|
||||||
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className={`font-medium ${config.textClass}`}>
|
<div className={`font-medium ${config.textClass}`}>
|
||||||
@@ -132,7 +132,7 @@ export default function AccountContent({ agent, authStatus, onLogin }: AccountCo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{authStatus.error && (
|
{authStatus.error && (
|
||||||
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
|
<div className="border-t border-border/50 pt-4">
|
||||||
<div className="text-sm text-red-600 dark:text-red-400">
|
<div className="text-sm text-red-600 dark:text-red-400">
|
||||||
{t('agents.error', { error: authStatus.error })}
|
{t('agents.error', { error: authStatus.error })}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function ClaudeMcpServers({
|
|||||||
const toolsResult = serverTools[serverId];
|
const toolsResult = serverTools[serverId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={serverId} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -102,19 +102,19 @@ function ClaudeMcpServers({
|
|||||||
{server.type === 'stdio' && server.config?.command && (
|
{server.type === 'stdio' && server.config?.command && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.command')}:{' '}
|
{t('mcpServers.config.command')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.url')}:{' '}
|
{t('mcpServers.config.url')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.url}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
{server.config?.args && server.config.args.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.args')}:{' '}
|
{t('mcpServers.config.args')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.args.join(' ')}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -156,7 +156,7 @@ function ClaudeMcpServers({
|
|||||||
onClick={() => onEdit(server)}
|
onClick={() => onEdit(server)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-gray-700"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
title={t('mcpServers.actions.edit')}
|
title={t('mcpServers.actions.edit')}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
@@ -176,7 +176,7 @@ function ClaudeMcpServers({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{servers.length === 0 && (
|
{servers.length === 0 && (
|
||||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
|
|||||||
const serverId = server.id || server.name;
|
const serverId = server.id || server.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={serverId} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -226,7 +226,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
|
|||||||
{server.config?.command && (
|
{server.config?.command && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.command')}:{' '}
|
{t('mcpServers.config.command')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -236,7 +236,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
|
|||||||
onClick={() => onEdit(server)}
|
onClick={() => onEdit(server)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-gray-700"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
title={t('mcpServers.actions.edit')}
|
title={t('mcpServers.actions.edit')}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
@@ -256,7 +256,7 @@ function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpSe
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{servers.length === 0 && (
|
{servers.length === 0 && (
|
||||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,7 +278,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="h-5 w-5 text-gray-700 dark:text-gray-300" />
|
<Server className="h-5 w-5 text-muted-foreground" />
|
||||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
|
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
|
||||||
@@ -297,7 +297,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{servers.map((server) => (
|
{servers.map((server) => (
|
||||||
<div key={server.name} className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
@@ -310,19 +310,19 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
|
|||||||
{server.config?.command && (
|
{server.config?.command && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.command')}:{' '}
|
{t('mcpServers.config.command')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.command}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{server.config?.args && server.config.args.length > 0 && (
|
{server.config?.args && server.config.args.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.args')}:{' '}
|
{t('mcpServers.config.args')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">{server.config.args.join(' ')}</code>
|
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
{t('mcpServers.config.environment')}:{' '}
|
{t('mcpServers.config.environment')}:{' '}
|
||||||
<code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-800">
|
<code className="rounded bg-muted px-1 text-xs">
|
||||||
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,7 +335,7 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
|
|||||||
onClick={() => onEdit(server)}
|
onClick={() => onEdit(server)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-600 hover:text-gray-700"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
title={t('mcpServers.actions.edit')}
|
title={t('mcpServers.actions.edit')}
|
||||||
>
|
>
|
||||||
<Edit3 className="h-4 w-4" />
|
<Edit3 className="h-4 w-4" />
|
||||||
@@ -354,13 +354,13 @@ function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{servers.length === 0 && (
|
{servers.length === 0 && (
|
||||||
<div className="py-8 text-center text-gray-500 dark:text-gray-400">{t('mcpServers.empty')}</div>
|
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-300 bg-gray-100 p-4 dark:border-gray-600 dark:bg-gray-800/50">
|
<div className="rounded-lg border border-border bg-muted/50 p-4">
|
||||||
<h4 className="mb-2 font-medium text-gray-900 dark:text-gray-100">{t('mcpServers.help.title')}</h4>
|
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
|
||||||
<p className="text-sm text-gray-700 dark:text-gray-300">{t('mcpServers.help.description')}</p>
|
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function ClaudePermissions({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||||
@@ -150,7 +150,7 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
{t('permissions.allowedTools.quickAdd')}
|
{t('permissions.allowedTools.quickAdd')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -184,7 +184,7 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{allowedTools.length === 0 && (
|
{allowedTools.length === 0 && (
|
||||||
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
|
<div className="py-6 text-center text-muted-foreground">
|
||||||
{t('permissions.allowedTools.empty')}
|
{t('permissions.allowedTools.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -237,7 +237,7 @@ function ClaudePermissions({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{disallowedTools.length === 0 && (
|
{disallowedTools.length === 0 && (
|
||||||
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
|
<div className="py-6 text-center text-muted-foreground">
|
||||||
{t('permissions.blockedTools.empty')}
|
{t('permissions.blockedTools.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -315,7 +315,7 @@ function CursorPermissions({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={skipPermissions}
|
checked={skipPermissions}
|
||||||
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
onChange={(event) => onSkipPermissionsChange(event.target.checked)}
|
||||||
className="h-4 w-4 rounded border-gray-300 bg-gray-100 text-purple-600 focus:ring-2 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700"
|
className="h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-orange-900 dark:text-orange-100">
|
<div className="font-medium text-orange-900 dark:text-orange-100">
|
||||||
@@ -361,7 +361,7 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
{t('permissions.allowedCommands.quickAdd')}
|
{t('permissions.allowedCommands.quickAdd')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -395,7 +395,7 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{allowedCommands.length === 0 && (
|
{allowedCommands.length === 0 && (
|
||||||
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
|
<div className="py-6 text-center text-muted-foreground">
|
||||||
{t('permissions.allowedCommands.empty')}
|
{t('permissions.allowedCommands.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -448,7 +448,7 @@ function CursorPermissions({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{disallowedCommands.length === 0 && (
|
{disallowedCommands.length === 0 && (
|
||||||
<div className="py-6 text-center text-gray-500 dark:text-gray-400">
|
<div className="py-6 text-center text-muted-foreground">
|
||||||
{t('permissions.blockedCommands.empty')}
|
{t('permissions.blockedCommands.empty')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -490,8 +490,8 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
|
||||||
? 'border-gray-400 bg-gray-100 dark:border-gray-500 dark:bg-gray-800'
|
? 'border-border bg-accent'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('default')}
|
onClick={() => onPermissionModeChange('default')}
|
||||||
>
|
>
|
||||||
@@ -515,7 +515,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'acceptEdits'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'acceptEdits'
|
||||||
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
|
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('acceptEdits')}
|
onClick={() => onPermissionModeChange('acceptEdits')}
|
||||||
>
|
>
|
||||||
@@ -539,7 +539,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'bypassPermissions'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'bypassPermissions'
|
||||||
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
|
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('bypassPermissions')}
|
onClick={() => onPermissionModeChange('bypassPermissions')}
|
||||||
>
|
>
|
||||||
@@ -567,7 +567,7 @@ function CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<Codex
|
|||||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||||
{t('permissions.codex.technicalDetails')}
|
{t('permissions.codex.technicalDetails')}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-2 rounded-lg bg-gray-50 p-3 text-xs text-muted-foreground dark:bg-gray-900/50">
|
<div className="mt-2 space-y-2 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
|
||||||
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
|
<p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>
|
||||||
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
|
<p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>
|
||||||
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
|
<p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>
|
||||||
@@ -604,8 +604,8 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
|
|||||||
{/* Default Mode */}
|
{/* Default Mode */}
|
||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'
|
||||||
? 'border-gray-400 bg-gray-100 dark:border-gray-500 dark:bg-gray-800'
|
? 'border-border bg-accent'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('default')}
|
onClick={() => onPermissionModeChange('default')}
|
||||||
>
|
>
|
||||||
@@ -630,7 +630,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
|
|||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'auto_edit'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'auto_edit'
|
||||||
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
|
? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('auto_edit')}
|
onClick={() => onPermissionModeChange('auto_edit')}
|
||||||
>
|
>
|
||||||
@@ -655,7 +655,7 @@ function GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<Gemi
|
|||||||
<div
|
<div
|
||||||
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'yolo'
|
className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'yolo'
|
||||||
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
|
? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'
|
||||||
: 'border-gray-200 bg-gray-50 hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900/50 dark:hover:border-gray-600'
|
: 'border-border bg-card/50 active:border-border active:bg-accent/50'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onPermissionModeChange('yolo')}
|
onClick={() => onPermissionModeChange('yolo')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Check, GitBranch } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useGitSettings } from '../../../hooks/useGitSettings';
|
import { useGitSettings } from '../../../hooks/useGitSettings';
|
||||||
import { Button, Input } from '../../../../../shared/view/ui';
|
import { Button, Input } from '../../../../../shared/view/ui';
|
||||||
|
import SettingsCard from '../../SettingsCard';
|
||||||
|
import SettingsSection from '../../SettingsSection';
|
||||||
|
|
||||||
export default function GitSettingsTab() {
|
export default function GitSettingsTab() {
|
||||||
const { t } = useTranslation('settings');
|
const { t } = useTranslation('settings');
|
||||||
@@ -18,64 +20,62 @@ export default function GitSettingsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<SettingsSection
|
||||||
<div className="mb-4 flex items-center gap-2">
|
title={t('git.title')}
|
||||||
<GitBranch className="h-5 w-5" />
|
description={t('git.description')}
|
||||||
<h3 className="text-lg font-semibold">{t('git.title')}</h3>
|
>
|
||||||
</div>
|
<SettingsCard className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="settings-git-name" className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
{t('git.name.label')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="settings-git-name"
|
||||||
|
type="text"
|
||||||
|
value={gitName}
|
||||||
|
onChange={(event) => setGitName(event.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('git.name.help')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="mb-4 text-sm text-muted-foreground">{t('git.description')}</p>
|
<div>
|
||||||
|
<label htmlFor="settings-git-email" className="mb-2 block text-sm font-medium text-foreground">
|
||||||
|
{t('git.email.label')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="settings-git-email"
|
||||||
|
type="email"
|
||||||
|
value={gitEmail}
|
||||||
|
onChange={(event) => setGitEmail(event.target.value)}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('git.email.help')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 rounded-lg border bg-card p-4">
|
<div className="flex items-center gap-2">
|
||||||
<div>
|
<Button
|
||||||
<label htmlFor="settings-git-name" className="mb-2 block text-sm font-medium text-foreground">
|
onClick={saveGitConfig}
|
||||||
{t('git.name.label')}
|
disabled={isSaving || !gitName.trim() || !gitEmail.trim()}
|
||||||
</label>
|
>
|
||||||
<Input
|
{isSaving ? t('git.actions.saving') : t('git.actions.save')}
|
||||||
id="settings-git-name"
|
</Button>
|
||||||
type="text"
|
|
||||||
value={gitName}
|
{saveStatus === 'success' && (
|
||||||
onChange={(event) => setGitName(event.target.value)}
|
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
placeholder="John Doe"
|
<Check className="h-4 w-4" />
|
||||||
disabled={isLoading}
|
{t('git.status.success')}
|
||||||
className="w-full"
|
</div>
|
||||||
/>
|
)}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t('git.name.help')}</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
<div>
|
</SettingsSection>
|
||||||
<label htmlFor="settings-git-email" className="mb-2 block text-sm font-medium text-foreground">
|
|
||||||
{t('git.email.label')}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="settings-git-email"
|
|
||||||
type="email"
|
|
||||||
value={gitEmail}
|
|
||||||
onChange={(event) => setGitEmail(event.target.value)}
|
|
||||||
placeholder="john@example.com"
|
|
||||||
disabled={isLoading}
|
|
||||||
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={isSaving || !gitName.trim() || !gitEmail.trim()}
|
|
||||||
>
|
|
||||||
{isSaving ? t('git.actions.saving') : t('git.actions.save')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{saveStatus === 'success' && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
{t('git.status.success')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';
|
import { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';
|
||||||
|
import SettingsCard from '../../SettingsCard';
|
||||||
|
import SettingsRow from '../../SettingsRow';
|
||||||
|
import SettingsSection from '../../SettingsSection';
|
||||||
|
import SettingsToggle from '../../SettingsToggle';
|
||||||
|
|
||||||
type TasksSettingsContextValue = {
|
type TasksSettingsContextValue = {
|
||||||
tasksEnabled: boolean;
|
tasksEnabled: boolean;
|
||||||
@@ -19,88 +23,83 @@ export default function TasksSettingsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{isCheckingInstallation ? (
|
<SettingsSection title={t('mainTabs.tasks')}>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
{isCheckingInstallation ? (
|
||||||
<div className="flex items-center gap-3">
|
<SettingsCard className="p-4">
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
</div>
|
<span className="text-sm text-muted-foreground">{t('tasks.checking')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</SettingsCard>
|
||||||
<>
|
) : (
|
||||||
{!isTaskMasterInstalled && (
|
<>
|
||||||
<div className="rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950/50">
|
{!isTaskMasterInstalled && (
|
||||||
<div className="flex items-start gap-3">
|
<div className="rounded-xl border border-orange-200 bg-orange-50 p-4 dark:border-orange-800/50 dark:bg-orange-950/30">
|
||||||
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900">
|
<div className="flex items-start gap-3">
|
||||||
<svg className="h-4 w-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/50">
|
||||||
<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 className="h-4 w-4 text-orange-600 dark:text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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" />
|
||||||
</div>
|
</svg>
|
||||||
<div className="flex-1">
|
|
||||||
<div className="mb-2 font-medium text-orange-900 dark:text-orange-100">
|
|
||||||
{t('tasks.notInstalled.title')}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 text-sm text-orange-800 dark:text-orange-200">
|
<div className="flex-1">
|
||||||
<p>{t('tasks.notInstalled.description')}</p>
|
<div className="mb-2 font-medium text-orange-900 dark:text-orange-100">
|
||||||
|
{t('tasks.notInstalled.title')}
|
||||||
<div className="rounded-lg bg-orange-100 p-3 font-mono text-sm dark:bg-orange-900/50">
|
|
||||||
<code>{t('tasks.notInstalled.installCommand')}</code>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-3 text-sm text-orange-800 dark:text-orange-200">
|
||||||
|
<p>{t('tasks.notInstalled.description')}</p>
|
||||||
|
|
||||||
<div>
|
<div className="rounded-lg bg-orange-100 p-3 font-mono text-sm dark:bg-orange-900/40">
|
||||||
<a
|
<code>{t('tasks.notInstalled.installCommand')}</code>
|
||||||
href="https://github.com/eyaltoledano/claude-task-master"
|
</div>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-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="h-3 w-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">
|
<div>
|
||||||
<p className="font-medium">{t('tasks.notInstalled.afterInstallation')}</p>
|
<a
|
||||||
<ol className="list-inside list-decimal space-y-1 text-xs">
|
href="https://github.com/eyaltoledano/claude-task-master"
|
||||||
<li>{t('tasks.notInstalled.steps.restart')}</li>
|
target="_blank"
|
||||||
<li>{t('tasks.notInstalled.steps.autoAvailable')}</li>
|
rel="noopener noreferrer"
|
||||||
<li>{t('tasks.notInstalled.steps.initCommand')}</li>
|
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||||
</ol>
|
>
|
||||||
|
<svg className="h-4 w-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="h-3 w-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-inside list-decimal 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{isTaskMasterInstalled && (
|
{isTaskMasterInstalled && (
|
||||||
<div className="space-y-4">
|
<SettingsCard>
|
||||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
<SettingsRow
|
||||||
<div className="flex items-center justify-between">
|
label={t('tasks.settings.enableLabel')}
|
||||||
<div>
|
description={t('tasks.settings.enableDescription')}
|
||||||
<div className="font-medium text-foreground">{t('tasks.settings.enableLabel')}</div>
|
>
|
||||||
<div className="mt-1 text-sm text-muted-foreground">{t('tasks.settings.enableDescription')}</div>
|
<SettingsToggle
|
||||||
</div>
|
checked={tasksEnabled}
|
||||||
<label className="relative inline-flex cursor-pointer items-center">
|
onChange={setTasksEnabled}
|
||||||
<input
|
ariaLabel={t('tasks.settings.enableLabel')}
|
||||||
type="checkbox"
|
/>
|
||||||
checked={tasksEnabled}
|
</SettingsRow>
|
||||||
onChange={(event) => setTasksEnabled(event.target.checked)}
|
</SettingsCard>
|
||||||
className="peer sr-only"
|
)}
|
||||||
/>
|
</>
|
||||||
<div className="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-blue-800" />
|
)}
|
||||||
</label>
|
</SettingsSection>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import { isCodexLoginCommand } from '../utils/auth';
|
import { isCodexLoginCommand } from '../utils/auth';
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
@@ -103,6 +104,37 @@ export function useShellTerminal({
|
|||||||
|
|
||||||
nextTerminal.open(terminalContainerRef.current);
|
nextTerminal.open(terminalContainerRef.current);
|
||||||
|
|
||||||
|
const copyTerminalSelection = async () => {
|
||||||
|
const selection = nextTerminal.getSelection();
|
||||||
|
if (!selection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyTextToClipboard(selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalCopy = (event: ClipboardEvent) => {
|
||||||
|
if (!nextTerminal.hasSelection()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = nextTerminal.getSelection();
|
||||||
|
if (!selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (event.clipboardData) {
|
||||||
|
event.clipboardData.setData('text/plain', selection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void copyTextToClipboard(selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||||
? CODEX_DEVICE_AUTH_URL
|
? CODEX_DEVICE_AUTH_URL
|
||||||
@@ -132,7 +164,7 @@ export function useShellTerminal({
|
|||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
document.execCommand('copy');
|
void copyTerminalSelection();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +243,7 @@ export function useShellTerminal({
|
|||||||
resizeObserver.observe(terminalContainerRef.current);
|
resizeObserver.observe(terminalContainerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Shell({
|
|||||||
onProcessComplete = null,
|
onProcessComplete = null,
|
||||||
minimal = false,
|
minimal = false,
|
||||||
autoConnect = false,
|
autoConnect = false,
|
||||||
isActive,
|
isActive = true,
|
||||||
}: ShellProps) {
|
}: ShellProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [isRestarting, setIsRestarting] = useState(false);
|
||||||
@@ -48,9 +48,6 @@ export default function Shell({
|
|||||||
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onOutputRef = useRef<(() => void) | null>(null);
|
const onOutputRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Keep the public API stable for existing callers that still pass `isActive`.
|
|
||||||
void isActive;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -157,6 +154,24 @@ export default function Shell({
|
|||||||
}
|
}
|
||||||
}, [isConnected]);
|
}, [isConnected]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive || !isInitialized || !isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusTerminal = () => {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const animationFrameId = window.requestAnimationFrame(focusTerminal);
|
||||||
|
const timeoutId = window.setTimeout(focusTerminal, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isActive, isConnected, isInitialized, terminalRef]);
|
||||||
|
|
||||||
const sendInput = useCallback(
|
const sendInput = useCallback(
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
sendSocketMessage(wsRef.current, { type: 'input', data });
|
sendSocketMessage(wsRef.current, { type: 'input', data });
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import type { TFunction } from 'i18next';
|
|||||||
import { ScrollArea } from '../../../../shared/view/ui';
|
import { ScrollArea } from '../../../../shared/view/ui';
|
||||||
import type { Project } from '../../../../types/app';
|
import type { Project } from '../../../../types/app';
|
||||||
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
import type { ReleaseInfo } from '../../../../types/sharedTypes';
|
||||||
|
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
||||||
import SidebarFooter from './SidebarFooter';
|
import SidebarFooter from './SidebarFooter';
|
||||||
import SidebarHeader from './SidebarHeader';
|
import SidebarHeader from './SidebarHeader';
|
||||||
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
import SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';
|
||||||
import type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';
|
|
||||||
|
|
||||||
type SearchMode = 'projects' | 'conversations';
|
type SearchMode = 'projects' | 'conversations';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
|||||||
parts.push(snippet.slice(cursor, h.start));
|
parts.push(snippet.slice(cursor, h.start));
|
||||||
}
|
}
|
||||||
parts.push(
|
parts.push(
|
||||||
<mark key={h.start} className="bg-yellow-200 dark:bg-yellow-800 text-foreground rounded-sm px-0.5">
|
<mark key={h.start} className="rounded-sm bg-yellow-200 px-0.5 text-foreground dark:bg-yellow-800">
|
||||||
{snippet.slice(h.start, h.end)}
|
{snippet.slice(h.start, h.end)}
|
||||||
</mark>
|
</mark>
|
||||||
);
|
);
|
||||||
@@ -29,7 +29,7 @@ function HighlightedSnippet({ snippet, highlights }: { snippet: string; highligh
|
|||||||
parts.push(snippet.slice(cursor));
|
parts.push(snippet.slice(cursor));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-xs text-muted-foreground leading-relaxed">
|
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||||
{parts}
|
{parts}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -116,23 +116,23 @@ export default function SidebarContent({
|
|||||||
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
<ScrollArea className="flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2">
|
||||||
{showConversationSearch ? (
|
{showConversationSearch ? (
|
||||||
isSearching && !hasPartialResults ? (
|
isSearching && !hasPartialResults ? (
|
||||||
<div className="text-center py-12 md:py-8 px-4">
|
<div className="px-4 py-12 text-center md:py-8">
|
||||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
|
<p className="text-sm text-muted-foreground">{t('search.searching')}</p>
|
||||||
{searchProgress && (
|
{searchProgress && (
|
||||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
<p className="mt-1 text-xs text-muted-foreground/60">
|
||||||
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
|
{t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
|
) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (
|
||||||
<div className="text-center py-12 md:py-8 px-4">
|
<div className="px-4 py-12 text-center md:py-8">
|
||||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3">
|
||||||
<Search className="w-6 h-6 text-muted-foreground" />
|
<Search className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-medium text-foreground mb-2 md:mb-1">{t('search.noResults')}</h3>
|
<h3 className="mb-2 text-base font-medium text-foreground md:mb-1">{t('search.noResults')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
|
<p className="text-sm text-muted-foreground">{t('search.tryDifferentQuery')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : hasPartialResults ? (
|
) : hasPartialResults ? (
|
||||||
@@ -143,7 +143,7 @@ export default function SidebarContent({
|
|||||||
</p>
|
</p>
|
||||||
{isSearching && searchProgress && (
|
{isSearching && searchProgress && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-3 h-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
<div className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary" />
|
||||||
<p className="text-[10px] text-muted-foreground/60">
|
<p className="text-[10px] text-muted-foreground/60">
|
||||||
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
|
{searchProgress.scannedProjects}/{searchProgress.totalProjects}
|
||||||
</p>
|
</p>
|
||||||
@@ -151,9 +151,9 @@ export default function SidebarContent({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isSearching && searchProgress && (
|
{isSearching && searchProgress && (
|
||||||
<div className="mx-1 h-0.5 bg-muted rounded-full overflow-hidden">
|
<div className="mx-1 h-0.5 overflow-hidden rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary/60 rounded-full transition-all duration-300"
|
className="h-full rounded-full bg-primary/60 transition-all duration-300"
|
||||||
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
|
style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,15 +161,15 @@ export default function SidebarContent({
|
|||||||
{conversationResults.results.map((projectResult) => (
|
{conversationResults.results.map((projectResult) => (
|
||||||
<div key={projectResult.projectName} className="space-y-1">
|
<div key={projectResult.projectName} className="space-y-1">
|
||||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
<Folder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
|
||||||
<span className="text-xs font-medium text-foreground truncate">
|
<span className="truncate text-xs font-medium text-foreground">
|
||||||
{projectResult.projectDisplayName}
|
{projectResult.projectDisplayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{projectResult.sessions.map((session) => (
|
{projectResult.sessions.map((session) => (
|
||||||
<button
|
<button
|
||||||
key={`${projectResult.projectName}-${session.sessionId}`}
|
key={`${projectResult.projectName}-${session.sessionId}`}
|
||||||
className="w-full text-left rounded-md px-2 py-2 hover:bg-accent/50 transition-colors"
|
className="w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50"
|
||||||
onClick={() => onConversationResultClick(
|
onClick={() => onConversationResultClick(
|
||||||
projectResult.projectName,
|
projectResult.projectName,
|
||||||
session.sessionId,
|
session.sessionId,
|
||||||
@@ -178,13 +178,13 @@ export default function SidebarContent({
|
|||||||
session.matches[0]?.snippet
|
session.matches[0]?.snippet
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
<MessageSquare className="w-3 h-3 text-primary flex-shrink-0" />
|
<MessageSquare className="h-3 w-3 flex-shrink-0 text-primary" />
|
||||||
<span className="text-xs font-medium text-foreground truncate">
|
<span className="truncate text-xs font-medium text-foreground">
|
||||||
{session.sessionSummary}
|
{session.sessionSummary}
|
||||||
</span>
|
</span>
|
||||||
{session.provider && session.provider !== 'claude' && (
|
{session.provider && session.provider !== 'claude' && (
|
||||||
<span className="text-[9px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase flex-shrink-0">
|
<span className="flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] uppercase text-muted-foreground">
|
||||||
{session.provider}
|
{session.provider}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +192,7 @@ export default function SidebarContent({
|
|||||||
<div className="space-y-1 pl-4">
|
<div className="space-y-1 pl-4">
|
||||||
{session.matches.map((match, idx) => (
|
{session.matches.map((match, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-1">
|
<div key={idx} className="flex items-start gap-1">
|
||||||
<span className="text-[10px] text-muted-foreground/60 font-medium uppercase flex-shrink-0 mt-0.5">
|
<span className="mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60">
|
||||||
{match.role === 'user' ? 'U' : 'A'}
|
{match.role === 'user' ? 'U' : 'A'}
|
||||||
</span>
|
</span>
|
||||||
<HighlightedSnippet
|
<HighlightedSnippet
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function SidebarHeader({
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="h-3 w-3" />
|
||||||
{t('search.modeProjects')}
|
{t('search.modeProjects')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -134,26 +134,26 @@ export default function SidebarHeader({
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="h-3 w-3" />
|
||||||
{t('search.modeConversations')}
|
{t('search.modeConversations')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/50 pointer-events-none" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||||
className="nav-search-input pl-9 pr-8 h-9 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
className="nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
{searchFilter && (
|
{searchFilter && (
|
||||||
<button
|
<button
|
||||||
onClick={onClearSearchFilter}
|
onClick={onClearSearchFilter}
|
||||||
aria-label={t('tooltips.clearSearch')}
|
aria-label={t('tooltips.clearSearch')}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 hover:bg-accent rounded-md"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 text-muted-foreground" />
|
<X className="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@ export default function SidebarHeader({
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="w-3 h-3" />
|
<Folder className="h-3 w-3" />
|
||||||
{t('search.modeProjects')}
|
{t('search.modeProjects')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -226,26 +226,26 @@ export default function SidebarHeader({
|
|||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="h-3 w-3" />
|
||||||
{t('search.modeConversations')}
|
{t('search.modeConversations')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground/50 pointer-events-none" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}
|
||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={(event) => onSearchFilterChange(event.target.value)}
|
onChange={(event) => onSearchFilterChange(event.target.value)}
|
||||||
className="nav-search-input pl-10 pr-9 h-10 text-sm rounded-xl border-0 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0 transition-all duration-200"
|
className="nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
{searchFilter && (
|
{searchFilter && (
|
||||||
<button
|
<button
|
||||||
onClick={onClearSearchFilter}
|
onClick={onClearSearchFilter}
|
||||||
aria-label={t('tooltips.clearSearch')}
|
aria-label={t('tooltips.clearSearch')}
|
||||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded-md"
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5 text-muted-foreground" />
|
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type StandaloneShellProps = {
|
|||||||
session?: ProjectSession | null;
|
session?: ProjectSession | null;
|
||||||
command?: string | null;
|
command?: string | null;
|
||||||
isPlainShell?: boolean | null;
|
isPlainShell?: boolean | null;
|
||||||
|
isActive?: boolean;
|
||||||
autoConnect?: boolean;
|
autoConnect?: boolean;
|
||||||
onComplete?: ((exitCode: number) => void) | null;
|
onComplete?: ((exitCode: number) => void) | null;
|
||||||
onClose?: (() => void) | null;
|
onClose?: (() => void) | null;
|
||||||
@@ -24,6 +25,7 @@ export default function StandaloneShell({
|
|||||||
session = null,
|
session = null,
|
||||||
command = null,
|
command = null,
|
||||||
isPlainShell = null,
|
isPlainShell = null,
|
||||||
|
isActive = true,
|
||||||
autoConnect = true,
|
autoConnect = true,
|
||||||
onComplete = null,
|
onComplete = null,
|
||||||
onClose = null,
|
onClose = null,
|
||||||
@@ -64,6 +66,7 @@ export default function StandaloneShell({
|
|||||||
selectedSession={session}
|
selectedSession={session}
|
||||||
initialCommand={command}
|
initialCommand={command}
|
||||||
isPlainShell={shouldUsePlainShell}
|
isPlainShell={shouldUsePlainShell}
|
||||||
|
isActive={isActive}
|
||||||
onProcessComplete={handleProcessComplete}
|
onProcessComplete={handleProcessComplete}
|
||||||
minimal={minimal}
|
minimal={minimal}
|
||||||
autoConnect={minimal ? true : autoConnect}
|
autoConnect={minimal ? true : autoConnect}
|
||||||
|
|||||||
157
src/contexts/PluginsContext.tsx
Normal file
157
src/contexts/PluginsContext.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { authenticatedFetch } from '../utils/api';
|
||||||
|
|
||||||
|
export type Plugin = {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
icon: string;
|
||||||
|
type: 'react' | 'module';
|
||||||
|
slot: 'tab';
|
||||||
|
entry: string;
|
||||||
|
server: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
enabled: boolean;
|
||||||
|
serverRunning: boolean;
|
||||||
|
dirName: string;
|
||||||
|
repoUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PluginsContextValue = {
|
||||||
|
plugins: Plugin[];
|
||||||
|
loading: boolean;
|
||||||
|
pluginsError: string | null;
|
||||||
|
refreshPlugins: () => Promise<void>;
|
||||||
|
installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
|
togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PluginsContext = createContext<PluginsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function usePlugins() {
|
||||||
|
const context = useContext(PluginsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePlugins must be used within a PluginsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PluginsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pluginsError, setPluginsError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshPlugins = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedFetch('/api/plugins');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setPlugins(data.plugins || []);
|
||||||
|
setPluginsError(null);
|
||||||
|
} else {
|
||||||
|
let errorMessage = `Failed to fetch plugins (${res.status})`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
errorMessage = data.details || data.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
errorMessage = res.statusText || errorMessage;
|
||||||
|
}
|
||||||
|
setPluginsError(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch plugins';
|
||||||
|
setPluginsError(message);
|
||||||
|
console.error('[Plugins] Failed to fetch plugins:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshPlugins();
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
|
const installPlugin = useCallback(async (url: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedFetch('/api/plugins/install', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await refreshPlugins();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: data.details || data.error || 'Install failed' };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : 'Install failed' };
|
||||||
|
}
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
|
const uninstallPlugin = useCallback(async (name: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await refreshPlugins();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: data.details || data.error || 'Uninstall failed' };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };
|
||||||
|
}
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
|
const updatePlugin = useCallback(async (name: string) => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await refreshPlugins();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false, error: data.details || data.error || 'Update failed' };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : 'Update failed' };
|
||||||
|
}
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
|
const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {
|
||||||
|
try {
|
||||||
|
const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let errorMessage = `Toggle failed (${res.status})`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
errorMessage = data.details || data.error || errorMessage;
|
||||||
|
} catch {
|
||||||
|
// response body wasn't JSON, use status text
|
||||||
|
errorMessage = res.statusText || errorMessage;
|
||||||
|
}
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
await refreshPlugins();
|
||||||
|
return { success: true, error: null };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };
|
||||||
|
}
|
||||||
|
}, [refreshPlugins]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>
|
||||||
|
{children}
|
||||||
|
</PluginsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ const buildWebSocketUrl = (token: string | null) => {
|
|||||||
const useWebSocketProviderState = (): WebSocketContextType => {
|
const useWebSocketProviderState = (): WebSocketContextType => {
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const unmountedRef = useRef(false); // Track if component is unmounted
|
const unmountedRef = useRef(false); // Track if component is unmounted
|
||||||
|
const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)
|
||||||
const [latestMessage, setLatestMessage] = useState<any>(null);
|
const [latestMessage, setLatestMessage] = useState<any>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -61,6 +62,11 @@ const useWebSocketProviderState = (): WebSocketContextType => {
|
|||||||
websocket.onopen = () => {
|
websocket.onopen = () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
wsRef.current = websocket;
|
wsRef.current = websocket;
|
||||||
|
if (hasConnectedRef.current) {
|
||||||
|
// This is a reconnect — signal so components can catch up on missed messages
|
||||||
|
setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
hasConnectedRef.current = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onmessage = (event) => {
|
websocket.onmessage = (event) => {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ type UseProjectsStateArgs = {
|
|||||||
activeSessions: Set<string>;
|
activeSessions: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FetchProjectsOptions = {
|
||||||
|
showLoadingState?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
const serialize = (value: unknown) => JSON.stringify(value ?? null);
|
||||||
|
|
||||||
const projectsHaveChanges = (
|
const projectsHaveChanges = (
|
||||||
@@ -106,10 +110,14 @@ const isUpdateAdditive = (
|
|||||||
|
|
||||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||||
|
|
||||||
|
const isValidTab = (tab: string): tab is AppTab => {
|
||||||
|
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
|
||||||
|
};
|
||||||
|
|
||||||
const readPersistedTab = (): AppTab => {
|
const readPersistedTab = (): AppTab => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('activeTab');
|
const stored = localStorage.getItem('activeTab');
|
||||||
if (stored && VALID_TABS.has(stored)) {
|
if (stored && isValidTab(stored)) {
|
||||||
return stored as AppTab;
|
return stored as AppTab;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -148,9 +156,11 @@ export function useProjectsState({
|
|||||||
|
|
||||||
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const fetchProjects = useCallback(async () => {
|
const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {
|
||||||
try {
|
try {
|
||||||
setIsLoadingProjects(true);
|
if (showLoadingState) {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
}
|
||||||
const response = await api.projects();
|
const response = await api.projects();
|
||||||
const projectData = (await response.json()) as Project[];
|
const projectData = (await response.json()) as Project[];
|
||||||
|
|
||||||
@@ -166,10 +176,17 @@ export function useProjectsState({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching projects:', error);
|
console.error('Error fetching projects:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingProjects(false);
|
if (showLoadingState) {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshProjectsSilently = useCallback(async () => {
|
||||||
|
// Keep chat view stable while still syncing sidebar/session metadata in background.
|
||||||
|
await fetchProjects({ showLoadingState: false });
|
||||||
|
}, [fetchProjects]);
|
||||||
|
|
||||||
const openSettings = useCallback((tab = 'tools') => {
|
const openSettings = useCallback((tab = 'tools') => {
|
||||||
setSettingsInitialTab(tab);
|
setSettingsInitialTab(tab);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
@@ -543,6 +560,7 @@ export function useProjectsState({
|
|||||||
setShowSettings,
|
setShowSettings,
|
||||||
openSettings,
|
openSettings,
|
||||||
fetchProjects,
|
fetchProjects,
|
||||||
|
refreshProjectsSilently,
|
||||||
sidebarSharedProps,
|
sidebarSharedProps,
|
||||||
handleProjectSelect,
|
handleProjectSelect,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ import jaCodeEditor from './locales/ja/codeEditor.json';
|
|||||||
// eslint-disable-next-line import-x/order
|
// eslint-disable-next-line import-x/order
|
||||||
import jaTasks from './locales/ja/tasks.json';
|
import jaTasks from './locales/ja/tasks.json';
|
||||||
|
|
||||||
|
import ruCommon from './locales/ru/common.json';
|
||||||
|
import ruSettings from './locales/ru/settings.json';
|
||||||
|
import ruAuth from './locales/ru/auth.json';
|
||||||
|
import ruSidebar from './locales/ru/sidebar.json';
|
||||||
|
import ruChat from './locales/ru/chat.json';
|
||||||
|
import ruCodeEditor from './locales/ru/codeEditor.json';
|
||||||
|
// eslint-disable-next-line import-x/order
|
||||||
|
import ruTasks from './locales/ru/tasks.json';
|
||||||
|
|
||||||
// Import supported languages configuration
|
// Import supported languages configuration
|
||||||
import { languages } from './languages.js';
|
import { languages } from './languages.js';
|
||||||
|
|
||||||
@@ -107,6 +116,15 @@ i18n
|
|||||||
codeEditor: jaCodeEditor,
|
codeEditor: jaCodeEditor,
|
||||||
tasks: jaTasks,
|
tasks: jaTasks,
|
||||||
},
|
},
|
||||||
|
ru: {
|
||||||
|
common: ruCommon,
|
||||||
|
settings: ruSettings,
|
||||||
|
auth: ruAuth,
|
||||||
|
sidebar: ruSidebar,
|
||||||
|
chat: ruChat,
|
||||||
|
codeEditor: ruCodeEditor,
|
||||||
|
tasks: ruTasks,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Default language
|
// Default language
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export const languages = [
|
|||||||
label: 'Japanese',
|
label: 'Japanese',
|
||||||
nativeName: '日本語',
|
nativeName: '日本語',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'ru',
|
||||||
|
label: 'Russian',
|
||||||
|
nativeName: 'Русский',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,10 @@
|
|||||||
},
|
},
|
||||||
"copyMessage": {
|
"copyMessage": {
|
||||||
"copy": "Copy message",
|
"copy": "Copy message",
|
||||||
"copied": "Message copied"
|
"copied": "Message copied",
|
||||||
|
"selectFormat": "Select copy format",
|
||||||
|
"copyAsMarkdown": "Copy as markdown",
|
||||||
|
"copyAsText": "Copy as text"
|
||||||
},
|
},
|
||||||
"messageTypes": {
|
"messageTypes": {
|
||||||
"user": "U",
|
"user": "U",
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & Tokens",
|
"apiTokens": "API & Tokens",
|
||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications",
|
||||||
|
"plugins": "Plugins"
|
||||||
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "Notifications",
|
"title": "Notifications",
|
||||||
@@ -328,6 +330,9 @@
|
|||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"description": "OpenAI Codex AI assistant"
|
"description": "OpenAI Codex AI assistant"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"description": "Google Gemini AI assistant"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connectionStatus": "Connection Status",
|
"connectionStatus": "Connection Status",
|
||||||
@@ -450,5 +455,41 @@
|
|||||||
"title": "About Codex MCP",
|
"title": "About Codex MCP",
|
||||||
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
"description": "Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "Plugins",
|
||||||
|
"description": "Extend the interface with custom plugins. Install from git or drop a folder in ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "Install",
|
||||||
|
"installing": "Installing…",
|
||||||
|
"securityWarning": "Only install plugins whose source code you have reviewed or from authors you trust.",
|
||||||
|
"scanningPlugins": "Scanning plugins…",
|
||||||
|
"noPluginsInstalled": "No plugins installed",
|
||||||
|
"pullLatest": "Pull latest from git",
|
||||||
|
"noGitRemote": "No git remote — update not available",
|
||||||
|
"uninstallPlugin": "Uninstall plugin",
|
||||||
|
"confirmUninstall": "Click again to confirm",
|
||||||
|
"confirmUninstallMessage": "Remove {{name}}? This cannot be undone.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"remove": "Remove",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"installFailed": "Installation failed",
|
||||||
|
"uninstallFailed": "Uninstall failed",
|
||||||
|
"toggleFailed": "Toggle failed",
|
||||||
|
"buildYourOwn": "Build your own plugin",
|
||||||
|
"starter": "Starter",
|
||||||
|
"docs": "Docs",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "Project Stats",
|
||||||
|
"badge": "starter",
|
||||||
|
"description": "File counts, lines of code, file-type breakdown, and recent activity for your project.",
|
||||||
|
"install": "Install"
|
||||||
|
},
|
||||||
|
"morePlugins": "More",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable",
|
||||||
|
"installAriaLabel": "Plugin git repository URL",
|
||||||
|
"tab": "tab",
|
||||||
|
"runningStatus": "running"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
},
|
},
|
||||||
"copyMessage": {
|
"copyMessage": {
|
||||||
"copy": "メッセージをコピー",
|
"copy": "メッセージをコピー",
|
||||||
"copied": "メッセージをコピーしました"
|
"copied": "メッセージをコピーしました",
|
||||||
|
"selectFormat": "コピー形式を選択",
|
||||||
|
"copyAsMarkdown": "Markdownとしてコピー",
|
||||||
|
"copyAsText": "テキストとしてコピー"
|
||||||
},
|
},
|
||||||
"messageTypes": {
|
"messageTypes": {
|
||||||
"user": "U",
|
"user": "U",
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & トークン",
|
"apiTokens": "API & トークン",
|
||||||
"tasks": "タスク",
|
"tasks": "タスク",
|
||||||
"notifications": "通知"
|
"notifications": "通知",
|
||||||
|
"plugins": "プラグイン"
|
||||||
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "通知",
|
"title": "通知",
|
||||||
@@ -328,6 +330,9 @@
|
|||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"description": "OpenAI Codex AIアシスタント"
|
"description": "OpenAI Codex AIアシスタント"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"description": "Google Gemini AIアシスタント"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connectionStatus": "接続状態",
|
"connectionStatus": "接続状態",
|
||||||
@@ -450,5 +455,41 @@
|
|||||||
"title": "Codex MCPについて",
|
"title": "Codex MCPについて",
|
||||||
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
|
"description": "Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "プラグイン",
|
||||||
|
"description": "カスタムプラグインでインターフェースを拡張します。gitからインストールするか、~/.claude-code-ui/plugins/ にフォルダを配置してください。",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "インストール",
|
||||||
|
"installing": "インストール中…",
|
||||||
|
"securityWarning": "信頼できる作成者のプラグイン、またはソースコードを確認済みのプラグインのみをインストールしてください。",
|
||||||
|
"scanningPlugins": "プラグインをスキャン中…",
|
||||||
|
"noPluginsInstalled": "プラグインがインストールされていません",
|
||||||
|
"pullLatest": "gitから最新を取得",
|
||||||
|
"noGitRemote": "リモートgitリポジトリがありません — アップデート不可",
|
||||||
|
"uninstallPlugin": "プラグインを削除",
|
||||||
|
"confirmUninstall": "クリックして確定",
|
||||||
|
"confirmUninstallMessage": "{{name}} を削除しますか?この操作は取り消せません。",
|
||||||
|
"cancel": "キャンセル",
|
||||||
|
"remove": "削除",
|
||||||
|
"updateFailed": "アップデートに失敗しました",
|
||||||
|
"installFailed": "インストールに失敗しました",
|
||||||
|
"uninstallFailed": "削除に失敗しました",
|
||||||
|
"toggleFailed": "切り替えに失敗しました",
|
||||||
|
"buildYourOwn": "プラグインを自作する",
|
||||||
|
"starter": "スターター",
|
||||||
|
"docs": "ドキュメント",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "プロジェクト統計",
|
||||||
|
"badge": "スターター",
|
||||||
|
"description": "プロジェクトのファイル数、コード行数、ファイルタイプの内訳、最近のアクティビティを表示します。",
|
||||||
|
"install": "インストール"
|
||||||
|
},
|
||||||
|
"morePlugins": "詳細",
|
||||||
|
"enable": "有効にする",
|
||||||
|
"disable": "無効にする",
|
||||||
|
"installAriaLabel": "プラグインのgitリポジトリURL",
|
||||||
|
"tab": "タブ",
|
||||||
|
"runningStatus": "実行中"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
},
|
},
|
||||||
"copyMessage": {
|
"copyMessage": {
|
||||||
"copy": "메시지 복사",
|
"copy": "메시지 복사",
|
||||||
"copied": "메시지 복사됨"
|
"copied": "메시지 복사됨",
|
||||||
|
"selectFormat": "복사 형식 선택",
|
||||||
|
"copyAsMarkdown": "마크다운으로 복사",
|
||||||
|
"copyAsText": "텍스트로 복사"
|
||||||
},
|
},
|
||||||
"messageTypes": {
|
"messageTypes": {
|
||||||
"user": "U",
|
"user": "U",
|
||||||
|
|||||||
@@ -105,7 +105,9 @@
|
|||||||
"git": "Git",
|
"git": "Git",
|
||||||
"apiTokens": "API & 토큰",
|
"apiTokens": "API & 토큰",
|
||||||
"tasks": "작업",
|
"tasks": "작업",
|
||||||
"notifications": "알림"
|
"notifications": "알림",
|
||||||
|
"plugins": "플러그인"
|
||||||
|
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"title": "알림",
|
"title": "알림",
|
||||||
@@ -328,6 +330,9 @@
|
|||||||
},
|
},
|
||||||
"codex": {
|
"codex": {
|
||||||
"description": "OpenAI Codex AI 어시스턴트"
|
"description": "OpenAI Codex AI 어시스턴트"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"description": "Google Gemini AI 어시스턴트"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"connectionStatus": "연결 상태",
|
"connectionStatus": "연결 상태",
|
||||||
@@ -450,5 +455,41 @@
|
|||||||
"title": "Codex MCP 정보",
|
"title": "Codex MCP 정보",
|
||||||
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
|
"description": "Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "플러그인",
|
||||||
|
"description": "커스텀 플러그인으로 인터페이스를 확장하세요. git에서 설치하거나 ~/.claude-code-ui/plugins/ 폴더에 직접 추가할 수 있습니다.",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "설치",
|
||||||
|
"installing": "설치 중…",
|
||||||
|
"securityWarning": "소스 코드를 검토했거나 신뢰할 수 있는 작성자의 플러그인만 설치하세요.",
|
||||||
|
"scanningPlugins": "플러그인 스캔 중…",
|
||||||
|
"noPluginsInstalled": "설치된 플러그인이 없습니다",
|
||||||
|
"pullLatest": "git에서 최신 버전 가져오기",
|
||||||
|
"noGitRemote": "git 리모트가 없음 — 업데이트 불가",
|
||||||
|
"uninstallPlugin": "플러그인 삭제",
|
||||||
|
"confirmUninstall": "다시 클릭하여 확인",
|
||||||
|
"confirmUninstallMessage": "{{name}} 플러그인을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||||
|
"cancel": "취소",
|
||||||
|
"remove": "삭제",
|
||||||
|
"updateFailed": "업데이트 실패",
|
||||||
|
"installFailed": "설치 실패",
|
||||||
|
"uninstallFailed": "삭제 실패",
|
||||||
|
"toggleFailed": "토글 실패",
|
||||||
|
"buildYourOwn": "나만의 플러그인 만들기",
|
||||||
|
"starter": "스타터",
|
||||||
|
"docs": "문서",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "프로젝트 통계",
|
||||||
|
"badge": "스타터",
|
||||||
|
"description": "프로젝트의 파일 수, 코드 라인 수, 파일 유형별 분석 및 최근 활동을 확인합니다.",
|
||||||
|
"install": "설치"
|
||||||
|
},
|
||||||
|
"morePlugins": "더 보기",
|
||||||
|
"enable": "활성화",
|
||||||
|
"disable": "비활성화",
|
||||||
|
"installAriaLabel": "플러그인 git 저장소 URL",
|
||||||
|
"tab": "탭",
|
||||||
|
"runningStatus": "실행 중"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
37
src/i18n/locales/ru/auth.json
Normal file
37
src/i18n/locales/ru/auth.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"login": {
|
||||||
|
"title": "Добро пожаловать",
|
||||||
|
"description": "Войдите в свой аккаунт Claude Code UI",
|
||||||
|
"username": "Имя пользователя",
|
||||||
|
"password": "Пароль",
|
||||||
|
"submit": "Войти",
|
||||||
|
"loading": "Вход...",
|
||||||
|
"errors": {
|
||||||
|
"invalidCredentials": "Неверное имя пользователя или пароль",
|
||||||
|
"requiredFields": "Пожалуйста, заполните все поля",
|
||||||
|
"networkError": "Ошибка сети. Попробуйте снова."
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"username": "Введите имя пользователя",
|
||||||
|
"password": "Введите пароль"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Создать аккаунт",
|
||||||
|
"username": "Имя пользователя",
|
||||||
|
"password": "Пароль",
|
||||||
|
"confirmPassword": "Подтвердите пароль",
|
||||||
|
"submit": "Создать аккаунт",
|
||||||
|
"loading": "Создание аккаунта...",
|
||||||
|
"errors": {
|
||||||
|
"passwordMismatch": "Пароли не совпадают",
|
||||||
|
"usernameTaken": "Имя пользователя уже занято",
|
||||||
|
"weakPassword": "Пароль слишком слабый"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"title": "Выйти",
|
||||||
|
"confirm": "Вы уверены, что хотите выйти?",
|
||||||
|
"button": "Выйти"
|
||||||
|
}
|
||||||
|
}
|
||||||
272
src/i18n/locales/ru/chat.json
Normal file
272
src/i18n/locales/ru/chat.json
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
{
|
||||||
|
"codeBlock": {
|
||||||
|
"copy": "Копировать",
|
||||||
|
"copied": "Скопировано",
|
||||||
|
"copyCode": "Копировать код"
|
||||||
|
},
|
||||||
|
"copyMessage": {
|
||||||
|
"copy": "Копировать сообщение",
|
||||||
|
"copied": "Сообщение скопировано",
|
||||||
|
"selectFormat": "Выбрать формат копирования",
|
||||||
|
"copyAsMarkdown": "Копировать как Markdown",
|
||||||
|
"copyAsText": "Копировать как текст"
|
||||||
|
},
|
||||||
|
"messageTypes": {
|
||||||
|
"user": "П",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"tool": "Инструмент",
|
||||||
|
"claude": "Claude",
|
||||||
|
"cursor": "Cursor",
|
||||||
|
"codex": "Codex",
|
||||||
|
"gemini": "Gemini"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"settings": "Настройки инструмента",
|
||||||
|
"error": "Ошибка инструмента",
|
||||||
|
"result": "Результат инструмента",
|
||||||
|
"viewParams": "Просмотр входных параметров",
|
||||||
|
"viewRawParams": "Просмотр сырых параметров",
|
||||||
|
"viewDiff": "Просмотр различий редактирования для",
|
||||||
|
"creatingFile": "Создание нового файла:",
|
||||||
|
"updatingTodo": "Обновление списка задач",
|
||||||
|
"read": "Чтение",
|
||||||
|
"readFile": "Чтение файла",
|
||||||
|
"updateTodo": "Обновить список задач",
|
||||||
|
"readTodo": "Прочитать список задач",
|
||||||
|
"searchResults": "результаты"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"found": "Найдено {{count}} {{type}}",
|
||||||
|
"file": "файл",
|
||||||
|
"files": "файлов",
|
||||||
|
"pattern": "шаблон:",
|
||||||
|
"in": "в:"
|
||||||
|
},
|
||||||
|
"fileOperations": {
|
||||||
|
"updated": "Файл успешно обновлен",
|
||||||
|
"created": "Файл успешно создан",
|
||||||
|
"written": "Файл успешно записан",
|
||||||
|
"diff": "Различия",
|
||||||
|
"newFile": "Новый файл",
|
||||||
|
"viewContent": "Просмотр содержимого файла",
|
||||||
|
"viewFullOutput": "Просмотр полного вывода ({{count}} символов)",
|
||||||
|
"contentDisplayed": "Содержимое файла отображено в представлении различий выше"
|
||||||
|
},
|
||||||
|
"interactive": {
|
||||||
|
"title": "Интерактивный запрос",
|
||||||
|
"waiting": "Ожидание вашего ответа в CLI",
|
||||||
|
"instruction": "Пожалуйста, выберите опцию в терминале, где запущен Claude.",
|
||||||
|
"selectedOption": "✓ Claude выбрал опцию {{number}}",
|
||||||
|
"instructionDetail": "В CLI вы бы выбрали эту опцию интерактивно, используя клавиши со стрелками или введя номер."
|
||||||
|
},
|
||||||
|
"thinking": {
|
||||||
|
"title": "Думаю...",
|
||||||
|
"emoji": "💭 Думаю..."
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"response": "JSON ответ"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"grant": "Предоставить разрешение для {{tool}}",
|
||||||
|
"added": "Разрешение добавлено",
|
||||||
|
"addTo": "Добавляет {{entry}} в разрешенные инструменты.",
|
||||||
|
"retry": "Разрешение сохранено. Повторите запрос для использования инструмента.",
|
||||||
|
"error": "Не удалось обновить разрешения. Попробуйте снова.",
|
||||||
|
"openSettings": "Открыть настройки"
|
||||||
|
},
|
||||||
|
"todo": {
|
||||||
|
"updated": "Список задач успешно обновлен",
|
||||||
|
"current": "Текущий список задач"
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"viewPlan": "📋 Просмотр плана реализации",
|
||||||
|
"title": "План реализации"
|
||||||
|
},
|
||||||
|
"usageLimit": {
|
||||||
|
"resetAt": "Достигнут лимит использования Claude. Ваш лимит будет сброшен в **{{time}} {{timezone}}** - {{date}}"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"permissionMode": "Режим разрешений",
|
||||||
|
"modes": {
|
||||||
|
"default": "Режим по умолчанию",
|
||||||
|
"acceptEdits": "Принимать правки",
|
||||||
|
"bypassPermissions": "Обход разрешений",
|
||||||
|
"plan": "Режим планирования"
|
||||||
|
},
|
||||||
|
"descriptions": {
|
||||||
|
"default": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.",
|
||||||
|
"acceptEdits": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.",
|
||||||
|
"bypassPermissions": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.",
|
||||||
|
"plan": "Режим планирования - команды не выполняются"
|
||||||
|
},
|
||||||
|
"technicalDetails": "Технические детали"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"permissionMode": "Режим разрешений Gemini",
|
||||||
|
"description": "Управление тем, как Gemini CLI обрабатывает подтверждения операций.",
|
||||||
|
"modes": {
|
||||||
|
"default": {
|
||||||
|
"title": "Стандартный (запрашивать подтверждение)",
|
||||||
|
"description": "Gemini будет запрашивать подтверждение перед выполнением команд, записью файлов и получением веб-ресурсов."
|
||||||
|
},
|
||||||
|
"autoEdit": {
|
||||||
|
"title": "Автоматическое редактирование (пропускать подтверждения файлов)",
|
||||||
|
"description": "Gemini будет автоматически подтверждать редактирование файлов и веб-запросы, но все еще будет запрашивать подтверждение для команд оболочки."
|
||||||
|
},
|
||||||
|
"yolo": {
|
||||||
|
"title": "YOLO (обход всех разрешений)",
|
||||||
|
"description": "Gemini будет выполнять все операции без запроса подтверждения. Будьте осторожны."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"placeholder": "Введите / для команд, @ для файлов, или спросите {{provider}} что угодно...",
|
||||||
|
"placeholderDefault": "Введите ваше сообщение...",
|
||||||
|
"disabled": "Ввод отключен",
|
||||||
|
"attachFiles": "Прикрепить файлы",
|
||||||
|
"attachImages": "Прикрепить изображения",
|
||||||
|
"send": "Отправить",
|
||||||
|
"stop": "Остановить",
|
||||||
|
"hintText": {
|
||||||
|
"ctrlEnter": "Ctrl+Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд",
|
||||||
|
"enter": "Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд"
|
||||||
|
},
|
||||||
|
"clickToChangeMode": "Нажмите для смены режима разрешений (или нажмите Tab в поле ввода)",
|
||||||
|
"showAllCommands": "Показать все команды",
|
||||||
|
"clearInput": "Очистить ввод",
|
||||||
|
"scrollToBottom": "Прокрутить вниз"
|
||||||
|
},
|
||||||
|
"thinkingMode": {
|
||||||
|
"selector": {
|
||||||
|
"title": "Режим размышления",
|
||||||
|
"description": "Расширенное размышление дает Claude больше времени для оценки альтернатив",
|
||||||
|
"active": "Активен",
|
||||||
|
"tip": "Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ"
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"none": {
|
||||||
|
"name": "Стандартный",
|
||||||
|
"description": "Обычный ответ Claude",
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"think": {
|
||||||
|
"name": "Думать",
|
||||||
|
"description": "Базовое расширенное размышление",
|
||||||
|
"prefix": "думать"
|
||||||
|
},
|
||||||
|
"thinkHard": {
|
||||||
|
"name": "Думать усердно",
|
||||||
|
"description": "Более тщательная оценка",
|
||||||
|
"prefix": "думать усердно"
|
||||||
|
},
|
||||||
|
"thinkHarder": {
|
||||||
|
"name": "Думать еще усерднее",
|
||||||
|
"description": "Глубокий анализ с альтернативами",
|
||||||
|
"prefix": "думать еще усерднее"
|
||||||
|
},
|
||||||
|
"ultrathink": {
|
||||||
|
"name": "Ультра-размышление",
|
||||||
|
"description": "Максимальный бюджет размышления",
|
||||||
|
"prefix": "ультра-размышление"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buttonTitle": "Режим размышления: {{mode}}"
|
||||||
|
},
|
||||||
|
"providerSelection": {
|
||||||
|
"title": "Выберите вашего AI-ассистента",
|
||||||
|
"description": "Выберите провайдера для начала нового разговора",
|
||||||
|
"selectModel": "Выбрать модель",
|
||||||
|
"providerInfo": {
|
||||||
|
"anthropic": "от Anthropic",
|
||||||
|
"openai": "от OpenAI",
|
||||||
|
"cursorEditor": "AI редактор кода",
|
||||||
|
"google": "от Google"
|
||||||
|
},
|
||||||
|
"readyPrompt": {
|
||||||
|
"claude": "Готов использовать Claude с {{model}}. Начните вводить сообщение ниже.",
|
||||||
|
"cursor": "Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.",
|
||||||
|
"codex": "Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.",
|
||||||
|
"gemini": "Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.",
|
||||||
|
"default": "Выберите провайдера выше для начала"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"continue": {
|
||||||
|
"title": "Продолжить разговор",
|
||||||
|
"description": "Задавайте вопросы о вашем коде, запрашивайте изменения или получайте помощь с задачами разработки"
|
||||||
|
},
|
||||||
|
"loading": {
|
||||||
|
"olderMessages": "Загрузка старых сообщений...",
|
||||||
|
"sessionMessages": "Загрузка сообщений сеанса..."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"showingOf": "Показано {{shown}} из {{total}} сообщений",
|
||||||
|
"scrollToLoad": "Прокрутите вверх для загрузки еще",
|
||||||
|
"showingLast": "Показаны последние {{count}} сообщений (всего {{total}})",
|
||||||
|
"loadEarlier": "Загрузить более ранние сообщения",
|
||||||
|
"loadAll": "Загрузить все сообщения",
|
||||||
|
"loadingAll": "Загрузка всех сообщений...",
|
||||||
|
"allLoaded": "Все сообщения загружены",
|
||||||
|
"perfWarning": "Все сообщения загружены — прокрутка может быть медленнее. Нажмите \"Прокрутить вниз\" для восстановления производительности."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shell": {
|
||||||
|
"selectProject": {
|
||||||
|
"title": "Выберите проект",
|
||||||
|
"description": "Выберите проект для открытия интерактивной оболочки в этом каталоге"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"newSession": "Новый сеанс",
|
||||||
|
"initializing": "Инициализация...",
|
||||||
|
"restarting": "Перезапуск..."
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"disconnect": "Отключиться",
|
||||||
|
"disconnectTitle": "Отключиться от оболочки",
|
||||||
|
"restart": "Перезапустить",
|
||||||
|
"restartTitle": "Перезапустить оболочку (сначала отключитесь)",
|
||||||
|
"connect": "Продолжить в оболочке",
|
||||||
|
"connectTitle": "Подключиться к оболочке"
|
||||||
|
},
|
||||||
|
"loading": "Загрузка терминала...",
|
||||||
|
"connecting": "Подключение к оболочке...",
|
||||||
|
"startSession": "Начать новый сеанс Claude",
|
||||||
|
"resumeSession": "Возобновить сеанс: {{displayName}}...",
|
||||||
|
"runCommand": "Выполнить {{command}} в {{projectName}}",
|
||||||
|
"startCli": "Запуск Claude CLI в {{projectName}}",
|
||||||
|
"defaultCommand": "команда"
|
||||||
|
},
|
||||||
|
"claudeStatus": {
|
||||||
|
"actions": {
|
||||||
|
"thinking": "Думает",
|
||||||
|
"processing": "Обрабатывает",
|
||||||
|
"analyzing": "Анализирует",
|
||||||
|
"working": "Работает",
|
||||||
|
"computing": "Вычисляет",
|
||||||
|
"reasoning": "Рассуждает"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"live": "В сети",
|
||||||
|
"paused": "Приостановлен"
|
||||||
|
},
|
||||||
|
"elapsed": {
|
||||||
|
"seconds": "{{count}}с",
|
||||||
|
"minutesSeconds": "{{minutes}}м {{seconds}}с",
|
||||||
|
"label": "Прошло {{time}}",
|
||||||
|
"startingNow": "Начинается сейчас"
|
||||||
|
},
|
||||||
|
"controls": {
|
||||||
|
"stopGeneration": "Остановить генерацию",
|
||||||
|
"pressEscToStop": "Нажмите Esc в любое время для остановки"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"assistant": "Ассистент"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectSelection": {
|
||||||
|
"startChatWithProvider": "Выберите проект для начала чата с {{provider}}"
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"nextTaskPrompt": "Начать следующую задачу"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/i18n/locales/ru/codeEditor.json
Normal file
36
src/i18n/locales/ru/codeEditor.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"toolbar": {
|
||||||
|
"changes": "изменения",
|
||||||
|
"previousChange": "Предыдущее изменение",
|
||||||
|
"nextChange": "Следующее изменение",
|
||||||
|
"hideDiff": "Скрыть подсветку различий",
|
||||||
|
"showDiff": "Показать подсветку различий",
|
||||||
|
"settings": "Настройки редактора",
|
||||||
|
"collapse": "Свернуть редактор",
|
||||||
|
"expand": "Развернуть редактор на всю ширину"
|
||||||
|
},
|
||||||
|
"loading": "Загрузка {{fileName}}...",
|
||||||
|
"header": {
|
||||||
|
"showingChanges": "Показаны изменения"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"download": "Скачать файл",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"saved": "Сохранено!",
|
||||||
|
"exitFullscreen": "Выйти из полноэкранного режима",
|
||||||
|
"fullscreen": "Полноэкранный режим",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"previewMarkdown": "Предпросмотр markdown",
|
||||||
|
"editMarkdown": "Редактировать markdown"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"lines": "Строк:",
|
||||||
|
"characters": "Символов:",
|
||||||
|
"shortcuts": "Нажмите Ctrl+S для сохранения • Esc для закрытия"
|
||||||
|
},
|
||||||
|
"binaryFile": {
|
||||||
|
"title": "Бинарный файл",
|
||||||
|
"message": "Файл \"{{fileName}}\" не может быть отображен в текстовом редакторе, так как это бинарный файл."
|
||||||
|
}
|
||||||
|
}
|
||||||
238
src/i18n/locales/ru/common.json
Normal file
238
src/i18n/locales/ru/common.json
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{
|
||||||
|
"buttons": {
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"create": "Создать",
|
||||||
|
"edit": "Редактировать",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"confirm": "Подтвердить",
|
||||||
|
"submit": "Отправить",
|
||||||
|
"retry": "Повторить",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"search": "Поиск",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"copy": "Копировать",
|
||||||
|
"download": "Скачать",
|
||||||
|
"upload": "Загрузить",
|
||||||
|
"browse": "Обзор"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"chat": "Чат",
|
||||||
|
"shell": "Терминал",
|
||||||
|
"files": "Файлы",
|
||||||
|
"git": "Система контроля версий",
|
||||||
|
"tasks": "Задачи"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"success": "Успешно",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"failed": "Не удалось",
|
||||||
|
"pending": "Ожидание",
|
||||||
|
"completed": "Завершено",
|
||||||
|
"inProgress": "В процессе"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"savedSuccessfully": "Успешно сохранено",
|
||||||
|
"deletedSuccessfully": "Успешно удалено",
|
||||||
|
"updatedSuccessfully": "Успешно обновлено",
|
||||||
|
"operationFailed": "Операция не удалась",
|
||||||
|
"networkError": "Ошибка сети. Проверьте подключение.",
|
||||||
|
"unauthorized": "Не авторизован. Пожалуйста, войдите.",
|
||||||
|
"notFound": "Не найдено",
|
||||||
|
"invalidInput": "Неверный ввод",
|
||||||
|
"requiredField": "Это поле обязательно",
|
||||||
|
"unknownError": "Произошла неизвестная ошибка"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"settings": "Настройки",
|
||||||
|
"home": "Главная",
|
||||||
|
"back": "Назад",
|
||||||
|
"next": "Далее",
|
||||||
|
"previous": "Предыдущий",
|
||||||
|
"logout": "Выйти"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"language": "Язык",
|
||||||
|
"theme": "Тема",
|
||||||
|
"darkMode": "Темная тема",
|
||||||
|
"lightMode": "Светлая тема",
|
||||||
|
"name": "Имя",
|
||||||
|
"description": "Описание",
|
||||||
|
"enabled": "Включено",
|
||||||
|
"disabled": "Отключено",
|
||||||
|
"optional": "Необязательно",
|
||||||
|
"version": "Версия",
|
||||||
|
"select": "Выбрать",
|
||||||
|
"selectAll": "Выбрать все",
|
||||||
|
"deselectAll": "Снять выделение"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "Только что",
|
||||||
|
"minutesAgo": "{{count}} мин. назад",
|
||||||
|
"hoursAgo": "{{count}} ч. назад",
|
||||||
|
"daysAgo": "{{count}} дн. назад",
|
||||||
|
"yesterday": "Вчера"
|
||||||
|
},
|
||||||
|
"fileOperations": {
|
||||||
|
"newFile": "Новый файл",
|
||||||
|
"newFolder": "Новая папка",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"move": "Переместить",
|
||||||
|
"copyPath": "Копировать путь",
|
||||||
|
"openInEditor": "Открыть в редакторе"
|
||||||
|
},
|
||||||
|
"mainContent": {
|
||||||
|
"loading": "Загрузка Claude Code UI",
|
||||||
|
"settingUpWorkspace": "Настройка рабочего пространства...",
|
||||||
|
"chooseProject": "Выберите проект",
|
||||||
|
"selectProjectDescription": "Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.",
|
||||||
|
"tip": "Совет",
|
||||||
|
"createProjectMobile": "Нажмите кнопку меню выше для доступа к проектам",
|
||||||
|
"createProjectDesktop": "Создайте новый проект, нажав на значок папки на боковой панели",
|
||||||
|
"newSession": "Новый сеанс",
|
||||||
|
"untitledSession": "Безымянный сеанс",
|
||||||
|
"projectFiles": "Файлы проекта"
|
||||||
|
},
|
||||||
|
"fileTree": {
|
||||||
|
"loading": "Загрузка файлов...",
|
||||||
|
"files": "Файлы",
|
||||||
|
"simpleView": "Простой вид",
|
||||||
|
"compactView": "Компактный вид",
|
||||||
|
"detailedView": "Подробный вид",
|
||||||
|
"searchPlaceholder": "Поиск файлов и папок...",
|
||||||
|
"clearSearch": "Очистить поиск",
|
||||||
|
"name": "Имя",
|
||||||
|
"size": "Размер",
|
||||||
|
"modified": "Изменено",
|
||||||
|
"permissions": "Права доступа",
|
||||||
|
"noFilesFound": "Файлы не найдены",
|
||||||
|
"checkProjectPath": "Проверьте доступность пути к проекту",
|
||||||
|
"noMatchesFound": "Совпадений не найдено",
|
||||||
|
"tryDifferentSearch": "Попробуйте другой поисковый запрос или очистите поиск",
|
||||||
|
"justNow": "только что",
|
||||||
|
"minAgo": "{{count}} мин. назад",
|
||||||
|
"hoursAgo": "{{count}} ч. назад",
|
||||||
|
"daysAgo": "{{count}} дн. назад",
|
||||||
|
"newFile": "Новый файл (Cmd+N)",
|
||||||
|
"newFolder": "Новая папка (Cmd+Shift+N)",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"collapseAll": "Свернуть все",
|
||||||
|
"context": {
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"copyPath": "Копировать путь",
|
||||||
|
"download": "Скачать",
|
||||||
|
"newFile": "Новый файл",
|
||||||
|
"newFolder": "Новая папка",
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"menuLabel": "Контекстное меню файла",
|
||||||
|
"loading": "Загрузка..."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"projectWizard": {
|
||||||
|
"title": "Создать новый проект",
|
||||||
|
"steps": {
|
||||||
|
"type": "Тип",
|
||||||
|
"configure": "Настройка",
|
||||||
|
"confirm": "Подтверждение"
|
||||||
|
},
|
||||||
|
"step1": {
|
||||||
|
"question": "У вас уже есть рабочее пространство или вы хотите создать новое?",
|
||||||
|
"existing": {
|
||||||
|
"title": "Существующее рабочее пространство",
|
||||||
|
"description": "У меня уже есть рабочее пространство на сервере, нужно только добавить его в список проектов"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"title": "Новое рабочее пространство",
|
||||||
|
"description": "Создать новое рабочее пространство, опционально клонировать из репозитория GitHub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"step2": {
|
||||||
|
"existingPath": "Путь к рабочему пространству",
|
||||||
|
"newPath": "Путь к рабочему пространству",
|
||||||
|
"existingPlaceholder": "/путь/к/существующему/пространству",
|
||||||
|
"newPlaceholder": "/путь/к/новому/пространству",
|
||||||
|
"existingHelp": "Полный путь к каталогу вашего рабочего пространства",
|
||||||
|
"newHelp": "Полный путь к каталогу вашего рабочего пространства",
|
||||||
|
"githubUrl": "URL GitHub (необязательно)",
|
||||||
|
"githubPlaceholder": "https://github.com/username/repository",
|
||||||
|
"githubHelp": "Необязательно: укажите URL GitHub для клонирования репозитория",
|
||||||
|
"githubAuth": "Аутентификация GitHub (необязательно)",
|
||||||
|
"githubAuthHelp": "Требуется только для приватных репозиториев. Публичные репозитории можно клонировать без аутентификации.",
|
||||||
|
"loadingTokens": "Загрузка сохраненных токенов...",
|
||||||
|
"storedToken": "Сохраненный токен",
|
||||||
|
"newToken": "Новый токен",
|
||||||
|
"nonePublic": "Нет (публичный)",
|
||||||
|
"selectToken": "Выбрать токен",
|
||||||
|
"selectTokenPlaceholder": "-- Выберите токен --",
|
||||||
|
"tokenPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||||
|
"tokenHelp": "Этот токен будет использован только для этой операции",
|
||||||
|
"publicRepoInfo": "Публичные репозитории не требуют аутентификации. Вы можете пропустить токен при клонировании публичного репозитория.",
|
||||||
|
"noTokensHelp": "Нет доступных сохраненных токенов. Вы можете добавить токены в Настройки → API ключи для удобного повторного использования.",
|
||||||
|
"optionalTokenPublic": "Токен GitHub (необязательно для публичных репозиториев)",
|
||||||
|
"tokenPublicPlaceholder": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (оставьте пустым для публичных репозиториев)"
|
||||||
|
},
|
||||||
|
"step3": {
|
||||||
|
"reviewConfig": "Проверьте вашу конфигурацию",
|
||||||
|
"workspaceType": "Тип рабочего пространства:",
|
||||||
|
"existingWorkspace": "Существующее рабочее пространство",
|
||||||
|
"newWorkspace": "Новое рабочее пространство",
|
||||||
|
"path": "Путь:",
|
||||||
|
"cloneFrom": "Клонировать из:",
|
||||||
|
"authentication": "Аутентификация:",
|
||||||
|
"usingStoredToken": "Использование сохраненного токена:",
|
||||||
|
"usingProvidedToken": "Использование предоставленного токена",
|
||||||
|
"noAuthentication": "Без аутентификации",
|
||||||
|
"sshKey": "SSH ключ",
|
||||||
|
"existingInfo": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
|
||||||
|
"newWithClone": "Репозиторий будет клонирован в эту папку.",
|
||||||
|
"newEmpty": "Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.",
|
||||||
|
"cloningRepository": "Клонирование репозитория..."
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"back": "Назад",
|
||||||
|
"next": "Далее",
|
||||||
|
"createProject": "Создать проект",
|
||||||
|
"creating": "Создание...",
|
||||||
|
"cloning": "Клонирование..."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"selectType": "Пожалуйста, выберите, есть ли у вас существующее рабочее пространство или вы хотите создать новое",
|
||||||
|
"providePath": "Пожалуйста, укажите путь к рабочему пространству",
|
||||||
|
"failedToCreate": "Не удалось создать рабочее пространство",
|
||||||
|
"failedToCreateFolder": "Не удалось создать папку"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"versionUpdate": {
|
||||||
|
"title": "Доступно обновление",
|
||||||
|
"newVersionReady": "Новая версия готова",
|
||||||
|
"currentVersion": "Текущая версия",
|
||||||
|
"latestVersion": "Последняя версия",
|
||||||
|
"whatsNew": "Что нового:",
|
||||||
|
"viewFullRelease": "Посмотреть полный релиз",
|
||||||
|
"updateProgress": "Прогресс обновления:",
|
||||||
|
"manualUpgrade": "Ручное обновление:",
|
||||||
|
"npmUpgradeCommand": "npm install -g @siteboon/claude-code-ui@latest",
|
||||||
|
"manualUpgradeHint": "Или нажмите \"Обновить сейчас\" для автоматического обновления.",
|
||||||
|
"updateCompleted": "Обновление успешно завершено!",
|
||||||
|
"restartServer": "Пожалуйста, перезапустите сервер для применения изменений.",
|
||||||
|
"updateFailed": "Обновление не удалось",
|
||||||
|
"buttons": {
|
||||||
|
"close": "Закрыть",
|
||||||
|
"later": "Позже",
|
||||||
|
"copyCommand": "Копировать команду",
|
||||||
|
"updateNow": "Обновить сейчас",
|
||||||
|
"updating": "Обновление..."
|
||||||
|
},
|
||||||
|
"ariaLabels": {
|
||||||
|
"closeModal": "Закрыть модальное окно обновления версии",
|
||||||
|
"showSidebar": "Показать боковую панель",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"updateAvailable": "Доступно обновление",
|
||||||
|
"closeSidebar": "Закрыть боковую панель"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
474
src/i18n/locales/ru/settings.json
Normal file
474
src/i18n/locales/ru/settings.json
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
{
|
||||||
|
"title": "Настройки",
|
||||||
|
"tabs": {
|
||||||
|
"account": "Аккаунт",
|
||||||
|
"permissions": "Разрешения",
|
||||||
|
"mcpServers": "MCP серверы",
|
||||||
|
"appearance": "Внешний вид"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Аккаунт",
|
||||||
|
"language": "Язык",
|
||||||
|
"languageLabel": "Язык интерфейса",
|
||||||
|
"languageDescription": "Выберите предпочитаемый язык для интерфейса",
|
||||||
|
"username": "Имя пользователя",
|
||||||
|
"email": "Email",
|
||||||
|
"profile": "Профиль",
|
||||||
|
"changePassword": "Изменить пароль"
|
||||||
|
},
|
||||||
|
"mcp": {
|
||||||
|
"title": "MCP серверы",
|
||||||
|
"addServer": "Добавить сервер",
|
||||||
|
"editServer": "Редактировать сервер",
|
||||||
|
"deleteServer": "Удалить сервер",
|
||||||
|
"serverName": "Имя сервера",
|
||||||
|
"serverType": "Тип сервера",
|
||||||
|
"config": "Конфигурация",
|
||||||
|
"testConnection": "Проверить подключение",
|
||||||
|
"status": "Статус",
|
||||||
|
"connected": "Подключен",
|
||||||
|
"disconnected": "Отключен",
|
||||||
|
"scope": {
|
||||||
|
"label": "Область",
|
||||||
|
"user": "Пользователь",
|
||||||
|
"project": "Проект"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Внешний вид",
|
||||||
|
"theme": "Тема",
|
||||||
|
"codeEditor": "Редактор кода",
|
||||||
|
"editorTheme": "Тема редактора",
|
||||||
|
"wordWrap": "Перенос слов",
|
||||||
|
"showMinimap": "Показать миникарту",
|
||||||
|
"lineNumbers": "Номера строк",
|
||||||
|
"fontSize": "Размер шрифта"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"saveChanges": "Сохранить изменения",
|
||||||
|
"resetToDefaults": "Сбросить к значениям по умолчанию",
|
||||||
|
"cancelChanges": "Отменить изменения"
|
||||||
|
},
|
||||||
|
"quickSettings": {
|
||||||
|
"title": "Быстрые настройки",
|
||||||
|
"sections": {
|
||||||
|
"appearance": "Внешний вид",
|
||||||
|
"toolDisplay": "Отображение инструментов",
|
||||||
|
"viewOptions": "Параметры просмотра",
|
||||||
|
"inputSettings": "Настройки ввода",
|
||||||
|
"whisperDictation": "Диктовка Whisper"
|
||||||
|
},
|
||||||
|
"darkMode": "Темная тема",
|
||||||
|
"autoExpandTools": "Автоматически разворачивать инструменты",
|
||||||
|
"showRawParameters": "Показывать сырые параметры",
|
||||||
|
"showThinking": "Показывать размышления",
|
||||||
|
"autoScrollToBottom": "Автопрокрутка вниз",
|
||||||
|
"sendByCtrlEnter": "Отправка по Ctrl+Enter",
|
||||||
|
"sendByCtrlEnterDescription": "Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.",
|
||||||
|
"dragHandle": {
|
||||||
|
"dragging": "Перетаскивание ручки",
|
||||||
|
"closePanel": "Закрыть панель настроек",
|
||||||
|
"openPanel": "Открыть панель настроек",
|
||||||
|
"draggingStatus": "Перетаскивание...",
|
||||||
|
"toggleAndMove": "Нажмите для переключения, перетащите для перемещения"
|
||||||
|
},
|
||||||
|
"whisper": {
|
||||||
|
"modes": {
|
||||||
|
"default": "Режим по умолчанию",
|
||||||
|
"defaultDescription": "Прямая транскрипция вашей речи",
|
||||||
|
"prompt": "Улучшение запроса",
|
||||||
|
"promptDescription": "Преобразование грубых идей в четкие, детальные AI-запросы",
|
||||||
|
"vibe": "Режим Vibe",
|
||||||
|
"vibeDescription": "Форматирование идей как четких инструкций агента с деталями"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terminalShortcuts": {
|
||||||
|
"title": "Горячие клавиши терминала",
|
||||||
|
"sectionKeys": "Клавиши",
|
||||||
|
"sectionNavigation": "Навигация",
|
||||||
|
"escape": "Escape",
|
||||||
|
"tab": "Tab",
|
||||||
|
"shiftTab": "Shift+Tab",
|
||||||
|
"arrowUp": "Стрелка вверх",
|
||||||
|
"arrowDown": "Стрелка вниз",
|
||||||
|
"scrollDown": "Прокрутка вниз",
|
||||||
|
"handle": {
|
||||||
|
"closePanel": "Закрыть панель горячих клавиш",
|
||||||
|
"openPanel": "Открыть панель горячих клавиш"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mainTabs": {
|
||||||
|
"label": "Настройки",
|
||||||
|
"agents": "Агенты",
|
||||||
|
"appearance": "Внешний вид",
|
||||||
|
"git": "Git",
|
||||||
|
"apiTokens": "API и токены",
|
||||||
|
"tasks": "Задачи",
|
||||||
|
"plugins": "Плагины"
|
||||||
|
},
|
||||||
|
"appearanceSettings": {
|
||||||
|
"darkMode": {
|
||||||
|
"label": "Темная тема",
|
||||||
|
"description": "Переключение между светлой и темной темами"
|
||||||
|
},
|
||||||
|
"projectSorting": {
|
||||||
|
"label": "Сортировка проектов",
|
||||||
|
"description": "Как проекты упорядочены на боковой панели",
|
||||||
|
"alphabetical": "По алфавиту",
|
||||||
|
"recentActivity": "По недавней активности"
|
||||||
|
},
|
||||||
|
"codeEditor": {
|
||||||
|
"title": "Редактор кода",
|
||||||
|
"theme": {
|
||||||
|
"label": "Тема редактора",
|
||||||
|
"description": "Тема по умолчанию для редактора кода"
|
||||||
|
},
|
||||||
|
"wordWrap": {
|
||||||
|
"label": "Перенос слов",
|
||||||
|
"description": "Включить перенос слов по умолчанию в редакторе"
|
||||||
|
},
|
||||||
|
"showMinimap": {
|
||||||
|
"label": "Показать миникарту",
|
||||||
|
"description": "Отображать миникарту для упрощения навигации в представлении различий"
|
||||||
|
},
|
||||||
|
"lineNumbers": {
|
||||||
|
"label": "Показать номера строк",
|
||||||
|
"description": "Отображать номера строк в редакторе"
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"label": "Размер шрифта",
|
||||||
|
"description": "Размер шрифта редактора в пикселях"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcpForm": {
|
||||||
|
"title": {
|
||||||
|
"add": "Добавить MCP сервер",
|
||||||
|
"edit": "Редактировать MCP сервер"
|
||||||
|
},
|
||||||
|
"importMode": {
|
||||||
|
"form": "Ввод формы",
|
||||||
|
"json": "Импорт JSON"
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"label": "Область",
|
||||||
|
"userGlobal": "Пользователь (глобально)",
|
||||||
|
"projectLocal": "Проект (локально)",
|
||||||
|
"userDescription": "Область пользователя: доступно во всех проектах на вашей машине",
|
||||||
|
"projectDescription": "Локальная область: доступно только в выбранном проекте",
|
||||||
|
"cannotChange": "Область не может быть изменена при редактировании существующего сервера"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"serverName": "Имя сервера",
|
||||||
|
"transportType": "Тип транспорта",
|
||||||
|
"command": "Команда",
|
||||||
|
"arguments": "Аргументы (по одному на строку)",
|
||||||
|
"jsonConfig": "JSON конфигурация",
|
||||||
|
"url": "URL",
|
||||||
|
"envVars": "Переменные окружения (КЛЮЧ=значение, по одной на строку)",
|
||||||
|
"headers": "Заголовки (КЛЮЧ=значение, по одному на строку)",
|
||||||
|
"selectProject": "Выберите проект..."
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"serverName": "мой-сервер"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"missingType": "Отсутствует обязательное поле: type",
|
||||||
|
"stdioRequiresCommand": "тип stdio требует поле command",
|
||||||
|
"httpRequiresUrl": "тип {{type}} требует поле url",
|
||||||
|
"invalidJson": "Неверный формат JSON",
|
||||||
|
"jsonHelp": "Вставьте конфигурацию вашего MCP сервера в формате JSON. Примеры форматов:",
|
||||||
|
"jsonExampleStdio": "• stdio: {\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}",
|
||||||
|
"jsonExampleHttp": "• http/sse: {\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}"
|
||||||
|
},
|
||||||
|
"configDetails": "Детали конфигурации (из {{configFile}})",
|
||||||
|
"projectPath": "Путь: {{path}}",
|
||||||
|
"actions": {
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"addServer": "Добавить сервер",
|
||||||
|
"updateServer": "Обновить сервер"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"saveStatus": {
|
||||||
|
"success": "Настройки успешно сохранены!",
|
||||||
|
"error": "Не удалось сохранить настройки",
|
||||||
|
"saving": "Сохранение..."
|
||||||
|
},
|
||||||
|
"footerActions": {
|
||||||
|
"save": "Сохранить настройки",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"title": "Конфигурация Git",
|
||||||
|
"description": "Настройте вашу git идентичность для коммитов. Эти настройки будут применены глобально через git config --global",
|
||||||
|
"name": {
|
||||||
|
"label": "Имя Git",
|
||||||
|
"help": "Ваше имя для git коммитов"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"label": "Email Git",
|
||||||
|
"help": "Ваш email для git коммитов"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Сохранить конфигурацию",
|
||||||
|
"saving": "Сохранение..."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"success": "Успешно сохранено"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apiKeys": {
|
||||||
|
"title": "API ключи",
|
||||||
|
"description": "Генерируйте API ключи для доступа к внешнему API из других приложений.",
|
||||||
|
"newKey": {
|
||||||
|
"alertTitle": "⚠️ Сохраните ваш API ключ",
|
||||||
|
"alertMessage": "Это единственный раз, когда вы увидите этот ключ. Сохраните его в безопасном месте.",
|
||||||
|
"iveSavedIt": "Я сохранил его"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"placeholder": "Имя API ключа (например, Продакшн сервер)",
|
||||||
|
"createButton": "Создать",
|
||||||
|
"cancelButton": "Отмена"
|
||||||
|
},
|
||||||
|
"newButton": "Новый API ключ",
|
||||||
|
"empty": "API ключи еще не созданы.",
|
||||||
|
"list": {
|
||||||
|
"created": "Создан:",
|
||||||
|
"lastUsed": "Последнее использование:"
|
||||||
|
},
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить этот API ключ?",
|
||||||
|
"status": {
|
||||||
|
"active": "Активен",
|
||||||
|
"inactive": "Неактивен"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"title": "GitHub токены",
|
||||||
|
"description": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев через внешний API.",
|
||||||
|
"descriptionAlt": "Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев. Вы также можете передавать токены напрямую в API запросах без их сохранения.",
|
||||||
|
"addButton": "Добавить токен",
|
||||||
|
"form": {
|
||||||
|
"namePlaceholder": "Имя токена (например, Личные репозитории)",
|
||||||
|
"tokenPlaceholder": "Персональный токен доступа GitHub (ghp_...)",
|
||||||
|
"descriptionPlaceholder": "Описание (необязательно)",
|
||||||
|
"addButton": "Добавить токен",
|
||||||
|
"cancelButton": "Отмена",
|
||||||
|
"howToCreate": "Как создать персональный токен доступа GitHub →"
|
||||||
|
},
|
||||||
|
"empty": "GitHub токены еще не добавлены.",
|
||||||
|
"added": "Добавлен:",
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить этот GitHub токен?"
|
||||||
|
},
|
||||||
|
"apiDocsLink": "Документация API",
|
||||||
|
"documentation": {
|
||||||
|
"title": "Документация внешнего API",
|
||||||
|
"description": "Узнайте, как использовать внешний API для запуска сеансов Claude/Cursor из ваших приложений.",
|
||||||
|
"viewLink": "Просмотр документации API →"
|
||||||
|
},
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"version": {
|
||||||
|
"updateAvailable": "Доступно обновление: v{{version}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tasks": {
|
||||||
|
"checking": "Проверка установки TaskMaster...",
|
||||||
|
"notInstalled": {
|
||||||
|
"title": "TaskMaster AI CLI не установлен",
|
||||||
|
"description": "TaskMaster CLI требуется для использования функций управления задачами. Установите его для начала работы:",
|
||||||
|
"installCommand": "npm install -g task-master-ai",
|
||||||
|
"viewOnGitHub": "Посмотреть на GitHub",
|
||||||
|
"afterInstallation": "После установки:",
|
||||||
|
"steps": {
|
||||||
|
"restart": "Перезапустите это приложение",
|
||||||
|
"autoAvailable": "Функции TaskMaster станут автоматически доступны",
|
||||||
|
"initCommand": "Используйте task-master init в каталоге вашего проекта"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"enableLabel": "Включить интеграцию TaskMaster",
|
||||||
|
"enableDescription": "Показывать задачи TaskMaster, баннеры и индикаторы боковой панели в интерфейсе"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"authStatus": {
|
||||||
|
"checking": "Проверка...",
|
||||||
|
"connected": "Подключен",
|
||||||
|
"notConnected": "Не подключен",
|
||||||
|
"disconnected": "Отключен",
|
||||||
|
"checkingAuth": "Проверка статуса аутентификации...",
|
||||||
|
"loggedInAs": "Вошли как {{email}}",
|
||||||
|
"authenticatedUser": "аутентифицированный пользователь"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"claude": {
|
||||||
|
"description": "AI-ассистент Anthropic Claude"
|
||||||
|
},
|
||||||
|
"cursor": {
|
||||||
|
"description": "Редактор кода с AI Cursor"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"description": "AI-ассистент OpenAI Codex"
|
||||||
|
},
|
||||||
|
"gemini": {
|
||||||
|
"description": "AI-ассистент Google Gemini"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connectionStatus": "Статус подключения",
|
||||||
|
"login": {
|
||||||
|
"title": "Вход",
|
||||||
|
"reAuthenticate": "Повторная аутентификация",
|
||||||
|
"description": "Войдите в ваш аккаунт {{agent}} для включения AI функций",
|
||||||
|
"reAuthDescription": "Войдите с другим аккаунтом или обновите учетные данные",
|
||||||
|
"button": "Войти",
|
||||||
|
"reLoginButton": "Войти снова"
|
||||||
|
},
|
||||||
|
"error": "Ошибка: {{error}}"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"title": "Настройки разрешений",
|
||||||
|
"skipPermissions": {
|
||||||
|
"label": "Пропускать запросы разрешений (используйте с осторожностью)",
|
||||||
|
"claudeDescription": "Эквивалентно флагу --dangerously-skip-permissions",
|
||||||
|
"cursorDescription": "Эквивалентно флагу -f в Cursor CLI"
|
||||||
|
},
|
||||||
|
"allowedTools": {
|
||||||
|
"title": "Разрешенные инструменты",
|
||||||
|
"description": "Инструменты, которые автоматически разрешены без запроса разрешения",
|
||||||
|
"placeholder": "например, \"Bash(git log:*)\" или \"Write\"",
|
||||||
|
"quickAdd": "Быстро добавить общие инструменты:",
|
||||||
|
"empty": "Разрешенные инструменты не настроены"
|
||||||
|
},
|
||||||
|
"blockedTools": {
|
||||||
|
"title": "Заблокированные инструменты",
|
||||||
|
"description": "Инструменты, которые автоматически блокируются без запроса разрешения",
|
||||||
|
"placeholder": "например, \"Bash(rm:*)\"",
|
||||||
|
"empty": "Заблокированные инструменты не настроены"
|
||||||
|
},
|
||||||
|
"allowedCommands": {
|
||||||
|
"title": "Разрешенные команды оболочки",
|
||||||
|
"description": "Команды оболочки, которые автоматически разрешены без запроса",
|
||||||
|
"placeholder": "например, \"Shell(ls)\" или \"Shell(git status)\"",
|
||||||
|
"quickAdd": "Быстро добавить общие команды:",
|
||||||
|
"empty": "Разрешенные команды не настроены"
|
||||||
|
},
|
||||||
|
"blockedCommands": {
|
||||||
|
"title": "Заблокированные команды оболочки",
|
||||||
|
"description": "Команды оболочки, которые автоматически блокируются",
|
||||||
|
"placeholder": "например, \"Shell(rm -rf)\" или \"Shell(sudo)\"",
|
||||||
|
"empty": "Заблокированные команды не настроены"
|
||||||
|
},
|
||||||
|
"toolExamples": {
|
||||||
|
"title": "Примеры шаблонов инструментов:",
|
||||||
|
"bashGitLog": "- Разрешить все команды git log",
|
||||||
|
"bashGitDiff": "- Разрешить все команды git diff",
|
||||||
|
"write": "- Разрешить все использование инструмента Write",
|
||||||
|
"bashRm": "- Заблокировать все команды rm (опасно)"
|
||||||
|
},
|
||||||
|
"shellExamples": {
|
||||||
|
"title": "Примеры команд оболочки:",
|
||||||
|
"ls": "- Разрешить команду ls",
|
||||||
|
"gitStatus": "- Разрешить git status",
|
||||||
|
"npmInstall": "- Разрешить npm install",
|
||||||
|
"rmRf": "- Заблокировать рекурсивное удаление"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"permissionMode": "Режим разрешений",
|
||||||
|
"description": "Управляет тем, как Codex обрабатывает изменения файлов и выполнение команд",
|
||||||
|
"modes": {
|
||||||
|
"default": {
|
||||||
|
"title": "По умолчанию",
|
||||||
|
"description": "Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство."
|
||||||
|
},
|
||||||
|
"acceptEdits": {
|
||||||
|
"title": "Принимать правки",
|
||||||
|
"description": "Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением."
|
||||||
|
},
|
||||||
|
"bypassPermissions": {
|
||||||
|
"title": "Обход разрешений",
|
||||||
|
"description": "Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"technicalDetails": "Технические детали",
|
||||||
|
"technicalInfo": {
|
||||||
|
"default": "sandboxMode=workspace-write, approvalPolicy=untrusted. Доверенные команды: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (без -exec) и т.д.",
|
||||||
|
"acceptEdits": "sandboxMode=workspace-write, approvalPolicy=never. Все команды автоматически выполняются в каталоге проекта.",
|
||||||
|
"bypassPermissions": "sandboxMode=danger-full-access, approvalPolicy=never. Полный системный доступ, используйте только в доверенных средах.",
|
||||||
|
"overrideNote": "Вы можете переопределить это для каждого сеанса, используя кнопку режима в интерфейсе чата."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Добавить"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"title": "MCP серверы",
|
||||||
|
"description": {
|
||||||
|
"claude": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Claude",
|
||||||
|
"cursor": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Cursor",
|
||||||
|
"codex": "Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Codex"
|
||||||
|
},
|
||||||
|
"addButton": "Добавить MCP сервер",
|
||||||
|
"empty": "MCP серверы не настроены",
|
||||||
|
"serverType": "Тип",
|
||||||
|
"scope": {
|
||||||
|
"local": "локальный",
|
||||||
|
"user": "пользователь"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"command": "Команда",
|
||||||
|
"url": "URL",
|
||||||
|
"args": "Аргументы",
|
||||||
|
"environment": "Окружение"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"title": "Инструменты",
|
||||||
|
"count": "({{count}}):",
|
||||||
|
"more": "+{{count}} еще"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"edit": "Редактировать сервер",
|
||||||
|
"delete": "Удалить сервер"
|
||||||
|
},
|
||||||
|
"help": {
|
||||||
|
"title": "О Codex MCP",
|
||||||
|
"description": "Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pluginSettings": {
|
||||||
|
"title": "Плагины",
|
||||||
|
"description": "Расширяйте интерфейс с помощью кастомных плагинов. Установите из git или добавьте папку в ~/.claude-code-ui/plugins/",
|
||||||
|
"installPlaceholder": "https://github.com/user/my-plugin",
|
||||||
|
"installButton": "Установить",
|
||||||
|
"installing": "Установка…",
|
||||||
|
"securityWarning": "Устанавливайте только те плагины, исходный код которых вы проверили или от авторов, которым вы доверяете.",
|
||||||
|
"scanningPlugins": "Сканирование плагинов…",
|
||||||
|
"noPluginsInstalled": "Плагины не установлены",
|
||||||
|
"pullLatest": "Получить обновления из git",
|
||||||
|
"noGitRemote": "Нет удаленного git-репозитория — обновление недоступно",
|
||||||
|
"uninstallPlugin": "Удалить плагин",
|
||||||
|
"confirmUninstall": "Нажмите еще раз для подтверждения",
|
||||||
|
"confirmUninstallMessage": "Удалить {{name}}? Это действие нельзя отменить.",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"updateFailed": "Ошибка обновления",
|
||||||
|
"installFailed": "Ошибка установки",
|
||||||
|
"uninstallFailed": "Ошибка удаления",
|
||||||
|
"toggleFailed": "Ошибка переключения",
|
||||||
|
"buildYourOwn": "Создайте свой плагин",
|
||||||
|
"starter": "Шаблон",
|
||||||
|
"docs": "Документация",
|
||||||
|
"starterPlugin": {
|
||||||
|
"name": "Статистика проекта",
|
||||||
|
"badge": "шаблон",
|
||||||
|
"description": "Количество файлов, строк кода, разбивка по типам файлов и недавняя активность в вашем проекте.",
|
||||||
|
"install": "Установить"
|
||||||
|
},
|
||||||
|
"morePlugins": "Ещё",
|
||||||
|
"enable": "Включить",
|
||||||
|
"disable": "Выключить",
|
||||||
|
"installAriaLabel": "URL git-репозитория плагина",
|
||||||
|
"tab": "вкладка",
|
||||||
|
"runningStatus": "запущен"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/i18n/locales/ru/sidebar.json
Normal file
134
src/i18n/locales/ru/sidebar.json
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"projects": {
|
||||||
|
"title": "Проекты",
|
||||||
|
"newProject": "Новый проект",
|
||||||
|
"deleteProject": "Удалить проект",
|
||||||
|
"renameProject": "Переименовать проект",
|
||||||
|
"noProjects": "Проекты не найдены",
|
||||||
|
"loadingProjects": "Загрузка проектов...",
|
||||||
|
"searchPlaceholder": "Поиск проектов...",
|
||||||
|
"projectNamePlaceholder": "Имя проекта",
|
||||||
|
"starred": "Избранное",
|
||||||
|
"all": "Все",
|
||||||
|
"untitledSession": "Безымянный сеанс",
|
||||||
|
"newSession": "Новый сеанс",
|
||||||
|
"codexSession": "Сеанс Codex",
|
||||||
|
"fetchingProjects": "Получение ваших проектов и сеансов Claude",
|
||||||
|
"projects": "проекты",
|
||||||
|
"noMatchingProjects": "Нет подходящих проектов",
|
||||||
|
"tryDifferentSearch": "Попробуйте изменить поисковый запрос",
|
||||||
|
"runClaudeCli": "Запустите Claude CLI в каталоге проекта для начала работы"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"title": "Claude Code UI",
|
||||||
|
"subtitle": "Интерфейс AI помощника для программирования"
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"title": "Сеансы",
|
||||||
|
"newSession": "Новый сеанс",
|
||||||
|
"deleteSession": "Удалить сеанс",
|
||||||
|
"renameSession": "Переименовать сеанс",
|
||||||
|
"noSessions": "Сеансов пока нет",
|
||||||
|
"loadingSessions": "Загрузка сеансов...",
|
||||||
|
"unnamed": "Без имени",
|
||||||
|
"loading": "Загрузка...",
|
||||||
|
"showMore": "Показать больше сеансов"
|
||||||
|
},
|
||||||
|
"tooltips": {
|
||||||
|
"viewEnvironments": "Просмотр окружений",
|
||||||
|
"hideSidebar": "Скрыть боковую панель",
|
||||||
|
"createProject": "Создать новый проект",
|
||||||
|
"refresh": "Обновить проекты и сеансы (Ctrl+R)",
|
||||||
|
"renameProject": "Переименовать проект (F2)",
|
||||||
|
"deleteProject": "Удалить пустой проект (Delete)",
|
||||||
|
"addToFavorites": "Добавить в избранное",
|
||||||
|
"removeFromFavorites": "Удалить из избранного",
|
||||||
|
"editSessionName": "Вручную редактировать имя сеанса",
|
||||||
|
"deleteSession": "Удалить этот сеанс навсегда",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"clearSearch": "Очистить поиск"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"chat": "Чат",
|
||||||
|
"files": "Файлы",
|
||||||
|
"git": "Git",
|
||||||
|
"terminal": "Терминал",
|
||||||
|
"tasks": "Задачи"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"refresh": "Обновить",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"collapseAll": "Свернуть все",
|
||||||
|
"expandAll": "Развернуть все",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"delete": "Удалить",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"joinCommunity": "Присоединиться к сообществу"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Активен",
|
||||||
|
"inactive": "Неактивен",
|
||||||
|
"thinking": "Думает...",
|
||||||
|
"error": "Ошибка",
|
||||||
|
"aborted": "Прервано",
|
||||||
|
"unknown": "Неизвестно"
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"justNow": "Только что",
|
||||||
|
"oneMinuteAgo": "1 мин. назад",
|
||||||
|
"minutesAgo": "{{count}} мин. назад",
|
||||||
|
"oneHourAgo": "1 час назад",
|
||||||
|
"hoursAgo": "{{count}} ч. назад",
|
||||||
|
"oneDayAgo": "1 день назад",
|
||||||
|
"daysAgo": "{{count}} дн. назад"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"deleteConfirm": "Вы уверены, что хотите это удалить?",
|
||||||
|
"renameSuccess": "Успешно переименовано",
|
||||||
|
"deleteSuccess": "Успешно удалено",
|
||||||
|
"errorOccurred": "Произошла ошибка",
|
||||||
|
"deleteSessionConfirm": "Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.",
|
||||||
|
"deleteProjectConfirm": "Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.",
|
||||||
|
"enterProjectPath": "Пожалуйста, введите путь к проекту",
|
||||||
|
"deleteSessionFailed": "Не удалось удалить сеанс. Попробуйте снова.",
|
||||||
|
"deleteSessionError": "Ошибка при удалении сеанса. Попробуйте снова.",
|
||||||
|
"renameSessionFailed": "Не удалось переименовать сеанс. Попробуйте снова.",
|
||||||
|
"renameSessionError": "Ошибка при переименовании сеанса. Попробуйте снова.",
|
||||||
|
"deleteProjectFailed": "Не удалось удалить проект. Попробуйте снова.",
|
||||||
|
"deleteProjectError": "Ошибка при удалении проекта. Попробуйте снова.",
|
||||||
|
"createProjectFailed": "Не удалось создать проект. Попробуйте снова.",
|
||||||
|
"createProjectError": "Ошибка при создании проекта. Попробуйте снова."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"updateAvailable": "Доступно обновление"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"modeProjects": "Проекты",
|
||||||
|
"modeConversations": "Разговоры",
|
||||||
|
"conversationsPlaceholder": "Поиск в разговорах...",
|
||||||
|
"searching": "Поиск...",
|
||||||
|
"noResults": "Результаты не найдены",
|
||||||
|
"tryDifferentQuery": "Попробуйте другой поисковый запрос",
|
||||||
|
"matches_one": "{{count}} совпадение",
|
||||||
|
"matches_few": "{{count}} совпадения",
|
||||||
|
"matches_many": "{{count}} совпадений",
|
||||||
|
"matches_other": "{{count}} совпадений",
|
||||||
|
"projectsScanned_one": "{{count}} проект просканирован",
|
||||||
|
"projectsScanned_few": "{{count}} проекта просканировано",
|
||||||
|
"projectsScanned_many": "{{count}} проектов просканировано",
|
||||||
|
"projectsScanned_other": "{{count}} проектов просканировано"
|
||||||
|
},
|
||||||
|
"deleteConfirmation": {
|
||||||
|
"deleteProject": "Удалить проект",
|
||||||
|
"deleteSession": "Удалить сеанс",
|
||||||
|
"confirmDelete": "Вы уверены, что хотите удалить",
|
||||||
|
"sessionCount_one": "Этот проект содержит {{count}} разговор.",
|
||||||
|
"sessionCount_few": "Этот проект содержит {{count}} разговора.",
|
||||||
|
"sessionCount_many": "Этот проект содержит {{count}} разговоров.",
|
||||||
|
"sessionCount_other": "Этот проект содержит {{count}} разговоров.",
|
||||||
|
"allConversationsDeleted": "Все разговоры будут удалены навсегда.",
|
||||||
|
"cannotUndo": "Это действие нельзя отменить."
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/i18n/locales/ru/tasks.json
Normal file
142
src/i18n/locales/ru/tasks.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"notConfigured": {
|
||||||
|
"title": "TaskMaster AI не настроен",
|
||||||
|
"description": "TaskMaster помогает разбивать сложные проекты на управляемые задачи с помощью AI",
|
||||||
|
"whatIsTitle": "🎯 Что такое TaskMaster?",
|
||||||
|
"features": {
|
||||||
|
"aiPowered": "Управление задачами с AI: разбивайте сложные проекты на управляемые подзадачи",
|
||||||
|
"prdTemplates": "Шаблоны PRD: генерируйте задачи из документов требований к продукту",
|
||||||
|
"dependencyTracking": "Отслеживание зависимостей: понимайте связи задач и порядок выполнения",
|
||||||
|
"progressVisualization": "Визуализация прогресса: канбан-доски и детальная аналитика задач",
|
||||||
|
"cliIntegration": "Интеграция с CLI: используйте команды taskmaster для продвинутых рабочих процессов"
|
||||||
|
},
|
||||||
|
"initializeButton": "Инициализировать TaskMaster AI"
|
||||||
|
},
|
||||||
|
"gettingStarted": {
|
||||||
|
"title": "Начало работы с TaskMaster",
|
||||||
|
"subtitle": "TaskMaster инициализирован! Вот что делать дальше:",
|
||||||
|
"steps": {
|
||||||
|
"createPRD": {
|
||||||
|
"title": "Создайте документ требований к продукту (PRD)",
|
||||||
|
"description": "Обсудите идею вашего проекта и создайте PRD, описывающий то, что вы хотите построить.",
|
||||||
|
"addButton": "Добавить PRD",
|
||||||
|
"existingPRDs": "Существующие PRD:"
|
||||||
|
},
|
||||||
|
"generateTasks": {
|
||||||
|
"title": "Генерация задач из PRD",
|
||||||
|
"description": "Когда у вас есть PRD, попросите вашего AI-ассистента разобрать его, и TaskMaster автоматически разобьет его на управляемые задачи с деталями реализации."
|
||||||
|
},
|
||||||
|
"analyzeTasks": {
|
||||||
|
"title": "Анализ и расширение задач",
|
||||||
|
"description": "Попросите вашего AI-ассистента проанализировать сложность задач и расширить их в детальные подзадачи для упрощения реализации."
|
||||||
|
},
|
||||||
|
"startBuilding": {
|
||||||
|
"title": "Начните разработку",
|
||||||
|
"description": "Попросите вашего AI-ассистента начать работу над задачами, обновлять их статус и добавлять новые задачи по мере развития вашего проекта."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tip": "💡 Совет: начните с PRD, чтобы получить максимум от AI-генерации задач TaskMaster"
|
||||||
|
},
|
||||||
|
"setupModal": {
|
||||||
|
"title": "Настройка TaskMaster",
|
||||||
|
"subtitle": "Интерактивный CLI для {{projectName}}",
|
||||||
|
"willStart": "Инициализация TaskMaster начнется автоматически",
|
||||||
|
"completed": "Настройка TaskMaster завершена! Теперь вы можете закрыть это окно.",
|
||||||
|
"closeButton": "Закрыть",
|
||||||
|
"closeContinueButton": "Закрыть и продолжить"
|
||||||
|
},
|
||||||
|
"helpGuide": {
|
||||||
|
"title": "Начало работы с TaskMaster",
|
||||||
|
"subtitle": "Ваш гид по продуктивному управлению задачами",
|
||||||
|
"examples": {
|
||||||
|
"parsePRD": "💬 Пример:\n\"Я только что инициализировал новый проект с Claude Task Master. У меня есть PRD в .taskmaster/docs/prd.txt. Можете помочь мне разобрать его и настроить начальные задачи?\"",
|
||||||
|
"expandTask": "💬 Пример:\n\"Задача 5 кажется сложной. Можете разбить её на подзадачи?\"",
|
||||||
|
"addTask": "💬 Пример:\n\"Пожалуйста, добавьте новую задачу для реализации загрузки изображений профиля пользователя с использованием Cloudinary, изучите лучший подход.\""
|
||||||
|
},
|
||||||
|
"moreExamples": "Посмотреть больше примеров и шаблонов использования →",
|
||||||
|
"proTips": {
|
||||||
|
"title": "💡 Профессиональные советы",
|
||||||
|
"search": "Используйте строку поиска для быстрого поиска конкретных задач",
|
||||||
|
"views": "Переключайтесь между представлениями Канбан, Список и Сетка, используя переключатели представлений",
|
||||||
|
"filters": "Используйте фильтры для фокусировки на конкретных статусах или приоритетах задач",
|
||||||
|
"details": "Нажмите на любую задачу для просмотра детальной информации и управления подзадачами"
|
||||||
|
},
|
||||||
|
"learnMore": {
|
||||||
|
"title": "📚 Узнать больше",
|
||||||
|
"description": "TaskMaster AI - это продвинутая система управления задачами, созданная для разработчиков. Получите документацию, примеры и внесите вклад в проект.",
|
||||||
|
"githubButton": "Посмотреть на GitHub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Поиск задач..."
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"button": "Фильтры",
|
||||||
|
"status": "Статус",
|
||||||
|
"priority": "Приоритет",
|
||||||
|
"sortBy": "Сортировать по",
|
||||||
|
"allStatuses": "Все статусы",
|
||||||
|
"allPriorities": "Все приоритеты",
|
||||||
|
"showing": "Показано {{filtered}} из {{total}} задач",
|
||||||
|
"clearFilters": "Очистить фильтры"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"id": "ID",
|
||||||
|
"status": "Статус",
|
||||||
|
"priority": "Приоритет",
|
||||||
|
"idAsc": "ID (по возрастанию)",
|
||||||
|
"idDesc": "ID (по убыванию)",
|
||||||
|
"titleAsc": "Название (А-Я)",
|
||||||
|
"titleDesc": "Название (Я-А)",
|
||||||
|
"statusAsc": "Статус (сначала ожидающие)",
|
||||||
|
"statusDesc": "Статус (сначала выполненные)",
|
||||||
|
"priorityAsc": "Приоритет (сначала высокий)",
|
||||||
|
"priorityDesc": "Приоритет (сначала низкий)"
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"kanban": "Представление Канбан",
|
||||||
|
"list": "Представление списком",
|
||||||
|
"grid": "Представление сеткой"
|
||||||
|
},
|
||||||
|
"kanban": {
|
||||||
|
"pending": "📋 К выполнению",
|
||||||
|
"inProgress": "🚀 В процессе",
|
||||||
|
"done": "✅ Выполнено",
|
||||||
|
"blocked": "🚫 Заблокировано",
|
||||||
|
"deferred": "⏳ Отложено",
|
||||||
|
"cancelled": "❌ Отменено",
|
||||||
|
"noTasksYet": "Задач пока нет",
|
||||||
|
"tasksWillAppear": "Задачи появятся здесь",
|
||||||
|
"moveTasksHere": "Перемещайте задачи сюда при начале работы",
|
||||||
|
"completedTasksHere": "Завершенные задачи появляются здесь",
|
||||||
|
"statusTasksHere": "Задачи с этим статусом появятся здесь"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"help": "Руководство по началу работы с TaskMaster",
|
||||||
|
"prds": "PRD",
|
||||||
|
"addPRD": "Добавить PRD",
|
||||||
|
"addTask": "Добавить задачу",
|
||||||
|
"createNewPRD": "Создать новый PRD",
|
||||||
|
"prdsAvailable": "Доступно {{count}} PRD"
|
||||||
|
},
|
||||||
|
"prd": {
|
||||||
|
"modified": "Изменено: {{date}}"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"pending": "Ожидание",
|
||||||
|
"in-progress": "В процессе",
|
||||||
|
"done": "Выполнено",
|
||||||
|
"blocked": "Заблокировано",
|
||||||
|
"deferred": "Отложено",
|
||||||
|
"cancelled": "Отменено"
|
||||||
|
},
|
||||||
|
"priorities": {
|
||||||
|
"high": "Высокий",
|
||||||
|
"medium": "Средний",
|
||||||
|
"low": "Низкий"
|
||||||
|
},
|
||||||
|
"noMatchingTasks": {
|
||||||
|
"title": "Нет задач, соответствующих вашим фильтрам",
|
||||||
|
"description": "Попробуйте изменить критерии поиска или фильтрации."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
},
|
},
|
||||||
"copyMessage": {
|
"copyMessage": {
|
||||||
"copy": "复制消息",
|
"copy": "复制消息",
|
||||||
"copied": "消息已复制"
|
"copied": "消息已复制",
|
||||||
|
"selectFormat": "选择复制格式",
|
||||||
|
"copyAsMarkdown": "复制为 Markdown",
|
||||||
|
"copyAsText": "复制为纯文本"
|
||||||
},
|
},
|
||||||
"messageTypes": {
|
"messageTypes": {
|
||||||
"user": "U",
|
"user": "U",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user