[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 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>
|
||||||
|
|||||||
@@ -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
@@ -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];
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user