refactor(command-palette): consolidate fetch source hooks behind useApiSource

This commit is contained in:
simosmik
2026-04-30 07:47:46 +00:00
parent 95ad7272e9
commit f1d7df2b1e
5 changed files with 130 additions and 153 deletions

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
export type ApiSourceState<T> = {
items: T[];
isLoading: boolean;
error: Error | null;
};
export function useApiSource<T, R = unknown>(opts: {
enabled: boolean;
deps: React.DependencyList;
fetcher: (signal: AbortSignal) => Promise<Response>;
parse: (raw: R) => T[];
}): ApiSourceState<T> {
const [items, setItems] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const { enabled, deps, fetcher, parse } = opts;
useEffect(() => {
if (!enabled) {
setItems([]);
setError(null);
return;
}
const controller = new AbortController();
setIsLoading(true);
setError(null);
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([]);
setError(err instanceof Error ? err : new Error(String(err)));
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, ...deps]);
return { items, isLoading, error };
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type BranchResult = { export type BranchResult = {
name: string; name: string;
isCurrent: boolean; isCurrent: boolean;
@@ -13,35 +13,20 @@ interface BranchesResponse {
} }
export function useBranchesSource(projectId: string | undefined, enabled: boolean) { export function useBranchesSource(projectId: string | undefined, enabled: boolean) {
const [items, setItems] = useState<BranchResult[]>([]); return useApiSource<BranchResult, BranchesResponse>({
enabled: enabled && !!projectId,
useEffect(() => { deps: [projectId],
if (!enabled || !projectId) { fetcher: (signal) => {
setItems([]); const params = new URLSearchParams({ project: projectId! });
return; return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal });
} },
let cancelled = false; parse: (data) => {
const params = new URLSearchParams({ project: projectId }); const list = data.branches ?? [];
authenticatedFetch(`/api/git/branches?${params.toString()}`) return list.map<BranchResult>((b) => ({
.then((r) => r.json() as Promise<BranchesResponse>) name: b.name,
.then((data) => { isCurrent: Boolean(b.current),
if (cancelled) return; isRemote: Boolean(b.isRemote),
const list = data.branches ?? []; }));
setItems( },
list.map<BranchResult>((b) => ({ });
name: b.name,
isCurrent: Boolean(b.current),
isRemote: Boolean(b.isRemote),
})),
);
})
.catch(() => {
if (!cancelled) setItems([]);
});
return () => {
cancelled = true;
};
}, [projectId, enabled]);
return { items };
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api'; import { authenticatedFetch } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type CommitResult = { export type CommitResult = {
hash: string; hash: string;
shortHash: string; shortHash: string;
@@ -15,44 +15,21 @@ interface CommitsResponse {
} }
export function useCommitsSource(projectId: string | undefined, enabled: boolean) { export function useCommitsSource(projectId: string | undefined, enabled: boolean) {
const [items, setItems] = useState<CommitResult[]>([]); return useApiSource<CommitResult, CommitsResponse>({
const [isLoading, setIsLoading] = useState(false); enabled: enabled && !!projectId,
deps: [projectId],
useEffect(() => { fetcher: (signal) => {
if (!enabled || !projectId) { const params = new URLSearchParams({ project: projectId!, limit: '50' });
setItems([]); return authenticatedFetch(`/api/git/commits?${params.toString()}`, { signal });
return; },
} parse: (data) => {
let cancelled = false; if (!data.commits) return [];
setIsLoading(true); return data.commits.map<CommitResult>((c) => ({
const params = new URLSearchParams({ project: projectId, limit: '50' }); hash: c.hash,
authenticatedFetch(`/api/git/commits?${params.toString()}`) shortHash: c.hash.slice(0, 7),
.then((r) => r.json() as Promise<CommitsResponse>) message: c.message,
.then((data) => { author: c.author,
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

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { api } from '../../../utils/api'; import { api } from '../../../utils/api';
import { useApiSource } from './useApiSource';
export type FileResult = { export type FileResult = {
path: string; path: string;
name: string; name: string;
@@ -28,36 +28,15 @@ function flatten(nodes: FileNode[], out: FileResult[]): void {
} }
export function useFilesSource(projectId: string | undefined, enabled: boolean) { export function useFilesSource(projectId: string | undefined, enabled: boolean) {
const [items, setItems] = useState<FileResult[]>([]); return useApiSource<FileResult, unknown>({
const [isLoading, setIsLoading] = useState(false); enabled: enabled && !!projectId,
deps: [projectId],
useEffect(() => { fetcher: (signal) => api.getFiles(projectId!, { signal }),
if (!enabled || !projectId) { parse: (data) => {
setItems([]); const tree: FileNode[] = Array.isArray(data) ? (data as FileNode[]) : [];
return; const flat: FileResult[] = [];
} flatten(tree, flat);
let cancelled = false; return flat;
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

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { authenticatedFetch } from '../../../utils/api';
import { api } from '../../../utils/api';
import type { LLMProvider, ProjectSession } from '../../../types/app'; import type { LLMProvider, ProjectSession } from '../../../types/app';
import { useApiSource } from './useApiSource';
export type SessionResult = { export type SessionResult = {
id: string; id: string;
label: string; label: string;
@@ -17,45 +17,28 @@ interface SessionsResponse {
} }
export function useSessionsSource(projectId: string | undefined, enabled: boolean) { export function useSessionsSource(projectId: string | undefined, enabled: boolean) {
const [items, setItems] = useState<SessionResult[]>([]); return useApiSource<SessionResult, SessionsResponse>({
const [isLoading, setIsLoading] = useState(false); enabled: enabled && !!projectId,
deps: [projectId],
useEffect(() => { fetcher: (signal) => {
if (!enabled || !projectId) { const params = new URLSearchParams({ limit: '50', offset: '0' });
setItems([]); return authenticatedFetch(
return; `/api/projects/${encodeURIComponent(projectId!)}/sessions?${params.toString()}`,
} { signal },
let cancelled = false; );
setIsLoading(true); },
api parse: (data) => {
.projectSessions(projectId, { limit: 50 }) const all: ProjectSession[] = [
.then((r) => r.json() as Promise<SessionsResponse>) ...(data.sessions ?? []),
.then((data) => { ...(data.cursorSessions ?? []),
if (cancelled) return; ...(data.codexSessions ?? []),
const all: ProjectSession[] = [ ...(data.geminiSessions ?? []),
...(data.sessions ?? []), ];
...(data.cursorSessions ?? []), return all.map<SessionResult>((s) => ({
...(data.codexSessions ?? []), id: s.id,
...(data.geminiSessions ?? []), label: (s.title || s.summary || s.name || s.id) as string,
]; provider: s.__provider,
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 };
} }