From f1d7df2b1e824efdc8967311161ca3c34bcb9d4a Mon Sep 17 00:00:00 2001 From: simosmik Date: Thu, 30 Apr 2026 07:47:46 +0000 Subject: [PATCH] refactor(command-palette): consolidate fetch source hooks behind useApiSource --- .../command-palette/sources/useApiSource.ts | 53 ++++++++++++++ .../sources/useBranchesSource.ts | 51 +++++-------- .../sources/useCommitsSource.ts | 61 +++++----------- .../command-palette/sources/useFilesSource.ts | 47 ++++-------- .../sources/useSessionsSource.ts | 71 +++++++------------ 5 files changed, 130 insertions(+), 153 deletions(-) create mode 100644 src/components/command-palette/sources/useApiSource.ts diff --git a/src/components/command-palette/sources/useApiSource.ts b/src/components/command-palette/sources/useApiSource.ts new file mode 100644 index 00000000..73a07790 --- /dev/null +++ b/src/components/command-palette/sources/useApiSource.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +export type ApiSourceState = { + items: T[]; + isLoading: boolean; + error: Error | null; +}; + +export function useApiSource(opts: { + enabled: boolean; + deps: React.DependencyList; + fetcher: (signal: AbortSignal) => Promise; + parse: (raw: R) => T[]; +}): ApiSourceState { + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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) + .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 }; +} diff --git a/src/components/command-palette/sources/useBranchesSource.ts b/src/components/command-palette/sources/useBranchesSource.ts index 3e276a4a..e16ca447 100644 --- a/src/components/command-palette/sources/useBranchesSource.ts +++ b/src/components/command-palette/sources/useBranchesSource.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; - import { authenticatedFetch } from '../../../utils/api'; +import { useApiSource } from './useApiSource'; + export type BranchResult = { name: string; isCurrent: boolean; @@ -13,35 +13,20 @@ interface BranchesResponse { } export function useBranchesSource(projectId: string | undefined, enabled: boolean) { - const [items, setItems] = useState([]); - - useEffect(() => { - if (!enabled || !projectId) { - setItems([]); - return; - } - let cancelled = false; - const params = new URLSearchParams({ project: projectId }); - authenticatedFetch(`/api/git/branches?${params.toString()}`) - .then((r) => r.json() as Promise) - .then((data) => { - if (cancelled) return; - const list = data.branches ?? []; - setItems( - list.map((b) => ({ - name: b.name, - isCurrent: Boolean(b.current), - isRemote: Boolean(b.isRemote), - })), - ); - }) - .catch(() => { - if (!cancelled) setItems([]); - }); - return () => { - cancelled = true; - }; - }, [projectId, enabled]); - - return { items }; + return useApiSource({ + enabled: enabled && !!projectId, + deps: [projectId], + fetcher: (signal) => { + const params = new URLSearchParams({ project: projectId! }); + return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal }); + }, + parse: (data) => { + const list = data.branches ?? []; + return list.map((b) => ({ + name: b.name, + isCurrent: Boolean(b.current), + isRemote: Boolean(b.isRemote), + })); + }, + }); } diff --git a/src/components/command-palette/sources/useCommitsSource.ts b/src/components/command-palette/sources/useCommitsSource.ts index ae091ea1..e173fa7f 100644 --- a/src/components/command-palette/sources/useCommitsSource.ts +++ b/src/components/command-palette/sources/useCommitsSource.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; - import { authenticatedFetch } from '../../../utils/api'; +import { useApiSource } from './useApiSource'; + export type CommitResult = { hash: string; shortHash: string; @@ -15,44 +15,21 @@ interface CommitsResponse { } export function useCommitsSource(projectId: string | undefined, enabled: boolean) { - const [items, setItems] = useState([]); - 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) - .then((data) => { - if (cancelled) return; - if (!data.commits) { - setItems([]); - return; - } - setItems( - data.commits.map((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 }; + return useApiSource({ + 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((c) => ({ + hash: c.hash, + shortHash: c.hash.slice(0, 7), + message: c.message, + author: c.author, + })); + }, + }); } diff --git a/src/components/command-palette/sources/useFilesSource.ts b/src/components/command-palette/sources/useFilesSource.ts index 972a6148..e96b511a 100644 --- a/src/components/command-palette/sources/useFilesSource.ts +++ b/src/components/command-palette/sources/useFilesSource.ts @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; - import { api } from '../../../utils/api'; +import { useApiSource } from './useApiSource'; + export type FileResult = { path: string; name: string; @@ -28,36 +28,15 @@ function flatten(nodes: FileNode[], out: FileResult[]): void { } export function useFilesSource(projectId: string | undefined, enabled: boolean) { - const [items, setItems] = useState([]); - 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 }; + return useApiSource({ + 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; + }, + }); } diff --git a/src/components/command-palette/sources/useSessionsSource.ts b/src/components/command-palette/sources/useSessionsSource.ts index aa91f5e3..9f20df58 100644 --- a/src/components/command-palette/sources/useSessionsSource.ts +++ b/src/components/command-palette/sources/useSessionsSource.ts @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; - -import { api } from '../../../utils/api'; +import { authenticatedFetch } from '../../../utils/api'; import type { LLMProvider, ProjectSession } from '../../../types/app'; +import { useApiSource } from './useApiSource'; + export type SessionResult = { id: string; label: string; @@ -17,45 +17,28 @@ interface SessionsResponse { } export function useSessionsSource(projectId: string | undefined, enabled: boolean) { - const [items, setItems] = useState([]); - 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) - .then((data) => { - if (cancelled) return; - const all: ProjectSession[] = [ - ...(data.sessions ?? []), - ...(data.cursorSessions ?? []), - ...(data.codexSessions ?? []), - ...(data.geminiSessions ?? []), - ]; - setItems( - all.map((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 }; + return useApiSource({ + 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((s) => ({ + id: s.id, + label: (s.title || s.summary || s.name || s.id) as string, + provider: s.__provider, + })); + }, + }); }