feat(palette): unify quick open into command palette with multi-source search

Merge file picker into command palette. Single Cmd+P entry searches
files, sessions, settings pages and commands; groups re-order by best
fuzzy score per source. Sessions show branch labels; git status is
lazily fetched for all session directories on open.

Drop QuickOpenDialog and Cmd+K shortcut.
This commit is contained in:
Bohdan Triapitsyn
2026-04-30 18:37:37 +03:00
parent 57eec76a7b
commit 24533dfe32
23 changed files with 550 additions and 882 deletions
+459 -244
View File
@@ -1,315 +1,530 @@
import React from 'react';
import {
CommandDialog,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useGitAllBranches, useGitStore } from '@/stores/useGitStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { toast } from '@/components/ui';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import {
RiAddLine,
RiChatAi3Line,
RiCheckLine,
RiClipboardLine,
RiComputerLine,
RiFileLine,
RiFolderLine,
RiGitBranchLine,
RiLayoutLeftLine,
RiLayoutRightLine,
RiMoonLine,
RiPieChartLine,
RiQuestionLine,
RiSettings3Line,
RiSunLine,
RiTerminalBoxLine,
} from '@remixicon/react';
import type { Session } from '@opencode-ai/sdk/v2';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
import { SETTINGS_PAGE_METADATA, SETTINGS_GROUP_LABELS, type SettingsRuntimeContext } from '@/lib/settings/metadata';
import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata';
import { getSettingsNavIcon } from '@/components/views/SettingsView';
import { rankByFuzzyQuery, scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import { truncatePathMiddle } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
type CommandEntry = {
id: string;
title: string;
icon: React.ReactNode;
shortcutId?: string;
searchText: string;
onSelect: () => void;
};
type FileHit = { 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;
};
export const CommandPalette: React.FC = () => {
const { t } = useI18n();
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);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const toggleRightSidebar = useUIStore((s) => s.toggleRightSidebar);
const setRightSidebarOpen = useUIStore((s) => s.setRightSidebarOpen);
const setRightSidebarTab = useUIStore((s) => s.setRightSidebarTab);
const toggleBottomTerminal = useUIStore((s) => s.toggleBottomTerminal);
const setBottomTerminalExpanded = useUIStore((s) => s.setBottomTerminalExpanded);
const isBottomTerminalExpanded = useUIStore((s) => s.isBottomTerminalExpanded);
const openContextOverview = useUIStore((s) => s.openContextOverview);
const openContextPlan = useUIStore((s) => s.openContextPlan);
const openContextFile = useUIStore((s) => s.openContextFile);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const getSessionsByDirectory = useSessionUIStore((s) => s.getSessionsByDirectory);
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const { themeMode, setThemeMode } = useThemeSystem();
const effectiveDirectory = useEffectiveDirectory();
const searchFiles = useFileSearchStore((s) => s.searchFiles);
const { files: filesApi, git: gitApi } = useRuntimeAPIs();
const ensureGitStatus = useGitStore((s) => s.ensureStatus);
const { isMobile } = useDeviceInfo();
const currentRoot = React.useMemo(
() => (effectiveDirectory ? normalizePath(effectiveDirectory) : null),
[effectiveDirectory],
);
const [query, setQuery] = React.useState('');
const debouncedQuery = useDebouncedValue(query, 200);
const trimmedQuery = debouncedQuery.trim();
const liveTrimmed = query.trim();
React.useEffect(() => {
if (!isCommandPaletteOpen) setQuery('');
}, [isCommandPaletteOpen]);
// Lazy-load git status for every session directory we plan to display so that
// branch labels become available across all projects, not only the active one.
// Deferred to idle to keep the first render (and the file-search effect) free
// from a flood of git store updates.
React.useEffect(() => {
if (!isCommandPaletteOpen || !gitApi) return;
const handle = setTimeout(() => {
const seen = new Set<string>();
for (const session of activeSessions) {
const dir = resolveGlobalSessionDirectory(session);
if (!dir || seen.has(dir)) continue;
seen.add(dir);
void ensureGitStatus(dir, gitApi);
}
}, 0);
return () => clearTimeout(handle);
}, [isCommandPaletteOpen, activeSessions, gitApi, ensureGitStatus]);
const close = React.useCallback(() => setCommandPaletteOpen(false), [setCommandPaletteOpen]);
const run = React.useCallback(
(fn: () => void | Promise<void>) => () => {
close();
void fn();
},
[close],
);
const run = React.useCallback((fn: () => void | Promise<void>) => async () => {
close();
await fn();
}, [close]);
const handleCreateSession = run(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
});
const handleOpenQuickOpen = run(() => setQuickOpenOpen(true));
const handleCreateWorktreeSession = run(() => {
createWorktreeSession();
});
const handleOpenSessionList = run(() => {
if (isMobile) {
const { isSessionSwitcherOpen } = useUIStore.getState();
setSessionSwitcherOpen(!isSessionSwitcherOpen);
} else {
toggleSidebar();
}
});
const handleToggleRightSidebar = run(() => toggleRightSidebar());
const handleOpenRightSidebarGit = run(() => { setRightSidebarOpen(true); setRightSidebarTab('git'); });
const handleOpenRightSidebarFiles = run(() => { setRightSidebarOpen(true); setRightSidebarTab('files'); });
const handleToggleTerminalDock = run(() => toggleBottomTerminal());
const handleToggleTerminalExpanded = run(() => setBottomTerminalExpanded(!isBottomTerminalExpanded));
const handleShowHelp = run(() => setHelpDialogOpen(true));
const handleOpenSettings = run(() => setSettingsDialogOpen(true));
const handleShowContextUsage = run(() => {
if (currentDirectory) openContextOverview(currentDirectory);
});
const handleShowPlan = run(() => {
if (currentDirectory) openContextPlan(currentDirectory);
});
const handleOpenSettingsPage = (slug: string) => run(() => {
setSettingsPage(slug);
setSettingsDialogOpen(true);
});
const handleOpenSession = (sessionId: string, directoryHint?: string | null) => run(() => {
setCurrentSession(sessionId, directoryHint ?? null);
});
const handleSetThemeMode = (mode: 'light' | 'dark' | 'system') => run(() => {
setThemeMode(mode);
});
// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------
const commands = React.useMemo<CommandEntry[]>(() => {
const list: CommandEntry[] = [
{
id: 'new-session',
title: t('commandPalette.item.newSession'),
icon: <RiAddLine className="mr-2 h-4 w-4" />,
shortcutId: 'new_chat',
searchText: t('commandPalette.item.newSession'),
onSelect: run(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
}),
},
{
id: 'new-worktree',
title: t('commandPalette.item.newWorktreeDraft'),
icon: <RiGitBranchLine className="mr-2 h-4 w-4" />,
shortcutId: 'new_chat_worktree',
searchText: t('commandPalette.item.newWorktreeDraft'),
onSelect: run(() => {
void createWorktreeSession();
}),
},
{
id: 'toggle-sidebar',
title: isMobile
? t('commandPalette.item.showSessionSwitcher')
: t('commandPalette.item.toggleSidebar'),
icon: <RiLayoutLeftLine className="mr-2 h-4 w-4" />,
shortcutId: 'toggle_sidebar',
searchText: isMobile
? t('commandPalette.item.showSessionSwitcher')
: t('commandPalette.item.toggleSidebar'),
onSelect: run(() => {
if (isMobile) {
const { isSessionSwitcherOpen } = useUIStore.getState();
setSessionSwitcherOpen(!isSessionSwitcherOpen);
} else {
toggleSidebar();
}
}),
},
{
id: 'toggle-right-sidebar',
title: t('commandPalette.item.toggleRightSidebar'),
icon: <RiLayoutRightLine className="mr-2 h-4 w-4" />,
shortcutId: 'toggle_right_sidebar',
searchText: t('commandPalette.item.toggleRightSidebar'),
onSelect: run(() => toggleRightSidebar()),
},
{
id: 'toggle-terminal',
title: t('commandPalette.item.toggleTerminal'),
icon: <RiTerminalBoxLine className="mr-2 h-4 w-4" />,
shortcutId: 'toggle_terminal',
searchText: t('commandPalette.item.toggleTerminal'),
onSelect: run(() => toggleBottomTerminal()),
},
{
id: 'context-usage',
title: t('commandPalette.item.showContextUsage'),
icon: <RiPieChartLine className="mr-2 h-4 w-4" />,
searchText: t('commandPalette.item.showContextUsage'),
onSelect: run(() => {
if (currentDirectory) openContextOverview(currentDirectory);
}),
},
{
id: 'open-settings',
title: t('commandPalette.item.openSettings'),
icon: <RiSettings3Line className="mr-2 h-4 w-4" />,
shortcutId: 'open_settings',
searchText: t('commandPalette.item.openSettings'),
onSelect: run(() => setSettingsDialogOpen(true)),
},
];
return list;
}, [
t,
run,
isMobile,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
toggleSidebar,
toggleRightSidebar,
toggleBottomTerminal,
currentDirectory,
openContextOverview,
setSettingsDialogOpen,
]);
// ---------------------------------------------------------------------------
// Settings sub-pages (only show when there's a query)
// ---------------------------------------------------------------------------
const settingsRuntimeCtx = React.useMemo<SettingsRuntimeContext>(() => {
const isDesktop = isDesktopShell();
return { isVSCode: isVSCodeRuntime(), isWeb: !isDesktop && isWebRuntime(), isDesktop };
}, []);
const settingsItems = React.useMemo(() => {
const groupLabel = (g: string) => (SETTINGS_GROUP_LABELS as Record<string, string>)[g] ?? g;
const settingsEntries = React.useMemo<CommandEntry[]>(() => {
return SETTINGS_PAGE_METADATA
.filter((p) => p.slug !== 'home')
.filter((p) => (p.isAvailable ? p.isAvailable(settingsRuntimeCtx) : true))
.slice()
.sort((a, b) => {
const g = groupLabel(a.group).localeCompare(groupLabel(b.group));
if (g !== 0) return g;
return a.title.localeCompare(b.title);
.map((page) => {
const Icon = getSettingsNavIcon(page.slug) ?? RiSettings3Line;
const keywords = (page.keywords ?? []).join(' ');
return {
id: `settings:${page.slug}`,
title: page.title,
icon: <Icon className="mr-2 h-4 w-4" />,
searchText: `${page.title} ${page.group} ${keywords}`,
onSelect: run(() => {
setSettingsPage(page.slug);
setSettingsDialogOpen(true);
}),
} satisfies CommandEntry;
});
}, [settingsRuntimeCtx]);
}, [settingsRuntimeCtx, run, setSettingsPage, setSettingsDialogOpen]);
const recentSessions = React.useMemo(() => {
return getSessionsByDirectory(currentDirectory ?? '').slice(0, 5);
}, [getSessionsByDirectory, currentDirectory]);
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
const sortedActiveSessions = React.useMemo(() => {
const getUpdated = (s: Session) =>
(typeof s.time?.updated === 'number' ? s.time.updated : 0) ||
(typeof s.time?.created === 'number' ? s.time.created : 0);
return [...activeSessions].sort((a, b) => getUpdated(b) - getUpdated(a));
}, [activeSessions]);
const shortcut = React.useCallback((actionId: string) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
const allBranches = useGitAllBranches();
const worktreeMetadata = useSessionUIStore((s) => s.worktreeMetadata);
const settingsGroupLabelMap: Record<string, string> = {
appearance: t('commandPalette.settingsGroup.appearance'),
projects: t('commandPalette.settingsGroup.projects'),
general: t('commandPalette.settingsGroup.general'),
opencode: t('commandPalette.settingsGroup.opencode'),
git: t('commandPalette.settingsGroup.git'),
skills: t('commandPalette.settingsGroup.skills'),
usage: t('commandPalette.settingsGroup.usage'),
advanced: t('commandPalette.settingsGroup.advanced'),
};
const branchForSession = React.useCallback(
(sessionId: string, dir: string | null): string | null => {
const meta = worktreeMetadata.get(sessionId);
if (meta?.branch) return meta.branch.trim() || null;
if (dir) return allBranches.get(dir)?.trim() || null;
return null;
},
[worktreeMetadata, allBranches],
);
const settingsPageLabelMap: Record<string, string> = {
home: t('commandPalette.settingsPage.home'),
projects: t('commandPalette.settingsPage.projects'),
'remote-instances': t('commandPalette.settingsPage.remoteInstances'),
providers: t('commandPalette.settingsPage.providers'),
usage: t('commandPalette.settingsPage.usage'),
agents: t('commandPalette.settingsPage.agents'),
commands: t('commandPalette.settingsPage.commands'),
mcp: t('commandPalette.settingsPage.mcp'),
'skills.installed': t('commandPalette.settingsPage.skillsInstalled'),
'skills.catalog': t('commandPalette.settingsPage.skillsCatalog'),
git: t('commandPalette.settingsPage.git'),
appearance: t('commandPalette.settingsPage.appearance'),
chat: t('commandPalette.settingsPage.chat'),
shortcuts: t('commandPalette.settingsPage.shortcuts'),
sessions: t('commandPalette.settingsPage.sessions'),
'magic-prompts': t('commandPalette.settingsPage.magicPrompts'),
notifications: t('commandPalette.settingsPage.notifications'),
voice: t('commandPalette.settingsPage.voice'),
tunnel: t('commandPalette.settingsPage.tunnel'),
};
// ---------------------------------------------------------------------------
// File search
// ---------------------------------------------------------------------------
const [fileResults, setFileResults] = React.useState<FileHit[]>([]);
const [isSearchingFiles, setIsSearchingFiles] = React.useState(false);
React.useEffect(() => {
if (!isCommandPaletteOpen) {
setFileResults([]);
setIsSearchingFiles(false);
return;
}
if (!currentRoot || trimmedQuery.length === 0) {
setFileResults([]);
setIsSearchingFiles(false);
return;
}
let cancelled = false;
setIsSearchingFiles(true);
void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' })
.then((results) => {
if (cancelled) return;
setFileResults(
results.map((file) => ({
path: normalizePath(file.path),
name: file.name,
relativePath: file.relativePath,
})),
);
})
.catch(() => {
if (!cancelled) setFileResults([]);
})
.finally(() => {
if (!cancelled) setIsSearchingFiles(false);
});
return () => {
cancelled = true;
};
}, [isCommandPaletteOpen, currentRoot, trimmedQuery, searchFiles]);
// ---------------------------------------------------------------------------
// Filter visible items
// ---------------------------------------------------------------------------
const hasQuery = liveTrimmed.length > 0;
const scoredCommands = React.useMemo(() => {
if (!hasQuery) return commands.map((item) => ({ item, score: 0 }));
return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, {
limit: 7,
noFuzzy: true,
});
}, [commands, liveTrimmed, hasQuery]);
const scoredSettings = React.useMemo(() => {
if (!hasQuery) return [];
return scoreByFuzzyQuery(settingsEntries, liveTrimmed, (c) => c.searchText, {
limit: 7,
noFuzzy: true,
});
}, [settingsEntries, liveTrimmed, hasQuery]);
const scoredSessions = React.useMemo(() => {
if (!hasQuery) return sortedActiveSessions.slice(0, 5).map((item) => ({ item, score: 0 }));
return scoreByFuzzyQuery(sortedActiveSessions, liveTrimmed, (s) => s.title || '', {
limit: 7,
threshold: 0.2,
});
}, [sortedActiveSessions, liveTrimmed, hasQuery]);
const scoredFiles = React.useMemo(() => {
if (!hasQuery || fileResults.length === 0) return [];
// Server already ranked by relevance; compute a comparable client score on
// basename so we can decide file group placement vs sessions/commands.
return scoreByFuzzyQuery(fileResults, liveTrimmed, (f) => f.name, {
limit: 10,
threshold: 0.4,
});
}, [fileResults, liveTrimmed, hasQuery]);
const visibleCommands = scoredCommands.map((x) => x.item);
const visibleSettings = scoredSettings.map((x) => x.item);
const visibleSessions = scoredSessions.map((x) => x.item);
const visibleFiles = hasQuery ? scoredFiles.map((x) => x.item) : [];
const groupOrder = React.useMemo<('commands' | 'settings' | 'sessions' | 'files')[]>(() => {
if (!hasQuery) return ['commands', 'sessions'];
const best = (arr: { score: number }[]): number => (arr.length ? arr[0].score : Infinity);
const groups: { key: 'commands' | 'settings' | 'sessions' | 'files'; score: number }[] = [
{ key: 'commands', score: best(scoredCommands) },
{ key: 'settings', score: best(scoredSettings) },
{ key: 'sessions', score: best(scoredSessions) },
{ key: 'files', score: best(scoredFiles) },
];
groups.sort((a, b) => a.score - b.score);
return groups.map((g) => g.key);
}, [hasQuery, scoredCommands, scoredSettings, scoredSessions, scoredFiles]);
const handleOpenSession = React.useCallback(
(session: Session) => {
close();
setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
},
[close, setCurrentSession],
);
const handleOpenFile = React.useCallback(
async (filePath: string) => {
if (!currentRoot) return;
const validation = await validateContextFileOpen(filesApi, filePath);
if (!validation.ok) {
toast.error(getContextFileOpenFailureMessage(validation.reason));
return;
}
openContextFile(currentRoot, filePath);
close();
},
[currentRoot, filesApi, openContextFile, close],
);
const shortcut = React.useCallback(
(actionId: string) =>
formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)),
[shortcutOverrides],
);
return (
<CommandDialog open={isCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<CommandInput placeholder={t('commandPalette.input.placeholder')} />
<CommandList>
<CommandEmpty>{t('commandPalette.empty.noResults')}</CommandEmpty>
<Dialog open={isCommandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
<DialogHeader className="sr-only">
<DialogTitle>{t('commandPalette.title')}</DialogTitle>
<DialogDescription>{t('commandPalette.description')}</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={t('commandPalette.input.placeholder')}
/>
<CommandList>
<CommandEmpty>{t('commandPalette.empty.noResults')}</CommandEmpty>
<CommandGroup heading={t('commandPalette.section.sessions')}>
<CommandItem onSelect={handleOpenQuickOpen}>
<RiFileLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.quickOpen')}</span>
<CommandShortcut>{shortcut('open_quick_open')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateSession}>
<RiAddLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.newSession')}</span>
<CommandShortcut>{shortcut('new_chat')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleCreateWorktreeSession}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.newWorktreeDraft')}</span>
<CommandShortcut>{shortcut('new_chat_worktree')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenSessionList}>
<RiLayoutLeftLine className="mr-2 h-4 w-4" />
<span>{isMobile ? t('commandPalette.item.showSessionSwitcher') : t('commandPalette.item.toggleSidebar')}</span>
<CommandShortcut>{shortcut('toggle_sidebar')}</CommandShortcut>
</CommandItem>
</CommandGroup>
{groupOrder.map((groupKey) => {
if (groupKey === 'commands' && visibleCommands.length > 0) {
return (
<CommandGroup key="commands">
{visibleCommands.map((cmd) => (
<CommandItem key={cmd.id} value={cmd.id} onSelect={cmd.onSelect}>
{cmd.icon}
<span>{cmd.title}</span>
{cmd.shortcutId ? (
<CommandShortcut>{shortcut(cmd.shortcutId)}</CommandShortcut>
) : null}
</CommandItem>
))}
</CommandGroup>
);
}
if (groupKey === 'settings' && visibleSettings.length > 0) {
return (
<CommandGroup key="settings">
{visibleSettings.map((cmd) => (
<CommandItem key={cmd.id} value={cmd.id} onSelect={cmd.onSelect}>
{cmd.icon}
<span>{cmd.title}</span>
</CommandItem>
))}
</CommandGroup>
);
}
if (groupKey === 'sessions' && visibleSessions.length > 0) {
return (
<CommandGroup key="sessions">
{visibleSessions.map((session) => {
const title = session.title || t('commandPalette.session.untitled');
const dir = resolveGlobalSessionDirectory(session);
const branch = branchForSession(session.id, dir);
return (
<CommandItem
key={session.id}
value={`session:${session.id}`}
onSelect={() => handleOpenSession(session)}
>
<RiChatAi3Line className="mr-2 h-4 w-4" />
<span className="truncate">{title}</span>
{branch ? (
<span className="ml-auto inline-flex items-center gap-1 text-muted-foreground typography-meta">
<RiGitBranchLine className="h-3 w-3" />
<span className="truncate max-w-[160px]">{branch}</span>
</span>
) : null}
</CommandItem>
);
})}
</CommandGroup>
);
}
if (groupKey === 'files' && visibleFiles.length > 0) {
return (
<CommandGroup key="files">
{visibleFiles.map((file) => {
const display = truncatePathMiddle(file.relativePath || file.name, {
maxLength: 80,
});
return (
<CommandItem
key={`file:${file.path}`}
value={`file:${file.path}`}
onSelect={() => {
void handleOpenFile(file.path);
}}
>
<FileTypeIcon filePath={file.path} className="mr-2 size-4 shrink-0" />
<span className="truncate" aria-label={file.relativePath}>
{display}
</span>
</CommandItem>
);
})}
</CommandGroup>
);
}
return null;
})}
<CommandSeparator />
<CommandGroup heading={t('commandPalette.section.view')}>
<CommandItem onSelect={handleToggleRightSidebar}>
<RiLayoutRightLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.toggleRightSidebar')}</span>
<CommandShortcut>{shortcut('toggle_right_sidebar')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenRightSidebarGit}>
<RiGitBranchLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.showGitRightSidebar')}</span>
<CommandShortcut>{shortcut('open_right_sidebar_git')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleOpenRightSidebarFiles}>
<RiFolderLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.showFilesRightSidebar')}</span>
<CommandShortcut>{shortcut('open_right_sidebar_files')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowContextUsage}>
<RiPieChartLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.showContextUsage')}</span>
</CommandItem>
<CommandItem onSelect={handleShowPlan}>
<RiClipboardLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.showPlan')}</span>
</CommandItem>
<CommandItem onSelect={handleToggleTerminalDock}>
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.toggleTerminal')}</span>
<CommandShortcut>{shortcut('toggle_terminal')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleToggleTerminalExpanded}>
<RiTerminalBoxLine className="mr-2 h-4 w-4" />
<span>{isBottomTerminalExpanded ? t('commandPalette.item.collapseTerminal') : t('commandPalette.item.expandTerminal')}</span>
<CommandShortcut>{shortcut('toggle_terminal_expanded')}</CommandShortcut>
</CommandItem>
<CommandItem onSelect={handleShowHelp}>
<RiQuestionLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.keyboardShortcuts')}</span>
<CommandShortcut>{shortcut('open_help')}</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t('commandPalette.section.theme')}>
<CommandItem onSelect={() => handleSetThemeMode('light')()}>
<RiSunLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.themeLight')}</span>
{themeMode === 'light' && <RiCheckLine className="ml-auto h-4 w-4" />}
</CommandItem>
<CommandItem onSelect={() => handleSetThemeMode('dark')()}>
<RiMoonLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.themeDark')}</span>
{themeMode === 'dark' && <RiCheckLine className="ml-auto h-4 w-4" />}
</CommandItem>
<CommandItem onSelect={() => handleSetThemeMode('system')()}>
<RiComputerLine className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.themeSystem')}</span>
{themeMode === 'system' && <RiCheckLine className="ml-auto h-4 w-4" />}
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={t('commandPalette.section.settings')}>
<CommandItem onSelect={handleOpenSettings}>
<RiSettings3Line className="mr-2 h-4 w-4" />
<span>{t('commandPalette.item.openSettings')}</span>
<CommandShortcut>{shortcut('open_settings')}</CommandShortcut>
</CommandItem>
{settingsItems.map((page) => {
const Icon = getSettingsNavIcon(page.slug) ?? RiSettings3Line;
return (
<CommandItem key={page.slug} onSelect={() => handleOpenSettingsPage(page.slug)()}>
<Icon className="mr-2 h-4 w-4" />
<span>{settingsGroupLabelMap[page.group] ?? SETTINGS_GROUP_LABELS[page.group]}: {settingsPageLabelMap[page.slug] ?? page.title}</span>
</CommandItem>
);
})}
</CommandGroup>
{recentSessions.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading={t('commandPalette.section.recentSessions')}>
{recentSessions.map((session) => (
<CommandItem
key={session.id}
onSelect={() => handleOpenSession(session.id, currentDirectory ?? null)()}
>
<RiChatAi3Line className="mr-2 h-4 w-4" />
<span className="truncate">{session.title || t('commandPalette.session.untitled')}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
{hasQuery && isSearchingFiles && visibleFiles.length === 0 ? (
<div className="px-3 py-2 typography-meta text-muted-foreground">
{t('commandPalette.empty.searchingFiles')}
</div>
) : null}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
};
+1 -7
View File
@@ -70,12 +70,6 @@ export const HelpDialog: React.FC = () => {
icon: RiCommandLine,
keys: '',
},
{
id: 'open_quick_open',
descriptionKey: 'helpDialog.item.quickOpenFile',
icon: RiFileLine,
keys: '',
},
{
id: 'open_help',
descriptionKey: "helpDialog.item.showKeyboardShortcuts",
@@ -298,7 +292,7 @@ export const HelpDialog: React.FC = () => {
<ul className="space-y-0.5 typography-meta">
<li>
{t('helpDialog.proTips.commandPalette', {
shortcut: renderShortcut('open_command_palette', `${mod} K`, shortcutOverrides),
shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides),
})}
</li>
<li>
@@ -1,257 +0,0 @@
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';
import { useI18n } from '@/lib/i18n';
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 { t } = useI18n();
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
? t('quickOpenDialog.empty.openProjectFirst')
: hasTypedQuery
? (isSearching ? t('quickOpenDialog.empty.searchingFiles') : t('quickOpenDialog.empty.noMatchingFiles'))
: t('quickOpenDialog.empty.noMatchingRecentFiles');
return (
<Dialog open={isQuickOpenOpen} onOpenChange={setQuickOpenOpen}>
<DialogHeader className="sr-only">
<DialogTitle>{t('quickOpenDialog.title')}</DialogTitle>
<DialogDescription>{t('quickOpenDialog.description')}</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={t('quickOpenDialog.input.placeholder')}
disabled={!currentRoot}
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
{currentRoot && visibleFiles.length > 0 && (
<CommandGroup heading={hasTypedQuery ? t('quickOpenDialog.group.files') : t('quickOpenDialog.group.recentFiles')}>
{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>
);
};