diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index d56078bb3..fc075d943 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -10,6 +10,7 @@ import { ChatState } from '../types/chatState'; import debounce from 'lodash/debounce'; import { LocalMessageStorage } from '../utils/localMessageStorage'; import { DirSwitcher } from './bottom_menu/DirSwitcher'; +import { GitBranchIndicator } from './GitBranchIndicator'; import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; import { BottomMenuExtensionSelection } from './bottom_menu/BottomMenuExtensionSelection'; import { cn } from '../utils'; @@ -1683,6 +1684,10 @@ export default function ChatInput({ /> )} + {!isBottomBarNarrow && currentWorkingDir && ( + + )} + {/* Spacer */}
diff --git a/ui/desktop/src/components/GitBranchIndicator.tsx b/ui/desktop/src/components/GitBranchIndicator.tsx new file mode 100644 index 000000000..62d884bd2 --- /dev/null +++ b/ui/desktop/src/components/GitBranchIndicator.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { GitBranch, Check, Search } from 'lucide-react'; +import { toastError } from '../toasts'; +import { cn } from '../utils'; +import { defineMessages, useIntl } from '../i18n'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; + +const i18n = defineMessages({ + noBranchesFound: { + id: 'gitBranchIndicator.noBranchesFound', + defaultMessage: 'No branches found', + }, + searchBranches: { + id: 'gitBranchIndicator.searchBranches', + defaultMessage: 'Search branches…', + }, + failedToSwitch: { + id: 'gitBranchIndicator.failedToSwitch', + defaultMessage: 'Failed to switch to {branch}', + }, + uncommittedChanges: { + id: 'gitBranchIndicator.uncommittedChanges', + defaultMessage: 'You may have uncommitted changes.', + }, +}); + +export const GitBranchIndicator: React.FC<{ dir: string; className?: string }> = ({ + dir, + className, +}) => { + const [branch, setBranch] = useState(null); + const [open, setOpen] = useState(false); + const [branches, setBranches] = useState([]); + const [search, setSearch] = useState(''); + const [switching, setSwitching] = useState(false); + const intl = useIntl(); + const searchRef = useRef(null); + + useEffect(() => { + if (!dir) return; + let cancelled = false; + const refresh = () => { + window.electron + .getGitBranchInfo(dir) + .then((info) => { + if (!cancelled) setBranch(info?.branch ?? null); + }) + .catch(() => {}); + }; + refresh(); + window.addEventListener('focus', refresh); + return () => { + cancelled = true; + window.removeEventListener('focus', refresh); + }; + }, [dir]); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setBranches([]); + setSearch(''); + Promise.all([window.electron.getGitBranchInfo(dir), window.electron.listGitBranches(dir)]) + .then(([info, list]) => { + if (!cancelled) { + setBranch(info?.branch ?? null); + setBranches(list); + } + }) + .catch(() => {}); + setTimeout(() => searchRef.current?.focus(), 50); + return () => { + cancelled = true; + }; + }, [open, dir]); + + if (!branch) return null; + + const filtered = branches.filter((b) => b.toLowerCase().includes(search.toLowerCase())); + + const handleSwitch = async (target: string) => { + if (target === branch || switching) return; + setSwitching(true); + setOpen(false); + const result = await window.electron + .switchGitBranch(dir, target) + .catch((err: Error) => ({ success: false, error: err.message })); + if (result.success) { + setBranch(target); + } else { + toastError({ + title: intl.formatMessage(i18n.failedToSwitch, { branch: target }), + msg: result.error ?? intl.formatMessage(i18n.uncommittedChanges), + }); + } + setSwitching(false); + }; + + return ( + + + + + +
+ {filtered.length === 0 && ( +
+ {intl.formatMessage(i18n.noBranchesFound)} +
+ )} + {filtered.map((b) => ( + void handleSwitch(b)}> + {b} + {b === branch && } + + ))} +
+ +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key.length === 1) e.stopPropagation(); + }} + placeholder={intl.formatMessage(i18n.searchBranches)} + aria-label={intl.formatMessage(i18n.searchBranches)} + className="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-primary/50 outline-none" + /> +
+
+
+ ); +}; diff --git a/ui/desktop/src/i18n/messages/de.json b/ui/desktop/src/i18n/messages/de.json index f3f031427..b4ed93b84 100644 --- a/ui/desktop/src/i18n/messages/de.json +++ b/ui/desktop/src/i18n/messages/de.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Zuletzt verwendete Verzeichnisse" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Wechsel zu {branch} fehlgeschlagen" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Keine Branches gefunden" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Branches durchsuchen…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Möglicherweise gibt es noch nicht committete Änderungen." + }, "elicitationRequest.accept": { "defaultMessage": "Akzeptieren" }, diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index 8440e32a9..0c2ddf79a 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -1259,6 +1259,18 @@ "externalBackendSection.workingDirPlaceholder": { "defaultMessage": "/home/goose/workspace" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Failed to switch to {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "No branches found" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Search branches…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "You may have uncommitted changes." + }, "goosehintsModal.close": { "defaultMessage": "Close" }, diff --git a/ui/desktop/src/i18n/messages/es.json b/ui/desktop/src/i18n/messages/es.json index 07d3a635a..7b274e551 100644 --- a/ui/desktop/src/i18n/messages/es.json +++ b/ui/desktop/src/i18n/messages/es.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Directorios recientes" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "No se pudo cambiar a {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "No se encontraron ramas" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Buscar ramas…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Puede que tengas cambios sin confirmar." + }, "elicitationRequest.accept": { "defaultMessage": "Aceptar" }, diff --git a/ui/desktop/src/i18n/messages/fr.json b/ui/desktop/src/i18n/messages/fr.json index 7d72de7f4..e65755c88 100644 --- a/ui/desktop/src/i18n/messages/fr.json +++ b/ui/desktop/src/i18n/messages/fr.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Répertoires récents" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Impossible de basculer vers {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Aucune branche trouvée" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Rechercher des branches…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Vous avez peut-être des modifications non validées." + }, "elicitationRequest.accept": { "defaultMessage": "Accepter" }, diff --git a/ui/desktop/src/i18n/messages/hi.json b/ui/desktop/src/i18n/messages/hi.json index ebc3fa608..674606433 100644 --- a/ui/desktop/src/i18n/messages/hi.json +++ b/ui/desktop/src/i18n/messages/hi.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "हाल की निर्देशिकाएँ" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "{branch} पर स्विच नहीं किया जा सका" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "कोई ब्रांच नहीं मिली" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "ब्रांच खोजें…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "आपके कुछ बदलाव कमिट नहीं हुए हो सकते हैं।" + }, "elicitationRequest.accept": { "defaultMessage": "स्वीकार करो" }, diff --git a/ui/desktop/src/i18n/messages/id.json b/ui/desktop/src/i18n/messages/id.json index 84e84a542..c0dba490b 100644 --- a/ui/desktop/src/i18n/messages/id.json +++ b/ui/desktop/src/i18n/messages/id.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Direktori terbaru" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Gagal beralih ke {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Tidak ada branch yang ditemukan" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Cari branch…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Anda mungkin memiliki perubahan yang belum di-commit." + }, "elicitationRequest.accept": { "defaultMessage": "Terima" }, diff --git a/ui/desktop/src/i18n/messages/it.json b/ui/desktop/src/i18n/messages/it.json index c6a8fa86a..b0d25fef3 100644 --- a/ui/desktop/src/i18n/messages/it.json +++ b/ui/desktop/src/i18n/messages/it.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Cartelle recenti" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Impossibile passare a {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Nessun branch trovato" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Cerca branch…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Potresti avere modifiche non incluse in un commit." + }, "elicitationRequest.accept": { "defaultMessage": "Accetta" }, diff --git a/ui/desktop/src/i18n/messages/ja.json b/ui/desktop/src/i18n/messages/ja.json index aa9a467fd..a0973b0bb 100644 --- a/ui/desktop/src/i18n/messages/ja.json +++ b/ui/desktop/src/i18n/messages/ja.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "最近のディレクトリ" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "{branch} への切り替えに失敗しました" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "ブランチが見つかりません" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "ブランチを検索…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "コミットされていない変更がある可能性があります。" + }, "elicitationRequest.accept": { "defaultMessage": "承諾" }, diff --git a/ui/desktop/src/i18n/messages/ko.json b/ui/desktop/src/i18n/messages/ko.json index 94135c817..53a07e342 100644 --- a/ui/desktop/src/i18n/messages/ko.json +++ b/ui/desktop/src/i18n/messages/ko.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "최근 디렉터리" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "{branch} 브랜치로 전환하지 못했습니다" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "브랜치를 찾을 수 없습니다" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "브랜치 검색…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "커밋되지 않은 변경 사항이 있을 수 있습니다." + }, "elicitationRequest.accept": { "defaultMessage": "수락" }, diff --git a/ui/desktop/src/i18n/messages/ms.json b/ui/desktop/src/i18n/messages/ms.json index 386271cd3..e2ed52864 100644 --- a/ui/desktop/src/i18n/messages/ms.json +++ b/ui/desktop/src/i18n/messages/ms.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Direktori terkini" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Gagal beralih ke {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Tiada cabang ditemui" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Cari cabang…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Anda mungkin mempunyai perubahan yang belum dikomit." + }, "elicitationRequest.accept": { "defaultMessage": "Terima" }, diff --git a/ui/desktop/src/i18n/messages/pt.json b/ui/desktop/src/i18n/messages/pt.json index afb03f900..f772f54a2 100644 --- a/ui/desktop/src/i18n/messages/pt.json +++ b/ui/desktop/src/i18n/messages/pt.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Diretórios recentes" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Falha ao mudar para {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Nenhum ramo encontrado" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Pesquisar ramos…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Pode haver alterações sem commit." + }, "elicitationRequest.accept": { "defaultMessage": "Aceitar" }, diff --git a/ui/desktop/src/i18n/messages/ru.json b/ui/desktop/src/i18n/messages/ru.json index 0960bd1c4..48080862b 100644 --- a/ui/desktop/src/i18n/messages/ru.json +++ b/ui/desktop/src/i18n/messages/ru.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Недавние каталоги" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Не удалось переключиться на {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Ветки не найдены" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Поиск веток…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Возможно, у вас есть незакоммиченные изменения." + }, "elicitationRequest.accept": { "defaultMessage": "Принять" }, diff --git a/ui/desktop/src/i18n/messages/tr.json b/ui/desktop/src/i18n/messages/tr.json index a9c151f05..0aaa9f832 100644 --- a/ui/desktop/src/i18n/messages/tr.json +++ b/ui/desktop/src/i18n/messages/tr.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Son dizinler" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "{branch} dalına geçilemedi" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Dal bulunamadı" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Dal ara…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Commit edilmemiş değişiklikleriniz olabilir." + }, "elicitationRequest.accept": { "defaultMessage": "Kabul et" }, diff --git a/ui/desktop/src/i18n/messages/vi.json b/ui/desktop/src/i18n/messages/vi.json index 9664bab0f..bc7fb1445 100644 --- a/ui/desktop/src/i18n/messages/vi.json +++ b/ui/desktop/src/i18n/messages/vi.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "Thư mục gần đây" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "Không thể chuyển sang {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "Không tìm thấy nhánh" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "Tìm kiếm nhánh…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "Bạn có thể có các thay đổi chưa commit." + }, "elicitationRequest.accept": { "defaultMessage": "Chấp nhận" }, diff --git a/ui/desktop/src/i18n/messages/zh-CN.json b/ui/desktop/src/i18n/messages/zh-CN.json index 90c620168..28cceb80c 100644 --- a/ui/desktop/src/i18n/messages/zh-CN.json +++ b/ui/desktop/src/i18n/messages/zh-CN.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "最近的目录" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "无法切换到 {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "未找到分支" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "搜索分支…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "你可能有尚未提交的更改。" + }, "elicitationRequest.accept": { "defaultMessage": "接受" }, diff --git a/ui/desktop/src/i18n/messages/zh-TW.json b/ui/desktop/src/i18n/messages/zh-TW.json index e1fe91ff3..62b8a9c6f 100644 --- a/ui/desktop/src/i18n/messages/zh-TW.json +++ b/ui/desktop/src/i18n/messages/zh-TW.json @@ -932,6 +932,18 @@ "dirSwitcher.recentDirectories": { "defaultMessage": "最近的目錄" }, + "gitBranchIndicator.failedToSwitch": { + "defaultMessage": "無法切換至 {branch}" + }, + "gitBranchIndicator.noBranchesFound": { + "defaultMessage": "找不到分支" + }, + "gitBranchIndicator.searchBranches": { + "defaultMessage": "搜尋分支…" + }, + "gitBranchIndicator.uncommittedChanges": { + "defaultMessage": "你可能有尚未提交的變更。" + }, "elicitationRequest.accept": { "defaultMessage": "接受" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 60a5fe3a3..ac622eaf6 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -51,6 +51,7 @@ import { updateTrayMenu, } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; +import './utils/gitBranchIpc'; import './utils/recipeHash'; import type { GooseApp } from './types/apps'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 0d33f9597..50c442c9a 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -180,6 +180,9 @@ type ElectronAPI = { addRecentDir: (dir: string) => Promise; listRecentDirs: () => Promise; listGitWorktreeDirs: (dir: string) => Promise; + getGitBranchInfo: (dir: string) => Promise<{ branch: string } | null>; + listGitBranches: (dir: string) => Promise; + switchGitBranch: (dir: string, branch: string) => Promise<{ success: boolean; error?: string }>; }; type AppConfigAPI = { @@ -339,6 +342,10 @@ const electronAPI: ElectronAPI = { addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), listRecentDirs: () => ipcRenderer.invoke('list-recent-dirs'), listGitWorktreeDirs: (dir: string) => ipcRenderer.invoke('list-git-worktree-dirs', dir), + getGitBranchInfo: (dir: string) => ipcRenderer.invoke('get-git-branch-info', dir), + listGitBranches: (dir: string) => ipcRenderer.invoke('list-git-branches', dir), + switchGitBranch: (dir: string, branch: string) => + ipcRenderer.invoke('switch-git-branch', dir, branch), }; function getAppLocale(): unknown { diff --git a/ui/desktop/src/utils/gitBranchIpc.ts b/ui/desktop/src/utils/gitBranchIpc.ts new file mode 100644 index 000000000..373db426e --- /dev/null +++ b/ui/desktop/src/utils/gitBranchIpc.ts @@ -0,0 +1,72 @@ +import { execFile } from 'child_process'; +import { ipcMain } from 'electron'; + +const gitArgs = (dir: string, args: string[]) => [ + '-c', + 'safe.bareRepository=explicit', + '-c', + 'core.fsmonitor=false', + '-C', + dir, + ...args, +]; + +const git = (dir: string, args: string[], timeout = 3000) => + new Promise((resolve, reject) => { + execFile('git', gitArgs(dir, args), { timeout }, (error, stdout) => { + if (error) reject(error); + else resolve(stdout.trim()); + }); + }); + +const getCurrentBranch = async (dir: string) => { + try { + const ref = await git(dir, ['symbolic-ref', 'HEAD']); + return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref; + } catch { + return git(dir, ['rev-parse', '--short', 'HEAD']).catch(() => null); + } +}; + +ipcMain.handle( + 'get-git-branch-info', + async (_event, dir: string): Promise<{ branch: string } | null> => { + if (!dir?.trim()) return null; + + const branch = await getCurrentBranch(dir); + return branch ? { branch } : null; + } +); + +ipcMain.handle('list-git-branches', async (_event, dir: string): Promise => { + if (!dir?.trim()) return []; + + try { + const branches = await git(dir, [ + 'for-each-ref', + 'refs/heads/', + '--format=%(refname:lstrip=2)', + ]); + return branches.split('\n').filter(Boolean); + } catch { + return []; + } +}); + +ipcMain.handle( + 'switch-git-branch', + async (_event, dir: string, branch: string): Promise<{ success: boolean; error?: string }> => { + if (!dir?.trim() || !branch?.trim()) return { success: false }; + + try { + await git(dir, ['checkout', branch], 30000); + return { success: true }; + } catch (error) { + const currentBranch = await getCurrentBranch(dir); + if (currentBranch === branch) return { success: true }; + + const gitError = error as Error & { stderr?: string }; + return { success: false, error: gitError.stderr?.toString() || gitError.message }; + } + } +);