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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user