feat(desktop): add interactive git branch indicator to chat bottom bar (#11290)

Signed-off-by: Abhijay Jain <Abhijay007j@gmail.com>
Signed-off-by: Douwe Osinga <douwe.osinga@gmail.com>
Co-authored-by: Douwe Osinga <douwe.osinga@gmail.com>
This commit is contained in:
Abhijay Jain
2026-08-18 17:18:59 +00:00
committed by GitHub
parent 935a37a684
commit c962612bc4
21 changed files with 429 additions and 0 deletions
+5
View File
@@ -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 && (
<GitBranchIndicator dir={currentWorkingDir} className="ml-1" />
)}
{/* Spacer */}
<div className="flex-1" />
@@ -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<string | null>(null);
const [open, setOpen] = useState(false);
const [branches, setBranches] = useState<string[]>([]);
const [search, setSearch] = useState('');
const [switching, setSwitching] = useState(false);
const intl = useIntl();
const searchRef = useRef<HTMLInputElement>(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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
className={cn(
'text-text-primary/70 text-xs flex items-center transition-colors',
switching ? 'opacity-50' : 'hover:cursor-pointer hover:text-text-primary',
className
)}
disabled={switching}
>
<GitBranch className="mr-1" size={14} />
<span className="max-w-[100px] truncate whitespace-nowrap">{branch}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-64 p-0 overflow-hidden">
<div className="overflow-y-auto p-1 max-h-52 space-y-0.5">
{filtered.length === 0 && (
<div className="px-2 py-1.5 text-sm text-text-primary/50">
{intl.formatMessage(i18n.noBranchesFound)}
</div>
)}
{filtered.map((b) => (
<DropdownMenuItem key={b} onSelect={() => void handleSwitch(b)}>
<span className="flex-1 truncate">{b}</span>
{b === branch && <Check className="ml-auto h-4 w-4 flex-shrink-0" />}
</DropdownMenuItem>
))}
</div>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 flex items-center gap-1.5">
<Search className="h-3.5 w-3.5 text-text-muted flex-shrink-0" />
<input
ref={searchRef}
value={search}
onChange={(e) => 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"
/>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
};
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "हाल की निर्देशिकाएँ"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "{branch} पर स्विच नहीं किया जा सका"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "कोई ब्रांच नहीं मिली"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "ब्रांच खोजें…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "आपके कुछ बदलाव कमिट नहीं हुए हो सकते हैं।"
},
"elicitationRequest.accept": {
"defaultMessage": "स्वीकार करो"
},
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "最近のディレクトリ"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "{branch} への切り替えに失敗しました"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "ブランチが見つかりません"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "ブランチを検索…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "コミットされていない変更がある可能性があります。"
},
"elicitationRequest.accept": {
"defaultMessage": "承諾"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "최근 디렉터리"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "{branch} 브랜치로 전환하지 못했습니다"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "브랜치를 찾을 수 없습니다"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "브랜치 검색…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "커밋되지 않은 변경 사항이 있을 수 있습니다."
},
"elicitationRequest.accept": {
"defaultMessage": "수락"
},
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "Недавние каталоги"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "Не удалось переключиться на {branch}"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "Ветки не найдены"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "Поиск веток…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "Возможно, у вас есть незакоммиченные изменения."
},
"elicitationRequest.accept": {
"defaultMessage": "Принять"
},
+12
View File
@@ -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"
},
+12
View File
@@ -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"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "最近的目录"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "无法切换到 {branch}"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "未找到分支"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "搜索分支…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "你可能有尚未提交的更改。"
},
"elicitationRequest.accept": {
"defaultMessage": "接受"
},
+12
View File
@@ -932,6 +932,18 @@
"dirSwitcher.recentDirectories": {
"defaultMessage": "最近的目錄"
},
"gitBranchIndicator.failedToSwitch": {
"defaultMessage": "無法切換至 {branch}"
},
"gitBranchIndicator.noBranchesFound": {
"defaultMessage": "找不到分支"
},
"gitBranchIndicator.searchBranches": {
"defaultMessage": "搜尋分支…"
},
"gitBranchIndicator.uncommittedChanges": {
"defaultMessage": "你可能有尚未提交的變更。"
},
"elicitationRequest.accept": {
"defaultMessage": "接受"
},
+1
View File
@@ -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';
+7
View File
@@ -180,6 +180,9 @@ type ElectronAPI = {
addRecentDir: (dir: string) => Promise<boolean>;
listRecentDirs: () => Promise<string[]>;
listGitWorktreeDirs: (dir: string) => Promise<string[]>;
getGitBranchInfo: (dir: string) => Promise<{ branch: string } | null>;
listGitBranches: (dir: string) => Promise<string[]>;
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 {
+72
View File
@@ -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<string>((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<string[]> => {
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 };
}
}
);