mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-10 00:17:43 +00:00
Compare commits
7 Commits
a7e8b12ef4
...
feat/impro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61084109f | ||
|
|
a17665e20b | ||
|
|
c7dcba8d91 | ||
|
|
8afb46af2e | ||
|
|
bc164140e0 | ||
|
|
86c33c1c0c | ||
|
|
cb4fd795c9 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -108,7 +108,7 @@ temp/
|
||||
.serena/
|
||||
CLAUDE.md
|
||||
.mcp.json
|
||||
|
||||
.gemini/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
@@ -130,3 +130,8 @@ dev-debug.log
|
||||
# Task files
|
||||
tasks.json
|
||||
tasks/
|
||||
|
||||
# Translations
|
||||
!src/i18n/locales/en/tasks.json
|
||||
!src/i18n/locales/ja/tasks.json
|
||||
!src/i18n/locales/ru/tasks.json
|
||||
@@ -1 +1 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
npx commitlint --edit $1
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
All notable changes to CloudCLI UI will be documented in this file.
|
||||
|
||||
|
||||
## [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)
|
||||
|
||||
### 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 のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。
|
||||
|
||||
<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 の全機能を使用するには、手動でツールを有効にする必要があります:
|
||||
|
||||
1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック
|
||||
3. **選択的に有効化** - 必要なツールのみを有効にする
|
||||
4. **設定を適用** - 環境設定はローカルに保存されます
|
||||
2. **選択的に有効化** - 必要なツールのみを有効にする
|
||||
3. **設定を適用** - 環境設定はローカルに保存されます
|
||||
|
||||
<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의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다.
|
||||
|
||||
<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의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다:
|
||||
|
||||
1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭
|
||||
3. **선택적으로 활성화** - 필요한 도구만 활성화
|
||||
4. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
||||
2. **선택적으로 활성화** - 필요한 도구만 활성화
|
||||
3. **설정 적용** - 환경설정은 로컬에 저장됩니다
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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><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>
|
||||
|
||||
---
|
||||
|
||||
@@ -129,8 +129,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:
|
||||
|
||||
1. **Open Tools Settings** - Click the gear icon in the sidebar
|
||||
3. **Enable Selectively** - Turn on only the tools you need
|
||||
4. **Apply Settings** - Your preferences are saved locally
|
||||
2. **Enable Selectively** - Turn on only the tools you need
|
||||
3. **Apply Settings** - Your preferences are saved locally
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
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 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
|
||||
|
||||
<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 的完整功能,您需要手动启用工具:
|
||||
|
||||
1. **打开工具设置** - 点击侧边栏中的齿轮图标
|
||||
3. **选择性启用** - 仅打开您需要的工具
|
||||
4. **应用设置** - 您的偏好设置将保存在本地
|
||||
2. **选择性启用** - 仅打开您需要的工具
|
||||
3. **应用设置** - 您的偏好设置将保存在本地
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -344,4 +344,4 @@ GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
|
||||
|
||||
<div align="center">
|
||||
<strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
43
docs/lint-and-commit-setup/lint-and-commit-setup.md
Normal file
43
docs/lint-and-commit-setup/lint-and-commit-setup.md
Normal file
@@ -0,0 +1,43 @@
|
||||
This markdown document provides explanations for how the lint and commit setup works in the project.
|
||||
|
||||

