mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 10:18:37 +00:00
feat(command-palette): add session, file, and commit search sources
This commit is contained in:
@@ -209,6 +209,7 @@ export default function AppContent() {
|
||||
selectedProject={selectedProject}
|
||||
onStartNewChat={handleNewSession}
|
||||
onOpenSettings={() => openSettings()}
|
||||
onShowTab={setActiveTab}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
src/components/command-palette/sources/useCommitsSource.ts
Normal file
58
src/components/command-palette/sources/useCommitsSource.ts
Normal 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 };
|
||||
}
|
||||
63
src/components/command-palette/sources/useFilesSource.ts
Normal file
63
src/components/command-palette/sources/useFilesSource.ts
Normal 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 };
|
||||
}
|
||||
61
src/components/command-palette/sources/useSessionsSource.ts
Normal file
61
src/components/command-palette/sources/useSessionsSource.ts
Normal 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 };
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
1
src/types/global.d.ts
vendored
1
src/types/global.d.ts
vendored
@@ -5,6 +5,7 @@ declare global {
|
||||
__ROUTER_BASENAME__?: string;
|
||||
refreshProjects?: () => void | Promise<void>;
|
||||
openSettings?: (tab?: string) => void;
|
||||
openFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
interface EventSourceEventMap {
|
||||
|
||||
Reference in New Issue
Block a user