1import { useAtomValue } from "@effect/atom-react";
2import {
3 type CheckpointDiffTarget,
4 type ComposerPathSearchTarget,
5} from "@t3tools/client-runtime/state/threads";
6import {
7 createThreadSearchResultsAtomFamily,
8 makeThreadSearchKey,
9 type EnvironmentThreadSearchMatch,
10} from "@t3tools/client-runtime/state/thread-search";
11import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
12import type {
13 EnvironmentId,
14 ProjectContentMatch,
15 ProjectEntry,
16 ProjectEntryKind,
17 ThreadId,
18 TurnItemId,
19 VcsListRefsResult,
20 VcsRef,
21} from "@t3tools/contracts";
22import * as Cause from "effect/Cause";
23import * as Option from "effect/Option";
24import { AsyncResult, Atom } from "effect/reactivity";
25import { useCallback, useEffect, useMemo, useState } from "react";
26
27import { appAtomRegistry } from "../rpc/atomRegistry";
28import { useFilesystemReadAccess } from "./filesystem";
29import { orchestrationEnvironment } from "./orchestration";
30import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";
31import { projectContentSearch, projectEnvironment } from "./projects";
32import { useEnvironmentQuery } from "./query";
33import { vcsEnvironment } from "./vcs";
34
35const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120;
36const COMPOSER_PATH_SEARCH_LIMIT = 80;
37const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120;
38const PROJECT_CONTENT_SEARCH_LIMIT = 500;
39const THREAD_SEARCH_DEBOUNCE_MS = 200;
40const VCS_REF_LIST_LIMIT = 100;
41const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
42const EMPTY_PROJECT_ENTRIES: ReadonlyArray<ProjectEntry> = [];
43const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = [];
44const INITIAL_BRANCH_CURSORS = [undefined] as const;
45const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
46const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
47 matches: EMPTY_THREAD_SEARCH_MATCHES,
48 isLoading: false,
49}).pipe(Atom.withLabel("web:thread-search:empty"));
50
51const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({
52 getSearchAtom: (environmentId, query) =>
53 orchestrationEnvironment.threadSearch({
54 environmentId,
55 input: { query },
56 }),
57 labelPrefix: "web:thread-search",
58});
59
60export function useDebouncedValue<A>(value: A, delayMs: number): A {
61 const [debounced, setDebounced] = useState(value);
62
63 useEffect(() => {
64 const timer = window.setTimeout(() => {
65 setDebounced(value);
66 }, delayMs);
67 return () => {
68 window.clearTimeout(timer);
69 };
70 }, [delayMs, value]);
71
72 return debounced;
73}
74
75export function useThreadSearch(
76 environmentIds: ReadonlyArray<EnvironmentId>,
77 query: string,
78): {
79 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
80 /** The settled query `matches` came from; it only changes when results do. */
81 readonly query: string;
82 readonly isPending: boolean;
83} {
84 const normalizedQuery = query.trim();
85 const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
86 const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2;
87 const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;
88 const searchKey = useMemo(
89 () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)),
90 [environmentIds, settledQuery],
91 );
92 const result = useAtomValue(
93 searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey),
94 );
95 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
96 return {
97 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
98 query: settledQuery ?? "",
99 isPending: canSearch && (isDebouncing || result.isLoading),
100 };
101}
102
103export function usePaginatedBranches(target: VcsRefTarget) {
104 const query = target.query?.trim() ?? "";
105 const targetKey =
106 target.environmentId !== null && target.cwd !== null
107 ? JSON.stringify([target.environmentId, target.cwd, query])
108 : null;
109 const [pagination, setPagination] = useState<{
110 readonly targetKey: string | null;
111 readonly cursors: ReadonlyArray<number | undefined>;
112 }>({
113 targetKey,
114 cursors: INITIAL_BRANCH_CURSORS,
115 });
116 const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS;
117 const pageAtoms = useMemo(
118 () =>
119 target.environmentId !== null && target.cwd !== null
120 ? cursors.map((cursor) =>
121 vcsEnvironment.listRefs({
122 environmentId: target.environmentId!,
123 input: {
124 cwd: target.cwd!,
125 ...(query.length > 0 ? { query } : {}),
126 ...(cursor === undefined ? {} : { cursor }),
127 limit: VCS_REF_LIST_LIMIT,
128 },
129 }),
130 )
131 : [],
132 [cursors, query, target.cwd, target.environmentId],
133 );
134 const pagesAtom = useMemo(
135 () =>
136 Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe(
137 Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`),
138 ),
139 [pageAtoms, targetKey],
140 );
141 const results = useAtomValue(pagesAtom);
142 const values = results.flatMap((result) => {
143 const value = Option.getOrNull(AsyncResult.value(result));
144 return value === null ? [] : [value];
145 });
146 const refs = new Map<string, VcsRef>();
147 for (const value of values) {
148 for (const ref of value.refs) {
149 refs.set(ref.name, ref);
150 }
151 }
152 const first = values[0] ?? null;
153 const last = values.at(-1) ?? null;
154 const data: VcsListRefsResult | null =
155 first === null || last === null
156 ? null
157 : {
158 refs: [...refs.values()],
159 isRepo: first.isRepo,
160 hasPrimaryRemote: first.hasPrimaryRemote,
161 nextCursor: last.nextCursor,
162 totalCount: Math.max(...values.map((value) => value.totalCount)),
163 };
164 const failed = results.find((result) => result._tag === "Failure");
165 const isFetchingNextPage = isPaginatedBranchesNextPagePending(results);
166 const error =
167 failed?._tag === "Failure"
168 ? (() => {
169 const cause = Cause.squash(failed.cause);
170 return cause instanceof Error && cause.message.trim().length > 0
171 ? cause.message
172 : "Failed to load refs.";
173 })()
174 : null;
175 const refresh = useCallback(() => {
176 const firstPage = pageAtoms[0];
177 setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS });
178 if (firstPage !== undefined) {
179 appAtomRegistry.refresh(firstPage);
180 }
181 }, [pageAtoms, targetKey]);
182 const loadNext = useCallback(() => {
183 if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) {
184 return;
185 }
186 setPagination((current) => {
187 const currentCursors =
188 current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS;
189 return currentCursors.includes(data.nextCursor!)
190 ? { targetKey, cursors: currentCursors }
191 : { targetKey, cursors: [...currentCursors, data.nextCursor!] };
192 });
193 }, [data?.nextCursor, targetKey]);
194
195 return {
196 data,
197 refs: data?.refs ?? EMPTY_REFS,
198 error,
199 isPending: results.some((result) => result.waiting),
200 isFetchingNextPage,
201 refresh,
202 loadNext,
203 };
204}
205
206type ProjectPathSearchTarget = ComposerPathSearchTarget & {
207 readonly kind?: ProjectEntryKind | undefined;
208 readonly imageOnly?: boolean | undefined;
209};
210
211export function areProjectPathSearchTargetsEqual(
212 left: ProjectPathSearchTarget,
213 right: ProjectPathSearchTarget,
214): boolean {
215 return (
216 left.environmentId === right.environmentId &&
217 left.cwd === right.cwd &&
218 left.query === right.query &&
219 left.kind === right.kind &&
220 left.imageOnly === right.imageOnly
221 );
222}
223
224export function useProjectPathSearch(
225 target: ProjectPathSearchTarget,
226 limit: number,
227 options?: { readonly allowEmptyQuery?: boolean },
228) {
229 const allowEmptyQuery = options?.allowEmptyQuery === true;
230 const normalizedTarget = useMemo(
231 () => ({
232 environmentId: target.environmentId,
233 cwd: target.cwd,
234 query: target.query == null ? null : target.query.trim(),
235 kind: target.kind,
236 imageOnly: target.imageOnly,
237 }),
238 [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],
239 );
240 const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);
241 const fileAccess = useFilesystemReadAccess(debouncedTarget.environmentId);
242 const { canReadFiles } = fileAccess;
243 const searchTarget =
244 debouncedTarget.environmentId !== null &&
245 debouncedTarget.cwd !== null &&
246 debouncedTarget.query !== null &&
247 (allowEmptyQuery || debouncedTarget.query.length > 0)
248 ? {
249 environmentId: debouncedTarget.environmentId,
250 input: {
251 cwd: debouncedTarget.cwd,
252 query: debouncedTarget.query,
253 limit,
254 ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),
255 ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),
256 },
257 }
258 : null;
259 const result = useEnvironmentQuery(
260 canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
261 );
262 const hasTarget = searchTarget !== null;
263
264 return {
265 entries: result.data?.entries ?? EMPTY_PROJECT_ENTRIES,
266 error:
267 !hasTarget || fileAccess.isPending
268 ? null
269 : canReadFiles
270 ? result.error
271 : (fileAccess.error ?? "This connection cannot search host files."),
272 isPending:
273 !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) ||
274 (hasTarget && (fileAccess.isPending || result.isPending)),
275 searchedQuery: debouncedTarget.query ?? "",
276 truncated: result.data?.truncated ?? false,
277 refresh: result.refresh,
278 };
279}
280
281export function useComposerPathSearch(target: ComposerPathSearchTarget) {
282 return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT);
283}
284
285interface ProjectContentSearchTarget {
286 readonly environmentId: EnvironmentId | null;
287 readonly cwd: string | null;
288 readonly query: string;
289 readonly caseSensitive: boolean;
290 readonly wholeWord: boolean;
291 readonly useRegex: boolean;
292}
293
294export function useProjectContentSearch(target: ProjectContentSearchTarget) {
295 const hasTarget = target.environmentId !== null && target.cwd !== null;
296 const fileAccess = useFilesystemReadAccess(target.environmentId);
297 const canReadFiles = hasTarget && fileAccess.canReadFiles;
298 const isCheckingAccess = hasTarget && fileAccess.isPending;
299 // Whitespace is significant in content queries; trimming is only used to
300 // decide whether the input is blank.
301 const query = target.query;
302 const hasQuery = query.trim().length > 0;
303 const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);
304 const result = useEnvironmentQuery(
305 canReadFiles &&
306 target.environmentId !== null &&
307 target.cwd !== null &&
308 hasQuery &&
309 debouncedQuery.trim().length > 0
310 ? projectContentSearch({
311 environmentId: target.environmentId,
312 input: {
313 cwd: target.cwd,
314 query: debouncedQuery,
315 limit: PROJECT_CONTENT_SEARCH_LIMIT,
316 caseSensitive: target.caseSensitive,
317 wholeWord: target.wholeWord,
318 useRegex: target.useRegex,
319 },
320 })
321 : null,
322 );
323
324 return {
325 canReadFiles,
326 isCheckingAccess,
327 matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,
328 error:
329 !hasTarget || isCheckingAccess
330 ? null
331 : canReadFiles
332 ? result.error
333 : (fileAccess.error ?? "This connection cannot search host files."),
334 isPending:
335 isCheckingAccess ||
336 (canReadFiles && hasQuery && (query !== debouncedQuery || result.isPending)),
337 hasQuery,
338 truncated: result.data?.truncated ?? false,
339 invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,
340 };
341}
342
343export function useCheckpointDiff(
344 target: CheckpointDiffTarget,
345 options?: { readonly enabled?: boolean },
346) {
347 const enabled =
348 options?.enabled !== false &&
349 target.environmentId !== null &&
350 target.threadId !== null &&
351 target.fromTurnCount !== null &&
352 target.toTurnCount !== null;
353 const fullThreadTarget =
354 enabled && target.fromTurnCount === 0
355 ? {
356 environmentId: target.environmentId!,
357 input: {
358 threadId: target.threadId!,
359 toTurnCount: target.toTurnCount!,
360 ignoreWhitespace: target.ignoreWhitespace,
361 },
362 }
363 : null;
364 const turnTarget =
365 enabled && target.fromTurnCount !== 0
366 ? {
367 environmentId: target.environmentId!,
368 input: {
369 threadId: target.threadId!,
370 fromTurnCount: target.fromTurnCount!,
371 toTurnCount: target.toTurnCount!,
372 ignoreWhitespace: target.ignoreWhitespace,
373 },
374 }
375 : null;
376 const fullThread = useEnvironmentQuery(
377 fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget),
378 );
379 const turn = useEnvironmentQuery(
380 turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget),
381 );
382 return fullThreadTarget === null ? turn : fullThread;
383}
384
385/** Full input and output of one timeline item, fetched only while its row is open. */
386export function useTurnItemDetail(
387 target: {
388 readonly environmentId: EnvironmentId;
389 readonly threadId: ThreadId;
390 readonly itemId: TurnItemId;
391 readonly revision: string;
392 } | null,
393) {
394 return useEnvironmentQuery(
395 target === null
396 ? null
397 : orchestrationEnvironment.turnItem({
398 environmentId: target.environmentId,
399 input: { threadId: target.threadId, itemId: target.itemId, revision: target.revision },
400 }),
401 );
402}