diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 11ae12a4..e00e2ec3 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,5 +1,17 @@ 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 { Command, @@ -14,10 +26,17 @@ import { } 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 { GROUPS, parseMode } from './registry'; -import type { GroupConfig, PaletteCtx } from './registry'; +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'; type CommandPaletteProps = { selectedProject: Project | null; @@ -26,6 +45,21 @@ 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, @@ -56,23 +90,53 @@ export default function CommandPalette({ const { mode, query } = parseMode(search); const projectId = selectedProject?.projectId; + const showActions = mode === 'mixed' || mode === 'actions'; + const showSessions = mode === 'mixed'; + const showFiles = mode === 'mixed' || mode === 'files'; + const showCommits = mode === 'mixed' || mode === 'commits'; + + const { items: sessions } = useSessionsSource(projectId, open && showSessions); + const { items: messageMatches } = useSessionMessageSearch(projectId, query, open && showSessions); + const { items: files } = useFilesSource(projectId, open && showFiles); + const { items: commits } = useCommitsSource(projectId, open && showCommits); + const { items: branches } = useBranchesSource(projectId, open && showActions); + 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(); + 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 filter = React.useCallback((value: string, rawSearch: string) => { + const stripped = parseMode(rawSearch).query.trim().toLowerCase(); + if (!stripped) return 1; + return value.toLowerCase().includes(stripped) ? 1 : 0; + }, []); + const run = React.useCallback((fn: () => void) => { setOpen(false); fn(); }, []); - const openFile = React.useCallback((path: string) => { - ops.openFile(path); - }, [ops]); - - const filter = React.useCallback( - (value: string, rawSearch: string) => { - const stripped = parseMode(rawSearch).query.trim().toLowerCase(); - if (!stripped) return 1; - return value.toLowerCase().includes(stripped) ? 1 : 0; - }, - [], - ); + const startNewChatDisabled = !selectedProject; return ( @@ -86,50 +150,160 @@ export default function CommandPalette({ /> No results. - {GROUPS.map((group) => ( - - ))} + + {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 && projectId && sessionRows.length > 0 && ( + + {sessionRows.map((s) => ( + run(() => navigate(`/session/${s.id}`))} + > + +
+ {s.label} + {s.snippet && ( + {s.snippet} + )} +
+ {s.provider && ( + {s.provider} + )} +
+ ))} +
+ )} + + {showFiles && projectId && files.length > 0 && ( + + {files.map((f) => ( + run(() => ops.openFile(f.path))} + > + + {f.name} + {f.path} + + ))} + + )} + + {showCommits && projectId && commits.length > 0 && ( + + {commits.map((c) => ( + run(() => onShowTab?.('git'))} + > + + {c.shortHash} + {c.message} + {c.author} + + ))} + + )}
); } - -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 deleted file mode 100644 index 125a3fb3..00000000 --- a/src/components/command-palette/registry/groups/actions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 13c68cff..00000000 --- a/src/components/command-palette/registry/groups/commits.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 2e32657c..00000000 --- a/src/components/command-palette/registry/groups/files.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 2e96df8f..00000000 --- a/src/components/command-palette/registry/groups/git.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 825471a5..00000000 --- a/src/components/command-palette/registry/groups/navigate.tsx +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 9c065637..00000000 --- a/src/components/command-palette/registry/groups/sessions.tsx +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 8bd170f5..00000000 --- a/src/components/command-palette/registry/groups/settings.tsx +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 01dcaa0b..00000000 --- a/src/components/command-palette/registry/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 49a54ec9..00000000 --- a/src/components/command-palette/registry/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -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[]; -};