feat(command-palette): add session, file, and commit search sources

This commit is contained in:
simosmik
2026-04-30 06:48:46 +00:00
parent 2811c00eb8
commit 4ce54946ce
7 changed files with 332 additions and 34 deletions

View File

@@ -209,6 +209,7 @@ export default function AppContent() {
selectedProject={selectedProject}
onStartNewChat={handleNewSession}
onOpenSettings={() => openSettings()}
onShowTab={setActiveTab}
/>
</div>
);

View File

@@ -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({
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl overflow-hidden p-0">
<DialogTitle>Command palette</DialogTitle>
<Command label="Command palette">
<CommandInput placeholder="Type a command or search…" />
<Command label="Command palette" filter={filter}>
<CommandInput
placeholder="Type to search — prefix with > for actions, / for files, # for commits"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<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="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>
)}
{showSessions && sessions.length > 0 && (
<CommandGroup heading="Sessions">
{sessions.map((s) => (
<CommandItem
key={s.id}
value={`session ${s.label} ${s.id}`}
onSelect={() => run(() => navigate(`/session/${s.id}`))}
>
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1 truncate">{s.label}</span>
{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>
)}
</CommandList>
</Command>
</DialogContent>

View File

@@ -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<CommitResult[]>([]);
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<CommitsResponse>)
.then((data) => {
if (cancelled) return;
if (!data.commits) {
setItems([]);
return;
}
setItems(
data.commits.map<CommitResult>((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 };
}

View File

@@ -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<FileResult[]>([]);
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 };
}

View File

@@ -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<SessionResult[]>([]);
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<SessionsResponse>)
.then((data) => {
if (cancelled) return;
const all: ProjectSession[] = [
...(data.sessions ?? []),
...(data.cursorSessions ?? []),
...(data.codexSessions ?? []),
...(data.geminiSessions ?? []),
];
setItems(
all.map<SessionResult>((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 };
}

View File

@@ -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 <MainContentStateView mode="loading" isMobile={isMobile} onMenuClick={onMenuClick} />;
}

View File

@@ -5,6 +5,7 @@ declare global {
__ROUTER_BASENAME__?: string;
refreshProjects?: () => void | Promise<void>;
openSettings?: (tab?: string) => void;
openFile?: (filePath: string) => void;
}
interface EventSourceEventMap {