[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 { FolderDot } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, FolderDot, FolderOpen, GitBranch, Plus } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { updateWorkingDir } from '../../api';
import { toast } from 'react-toastify';
import { defineMessages, useIntl } from '../../i18n';
@@ -10,6 +18,30 @@ const i18n = defineMessages({
id: 'dirSwitcher.failedToUpdateWorkingDir',
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 {
@@ -32,25 +64,38 @@ export const DirSwitcher: React.FC<DirSwitcherProps> = ({
const intl = useIntl();
const [isTooltipOpen, setIsTooltipOpen] = 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 () => {
if (isDirectoryChooserOpen) return;
setIsDirectoryChooserOpen(true);
const refreshMenuData = useCallback(async () => {
const version = ++refreshVersionRef.current;
setRecentDirs([]);
setWorktreeDirs([]);
let result;
try {
result = await window.electron.directoryChooser();
} finally {
setIsDirectoryChooserOpen(false);
}
const [recent, worktrees] = await Promise.all([
window.electron.listRecentDirs().catch(() => []),
window.electron.listGitWorktreeDirs(workingDir).catch(() => []),
]);
if (result.canceled || result.filePaths.length === 0) {
if (version !== refreshVersionRef.current) return;
setRecentDirs(recent);
setWorktreeDirs(worktrees);
}, [workingDir]);
useEffect(() => {
if (!isMenuOpen) {
return;
}
const newDir = result.filePaths[0];
void refreshMenuData();
}, [isMenuOpen, refreshMenuData]);
const applyDirectoryChange = async (newDir: string) => {
window.electron.addRecentDir(newDir);
setRecentDirs((previous) => [newDir, ...previous.filter((dir) => dir !== newDir)].slice(0, 10));
if (sessionId) {
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) => {
if (isDirectoryChooserOpen) {
event.preventDefault();
event.stopPropagation();
return;
}
const isCmdOrCtrlClick = event.metaKey || event.ctrlKey;
if (isCmdOrCtrlClick) {
event.preventDefault();
event.stopPropagation();
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 (
<TooltipProvider>
<Tooltip
open={isTooltipOpen && !isDirectoryChooserOpen}
open={isTooltipOpen && !isDirectoryChooserOpen && !isMenuOpen}
onOpenChange={(open) => {
if (!isDirectoryChooserOpen) setIsTooltipOpen(open);
if (!isDirectoryChooserOpen && !isMenuOpen) setIsTooltipOpen(open);
}}
>
<TooltipTrigger asChild>
<button
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}
disabled={isDirectoryChooserOpen}
>
<FolderDot className="mr-1" size={16} />
<div className="max-w-[200px] truncate [direction:rtl]">{workingDir}</div>
</button>
</TooltipTrigger>
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
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}
disabled={isDirectoryChooserOpen}
>
<FolderDot className="mr-1" size={16} />
<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>
</Tooltip>
</TooltipProvider>
+18
View File
@@ -935,9 +935,27 @@
"dictationSettings.voiceDictationProvider": {
"defaultMessage": "Voice Dictation Provider"
},
"dirSwitcher.chooseDirectory": {
"defaultMessage": "Choose directory…"
},
"dirSwitcher.currentDirectory": {
"defaultMessage": "Current directory"
},
"dirSwitcher.failedToUpdateWorkingDir": {
"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": {
"defaultMessage": "Accept"
},
+39 -1
View File
@@ -23,7 +23,7 @@ import fsSync from 'node:fs';
import started from 'electron-squirrel-startup';
import path from 'node:path';
import os from 'node:os';
import { execFileSync, spawn } from 'child_process';
import { execFileSync, spawn, execFile } from 'child_process';
import 'dotenv/config';
import { checkServerStatus } 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));
}
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() {
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_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) => {
const settings = getSettings();
return settings[key];
+4
View File
@@ -184,6 +184,8 @@ type ElectronAPI = {
refreshApp: (app: GooseApp) => Promise<void>;
closeApp: (appName: string) => Promise<void>;
addRecentDir: (dir: string) => Promise<boolean>;
listRecentDirs: () => Promise<string[]>;
listGitWorktreeDirs: (dir: string) => Promise<string[]>;
};
type AppConfigAPI = {
@@ -338,6 +340,8 @@ const electronAPI: ElectronAPI = {
refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app),
closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName),
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 = {