[RFC] Add worktree-aware directory switcher (#8450)

Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: goose <goose@aaif.dev>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Vincenzo Palazzo
2026-05-12 20:36:39 +02:00
committed by GitHub
parent 8905482318
commit 2c8d7f13bf
4 changed files with 232 additions and 28 deletions
@@ -1,6 +1,14 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FolderDot } from 'lucide-react'; import { Check, FolderDot, FolderOpen, GitBranch, Plus } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { updateWorkingDir } from '../../api'; import { updateWorkingDir } from '../../api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { defineMessages, useIntl } from '../../i18n'; import { defineMessages, useIntl } from '../../i18n';
@@ -10,6 +18,30 @@ const i18n = defineMessages({
id: 'dirSwitcher.failedToUpdateWorkingDir', id: 'dirSwitcher.failedToUpdateWorkingDir',
defaultMessage: 'Failed to update working directory', defaultMessage: 'Failed to update working directory',
}, },
currentDirectory: {
id: 'dirSwitcher.currentDirectory',
defaultMessage: 'Current directory',
},
gitWorktrees: {
id: 'dirSwitcher.gitWorktrees',
defaultMessage: 'Git worktrees',
},
recentDirectories: {
id: 'dirSwitcher.recentDirectories',
defaultMessage: 'Recent directories',
},
chooseDirectory: {
id: 'dirSwitcher.chooseDirectory',
defaultMessage: 'Choose directory…',
},
openInFinder: {
id: 'dirSwitcher.openInFinder',
defaultMessage: 'Open in file manager',
},
noWorktreesFound: {
id: 'dirSwitcher.noWorktreesFound',
defaultMessage: 'No worktrees found',
},
}); });
interface DirSwitcherProps { interface DirSwitcherProps {
@@ -32,25 +64,38 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
const intl = useIntl(); const intl = useIntl();
const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [recentDirs, setRecentDirs] = useState<string[]>([]);
const [worktreeDirs, setWorktreeDirs] = useState<string[]>([]);
const refreshVersionRef = useRef(0);
const handleDirectoryChange = async () => { const refreshMenuData = useCallback(async () => {
if (isDirectoryChooserOpen) return; const version = ++refreshVersionRef.current;
setIsDirectoryChooserOpen(true); setRecentDirs([]);
setWorktreeDirs([]);
let result; const [recent, worktrees] = await Promise.all([
try { window.electron.listRecentDirs().catch(() => []),
result = await window.electron.directoryChooser(); window.electron.listGitWorktreeDirs(workingDir).catch(() => []),
} finally { ]);
setIsDirectoryChooserOpen(false);
}
if (result.canceled || result.filePaths.length === 0) { if (version !== refreshVersionRef.current) return;
setRecentDirs(recent);
setWorktreeDirs(worktrees);
}, [workingDir]);
useEffect(() => {
if (!isMenuOpen) {
return; return;
} }
const newDir = result.filePaths[0]; void refreshMenuData();
}, [isMenuOpen, refreshMenuData]);
const applyDirectoryChange = async (newDir: string) => {
window.electron.addRecentDir(newDir); window.electron.addRecentDir(newDir);
setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10));
if (sessionId) { if (sessionId) {
onWorkingDirChange?.(newDir); onWorkingDirChange?.(newDir);
@@ -71,41 +116,140 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
} }
}; };
const handleDirectoryChange = async () => {
if (isDirectoryChooserOpen) return;
setIsDirectoryChooserOpen(true);
let result;
try {
result = await window.electron.directoryChooser();
} finally {
setIsDirectoryChooserOpen(false);
}
if (result.canceled || result.filePaths.length === 0) {
return;
}
const newDir = result.filePaths[0];
await applyDirectoryChange(newDir);
};
const handleSelectDirectory = async (newDir: string) => {
if (newDir === workingDir) {
setIsMenuOpen(false);
return;
}
setIsMenuOpen(false);
await applyDirectoryChange(newDir);
};
const handleDirectoryClick = async (event: React.MouseEvent) => { const handleDirectoryClick = async (event: React.MouseEvent) => {
if (isDirectoryChooserOpen) { if (isDirectoryChooserOpen) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
return; return;
} }
const isCmdOrCtrlClick = event.metaKey || event.ctrlKey; const isCmdOrCtrlClick = event.metaKey || event.ctrlKey;
if (isCmdOrCtrlClick) { if (isCmdOrCtrlClick) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
await window.electron.openDirectoryInExplorer(workingDir); await window.electron.openDirectoryInExplorer(workingDir);
} else {
await handleDirectoryChange();
} }
}; };
const filteredWorktreeDirs = useMemo(
() => worktreeDirs.filter((dir) => dir && dir !== workingDir),
[worktreeDirs, workingDir]
);
const filteredRecentDirs = useMemo(
() => recentDirs.filter((dir) => dir && dir !== workingDir),
[recentDirs, workingDir]
);
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip <Tooltip
open={isTooltipOpen && !isDirectoryChooserOpen} open={isTooltipOpen && !isDirectoryChooserOpen && !isMenuOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!isDirectoryChooserOpen) setIsTooltipOpen(open); if (!isDirectoryChooserOpen && !isMenuOpen) setIsTooltipOpen(open);
}} }}
> >
<TooltipTrigger asChild> <DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<button <TooltipTrigger asChild>
className={`z-[100] ${isDirectoryChooserOpen ? 'opacity-50' : 'hover:cursor-pointer hover:text-text-primary'} text-text-primary/70 text-xs flex items-center transition-colors pl-1 [&>svg]:size-4 ${className}`} <DropdownMenuTrigger asChild>
onClick={handleDirectoryClick} <button
disabled={isDirectoryChooserOpen} className={`z-[100] ${isDirectoryChooserOpen ? 'opacity-50' : 'hover:cursor-pointer hover:text-text-primary'} text-text-primary/70 text-xs flex items-center transition-colors pl-1 [&>svg]:size-4 ${className}`}
> onClick={handleDirectoryClick}
<FolderDot className="mr-1" size={16} /> disabled={isDirectoryChooserOpen}
<div className="max-w-[200px] truncate [direction:rtl]">{workingDir}</div> >
</button> <FolderDot className="mr-1" size={16} />
</TooltipTrigger> <div className="max-w-[200px] truncate [direction:rtl]">{workingDir}</div>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="w-80" side="top" align="start">
<DropdownMenuLabel>{intl.formatMessage(i18n.currentDirectory)}</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() => void window.electron.openDirectoryInExplorer(workingDir)}
>
<FolderOpen className="mr-2 h-4 w-4" />
<span className="truncate">{workingDir}</span>
<Check className="ml-auto h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{intl.formatMessage(i18n.gitWorktrees)}</DropdownMenuLabel>
{filteredWorktreeDirs.length > 0 ? (
filteredWorktreeDirs.map((dir) => (
<DropdownMenuItem
key={`worktree-${dir}`}
onSelect={() => void handleSelectDirectory(dir)}
>
<GitBranch className="mr-2 h-4 w-4" />
<span className="truncate">{dir}</span>
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>
<GitBranch className="mr-2 h-4 w-4" />
<span>{intl.formatMessage(i18n.noWorktreesFound)}</span>
</DropdownMenuItem>
)}
{filteredRecentDirs.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{intl.formatMessage(i18n.recentDirectories)}</DropdownMenuLabel>
{filteredRecentDirs.map((dir) => (
<DropdownMenuItem
key={`recent-${dir}`}
onSelect={() => void handleSelectDirectory(dir)}
>
<FolderDot className="mr-2 h-4 w-4" />
<span className="truncate">{dir}</span>
</DropdownMenuItem>
))}
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDirectoryChange()}>
<Plus className="mr-2 h-4 w-4" />
<span>{intl.formatMessage(i18n.chooseDirectory)}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => void window.electron.openDirectoryInExplorer(workingDir)}
>
<FolderOpen className="mr-2 h-4 w-4" />
<span>{intl.formatMessage(i18n.openInFinder)}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="top">{workingDir}</TooltipContent> <TooltipContent side="top">{workingDir}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
+18
View File
@@ -935,9 +935,27 @@
"dictationSettings.voiceDictationProvider": { "dictationSettings.voiceDictationProvider": {
"defaultMessage": "Voice Dictation Provider" "defaultMessage": "Voice Dictation Provider"
}, },
"dirSwitcher.chooseDirectory": {
"defaultMessage": "Choose directory…"
},
"dirSwitcher.currentDirectory": {
"defaultMessage": "Current directory"
},
"dirSwitcher.failedToUpdateWorkingDir": { "dirSwitcher.failedToUpdateWorkingDir": {
"defaultMessage": "Failed to update working directory" "defaultMessage": "Failed to update working directory"
}, },
"dirSwitcher.gitWorktrees": {
"defaultMessage": "Git worktrees"
},
"dirSwitcher.noWorktreesFound": {
"defaultMessage": "No worktrees found"
},
"dirSwitcher.openInFinder": {
"defaultMessage": "Open in file manager"
},
"dirSwitcher.recentDirectories": {
"defaultMessage": "Recent directories"
},
"elicitationRequest.accept": { "elicitationRequest.accept": {
"defaultMessage": "Accept" "defaultMessage": "Accept"
}, },
+39 -1
View File
@@ -23,7 +23,7 @@ import fsSync from 'node:fs';
import started from 'electron-squirrel-startup'; import started from 'electron-squirrel-startup';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { execFileSync, spawn } from 'child_process'; import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config'; import 'dotenv/config';
import { checkServerStatus } from './goosed'; import { checkServerStatus } from './goosed';
import { startGoosed } from './goosed'; import { startGoosed } from './goosed';
@@ -99,6 +99,36 @@ function updateSettings(modifier: (settings: Settings) => void): void {
fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); fsSync.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2));
} }
function listGitWorktreeDirs(dir: string): Promise<string[]> {
return new Promise((resolve) => {
if (!dir?.trim()) {
resolve([]);
return;
}
execFile(
'git',
['-C', dir, 'worktree', 'list', '--porcelain'],
{ timeout: 3000 },
(error, stdout) => {
if (error) {
resolve([]);
return;
}
const dirs = stdout
.split('\n')
.filter((line) => line.startsWith('worktree '))
.map((line) => line.slice('worktree '.length).trim())
.filter(Boolean)
.filter((worktreeDir, index, allDirs) => allDirs.indexOf(worktreeDir) === index);
resolve(dirs);
}
);
});
}
async function configureProxy() { async function configureProxy() {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy; const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy; const httpProxy = process.env.HTTP_PROXY || process.env.http_proxy;
@@ -1375,6 +1405,14 @@ ipcMain.handle('add-recent-dir', (_event, dir: string) => {
} }
}); });
ipcMain.handle('list-recent-dirs', () => {
return loadRecentDirs();
});
ipcMain.handle('list-git-worktree-dirs', async (_event, dir: string) => {
return await listGitWorktreeDirs(dir);
});
ipcMain.handle('get-setting', (_event, key: SettingKey) => { ipcMain.handle('get-setting', (_event, key: SettingKey) => {
const settings = getSettings(); const settings = getSettings();
return settings[key]; return settings[key];
+4
View File
@@ -184,6 +184,8 @@ type ElectronAPI = {
refreshApp: (app: GooseApp) => Promise<void>; refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>; closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>; addRecentDir: (dir: string) => Promise<boolean>;
listRecentDirs: () => Promise<string[]>;
listGitWorktreeDirs: (dir: string) => Promise<string[]>;
}; };
type AppConfigAPI = { type AppConfigAPI = {
@@ -338,6 +340,8 @@ const electronAPI: ElectronAPI = {
refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app), refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app),
closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName), closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName),
addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir), 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),
}; };
const appConfigAPI: AppConfigAPI = { const appConfigAPI: AppConfigAPI = {