mirror of
https://github.com/aaif-goose/goose.git
synced 2026-06-02 06:14:27 +02:00
[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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user