|
||||
|
||||
## Brief overview of git hooks
|
||||
- Hooks are named for the event they intercept and use `pre-` or `post-` prefixes to indicate their timing. E.gs are: `pre-commit`, `commit-msg`(after the commit message, but before the commit is created), `pre-push`, and `post-receive` (after successful push).
|
||||
|
||||
- Inside any git repo, the hooks are stored in `.git/hooks` directory. The sample hooks can be activated by renaming them and removing the `.sample` extension.
|
||||
|
||||
## How does husky work?
|
||||
- When we run `npm run prepare`, `husky install` command is executed, which creates a `.husky` directory in the root of the project and sets up the files for managing git hooks in `_` subdirectory. It also sets `git config core.hooksPath .husky` to tell git to look for hooks in the `.husky/_` directory instead of the default `.git/hooks` directory. We can override the default hooks by creating files with the same name as the hook we want to override in the `.husky` directory. For example, if we want to override the `pre-commit` hook, we can create a file named `pre-commit` in the `.husky` directory and add our custom script to it.
|
||||
|
||||
One e.g. commit flow is this:
|
||||
```txt
|
||||
git commit
|
||||
-> Git notices "pre-commit" should run
|
||||
-> Git looks in core.hooksPath (.husky/_)
|
||||
-> Git executes .husky/_/pre-commit (The command is: . "$(dirname "$0")/h")
|
||||
-> that forwards calls to Husky's (.husky/_/h) helper script
|
||||
-> Husky's helper runs our real .husky/pre-commit script
|
||||
```
|
||||
|
||||
## How commiting works in our project?
|
||||
- We use a library called `lint-staged` to run linters on the staged files before committing. The configuration for `lint-staged` is defined in the `package.json` file under the `lint-staged` key. It specifies that we want to run `eslint` on all staged JavaScript and TypeScript files.
|
||||
|
||||
- Our current pre-commit hook is set up to run `lint-staged` command, which runs `eslint` on the staged files.
|
||||
|
||||
- Then, the `commit-msg` hook is set up to run `commitlint` command, which checks the commit message against the defined rules in `commitlint.config.js` file. The rules specify that the commit message should follow the conventional commit format, which includes a type, an optional scope, and a description. If the commit message does not follow the format, the commit will be rejected and an error message will be displayed.
|
||||
|
||||
```txt
|
||||
git commit
|
||||
... (the above flow happens)
|
||||
-> `npx lint-staged` is executed in the pre-commit hook
|
||||
-> `lint-staged` looks at the configuration in `package.json` and finds that it needs to run `eslint` on all staged JavaScript and TypeScript files
|
||||
-> `eslint` runs on the staged files and checks for any linting errors
|
||||
-> If there are linting errors, the commit is rejected and an error message is displayed
|
||||
-> If there are no linting errors, the commit proceeds to the next step, which is the `commit-msg` hook
|
||||
-> `npx commitlint --edit $1` is executed in the `commit-msg` hook
|
||||
-> `commitlint` checks the commit message against the rules defined in `commitlint.config.js`
|
||||
-> If the commit message does not follow the conventional commit format, the commit is rejected and an error message is displayed
|
||||
-> If the commit message follows the conventional commit format, the commit is created successfully.
|
||||
```
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.23.2",
|
||||
"version": "1.24.0",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
|
||||
@@ -2545,12 +2545,12 @@ async function startServer() {
|
||||
});
|
||||
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = () => {
|
||||
stopAllPlugins();
|
||||
const shutdownPlugins = async () => {
|
||||
await stopAllPlugins();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdownPlugins);
|
||||
process.on('SIGINT', shutdownPlugins);
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { exec, spawn } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
@@ -8,7 +7,6 @@ import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
|
||||
const router = express.Router();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function spawnAsync(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -47,6 +45,36 @@ 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) {
|
||||
if (!file || file.includes('\0')) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
function validateRemoteName(remote) {
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
|
||||
throw new Error('Invalid remote name');
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
// Helper function to get the actual project path from the encoded project name
|
||||
async function getActualProjectPath(projectName) {
|
||||
try {
|
||||
@@ -98,14 +126,14 @@ async function validateGitRepository(projectPath) {
|
||||
|
||||
try {
|
||||
// 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';
|
||||
if (!isInsideWorkTree) {
|
||||
throw new Error('Not inside a git work tree');
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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.');
|
||||
}
|
||||
@@ -129,7 +157,7 @@ router.get('/status', async (req, res) => {
|
||||
let branch = 'main';
|
||||
let hasCommits = true;
|
||||
try {
|
||||
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
branch = branchOutput.trim();
|
||||
} catch (error) {
|
||||
// No HEAD exists - repository has no commits yet
|
||||
@@ -142,7 +170,7 @@ router.get('/status', async (req, res) => {
|
||||
}
|
||||
|
||||
// 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 added = [];
|
||||
@@ -201,8 +229,11 @@ router.get('/diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// 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', file], { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -223,21 +254,21 @@ router.get('/diff', async (req, res) => {
|
||||
}
|
||||
} else if (isDeleted) {
|
||||
// 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:${file}`], { cwd: projectPath });
|
||||
const lines = fileContent.split('\n');
|
||||
diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
|
||||
lines.map(line => `-${line}`).join('\n');
|
||||
} else {
|
||||
// Get diff for tracked files
|
||||
// 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', '--', file], { cwd: projectPath });
|
||||
|
||||
if (unstagedDiff) {
|
||||
// Show unstaged changes if they exist
|
||||
diff = stripDiffHeaders(unstagedDiff);
|
||||
} else {
|
||||
// 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', '--', file], { cwd: projectPath });
|
||||
diff = stripDiffHeaders(stagedDiff) || '';
|
||||
}
|
||||
}
|
||||
@@ -263,8 +294,11 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
// Validate git repository
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// Check file status
|
||||
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
|
||||
const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath });
|
||||
const isUntracked = statusOutput.startsWith('??');
|
||||
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
|
||||
|
||||
@@ -273,7 +307,7 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
|
||||
if (isDeleted) {
|
||||
// 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:${file}`], { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
currentContent = headContent; // Show the deleted content in editor
|
||||
} else {
|
||||
@@ -291,7 +325,7 @@ router.get('/file-with-diff', async (req, res) => {
|
||||
if (!isUntracked) {
|
||||
// Get the old content from HEAD for tracked files
|
||||
try {
|
||||
const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
|
||||
const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath });
|
||||
oldContent = headContent;
|
||||
} catch (error) {
|
||||
// File might be newly added to git (staged but not committed)
|
||||
@@ -328,17 +362,17 @@ router.post('/initial-commit', async (req, res) => {
|
||||
|
||||
// Check if there are already commits
|
||||
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.' });
|
||||
} catch (error) {
|
||||
// No HEAD - this is good, we can create initial commit
|
||||
}
|
||||
|
||||
// Add all files
|
||||
await execAsync('git add .', { cwd: projectPath });
|
||||
await spawnAsync('git', ['add', '.'], { cwd: projectPath });
|
||||
|
||||
// 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' });
|
||||
} catch (error) {
|
||||
@@ -372,11 +406,12 @@ router.post('/commit', async (req, res) => {
|
||||
|
||||
// Stage selected files
|
||||
for (const file of files) {
|
||||
await execAsync(`git add "${file}"`, { cwd: projectPath });
|
||||
validateFilePath(file);
|
||||
await spawnAsync('git', ['add', file], { cwd: projectPath });
|
||||
}
|
||||
|
||||
|
||||
// Commit with message
|
||||
const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath });
|
||||
|
||||
res.json({ success: true, output: stdout });
|
||||
} catch (error) {
|
||||
@@ -400,7 +435,7 @@ router.get('/branches', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get all branches
|
||||
const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
|
||||
const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
|
||||
|
||||
// Parse branches
|
||||
const branches = stdout
|
||||
@@ -439,7 +474,8 @@ router.post('/checkout', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// 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 });
|
||||
} catch (error) {
|
||||
@@ -460,7 +496,8 @@ router.post('/create-branch', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
// 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 });
|
||||
} catch (error) {
|
||||
@@ -509,8 +546,8 @@ router.get('/commits', async (req, res) => {
|
||||
// Get stats for each commit
|
||||
for (const commit of commits) {
|
||||
try {
|
||||
const { stdout: stats } = await execAsync(
|
||||
`git show --stat --format='' ${commit.hash}`,
|
||||
const { stdout: stats } = await spawnAsync(
|
||||
'git', ['show', '--stat', '--format=', commit.hash],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
|
||||
@@ -536,10 +573,13 @@ router.get('/commit-diff', async (req, res) => {
|
||||
|
||||
try {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
|
||||
|
||||
// Validate commit reference (defense-in-depth)
|
||||
validateCommitRef(commit);
|
||||
|
||||
// Get diff for the commit
|
||||
const { stdout } = await execAsync(
|
||||
`git show ${commit}`,
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['show', commit],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
@@ -570,8 +610,9 @@ router.post('/generate-commit-message', async (req, res) => {
|
||||
let diffContext = '';
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff HEAD -- "${file}"`,
|
||||
validateFilePath(file);
|
||||
const { stdout } = await spawnAsync(
|
||||
'git', ['diff', 'HEAD', '--', file],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
if (stdout) {
|
||||
@@ -764,14 +805,14 @@ router.get('/remote-status', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
// Check if there's a remote tracking branch (smart detection)
|
||||
let trackingBranch;
|
||||
let remoteName;
|
||||
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();
|
||||
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
|
||||
} catch (error) {
|
||||
@@ -779,7 +820,7 @@ router.get('/remote-status', async (req, res) => {
|
||||
let hasRemote = false;
|
||||
let remoteName = null;
|
||||
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());
|
||||
if (remotes.length > 0) {
|
||||
hasRemote = true;
|
||||
@@ -788,8 +829,8 @@ router.get('/remote-status', async (req, res) => {
|
||||
} catch (remoteError) {
|
||||
// No remotes configured
|
||||
}
|
||||
|
||||
return res.json({
|
||||
|
||||
return res.json({
|
||||
hasRemote,
|
||||
hasUpstream: false,
|
||||
branch,
|
||||
@@ -799,8 +840,8 @@ router.get('/remote-status', async (req, res) => {
|
||||
}
|
||||
|
||||
// Get ahead/behind counts
|
||||
const { stdout: countOutput } = await execAsync(
|
||||
`git rev-list --count --left-right ${trackingBranch}...HEAD`,
|
||||
const { stdout: countOutput } = await spawnAsync(
|
||||
'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
|
||||
{ cwd: projectPath }
|
||||
);
|
||||
|
||||
@@ -835,20 +876,21 @@ router.post('/fetch', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
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
|
||||
} catch (error) {
|
||||
// No upstream, try to fetch from origin anyway
|
||||
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 });
|
||||
} catch (error) {
|
||||
console.error('Git fetch error:', error);
|
||||
@@ -876,13 +918,13 @@ router.post('/pull', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
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();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -891,17 +933,19 @@ router.post('/pull', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Pull completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Git pull error:', error);
|
||||
|
||||
|
||||
// Enhanced error handling for common pull scenarios
|
||||
let errorMessage = 'Pull failed';
|
||||
let details = error.message;
|
||||
@@ -943,13 +987,13 @@ router.post('/push', async (req, res) => {
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Get current branch and its upstream remote
|
||||
const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
||||
const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const branch = currentBranch.trim();
|
||||
|
||||
let remoteName = 'origin'; // fallback
|
||||
let remoteBranch = branch; // fallback
|
||||
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();
|
||||
remoteName = tracking.split('/')[0]; // Extract remote name
|
||||
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
|
||||
@@ -958,11 +1002,13 @@ router.post('/push', async (req, res) => {
|
||||
console.log('No upstream configured, using origin/branch as fallback');
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
validateRemoteName(remoteName);
|
||||
validateBranchName(remoteBranch);
|
||||
const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: stdout || 'Push completed successfully',
|
||||
remoteName,
|
||||
remoteBranch
|
||||
});
|
||||
@@ -1012,35 +1058,39 @@ router.post('/publish', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate branch name
|
||||
validateBranchName(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 { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
|
||||
const currentBranchName = currentBranch.trim();
|
||||
|
||||
|
||||
if (currentBranchName !== branch) {
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
return res.status(400).json({
|
||||
error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if remote exists
|
||||
let remoteName = 'origin';
|
||||
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());
|
||||
if (remotes.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
return res.status(400).json({
|
||||
error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
|
||||
});
|
||||
}
|
||||
|
||||
// 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({
|
||||
success: true,
|
||||
@@ -1088,9 +1138,12 @@ router.post('/discard', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// 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', file], { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'No changes to discard for this file' });
|
||||
}
|
||||
@@ -1109,10 +1162,10 @@ router.post('/discard', async (req, res) => {
|
||||
}
|
||||
} else if (status.includes('M') || status.includes('D')) {
|
||||
// Modified or deleted file - restore from HEAD
|
||||
await execAsync(`git restore "${file}"`, { cwd: projectPath });
|
||||
await spawnAsync('git', ['restore', file], { cwd: projectPath });
|
||||
} else if (status.includes('A')) {
|
||||
// Added file - unstage it
|
||||
await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
|
||||
await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath });
|
||||
}
|
||||
|
||||
res.json({ success: true, message: `Changes discarded for ${file}` });
|
||||
@@ -1134,8 +1187,11 @@ router.post('/delete-untracked', async (req, res) => {
|
||||
const projectPath = await getActualProjectPath(project);
|
||||
await validateGitRepository(projectPath);
|
||||
|
||||
// Validate file path
|
||||
validateFilePath(file);
|
||||
|
||||
// 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', file], { cwd: projectPath });
|
||||
|
||||
if (!statusOutput.trim()) {
|
||||
return res.status(400).json({ error: 'File is not untracked or does not exist' });
|
||||
|
||||
@@ -39,6 +39,9 @@ router.get('/', (req, res) => {
|
||||
// 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) {
|
||||
@@ -53,6 +56,9 @@ router.get('/:name/manifest', (req, res) => {
|
||||
// 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) {
|
||||
@@ -64,9 +70,26 @@ router.get('/:name/assets/*', (req, res) => {
|
||||
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);
|
||||
fs.createReadStream(resolvedPath).pipe(res);
|
||||
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)
|
||||
@@ -99,7 +122,7 @@ router.put('/:name/enable', async (req, res) => {
|
||||
}
|
||||
}
|
||||
} else if (!enabled && isPluginRunning(plugin.name)) {
|
||||
stopPluginServer(plugin.name);
|
||||
await stopPluginServer(plugin.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +176,7 @@ router.post('/:name/update', async (req, res) => {
|
||||
|
||||
const wasRunning = isPluginRunning(pluginName);
|
||||
if (wasRunning) {
|
||||
stopPluginServer(pluginName);
|
||||
await stopPluginServer(pluginName);
|
||||
}
|
||||
|
||||
const manifest = await updatePluginFromGit(pluginName);
|
||||
@@ -235,11 +258,18 @@ router.all('/:name/rpc/*', async (req, res) => {
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
res.status(502).json({ error: 'Plugin server error', details: err.message });
|
||||
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)
|
||||
if (req.body && Object.keys(req.body).length > 0) {
|
||||
// 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);
|
||||
|
||||
@@ -311,13 +311,11 @@ router.post('/create-workspace', async (req, res) => {
|
||||
* Helper function to get GitHub token from database
|
||||
*/
|
||||
async function getGithubTokenById(tokenId, userId) {
|
||||
const { getDatabase } = await import('../database/db.js');
|
||||
const db = await getDatabase();
|
||||
const { db } = await import('../database/db.js');
|
||||
|
||||
const credential = await db.get(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||
[tokenId, userId, 'github_token']
|
||||
);
|
||||
const credential = db.prepare(
|
||||
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
|
||||
).get(tokenId, userId, 'github_token');
|
||||
|
||||
// Return in the expected format (github_token field for compatibility)
|
||||
if (credential) {
|
||||
|
||||
@@ -2,12 +2,29 @@ import express from 'express';
|
||||
import { userDb } from '../database/db.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
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) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
@@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
|
||||
userDb.updateGitConfig(userId, gitName, gitEmail);
|
||||
|
||||
try {
|
||||
await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
|
||||
await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
|
||||
await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
|
||||
await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
|
||||
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
|
||||
} catch (gitError) {
|
||||
console.error('Error applying git config:', gitError);
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
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
|
||||
@@ -10,8 +20,8 @@ const execAsync = promisify(exec);
|
||||
export async function getSystemGitConfig() {
|
||||
try {
|
||||
const [nameResult, emailResult] = await Promise.all([
|
||||
execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
|
||||
execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
|
||||
spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
|
||||
spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,19 @@ 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'];
|
||||
|
||||
@@ -31,9 +44,9 @@ export function getPluginsConfig() {
|
||||
export function savePluginsConfig(config) {
|
||||
const dir = path.dirname(PLUGINS_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function validateManifest(manifest) {
|
||||
@@ -60,6 +73,23 @@ export function validateManifest(manifest) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -75,8 +105,12 @@ export function scanPlugins() {
|
||||
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;
|
||||
@@ -89,6 +123,13 @@ export function scanPlugins() {
|
||||
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 {
|
||||
@@ -102,6 +143,8 @@ export function scanPlugins() {
|
||||
if (repoUrl.startsWith('git@')) {
|
||||
repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
|
||||
}
|
||||
// Strip embedded credentials (e.g. https://user:pass@host/...)
|
||||
repoUrl = sanitizeRepoUrl(repoUrl);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -143,14 +186,16 @@ export function resolvePluginAssetPath(name, assetPath) {
|
||||
|
||||
const resolved = path.resolve(pluginDir, assetPath);
|
||||
|
||||
// Prevent path traversal — resolved path must be within plugin directory
|
||||
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolved)) return null;
|
||||
|
||||
return resolved;
|
||||
return realResolved;
|
||||
}
|
||||
|
||||
export function installPluginFromGit(url) {
|
||||
@@ -233,6 +278,13 @@ export function installPluginFromGit(url) {
|
||||
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');
|
||||
|
||||
@@ -4,6 +4,8 @@ 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.
|
||||
@@ -11,10 +13,16 @@ const runningPlugins = new Map();
|
||||
* to stdout within 10 seconds.
|
||||
*/
|
||||
export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (runningPlugins.has(name)) {
|
||||
return resolve(runningPlugins.get(name).port);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -88,7 +96,12 @@ export function startPluginServer(name, pluginDir, serverEntry) {
|
||||
reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
|
||||
}
|
||||
});
|
||||
}).finally(() => {
|
||||
startingPlugins.delete(name);
|
||||
});
|
||||
|
||||
startingPlugins.set(name, startPromise);
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck, Ellipsis, Puzzle, Box, Database, Globe, Wrench, Zap, BarChart3 } from 'lucide-react';
|
||||
import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
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 { usePlugins } from '../../contexts/PluginsContext';
|
||||
import { AppTab } from '../../types/app';
|
||||
|
||||
const PLUGIN_ICON_MAP = {
|
||||
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 = {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
@@ -19,7 +41,7 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
|
||||
const { plugins } = usePlugins();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const moreRef = useRef(null);
|
||||
const moreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const enabledPlugins = plugins.filter((p) => p.enabled);
|
||||
const hasPlugins = enabledPlugins.length > 0;
|
||||
@@ -28,8 +50,9 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
// Close the menu on outside tap
|
||||
useEffect(() => {
|
||||
if (!moreOpen) return;
|
||||
const handleTap = (e) => {
|
||||
if (moreRef.current && !moreRef.current.contains(e.target)) {
|
||||
const handleTap = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
|
||||
setMoreOpen(false);
|
||||
}
|
||||
};
|
||||
@@ -38,18 +61,21 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
|
||||
}, [moreOpen]);
|
||||
|
||||
// Close menu when a plugin tab is selected
|
||||
const selectPlugin = (name) => {
|
||||
setActiveTab(`plugin:${name}`);
|
||||
const selectPlugin = (name: string) => {
|
||||
const pluginTab = `plugin:${name}` as AppTab;
|
||||
setActiveTab(pluginTab);
|
||||
setMoreOpen(false);
|
||||
};
|
||||
|
||||
const coreItems = [
|
||||
const baseCoreItems: CoreNavItem[] = [
|
||||
{ id: 'chat', icon: MessageSquare, label: 'Chat' },
|
||||
{ id: 'shell', icon: Terminal, label: 'Shell' },
|
||||
{ id: 'files', icon: Folder, label: 'Files' },
|
||||
{ id: 'git', icon: GitBranch, label: 'Git' },
|
||||
...(shouldShowTasksTab ? [{ id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }] : []),
|
||||
];
|
||||
const coreItems: CoreNavItem[] = shouldShowTasksTab
|
||||
? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
|
||||
: baseCoreItems;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -107,7 +107,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen
|
||||
|
||||
{activeView === 'changes' && (
|
||||
<ChangesView
|
||||
key={selectedProject.fullPath}
|
||||
isMobile={isMobile}
|
||||
projectPath={selectedProject.fullPath}
|
||||
gitStatus={gitStatus}
|
||||
gitDiff={gitDiff}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -9,6 +9,7 @@ import FileStatusLegend from './FileStatusLegend';
|
||||
|
||||
type ChangesViewProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
gitStatus: GitStatusResponse | null;
|
||||
gitDiff: GitDiffMap;
|
||||
isLoading: boolean;
|
||||
@@ -27,6 +28,7 @@ type ChangesViewProps = {
|
||||
|
||||
export default function ChangesView({
|
||||
isMobile,
|
||||
projectPath,
|
||||
gitStatus,
|
||||
gitDiff,
|
||||
isLoading,
|
||||
@@ -131,6 +133,7 @@ export default function ChangesView({
|
||||
<>
|
||||
<CommitComposer
|
||||
isMobile={isMobile}
|
||||
projectPath={projectPath}
|
||||
selectedFileCount={selectedFiles.size}
|
||||
isHidden={hasExpandedFiles}
|
||||
onCommit={commitSelectedFiles}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { useState } from 'react';
|
||||
import MicButton from '../../../mic-button/view/MicButton';
|
||||
import type { ConfirmationRequest } from '../../types/types';
|
||||
|
||||
// Persists commit messages across unmount/remount, keyed by project path
|
||||
const commitMessageCache = new Map<string, string>();
|
||||
|
||||
type CommitComposerProps = {
|
||||
isMobile: boolean;
|
||||
projectPath: string;
|
||||
selectedFileCount: number;
|
||||
isHidden: boolean;
|
||||
onCommit: (message: string) => Promise<boolean>;
|
||||
@@ -14,13 +18,24 @@ type CommitComposerProps = {
|
||||
|
||||
export default function CommitComposer({
|
||||
isMobile,
|
||||
projectPath,
|
||||
selectedFileCount,
|
||||
isHidden,
|
||||
onCommit,
|
||||
onGenerateMessage,
|
||||
onRequestConfirmation,
|
||||
}: 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 [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(isMobile);
|
||||
|
||||
@@ -3,7 +3,7 @@ import ChatInterface from '../../chat/view/ChatInterface';
|
||||
import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import PluginTabContent from '../../plugins/PluginTabContent';
|
||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '../../../../shared/view/ui';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
import PluginIcon from '../../../plugins/PluginIcon';
|
||||
import PluginIcon from '../../../plugins/view/PluginIcon';
|
||||
|
||||
type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { authenticatedFetch } from '../../utils/api';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
type Props = {
|
||||
pluginName: string;
|
||||
@@ -11,15 +11,20 @@ type Props = {
|
||||
const svgCache = new Map<string, string>();
|
||||
|
||||
export default function PluginIcon({ pluginName, iconFile, className }: Props) {
|
||||
const url = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`;
|
||||
const [svg, setSvg] = useState<string | null>(svgCache.get(url) ?? null);
|
||||
const url = iconFile
|
||||
? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`
|
||||
: '';
|
||||
const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);
|
||||
|
||||
useEffect(() => {
|
||||
if (svgCache.has(url)) return;
|
||||
if (!url || svgCache.has(url)) return;
|
||||
authenticatedFetch(url)
|
||||
.then((r) => r.text())
|
||||
.then((r) => {
|
||||
if (!r.ok) return;
|
||||
return r.text();
|
||||
})
|
||||
.then((text) => {
|
||||
if (text.trimStart().startsWith('<svg')) {
|
||||
if (text && text.trimStart().startsWith('<svg')) {
|
||||
svgCache.set(url, text);
|
||||
setSvg(text);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
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 { 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 }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
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
|
||||
@@ -15,6 +15,7 @@ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: b
|
||||
className="peer sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
@@ -141,8 +142,9 @@ function PluginCard({
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
onClick={onUpdate}
|
||||
disabled={updating}
|
||||
title="Pull latest from git"
|
||||
disabled={updating || !plugin.repoUrl}
|
||||
title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'}
|
||||
aria-label={`Update ${plugin.displayName}`}
|
||||
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
{updating ? (
|
||||
@@ -155,6 +157,7 @@ function PluginCard({
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
|
||||
aria-label={`Uninstall ${plugin.displayName}`}
|
||||
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'
|
||||
@@ -164,7 +167,7 @@ function PluginCard({
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} />
|
||||
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -268,17 +271,17 @@ export default function PluginSettingsTab() {
|
||||
const [installingStarter, setInstallingStarter] = useState(false);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
|
||||
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null);
|
||||
const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
|
||||
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const handleUpdate = async (name: string) => {
|
||||
setUpdatingPlugin(name);
|
||||
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 || 'Update failed' }));
|
||||
}
|
||||
setUpdatingPlugin(null);
|
||||
setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
@@ -309,8 +312,13 @@ export default function PluginSettingsTab() {
|
||||
setConfirmUninstall(name);
|
||||
return;
|
||||
}
|
||||
await uninstallPlugin(name);
|
||||
setConfirmUninstall(null);
|
||||
const result = await uninstallPlugin(name);
|
||||
if (result.success) {
|
||||
setConfirmUninstall(null);
|
||||
} else {
|
||||
setInstallError(result.error || 'Uninstall failed');
|
||||
setConfirmUninstall(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');
|
||||
@@ -347,6 +355,7 @@ export default function PluginSettingsTab() {
|
||||
setInstallError(null);
|
||||
}}
|
||||
placeholder="https://github.com/user/my-plugin"
|
||||
aria-label="Plugin git repository URL"
|
||||
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();
|
||||
@@ -396,10 +405,10 @@ export default function PluginSettingsTab() {
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
index={index}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
|
||||
onToggle={(enabled) => void togglePlugin(plugin.name, enabled).then(r => { if (!r.success) setInstallError(r.error || 'Toggle failed'); })}
|
||||
onUpdate={() => void handleUpdate(plugin.name)}
|
||||
onUninstall={() => void handleUninstall(plugin.name)}
|
||||
updating={updatingPlugin === plugin.name}
|
||||
updating={updatingPlugins.has(plugin.name)}
|
||||
confirmingUninstall={confirmUninstall === plugin.name}
|
||||
onCancelUninstall={() => setConfirmUninstall(null)}
|
||||
updateError={updateErrors[plugin.name] ?? null}
|
||||
@@ -1,8 +1,8 @@
|
||||
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';
|
||||
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;
|
||||
@@ -24,10 +24,16 @@ function buildContext(
|
||||
return {
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
project: selectedProject
|
||||
? { name: selectedProject.name, path: selectedProject.fullPath || selectedProject.path }
|
||||
? {
|
||||
name: selectedProject.name,
|
||||
path: selectedProject.fullPath || selectedProject.path || '',
|
||||
}
|
||||
: null,
|
||||
session: selectedSession
|
||||
? { id: selectedSession.id, title: selectedSession.title }
|
||||
? {
|
||||
id: selectedSession.id,
|
||||
title: selectedSession.title || selectedSession.name || selectedSession.id,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -44,7 +50,7 @@ export default function PluginTabContent({
|
||||
// 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);
|
||||
@@ -60,17 +66,18 @@ export default function PluginTabContent({
|
||||
}, [isDarkMode, selectedProject, selectedSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
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/${entryFile}`;
|
||||
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();
|
||||
@@ -86,8 +93,8 @@ export default function PluginTabContent({
|
||||
get context(): PluginContext { return contextRef.current; },
|
||||
|
||||
onContextChange(cb: (ctx: PluginContext) => void): () => void {
|
||||
contextCallbacksRef.current.add(cb);
|
||||
return () => contextCallbacksRef.current.delete(cb);
|
||||
contextCallbacks.add(cb);
|
||||
return () => contextCallbacks.delete(cb);
|
||||
},
|
||||
|
||||
async rpc(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
@@ -105,11 +112,19 @@ export default function PluginTabContent({
|
||||
};
|
||||
|
||||
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) {
|
||||
containerRef.current.innerHTML = `<div style="padding:16px;font-size:13px;color:#dc2626">Plugin failed to load: ${String(err)}</div>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -117,10 +132,10 @@ export default function PluginTabContent({
|
||||
return () => {
|
||||
active = false;
|
||||
try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }
|
||||
contextCallbacksRef.current.clear();
|
||||
contextCallbacks.clear();
|
||||
moduleRef.current = null;
|
||||
};
|
||||
}, [pluginName, plugin?.entry]); // re-mount only when the plugin itself changes
|
||||
}, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-auto" />;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ type CodexSettingsStorage = {
|
||||
|
||||
type ActiveLoginProvider = AgentProvider | '';
|
||||
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks'];
|
||||
const KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'plugins'];
|
||||
|
||||
const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
// Keep backwards compatibility with older callers that still pass "tools".
|
||||
|
||||
@@ -10,7 +10,7 @@ import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
import CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';
|
||||
import GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';
|
||||
import TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/PluginSettingsTab';
|
||||
import PluginSettingsTab from '../../plugins/view/PluginSettingsTab';
|
||||
import { useSettingsController } from '../hooks/useSettingsController';
|
||||
import type { SettingsProps } from '../types/types';
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const TAB_CONFIG: MainTabConfig[] = [
|
||||
{ id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },
|
||||
{ id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },
|
||||
{ id: 'tasks', labelKey: 'mainTabs.tasks' },
|
||||
{ id: 'plugins', label: 'Plugins', icon: Puzzle },
|
||||
{ id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },
|
||||
];
|
||||
|
||||
export default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {
|
||||
|
||||
@@ -49,6 +49,15 @@ import jaCodeEditor from './locales/ja/codeEditor.json';
|
||||
// eslint-disable-next-line import-x/order
|
||||
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 { languages } from './languages.js';
|
||||
|
||||
@@ -107,6 +116,15 @@ i18n
|
||||
codeEditor: jaCodeEditor,
|
||||
tasks: jaTasks,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
settings: ruSettings,
|
||||
auth: ruAuth,
|
||||
sidebar: ruSidebar,
|
||||
chat: ruChat,
|
||||
codeEditor: ruCodeEditor,
|
||||
tasks: ruTasks,
|
||||
},
|
||||
},
|
||||
|
||||
// Default language
|
||||
|
||||
@@ -29,6 +29,11 @@ export const languages = [
|
||||
label: 'Japanese',
|
||||
nativeName: '日本語',
|
||||
},
|
||||
{
|
||||
value: 'ru',
|
||||
label: 'Russian',
|
||||
nativeName: 'Русский',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "Appearance",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & Tokens",
|
||||
"tasks": "Tasks"
|
||||
"tasks": "Tasks",
|
||||
"plugins": "Plugins"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "外観",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & トークン",
|
||||
"tasks": "タスク"
|
||||
"tasks": "タスク",
|
||||
"plugins": "プラグイン"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "외관",
|
||||
"git": "Git",
|
||||
"apiTokens": "API & 토큰",
|
||||
"tasks": "작업"
|
||||
"tasks": "작업",
|
||||
"plugins": "플러그인"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
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": "Выйти"
|
||||
}
|
||||
}
|
||||
269
src/i18n/locales/ru/chat.json
Normal file
269
src/i18n/locales/ru/chat.json
Normal file
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"codeBlock": {
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyCode": "Копировать код"
|
||||
},
|
||||
"copyMessage": {
|
||||
"copy": "Копировать сообщение",
|
||||
"copied": "Сообщение скопировано"
|
||||
},
|
||||
"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": "Закрыть боковую панель"
|
||||
}
|
||||
}
|
||||
}
|
||||
434
src/i18n/locales/ru/settings.json
Normal file
434
src/i18n/locales/ru/settings.json
Normal file
@@ -0,0 +1,434 @@
|
||||
{
|
||||
"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": "Задачи"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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 дополнительными инструментами и ресурсами."
|
||||
}
|
||||
}
|
||||
}
|
||||
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": "Попробуйте изменить критерии поиска или фильтрации."
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,8 @@
|
||||
"appearance": "外观",
|
||||
"git": "Git",
|
||||
"apiTokens": "API 和令牌",
|
||||
"tasks": "任务"
|
||||
"tasks": "任务",
|
||||
"plugins": "插件"
|
||||
},
|
||||
"appearanceSettings": {
|
||||
"darkMode": {
|
||||
|
||||
Reference in New Issue
Block a user