mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-18 06:12:08 +08:00
* refactor(ui): replace in-repo Command primitive with cmdk wrapper * feat(command-palette): add global Cmd+K palette with v1 actions * feat(command-palette): add session, file, and commit search sources * refactor: add provider names to model constants * feat(command-palette): add settings, navigation, message search, and ⌘K hints * feat(command-palette): add git fetch/pull/push and branch switch actions * refactor(command-palette): consolidate fetch source hooks behind useApiSource * refactor(command-palette): extract useCommandKey and SETTINGS_MAIN_TABS metadata * refactor(command-palette): extract groups into declarative registry * refactor(command-palette): wire openFile through PaletteOpsContext * refactor: migrate openSettings and refreshProjects from window.* to PaletteOpsContext * refactor(command-palette): inline groups and delete registry indirection * refactor(command-palette): return items array directly from source hooks * refactor(palette-ops): flatten Handle wrapper into ref-based registry * refactor: inline useCommandKey as MOD_KEY constant in two call sites * feat: introduce pages and fix bug on branch switching * fix: small labels * fix: coderabbit issues * fix: coderabbit comments * Update src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
import * as React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ArrowDownToLine,
|
|
ArrowUpFromLine,
|
|
ChevronRight,
|
|
FileText,
|
|
GitCommit,
|
|
GitMerge,
|
|
MessageSquare,
|
|
MessageSquarePlus,
|
|
RefreshCw,
|
|
Settings,
|
|
SunMoon,
|
|
X,
|
|
} from 'lucide-react';
|
|
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogTitle,
|
|
} from '../../shared/view/ui';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { usePaletteOps } from '../../contexts/PaletteOpsContext';
|
|
import { SETTINGS_MAIN_TABS } from '../settings/constants/constants';
|
|
import type { AppTab, Project } from '../../types/app';
|
|
|
|
import { useSessionsSource } from './sources/useSessionsSource';
|
|
import { useFilesSource } from './sources/useFilesSource';
|
|
import { useCommitsSource } from './sources/useCommitsSource';
|
|
import { useSessionMessageSearch } from './sources/useSessionMessageSearch';
|
|
import { useBranchesSource } from './sources/useBranchesSource';
|
|
import { useGitActions } from './sources/useGitActions';
|
|
|
|
type Page = 'actions' | 'files' | 'sessions' | 'commits' | 'branches';
|
|
|
|
const PAGE_LABELS: Record<Page, string> = {
|
|
actions: 'Actions',
|
|
files: 'Files',
|
|
sessions: 'Sessions',
|
|
commits: 'Commits',
|
|
branches: 'Branches',
|
|
};
|
|
|
|
type CommandPaletteProps = {
|
|
selectedProject: Project | null;
|
|
onStartNewChat: (project: Project) => void;
|
|
onOpenSettings: (tab?: string) => void;
|
|
onShowTab?: (tab: AppTab) => void;
|
|
};
|
|
|
|
const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [
|
|
{ id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' },
|
|
{ id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' },
|
|
{ id: 'shell', label: 'Go to Shell', keywords: 'shell terminal console' },
|
|
{ id: 'git', label: 'Go to Git', keywords: 'git diff branches' },
|
|
{ id: 'tasks', label: 'Go to Tasks', keywords: 'tasks taskmaster' },
|
|
];
|
|
|
|
export default function CommandPalette({
|
|
selectedProject,
|
|
onStartNewChat,
|
|
onOpenSettings,
|
|
onShowTab,
|
|
}: CommandPaletteProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [search, setSearch] = React.useState('');
|
|
const [pages, setPages] = React.useState<Page[]>([]);
|
|
const { toggleDarkMode } = useTheme();
|
|
const navigate = useNavigate();
|
|
const ops = usePaletteOps();
|
|
|
|
const page = pages.at(-1);
|
|
|
|
React.useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const isCmdK = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k';
|
|
if (!isCmdK) return;
|
|
e.preventDefault();
|
|
setOpen((prev) => !prev);
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) {
|
|
setSearch('');
|
|
setPages([]);
|
|
}
|
|
}, [open]);
|
|
|
|
const projectId = selectedProject?.projectId;
|
|
|
|
const showActions = !page || page === 'actions';
|
|
const showSessions = !page || page === 'sessions';
|
|
const showFiles = !page || page === 'files';
|
|
const showCommits = !page || page === 'commits';
|
|
const showBranches = !page || page === 'branches' || page === 'actions';
|
|
|
|
const sessions = useSessionsSource(projectId, open && showSessions);
|
|
const messageMatches = useSessionMessageSearch(projectId, search, open && showSessions);
|
|
const files = useFilesSource(projectId, open && showFiles);
|
|
const commits = useCommitsSource(projectId, open && showCommits);
|
|
const branches = useBranchesSource(projectId, open && showBranches);
|
|
const git = useGitActions(projectId);
|
|
|
|
const sessionRows = React.useMemo(() => {
|
|
if (!showSessions) return [];
|
|
type Row = { id: string; label: string; provider?: string; snippet?: string };
|
|
const byId = new Map<string, Row>();
|
|
for (const s of sessions) {
|
|
byId.set(s.id, { id: s.id, label: s.label, provider: s.provider });
|
|
}
|
|
for (const m of messageMatches) {
|
|
const existing = byId.get(m.sessionId);
|
|
if (existing) {
|
|
existing.snippet = m.snippet;
|
|
} else {
|
|
byId.set(m.sessionId, {
|
|
id: m.sessionId,
|
|
label: m.label,
|
|
provider: m.provider,
|
|
snippet: m.snippet,
|
|
});
|
|
}
|
|
}
|
|
return Array.from(byId.values());
|
|
}, [sessions, messageMatches, showSessions]);
|
|
|
|
const run = React.useCallback((fn: () => void) => {
|
|
setOpen(false);
|
|
fn();
|
|
}, []);
|
|
|
|
const pushPage = React.useCallback((next: Page) => {
|
|
setSearch('');
|
|
setPages((prev) => [...prev, next]);
|
|
}, []);
|
|
|
|
const popPage = React.useCallback(() => {
|
|
setSearch('');
|
|
setPages((prev) => prev.slice(0, -1));
|
|
}, []);
|
|
|
|
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Backspace' && !search && pages.length > 0) {
|
|
e.preventDefault();
|
|
popPage();
|
|
}
|
|
}, [search, pages.length, popPage]);
|
|
|
|
const startNewChatDisabled = !selectedProject;
|
|
const browseLimit = 5;
|
|
const filesShown = page === 'files' ? files : files.slice(0, browseLimit);
|
|
const commitsShown = page === 'commits' ? commits : commits.slice(0, browseLimit);
|
|
const sessionsShown = page === 'sessions' ? sessionRows : sessionRows.slice(0, browseLimit);
|
|
const branchesShown = page === 'branches' ? branches : branches.slice(0, browseLimit);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent className="max-w-xl overflow-hidden p-0">
|
|
<DialogTitle>Command palette</DialogTitle>
|
|
<Command label="Command palette" onKeyDown={handleKeyDown}>
|
|
{page && (
|
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
|
<span className="inline-flex items-center gap-1 rounded-md bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
|
|
{PAGE_LABELS[page]}
|
|
<button
|
|
type="button"
|
|
onClick={popPage}
|
|
aria-label="Back to all"
|
|
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">Backspace to go back</span>
|
|
</div>
|
|
)}
|
|
<CommandInput
|
|
placeholder={page ? `Search ${PAGE_LABELS[page].toLowerCase()}…` : 'Type to search anything…'}
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>No results.</CommandEmpty>
|
|
|
|
{showActions && (
|
|
<CommandGroup heading="Actions">
|
|
<CommandItem
|
|
value="Start new chat"
|
|
disabled={startNewChatDisabled}
|
|
onSelect={() => {
|
|
if (!selectedProject) return;
|
|
run(() => onStartNewChat(selectedProject));
|
|
}}
|
|
>
|
|
<MessageSquarePlus className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Start new chat</span>
|
|
{startNewChatDisabled && (
|
|
<span className="text-xs text-muted-foreground">Select a project first</span>
|
|
)}
|
|
</CommandItem>
|
|
<CommandItem value="Open settings" onSelect={() => run(() => onOpenSettings())}>
|
|
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Open settings</span>
|
|
</CommandItem>
|
|
<CommandItem value="Toggle theme dark light mode" onSelect={() => run(toggleDarkMode)}>
|
|
<SunMoon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Toggle theme</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showActions && (
|
|
<CommandGroup heading="Navigate">
|
|
{NAV_TABS.map((tab) => (
|
|
<CommandItem
|
|
key={tab.id as string}
|
|
value={`${tab.label} ${tab.keywords}`}
|
|
onSelect={() => run(() => onShowTab?.(tab.id))}
|
|
>
|
|
<span className="flex-1">{tab.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showActions && projectId && (
|
|
<CommandGroup heading="Git">
|
|
<CommandItem
|
|
value="Git Fetch remote"
|
|
onSelect={() => run(() => { void git.fetch(); onShowTab?.('git'); })}
|
|
>
|
|
<RefreshCw className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Git: Fetch</span>
|
|
</CommandItem>
|
|
<CommandItem
|
|
value="Git Pull merge upstream"
|
|
onSelect={() => run(() => { void git.pull(); onShowTab?.('git'); })}
|
|
>
|
|
<ArrowDownToLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Git: Pull</span>
|
|
</CommandItem>
|
|
<CommandItem
|
|
value="Git Push origin remote"
|
|
onSelect={() => run(() => { void git.push(); onShowTab?.('git'); })}
|
|
>
|
|
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Git: Push</span>
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showActions && (
|
|
<CommandGroup heading="Settings">
|
|
{SETTINGS_MAIN_TABS.map(({ id, label, keywords, icon: Icon }) => (
|
|
<CommandItem
|
|
key={id}
|
|
value={`Settings ${label} ${keywords}`}
|
|
onSelect={() => run(() => onOpenSettings(id))}
|
|
>
|
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1">Settings: {label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showSessions && projectId && sessionsShown.length > 0 && (
|
|
<CommandGroup heading="Sessions">
|
|
{sessionsShown.map((s) => (
|
|
<CommandItem
|
|
key={s.id}
|
|
value={`${s.label} ${s.snippet ?? ''} ${s.id}`.trim()}
|
|
onSelect={() => run(() => navigate(`/session/${s.id}`))}
|
|
>
|
|
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate">{s.label}</span>
|
|
{s.snippet && (
|
|
<span className="truncate text-xs text-muted-foreground">{s.snippet}</span>
|
|
)}
|
|
</div>
|
|
{s.provider && (
|
|
<span className="text-xs text-muted-foreground">{s.provider}</span>
|
|
)}
|
|
</CommandItem>
|
|
))}
|
|
{!page && sessionRows.length > browseLimit && (
|
|
<BrowseAllItem label={`Browse all sessions (${sessionRows.length})`} onSelect={() => pushPage('sessions')} />
|
|
)}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showFiles && projectId && filesShown.length > 0 && (
|
|
<CommandGroup heading="Files">
|
|
{filesShown.map((f) => (
|
|
<CommandItem
|
|
key={f.path}
|
|
value={f.path}
|
|
onSelect={() => run(() => ops.openFile(f.path))}
|
|
>
|
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1 truncate">{f.name}</span>
|
|
<span className="truncate text-xs text-muted-foreground">{f.path}</span>
|
|
</CommandItem>
|
|
))}
|
|
{!page && files.length > browseLimit && (
|
|
<BrowseAllItem label={`Browse all files (${files.length})`} onSelect={() => pushPage('files')} />
|
|
)}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showCommits && projectId && commitsShown.length > 0 && (
|
|
<CommandGroup heading="Commits">
|
|
{commitsShown.map((c) => (
|
|
<CommandItem
|
|
key={c.hash}
|
|
value={`${c.message} ${c.author} ${c.shortHash}`}
|
|
onSelect={() => run(() => onShowTab?.('git'))}
|
|
>
|
|
<GitCommit className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="font-mono text-xs text-muted-foreground">{c.shortHash}</span>
|
|
<span className="flex-1 truncate">{c.message}</span>
|
|
<span className="truncate text-xs text-muted-foreground">{c.author}</span>
|
|
</CommandItem>
|
|
))}
|
|
{!page && commits.length > browseLimit && (
|
|
<BrowseAllItem label={`Browse all commits (${commits.length})`} onSelect={() => pushPage('commits')} />
|
|
)}
|
|
</CommandGroup>
|
|
)}
|
|
|
|
{showBranches && projectId && branchesShown.length > 0 && (
|
|
<CommandGroup heading="Branches">
|
|
{branchesShown.map((b) => (
|
|
<CommandItem
|
|
key={`branch-${b.name}`}
|
|
value={b.name}
|
|
onSelect={() => run(() => { void git.checkout(b.name); onShowTab?.('git'); })}
|
|
>
|
|
<GitMerge className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1 truncate">Switch to: {b.name}</span>
|
|
</CommandItem>
|
|
))}
|
|
{!page && branches.length > browseLimit && (
|
|
<BrowseAllItem label={`Browse all branches (${branches.length})`} onSelect={() => pushPage('branches')} />
|
|
)}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function BrowseAllItem({ label, onSelect }: { label: string; onSelect: () => void }) {
|
|
return (
|
|
<CommandItem value={label} onSelect={onSelect}>
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
<span className="flex-1 text-muted-foreground">{label}</span>
|
|
</CommandItem>
|
|
);
|
|
}
|