From 2fbfd803f797e76df9ea4615e46a2c780e4d31cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jinwoo=20An=20=28=EC=95=88=EC=A7=84=EC=9A=B0=29?= <119289033+An-jinu@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:24:58 +0900 Subject: [PATCH] feat: add desktop quick open workflow (#925) Introduce a dedicated Quick Open dialog, wire it to Cmd+P and the macOS app menu, and show file-type icons in quick-open results so file navigation matches the rest of the app. --- packages/desktop/src-tauri/src/main.rs | 15 ++ packages/ui/src/App.tsx | 2 + .../ui/src/components/ui/CommandPalette.tsx | 13 +- packages/ui/src/components/ui/HelpDialog.tsx | 7 + .../ui/src/components/ui/QuickOpenDialog.tsx | 255 ++++++++++++++++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 8 + packages/ui/src/hooks/useMenuActions.ts | 7 + packages/ui/src/lib/shortcuts.ts | 7 + packages/ui/src/stores/useUIStore.ts | 12 + 9 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/components/ui/QuickOpenDialog.tsx diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 5c22de11..80534a96 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -115,6 +115,8 @@ const MENU_ITEM_SETTINGS_ID: &str = "menu_settings"; #[cfg(target_os = "macos")] const MENU_ITEM_COMMAND_PALETTE_ID: &str = "menu_command_palette"; #[cfg(target_os = "macos")] +const MENU_ITEM_QUICK_OPEN_ID: &str = "menu_quick_open"; +#[cfg(target_os = "macos")] const MENU_ITEM_NEW_SESSION_ID: &str = "menu_new_session"; #[cfg(target_os = "macos")] const MENU_ITEM_WORKTREE_CREATOR_ID: &str = "menu_worktree_creator"; @@ -395,6 +397,14 @@ fn build_macos_menu( Some("Cmd+K"), )?; + let quick_open = MenuItem::with_id( + app, + MENU_ITEM_QUICK_OPEN_ID, + "Quick Open…", + true, + Some("Cmd+P"), + )?; + let new_window = MenuItem::with_id( app, MENU_ITEM_NEW_WINDOW_ID, @@ -582,6 +592,7 @@ fn build_macos_menu( &PredefinedMenuItem::separator(app)?, &settings, &command_palette, + &quick_open, &PredefinedMenuItem::separator(app)?, &PredefinedMenuItem::services(app, None)?, &PredefinedMenuItem::separator(app)?, @@ -3825,6 +3836,10 @@ fn main() { dispatch_menu_action(app, "command-palette"); return; } + if id == MENU_ITEM_QUICK_OPEN_ID { + dispatch_menu_action(app, "quick-open"); + return; + } if id == MENU_ITEM_NEW_SESSION_ID { dispatch_menu_action(app, "new-session"); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 93668033..a690a42e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -51,6 +51,7 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import type { RuntimeAPIs } from '@/lib/api/types'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog'; const AboutDialogWrapper: React.FC = () => { const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen); @@ -704,6 +705,7 @@ function App({ apis }: AppProps) { + {showMemoryDebug && ( setShowMemoryDebug(false)} /> diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index ec3ae82b..c4487773 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -14,7 +14,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDeviceInfo } from '@/lib/device'; -import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react'; +import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiFileLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, RiMoonLine, RiQuestionLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; @@ -24,6 +24,7 @@ export const CommandPalette: React.FC = () => { const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen); const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen); const setHelpDialogOpen = useUIStore((s) => s.setHelpDialogOpen); + const setQuickOpenOpen = useUIStore((s) => s.setQuickOpenOpen); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); const setSettingsPage = useUIStore((s) => s.setSettingsPage); @@ -70,6 +71,11 @@ export const CommandPalette: React.FC = () => { handleClose(); }; + const handleOpenQuickOpen = () => { + setQuickOpenOpen(true); + handleClose(); + }; + const handleCreateWorktreeSession = () => { handleClose(); createWorktreeSession(); @@ -183,6 +189,11 @@ export const CommandPalette: React.FC = () => { Open Session List {shortcut('toggle_sidebar')} + + + Quick Open + {shortcut('open_quick_open')} + New Session diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 11b53cfe..d0d6d803 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -14,6 +14,7 @@ import { RiBrainAi3Line, RiCloseCircleLine, RiCommandLine, + RiFileLine, RiGitBranchLine, RiLayoutLeftLine, RiLayoutRightLine, @@ -67,6 +68,12 @@ export const HelpDialog: React.FC = () => { icon: RiCommandLine, keys: '', }, + { + id: 'open_quick_open', + description: 'Quick open file', + icon: RiFileLine, + keys: '', + }, { id: 'open_help', description: "Show Keyboard Shortcuts (this dialog)", diff --git a/packages/ui/src/components/ui/QuickOpenDialog.tsx b/packages/ui/src/components/ui/QuickOpenDialog.tsx new file mode 100644 index 00000000..c04f5317 --- /dev/null +++ b/packages/ui/src/components/ui/QuickOpenDialog.tsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { toast } from '@/components/ui'; +import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard'; +import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; +import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; +import { useFileSearchStore } from '@/stores/useFileSearchStore'; +import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; +import { useUIStore } from '@/stores/useUIStore'; + +type RecentQuickOpenFile = { + path: string; + name: string; + relativePath: string; +}; + +const normalizePath = (value: string): string => { + if (!value) return ''; + + const raw = value.replace(/\\/g, '/'); + const hadUncPrefix = raw.startsWith('//'); + + let normalized = raw.replace(/\/+/g, '/'); + if (hadUncPrefix && !normalized.startsWith('//')) { + normalized = `/${normalized}`; + } + + const isUnixRoot = normalized === '/'; + const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized); + if (!isUnixRoot && !isWindowsDriveRoot) { + normalized = normalized.replace(/\/+$/, ''); + } + + return normalized; +}; + +const getRelativePath = (root: string, filePath: string): string => { + const normalizedRoot = normalizePath(root); + const normalizedPath = normalizePath(filePath); + + if (!normalizedRoot || !normalizedPath) { + return normalizedPath; + } + + if (normalizedPath === normalizedRoot) { + return normalizedPath.split('/').filter(Boolean).pop() || normalizedPath; + } + + if (normalizedPath.startsWith(`${normalizedRoot}/`)) { + return normalizedPath.slice(normalizedRoot.length + 1); + } + + return normalizedPath; +}; + +export const QuickOpenDialog: React.FC = () => { + const { files } = useRuntimeAPIs(); + const isQuickOpenOpen = useUIStore((state) => state.isQuickOpenOpen); + const setQuickOpenOpen = useUIStore((state) => state.setQuickOpenOpen); + const openContextFile = useUIStore((state) => state.openContextFile); + const effectiveDirectory = useEffectiveDirectory(); + const showHidden = useDirectoryShowHidden(); + const showGitignored = useFilesViewShowGitignored(); + const searchFiles = useFileSearchStore((state) => state.searchFiles); + const currentRoot = React.useMemo( + () => (effectiveDirectory ? normalizePath(effectiveDirectory) : undefined), + [effectiveDirectory], + ); + const [query, setQuery] = React.useState(''); + const debouncedQuery = useDebouncedValue(query, 200); + const [searchResults, setSearchResults] = React.useState([]); + const [isSearching, setIsSearching] = React.useState(false); + const rootTabs = useFilesViewTabsStore( + React.useCallback( + (state) => (currentRoot ? state.byRoot[currentRoot] : undefined), + [currentRoot], + ), + ); + + const recentFiles = React.useMemo(() => { + if (!currentRoot || !rootTabs) { + return [] as RecentQuickOpenFile[]; + } + + const orderedPaths = [ + rootTabs.selectedPath, + ...rootTabs.openPaths, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + + const seen = new Set(); + + return orderedPaths + .map((filePath) => normalizePath(filePath)) + .filter((filePath) => { + if (!filePath || seen.has(filePath)) { + return false; + } + + seen.add(filePath); + return true; + }) + .slice(0, 10) + .map((filePath) => { + const name = filePath.split('/').filter(Boolean).pop() || filePath; + return { + path: filePath, + name, + relativePath: getRelativePath(currentRoot, filePath), + } satisfies RecentQuickOpenFile; + }); + }, [currentRoot, rootTabs]); + + const trimmedQuery = debouncedQuery.trim(); + + React.useEffect(() => { + if (!isQuickOpenOpen) { + setQuery(''); + setSearchResults([]); + setIsSearching(false); + } + }, [isQuickOpenOpen]); + + React.useEffect(() => { + if (!currentRoot || trimmedQuery.length === 0) { + setSearchResults([]); + setIsSearching(false); + return; + } + + let cancelled = false; + setIsSearching(true); + + void searchFiles(currentRoot, trimmedQuery, 150, { + includeHidden: showHidden, + respectGitignore: !showGitignored, + type: 'file', + }) + .then((results) => { + if (cancelled) { + return; + } + + setSearchResults(results.map((file) => ({ + path: normalizePath(file.path), + name: file.name, + relativePath: file.relativePath, + }))); + }) + .catch(() => { + if (cancelled) { + return; + } + + setSearchResults([]); + }) + .finally(() => { + if (!cancelled) { + setIsSearching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [currentRoot, searchFiles, showGitignored, showHidden, trimmedQuery]); + + const handleSelectFile = React.useCallback(async (filePath: string) => { + if (!currentRoot) { + return; + } + + const openValidation = await validateContextFileOpen(files, filePath); + if (!openValidation.ok) { + toast.error(getContextFileOpenFailureMessage(openValidation.reason)); + return; + } + + openContextFile(currentRoot, filePath); + setQuickOpenOpen(false); + }, [currentRoot, files, openContextFile, setQuickOpenOpen]); + + const hasTypedQuery = query.trim().length > 0; + const visibleFiles = hasTypedQuery ? searchResults : recentFiles; + const emptyMessage = !currentRoot + ? 'Open a project to quick-open files' + : hasTypedQuery + ? (isSearching ? 'Searching files…' : 'No matching files') + : 'No matching recent files'; + + return ( + + + Quick Open + Type a file name or path + + + + + + {emptyMessage} + + {currentRoot && visibleFiles.length > 0 && ( + + {visibleFiles.map((file) => ( + { + void handleSelectFile(file.path); + }} + > + +
+ {file.name} + {file.relativePath} +
+
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 88ff4735..16f8e001 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -18,6 +18,7 @@ export const useKeyboardShortcuts = () => { const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const abortCurrentOperation = sessionActions.abortCurrentOperation;; const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const setQuickOpenOpen = useUIStore((s) => s.setQuickOpenOpen); const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar); @@ -61,6 +62,12 @@ export const useKeyboardShortcuts = () => { return; } + if (eventMatchesShortcut(e, combo('open_quick_open'))) { + e.preventDefault(); + setQuickOpenOpen(true); + return; + } + if (eventMatchesShortcut(e, combo('open_status'))) { e.preventDefault(); void showOpenCodeStatus(); @@ -414,6 +421,7 @@ export const useKeyboardShortcuts = () => { openNewSessionDraft, abortCurrentOperation, toggleCommandPalette, + setQuickOpenOpen, toggleHelpDialog, toggleSidebar, toggleRightSidebar, diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 1fed8542..948aa20b 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -29,6 +29,7 @@ type MenuAction = | 'about' | 'settings' | 'command-palette' + | 'quick-open' | 'new-session' | 'new-worktree-session' | 'change-workspace' @@ -50,6 +51,7 @@ export const useMenuActions = ( ) => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); + const setQuickOpenOpen = useUIStore((s) => s.setQuickOpenOpen); const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); const toggleSidebar = useUIStore((s) => s.toggleSidebar); const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); @@ -139,6 +141,10 @@ export const useMenuActions = ( toggleCommandPalette(); break; + case 'quick-open': + setQuickOpenOpen(true); + break; + case 'new-session': setActiveMainTab('chat'); setSessionSwitcherOpen(false); @@ -227,6 +233,7 @@ export const useMenuActions = ( setAboutDialogOpen, setActiveMainTab, setSessionSwitcherOpen, + setQuickOpenOpen, setSettingsDialogOpen, setThemeMode, toggleCommandPalette, diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index d7dce064..fa769fa2 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -104,6 +104,13 @@ export function keyToShortcutToken(key: string): string { } const SHORTCUT_ACTIONS: ReadonlyArray = [ + { + id: 'open_quick_open', + defaultCombo: 'mod+p', + label: 'Open quick open', + description: 'Open the quick open dialog', + customizable: true, + }, { id: 'open_command_palette', defaultCombo: 'mod+k', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 62dae489..a154fc9b 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -483,6 +483,7 @@ interface UIStore { pendingFileFocusPath: string | null; isMobile: boolean; isKeyboardOpen: boolean; + isQuickOpenOpen: boolean; isCommandPaletteOpen: boolean; isHelpDialogOpen: boolean; isAboutDialogOpen: boolean; @@ -603,6 +604,8 @@ interface UIStore { navigateToDiff: (filePath: string) => void; consumePendingDiffFile: () => string | null; setIsMobile: (isMobile: boolean) => void; + setQuickOpenOpen: (open: boolean) => void; + toggleQuickOpen: () => void; toggleCommandPalette: () => void; setCommandPaletteOpen: (open: boolean) => void; toggleHelpDialog: () => void; @@ -722,6 +725,7 @@ export const useUIStore = create()( pendingFileFocusPath: null, isMobile: false, isKeyboardOpen: false, + isQuickOpenOpen: false, isCommandPaletteOpen: false, isHelpDialogOpen: false, isAboutDialogOpen: false, @@ -1235,6 +1239,14 @@ export const useUIStore = create()( set({ isMobile }); }, + setQuickOpenOpen: (open) => { + set({ isQuickOpenOpen: open }); + }, + + toggleQuickOpen: () => { + set((state) => ({ isQuickOpenOpen: !state.isQuickOpenOpen })); + }, + toggleCommandPalette: () => { set((state) => ({ isCommandPaletteOpen: !state.isCommandPaletteOpen })); },