diff --git a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx index 7fb4078d..13b1b7a0 100644 --- a/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx +++ b/ui/desktop/src/components/bottom_menu/DirSwitcher.tsx @@ -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 = ({ const intl = useIntl(); const [isTooltipOpen, setIsTooltipOpen] = useState(false); const [isDirectoryChooserOpen, setIsDirectoryChooserOpen] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [recentDirs, setRecentDirs] = useState([]); + const [worktreeDirs, setWorktreeDirs] = useState([]); + 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 = ({ } }; + 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 ( { - if (!isDirectoryChooserOpen) setIsTooltipOpen(open); + if (!isDirectoryChooserOpen && !isMenuOpen) setIsTooltipOpen(open); }} > - - - + + + + + + + + {intl.formatMessage(i18n.currentDirectory)} + void window.electron.openDirectoryInExplorer(workingDir)} + > + + {workingDir} + + + + + {intl.formatMessage(i18n.gitWorktrees)} + {filteredWorktreeDirs.length > 0 ? ( + filteredWorktreeDirs.map((dir) => ( + void handleSelectDirectory(dir)} + > + + {dir} + + )) + ) : ( + + + {intl.formatMessage(i18n.noWorktreesFound)} + + )} + + {filteredRecentDirs.length > 0 && ( + <> + + {intl.formatMessage(i18n.recentDirectories)} + {filteredRecentDirs.map((dir) => ( + void handleSelectDirectory(dir)} + > + + {dir} + + ))} + + )} + + + void handleDirectoryChange()}> + + {intl.formatMessage(i18n.chooseDirectory)} + + void window.electron.openDirectoryInExplorer(workingDir)} + > + + {intl.formatMessage(i18n.openInFinder)} + + + {workingDir} diff --git a/ui/desktop/src/i18n/messages/en.json b/ui/desktop/src/i18n/messages/en.json index c9d71838..bc49bfba 100644 --- a/ui/desktop/src/i18n/messages/en.json +++ b/ui/desktop/src/i18n/messages/en.json @@ -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" }, diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 9c7a99ca..8f9c80dd 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -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 { + 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]; diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 10e1ae0c..777cff7b 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -184,6 +184,8 @@ type ElectronAPI = { refreshApp: (app: GooseApp) => Promise; closeApp: (appName: string) => Promise; addRecentDir: (dir: string) => Promise; + listRecentDirs: () => Promise; + listGitWorktreeDirs: (dir: string) => Promise; }; 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 = {