1import {
2 composerPullRequestsFromLinks,
3 filterComposerPullRequestMatches,
4} from "@t3tools/shared/composerPullRequestMatches";
5import { resolveFilesystemReadAccess } from "@t3tools/client-runtime/state/filesystem";
6import { environmentSession } from "./session";
7import { useEnvironmentPresentation } from "./presentation";
8import type { VcsRefTarget } from "@t3tools/client-runtime/state/vcs";
9import type {
10 EnvironmentId,
11 OrchestrationV2ProjectedTurnItem,
12 ProjectId,
13 ThreadId,
14 ThreadPullRequestLink,
15 VcsListRefsResult,
16 VcsRef,
17} from "@t3tools/contracts";
18import {
19 createThreadSearchResultsAtomFamily,
20 makeThreadSearchKey,
21 type EnvironmentThreadSearchMatch,
22} from "@t3tools/client-runtime/state/thread-search";
23import { useAtomValue } from "@effect/atom-react";
24import * as Cause from "effect/Cause";
25import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
26import * as Option from "effect/Option";
27import { AsyncResult, Atom } from "effect/reactivity";
28import { useCallback, useEffect, useMemo, useState } from "react";
29
30import { appAtomRegistry } from "./atom-registry";
31import { orchestrationEnvironment } from "./orchestration";
32import { projectEnvironment } from "./projects";
33import { useEnvironmentQuery } from "./query";
34import { vcsEnvironment } from "./vcs";
35import { composerPullRequests } from "./pull-requests";
36import {
37 buildCheckpointDiffTargets,
38 normalizeComposerPathSearchQuery,
39 type CheckpointDiffTarget,
40} from "./queryTargets";
41
42const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 200;
43const COMPOSER_PATH_SEARCH_LIMIT = 20;
44const THREAD_SEARCH_DEBOUNCE_MS = 200;
45const VCS_REF_LIST_LIMIT = 100;
46const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
47const INITIAL_BRANCH_CURSORS = [undefined] as const;
48const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);
49const EMPTY_THREAD_SEARCH_ATOM = Atom.make({
50 matches: EMPTY_THREAD_SEARCH_MATCHES,
51 isLoading: false,
52}).pipe(Atom.withLabel("mobile:thread-search:empty"));
53
54const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({
55 getSearchAtom: (environmentId, query) =>
56 orchestrationEnvironment.threadSearch({
57 environmentId,
58 input: { query },
59 }),
60 labelPrefix: "mobile:thread-search",
61});
62
63export interface ComposerPathSearchTarget {
64 readonly environmentId: EnvironmentId | null;
65 readonly cwd: string | null;
66 readonly query: string | null;
67}
68
69export function useDebouncedValue<A>(value: A, delayMs: number): A {
70 const [debounced, setDebounced] = useState(value);
71
72 useEffect(() => {
73 const timer = setTimeout(() => {
74 setDebounced(value);
75 }, delayMs);
76 return () => {
77 clearTimeout(timer);
78 };
79 }, [delayMs, value]);
80
81 return debounced;
82}
83
84export function useComposerPullRequestSearch(input: {
85 environmentId: EnvironmentId | null;
86 projectId: ProjectId | null;
87 repository: string | null;
88 /** The thread's linked pull requests, offered in numeric searches from any repository. */
89 threadPullRequests?: ReadonlyArray<ThreadPullRequestLink> | undefined;
90 query: string | null;
91}) {
92 const query = useDebouncedValue(input.query, 180);
93 const linked = useMemo(
94 () =>
95 input.projectId === null
96 ? []
97 : composerPullRequestsFromLinks(input.threadPullRequests ?? [], input.projectId),
98 [input.projectId, input.threadPullRequests],
99 );
100 const ready =
101 query === input.query &&
102 query !== null &&
103 input.environmentId !== null &&
104 input.projectId !== null &&
105 input.repository !== null;
106 const numeric = query !== null && /^\d*$/.test(query);
107 const list = useEnvironmentQuery(
108 ready
109 ? composerPullRequests.list({
110 environmentId: input.environmentId!,
111 input: {
112 projectId: input.projectId!,
113 state: "all",
114 limit: 200,
115 ...(!numeric && query ? { query } : {}),
116 },
117 })
118 : null,
119 );
120 const number = numeric && query ? Number(query) : null;
121 const hasExact = list.data?.entries.some(
122 (entry) =>
123 entry.number === number && entry.repository.toLowerCase() === input.repository?.toLowerCase(),
124 );
125 const exact = useEnvironmentQuery(
126 ready && number !== null && Number.isSafeInteger(number) && number > 0 && !hasExact
127 ? composerPullRequests.detail({
128 environmentId: input.environmentId!,
129 input: { projectId: input.projectId!, repository: input.repository!, number },
130 })
131 : null,
132 );
133 const entries = useMemo(() => {
134 if (!ready) return [];
135 if (numeric) {
136 return filterComposerPullRequestMatches({
137 entries: [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])],
138 linked,
139 projectId: input.projectId!,
140 repository: input.repository!,
141 query: query ?? "",
142 limit: 20,
143 });
144 }
145 const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean);
146 const found = [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])].filter(
147 (entry) =>
148 entry.projectId === input.projectId &&
149 entry.repository.toLowerCase() === input.repository?.toLowerCase() &&
150 words.every((word) =>
151 `${entry.title} ${entry.headBranch} ${entry.baseBranch}`.toLowerCase().includes(word),
152 ),
153 );
154 const unique = new Map<number, (typeof found)[number]>();
155 for (const entry of found) if (!unique.has(entry.number)) unique.set(entry.number, entry);
156 return [...unique.values()]
157 .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
158 .slice(0, 20);
159 }, [ready, exact.data, list.data, linked, input.projectId, input.repository, numeric, query]);
160 return {
161 entries,
162 isPending: input.query !== null && (query !== input.query || list.isPending || exact.isPending),
163 error: list.error ?? list.data?.errors[0]?.message ?? exact.error,
164 };
165}
166
167export function useThreadSearch(
168 environmentIds: ReadonlyArray<EnvironmentId>,
169 query: string,
170): {
171 readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;
172 readonly isPending: boolean;
173} {
174 const normalizedQuery = query.trim();
175 const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);
176 const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2;
177 const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;
178 const searchKey = useMemo(
179 () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)),
180 [environmentIds, settledQuery],
181 );
182 const result = useAtomValue(
183 searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey),
184 );
185 const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;
186 return {
187 matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,
188 isPending: canSearch && (isDebouncing || result.isLoading),
189 };
190}
191
192export function useBranches(input: {
193 readonly environmentId: EnvironmentId | null;
194 readonly cwd: string | null;
195 readonly query?: string | null;
196}) {
197 const query = input.query?.trim() ?? "";
198 return useEnvironmentQuery(
199 input.environmentId !== null && input.cwd !== null
200 ? vcsEnvironment.listRefs({
201 environmentId: input.environmentId,
202 input: {
203 cwd: input.cwd,
204 ...(query.length > 0 ? { query } : {}),
205 limit: VCS_REF_LIST_LIMIT,
206 },
207 })
208 : null,
209 );
210}
211
212export function usePaginatedBranches(target: VcsRefTarget) {
213 const query = target.query?.trim() ?? "";
214 const targetKey =
215 target.environmentId !== null && target.cwd !== null
216 ? JSON.stringify([target.environmentId, target.cwd, query])
217 : null;
218 const [pagination, setPagination] = useState<{
219 readonly targetKey: string | null;
220 readonly cursors: ReadonlyArray<number | undefined>;
221 }>({
222 targetKey,
223 cursors: INITIAL_BRANCH_CURSORS,
224 });
225 const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS;
226 const pageAtoms = useMemo(
227 () =>
228 target.environmentId !== null && target.cwd !== null
229 ? cursors.map((cursor) =>
230 vcsEnvironment.listRefs({
231 environmentId: target.environmentId!,
232 input: {
233 cwd: target.cwd!,
234 ...(query.length > 0 ? { query } : {}),
235 ...(cursor === undefined ? {} : { cursor }),
236 limit: VCS_REF_LIST_LIMIT,
237 },
238 }),
239 )
240 : [],
241 [cursors, query, target.cwd, target.environmentId],
242 );
243 const pagesAtom = useMemo(
244 () =>
245 Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe(
246 Atom.withLabel(`mobile:vcs-ref-pages:${targetKey ?? "empty"}`),
247 ),
248 [pageAtoms, targetKey],
249 );
250 const results = useAtomValue(pagesAtom);
251 const values = results.flatMap((result) => {
252 const value = Option.getOrNull(AsyncResult.value(result));
253 return value === null ? [] : [value];
254 });
255 const refs = new Map<string, VcsRef>();
256 for (const value of values) {
257 for (const ref of value.refs) {
258 refs.set(ref.name, ref);
259 }
260 }
261 const first = values[0] ?? null;
262 const last = values.at(-1) ?? null;
263 const data: VcsListRefsResult | null =
264 first === null || last === null
265 ? null
266 : {
267 refs: [...refs.values()],
268 isRepo: first.isRepo,
269 hasPrimaryRemote: first.hasPrimaryRemote,
270 nextCursor: last.nextCursor,
271 totalCount: Math.max(...values.map((value) => value.totalCount)),
272 };
273 const lastResult = results.at(-1);
274 const isFetchingNextPage =
275 results.length > 1 &&
276 lastResult?.waiting === true &&
277 Option.isNone(AsyncResult.value(lastResult));
278 const failed = results.find((result) => result._tag === "Failure");
279 const error =
280 failed?._tag === "Failure"
281 ? (() => {
282 const cause = Cause.squash(failed.cause);
283 return cause instanceof Error && cause.message.trim().length > 0
284 ? cause.message
285 : "Failed to load refs.";
286 })()
287 : null;
288 const refresh = useCallback(() => {
289 const firstPage = pageAtoms[0];
290 setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS });
291 if (firstPage !== undefined) {
292 appAtomRegistry.refresh(firstPage);
293 }
294 }, [pageAtoms, targetKey]);
295 const loadNext = useCallback(() => {
296 if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) {
297 return;
298 }
299 setPagination((current) => {
300 const currentCursors =
301 current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS;
302 return currentCursors.includes(data.nextCursor!)
303 ? { targetKey, cursors: currentCursors }
304 : { targetKey, cursors: [...currentCursors, data.nextCursor!] };
305 });
306 }, [data?.nextCursor, targetKey]);
307
308 return {
309 data,
310 refs: data?.refs ?? EMPTY_REFS,
311 error,
312 isPending: results.some((result) => result.waiting),
313 isFetchingNextPage,
314 refresh,
315 loadNext,
316 };
317}
318
319export function useComposerPathSearch(target: ComposerPathSearchTarget) {
320 const normalizedTarget = useMemo(
321 () => ({
322 environmentId: target.environmentId,
323 cwd: target.cwd,
324 query: normalizeComposerPathSearchQuery(target.query),
325 }),
326 [target.cwd, target.environmentId, target.query],
327 );
328 const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS);
329 const fileAccessSession = useEnvironmentQuery(
330 debouncedTarget.environmentId === null
331 ? null
332 : environmentSession.sessionStateAtom(debouncedTarget.environmentId),
333 );
334 const fileEnvironment = useEnvironmentPresentation(debouncedTarget.environmentId);
335 const fileAccess = resolveFilesystemReadAccess({
336 isCatalogReady: fileEnvironment.isReady,
337 connection: fileEnvironment.presentation?.connection ?? null,
338 session: fileAccessSession.data,
339 sessionError: fileAccessSession.error,
340 });
341 const { canReadFiles } = fileAccess;
342 const searchTarget =
343 debouncedTarget.environmentId !== null &&
344 debouncedTarget.cwd !== null &&
345 debouncedTarget.query.length > 0
346 ? {
347 environmentId: debouncedTarget.environmentId,
348 input: {
349 cwd: debouncedTarget.cwd,
350 query: debouncedTarget.query,
351 limit: COMPOSER_PATH_SEARCH_LIMIT,
352 },
353 }
354 : null;
355 const result = useEnvironmentQuery(
356 canReadFiles && searchTarget !== null ? projectEnvironment.searchEntries(searchTarget) : null,
357 );
358 const hasTarget = searchTarget !== null;
359
360 return {
361 entries: result.data?.entries ?? [],
362 error:
363 !hasTarget || fileAccess.isPending
364 ? null
365 : canReadFiles
366 ? result.error
367 : (fileAccess.error ?? "This connection cannot search host files."),
368 isPending:
369 normalizedTarget.query !== debouncedTarget.query ||
370 (hasTarget && (fileAccess.isPending || result.isPending)),
371 refresh: result.refresh,
372 };
373}
374
375export function useCheckpointDiff(target: CheckpointDiffTarget) {
376 const targets = useMemo(
377 () => buildCheckpointDiffTargets(target),
378 [
379 target.environmentId,
380 target.fromTurnCount,
381 target.ignoreWhitespace,
382 target.threadId,
383 target.toTurnCount,
384 ],
385 );
386 const fullThread = useEnvironmentQuery(
387 targets.fullThread === null
388 ? null
389 : orchestrationEnvironment.fullThreadDiff(targets.fullThread),
390 );
391 const turn = useEnvironmentQuery(
392 targets.turn === null ? null : orchestrationEnvironment.turnDiff(targets.turn),
393 );
394 return targets.fullThread === null ? turn : fullThread;
395}
396
397/** Full input and output for one tool row; pass null to skip fetching. */
398export function useTurnItemDetail(
399 target: {
400 readonly environmentId: EnvironmentId;
401 readonly row: OrchestrationV2ProjectedTurnItem;
402 } | null,
403) {
404 return useEnvironmentQuery(
405 target === null
406 ? null
407 : orchestrationEnvironment.turnItem({
408 environmentId: target.environmentId,
409 input: {
410 threadId: target.row.sourceThreadId,
411 itemId: target.row.sourceItemId,
412 revision: turnItemDetailRevision(target.row.item),
413 },
414 }),
415 );
416}