Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 171 additions & 27 deletions ui/desktop/src/components/bottom_menu/DirSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -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);
Comment thread
vincenzopalazzo marked this conversation as resolved.
}, [workingDir]);

useEffect(() => {
if (!isMenuOpen) {
return;
}

const newDir = result.filePaths[0];
void refreshMenuData();
Comment on lines +88 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale worktree options when opening the switcher

worktreeDirs is rendered immediately from component state, but this effect only refreshes data asynchronously after the menu opens and does nothing while closed. If workingDir changes between openings, the previous repo’s worktree entries remain visible and clickable until refreshMenuData() finishes, so a fast click can switch the user to an unrelated directory. Reset or hide worktree options until the fetch for the current workingDir completes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset directory lists before opening the dropdown

The menu can render stale worktreeDirs/recentDirs for one frame when reopened because the reset/fetch is deferred to useEffect after isMenuOpen becomes true. If workingDir changed while closed (or an earlier fetch completed after close), users briefly see and can select entries from the previous repo before refreshMenuData() clears them, which can switch to the wrong directory. Clear/hide menu data synchronously in the onOpenChange open path (or invalidate on close) so stale items are never interactive.

Useful? React with 👍 / 👎.

}, [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);
Expand All @@ -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>
Expand Down
18 changes: 18 additions & 0 deletions ui/desktop/src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -917,9 +917,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"
},
Expand Down
40 changes: 39 additions & 1 deletion ui/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand Down
4 changes: 4 additions & 0 deletions ui/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down