diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 17df2019..9520974b 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -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(); - 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 ( @@ -152,163 +84,50 @@ export default function CommandPalette({ /> No results. - - {showActions && ( - - { - if (!selectedProject) return; - run(() => onStartNewChat(selectedProject)); - }} - > - - Start new chat - {startNewChatDisabled && ( - Select a project first - )} - - run(() => onOpenSettings())}> - - Open settings - - run(toggleDarkMode)} - > - - Toggle theme - - - )} - - {showActions && ( - - {NAV_TABS.map((tab) => ( - run(() => onShowTab?.(tab.id))} - > - {tab.label} - - ))} - - )} - - {showActions && projectId && ( - - run(() => { void git.fetch(); onShowTab?.('git'); })} - > - - Git: Fetch - - run(() => { void git.pull(); onShowTab?.('git'); })} - > - - Git: Pull - - run(() => { void git.push(); onShowTab?.('git'); })} - > - - Git: Push - - {branches - .filter((b) => !b.isCurrent && !b.isRemote) - .slice(0, 30) - .map((b) => ( - run(() => { void git.checkout(b.name); onShowTab?.('git'); })} - > - - Switch to branch: {b.name} - - ))} - - )} - - {showActions && ( - - {SETTINGS_MAIN_TABS.map(({ id, label, keywords, icon: Icon }) => ( - run(() => onOpenSettings(id))} - > - - Settings: {label} - - ))} - - )} - - {showSessions && sessionRows.length > 0 && ( - - {sessionRows.map((s) => ( - run(() => navigate(`/session/${s.id}`))} - > - -
- {s.label} - {s.snippet && ( - {s.snippet} - )} -
- {s.provider && ( - {s.provider} - )} -
- ))} -
- )} - - {showFiles && files.length > 0 && ( - - {files.map((f) => ( - run(() => window.openFile?.(f.path))} - > - - {f.name} - {f.path} - - ))} - - )} - - {showCommits && commits.length > 0 && ( - - {commits.map((c) => ( - run(() => onShowTab?.('git'))} - > - - {c.shortHash} - {c.message} - {c.author} - - ))} - - )} + {GROUPS.map((group) => ( + + ))}
); } + +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 ( + + {items.map((item) => ( + + {item.node} + + ))} + + ); +} diff --git a/src/components/command-palette/registry/groups/actions.tsx b/src/components/command-palette/registry/groups/actions.tsx new file mode 100644 index 00000000..125a3fb3 --- /dev/null +++ b/src/components/command-palette/registry/groups/actions.tsx @@ -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: ( + <> + + Start new chat + {startDisabled && ( + Select a project first + )} + + ), + }, + { + key: 'open-settings', + value: 'Open settings', + onSelect: () => ctx.run(() => ctx.onOpenSettings()), + node: ( + <> + + Open settings + + ), + }, + { + key: 'toggle-theme', + value: 'Toggle theme dark light mode', + onSelect: () => ctx.run(ctx.toggleDarkMode), + node: ( + <> + + Toggle theme + + ), + }, + ]; + }, +}; diff --git a/src/components/command-palette/registry/groups/commits.tsx b/src/components/command-palette/registry/groups/commits.tsx new file mode 100644 index 00000000..13c68cff --- /dev/null +++ b/src/components/command-palette/registry/groups/commits.tsx @@ -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: ( + <> + + {c.shortHash} + {c.message} + {c.author} + + ), + })); + }, +}; diff --git a/src/components/command-palette/registry/groups/files.tsx b/src/components/command-palette/registry/groups/files.tsx new file mode 100644 index 00000000..2e32657c --- /dev/null +++ b/src/components/command-palette/registry/groups/files.tsx @@ -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: ( + <> + + {f.name} + {f.path} + + ), + })); + }, +}; diff --git a/src/components/command-palette/registry/groups/git.tsx b/src/components/command-palette/registry/groups/git.tsx new file mode 100644 index 00000000..2e96df8f --- /dev/null +++ b/src/components/command-palette/registry/groups/git.tsx @@ -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: ( + <> + + Git: Fetch + + ), + }, + { + key: 'git-pull', + value: 'Git Pull merge upstream', + onSelect: () => ctx.run(() => { void git.pull(); ctx.onShowTab?.('git'); }), + node: ( + <> + + Git: Pull + + ), + }, + { + key: 'git-push', + value: 'Git Push origin remote', + onSelect: () => ctx.run(() => { void git.push(); ctx.onShowTab?.('git'); }), + node: ( + <> + + Git: Push + + ), + }, + ]; + + 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: ( + <> + + Switch to branch: {b.name} + + ), + }); + } + return items; + }, +}; diff --git a/src/components/command-palette/registry/groups/navigate.tsx b/src/components/command-palette/registry/groups/navigate.tsx new file mode 100644 index 00000000..825471a5 --- /dev/null +++ b/src/components/command-palette/registry/groups/navigate.tsx @@ -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: {tab.label}, + })), +}; diff --git a/src/components/command-palette/registry/groups/sessions.tsx b/src/components/command-palette/registry/groups/sessions.tsx new file mode 100644 index 00000000..9c065637 --- /dev/null +++ b/src/components/command-palette/registry/groups/sessions.tsx @@ -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(); + 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: ( + <> + +
+ {s.label} + {s.snippet && ( + {s.snippet} + )} +
+ {s.provider && ( + {s.provider} + )} + + ), + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessions, messageMatches]); + }, +}; diff --git a/src/components/command-palette/registry/groups/settings.tsx b/src/components/command-palette/registry/groups/settings.tsx new file mode 100644 index 00000000..8bd170f5 --- /dev/null +++ b/src/components/command-palette/registry/groups/settings.tsx @@ -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: ( + <> + + Settings: {label} + + ), + })), +}; diff --git a/src/components/command-palette/registry/index.ts b/src/components/command-palette/registry/index.ts new file mode 100644 index 00000000..01dcaa0b --- /dev/null +++ b/src/components/command-palette/registry/index.ts @@ -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'; diff --git a/src/components/command-palette/registry/types.ts b/src/components/command-palette/registry/types.ts new file mode 100644 index 00000000..49a54ec9 --- /dev/null +++ b/src/components/command-palette/registry/types.ts @@ -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[]; +};