mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-01 18:28:38 +00:00
Create command palette and add new features for search and actions (#728)
* refactor(ui): replace in-repo Command primitive with cmdk wrapper * feat(command-palette): add global Cmd+K palette with v1 actions * feat(command-palette): add session, file, and commit search sources * refactor: add provider names to model constants * feat(command-palette): add settings, navigation, message search, and ⌘K hints * feat(command-palette): add git fetch/pull/push and branch switch actions * refactor(command-palette): consolidate fetch source hooks behind useApiSource * refactor(command-palette): extract useCommandKey and SETTINGS_MAIN_TABS metadata * refactor(command-palette): extract groups into declarative registry * refactor(command-palette): wire openFile through PaletteOpsContext * refactor: migrate openSettings and refreshProjects from window.* to PaletteOpsContext * refactor(command-palette): inline groups and delete registry indirection * refactor(command-palette): return items array directly from source hooks * refactor(palette-ops): flatten Handle wrapper into ref-based registry * refactor: inline useCommandKey as MOD_KEY constant in two call sites * feat: introduce pages and fix bug on branch switching * fix: small labels * fix: coderabbit issues * fix: coderabbit comments * Update src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
37
src/components/command-palette/sources/useApiSource.ts
Normal file
37
src/components/command-palette/sources/useApiSource.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState, type DependencyList } from 'react';
|
||||
|
||||
export function useApiSource<T, R = unknown>(opts: {
|
||||
enabled: boolean;
|
||||
deps: DependencyList;
|
||||
fetcher: (signal: AbortSignal) => Promise<Response>;
|
||||
parse: (raw: R) => T[];
|
||||
}): T[] {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const { enabled, deps, fetcher, parse } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
fetcher(controller.signal)
|
||||
.then((r) => r.json() as Promise<R>)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setItems(parse(data));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setItems([]);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, ...deps]);
|
||||
|
||||
return items;
|
||||
}
|
||||
21
src/components/command-palette/sources/useBranchesSource.ts
Normal file
21
src/components/command-palette/sources/useBranchesSource.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
import { useApiSource } from './useApiSource';
|
||||
|
||||
export type BranchResult = { name: string };
|
||||
|
||||
interface BranchesResponse {
|
||||
localBranches?: string[];
|
||||
}
|
||||
|
||||
export function useBranchesSource(projectId: string | undefined, enabled: boolean) {
|
||||
return useApiSource<BranchResult, BranchesResponse>({
|
||||
enabled: enabled && !!projectId,
|
||||
deps: [projectId],
|
||||
fetcher: (signal) => {
|
||||
const params = new URLSearchParams({ project: projectId! });
|
||||
return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal });
|
||||
},
|
||||
parse: (data) => (data.localBranches ?? []).map((name) => ({ name })),
|
||||
});
|
||||
}
|
||||
35
src/components/command-palette/sources/useCommitsSource.ts
Normal file
35
src/components/command-palette/sources/useCommitsSource.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
import { useApiSource } from './useApiSource';
|
||||
|
||||
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) {
|
||||
return useApiSource<CommitResult, CommitsResponse>({
|
||||
enabled: enabled && !!projectId,
|
||||
deps: [projectId],
|
||||
fetcher: (signal) => {
|
||||
const params = new URLSearchParams({ project: projectId!, limit: '50' });
|
||||
return authenticatedFetch(`/api/git/commits?${params.toString()}`, { signal });
|
||||
},
|
||||
parse: (data) => {
|
||||
if (!data.commits) return [];
|
||||
return data.commits.map<CommitResult>((c) => ({
|
||||
hash: c.hash,
|
||||
shortHash: c.hash.slice(0, 7),
|
||||
message: c.message,
|
||||
author: c.author,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
42
src/components/command-palette/sources/useFilesSource.ts
Normal file
42
src/components/command-palette/sources/useFilesSource.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { api } from '../../../utils/api';
|
||||
|
||||
import { useApiSource } from './useApiSource';
|
||||
|
||||
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) {
|
||||
return useApiSource<FileResult, unknown>({
|
||||
enabled: enabled && !!projectId,
|
||||
deps: [projectId],
|
||||
fetcher: (signal) => api.getFiles(projectId!, { signal }),
|
||||
parse: (data) => {
|
||||
const tree: FileNode[] = Array.isArray(data) ? (data as FileNode[]) : [];
|
||||
const flat: FileResult[] = [];
|
||||
flatten(tree, flat);
|
||||
return flat;
|
||||
},
|
||||
});
|
||||
}
|
||||
38
src/components/command-palette/sources/useGitActions.ts
Normal file
38
src/components/command-palette/sources/useGitActions.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
async function postGit(path: string, body: Record<string, unknown>) {
|
||||
const res = await authenticatedFetch(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function useGitActions(projectId: string | undefined) {
|
||||
const fetch = useCallback(() => {
|
||||
if (!projectId) return Promise.resolve();
|
||||
return postGit('/api/git/fetch', { project: projectId });
|
||||
}, [projectId]);
|
||||
|
||||
const pull = useCallback(() => {
|
||||
if (!projectId) return Promise.resolve();
|
||||
return postGit('/api/git/pull', { project: projectId });
|
||||
}, [projectId]);
|
||||
|
||||
const push = useCallback(() => {
|
||||
if (!projectId) return Promise.resolve();
|
||||
return postGit('/api/git/push', { project: projectId });
|
||||
}, [projectId]);
|
||||
|
||||
const checkout = useCallback(
|
||||
(branch: string) => {
|
||||
if (!projectId) return Promise.resolve();
|
||||
return postGit('/api/git/checkout', { project: projectId, branch });
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
return { fetch, pull, push, checkout };
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '../../../utils/api';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
|
||||
export type SessionMessageMatch = {
|
||||
sessionId: string;
|
||||
label: string;
|
||||
snippet: string;
|
||||
provider: LLMProvider;
|
||||
};
|
||||
|
||||
type ProjectResult = {
|
||||
projectId: string | null;
|
||||
projectName: string;
|
||||
sessions: Array<{
|
||||
sessionId: string;
|
||||
provider: LLMProvider;
|
||||
sessionSummary: string;
|
||||
matches: Array<{ snippet: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const MIN_QUERY = 2;
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
export function useSessionMessageSearch(
|
||||
projectId: string | undefined,
|
||||
query: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const [items, setItems] = useState<SessionMessageMatch[]>([]);
|
||||
const seqRef = useRef(0);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const trimmed = query.trim();
|
||||
if (!enabled || !projectId || trimmed.length < MIN_QUERY) {
|
||||
setItems([]);
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
seqRef.current++;
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
const seq = ++seqRef.current;
|
||||
const url = api.searchConversationsUrl(trimmed);
|
||||
const es = new EventSource(url);
|
||||
esRef.current = es;
|
||||
const accumulated: SessionMessageMatch[] = [];
|
||||
|
||||
es.addEventListener('result', (evt) => {
|
||||
if (seq !== seqRef.current) {
|
||||
es.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse((evt as MessageEvent).data) as { projectResult: ProjectResult };
|
||||
const pr = data.projectResult;
|
||||
if (pr.projectId !== projectId) return;
|
||||
for (const s of pr.sessions) {
|
||||
accumulated.push({
|
||||
sessionId: s.sessionId,
|
||||
label: s.sessionSummary || s.sessionId,
|
||||
snippet: s.matches[0]?.snippet ?? '',
|
||||
provider: s.provider,
|
||||
});
|
||||
}
|
||||
setItems([...accumulated]);
|
||||
} catch {
|
||||
// ignore malformed
|
||||
}
|
||||
});
|
||||
|
||||
const finish = () => {
|
||||
if (seq !== seqRef.current) return;
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
es.addEventListener('done', finish);
|
||||
es.addEventListener('error', finish);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [projectId, query, enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
esRef.current?.close();
|
||||
esRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return items;
|
||||
}
|
||||
44
src/components/command-palette/sources/useSessionsSource.ts
Normal file
44
src/components/command-palette/sources/useSessionsSource.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { LLMProvider, ProjectSession } from '../../../types/app';
|
||||
|
||||
import { useApiSource } from './useApiSource';
|
||||
|
||||
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) {
|
||||
return useApiSource<SessionResult, SessionsResponse>({
|
||||
enabled: enabled && !!projectId,
|
||||
deps: [projectId],
|
||||
fetcher: (signal) => {
|
||||
const params = new URLSearchParams({ limit: '50', offset: '0' });
|
||||
return authenticatedFetch(
|
||||
`/api/projects/${encodeURIComponent(projectId!)}/sessions?${params.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
parse: (data) => {
|
||||
const all: ProjectSession[] = [
|
||||
...(data.sessions ?? []),
|
||||
...(data.cursorSessions ?? []),
|
||||
...(data.codexSessions ?? []),
|
||||
...(data.geminiSessions ?? []),
|
||||
];
|
||||
return all.map<SessionResult>((s) => ({
|
||||
id: s.id,
|
||||
label: (s.title || s.summary || s.name || s.id) as string,
|
||||
provider: s.__provider,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user