358 lines · 11.9 KB · TypeScript
RawDownload
import { useAtomValue } from "@effect/atom-react";import {  type CheckpointDiffTarget,  type ComposerPathSearchTarget,} from "@t3tools/client-runtime/state/threads";import {  createThreadSearchResultsAtomFamily,  makeThreadSearchKey,  type EnvironmentThreadSearchMatch,} from "@t3tools/client-runtime/state/thread-search";import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs";import type {  EnvironmentId,  OrchestrationThread,  ProjectContentMatch,  ProjectEntryKind,  VcsListRefsResult,  VcsRef,} from "@t3tools/contracts";import * as Cause from "effect/Cause";import * as Option from "effect/Option";import { AsyncResult, Atom } from "effect/unstable/reactivity";import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry";import { orchestrationEnvironment } from "./orchestration";import { isPaginatedBranchesNextPagePending } from "./paginatedBranches";import { projectContentSearch, projectEnvironment } from "./projects";import { useEnvironmentQuery } from "./query";import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120;const COMPOSER_PATH_SEARCH_LIMIT = 80;const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120;const PROJECT_CONTENT_SEARCH_LIMIT = 500;const THREAD_SEARCH_DEBOUNCE_MS = 200;const VCS_REF_LIST_LIMIT = 100;const EMPTY_REFS: ReadonlyArray<VcsRef> = [];const EMPTY_CONTENT_MATCHES: ReadonlyArray<ProjectContentMatch> = [];const INITIAL_BRANCH_CURSORS = [undefined] as const;const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray<EnvironmentThreadSearchMatch> = Object.freeze([]);const EMPTY_THREAD_SEARCH_ATOM = Atom.make({  matches: EMPTY_THREAD_SEARCH_MATCHES,  isLoading: false,}).pipe(Atom.withLabel("web:thread-search:empty")); const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({  getSearchAtom: (environmentId, query) =>    orchestrationEnvironment.threadSearch({      environmentId,      input: { query },    }),  labelPrefix: "web:thread-search",}); export interface ThreadDetailView {  readonly data: OrchestrationThread | null;  readonly error: string | null;  readonly isPending: boolean;  readonly isDeleted: boolean;} /** Shared with the pull requests page, which debounces its search the same way. */export function useDebouncedValue<A>(value: A, delayMs: number): A {  const [debounced, setDebounced] = useState(value);   useEffect(() => {    const timer = window.setTimeout(() => {      setDebounced(value);    }, delayMs);    return () => {      window.clearTimeout(timer);    };  }, [delayMs, value]);   return debounced;} export function useThreadSearch(  environmentIds: ReadonlyArray<EnvironmentId>,  query: string,): {  readonly matches: ReadonlyArray<EnvironmentThreadSearchMatch>;  readonly isPending: boolean;} {  const normalizedQuery = query.trim();  const debouncedQuery = useDebouncedValue(normalizedQuery, THREAD_SEARCH_DEBOUNCE_MS);  const canSearch = environmentIds.length > 0 && normalizedQuery.length >= 2;  const settledQuery = canSearch && normalizedQuery === debouncedQuery ? debouncedQuery : null;  const searchKey = useMemo(    () => (settledQuery === null ? null : makeThreadSearchKey(environmentIds, settledQuery)),    [environmentIds, settledQuery],  );  const result = useAtomValue(    searchKey === null ? EMPTY_THREAD_SEARCH_ATOM : threadSearchResultsAtom(searchKey),  );  const isDebouncing = canSearch && normalizedQuery !== debouncedQuery;  return {    matches: isDebouncing ? EMPTY_THREAD_SEARCH_MATCHES : result.matches,    isPending: canSearch && (isDebouncing || result.isLoading),  };} export function usePaginatedBranches(target: VcsRefTarget) {  const query = target.query?.trim() ?? "";  const targetKey =    target.environmentId !== null && target.cwd !== null      ? JSON.stringify([target.environmentId, target.cwd, query])      : null;  const [pagination, setPagination] = useState<{    readonly targetKey: string | null;    readonly cursors: ReadonlyArray<number | undefined>;  }>({    targetKey,    cursors: INITIAL_BRANCH_CURSORS,  });  const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS;  const pageAtoms = useMemo(    () =>      target.environmentId !== null && target.cwd !== null        ? cursors.map((cursor) =>            vcsEnvironment.listRefs({              environmentId: target.environmentId!,              input: {                cwd: target.cwd!,                ...(query.length > 0 ? { query } : {}),                ...(cursor === undefined ? {} : { cursor }),                limit: VCS_REF_LIST_LIMIT,              },            }),          )        : [],    [cursors, query, target.cwd, target.environmentId],  );  const pagesAtom = useMemo(    () =>      Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe(        Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`),      ),    [pageAtoms, targetKey],  );  const results = useAtomValue(pagesAtom);  const values = results.flatMap((result) => {    const value = Option.getOrNull(AsyncResult.value(result));    return value === null ? [] : [value];  });  const refs = new Map<string, VcsRef>();  for (const value of values) {    for (const ref of value.refs) {      refs.set(ref.name, ref);    }  }  const first = values[0] ?? null;  const last = values.at(-1) ?? null;  const data: VcsListRefsResult | null =    first === null || last === null      ? null      : {          refs: [...refs.values()],          isRepo: first.isRepo,          hasPrimaryRemote: first.hasPrimaryRemote,          nextCursor: last.nextCursor,          totalCount: Math.max(...values.map((value) => value.totalCount)),        };  const failed = results.find((result) => result._tag === "Failure");  const isFetchingNextPage = isPaginatedBranchesNextPagePending(results);  const error =    failed?._tag === "Failure"      ? (() => {          const cause = Cause.squash(failed.cause);          return cause instanceof Error && cause.message.trim().length > 0            ? cause.message            : "Failed to load refs.";        })()      : null;  const refresh = useCallback(() => {    const firstPage = pageAtoms[0];    setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS });    if (firstPage !== undefined) {      appAtomRegistry.refresh(firstPage);    }  }, [pageAtoms, targetKey]);  const loadNext = useCallback(() => {    if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) {      return;    }    setPagination((current) => {      const currentCursors =        current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS;      return currentCursors.includes(data.nextCursor!)        ? { targetKey, cursors: currentCursors }        : { targetKey, cursors: [...currentCursors, data.nextCursor!] };    });  }, [data?.nextCursor, targetKey]);   return {    data,    refs: data?.refs ?? EMPTY_REFS,    error,    isPending: results.some((result) => result.waiting),    isFetchingNextPage,    refresh,    loadNext,  };} type ProjectPathSearchTarget = ComposerPathSearchTarget & {  readonly kind?: ProjectEntryKind | undefined;  readonly imageOnly?: boolean | undefined;}; export function areProjectPathSearchTargetsEqual(  left: ProjectPathSearchTarget,  right: ProjectPathSearchTarget,): boolean {  return (    left.environmentId === right.environmentId &&    left.cwd === right.cwd &&    left.query === right.query &&    left.kind === right.kind &&    left.imageOnly === right.imageOnly  );} export function useProjectPathSearch(  target: ProjectPathSearchTarget,  limit: number,  options?: { readonly allowEmptyQuery?: boolean },) {  const allowEmptyQuery = options?.allowEmptyQuery === true;  const normalizedTarget = useMemo(    () => ({      environmentId: target.environmentId,      cwd: target.cwd,      query: target.query == null ? null : target.query.trim(),      kind: target.kind,      imageOnly: target.imageOnly,    }),    [target.cwd, target.environmentId, target.imageOnly, target.kind, target.query],  );  const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS);  const result = useEnvironmentQuery(    debouncedTarget.environmentId !== null &&      debouncedTarget.cwd !== null &&      debouncedTarget.query !== null &&      (allowEmptyQuery || debouncedTarget.query.length > 0)      ? projectEnvironment.searchEntries({          environmentId: debouncedTarget.environmentId,          input: {            cwd: debouncedTarget.cwd,            query: debouncedTarget.query,            limit,            ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}),            ...(debouncedTarget.imageOnly ? { imageOnly: true } : {}),          },        })      : null,  );   return {    entries: result.data?.entries ?? [],    error: result.error,    isPending:      !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending,    searchedQuery: debouncedTarget.query ?? "",    refresh: result.refresh,  };} export function useComposerPathSearch(target: ComposerPathSearchTarget) {  return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT);} interface ProjectContentSearchTarget {  readonly environmentId: EnvironmentId | null;  readonly cwd: string | null;  readonly query: string;  readonly caseSensitive: boolean;  readonly wholeWord: boolean;  readonly useRegex: boolean;} export function useProjectContentSearch(target: ProjectContentSearchTarget) {  // Whitespace is significant in content queries; trimming is only used to  // decide whether the input is blank.  const query = target.query;  const hasQuery = query.trim().length > 0;  const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS);  const result = useEnvironmentQuery(    target.environmentId !== null &&      target.cwd !== null &&      hasQuery &&      debouncedQuery.trim().length > 0      ? projectContentSearch({          environmentId: target.environmentId,          input: {            cwd: target.cwd,            query: debouncedQuery,            limit: PROJECT_CONTENT_SEARCH_LIMIT,            caseSensitive: target.caseSensitive,            wholeWord: target.wholeWord,            useRegex: target.useRegex,          },        })      : null,  );   return {    matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES,    error: result.error,    isPending: hasQuery && (query !== debouncedQuery || result.isPending),    hasQuery,    truncated: result.data?.truncated ?? false,    invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined,  };} export function useCheckpointDiff(  target: CheckpointDiffTarget,  options?: { readonly enabled?: boolean },) {  const enabled =    options?.enabled !== false &&    target.environmentId !== null &&    target.threadId !== null &&    target.fromTurnCount !== null &&    target.toTurnCount !== null;  const fullThreadTarget =    enabled && target.fromTurnCount === 0      ? {          environmentId: target.environmentId!,          input: {            threadId: target.threadId!,            toTurnCount: target.toTurnCount!,            ignoreWhitespace: target.ignoreWhitespace,          },        }      : null;  const turnTarget =    enabled && target.fromTurnCount !== 0      ? {          environmentId: target.environmentId!,          input: {            threadId: target.threadId!,            fromTurnCount: target.fromTurnCount!,            toTurnCount: target.toTurnCount!,            ignoreWhitespace: target.ignoreWhitespace,          },        }      : null;  const fullThread = useEnvironmentQuery(    fullThreadTarget === null ? null : orchestrationEnvironment.fullThreadDiff(fullThreadTarget),  );  const turn = useEnvironmentQuery(    turnTarget === null ? null : orchestrationEnvironment.turnDiff(turnTarget),  );  return fullThreadTarget === null ? turn : fullThread;}