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.
This commit is contained in:
Jinwoo An (안진우)
2026-04-16 23:24:58 +03:00
committed by GitHub
parent 5bc52a15ad
commit 2fbfd803f7
9 changed files with 325 additions and 1 deletions
+2
View File
@@ -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) {
<MainLayout />
<Toaster />
<ConfigUpdateOverlay />
<QuickOpenDialog />
<AboutDialogWrapper />
{showMemoryDebug && (
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
@@ -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 = () => {
<span>Open Session List</span>
<CommandShortcut>{shortcut('toggle_sidebar')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenQuickOpen}>
<RiFileLine className="mr-2 h-4 w-4" />
<span>Quick Open</span>
<CommandShortcut>{shortcut('open_quick_open')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateSession}>
<RiAddLine className="mr-2 h-4 w-4" />
<span>New Session</span>
@@ -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)",
@@ -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<RecentQuickOpenFile[]>([]);
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<string>();
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 (
<Dialog open={isQuickOpenOpen} onOpenChange={setQuickOpenOpen}>
<DialogHeader className="sr-only">
<DialogTitle>Quick Open</DialogTitle>
<DialogDescription>Type a file name or path</DialogDescription>
</DialogHeader>
<DialogContent
className="overflow-hidden p-0 transform-gpu will-change-transform"
showCloseButton
>
<Command
shouldFilter={false}
className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-4 [&_[cmdk-input-wrapper]_svg]:w-4 [&_[cmdk-input]]:h-8 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-1.5 [&_[cmdk-item]_svg]:h-4 [&_[cmdk-item]_svg]:w-4 [&_[cmdk-item]]:typography-meta"
>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder="Type a file name or path"
disabled={!currentRoot}
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
{currentRoot && visibleFiles.length > 0 && (
<CommandGroup heading={hasTypedQuery ? 'Files' : 'Recent Files'}>
{visibleFiles.map((file) => (
<CommandItem
key={file.path}
value={file.path}
onSelect={() => {
void handleSelectFile(file.path);
}}
>
<FileTypeIcon filePath={file.path} className="size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate">{file.name}</span>
<span className="truncate text-muted-foreground">{file.relativePath}</span>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
};
@@ -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,
+7
View File
@@ -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,
+7
View File
@@ -104,6 +104,13 @@ export function keyToShortcutToken(key: string): string {
}
const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
{
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',
+12
View File
@@ -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<UIStore>()(
pendingFileFocusPath: null,
isMobile: false,
isKeyboardOpen: false,
isQuickOpenOpen: false,
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
isAboutDialogOpen: false,
@@ -1235,6 +1239,14 @@ export const useUIStore = create<UIStore>()(
set({ isMobile });
},
setQuickOpenOpen: (open) => {
set({ isQuickOpenOpen: open });
},
toggleQuickOpen: () => {
set((state) => ({ isQuickOpenOpen: !state.isQuickOpenOpen }));
},
toggleCommandPalette: () => {
set((state) => ({ isCommandPaletteOpen: !state.isCommandPaletteOpen }));
},