From 1d31c3ec8309b433a041f3099955addc8c136c35 Mon Sep 17 00:00:00 2001 From: Benjamin <1159333+b0x42@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:04:01 +0100 Subject: [PATCH 1/5] docs: add German language link to all README files (#534) * docs: add German language link to all README files --- README.de.md | 239 ++++++++++++++++++++++++++++++++++++++++++++++++ README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 README.de.md diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..a3e190d --- /dev/null +++ b/README.de.md @@ -0,0 +1,239 @@ +
+ CloudCLI UI +

Cloud CLI (auch bekannt als Claude Code UI)

+

Eine Desktop- und Mobile-Oberfläche für Claude Code, Cursor CLI, Codex und Gemini-CLI.
Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.

+
+ +

+ CloudCLI Cloud · Dokumentation · Discord · Fehler melden · Mitwirken +

+ +

+ CloudCLI Cloud + Discord beitreten +

+ siteboon%2Fclaudecodeui | Trendshift +

+ +
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
+ +--- + +## Screenshots + +
+ + + + + + + + + +
+

Desktop-Ansicht

+Desktop-Oberfläche +
+Hauptoberfläche mit Projektübersicht und Chat +
+

Mobile-Erfahrung

+Mobile-Oberfläche +
+Responsives mobiles Design mit Touch-Navigation +
+

CLI-Auswahl

+CLI-Auswahl +
+Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex +
+ + + +
+ +## Funktionen + +- **Responsives Design** – Funktioniert nahtlos auf Desktop, Tablet und Mobilgerät, sodass du Agents auch vom Smartphone aus nutzen kannst +- **Interaktives Chat-Interface** – Eingebaute Chat-Oberfläche für die reibungslose Kommunikation mit den Agents +- **Integriertes Shell-Terminal** – Direkter Zugriff auf die Agents CLI über die eingebaute Shell-Funktionalität +- **Datei-Explorer** – Interaktiver Dateibaum mit Syntaxhervorhebung und Live-Bearbeitung +- **Git-Explorer** – Änderungen anzeigen, stagen und committen. Branches wechseln ebenfalls möglich +- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen +- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter) +- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung +- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js)) + + +## Schnellstart + +### CloudCLI Cloud (Empfohlen) + +Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine vollständig verwaltete, containerisierte Entwicklungsumgebung, die über Web, Mobile App, API oder deine bevorzugte IDE erreichbar ist. + +**[Mit CloudCLI Cloud starten](https://cloudcli.ai)** + + +### Self-Hosted (Open Source) + +CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+): + +```bash +npx @siteboon/claude-code-ui +``` + +Oder **global** installieren für regelmäßige Nutzung: + +```bash +npm install -g @siteboon/claude-code-ui +cloudcli +``` + +Öffne `http://localhost:3001` – alle vorhandenen Sitzungen werden automatisch erkannt. + +Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr. + + +--- + +## Welche Option passt zu dir? + +CloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kannst es auf deinem eigenen Rechner selbst hosten oder CloudCLI Cloud nutzen, das darauf aufbaut und eine vollständig verwaltete Cloud-Umgebung, Team-Funktionen und tiefere Integrationen bietet. + +| | CloudCLI UI (Self-hosted) | CloudCLI Cloud | +|---|---|---| +| **Am besten für** | Entwickler:innen, die eine vollständige UI für lokale Agent-Sitzungen auf ihrem eigenen Rechner möchten | Teams und Entwickler:innen, die Agents in der Cloud betreiben möchten, überall erreichbar | +| **Zugriff** | Browser via `[deineIP]:port` | Browser, jede IDE, REST API, n8n | +| **Einrichtung** | `npx @siteboon/claude-code-ui` | Keine Einrichtung erforderlich | +| **Rechner muss laufen** | Ja | Nein | +| **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung | +| **Verfügbare Sitzungen** | Alle Sitzungen automatisch aus `~/.claude` erkannt | Alle Sitzungen in deiner Cloud-Umgebung | +| **Unterstützte Agents** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI | +| **Datei-Explorer und Git** | Ja, direkt in der UI | Ja, direkt in der UI | +| **MCP-Konfiguration** | Über UI verwaltet, synchronisiert mit lokalem `~/.claude` | Über UI verwaltet | +| **IDE-Zugriff** | Deine lokale IDE | Jede IDE, die mit deiner Cloud-Umgebung verbunden ist | +| **REST API** | Ja | Ja | +| **n8n-Node** | Nein | Ja | +| **Team-Sharing** | Nein | Ja | +| **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat | + +> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) – CloudCLI stellt die Umgebung bereit, nicht die KI. + +--- + +## Sicherheit & Tool-Konfiguration + +**🔒 Wichtiger Hinweis**: Alle Claude Code Tools sind **standardmäßig deaktiviert**. Dies verhindert, dass potenziell schädliche Operationen automatisch ausgeführt werden. + +### Tools aktivieren + +Um den vollen Funktionsumfang von Claude Code zu nutzen, müssen Tools manuell aktiviert werden: + +1. **Tool-Einstellungen öffnen** – Klicke auf das Zahnrad-Symbol in der Seitenleiste +2. **Selektiv aktivieren** – Nur die benötigten Tools einschalten +3. **Einstellungen übernehmen** – Deine Einstellungen werden lokal gespeichert + +
+ +![Tool-Einstellungen Modal](public/screenshots/tools-modal.png) +*Tool-Einstellungen – nur aktivieren, was benötigt wird* + +
+ +**Empfohlene Vorgehensweise**: Mit grundlegenden Tools starten und bei Bedarf weitere hinzufügen. Die Einstellungen können jederzeit angepasst werden. + +--- + +## Plugins + +CloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit eigener Frontend-UI und optionalem Node.js-Backend hinzugefügt werden können. Plugins können direkt in **Einstellungen > Plugins** aus Git-Repos installiert oder selbst entwickelt werden. + +### Verfügbare Plugins + +| Plugin | Beschreibung | +|---|---| +| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts | + +### Eigenes Plugin erstellen + +**[Plugin-Starter-Vorlage →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** – Forke dieses Repository, um ein eigenes Plugin zu erstellen. Es enthält ein funktionierendes Beispiel mit Frontend-Rendering, Live-Kontext-Updates und RPC-Kommunikation zu einem Backend-Server. + +**[Plugin-Dokumentation →](https://cloudcli.ai/docs/plugin-overview)** – Vollständige Anleitung zur Plugin-API, zum Manifest-Format, zum Sicherheitsmodell und mehr. + +--- +## FAQ + +
+Wie unterscheidet sich das von Claude Code Remote Control? + +Claude Code Remote Control ermöglicht es, Nachrichten an eine bereits im lokalen Terminal laufende Sitzung zu senden. Der Rechner muss eingeschaltet bleiben, das Terminal muss offen bleiben, und Sitzungen laufen nach etwa 10 Minuten ohne Netzwerkverbindung ab. + +CloudCLI UI und CloudCLI Cloud erweitern Claude Code, anstatt neben ihm zu laufen – MCP-Server, Berechtigungen, Einstellungen und Sitzungen sind exakt dieselben, die Claude Code nativ verwendet. Nichts wird dupliziert oder separat verwaltet. + +Das bedeutet in der Praxis: + +- **Alle Sitzungen, nicht nur eine** – CloudCLI UI erkennt automatisch jede Sitzung aus dem `~/.claude`-Ordner. Remote Control stellt nur die einzelne aktive Sitzung bereit, um sie in der Claude Mobile App verfügbar zu machen. +- **Deine Einstellungen sind deine Einstellungen** – MCP-Server, Tool-Berechtigungen und Projektkonfiguration, die in CloudCLI UI geändert werden, werden direkt in die Claude Code-Konfiguration geschrieben und treten sofort in Kraft – und umgekehrt. +- **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI, Codex und Gemini CLI, nicht nur Claude Code. +- **Vollständige UI, nicht nur ein Chat-Fenster** – Datei-Explorer, Git-Integration, MCP-Verwaltung und ein Shell-Terminal sind alle eingebaut. +- **CloudCLI Cloud läuft in der Cloud** – Laptop zuklappen, der Agent läuft weiter. Kein Terminal zu überwachen, kein Rechner, der laufen muss. + +
+ +
+Muss ich ein KI-Abonnement separat bezahlen? + +Ja. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu. + +
+ +
+Kann ich CloudCLI UI auf meinem Smartphone nutzen? + +Ja. Bei Self-Hosted: Server auf dem eigenen Rechner starten und `[deineIP]:port` in einem beliebigen Browser im Netzwerk öffnen. Bei CloudCLI Cloud: Von jedem Gerät aus öffnen – kein VPN, keine Portweiterleitung, keine Einrichtung. Eine native App ist ebenfalls in Entwicklung. + +
+ +
+Wirken sich Änderungen in der UI auf mein lokales Claude Code-Setup aus? + +Ja, bei Self-Hosted. CloudCLI UI liest aus und schreibt in dieselbe `~/.claude`-Konfiguration, die Claude Code nativ verwendet. MCP-Server, die über die UI hinzugefügt werden, erscheinen sofort in Claude Code und umgekehrt. + +
+ +--- + +## Community & Support + +- **[Dokumentation](https://cloudcli.ai/docs)** — Installation, Konfiguration, Funktionen und Fehlerbehebung +- **[Discord](https://discord.gg/buxwujPNRE)** — Hilfe erhalten und mit anderen Nutzer:innen in Kontakt treten +- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — Fehlerberichte und Feature-Anfragen +- **[Beitragsrichtlinien](CONTRIBUTING.md)** — So kannst du zum Projekt beitragen + +## Lizenz + +GNU General Public License v3.0 – siehe [LICENSE](LICENSE)-Datei für Details. + +Dieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutzt, modifiziert und verteilt werden. + +## Danksagungen + +### Erstellt mit +- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropics offizielle CLI +- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI +- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex +- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI +- **[React](https://react.dev/)** - UI-Bibliothek +- **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server +- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework +- **[CodeMirror](https://codemirror.net/)** - Erweiterter Code-Editor +- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - KI-gestütztes Projektmanagement und Aufgabenplanung + + +### Sponsoren +- [Siteboon - KI-gestützter Website-Builder](https://siteboon.ai) +--- + +
+ Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt. +
diff --git a/README.ja.md b/README.ja.md index 67d53f0..960fe67 100644 --- a/README.ja.md +++ b/README.ja.md @@ -6,7 +6,7 @@ [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview)、[Codex](https://developers.openai.com/codex) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。 -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## スクリーンショット diff --git a/README.ko.md b/README.ko.md index 00250f9..5747351 100644 --- a/README.ko.md +++ b/README.ko.md @@ -6,7 +6,7 @@ [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview) 및 [Codex](https://developers.openai.com/codex)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다. -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## 스크린샷 diff --git a/README.md b/README.md index 3eb4b0f..7df11e2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ siteboon%2Fclaudecodeui | Trendshift

-
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
--- diff --git a/README.ru.md b/README.ru.md index d864711..06e0f98 100644 --- a/README.ru.md +++ b/README.ru.md @@ -15,7 +15,7 @@ siteboon%2Fclaudecodeui | Trendshift

-
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## Скриншоты diff --git a/README.zh-CN.md b/README.zh-CN.md index 60e25f6..beb10ee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。 -
English · Русский · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語 · Deutsch
## 截图 From adb3a06d7e66a6d2dbcdfb501615e617178314af Mon Sep 17 00:00:00 2001 From: Simos Mikelatos Date: Fri, 13 Mar 2026 15:38:53 +0100 Subject: [PATCH 2/5] feat: git panel redesign (#535) * feat(git-panel): add Branches tab, Fetch always visible, inline error banners - Add dedicated Branches tab (local/remote sections, switch with confirmation, delete branch, create branch) - Rename History tab to Commits; add change-count badge on Changes tab - Fetch button always visible when remote exists (not only when both ahead & behind) - Inline error banner below header for failed push/pull/fetch, with dismiss button - Server: /api/git/branches now returns localBranches + remoteBranches separately - Server: add /api/git/delete-branch endpoint (prevents deleting current branch) - Controller: expose operationError, clearOperationError, deleteBranch, localBranches, remoteBranches - Constants: add deleteBranch to all ConfirmActionType record maps Co-Authored-By: Claude Sonnet 4.6 * fix: git log datetime * feat(git-panel): add staged/unstaged sections and enhanced commit details --------- Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Haile <118998054+blackmammoth@users.noreply.github.com> --- package-lock.json | 2 + server/routes/git.js | 68 +++-- .../git-panel/constants/constants.ts | 9 +- .../git-panel/hooks/useGitPanelController.ts | 62 ++++- src/components/git-panel/types/types.ts | 11 +- .../git-panel/utils/gitPanelUtils.ts | 67 +++++ src/components/git-panel/view/GitPanel.tsx | 34 ++- .../git-panel/view/GitPanelHeader.tsx | 115 +++++---- src/components/git-panel/view/GitViewTabs.tsx | 58 +++-- .../git-panel/view/branches/BranchesView.tsx | 242 ++++++++++++++++++ .../git-panel/view/changes/ChangesView.tsx | 105 +++++--- .../git-panel/view/changes/FileChangeList.tsx | 38 +-- .../view/history/CommitHistoryItem.tsx | 93 ++++++- 13 files changed, 732 insertions(+), 172 deletions(-) create mode 100644 src/components/git-panel/view/branches/BranchesView.tsx diff --git a/package-lock.json b/package-lock.json index 82ad891..1799af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1909,6 +1909,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1925,6 +1926,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ diff --git a/server/routes/git.js b/server/routes/git.js index 701c3be..a439563 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -651,26 +651,28 @@ router.get('/branches', async (req, res) => { // Get all branches const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath }); - - // Parse branches - const branches = stdout + + const rawLines = stdout .split('\n') - .map(branch => branch.trim()) - .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer - .map(branch => { - // Remove asterisk from current branch - if (branch.startsWith('* ')) { - return branch.substring(2); - } - // Remove remotes/ prefix - if (branch.startsWith('remotes/origin/')) { - return branch.substring(15); - } - return branch; - }) - .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates - - res.json({ branches }); + .map(b => b.trim()) + .filter(b => b && !b.includes('->')); + + // Local branches (may start with '* ' for current) + const localBranches = rawLines + .filter(b => !b.startsWith('remotes/')) + .map(b => (b.startsWith('* ') ? b.substring(2) : b)); + + // Remote branches — strip 'remotes//' prefix + const remoteBranches = rawLines + .filter(b => b.startsWith('remotes/')) + .map(b => b.replace(/^remotes\/[^/]+\//, '')) + .filter(name => !localBranches.includes(name)); // skip if already a local branch + + // Backward-compat flat list (local + unique remotes, deduplicated) + const branches = [...localBranches, ...remoteBranches] + .filter((b, i, arr) => arr.indexOf(b) === i); + + res.json({ branches, localBranches, remoteBranches }); } catch (error) { console.error('Git branches error:', error); res.json({ error: error.message }); @@ -721,6 +723,32 @@ router.post('/create-branch', async (req, res) => { } }); +// Delete a local branch +router.post('/delete-branch', async (req, res) => { + const { project, branch } = req.body; + + if (!project || !branch) { + return res.status(400).json({ error: 'Project name and branch name are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + + // Safety: cannot delete the currently checked-out branch + const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath }); + if (currentBranch.trim() === branch) { + return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' }); + } + + const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath }); + res.json({ success: true, output: stdout }); + } catch (error) { + console.error('Git delete branch error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Get recent commits router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; @@ -740,7 +768,7 @@ router.get('/commits', async (req, res) => { // Get commit log with stats const { stdout } = await spawnAsync( 'git', - ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)], + ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)], { cwd: projectPath }, ); diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 420f955..5e2ff19 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -27,21 +27,23 @@ export const FILE_STATUS_BADGE_CLASSES: Record = { export const CONFIRMATION_TITLES: Record = { discard: 'Discard Changes', delete: 'Delete File', - commit: 'Confirm Commit', + commit: 'Confirm Action', pull: 'Confirm Pull', push: 'Confirm Push', publish: 'Publish Branch', revertLocalCommit: 'Revert Local Commit', + deleteBranch: 'Delete Branch', }; export const CONFIRMATION_ACTION_LABELS: Record = { discard: 'Discard', delete: 'Delete', - commit: 'Commit', + commit: 'Confirm', pull: 'Pull', push: 'Push', publish: 'Publish', revertLocalCommit: 'Revert Commit', + deleteBranch: 'Delete', }; export const CONFIRMATION_BUTTON_CLASSES: Record = { @@ -52,6 +54,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { push: 'bg-orange-600 hover:bg-orange-700', publish: 'bg-purple-600 hover:bg-purple-700', revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700', + deleteBranch: 'bg-red-600 hover:bg-red-700', }; export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -62,6 +65,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -72,4 +76,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { push: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400', revertLocalCommit: 'text-yellow-600 dark:text-yellow-400', + deleteBranch: 'text-red-600 dark:text-red-400', }; diff --git a/src/components/git-panel/hooks/useGitPanelController.ts b/src/components/git-panel/hooks/useGitPanelController.ts index 7fe8635..cb34cf1 100644 --- a/src/components/git-panel/hooks/useGitPanelController.ts +++ b/src/components/git-panel/hooks/useGitPanelController.ts @@ -53,12 +53,17 @@ export function useGitPanelController({ const [recentCommits, setRecentCommits] = useState([]); const [commitDiffs, setCommitDiffs] = useState({}); const [remoteStatus, setRemoteStatus] = useState(null); + const [localBranches, setLocalBranches] = useState([]); + const [remoteBranches, setRemoteBranches] = useState([]); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [isFetching, setIsFetching] = useState(false); const [isPulling, setIsPulling] = useState(false); const [isPushing, setIsPushing] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false); + const [operationError, setOperationError] = useState(null); + + const clearOperationError = useCallback(() => setOperationError(null), []); const selectedProjectNameRef = useRef(selectedProject?.name ?? null); useEffect(() => { @@ -169,13 +174,19 @@ export function useGitPanelController({ if (!data.error && data.branches) { setBranches(data.branches); + setLocalBranches(data.localBranches ?? data.branches); + setRemoteBranches(data.remoteBranches ?? []); return; } setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); } catch (error) { console.error('Error fetching branches:', error); setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); } }, [selectedProject]); @@ -271,6 +282,33 @@ export function useGitPanelController({ [fetchBranches, fetchGitStatus, selectedProject], ); + const deleteBranch = useCallback( + async (branchName: string) => { + if (!selectedProject) return false; + + try { + const response = await fetchWithAuth('/api/git/delete-branch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: selectedProject.name, branch: branchName }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Delete branch failed'); + return false; + } + + void fetchBranches(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Delete branch failed'); + return false; + } + }, + [fetchBranches, selectedProject], + ); + const handleFetch = useCallback(async () => { if (!selectedProject) { return; @@ -290,16 +328,17 @@ export function useGitPanelController({ if (data.success) { void fetchGitStatus(); void fetchRemoteStatus(); + void fetchBranches(); return; } - console.error('Fetch failed:', data.error); + setOperationError(data.error ?? 'Fetch failed'); } catch (error) { - console.error('Error fetching from remote:', error); + setOperationError(error instanceof Error ? error.message : 'Fetch failed'); } finally { setIsFetching(false); } - }, [fetchGitStatus, fetchRemoteStatus, selectedProject]); + }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]); const handlePull = useCallback(async () => { if (!selectedProject) { @@ -323,9 +362,9 @@ export function useGitPanelController({ return; } - console.error('Pull failed:', data.error); + setOperationError(data.error ?? 'Pull failed'); } catch (error) { - console.error('Error pulling from remote:', error); + setOperationError(error instanceof Error ? error.message : 'Pull failed'); } finally { setIsPulling(false); } @@ -353,9 +392,9 @@ export function useGitPanelController({ return; } - console.error('Push failed:', data.error); + setOperationError(data.error ?? 'Push failed'); } catch (error) { - console.error('Error pushing to remote:', error); + setOperationError(error instanceof Error ? error.message : 'Push failed'); } finally { setIsPushing(false); } @@ -640,12 +679,15 @@ export function useGitPanelController({ // Reset repository-scoped state when project changes to avoid stale UI. setCurrentBranch(''); setBranches([]); + setLocalBranches([]); + setRemoteBranches([]); setGitStatus(null); setRemoteStatus(null); setGitDiff({}); setRecentCommits([]); setCommitDiffs({}); setIsLoading(false); + setOperationError(null); if (!selectedProject) { return () => { @@ -666,7 +708,6 @@ export function useGitPanelController({ if (!selectedProject || activeView !== 'history') { return; } - void fetchRecentCommits(); }, [activeView, fetchRecentCommits, selectedProject]); @@ -676,6 +717,8 @@ export function useGitPanelController({ isLoading, currentBranch, branches, + localBranches, + remoteBranches, recentCommits, commitDiffs, remoteStatus, @@ -685,9 +728,12 @@ export function useGitPanelController({ isPushing, isPublishing, isCreatingInitialCommit, + operationError, + clearOperationError, refreshAll, switchBranch, createBranch, + deleteBranch, handleFetch, handlePull, handlePush, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index c8188e9..7abf982 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -1,9 +1,9 @@ import type { Project } from '../../../types/app'; -export type GitPanelView = 'changes' | 'history'; +export type GitPanelView = 'changes' | 'history' | 'branches'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; -export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit'; +export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit' | 'deleteBranch'; export type FileDiffInfo = { old_string: string; @@ -76,6 +76,8 @@ export type GitPanelController = { isLoading: boolean; currentBranch: string; branches: string[]; + localBranches: string[]; + remoteBranches: string[]; recentCommits: GitCommitSummary[]; commitDiffs: GitDiffMap; remoteStatus: GitRemoteStatus | null; @@ -85,9 +87,12 @@ export type GitPanelController = { isPushing: boolean; isPublishing: boolean; isCreatingInitialCommit: boolean; + operationError: string | null; + clearOperationError: () => void; refreshAll: () => void; switchBranch: (branchName: string) => Promise; createBranch: (branchName: string) => Promise; + deleteBranch: (branchName: string) => Promise; handleFetch: () => Promise; handlePull: () => Promise; handlePush: () => Promise; @@ -112,6 +117,8 @@ export type GitDiffResponse = GitApiErrorResponse & { export type GitBranchesResponse = GitApiErrorResponse & { branches?: string[]; + localBranches?: string[]; + remoteBranches?: string[]; }; export type GitCommitsResponse = GitApiErrorResponse & { diff --git a/src/components/git-panel/utils/gitPanelUtils.ts b/src/components/git-panel/utils/gitPanelUtils.ts index 736deeb..7a66ba6 100644 --- a/src/components/git-panel/utils/gitPanelUtils.ts +++ b/src/components/git-panel/utils/gitPanelUtils.ts @@ -24,3 +24,70 @@ export function getStatusLabel(status: FileStatusCode): string { export function getStatusBadgeClass(status: FileStatusCode): string { return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U; } + +// --------------------------------------------------------------------------- +// Parse `git show` output to extract per-file change info +// --------------------------------------------------------------------------- + +export type CommitFileChange = { + path: string; + directory: string; + filename: string; + status: FileStatusCode; + insertions: number; + deletions: number; +}; + +export type CommitFileSummary = { + files: CommitFileChange[]; + totalFiles: number; + totalInsertions: number; + totalDeletions: number; +}; + +export function parseCommitFiles(showOutput: string): CommitFileSummary { + const files: CommitFileChange[] = []; + // Split on file diff boundaries + const fileDiffs = showOutput.split(/^diff --git /m).slice(1); + + for (const section of fileDiffs) { + const lines = section.split('\n'); + // Extract path from "a/path b/path" + const header = lines[0] ?? ''; + const match = header.match(/^a\/(.+?) b\/(.+)/); + if (!match) continue; + + const pathA = match[1]; + const pathB = match[2]; + + // Determine status + let status: FileStatusCode = 'M'; + const joined = lines.slice(0, 6).join('\n'); + if (joined.includes('new file mode')) status = 'A'; + else if (joined.includes('deleted file mode')) status = 'D'; + + const filePath = status === 'D' ? pathA : pathB; + + // Count insertions/deletions (lines starting with +/- but not +++/---) + let insertions = 0; + let deletions = 0; + for (const line of lines) { + if (line.startsWith('+++') || line.startsWith('---')) continue; + if (line.startsWith('+')) insertions++; + else if (line.startsWith('-')) deletions++; + } + + const lastSlash = filePath.lastIndexOf('/'); + const directory = lastSlash >= 0 ? filePath.substring(0, lastSlash + 1) : ''; + const filename = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath; + + files.push({ path: filePath, directory, filename, status, insertions, deletions }); + } + + return { + files, + totalFiles: files.length, + totalInsertions: files.reduce((sum, f) => sum + f.insertions, 0), + totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0), + }; +} diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index d670f65..fc6438b 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -2,8 +2,10 @@ import { useCallback, useState } from 'react'; import { useGitPanelController } from '../hooks/useGitPanelController'; import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; +import { getChangedFileCount } from '../utils/gitPanelUtils'; import ChangesView from '../view/changes/ChangesView'; import HistoryView from '../view/history/HistoryView'; +import BranchesView from '../view/branches/BranchesView'; import GitPanelHeader from '../view/GitPanelHeader'; import GitRepositoryErrorState from '../view/GitRepositoryErrorState'; import GitViewTabs from '../view/GitViewTabs'; @@ -21,6 +23,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isLoading, currentBranch, branches, + localBranches, + remoteBranches, recentCommits, commitDiffs, remoteStatus, @@ -30,9 +34,12 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPushing, isPublishing, isCreatingInitialCommit, + operationError, + clearOperationError, refreshAll, switchBranch, createBranch, + deleteBranch, handleFetch, handlePull, handlePush, @@ -56,13 +63,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen }); const executeConfirmedAction = useCallback(async () => { - if (!confirmAction) { - return; - } - + if (!confirmAction) return; const actionToExecute = confirmAction; setConfirmAction(null); - try { await actionToExecute.onConfirm(); } catch (error) { @@ -70,6 +73,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen } }, [confirmAction]); + const changeCount = getChangedFileCount(gitStatus); + if (!selectedProject) { return (
@@ -92,6 +97,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPushing={isPushing} isPublishing={isPublishing} isRevertingLocalCommit={isRevertingLocalCommit} + operationError={operationError} onRefresh={refreshAll} onRevertLocalCommit={revertLatestLocalCommit} onSwitchBranch={switchBranch} @@ -100,6 +106,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onPull={handlePull} onPush={handlePush} onPublish={handlePublish} + onClearError={clearOperationError} onRequestConfirmation={setConfirmAction} /> @@ -110,6 +117,7 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen @@ -145,6 +153,22 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFetchCommitDiff={fetchCommitDiff} /> )} + + {activeView === 'branches' && ( + + )} )} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 2710d4b..9913cef 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload } from 'lucide-react'; +import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -15,6 +15,7 @@ type GitPanelHeaderProps = { isPushing: boolean; isPublishing: boolean; isRevertingLocalCommit: boolean; + operationError: string | null; onRefresh: () => void; onRevertLocalCommit: () => Promise; onSwitchBranch: (branchName: string) => Promise; @@ -23,6 +24,7 @@ type GitPanelHeaderProps = { onPull: () => Promise; onPush: () => Promise; onPublish: () => Promise; + onClearError: () => void; onRequestConfirmation: (request: ConfirmationRequest) => void; }; @@ -38,6 +40,7 @@ export default function GitPanelHeader({ isPushing, isPublishing, isRevertingLocalCommit, + operationError, onRefresh, onRevertLocalCommit, onSwitchBranch, @@ -46,6 +49,7 @@ export default function GitPanelHeader({ onPull, onPush, onPublish, + onClearError, onRequestConfirmation, }: GitPanelHeaderProps) { const [showBranchDropdown, setShowBranchDropdown] = useState(false); @@ -63,10 +67,10 @@ export default function GitPanelHeader({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); - const aheadCount = remoteStatus?.ahead || 0; - const behindCount = remoteStatus?.behind || 0; - const remoteName = remoteStatus?.remoteName || 'remote'; - const shouldShowFetchButton = aheadCount > 0 && behindCount > 0; + const aheadCount = remoteStatus?.ahead ?? 0; + const behindCount = remoteStatus?.behind ?? 0; + const remoteName = remoteStatus?.remoteName ?? 'remote'; + const anyPending = isFetching || isPulling || isPushing || isPublishing; const requestPullConfirmation = () => { onRequestConfirmation({ @@ -103,57 +107,39 @@ export default function GitPanelHeader({ const handleSwitchBranch = async (branchName: string) => { try { const success = await onSwitchBranch(branchName); - if (success) { - setShowBranchDropdown(false); - } + if (success) setShowBranchDropdown(false); } catch (error) { console.error('[GitPanelHeader] Failed to switch branch:', error); } }; - const handleFetch = async () => { - try { - await onFetch(); - } catch (error) { - console.error('[GitPanelHeader] Failed to fetch remote changes:', error); - } - }; - return ( <> + {/* Branch row + action buttons */}
+ {/* Branch selector */}
+ {/* Action buttons */}
{remoteStatus?.hasRemote && ( <> - {!remoteStatus.hasUpstream && ( + {!remoteStatus.hasUpstream ? ( - )} - - {remoteStatus.hasUpstream && !remoteStatus.isUpToDate && ( + ) : ( <> + {/* Fetch — always visible when remote exists */} + + {behindCount > 0 && ( )} {aheadCount > 0 && ( - )} - - {shouldShowFetchButton && ( - )} @@ -274,6 +258,21 @@ export default function GitPanelHeader({
+ {/* Inline error banner */} + {operationError && ( +
+ + {operationError} + +
+ )} + void; }; -export default function GitViewTabs({ activeView, isHidden, onChange }: GitViewTabsProps) { +const TABS: { id: GitPanelView; label: string; Icon: typeof FileText }[] = [ + { id: 'changes', label: 'Changes', Icon: FileText }, + { id: 'history', label: 'Commits', Icon: History }, + { id: 'branches', label: 'Branches', Icon: GitBranch }, +]; + +export default function GitViewTabs({ activeView, isHidden, changeCount, onChange }: GitViewTabsProps) { return (
- - + {TABS.map(({ id, label, Icon }) => ( + + ))}
); } diff --git a/src/components/git-panel/view/branches/BranchesView.tsx b/src/components/git-panel/view/branches/BranchesView.tsx new file mode 100644 index 0000000..6fd06b0 --- /dev/null +++ b/src/components/git-panel/view/branches/BranchesView.tsx @@ -0,0 +1,242 @@ +import { Check, GitBranch, Globe, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import type { ConfirmationRequest, GitRemoteStatus } from '../../types/types'; +import NewBranchModal from '../modals/NewBranchModal'; + +type BranchesViewProps = { + isMobile: boolean; + isLoading: boolean; + currentBranch: string; + localBranches: string[]; + remoteBranches: string[]; + remoteStatus: GitRemoteStatus | null; + isCreatingBranch: boolean; + onSwitchBranch: (branchName: string) => Promise; + onCreateBranch: (branchName: string) => Promise; + onDeleteBranch: (branchName: string) => Promise; + onRequestConfirmation: (request: ConfirmationRequest) => void; +}; + +// --------------------------------------------------------------------------- +// Branch row +// --------------------------------------------------------------------------- + +type BranchRowProps = { + name: string; + isCurrent: boolean; + isRemote: boolean; + aheadCount: number; + behindCount: number; + isMobile: boolean; + onSwitch: () => void; + onDelete: () => void; +}; + +function BranchRow({ name, isCurrent, isRemote, aheadCount, behindCount, isMobile, onSwitch, onDelete }: BranchRowProps) { + return ( +
+ {/* Branch icon */} +
+ {isRemote ? : } +
+ + {/* Name + pills */} +
+
+ + {name} + + {isCurrent && ( + + current + + )} + {isRemote && !isCurrent && ( + + remote + + )} +
+ {/* Ahead/behind — only meaningful for the current branch */} + {isCurrent && (aheadCount > 0 || behindCount > 0) && ( +
+ {aheadCount > 0 && ( + ↑{aheadCount} ahead + )} + {behindCount > 0 && ( + ↓{behindCount} behind + )} +
+ )} +
+ + {/* Actions */} +
+ {isCurrent ? ( + + ) : !isRemote ? ( + <> + + + + ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Section header +// --------------------------------------------------------------------------- + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( +
+ {label} + {count} +
+ ); +} + +// --------------------------------------------------------------------------- +// BranchesView +// --------------------------------------------------------------------------- + +export default function BranchesView({ + isMobile, + isLoading, + currentBranch, + localBranches, + remoteBranches, + remoteStatus, + isCreatingBranch, + onSwitchBranch, + onCreateBranch, + onDeleteBranch, + onRequestConfirmation, +}: BranchesViewProps) { + const [showNewBranchModal, setShowNewBranchModal] = useState(false); + + const aheadCount = remoteStatus?.ahead ?? 0; + const behindCount = remoteStatus?.behind ?? 0; + + const requestSwitch = (branch: string) => { + onRequestConfirmation({ + type: 'commit', // reuse neutral type for switch + message: `Switch to branch "${branch}"? Make sure you have no uncommitted changes.`, + onConfirm: () => void onSwitchBranch(branch), + }); + }; + + const requestDelete = (branch: string) => { + onRequestConfirmation({ + type: 'deleteBranch', + message: `Delete branch "${branch}"? This cannot be undone.`, + onConfirm: () => void onDeleteBranch(branch), + }); + }; + + if (isLoading && localBranches.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Create branch button */} +
+ + {localBranches.length} local{remoteBranches.length > 0 ? `, ${remoteBranches.length} remote` : ''} + + +
+ + {/* Branch list */} +
+ {localBranches.length > 0 && ( + <> + + {localBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {remoteBranches.length > 0 && ( + <> + + {remoteBranches.map((branch) => ( + requestSwitch(branch)} + onDelete={() => requestDelete(branch)} + /> + ))} + + )} + + {localBranches.length === 0 && remoteBranches.length === 0 && ( +
+ +

No branches found

+
+ )} +
+ + setShowNewBranchModal(false)} + onCreateBranch={onCreateBranch} + /> +
+ ); +} diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx index 4810bb2..cfcb29f 100644 --- a/src/components/git-panel/view/changes/ChangesView.tsx +++ b/src/components/git-panel/view/changes/ChangesView.tsx @@ -4,7 +4,6 @@ import type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse import { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils'; import CommitComposer from './CommitComposer'; import FileChangeList from './FileChangeList'; -import FileSelectionControls from './FileSelectionControls'; import FileStatusLegend from './FileStatusLegend'; type ChangesViewProps = { @@ -56,8 +55,12 @@ export default function ChangesView({ return; } - // Preserve previous behavior: every fresh status snapshot reselects changed files. - setSelectedFiles(new Set(getAllChangedFiles(gitStatus))); + // Remove any selected files that no longer exist in the status + setSelectedFiles((prev) => { + const allFiles = new Set(getAllChangedFiles(gitStatus)); + const next = new Set([...prev].filter((f) => allFiles.has(f))); + return next; + }); }, [gitStatus]); useEffect(() => { @@ -129,6 +132,11 @@ export default function ChangesView({ return onGenerateCommitMessage(Array.from(selectedFiles)); }, [onGenerateCommitMessage, selectedFiles]); + const unstagedFiles = useMemo( + () => new Set(changedFiles.filter((f) => !selectedFiles.has(f))), + [changedFiles, selectedFiles], + ); + return ( <> - {gitStatus && !gitStatus.error && ( - setSelectedFiles(new Set(changedFiles))} - onDeselectAll={() => setSelectedFiles(new Set())} - /> - )} - {!gitStatus?.error && }
@@ -193,21 +190,71 @@ export default function ChangesView({
) : (
- { - void onOpenFile(filePath); - }} - onToggleWrapText={() => onWrapTextChange(!wrapText)} - onRequestFileAction={requestFileAction} - /> + {/* STAGED section */} +
+ + Staged ({selectedFiles.size}) + + {selectedFiles.size > 0 && ( + + )} +
+ {selectedFiles.size === 0 ? ( +
No staged files
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )} + + {/* CHANGES section */} +
+ + Changes ({unstagedFiles.size}) + + {unstagedFiles.size > 0 && ( + + )} +
+ {unstagedFiles.size === 0 ? ( +
All changes staged
+ ) : ( + { void onOpenFile(filePath); }} + onToggleWrapText={() => onWrapTextChange(!wrapText)} + onRequestFileAction={requestFileAction} + /> + )}
)}
diff --git a/src/components/git-panel/view/changes/FileChangeList.tsx b/src/components/git-panel/view/changes/FileChangeList.tsx index 0438382..59f7b3d 100644 --- a/src/components/git-panel/view/changes/FileChangeList.tsx +++ b/src/components/git-panel/view/changes/FileChangeList.tsx @@ -9,6 +9,7 @@ type FileChangeListProps = { selectedFiles: Set; isMobile: boolean; wrapText: boolean; + filePaths?: Set; onToggleSelected: (filePath: string) => void; onToggleExpanded: (filePath: string) => void; onOpenFile: (filePath: string) => void; @@ -23,6 +24,7 @@ export default function FileChangeList({ selectedFiles, isMobile, wrapText, + filePaths, onToggleSelected, onToggleExpanded, onOpenFile, @@ -32,23 +34,25 @@ export default function FileChangeList({ return ( <> {FILE_STATUS_GROUPS.map(({ key, status }) => - (gitStatus[key] || []).map((filePath) => ( - - )), + (gitStatus[key] || []) + .filter((filePath) => !filePaths || filePaths.has(filePath)) + .map((filePath) => ( + + )), )} ); diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx index 60fd5ab..33a4139 100644 --- a/src/components/git-panel/view/history/CommitHistoryItem.tsx +++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx @@ -1,7 +1,16 @@ import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useMemo } from 'react'; import type { GitCommitSummary } from '../../types/types'; +import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils'; import GitDiffViewer from '../shared/GitDiffViewer'; +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} type CommitHistoryItemProps = { commit: GitCommitSummary; @@ -20,6 +29,11 @@ export default function CommitHistoryItem({ wrapText, onToggle, }: CommitHistoryItemProps) { + const fileSummary = useMemo(() => { + if (!diff) return null; + return parseCommitFiles(diff); + }, [diff]); + return (
+ {isPushSubscribed && ( + + {t('notifications.webPush.enabled')} + + )} +
+ )} + + +
+

{t('notifications.events.title')}

+
+ + + + + +
+
+ + ); +} diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx index 0d575f2..fa545aa 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx @@ -255,6 +255,7 @@ function ClaudePermissions({
  • "Bash(rm:*)" {t('permissions.toolExamples.bashRm')}
  • + ); } diff --git a/src/hooks/useWebPush.ts b/src/hooks/useWebPush.ts new file mode 100644 index 0000000..b5e365a --- /dev/null +++ b/src/hooks/useWebPush.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useState } from 'react'; +import { authenticatedFetch } from '../utils/api'; + +type WebPushState = { + permission: NotificationPermission | 'unsupported'; + isSubscribed: boolean; + isLoading: boolean; + subscribe: () => Promise; + unsubscribe: () => Promise; +}; + +function urlBase64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +export function useWebPush(): WebPushState { + const [permission, setPermission] = useState(() => { + if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) { + return 'unsupported'; + } + return Notification.permission; + }); + const [isSubscribed, setIsSubscribed] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // Check existing subscription on mount + useEffect(() => { + if (permission === 'unsupported') return; + + navigator.serviceWorker.ready.then((registration) => { + registration.pushManager.getSubscription().then((sub) => { + setIsSubscribed(sub !== null); + }); + }).catch(() => { + // SW not ready yet + }); + }, [permission]); + + const subscribe = useCallback(async () => { + if (permission === 'unsupported') return; + setIsLoading(true); + + try { + const perm = await Notification.requestPermission(); + setPermission(perm); + if (perm !== 'granted') return; + + const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key'); + const { publicKey } = await keyRes.json(); + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer, + }); + + const subJson = subscription.toJSON(); + await authenticatedFetch('/api/settings/push/subscribe', { + method: 'POST', + body: JSON.stringify({ + endpoint: subJson.endpoint, + keys: subJson.keys, + }), + }); + + setIsSubscribed(true); + } catch (err) { + console.error('Push subscribe failed:', err); + } finally { + setIsLoading(false); + } + }, [permission]); + + const unsubscribe = useCallback(async () => { + setIsLoading(true); + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + const endpoint = subscription.endpoint; + await subscription.unsubscribe(); + await authenticatedFetch('/api/settings/push/unsubscribe', { + method: 'POST', + body: JSON.stringify({ endpoint }), + }); + } + setIsSubscribed(false); + } catch (err) { + console.error('Push unsubscribe failed:', err); + } finally { + setIsLoading(false); + } + }, []); + + return { permission, isSubscribed, isLoading, subscribe, unsubscribe }; +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f5df816..7bd6e1d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "Failed to create folder" } }, + "notifications": { + "genericTool": "a tool", + "codes": { + "generic": { + "info": { + "title": "Notification" + } + }, + "permission": { + "required": { + "title": "Action Required", + "body": "{{toolName}} is waiting for your decision." + } + }, + "run": { + "stopped": { + "title": "Run Stopped", + "body": "Reason: {{reason}}" + }, + "failed": { + "title": "Run Failed" + } + }, + "agent": { + "notification": { + "title": "Agent Notification" + } + } + } + }, "versionUpdate": { "title": "Update Available", "newVersionReady": "A new version is ready", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 00e7eaa..fcd1c72 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & Tokens", "tasks": "Tasks", + "notifications": "Notifications", "plugins": "Plugins" + + }, + "notifications": { + "title": "Notifications", + "description": "Control which notification events you receive.", + "webPush": { + "title": "Web Push Notifications", + "enable": "Enable Push Notifications", + "disable": "Disable Push Notifications", + "enabled": "Push notifications are enabled", + "loading": "Updating...", + "unsupported": "Push notifications are not supported in this browser.", + "denied": "Push notifications are blocked. Please allow them in your browser settings." + }, + "events": { + "title": "Event Types", + "actionRequired": "Action required", + "stop": "Run stopped", + "error": "Run failed" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index e8dd3c3..3625448 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "フォルダの作成に失敗しました" } }, + "notifications": { + "genericTool": "ツール", + "codes": { + "generic": { + "info": { + "title": "通知" + } + }, + "permission": { + "required": { + "title": "対応が必要です", + "body": "{{toolName}} があなたの判断を待っています。" + } + }, + "run": { + "stopped": { + "title": "実行が停止しました", + "body": "理由: {{reason}}" + }, + "failed": { + "title": "実行に失敗しました" + } + }, + "agent": { + "notification": { + "title": "エージェント通知" + } + } + } + }, "versionUpdate": { "title": "アップデートのお知らせ", "newVersionReady": "新しいバージョンが利用可能です", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 5b01a5c..60b454c 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & トークン", "tasks": "タスク", + "notifications": "通知", "plugins": "プラグイン" + + }, + "notifications": { + "title": "通知", + "description": "受信する通知イベントを設定します。", + "webPush": { + "title": "Webプッシュ通知", + "enable": "プッシュ通知を有効にする", + "disable": "プッシュ通知を無効にする", + "enabled": "プッシュ通知は有効です", + "loading": "更新中...", + "unsupported": "このブラウザではプッシュ通知がサポートされていません。", + "denied": "プッシュ通知がブロックされています。ブラウザの設定で許可してください。" + }, + "events": { + "title": "イベント種別", + "actionRequired": "対応が必要", + "stop": "実行停止", + "error": "実行失敗" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 980e5bc..dd0d220 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "폴더 생성 실패" } }, + "notifications": { + "genericTool": "도구", + "codes": { + "generic": { + "info": { + "title": "알림" + } + }, + "permission": { + "required": { + "title": "작업 필요", + "body": "{{toolName}} 에 대한 결정을 기다리고 있습니다." + } + }, + "run": { + "stopped": { + "title": "실행이 중지되었습니다", + "body": "사유: {{reason}}" + }, + "failed": { + "title": "실행 실패" + } + }, + "agent": { + "notification": { + "title": "에이전트 알림" + } + } + } + }, "versionUpdate": { "title": "업데이트 가능", "newVersionReady": "새 버전이 준비되었습니다", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index 468f480..b8a1f45 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API & 토큰", "tasks": "작업", + "notifications": "알림", "plugins": "플러그인" + + }, + "notifications": { + "title": "알림", + "description": "수신할 알림 이벤트를 설정합니다.", + "webPush": { + "title": "웹 푸시 알림", + "enable": "푸시 알림 활성화", + "disable": "푸시 알림 비활성화", + "enabled": "푸시 알림이 활성화되었습니다", + "loading": "업데이트 중...", + "unsupported": "이 브라우저에서는 푸시 알림이 지원되지 않습니다.", + "denied": "푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요." + }, + "events": { + "title": "이벤트 유형", + "actionRequired": "작업 필요", + "stop": "실행 중지", + "error": "실행 실패" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 039ba25..c54f684 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -206,6 +206,36 @@ "failedToCreateFolder": "创建文件夹失败" } }, + "notifications": { + "genericTool": "工具", + "codes": { + "generic": { + "info": { + "title": "通知" + } + }, + "permission": { + "required": { + "title": "需要处理", + "body": "{{toolName}} 正在等待你的决策。" + } + }, + "run": { + "stopped": { + "title": "运行已停止", + "body": "原因:{{reason}}" + }, + "failed": { + "title": "运行失败" + } + }, + "agent": { + "notification": { + "title": "Agent 通知" + } + } + } + }, "versionUpdate": { "title": "有可用更新", "newVersionReady": "新版本已准备就绪", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 0bd7731..d9f2b2c 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -105,7 +105,28 @@ "git": "Git", "apiTokens": "API 和令牌", "tasks": "任务", + "notifications": "通知", "plugins": "插件" + + }, + "notifications": { + "title": "通知", + "description": "控制你希望接收的通知事件。", + "webPush": { + "title": "Web 推送通知", + "enable": "启用推送通知", + "disable": "关闭推送通知", + "enabled": "推送通知已启用", + "loading": "更新中...", + "unsupported": "此浏览器不支持推送通知。", + "denied": "推送通知已被阻止,请在浏览器设置中允许。" + }, + "events": { + "title": "事件类型", + "actionRequired": "需要处理", + "stop": "运行已停止", + "error": "运行失败" + } }, "appearanceSettings": { "darkMode": { diff --git a/src/main.jsx b/src/main.jsx index cacb2db..0c88aea 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -7,14 +7,10 @@ import 'katex/dist/katex.min.css' // Initialize i18n import './i18n/config.js' -// Clean up stale service workers on app load to prevent caching issues after builds +// Register service worker for PWA + Web Push support if ('serviceWorker' in navigator) { - navigator.serviceWorker.getRegistrations().then(registrations => { - registrations.forEach(registration => { - registration.unregister(); - }); - }).catch(err => { - console.warn('Failed to unregister service workers:', err); + navigator.serviceWorker.register('/sw.js').catch(err => { + console.warn('Service worker registration failed:', err); }); } From 95bcee0ec459f186d52aeffe100ac1a024e92909 Mon Sep 17 00:00:00 2001 From: Luc Peng Date: Sat, 14 Mar 2026 01:22:09 +0800 Subject: [PATCH 5/5] fix: detect Claude auth from settings env (#527) - detect Claude auth from ~/.claude/settings.json env values - treat ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN from settings as authenticated - keep existing .credentials.json OAuth detection unchanged --- server/routes/cli-auth.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/server/routes/cli-auth.js b/server/routes/cli-auth.js index cb7275d..78ffa30 100644 --- a/server/routes/cli-auth.js +++ b/server/routes/cli-auth.js @@ -96,10 +96,27 @@ router.get('/gemini/status', async (req, res) => { } }); +async function loadClaudeSettingsEnv() { + try { + const settingsPath = path.join(os.homedir(), '.claude', 'settings.json'); + const content = await fs.readFile(settingsPath, 'utf8'); + const settings = JSON.parse(content); + + if (settings?.env && typeof settings.env === 'object') { + return settings.env; + } + } catch (error) { + // Ignore missing or malformed settings and fall back to other auth sources. + } + + return {}; +} + /** * Checks Claude authentication credentials using two methods with priority order: * * Priority 1: ANTHROPIC_API_KEY environment variable + * Priority 1b: ~/.claude/settings.json env values * Priority 2: ~/.claude/.credentials.json OAuth tokens * * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions. @@ -128,6 +145,27 @@ async function checkClaudeCredentials() { }; } + // Priority 1b: Check ~/.claude/settings.json env values. + // Claude Code can read proxy/auth values from settings.json even when the + // CloudCLI server process itself was not started with those env vars exported. + const settingsEnv = await loadClaudeSettingsEnv(); + + if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) { + return { + authenticated: true, + email: 'API Key Auth', + method: 'api_key' + }; + } + + if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) { + return { + authenticated: true, + email: 'Configured via settings.json', + method: 'api_key' + }; + } + // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens // This is the standard authentication method used by Claude CLI after running // 'claude /login' or 'claude setup-token' commands.