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