refactor(command-palette): extract groups into declarative registry

This commit is contained in:
simosmik
2026-04-30 07:56:26 +00:00
parent 384cee2995
commit 9179ca2f00
10 changed files with 397 additions and 231 deletions

View File

@@ -1,19 +1,5 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowDownToLine,
ArrowUpFromLine,
FileText,
GitCommit,
GitMerge,
MessageSquare,
MessageSquarePlus,
RefreshCw,
Settings,
SunMoon,
} from 'lucide-react';
import { SETTINGS_MAIN_TABS } from '../settings/constants/constants';
import {
Command,
@@ -29,14 +15,8 @@ import {
import { useTheme } from '../../contexts/ThemeContext';
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 Mode = 'mixed' | 'actions' | 'files' | 'commits';
import { GROUPS, parseMode } from './registry';
import type { GroupConfig, PaletteCtx } from './registry';
type CommandPaletteProps = {
selectedProject: Project | null;
@@ -45,21 +25,6 @@ type CommandPaletteProps = {
onShowTab?: (tab: AppTab) => void;
};
function parseMode(input: string): { mode: Mode; query: string } {
if (input.startsWith('> ')) return { mode: 'actions', query: input.slice(2) };
if (input.startsWith('/')) return { mode: 'files', query: input.slice(1) };
if (input.startsWith('#')) return { mode: 'commits', query: input.slice(1) };
return { mode: 'mixed', query: input };
}
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,
@@ -87,42 +52,16 @@ export default function CommandPalette({
}, [open]);
const { mode, query } = parseMode(search);
const projectId = selectedProject?.projectId;
const { items: sessions } = useSessionsSource(projectId, open && (mode === 'mixed'));
const { items: messageMatches } = useSessionMessageSearch(projectId, query, open && mode === 'mixed');
const { items: files } = useFilesSource(projectId, open && (mode === 'mixed' || mode === 'files'));
const { items: commits } = useCommitsSource(projectId, open && (mode === 'mixed' || mode === 'commits'));
const { items: branches } = useBranchesSource(projectId, open && (mode === 'mixed' || mode === 'actions'));
const git = useGitActions(projectId);
const showActions = mode === 'mixed' || mode === 'actions';
const showSessions = mode === 'mixed';
const showFiles = mode === 'mixed' || mode === 'files';
const showCommits = mode === 'mixed' || mode === 'commits';
const run = React.useCallback((fn: () => void) => {
setOpen(false);
fn();
}, []);
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 openFile = React.useCallback((path: string) => {
window.openFile?.(path);
}, []);
const filter = React.useCallback(
(value: string, rawSearch: string) => {
@@ -133,13 +72,6 @@ export default function CommandPalette({
[],
);
const run = React.useCallback((fn: () => void) => {
setOpen(false);
fn();
}, []);
const startNewChatDisabled = !selectedProject;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl overflow-hidden p-0">
@@ -152,163 +84,50 @@ export default function CommandPalette({
/>
<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={`navigate ${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>
{branches
.filter((b) => !b.isCurrent && !b.isRemote)
.slice(0, 30)
.map((b) => (
<CommandItem
key={`branch-${b.name}`}
value={`git switch checkout branch ${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 branch: {b.name}</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 && sessionRows.length > 0 && (
<CommandGroup heading="Sessions">
{sessionRows.map((s) => (
<CommandItem
key={s.id}
value={`session ${s.label} ${s.id} ${s.snippet ?? ''}`}
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>
))}
</CommandGroup>
)}
{showFiles && files.length > 0 && (
<CommandGroup heading="Files">
{files.map((f) => (
<CommandItem
key={f.path}
value={`file ${f.path}`}
onSelect={() => run(() => window.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>
))}
</CommandGroup>
)}
{showCommits && commits.length > 0 && (
<CommandGroup heading="Commits">
{commits.map((c) => (
<CommandItem
key={c.hash}
value={`commit ${c.shortHash} ${c.message} ${c.author}`}
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>
))}
</CommandGroup>
)}
{GROUPS.map((group) => (
<GroupSlot
key={group.id}
group={group}
mode={mode}
ctx={{
projectId,
selectedProject,
query,
enabled: open && group.modes.includes(mode) && (!group.requiresProject || !!projectId),
open,
run,
navigate,
toggleDarkMode,
onStartNewChat,
onOpenSettings,
onShowTab,
openFile,
}}
/>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function GroupSlot({ group, mode, ctx }: { group: GroupConfig; mode: string; ctx: PaletteCtx }) {
const items = group.useItems(ctx);
const eligible = group.modes.includes(mode) && (!group.requiresProject || !!ctx.projectId);
if (!eligible || items.length === 0) return null;
return (
<CommandGroup heading={group.heading}>
{items.map((item) => (
<CommandItem
key={item.key}
value={item.value}
disabled={item.disabled}
onSelect={item.onSelect}
>
{item.node}
</CommandItem>
))}
</CommandGroup>
);
}

View File

@@ -0,0 +1,55 @@
import { MessageSquarePlus, Settings, SunMoon } from 'lucide-react';
import type { GroupConfig } from '../types';
export const actionsGroup: GroupConfig = {
id: 'actions',
heading: 'Actions',
modes: ['mixed', 'actions'],
prefix: { char: '> ', mode: 'actions' },
useItems: (ctx) => {
const startDisabled = !ctx.selectedProject;
return [
{
key: 'start-new-chat',
value: 'Start new chat',
disabled: startDisabled,
onSelect: () => {
if (!ctx.selectedProject) return;
ctx.run(() => ctx.onStartNewChat(ctx.selectedProject!));
},
node: (
<>
<MessageSquarePlus className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Start new chat</span>
{startDisabled && (
<span className="text-xs text-muted-foreground">Select a project first</span>
)}
</>
),
},
{
key: 'open-settings',
value: 'Open settings',
onSelect: () => ctx.run(() => ctx.onOpenSettings()),
node: (
<>
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Open settings</span>
</>
),
},
{
key: 'toggle-theme',
value: 'Toggle theme dark light mode',
onSelect: () => ctx.run(ctx.toggleDarkMode),
node: (
<>
<SunMoon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Toggle theme</span>
</>
),
},
];
},
};

View File

@@ -0,0 +1,28 @@
import { GitCommit } from 'lucide-react';
import { useCommitsSource } from '../../sources/useCommitsSource';
import type { GroupConfig } from '../types';
export const commitsGroup: GroupConfig = {
id: 'commits',
heading: 'Commits',
modes: ['mixed', 'commits'],
prefix: { char: '#', mode: 'commits' },
requiresProject: true,
useItems: (ctx) => {
const { items: commits } = useCommitsSource(ctx.projectId, ctx.enabled);
return commits.map((c) => ({
key: `commit-${c.hash}`,
value: `${c.shortHash} ${c.message} ${c.author}`,
onSelect: () => ctx.run(() => ctx.onShowTab?.('git')),
node: (
<>
<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>
</>
),
}));
},
};

View File

@@ -0,0 +1,27 @@
import { FileText } from 'lucide-react';
import { useFilesSource } from '../../sources/useFilesSource';
import type { GroupConfig } from '../types';
export const filesGroup: GroupConfig = {
id: 'files',
heading: 'Files',
modes: ['mixed', 'files'],
prefix: { char: '/', mode: 'files' },
requiresProject: true,
useItems: (ctx) => {
const { items: files } = useFilesSource(ctx.projectId, ctx.enabled);
return files.map((f) => ({
key: `file-${f.path}`,
value: f.path,
onSelect: () => ctx.run(() => ctx.openFile(f.path)),
node: (
<>
<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>
</>
),
}));
},
};

View File

@@ -0,0 +1,67 @@
import { ArrowDownToLine, ArrowUpFromLine, GitMerge, RefreshCw } from 'lucide-react';
import { useBranchesSource } from '../../sources/useBranchesSource';
import { useGitActions } from '../../sources/useGitActions';
import type { GroupConfig, PaletteItem } from '../types';
export const gitGroup: GroupConfig = {
id: 'git',
heading: 'Git',
modes: ['mixed', 'actions'],
requiresProject: true,
useItems: (ctx) => {
const git = useGitActions(ctx.projectId);
const { items: branches } = useBranchesSource(ctx.projectId, ctx.enabled);
const items: PaletteItem[] = [
{
key: 'git-fetch',
value: 'Git Fetch remote',
onSelect: () => ctx.run(() => { void git.fetch(); ctx.onShowTab?.('git'); }),
node: (
<>
<RefreshCw className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Fetch</span>
</>
),
},
{
key: 'git-pull',
value: 'Git Pull merge upstream',
onSelect: () => ctx.run(() => { void git.pull(); ctx.onShowTab?.('git'); }),
node: (
<>
<ArrowDownToLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Pull</span>
</>
),
},
{
key: 'git-push',
value: 'Git Push origin remote',
onSelect: () => ctx.run(() => { void git.push(); ctx.onShowTab?.('git'); }),
node: (
<>
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Git: Push</span>
</>
),
},
];
for (const b of branches.filter((br) => !br.isCurrent && !br.isRemote).slice(0, 30)) {
items.push({
key: `git-branch-${b.name}`,
value: `Switch to branch ${b.name}`,
onSelect: () => ctx.run(() => { void git.checkout(b.name); ctx.onShowTab?.('git'); }),
node: (
<>
<GitMerge className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 truncate">Switch to branch: {b.name}</span>
</>
),
});
}
return items;
},
};

View File

@@ -0,0 +1,23 @@
import type { AppTab } from '../../../../types/app';
import type { GroupConfig } from '../types';
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 const navigateGroup: GroupConfig = {
id: 'navigate',
heading: 'Navigate',
modes: ['mixed', 'actions'],
useItems: (ctx) =>
NAV_TABS.map((tab) => ({
key: `nav-${tab.id}`,
value: `${tab.label} ${tab.keywords}`,
onSelect: () => ctx.run(() => ctx.onShowTab?.(tab.id)),
node: <span className="flex-1">{tab.label}</span>,
})),
};

View File

@@ -0,0 +1,62 @@
import { useMemo } from 'react';
import { MessageSquare } from 'lucide-react';
import { useSessionsSource } from '../../sources/useSessionsSource';
import { useSessionMessageSearch } from '../../sources/useSessionMessageSearch';
import type { GroupConfig } from '../types';
export const sessionsGroup: GroupConfig = {
id: 'sessions',
heading: 'Sessions',
modes: ['mixed'],
requiresProject: true,
useItems: (ctx) => {
const { items: sessions } = useSessionsSource(ctx.projectId, ctx.enabled);
const { items: messageMatches } = useSessionMessageSearch(
ctx.projectId,
ctx.query,
ctx.enabled,
);
type Row = { id: string; label: string; provider?: string; snippet?: string };
return useMemo(() => {
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()).map((s) => ({
key: `session-${s.id}`,
value: `${s.label} ${s.snippet ?? ''}`.trim(),
onSelect: () => ctx.run(() => ctx.navigate(`/session/${s.id}`)),
node: (
<>
<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>
)}
</>
),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessions, messageMatches]);
},
};

View File

@@ -0,0 +1,20 @@
import { SETTINGS_MAIN_TABS } from '../../../settings/constants/constants';
import type { GroupConfig } from '../types';
export const settingsGroup: GroupConfig = {
id: 'settings',
heading: 'Settings',
modes: ['mixed', 'actions'],
useItems: (ctx) =>
SETTINGS_MAIN_TABS.map(({ id, label, keywords, icon: Icon }) => ({
key: `settings-${id}`,
value: `Settings ${label} ${keywords}`,
onSelect: () => ctx.run(() => ctx.onOpenSettings(id)),
node: (
<>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">Settings: {label}</span>
</>
),
})),
};

View File

@@ -0,0 +1,29 @@
import { actionsGroup } from './groups/actions';
import { commitsGroup } from './groups/commits';
import { filesGroup } from './groups/files';
import { gitGroup } from './groups/git';
import { navigateGroup } from './groups/navigate';
import { sessionsGroup } from './groups/sessions';
import { settingsGroup } from './groups/settings';
import type { GroupConfig } from './types';
export const GROUPS: GroupConfig[] = [
actionsGroup,
navigateGroup,
gitGroup,
settingsGroup,
sessionsGroup,
filesGroup,
commitsGroup,
];
export function parseMode(input: string): { mode: string; query: string } {
for (const g of GROUPS) {
if (g.prefix && input.startsWith(g.prefix.char)) {
return { mode: g.prefix.mode, query: input.slice(g.prefix.char.length) };
}
}
return { mode: 'mixed', query: input };
}
export type { GroupConfig, PaletteCtx, PaletteItem } from './types';

View File

@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import type { NavigateFunction } from 'react-router-dom';
import type { AppTab, Project } from '../../../types/app';
export type PaletteCtx = {
projectId: string | undefined;
selectedProject: Project | null;
query: string;
enabled: boolean;
open: boolean;
run: (fn: () => void) => void;
navigate: NavigateFunction;
toggleDarkMode: () => void;
onStartNewChat: (project: Project) => void;
onOpenSettings: (tab?: string) => void;
onShowTab?: (tab: AppTab) => void;
openFile: (path: string) => void;
};
export type PaletteItem = {
key: string;
value: string;
node: ReactNode;
onSelect: () => void;
disabled?: boolean;
};
export type GroupConfig = {
id: string;
heading: string;
modes: string[];
prefix?: { char: string; mode: string };
requiresProject?: boolean;
useItems: (ctx: PaletteCtx) => PaletteItem[];
};