diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx index 1671fa44..db42a378 100644 --- a/src/components/app/AppContent.tsx +++ b/src/components/app/AppContent.tsx @@ -209,6 +209,7 @@ export default function AppContent() { selectedProject={selectedProject} onStartNewChat={handleNewSession} onOpenSettings={() => openSettings()} + onShowTab={setActiveTab} /> ); diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index d2109e77..b60630eb 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; -import { MessageSquarePlus, Settings, SunMoon } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { FileText, GitCommit, MessageSquare, MessageSquarePlus, Settings, SunMoon } from 'lucide-react'; import { Command, @@ -13,21 +14,38 @@ import { DialogTitle, } from '../../shared/view/ui'; import { useTheme } from '../../contexts/ThemeContext'; -import type { Project } from '../../types/app'; +import type { AppTab, Project } from '../../types/app'; + +import { useSessionsSource } from './sources/useSessionsSource'; +import { useFilesSource } from './sources/useFilesSource'; +import { useCommitsSource } from './sources/useCommitsSource'; + +type Mode = 'mixed' | 'actions' | 'files' | 'commits'; type CommandPaletteProps = { selectedProject: Project | null; onStartNewChat: (project: Project) => void; onOpenSettings: () => void; + 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 }; +} + export default function CommandPalette({ selectedProject, onStartNewChat, onOpenSettings, + onShowTab, }: CommandPaletteProps) { const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(''); const { toggleDarkMode } = useTheme(); + const navigate = useNavigate(); React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -40,6 +58,31 @@ export default function CommandPalette({ return () => document.removeEventListener('keydown', handleKeyDown); }, []); + React.useEffect(() => { + if (!open) setSearch(''); + }, [open]); + + const { mode } = parseMode(search); + + const projectId = selectedProject?.projectId; + const { items: sessions } = useSessionsSource(projectId, open && (mode === 'mixed')); + const { items: files } = useFilesSource(projectId, open && (mode === 'mixed' || mode === 'files')); + const { items: commits } = useCommitsSource(projectId, open && (mode === 'mixed' || mode === 'commits')); + + const showActions = mode === 'mixed' || mode === 'actions'; + const showSessions = mode === 'mixed'; + const showFiles = mode === 'mixed' || mode === 'files'; + const showCommits = mode === 'mixed' || mode === 'commits'; + + 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(); @@ -51,40 +94,95 @@ export default function CommandPalette({ Command palette - - + + No results. - - { - if (!selectedProject) return; - run(() => onStartNewChat(selectedProject)); - }} - > - - Start new chat - {startNewChatDisabled && ( - Select a project first - )} - - run(onOpenSettings)} - > - - Open settings - - run(toggleDarkMode)} - > - - Toggle theme - - + + {showActions && ( + + { + if (!selectedProject) return; + run(() => onStartNewChat(selectedProject)); + }} + > + + Start new chat + {startNewChatDisabled && ( + Select a project first + )} + + run(onOpenSettings)}> + + Open settings + + run(toggleDarkMode)} + > + + Toggle theme + + + )} + + {showSessions && sessions.length > 0 && ( + + {sessions.map((s) => ( + run(() => navigate(`/session/${s.id}`))} + > + + {s.label} + {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} + + ))} + + )} diff --git a/src/components/command-palette/sources/useCommitsSource.ts b/src/components/command-palette/sources/useCommitsSource.ts new file mode 100644 index 00000000..ae091ea1 --- /dev/null +++ b/src/components/command-palette/sources/useCommitsSource.ts @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; + +import { authenticatedFetch } from '../../../utils/api'; + +export type CommitResult = { + hash: string; + shortHash: string; + message: string; + author: string; +}; + +interface CommitsResponse { + commits?: Array<{ hash: string; message: string; author: string }>; + error?: string; +} + +export function useCommitsSource(projectId: string | undefined, enabled: boolean) { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!enabled || !projectId) { + setItems([]); + return; + } + let cancelled = false; + setIsLoading(true); + const params = new URLSearchParams({ project: projectId, limit: '50' }); + authenticatedFetch(`/api/git/commits?${params.toString()}`) + .then((r) => r.json() as Promise) + .then((data) => { + if (cancelled) return; + if (!data.commits) { + setItems([]); + return; + } + setItems( + data.commits.map((c) => ({ + hash: c.hash, + shortHash: c.hash.slice(0, 7), + message: c.message, + author: c.author, + })), + ); + }) + .catch(() => { + if (!cancelled) setItems([]); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId, enabled]); + + return { items, isLoading }; +} diff --git a/src/components/command-palette/sources/useFilesSource.ts b/src/components/command-palette/sources/useFilesSource.ts new file mode 100644 index 00000000..972a6148 --- /dev/null +++ b/src/components/command-palette/sources/useFilesSource.ts @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; + +import { api } from '../../../utils/api'; + +export type FileResult = { + path: string; + name: string; +}; + +interface FileNode { + type: 'file' | 'directory'; + name: string; + path: string; + children?: FileNode[]; +} + +const MAX_FILES = 500; + +function flatten(nodes: FileNode[], out: FileResult[]): void { + for (const node of nodes) { + if (out.length >= MAX_FILES) return; + if (node.type === 'file') { + out.push({ path: node.path, name: node.name }); + } else if (node.children && node.children.length > 0) { + flatten(node.children, out); + } + } +} + +export function useFilesSource(projectId: string | undefined, enabled: boolean) { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!enabled || !projectId) { + setItems([]); + return; + } + let cancelled = false; + setIsLoading(true); + api + .getFiles(projectId) + .then((r) => r.json()) + .then((data: unknown) => { + if (cancelled) return; + const tree: FileNode[] = Array.isArray(data) ? (data as FileNode[]) : []; + const flat: FileResult[] = []; + flatten(tree, flat); + setItems(flat); + }) + .catch(() => { + if (!cancelled) setItems([]); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId, enabled]); + + return { items, isLoading }; +} diff --git a/src/components/command-palette/sources/useSessionsSource.ts b/src/components/command-palette/sources/useSessionsSource.ts new file mode 100644 index 00000000..aa91f5e3 --- /dev/null +++ b/src/components/command-palette/sources/useSessionsSource.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +import { api } from '../../../utils/api'; +import type { LLMProvider, ProjectSession } from '../../../types/app'; + +export type SessionResult = { + id: string; + label: string; + provider?: LLMProvider; +}; + +interface SessionsResponse { + sessions?: ProjectSession[]; + cursorSessions?: ProjectSession[]; + codexSessions?: ProjectSession[]; + geminiSessions?: ProjectSession[]; +} + +export function useSessionsSource(projectId: string | undefined, enabled: boolean) { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (!enabled || !projectId) { + setItems([]); + return; + } + let cancelled = false; + setIsLoading(true); + api + .projectSessions(projectId, { limit: 50 }) + .then((r) => r.json() as Promise) + .then((data) => { + if (cancelled) return; + const all: ProjectSession[] = [ + ...(data.sessions ?? []), + ...(data.cursorSessions ?? []), + ...(data.codexSessions ?? []), + ...(data.geminiSessions ?? []), + ]; + setItems( + all.map((s) => ({ + id: s.id, + label: (s.title || s.summary || s.name || s.id) as string, + provider: s.__provider, + })), + ); + }) + .catch(() => { + if (!cancelled) setItems([]); + }) + .finally(() => { + if (!cancelled) setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [projectId, enabled]); + + return { items, isLoading }; +} diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index bf0b87fc..ba3d0840 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; + import ChatInterface from '../../chat/view/ChatInterface'; import FileTree from '../../file-tree/view/FileTree'; import StandaloneShell from '../../standalone-shell/view/StandaloneShell'; @@ -12,6 +13,7 @@ import { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar'; import EditorSidebar from '../../code-editor/view/EditorSidebar'; import type { Project } from '../../../types/app'; import { TaskMasterPanel } from '../../task-master'; + import MainContentHeader from './subcomponents/MainContentHeader'; import MainContentStateView from './subcomponents/MainContentStateView'; import ErrorBoundary from './ErrorBoundary'; @@ -89,6 +91,20 @@ function MainContent({ } }, [shouldShowTasksTab, activeTab, setActiveTab]); + // Expose file-open to non-descendant features (command palette). + useEffect(() => { + const open = (filePath: string) => { + setActiveTab('files'); + handleFileOpen(filePath); + }; + window.openFile = open; + return () => { + if (window.openFile === open) { + delete window.openFile; + } + }; + }, [handleFileOpen, setActiveTab]); + if (isLoading) { return ; } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ba368e4b..aea2538a 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -5,6 +5,7 @@ declare global { __ROUTER_BASENAME__?: string; refreshProjects?: () => void | Promise; openSettings?: (tab?: string) => void; + openFile?: (filePath: string) => void; } interface EventSourceEventMap {