mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-01 18:05:32 +08: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}
|
selectedProject={selectedProject}
|
||||||
onStartNewChat={handleNewSession}
|
onStartNewChat={handleNewSession}
|
||||||
onOpenSettings={() => openSettings()}
|
onOpenSettings={() => openSettings()}
|
||||||
|
onShowTab={setActiveTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
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 {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -13,21 +14,38 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '../../shared/view/ui';
|
} from '../../shared/view/ui';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
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 = {
|
type CommandPaletteProps = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
onStartNewChat: (project: Project) => void;
|
onStartNewChat: (project: Project) => void;
|
||||||
onOpenSettings: () => 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({
|
export default function CommandPalette({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
onStartNewChat,
|
onStartNewChat,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
onShowTab,
|
||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
const { toggleDarkMode } = useTheme();
|
const { toggleDarkMode } = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -40,6 +58,31 @@ export default function CommandPalette({
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
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) => {
|
const run = React.useCallback((fn: () => void) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
fn();
|
fn();
|
||||||
@@ -51,40 +94,95 @@ export default function CommandPalette({
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||||
<DialogTitle>Command palette</DialogTitle>
|
<DialogTitle>Command palette</DialogTitle>
|
||||||
<Command label="Command palette">
|
<Command label="Command palette" filter={filter}>
|
||||||
<CommandInput placeholder="Type a command or search…" />
|
<CommandInput
|
||||||
|
placeholder="Type to search — prefix with > for actions, / for files, # for commits"
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results.</CommandEmpty>
|
<CommandEmpty>No results.</CommandEmpty>
|
||||||
<CommandGroup heading="Actions">
|
|
||||||
<CommandItem
|
{showActions && (
|
||||||
value="start new chat"
|
<CommandGroup heading="Actions">
|
||||||
disabled={startNewChatDisabled}
|
<CommandItem
|
||||||
onSelect={() => {
|
value="start new chat"
|
||||||
if (!selectedProject) return;
|
disabled={startNewChatDisabled}
|
||||||
run(() => onStartNewChat(selectedProject));
|
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 && (
|
<MessageSquarePlus className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<span className="text-xs text-muted-foreground">Select a project first</span>
|
<span className="flex-1">Start new chat</span>
|
||||||
)}
|
{startNewChatDisabled && (
|
||||||
</CommandItem>
|
<span className="text-xs text-muted-foreground">Select a project first</span>
|
||||||
<CommandItem
|
)}
|
||||||
value="open settings"
|
</CommandItem>
|
||||||
onSelect={() => run(onOpenSettings)}
|
<CommandItem value="open settings" onSelect={() => run(onOpenSettings)}>
|
||||||
>
|
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<Settings className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<span className="flex-1">Open settings</span>
|
||||||
<span className="flex-1">Open settings</span>
|
</CommandItem>
|
||||||
</CommandItem>
|
<CommandItem
|
||||||
<CommandItem
|
value="toggle theme dark light mode"
|
||||||
value="toggle theme dark light mode"
|
onSelect={() => run(toggleDarkMode)}
|
||||||
onSelect={() => run(toggleDarkMode)}
|
>
|
||||||
>
|
<SunMoon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<SunMoon className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<span className="flex-1">Toggle theme</span>
|
||||||
<span className="flex-1">Toggle theme</span>
|
</CommandItem>
|
||||||
</CommandItem>
|
</CommandGroup>
|
||||||
</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>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</DialogContent>
|
</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 React, { useEffect } from 'react';
|
||||||
|
|
||||||
import ChatInterface from '../../chat/view/ChatInterface';
|
import ChatInterface from '../../chat/view/ChatInterface';
|
||||||
import FileTree from '../../file-tree/view/FileTree';
|
import FileTree from '../../file-tree/view/FileTree';
|
||||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
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 EditorSidebar from '../../code-editor/view/EditorSidebar';
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import { TaskMasterPanel } from '../../task-master';
|
import { TaskMasterPanel } from '../../task-master';
|
||||||
|
|
||||||
import MainContentHeader from './subcomponents/MainContentHeader';
|
import MainContentHeader from './subcomponents/MainContentHeader';
|
||||||
import MainContentStateView from './subcomponents/MainContentStateView';
|
import MainContentStateView from './subcomponents/MainContentStateView';
|
||||||
import ErrorBoundary from './ErrorBoundary';
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
@@ -89,6 +91,20 @@ function MainContent({
|
|||||||
}
|
}
|
||||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return <MainContentStateView mode="loading" isMobile={isMobile} onMenuClick={onMenuClick} />;
|
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;
|
__ROUTER_BASENAME__?: string;
|
||||||
refreshProjects?: () => void | Promise<void>;
|
refreshProjects?: () => void | Promise<void>;
|
||||||
openSettings?: (tab?: string) => void;
|
openSettings?: (tab?: string) => void;
|
||||||
|
openFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventSourceEventMap {
|
interface EventSourceEventMap {
|
||||||
|
|||||||
Reference in New Issue
Block a user