History

Replace git react-query layer with vcsRefManager and source control acti

- Migrate branch selector and git actions to useVcsRefs and new source control action hooks - Remove gitReactQuery and projectReactQuery modules in favor of sourceControlActions and vcsRefState - Add staleTimeMs to mobile vcs ref manager configuration

Julius Marminge committed 6 months ago

Commit 9ef8d751 parent c0a2f0015 files · +770 −732Browse files

Unified · Split

15 files
  1. apps/mobile/src/state/use-vcs-refs.ts +7 −1
  2. apps/web/src/components/BranchToolbarBranchSelector.tsx +35 −35
  3. apps/web/src/components/GitActionsControl.browser.tsx +31 −48
  4. apps/web/src/components/GitActionsControl.tsx +60 −74
  5. apps/web/src/components/PullRequestThreadDialog.tsx +36 −40
  6. apps/web/src/environments/runtime/service.savedEnvironments.test.ts +0 −6
  7. apps/web/src/environments/runtime/service.ts +0 −2
  8. apps/web/src/hooks/useThreadActions.ts +2 −5
  9. apps/web/src/lib/gitReactQuery.test.ts +0 −135
  10. apps/web/src/lib/gitReactQuery.ts +0 −327
  11. apps/web/src/lib/projectReactQuery.ts +0 −52
  12. apps/web/src/lib/sourceControlActions.ts +381 −0
  13. apps/web/src/lib/vcsRefState.ts +48 −0
  14. packages/client-runtime/src/vcsRefState.test.ts +106 −2
  15. packages/client-runtime/src/vcsRefState.ts +64 −5
apps/mobile/src/state/use-vcs-refs.ts+7−1
@@ -16,6 +16,9 @@ import { · 15 unmodified lines
1616 subscribeEnvironmentConnections,
1717 } from "./environment-session-registry";
1818
19+const VCS_REF_LIST_LIMIT = 100;
20+const VCS_REF_STALE_TIME_MS = 5_000;
21+
1922 export const vcsRefManager = createVcsRefManager({
2023 getRegistry: () => appAtomRegistry,
2124 getClient: (environmentId) => {
@@ -23,7 +26,8 @@ export const vcsRefManager = createVcsRefManager({ · 1 unmodified line
2326 return client ? client.vcs : null;
2427 },
2528 subscribeClientChanges: subscribeEnvironmentConnections,
26− watchLimit: 100,
29+ watchLimit: VCS_REF_LIST_LIMIT,
30+ staleTimeMs: VCS_REF_STALE_TIME_MS,
2731 onBackgroundError: (error) => {
2832 console.warn("[vcs-refs] background refresh failed", error);
2933 },
@@ -39,7 +43,9 @@ export function useVcsRefs(target: VcsRefTarget): VcsRefState { · 9 unmodified lines
3943 [target.cwd, target.environmentId, target.query],
4044 );
4145 const targetKey = getVcsRefTargetKey(stableTarget);
46+
4247 useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
48+
4349 const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
4450 return targetKey === null ? EMPTY_VCS_REF_STATE : state;
4551 }
apps/web/src/components/BranchToolbarBranchSelector.tsx+35−35
@@ -1,6 +1,5 @@
11 import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22 import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
3−import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
43 import { LegendList, type LegendListRef } from "@legendapp/list/react";
54 import { ChevronDownIcon } from "lucide-react";
65 import {
@@ -16,8 +15,8 @@ import { · 9 unmodified lines
1615
1716 import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
1817 import { readEnvironmentApi } from "../environmentApi";
19−import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
2018 import { useGitStatus } from "../lib/gitStatusState";
19+import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
2120 import { newCommandId } from "../lib/utils";
2221 import { cn } from "../lib/utils";
2322 import { parsePullRequestReference } from "../pullRequestReference";
@@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps { · 35 unmodified lines
5958 onComposerFocusRequest?: () => void;
6059 }
6160
61+const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
62+
6263 function toBranchActionErrorMessage(error: unknown): string {
6364 return error instanceof Error ? error.message : "An error occurred.";
6465 }
@@ -196,7 +197,6 @@ export function BranchToolbarBranchSelector({ · 131 unmodified lines
196197 // ---------------------------------------------------------------------------
197198 // Git ref queries
198199 // ---------------------------------------------------------------------------
199− const queryClient = useQueryClient();
200200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
201201 const [branchQuery, setBranchQuery] = useState("");
202202 const deferredBranchQuery = useDeferredValue(branchQuery);
@@ -204,31 +204,20 @@ export function BranchToolbarBranchSelector({ · 1 unmodified line
204204 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
205205 const trimmedBranchQuery = branchQuery.trim();
206206 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
207−
208− useEffect(() => {
209− if (!branchCwd) return;
210− void queryClient.prefetchInfiniteQuery(
211− gitBranchSearchInfiniteQueryOptions({ environmentId, cwd: branchCwd, query: "" }),
212− );
213− }, [branchCwd, environmentId, queryClient]);
214−
215− const {
216− data: branchesSearchData,
217− fetchNextPage,
218− hasNextPage,
219− isFetchingNextPage,
220− isPending: isBranchesSearchPending,
221− } = useInfiniteQuery(
222− gitBranchSearchInfiniteQueryOptions({
207+ const branchRefTarget = useMemo(
208+ () => ({
223209 environmentId,
224210 cwd: branchCwd,
225211 query: deferredTrimmedBranchQuery,
226212 }),
213+ [branchCwd, deferredTrimmedBranchQuery, environmentId],
227214 );
228− const refs = useMemo(
229− () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [],
230− [branchesSearchData?.pages],
231− );
215+ const branchRefState = useVcsRefs(branchRefTarget);
216+ const refs = branchRefState.data?.refs ?? EMPTY_REFS;
217+ const hasNextPage =
218+ branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined;
219+ const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
220+ const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
232221 const currentGitBranch =
233222 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
234223 const sourceControlPresentation = useMemo(
@@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({ · 58 unmodified lines
293282 );
294283 const [isBranchActionPending, startBranchActionTransition] = useTransition();
295284 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
296− const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
297− const branchStatusText = isBranchesSearchPending
285+ const totalBranchCount = branchRefState.data?.totalCount ?? 0;
286+ const branchStatusText = isInitialBranchesLoadPending
298287 ? "Loading refs..."
299288 : isFetchingNextPage
300289 ? "Loading more refs..."
@@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({ · 7 unmodified lines
308297 const runBranchAction = (action: () => Promise<void>) => {
309298 startBranchActionTransition(async () => {
310299 await action().catch(() => undefined);
311− await queryClient
312− .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) })
300+ await vcsRefManager
301+ .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
313302 .catch(() => undefined);
314303 });
315304 };
@@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({ · 109 unmodified lines
425414 setBranchQuery("");
426415 return;
427416 }
428− void queryClient.invalidateQueries({
429− queryKey: gitQueryKeys.refs(environmentId, branchCwd),
430− });
417+ void vcsRefManager
418+ .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
419+ .catch(() => undefined);
431420 },
432− [branchCwd, environmentId, queryClient],
421+ [branchRefTarget],
433422 );
434423
435424 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);
425+ const fetchNextBranchPage = useCallback(() => {
426+ if (!hasNextPage || isFetchingNextPage) {
427+ return;
428+ }
429+
430+ setIsFetchingNextPage(true);
431+ void vcsRefManager
432+ .loadNext(branchRefTarget, undefined, { limit: 100 })
433+ .catch(() => undefined)
434+ .finally(() => setIsFetchingNextPage(false));
435+ }, [branchRefTarget, hasNextPage, isFetchingNextPage]);
436436 const maybeFetchNextBranchPage = useCallback(() => {
437437 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
438438 return;
@@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({ · 10 unmodified lines
449449 return;
450450 }
451451
452− void fetchNextPage().catch(() => undefined);
453− }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
452+ fetchNextBranchPage();
453+ }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
454454 const branchListRef = useRef<LegendListRef | null>(null);
455455 const setBranchListRef = useCallback((element: HTMLDivElement | null) => {
456456 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
@@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({ · 135 unmodified lines
592592 <ComboboxTrigger
593593 render={<Button variant="ghost" size="xs" />}
594594 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}
595− disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending}
595+ disabled={isInitialBranchesLoadPending || isBranchActionPending}
596596 >
597597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
598598 <ChevronDownIcon className="shrink-0" />
@@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({ · 23 unmodified lines
622622 drawDistance={336}
623623 onEndReached={() => {
624624 if (hasNextPage && !isFetchingNextPage) {
625− void fetchNextPage().catch(() => undefined);
625+ fetchNextBranchPage();
626626 }
627627 }}
628628 style={{ maxHeight: "14rem" }}
apps/web/src/components/GitActionsControl.browser.tsx+31−48
@@ -26,9 +26,9 @@ const { · 25 unmodified lines
2626 activeRunStackedActionDeferredRef,
2727 activeDraftThreadRef,
2828 hasServerThreadRef,
29− invalidateGitQueriesSpy,
29+ invalidateSourceControlStateSpy,
3030 refreshGitStatusSpy,
31− runStackedActionMutateAsyncSpy,
31+ runStackedActionSpy,
3232 setDraftThreadContextSpy,
3333 setThreadBranchSpy,
3434 toastAddSpy,
@@ -39,9 +39,9 @@ const { · 4 unmodified lines
3939 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
4040 activeDraftThreadRef: { current: null as unknown },
4141 hasServerThreadRef: { current: true },
42− invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
42+ invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()),
4343 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
44− runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
44+ runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
4545 setDraftThreadContextSpy: vi.fn(),
4646 setThreadBranchSpy: vi.fn(),
4747 toastAddSpy: vi.fn(() => "toast-1"),
@@ -50,39 +50,6 @@ const { · 2 unmodified lines
5050 toastUpdateSpy: vi.fn(),
5151 }));
5252
53−vi.mock("@tanstack/react-query", async () => {
54− const actual =
55− await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
56−
57− return {
58− ...actual,
59− useIsMutating: vi.fn(() => 0),
60− useMutation: vi.fn((options: { __kind?: string }) => {
61− if (options.__kind === "run-stacked-action") {
62− return {
63− mutateAsync: runStackedActionMutateAsyncSpy,
64− isPending: false,
65− };
66− }
67−
68− if (options.__kind === "pull") {
69− return {
70− mutateAsync: vi.fn(),
71− isPending: false,
72− };
73− }
74−
75− return {
76− mutate: vi.fn(),
77− mutateAsync: vi.fn(),
78− isPending: false,
79− };
80− }),
81− useQuery: vi.fn(() => ({ data: null, error: null })),
82− useQueryClient: vi.fn(() => ({})),
83− };
84−});
85−
8653 vi.mock("~/components/ui/toast", () => ({
8754 toastManager: {
8855 add: toastAddSpy,
@@ -97,17 +64,33 @@ vi.mock("~/editorPreferences", () => ({ · 8 unmodified lines
9764 openInPreferredEditor: vi.fn(),
9865 }));
9966
100−vi.mock("~/lib/gitReactQuery", () => ({
101− gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),
102− gitMutationKeys: {
103− publishRepository: vi.fn(() => ["publish-repository"]),
104− pull: vi.fn(() => ["pull"]),
105− runStackedAction: vi.fn(() => ["run-stacked-action"]),
106− },
107− gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),
108− gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),
109− invalidateGitQueries: invalidateGitQueriesSpy,
110− sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })),
67+vi.mock("~/lib/sourceControlActions", () => ({
68+ invalidateSourceControlState: invalidateSourceControlStateSpy,
69+ useGitStackedAction: vi.fn(() => ({
70+ error: null,
71+ isPending: false,
72+ resetError: vi.fn(),
73+ run: runStackedActionSpy,
74+ })),
75+ useSourceControlActionRunning: vi.fn(() => false),
76+ useSourceControlPublishRepositoryAction: vi.fn(() => ({
77+ error: null,
78+ isPending: false,
79+ resetError: vi.fn(),
80+ run: vi.fn(),
81+ })),
82+ useVcsInitAction: vi.fn(() => ({
83+ error: null,
84+ isPending: false,
85+ resetError: vi.fn(),
86+ run: vi.fn(),
87+ })),
88+ useVcsPullAction: vi.fn(() => ({
89+ error: null,
90+ isPending: false,
91+ resetError: vi.fn(),
92+ run: vi.fn(),
93+ })),
11194 }));
11295
11396 vi.mock("~/lib/gitStatusState", () => ({
apps/web/src/components/GitActionsControl.tsx+60−74
@@ -10,7 +10,6 @@ import type { · 9 unmodified lines
1010 SourceControlRepositoryVisibility,
1111 VcsStatusResult,
1212 } from "@t3tools/contracts";
13−import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
1413 import { useNavigate } from "@tanstack/react-router";
1514 import * as Option from "effect/Option";
1615 import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -66,12 +65,12 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon · 49 unmodified lines
6665 import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
6766 import { openInPreferredEditor } from "~/editorPreferences";
6867 import {
69− gitInitMutationOptions,
70− gitMutationKeys,
71− gitPullMutationOptions,
72− gitRunStackedActionMutationOptions,
73− sourceControlPublishRepositoryMutationOptions,
74−} from "~/lib/gitReactQuery";
68+ useGitStackedAction,
69+ useSourceControlActionRunning,
70+ useSourceControlPublishRepositoryAction,
71+ useVcsInitAction,
72+ useVcsPullAction,
73+} from "~/lib/sourceControlActions";
7574 import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
7675 import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
7776 import { newCommandId, randomUUID } from "~/lib/utils";
@@ -129,6 +128,7 @@ interface RunGitActionWithToastInput { · 51 unmodified lines
129128 }
130129
131130 const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
131+const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
132132
133133 const PUBLISH_PROVIDER_OPTIONS = [
134134 {
@@ -278,7 +278,6 @@ interface PublishRepositoryDialogProps { · 143 unmodified lines
278278 }
279279
280280 function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
281− const queryClient = useQueryClient();
282281 const navigate = useNavigate();
283282 const sourceControlDiscovery = useSourceControlDiscovery();
284283 const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");
@@ -294,13 +293,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 9 unmodified lines
294293 null,
295294 );
296295 const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);
297− const publishRepositoryMutation = useMutation(
298− sourceControlPublishRepositoryMutationOptions({
296+ const sourceControlScope = useMemo(
297+ () => ({
299298 environmentId: props.environmentId,
300299 cwd: props.gitCwd,
301− queryClient,
302300 }),
301+ [props.environmentId, props.gitCwd],
303302 );
303+ const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
304304 const publishAccountByProvider = useMemo(() => {
305305 const accounts: Record<PublishProviderKind, string | null> = {
306306 github: null,
@@ -367,13 +367,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 60 unmodified lines
367367
368368 const canSubmitPublishRepository = useMemo(() => {
369369 if (!selectedPublishProviderReadiness.ready) return false;
370− if (publishRepositoryMutation.isPending) return false;
370+ if (publishRepositoryAction.isPending) return false;
371371 const repositoryParts = publishRepository.trim().split("/");
372372 const owner = repositoryParts[0]?.trim() ?? "";
373373 const rest = repositoryParts.slice(1);
374374 const name = rest.join("/").trim();
375375 return owner.length > 0 && name.length > 0;
376− }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]);
376+ }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]);
377377
378378 useEffect(() => {
379379 if (!props.open) {
@@ -397,8 +397,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 17 unmodified lines
397397
398398 setPublishError(null);
399399
400− void publishRepositoryMutation
401− .mutateAsync({
400+ void publishRepositoryAction
401+ .run({
402402 provider: publishProvider,
403403 repository: publishRepository.trim(),
404404 visibility: publishVisibility,
@@ -425,7 +425,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 20 unmodified lines
425425 publishProvider,
426426 publishRemoteName,
427427 publishRepository,
428− publishRepositoryMutation,
428+ publishRepositoryAction,
429429 publishVisibility,
430430 ]);
431431
@@ -621,7 +621,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 189 unmodified lines
621621 }
622622 }}
623623 placeholder={publishPathPlaceholder}
624− disabled={publishRepositoryMutation.isPending}
624+ disabled={publishRepositoryAction.isPending}
625625 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
626626 />
627627 </div>
@@ -640,7 +640,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 12 unmodified lines
640640 setPublishVisibility(value as SourceControlRepositoryVisibility)
641641 }
642642 aria-labelledby="publish-visibility-cards-label"
643− disabled={publishRepositoryMutation.isPending}
643+ disabled={publishRepositoryAction.isPending}
644644 className="grid grid-cols-2 gap-2.5"
645645 >
646646 {[
@@ -712,7 +712,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 65 unmodified lines
712712 value={publishRemoteName}
713713 onChange={(event) => setPublishRemoteName(event.target.value)}
714714 placeholder="origin"
715− disabled={publishRepositoryMutation.isPending}
715+ disabled={publishRepositoryAction.isPending}
716716 />
717717 </label>
718718 <div className="space-y-1.5">
@@ -728,7 +728,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 9 unmodified lines
728728 setPublishProtocol(value as SourceControlCloneProtocol)
729729 }
730730 aria-labelledby="publish-protocol-label"
731− disabled={publishRepositoryMutation.isPending}
731+ disabled={publishRepositoryAction.isPending}
732732 className="grid grid-cols-2 gap-2"
733733 >
734734 {(["ssh", "https"] as const).map((value) => {
@@ -755,7 +755,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 20 unmodified lines
755755 ) : null}
756756 </div>
757757
758− {publishRepositoryMutation.isPending ? (
758+ {publishRepositoryAction.isPending ? (
759759 <div
760760 role="status"
761761 aria-live="polite"
@@ -765,7 +765,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 3 unmodified lines
765765 Publishing repository to {publishProviderLabel}...
766766 </div>
767767 ) : null}
768− {publishError && !publishRepositoryMutation.isPending ? (
768+ {publishError && !publishRepositoryAction.isPending ? (
769769 <div
770770 role="alert"
771771 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
@@ -833,7 +833,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 61 unmodified lines
833833 <Button
834834 variant="outline"
835835 size="sm"
836− disabled={publishRepositoryMutation.isPending}
836+ disabled={publishRepositoryAction.isPending}
837837 onClick={() => {
838838 if (publishWizardStep === 0) {
839839 handleOpenChange(false);
@@ -858,7 +858,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 18 unmodified lines
858858 disabled={!canSubmitPublishRepository}
859859 onClick={submitPublishRepository}
860860 >
861− {publishRepositoryMutation.isPending ? (
861+ {publishRepositoryAction.isPending ? (
862862 <>
863863 <Spinner className="size-3.5" aria-hidden />
864864 Publishing...
@@ -901,7 +901,6 @@ export default function GitActionsControl({ · 36 unmodified lines
901901 );
902902 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
903903 const setThreadBranch = useStore((store) => store.setThreadBranch);
904− const queryClient = useQueryClient();
905904 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
906905 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
907906 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
@@ -910,6 +909,10 @@ export default function GitActionsControl({ · 2 unmodified lines
910909 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
911910 useState<PendingDefaultBranchAction | null>(null);
912911 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
912+ const sourceControlScope = useMemo(
913+ () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }),
914+ [activeEnvironmentId, gitCwd],
915+ );
913916 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
914917
915918 const updateActiveProgressToast = useCallback(() => {
@@ -1006,32 +1009,13 @@ export default function GitActionsControl({ · 90 unmodified lines
10061009 const allSelected = excludedFiles.size === 0;
10071010 const noneSelected = selectedFiles.length === 0;
10081011
1009− const initMutation = useMutation(
1010− gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
1011− );
1012−
1013− const runImmediateGitActionMutation = useMutation(
1014− gitRunStackedActionMutationOptions({
1015− environmentId: activeEnvironmentId,
1016− cwd: gitCwd,
1017− queryClient,
1018− }),
1012+ const initAction = useVcsInitAction(sourceControlScope);
1013+ const runImmediateGitAction = useGitStackedAction(sourceControlScope);
1014+ const pullAction = useVcsPullAction(sourceControlScope);
1015+ const isGitActionRunning = useSourceControlActionRunning(
1016+ sourceControlScope,
1017+ RUNNING_SOURCE_CONTROL_ACTIONS,
10191018 );
1020− const pullMutation = useMutation(
1021− gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),
1022− );
1023−
1024− const isRunStackedActionRunning =
1025− useIsMutating({
1026− mutationKey: gitMutationKeys.runStackedAction(activeEnvironmentId, gitCwd),
1027− }) > 0;
1028− const isPullRunning =
1029− useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0;
1030− const isPublishRunning =
1031− useIsMutating({
1032− mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd),
1033− }) > 0;
1034− const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning;
10351019 const isSelectingWorktreeBase =
10361020 !activeServerThread &&
10371021 activeDraftThread?.envMode === "worktree" &&
@@ -1308,7 +1292,7 @@ export default function GitActionsControl({ · 270 unmodified lines
13081292 updateActiveProgressToast();
13091293 };
13101294
1311− const promise = runImmediateGitActionMutation.mutateAsync({
1295+ const promise = runImmediateGitAction.run({
13121296 actionId,
13131297 action,
13141298 ...(commitMessage ? { commitMessage } : {}),
@@ -1449,26 +1433,26 @@ export default function GitActionsControl({ · 134 unmodified lines
14491433 return;
14501434 }
14511435 if (quickAction.kind === "run_pull") {
1452− const promise = pullMutation.mutateAsync();
1453− void toastManager.promise<
1454− Awaited<ReturnType<typeof pullMutation.mutateAsync>>,
1455− ThreadToastData
1456− >(promise, {
1457− loading: { title: "Pulling...", data: threadToastData },
1458− success: (result) => ({
1459− title: result.status === "pulled" ? "Pulled" : "Already up to date",
1460− description:
1461− result.status === "pulled"
1462− ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`
1463− : `${result.refName} is already synchronized.`,
1464− data: threadToastData,
1465− }),
1466− error: (err) => ({
1467− title: "Pull failed",
1468− description: err instanceof Error ? err.message : "An error occurred.",
1469− data: threadToastData,
1470− }),
1471− });
1436+ const promise = pullAction.run();
1437+ void toastManager.promise<Awaited<ReturnType<typeof pullAction.run>>, ThreadToastData>(
1438+ promise,
1439+ {
1440+ loading: { title: "Pulling...", data: threadToastData },
1441+ success: (result) => ({
1442+ title: result.status === "pulled" ? "Pulled" : "Already up to date",
1443+ description:
1444+ result.status === "pulled"
1445+ ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`
1446+ : `${result.refName} is already synchronized.`,
1447+ data: threadToastData,
1448+ }),
1449+ error: (err) => ({
1450+ title: "Pull failed",
1451+ description: err instanceof Error ? err.message : "An error occurred.",
1452+ data: threadToastData,
1453+ }),
1454+ },
1455+ );
14721456 void promise.catch(() => undefined);
14731457 return;
14741458 }
@@ -1555,10 +1539,12 @@ export default function GitActionsControl({ · 80 unmodified lines
15551539 <Button
15561540 variant="outline"
15571541 size="xs"
1558− disabled={initMutation.isPending}
1559− onClick={() => initMutation.mutate()}
1542+ disabled={initAction.isPending}
1543+ onClick={() => {
1544+ void initAction.run();
1545+ }}
15601546 >
1561− {initMutation.isPending ? "Initializing..." : "Initialize Git"}
1547+ {initAction.isPending ? "Initializing..." : "Initialize Git"}
15621548 </Button>
15631549 ) : (
15641550 <Group aria-label="Git actions" className="shrink-0">
apps/web/src/components/PullRequestThreadDialog.tsx+36−40
@@ -1,12 +1,12 @@
1−import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";
2−import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1+import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
32 import { useDebouncedValue } from "@tanstack/react-pacer";
43 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
54
65 import {
7− gitPreparePullRequestThreadMutationOptions,
8− gitResolvePullRequestQueryOptions,
9−} from "~/lib/gitReactQuery";
6+ readCachedPullRequestResolution,
7+ usePreparePullRequestThreadAction,
8+ usePullRequestResolution,
9+} from "~/lib/sourceControlActions";
1010 import { useGitStatus } from "~/lib/gitStatusState";
1111 import { cn } from "~/lib/utils";
1212 import { parsePullRequestReference } from "~/pullRequestReference";
@@ -43,7 +43,6 @@ export function PullRequestThreadDialog({ · 30 unmodified lines
4343 onOpenChange,
4444 onPrepared,
4545 }: PullRequestThreadDialogProps) {
46− const queryClient = useQueryClient();
4746 const referenceInputRef = useRef<HTMLInputElement>(null);
4847 const [reference, setReference] = useState(initialReference ?? "");
4948 const [referenceDirty, setReferenceDirty] = useState(false);
@@ -81,33 +80,30 @@ export function PullRequestThreadDialog({ · 31 unmodified lines
8180
8281 const parsedReference = parsePullRequestReference(reference);
8382 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
84− const resolvePullRequestQuery = useQuery(
85− gitResolvePullRequestQueryOptions({
83+ const sourceControlScope = useMemo(
84+ () => ({
8685 environmentId,
8786 cwd,
88− reference: open ? parsedDebouncedReference : null,
8987 }),
88+ [cwd, environmentId],
9089 );
90+ const pullRequestResolution = usePullRequestResolution({
91+ ...sourceControlScope,
92+ reference: open ? parsedDebouncedReference : null,
93+ });
9194 const cachedPullRequest = useMemo(() => {
92− if (!cwd || !parsedReference) {
93− return null;
94− }
95− const cached = queryClient.getQueryData<GitResolvePullRequestResult>([
96− "git",
97− "pull-request",
98− environmentId,
99− cwd,
100− parsedReference,
101− ]);
102− return cached?.pullRequest ?? null;
103− }, [cwd, environmentId, parsedReference, queryClient]);
104− const preparePullRequestThreadMutation = useMutation(
105− gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
106− );
95+ return (
96+ readCachedPullRequestResolution({
97+ ...sourceControlScope,
98+ reference: parsedReference,
99+ })?.pullRequest ?? null
100+ );
101+ }, [parsedReference, sourceControlScope]);
102+ const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
107103
108104 const liveResolvedPullRequest =
109105 parsedReference !== null && parsedReference === parsedDebouncedReference
110− ? (resolvePullRequestQuery.data?.pullRequest ?? null)
106+ ? (pullRequestResolution.data?.pullRequest ?? null)
111107 : null;
112108 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;
113109 const isResolving =
@@ -116,8 +112,8 @@ export function PullRequestThreadDialog({ · 2 unmodified lines
116112 resolvedPullRequest === null &&
117113 (referenceDebouncer.state.isPending ||
118114 parsedReference !== parsedDebouncedReference ||
119− resolvePullRequestQuery.isPending ||
120− resolvePullRequestQuery.isFetching);
115+ pullRequestResolution.isPending ||
116+ pullRequestResolution.isFetching);
121117 const statusTone = useMemo(() => {
122118 switch (resolvedPullRequest?.state) {
123119 case "merged":
@@ -142,7 +138,7 @@ export function PullRequestThreadDialog({ · 18 unmodified lines
142138 }
143139 setPreparingMode(mode);
144140 try {
145− const result = await preparePullRequestThreadMutation.mutateAsync({
141+ const result = await preparePullRequestThreadAction.run({
146142 reference: parsedReference,
147143 mode,
148144 ...(mode === "worktree" ? { threadId } : {}),
@@ -161,7 +157,7 @@ export function PullRequestThreadDialog({ · 12 unmodified lines
161157 onOpenChange,
162158 onPrepared,
163159 parsedReference,
164− preparePullRequestThreadMutation,
160+ preparePullRequestThreadAction,
165161 resolvedPullRequest,
166162 threadId,
167163 ],
@@ -176,13 +172,13 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
176172 : null;
177173 const errorMessage =
178174 validationMessage ??
179− (resolvedPullRequest === null && resolvePullRequestQuery.isError
180− ? resolvePullRequestQuery.error instanceof Error
181− ? resolvePullRequestQuery.error.message
175+ (resolvedPullRequest === null && pullRequestResolution.error
176+ ? pullRequestResolution.error instanceof Error
177+ ? pullRequestResolution.error.message
182178 : `Failed to resolve ${terminology.singular}.`
183− : preparePullRequestThreadMutation.error instanceof Error
184− ? preparePullRequestThreadMutation.error.message
185− : preparePullRequestThreadMutation.error
179+ : preparePullRequestThreadAction.error instanceof Error
180+ ? preparePullRequestThreadAction.error.message
181+ : preparePullRequestThreadAction.error
186182 ? `Failed to prepare ${terminology.singular} thread.`
187183 : null);
188184
@@ -190,7 +186,7 @@ export function PullRequestThreadDialog({ · 1 unmodified line
190186 <Dialog
191187 open={open}
192188 onOpenChange={(nextOpen) => {
193− if (!preparePullRequestThreadMutation.isPending) {
189+ if (!preparePullRequestThreadAction.isPending) {
194190 onOpenChange(nextOpen);
195191 }
196192 }}
@@ -224,7 +220,7 @@ export function PullRequestThreadDialog({ · 27 unmodified lines
224220 return;
225221 }
226222 event.preventDefault();
227− if (!isResolving && !preparePullRequestThreadMutation.isPending) {
223+ if (!isResolving && !preparePullRequestThreadAction.isPending) {
228224 void handleConfirm("local");
229225 }
230226 }}
@@ -263,7 +259,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines
263259 variant="outline"
264260 size="sm"
265261 onClick={() => onOpenChange(false)}
266− disabled={preparePullRequestThreadMutation.isPending}
262+ disabled={preparePullRequestThreadAction.isPending}
267263 >
268264 Cancel
269265 </Button>
@@ -278,7 +274,7 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
278274 !cwd ||
279275 !resolvedPullRequest ||
280276 isResolving ||
281− preparePullRequestThreadMutation.isPending
277+ preparePullRequestThreadAction.isPending
282278 }
283279 >
284280 {preparingMode === "local" ? "Preparing local..." : "Local"}
@@ -293,7 +289,7 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
293289 !cwd ||
294290 !resolvedPullRequest ||
295291 isResolving ||
296− preparePullRequestThreadMutation.isPending
292+ preparePullRequestThreadAction.isPending
297293 }
298294 >
299295 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}
apps/web/src/environments/runtime/service.savedEnvironments.test.ts+0−6
@@ -108,12 +108,6 @@ vi.mock("~/orchestrationEventEffects", () => ({ · 107 unmodified lines
108108 })),
109109 }));
110110
111−vi.mock("~/lib/projectReactQuery", () => ({
112− projectQueryKeys: {
113− all: ["projects"],
114− },
115−}));
116−
117111 vi.mock("~/lib/providerReactQuery", () => ({
118112 providerQueryKeys: {
119113 all: ["providers"],
apps/web/src/environments/runtime/service.ts+0−2
@@ -33,7 +33,6 @@ import { · 32 unmodified lines
3333 import { ensureLocalApi } from "~/localApi";
3434 import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
3535 import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
36−import { projectQueryKeys } from "~/lib/projectReactQuery";
3736 import { providerQueryKeys } from "~/lib/providerReactQuery";
3837 import { getPrimaryKnownEnvironment } from "../primary";
3938 import {
@@ -1770,7 +1769,6 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () · 1730 unmodified lines
17701769 }
17711770 needsProviderInvalidation = false;
17721771 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
1773− void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
17741772 },
17751773 {
17761774 wait: 100,
apps/web/src/hooks/useThreadActions.ts+2−5
@@ -1,6 +1,5 @@
11 import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
22 import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3−import { useQueryClient } from "@tanstack/react-query";
43 import { useRouter } from "@tanstack/react-router";
54 import { useCallback, useRef } from "react";
65
@@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; · 1 unmodified line
87 import { useComposerDraftStore } from "../composerDraftStore";
98 import { useNewThreadHandler } from "./useHandleNewThread";
109 import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11−import { invalidateGitQueries } from "../lib/gitReactQuery";
10+import { invalidateSourceControlState } from "../lib/sourceControlActions";
1211 import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
1312 import { newCommandId } from "../lib/utils";
1413 import { readLocalApi } from "../localApi";
@@ -40,7 +39,6 @@ export function useThreadActions() { · 25 unmodified lines
4039 // sidebar row via archiveThread → attemptArchiveThread.
4140 const handleNewThreadRef = useRef(handleNewThread);
4241 handleNewThreadRef.current = handleNewThread;
43− const queryClient = useQueryClient();
4442
4543 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
4644 const state = useStore.getState();
@@ -229,7 +227,7 @@ export function useThreadActions() { · 182 unmodified lines
229227 path: orphanedWorktreePath,
230228 force: true,
231229 });
232− await invalidateGitQueries(queryClient, {
230+ await invalidateSourceControlState({
233231 environmentId: threadRef.environmentId,
234232 });
235233 } catch (error) {
@@ -255,7 +253,6 @@ export function useThreadActions() { · 19 unmodified lines
255253 clearTerminalUiState,
256254 getCurrentRouteThreadRef,
257255 router,
258− queryClient,
259256 resolveThreadTarget,
260257 sidebarThreadSortOrder,
261258 ],
apps/web/src/lib/gitReactQuery.test.ts+0−135
@@ -1,135 +0,0 @@
1−import { QueryClient } from "@tanstack/react-query";
2−import { describe, expect, it, vi } from "vitest";
3−
4−vi.mock("../environmentApi", () => ({
5− ensureEnvironmentApi: vi.fn(),
6−}));
7−
8−vi.mock("../wsRpcClient", () => ({
9− getWsRpcClient: vi.fn(),
10− getWsRpcClientForEnvironment: vi.fn(),
11−}));
12−
13−import type { InfiniteData } from "@tanstack/react-query";
14−import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
15−
16−import {
17− gitBranchSearchInfiniteQueryOptions,
18− gitMutationKeys,
19− gitPreparePullRequestThreadMutationOptions,
20− gitPullMutationOptions,
21− gitRunStackedActionMutationOptions,
22− invalidateGitQueries,
23−} from "./gitReactQuery";
24−
25−const BRANCH_QUERY_RESULT: VcsListRefsResult = {
26− refs: [],
27− isRepo: true,
28− hasPrimaryRemote: true,
29− nextCursor: null,
30− totalCount: 0,
31−};
32−
33−const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = {
34− pages: [BRANCH_QUERY_RESULT],
35− pageParams: [0],
36−};
37−const ENVIRONMENT_A = EnvironmentId.make("environment-a");
38−const ENVIRONMENT_B = EnvironmentId.make("environment-b");
39−
40−describe("gitMutationKeys", () => {
41− it("scopes stacked action keys by cwd", () => {
42− expect(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a")).not.toEqual(
43− gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/b"),
44− );
45− });
46−
47− it("scopes pull keys by cwd", () => {
48− expect(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a")).not.toEqual(
49− gitMutationKeys.pull(ENVIRONMENT_A, "/repo/b"),
50− );
51− });
52−
53− it("scopes pull request thread preparation keys by cwd", () => {
54− expect(gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a")).not.toEqual(
55− gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/b"),
56− );
57− });
58−});
59−
60−describe("git mutation options", () => {
61− const queryClient = new QueryClient();
62−
63− it("attaches cwd-scoped mutation key for runStackedAction", () => {
64− const options = gitRunStackedActionMutationOptions({
65− environmentId: ENVIRONMENT_A,
66− cwd: "/repo/a",
67− queryClient,
68− });
69− expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction(ENVIRONMENT_A, "/repo/a"));
70− });
71−
72− it("attaches cwd-scoped mutation key for pull", () => {
73− const options = gitPullMutationOptions({
74− environmentId: ENVIRONMENT_A,
75− cwd: "/repo/a",
76− queryClient,
77− });
78− expect(options.mutationKey).toEqual(gitMutationKeys.pull(ENVIRONMENT_A, "/repo/a"));
79− });
80−
81− it("attaches cwd-scoped mutation key for preparePullRequestThread", () => {
82− const options = gitPreparePullRequestThreadMutationOptions({
83− environmentId: ENVIRONMENT_A,
84− cwd: "/repo/a",
85− queryClient,
86− });
87− expect(options.mutationKey).toEqual(
88− gitMutationKeys.preparePullRequestThread(ENVIRONMENT_A, "/repo/a"),
89− );
90− });
91−});
92−
93−describe("invalidateGitQueries", () => {
94− it("can invalidate a single cwd without blasting other git query scopes", async () => {
95− const queryClient = new QueryClient();
96−
97− queryClient.setQueryData(
98− gitBranchSearchInfiniteQueryOptions({
99− environmentId: ENVIRONMENT_A,
100− cwd: "/repo/a",
101− query: "feature",
102− }).queryKey,
103− BRANCH_SEARCH_RESULT,
104− );
105− queryClient.setQueryData(
106− gitBranchSearchInfiniteQueryOptions({
107− environmentId: ENVIRONMENT_B,
108− cwd: "/repo/b",
109− query: "feature",
110− }).queryKey,
111− BRANCH_SEARCH_RESULT,
112− );
113−
114− await invalidateGitQueries(queryClient, { environmentId: ENVIRONMENT_A, cwd: "/repo/a" });
115−
116− expect(
117− queryClient.getQueryState(
118− gitBranchSearchInfiniteQueryOptions({
119− environmentId: ENVIRONMENT_A,
120− cwd: "/repo/a",
121− query: "feature",
122− }).queryKey,
123− )?.isInvalidated,
124− ).toBe(true);
125− expect(
126− queryClient.getQueryState(
127− gitBranchSearchInfiniteQueryOptions({
128− environmentId: ENVIRONMENT_B,
129− cwd: "/repo/b",
130− query: "feature",
131− }).queryKey,
132− )?.isInvalidated,
133− ).toBe(false);
134− });
135−});
apps/web/src/lib/gitReactQuery.ts+0−327
@@ -1,327 +0,0 @@
1−import {
2− type EnvironmentId,
3− type GitActionProgressEvent,
4− type GitStackedAction,
5− type SourceControlPublishRepositoryInput,
6− type ThreadId,
7−} from "@t3tools/contracts";
8−import {
9− infiniteQueryOptions,
10− mutationOptions,
11− queryOptions,
12− type QueryClient,
13−} from "@tanstack/react-query";
14−import { ensureEnvironmentApi } from "../environmentApi";
15−import { requireEnvironmentConnection } from "../environments/runtime";
16−
17−const GIT_BRANCHES_STALE_TIME_MS = 15_000;
18−const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
19−const GIT_BRANCHES_PAGE_SIZE = 100;
20−
21−export const gitQueryKeys = {
22− all: ["git"] as const,
23− refs: (environmentId: EnvironmentId | null, cwd: string | null) =>
24− ["git", "refs", environmentId ?? null, cwd] as const,
25− branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
26− ["git", "refs", environmentId ?? null, cwd, "search", query] as const,
27−};
28−
29−export const gitMutationKeys = {
30− init: (environmentId: EnvironmentId | null, cwd: string | null) =>
31− ["git", "mutation", "init", environmentId ?? null, cwd] as const,
32− switchRef: (environmentId: EnvironmentId | null, cwd: string | null) =>
33− ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const,
34− runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
35− ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const,
36− pull: (environmentId: EnvironmentId | null, cwd: string | null) =>
37− ["git", "mutation", "pull", environmentId ?? null, cwd] as const,
38− preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) =>
39− ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const,
40− publishRepository: (environmentId: EnvironmentId | null, cwd: string | null) =>
41− ["git", "mutation", "publish-repository", environmentId ?? null, cwd] as const,
42−};
43−
44−export function invalidateGitQueries(
45− queryClient: QueryClient,
46− input?: { environmentId?: EnvironmentId | null; cwd?: string | null },
47−) {
48− const environmentId = input?.environmentId ?? null;
49− const cwd = input?.cwd ?? null;
50− if (cwd !== null) {
51− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
52− }
53−
54− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
55−}
56−
57−function invalidateGitBranchQueries(
58− queryClient: QueryClient,
59− environmentId: EnvironmentId | null,
60− cwd: string | null,
61−) {
62− if (cwd === null) {
63− return Promise.resolve();
64− }
65−
66− return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
67−}
68−
69−/**
70− * @deprecated Use a VCS-named query helper once the UI naming migration lands.
71− */
72−export function gitBranchSearchInfiniteQueryOptions(input: {
73− environmentId: EnvironmentId | null;
74− cwd: string | null;
75− query: string;
76− enabled?: boolean;
77−}) {
78− const normalizedQuery = input.query.trim();
79−
80− return infiniteQueryOptions({
81− queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
82− initialPageParam: 0,
83− queryFn: async ({ pageParam }) => {
84− if (!input.cwd) throw new Error("Git refs are unavailable.");
85− if (!input.environmentId) throw new Error("Git refs are unavailable.");
86− const api = ensureEnvironmentApi(input.environmentId);
87− return api.vcs.listRefs({
88− cwd: input.cwd,
89− ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
90− cursor: pageParam,
91− limit: GIT_BRANCHES_PAGE_SIZE,
92− });
93− },
94− getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
95− enabled: input.cwd !== null && (input.enabled ?? true),
96− staleTime: GIT_BRANCHES_STALE_TIME_MS,
97− refetchOnWindowFocus: true,
98− refetchOnReconnect: true,
99− refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS,
100− });
101−}
102−
103−export function gitResolvePullRequestQueryOptions(input: {
104− environmentId: EnvironmentId | null;
105− cwd: string | null;
106− reference: string | null;
107−}) {
108− return queryOptions({
109− queryKey: [
110− "git",
111− "pull-request",
112− input.environmentId ?? null,
113− input.cwd,
114− input.reference,
115− ] as const,
116− queryFn: async () => {
117− if (!input.cwd || !input.reference || !input.environmentId) {
118− throw new Error("Pull request lookup is unavailable.");
119− }
120− const api = ensureEnvironmentApi(input.environmentId);
121− return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference });
122− },
123− enabled: input.environmentId !== null && input.cwd !== null && input.reference !== null,
124− staleTime: 30_000,
125− refetchOnWindowFocus: false,
126− refetchOnReconnect: false,
127− });
128−}
129−
130−/**
131− * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
132− */
133−export function gitInitMutationOptions(input: {
134− environmentId: EnvironmentId | null;
135− cwd: string | null;
136− queryClient: QueryClient;
137−}) {
138− return mutationOptions({
139− mutationKey: gitMutationKeys.init(input.environmentId, input.cwd),
140− mutationFn: async () => {
141− if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
142− const api = ensureEnvironmentApi(input.environmentId);
143− return api.vcs.init({ cwd: input.cwd });
144− },
145− onSettled: async () => {
146− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
147− },
148− });
149−}
150−
151−/**
152− * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
153− */
154−export function gitCheckoutMutationOptions(input: {
155− environmentId: EnvironmentId | null;
156− cwd: string | null;
157− queryClient: QueryClient;
158−}) {
159− return mutationOptions({
160− mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd),
161− mutationFn: async (refName: string) => {
162− if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable.");
163− const api = ensureEnvironmentApi(input.environmentId);
164− return api.vcs.switchRef({ cwd: input.cwd, refName });
165− },
166− onSettled: async () => {
167− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
168− },
169− });
170−}
171−
172−export function gitRunStackedActionMutationOptions(input: {
173− environmentId: EnvironmentId | null;
174− cwd: string | null;
175− queryClient: QueryClient;
176−}) {
177− return mutationOptions({
178− mutationKey: gitMutationKeys.runStackedAction(input.environmentId, input.cwd),
179− mutationFn: async ({
180− actionId,
181− action,
182− commitMessage,
183− featureBranch,
184− filePaths,
185− onProgress,
186− }: {
187− actionId: string;
188− action: GitStackedAction;
189− commitMessage?: string;
190− featureBranch?: boolean;
191− filePaths?: string[];
192− onProgress?: (event: GitActionProgressEvent) => void;
193− }) => {
194− if (!input.cwd || !input.environmentId) throw new Error("Git action is unavailable.");
195− return requireEnvironmentConnection(input.environmentId).client.git.runStackedAction(
196− {
197− action,
198− actionId,
199− cwd: input.cwd,
200− ...(commitMessage ? { commitMessage } : {}),
201− ...(featureBranch ? { featureBranch: true } : {}),
202− ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
203− },
204− ...(onProgress ? [{ onProgress }] : []),
205− );
206− },
207− onSuccess: async () => {
208− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
209− },
210− });
211−}
212−
213−/**
214− * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
215− */
216−export function gitPullMutationOptions(input: {
217− environmentId: EnvironmentId | null;
218− cwd: string | null;
219− queryClient: QueryClient;
220−}) {
221− return mutationOptions({
222− mutationKey: gitMutationKeys.pull(input.environmentId, input.cwd),
223− mutationFn: async () => {
224− if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
225− const api = ensureEnvironmentApi(input.environmentId);
226− return api.vcs.pull({ cwd: input.cwd });
227− },
228− onSuccess: async () => {
229− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
230− },
231− });
232−}
233−
234−export function sourceControlPublishRepositoryMutationOptions(input: {
235− environmentId: EnvironmentId | null;
236− cwd: string | null;
237− queryClient: QueryClient;
238−}) {
239− return mutationOptions({
240− mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd),
241− mutationFn: async (args: Omit<SourceControlPublishRepositoryInput, "cwd">) => {
242− if (!input.cwd || !input.environmentId) {
243− throw new Error("Repository publishing is unavailable.");
244− }
245− const api = ensureEnvironmentApi(input.environmentId);
246− return api.sourceControl.publishRepository({ cwd: input.cwd, ...args });
247− },
248− onSuccess: async () => {
249− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
250− },
251− });
252−}
253−
254−/**
255− * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
256− */
257−export function gitCreateWorktreeMutationOptions(input: {
258− environmentId: EnvironmentId | null;
259− queryClient: QueryClient;
260−}) {
261− return mutationOptions({
262− mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
263− mutationFn: (
264− args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["createWorktree"]>[0],
265− ) => {
266− if (!input.environmentId) {
267− throw new Error("Worktree creation is unavailable.");
268− }
269− return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args);
270− },
271− onSuccess: async () => {
272− await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
273− },
274− });
275−}
276−
277−/**
278− * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
279− */
280−export function gitRemoveWorktreeMutationOptions(input: {
281− environmentId: EnvironmentId | null;
282− queryClient: QueryClient;
283−}) {
284− return mutationOptions({
285− mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
286− mutationFn: (
287− args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["removeWorktree"]>[0],
288− ) => {
289− if (!input.environmentId) {
290− throw new Error("Worktree removal is unavailable.");
291− }
292− return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args);
293− },
294− onSuccess: async () => {
295− await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
296− },
297− });
298−}
299−
300−export function gitPreparePullRequestThreadMutationOptions(input: {
301− environmentId: EnvironmentId | null;
302− cwd: string | null;
303− queryClient: QueryClient;
304−}) {
305− return mutationOptions({
306− mutationKey: gitMutationKeys.preparePullRequestThread(input.environmentId, input.cwd),
307− mutationFn: async (args: {
308− reference: string;
309− mode: "local" | "worktree";
310− threadId?: ThreadId;
311− }) => {
312− if (!input.cwd || !input.environmentId) {
313− throw new Error("Pull request thread preparation is unavailable.");
314− }
315− const api = ensureEnvironmentApi(input.environmentId);
316− return api.git.preparePullRequestThread({
317− cwd: input.cwd,
318− reference: args.reference,
319− mode: args.mode,
320− ...(args.threadId ? { threadId: args.threadId } : {}),
321− });
322− },
323− onSuccess: async () => {
324− await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
325− },
326− });
327−}
apps/web/src/lib/projectReactQuery.ts+0−52
@@ -1,52 +0,0 @@
1−import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2−import { queryOptions } from "@tanstack/react-query";
3−import { ensureEnvironmentApi } from "~/environmentApi";
4−
5−export const projectQueryKeys = {
6− all: ["projects"] as const,
7− searchEntries: (
8− environmentId: EnvironmentId | null,
9− cwd: string | null,
10− query: string,
11− limit: number,
12− ) => ["projects", "search-entries", environmentId ?? null, cwd, query, limit] as const,
13−};
14−
15−const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
16−const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000;
17−const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
18− entries: [],
19− truncated: false,
20−};
21−
22−export function projectSearchEntriesQueryOptions(input: {
23− environmentId: EnvironmentId | null;
24− cwd: string | null;
25− query: string;
26− enabled?: boolean;
27− limit?: number;
28− staleTime?: number;
29−}) {
30− const limit = input.limit ?? DEFAULT_SEARCH_ENTRIES_LIMIT;
31− return queryOptions({
32− queryKey: projectQueryKeys.searchEntries(input.environmentId, input.cwd, input.query, limit),
33− queryFn: async () => {
34− if (!input.cwd || !input.environmentId) {
35− throw new Error("Workspace entry search is unavailable.");
36− }
37− const api = ensureEnvironmentApi(input.environmentId);
38− return api.projects.searchEntries({
39− cwd: input.cwd,
40− query: input.query,
41− limit,
42− });
43− },
44− enabled:
45− (input.enabled ?? true) &&
46− input.environmentId !== null &&
47− input.cwd !== null &&
48− input.query.length > 0,
49− staleTime: input.staleTime ?? DEFAULT_SEARCH_ENTRIES_STALE_TIME,
50− placeholderData: (previous) => previous ?? EMPTY_SEARCH_ENTRIES_RESULT,
51− });
52−}
apps/web/src/lib/sourceControlActions.ts+381−0
@@ -0,0 +1,381 @@
1+import {
2+ type EnvironmentId,
3+ type GitActionProgressEvent,
4+ type GitRunStackedActionResult,
5+ type GitStackedAction,
6+ type GitResolvePullRequestResult,
7+ type SourceControlCloneProtocol,
8+ type SourceControlPublishRepositoryResult,
9+ type SourceControlRepositoryVisibility,
10+ type ThreadId,
11+ type VcsPullResult,
12+} from "@t3tools/contracts";
13+import {
14+ useCallback,
15+ useEffect,
16+ useMemo,
17+ useState,
18+ useSyncExternalStore,
19+ useTransition,
20+} from "react";
21+
22+import { ensureEnvironmentApi } from "../environmentApi";
23+import { requireEnvironmentConnection } from "../environments/runtime";
24+import { refreshGitStatus } from "./gitStatusState";
25+import { vcsRefManager } from "./vcsRefState";
26+
27+type SourceControlActionKind =
28+ | "init"
29+ | "pull"
30+ | "publishRepository"
31+ | "runStackedAction"
32+ | "preparePullRequestThread";
33+
34+interface SourceControlActionScope {
35+ readonly environmentId: EnvironmentId | null;
36+ readonly cwd: string | null;
37+}
38+
39+interface SourceControlActionState<TArgs extends ReadonlyArray<unknown>, TResult> {
40+ readonly isPending: boolean;
41+ readonly error: unknown;
42+ readonly run: (...args: TArgs) => Promise<TResult>;
43+ readonly resetError: () => void;
44+}
45+
46+const actionListeners = new Set<() => void>();
47+const activeActionCounts = new Map<string, number>();
48+
49+function notifyActionListeners(): void {
50+ for (const listener of actionListeners) {
51+ listener();
52+ }
53+}
54+
55+function subscribeActionState(listener: () => void): () => void {
56+ actionListeners.add(listener);
57+ return () => {
58+ actionListeners.delete(listener);
59+ };
60+}
61+
62+function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string {
63+ return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`;
64+}
65+
66+function beginAction(key: string): () => void {
67+ activeActionCounts.set(key, (activeActionCounts.get(key) ?? 0) + 1);
68+ notifyActionListeners();
69+ let ended = false;
70+ return () => {
71+ if (ended) {
72+ return;
73+ }
74+ ended = true;
75+ const next = (activeActionCounts.get(key) ?? 1) - 1;
76+ if (next <= 0) {
77+ activeActionCounts.delete(key);
78+ } else {
79+ activeActionCounts.set(key, next);
80+ }
81+ notifyActionListeners();
82+ };
83+}
84+
85+function isAnyActionRunning(
86+ kinds: ReadonlyArray<SourceControlActionKind>,
87+ scope: SourceControlActionScope,
88+): boolean {
89+ return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0);
90+}
91+
92+export function invalidateSourceControlState(scope?: {
93+ readonly environmentId?: EnvironmentId | null;
94+ readonly cwd?: string | null;
95+}): Promise<void> {
96+ const environmentId = scope?.environmentId ?? null;
97+ const cwd = scope?.cwd ?? null;
98+ if (cwd !== null) {
99+ vcsRefManager.invalidateScope({ environmentId, cwd });
100+ if (environmentId !== null) {
101+ return refreshGitStatus({ environmentId, cwd }).then(
102+ () => undefined,
103+ () => undefined,
104+ );
105+ }
106+ return Promise.resolve();
107+ }
108+
109+ vcsRefManager.invalidate();
110+ return Promise.resolve();
111+}
112+
113+function useSourceControlAction<TArgs extends ReadonlyArray<unknown>, TResult>(input: {
114+ readonly kind: SourceControlActionKind;
115+ readonly scope: SourceControlActionScope;
116+ readonly action: (...args: TArgs) => Promise<TResult>;
117+ readonly invalidateOnSuccess?: boolean;
118+}): SourceControlActionState<TArgs, TResult> {
119+ const { action, invalidateOnSuccess = true, kind, scope } = input;
120+ const [error, setError] = useState<unknown>(null);
121+ const [activeCount, setActiveCount] = useState(0);
122+ const [isTransitionPending, startTransition] = useTransition();
123+ const key = actionKey(kind, scope);
124+
125+ const resetError = useCallback(() => {
126+ startTransition(() => setError(null));
127+ }, [startTransition]);
128+
129+ const run = useCallback(
130+ async (...args: TArgs): Promise<TResult> => {
131+ const endAction = beginAction(key);
132+ startTransition(() => {
133+ setError(null);
134+ setActiveCount((count) => count + 1);
135+ });
136+ try {
137+ const result = await action(...args);
138+ if (invalidateOnSuccess) {
139+ await invalidateSourceControlState(scope);
140+ }
141+ return result;
142+ } catch (nextError) {
143+ startTransition(() => setError(nextError));
144+ throw nextError;
145+ } finally {
146+ endAction();
147+ startTransition(() => setActiveCount((count) => Math.max(0, count - 1)));
148+ }
149+ },
150+ [action, invalidateOnSuccess, key, scope, startTransition],
151+ );
152+
153+ return {
154+ error,
155+ isPending: activeCount > 0 || isTransitionPending,
156+ resetError,
157+ run,
158+ };
159+}
160+
161+export function useSourceControlActionRunning(
162+ scope: SourceControlActionScope,
163+ kinds: ReadonlyArray<SourceControlActionKind>,
164+): boolean {
165+ const stableKinds = useMemo(() => [...kinds].sort(), [kinds]);
166+ return useSyncExternalStore(
167+ subscribeActionState,
168+ () => isAnyActionRunning(stableKinds, scope),
169+ () => false,
170+ );
171+}
172+
173+export function useVcsInitAction(scope: SourceControlActionScope) {
174+ const action = useCallback(async () => {
175+ if (!scope.cwd || !scope.environmentId) throw new Error("Git init is unavailable.");
176+ return ensureEnvironmentApi(scope.environmentId).vcs.init({ cwd: scope.cwd });
177+ }, [scope]);
178+
179+ return useSourceControlAction({
180+ kind: "init",
181+ scope,
182+ action,
183+ });
184+}
185+
186+export function useGitStackedAction(scope: SourceControlActionScope) {
187+ const action = useCallback(
188+ async ({
189+ actionId,
190+ action,
191+ commitMessage,
192+ featureBranch,
193+ filePaths,
194+ onProgress,
195+ }: {
196+ actionId: string;
197+ action: GitStackedAction;
198+ commitMessage?: string;
199+ featureBranch?: boolean;
200+ filePaths?: string[];
201+ onProgress?: (event: GitActionProgressEvent) => void;
202+ }): Promise<GitRunStackedActionResult> => {
203+ if (!scope.cwd || !scope.environmentId) throw new Error("Git action is unavailable.");
204+ return requireEnvironmentConnection(scope.environmentId).client.git.runStackedAction(
205+ {
206+ action,
207+ actionId,
208+ cwd: scope.cwd,
209+ ...(commitMessage ? { commitMessage } : {}),
210+ ...(featureBranch ? { featureBranch: true } : {}),
211+ ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
212+ },
213+ ...(onProgress ? [{ onProgress }] : []),
214+ );
215+ },
216+ [scope],
217+ );
218+
219+ return useSourceControlAction({
220+ kind: "runStackedAction",
221+ scope,
222+ action,
223+ });
224+}
225+
226+export function useVcsPullAction(scope: SourceControlActionScope) {
227+ const action = useCallback(async (): Promise<VcsPullResult> => {
228+ if (!scope.cwd || !scope.environmentId) throw new Error("Git pull is unavailable.");
229+ return ensureEnvironmentApi(scope.environmentId).vcs.pull({ cwd: scope.cwd });
230+ }, [scope]);
231+
232+ return useSourceControlAction({
233+ kind: "pull",
234+ scope,
235+ action,
236+ });
237+}
238+
239+export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) {
240+ const action = useCallback(
241+ async (args: {
242+ provider: "github" | "gitlab" | "bitbucket" | "azure-devops";
243+ repository: string;
244+ visibility: SourceControlRepositoryVisibility;
245+ remoteName: string;
246+ protocol: SourceControlCloneProtocol;
247+ }): Promise<SourceControlPublishRepositoryResult> => {
248+ if (!scope.cwd || !scope.environmentId) {
249+ throw new Error("Repository publishing is unavailable.");
250+ }
251+ return ensureEnvironmentApi(scope.environmentId).sourceControl.publishRepository({
252+ cwd: scope.cwd,
253+ ...args,
254+ });
255+ },
256+ [scope],
257+ );
258+
259+ return useSourceControlAction({
260+ kind: "publishRepository",
261+ scope,
262+ action,
263+ });
264+}
265+
266+export function usePreparePullRequestThreadAction(scope: SourceControlActionScope) {
267+ const action = useCallback(
268+ async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId }) => {
269+ if (!scope.cwd || !scope.environmentId) {
270+ throw new Error("Pull request thread preparation is unavailable.");
271+ }
272+ return ensureEnvironmentApi(scope.environmentId).git.preparePullRequestThread({
273+ cwd: scope.cwd,
274+ reference: args.reference,
275+ mode: args.mode,
276+ ...(args.threadId ? { threadId: args.threadId } : {}),
277+ });
278+ },
279+ [scope],
280+ );
281+
282+ return useSourceControlAction({
283+ kind: "preparePullRequestThread",
284+ scope,
285+ action,
286+ });
287+}
288+
289+interface PullRequestResolutionTarget {
290+ readonly environmentId: EnvironmentId | null;
291+ readonly cwd: string | null;
292+ readonly reference: string | null;
293+}
294+
295+interface PullRequestResolutionState {
296+ readonly data: GitResolvePullRequestResult | null;
297+ readonly error: unknown;
298+ readonly isPending: boolean;
299+ readonly isFetching: boolean;
300+}
301+
302+const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = {
303+ data: null,
304+ error: null,
305+ isPending: false,
306+ isFetching: false,
307+};
308+
309+const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>();
310+
311+function pullRequestResolutionKey(target: PullRequestResolutionTarget): string | null {
312+ if (!target.environmentId || !target.cwd || !target.reference) {
313+ return null;
314+ }
315+ return `${target.environmentId}:${target.cwd}:${target.reference}`;
316+}
317+
318+export function readCachedPullRequestResolution(
319+ target: PullRequestResolutionTarget,
320+): GitResolvePullRequestResult | null {
321+ const key = pullRequestResolutionKey(target);
322+ return key ? (pullRequestResolutionCache.get(key) ?? null) : null;
323+}
324+
325+export function usePullRequestResolution(
326+ target: PullRequestResolutionTarget,
327+): PullRequestResolutionState {
328+ const stableTarget = useMemo(
329+ () => ({
330+ environmentId: target.environmentId,
331+ cwd: target.cwd,
332+ reference: target.reference,
333+ }),
334+ [target.cwd, target.environmentId, target.reference],
335+ );
336+ const key = pullRequestResolutionKey(stableTarget);
337+ const [state, setState] = useState<PullRequestResolutionState>(() => {
338+ const cached = readCachedPullRequestResolution(stableTarget);
339+ return cached
340+ ? { data: cached, error: null, isPending: false, isFetching: false }
341+ : EMPTY_PULL_REQUEST_RESOLUTION;
342+ });
343+
344+ useEffect(() => {
345+ if (!key || !stableTarget.environmentId || !stableTarget.cwd || !stableTarget.reference) {
346+ setState(EMPTY_PULL_REQUEST_RESOLUTION);
347+ return;
348+ }
349+
350+ const cached = pullRequestResolutionCache.get(key) ?? null;
351+ setState({
352+ data: cached,
353+ error: null,
354+ isPending: cached === null,
355+ isFetching: true,
356+ });
357+
358+ let cancelled = false;
359+ ensureEnvironmentApi(stableTarget.environmentId)
360+ .git.resolvePullRequest({ cwd: stableTarget.cwd, reference: stableTarget.reference })
361+ .then((result) => {
362+ if (cancelled) {
363+ return;
364+ }
365+ pullRequestResolutionCache.set(key, result);
366+ setState({ data: result, error: null, isPending: false, isFetching: false });
367+ })
368+ .catch((error: unknown) => {
369+ if (cancelled) {
370+ return;
371+ }
372+ setState({ data: cached, error, isPending: false, isFetching: false });
373+ });
374+
375+ return () => {
376+ cancelled = true;
377+ };
378+ }, [key, stableTarget]);
379+
380+ return state;
381+}
apps/web/src/lib/vcsRefState.ts+48−0
@@ -0,0 +1,48 @@
1+import { useAtomValue } from "@effect/atom-react";
2+import {
3+ type VcsRefState,
4+ type VcsRefTarget,
5+ EMPTY_VCS_REF_ATOM,
6+ EMPTY_VCS_REF_STATE,
7+ createVcsRefManager,
8+ getVcsRefTargetKey,
9+ vcsRefStateAtom,
10+} from "@t3tools/client-runtime";
11+import { useEffect, useMemo } from "react";
12+
13+import {
14+ readEnvironmentConnection,
15+ subscribeEnvironmentConnections,
16+} from "../environments/runtime";
17+import { appAtomRegistry } from "../rpc/atomRegistry";
18+
19+const VCS_REF_LIST_LIMIT = 100;
20+const VCS_REF_STALE_TIME_MS = 5_000;
21+
22+export const vcsRefManager = createVcsRefManager({
23+ getRegistry: () => appAtomRegistry,
24+ getClient: (environmentId) => readEnvironmentConnection(environmentId)?.client.vcs ?? null,
25+ subscribeClientChanges: subscribeEnvironmentConnections,
26+ watchLimit: VCS_REF_LIST_LIMIT,
27+ staleTimeMs: VCS_REF_STALE_TIME_MS,
28+ onBackgroundError: (error) => {
29+ console.warn("[vcs-refs] background refresh failed", error);
30+ },
31+});
32+
33+export function useVcsRefs(target: VcsRefTarget): VcsRefState {
34+ const stableTarget = useMemo(
35+ () => ({
36+ environmentId: target.environmentId,
37+ cwd: target.cwd,
38+ query: target.query ?? null,
39+ }),
40+ [target.cwd, target.environmentId, target.query],
41+ );
42+ const targetKey = getVcsRefTargetKey(stableTarget);
43+
44+ useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
45+
46+ const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
47+ return targetKey === null ? EMPTY_VCS_REF_STATE : state;
48+}
packages/client-runtime/src/vcsRefState.test.ts+106−2
@@ -102,7 +102,7 @@ describe("createVcsRefManager", () => { · 101 unmodified lines
102102 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
103103 });
104104
105− it("loads the next page and appends branches", async () => {
105+ it("loads the next page and appends refs", async () => {
106106 const mock = createMockClient();
107107 const manager = createVcsRefManager({
108108 getRegistry: () => atomRegistry,
@@ -126,6 +126,70 @@ describe("createVcsRefManager", () => { · 17 unmodified lines
126126 });
127127 });
128128
129+ it("keeps cached refs visible while refreshing", async () => {
130+ const nextLoad = deferred<VcsListRefsResult>();
131+ let callCount = 0;
132+ const listRefs = vi.fn((async () => {
133+ callCount += 1;
134+ return callCount === 1 ? FIRST_PAGE : nextLoad.promise;
135+ }) satisfies VcsRefClient["listRefs"]);
136+ const client = { listRefs } satisfies VcsRefClient;
137+ const manager = createVcsRefManager({
138+ getRegistry: () => atomRegistry,
139+ getClient: () => client,
140+ });
141+
142+ await manager.load(TARGET, client);
143+
144+ const refresh = manager.load(TARGET, client);
145+ expect(manager.getSnapshot(TARGET)).toEqual({
146+ data: FIRST_PAGE,
147+ isPending: true,
148+ error: null,
149+ });
150+
151+ nextLoad.resolve(SECOND_PAGE);
152+ await expect(refresh).resolves.toEqual(SECOND_PAGE);
153+ expect(manager.getSnapshot(TARGET)).toEqual({
154+ data: SECOND_PAGE,
155+ isPending: false,
156+ error: null,
157+ });
158+ });
159+
160+ it("preserves loaded pages during first-page revalidation", async () => {
161+ const refreshedFirstPage: VcsListRefsResult = {
162+ ...FIRST_PAGE,
163+ refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
164+ nextCursor: 1,
165+ totalCount: 3,
166+ };
167+ let callCount = 0;
168+ const listRefs = vi.fn((async (input) => {
169+ callCount += 1;
170+ if (input.cursor === 2) {
171+ return SECOND_PAGE;
172+ }
173+ return callCount === 1 ? FIRST_PAGE : refreshedFirstPage;
174+ }) satisfies VcsRefClient["listRefs"]);
175+ const client = { listRefs } satisfies VcsRefClient;
176+ const manager = createVcsRefManager({
177+ getRegistry: () => atomRegistry,
178+ getClient: () => client,
179+ });
180+
181+ await manager.load(TARGET, client);
182+ await manager.loadNext(TARGET, client);
183+ const beforeRefresh = manager.getSnapshot(TARGET).data;
184+ expect(beforeRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]);
185+
186+ await manager.load(TARGET, client, { preserveLoadedRefs: true });
187+
188+ const afterRefresh = manager.getSnapshot(TARGET).data;
189+ expect(afterRefresh?.refs.map((ref) => ref.name)).toEqual(["main", "feature/a", "feature/b"]);
190+ expect(afterRefresh?.nextCursor).toBeNull();
191+ });
192+
129193 it("stores query-specific state independently", async () => {
130194 const mock = createMockClient();
131195 const manager = createVcsRefManager({
@@ -171,7 +235,24 @@ describe("createVcsRefManager", () => { · 39 unmodified lines
171235 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);
172236 });
173237
174− it("watches branches with a ref-counted client-change subscription", async () => {
238+ it("invalidates every query for a cwd scope", async () => {
239+ const mock = createMockClient();
240+ const manager = createVcsRefManager({
241+ getRegistry: () => atomRegistry,
242+ getClient: () => mock.client,
243+ });
244+ const queriedTarget = { ...TARGET, query: "feature" } as const;
245+
246+ await manager.load(TARGET, mock.client);
247+ await manager.load(queriedTarget, mock.client);
248+
249+ manager.invalidateScope({ environmentId: TARGET.environmentId, cwd: TARGET.cwd });
250+
251+ expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);
252+ expect(manager.getSnapshot(queriedTarget)).toEqual(EMPTY_VCS_REF_STATE);
253+ });
254+
255+ it("watches refs with a ref-counted client-change subscription", async () => {
175256 const mock = createMockClient();
176257 let listener: () => void = noop;
177258 const unsubscribe = vi.fn();
@@ -202,6 +283,29 @@ describe("createVcsRefManager", () => { · 24 unmodified lines
202283 expect(unsubscribe).toHaveBeenCalledTimes(1);
203284 });
204285
286+ it("skips watched refresh while cached refs are fresh", async () => {
287+ const mock = createMockClient();
288+ const manager = createVcsRefManager({
289+ getRegistry: () => atomRegistry,
290+ getClient: () => mock.client,
291+ staleTimeMs: 60_000,
292+ watchLimit: 100,
293+ });
294+
295+ await manager.load(TARGET, mock.client, { limit: 100 });
296+ const unwatch = manager.watch(TARGET);
297+ await Promise.resolve();
298+
299+ expect(mock.listRefs).toHaveBeenCalledTimes(1);
300+ expect(manager.getSnapshot(TARGET)).toEqual({
301+ data: FIRST_PAGE,
302+ isPending: false,
303+ error: null,
304+ });
305+
306+ unwatch();
307+ });
308+
205309 it("swallows watched refresh failures after storing error state", async () => {
206310 const refreshError = new Error("backend unavailable");
207311 const listRefs = vi.fn(async () => {
packages/client-runtime/src/vcsRefState.ts+64−5
@@ -4,6 +4,8 @@ import type { · 3 unmodified lines
44 VcsListRefsResult,
55 VcsRef as ContractVcsRef,
66 } from "@t3tools/contracts";
7+import * as Clock from "effect/Clock";
8+import * as Effect from "effect/Effect";
79 import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
810
911 import type { WsRpcClient } from "./wsRpcClient.ts";
@@ -14,6 +16,11 @@ export interface VcsRefTarget { · 4 unmodified lines
1416 readonly query?: string | null;
1517 }
1618
19+export interface VcsRefScope {
20+ readonly environmentId: EnvironmentId | null;
21+ readonly cwd: string | null;
22+}
23+
1724 export interface VcsRefState {
1825 readonly data: VcsListRefsResult | null;
1926 readonly isPending: boolean;
@@ -82,6 +89,7 @@ export interface VcsRefManagerConfig { · 62 unmodified lines
8289 readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null;
8390 readonly subscribeClientChanges?: (listener: () => void) => () => void;
8491 readonly watchLimit?: number;
92+ readonly staleTimeMs?: number;
8593 readonly onBackgroundError?: (error: unknown) => void;
8694 }
8795
@@ -102,8 +110,11 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 14 unmodified lines
102110 >();
103111 const loadVersions = new Map<string, number>();
104112 const watched = new Map<string, WatchedEntry>();
113+ const lastLoadedAt = new Map<string, number>();
105114 const watchLoadOptions =
106− config.watchLimit === undefined ? undefined : { limit: config.watchLimit };
115+ config.watchLimit === undefined
116+ ? undefined
117+ : { limit: config.watchLimit, preserveLoadedRefs: true };
107118
108119 function getLoadVersion(targetKey: string): number {
109120 return loadVersions.get(targetKey) ?? 0;
@@ -136,6 +147,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 26 unmodified lines
136147 }
137148
138149 function setData(targetKey: string, data: VcsListRefsResult): void {
150+ lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis));
139151 setState(targetKey, {
140152 data,
141153 isPending: false,
@@ -159,6 +171,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 17 unmodified lines
159171 readonly cursor?: number;
160172 readonly limit?: number;
161173 readonly append?: boolean;
174+ readonly preserveLoadedRefs?: boolean;
162175 },
163176 ): Promise<VcsListRefsResult | null> {
164177 const targetKey = getVcsRefTargetKey(target);
@@ -196,7 +209,14 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 31 unmodified lines
196209 ...result,
197210 refs: mergeRefs(current.refs, result.refs),
198211 }
199− : result;
212+ : options?.preserveLoadedRefs && current && current.refs.length > result.refs.length
213+ ? {
214+ ...result,
215+ refs: mergeRefs(result.refs, current.refs),
216+ nextCursor: current.nextCursor,
217+ totalCount: Math.max(result.totalCount, current.totalCount),
218+ }
219+ : result;
200220 if (getLoadVersion(targetKey) === loadVersion) {
201221 setData(targetKey, nextData);
202222 }
@@ -227,6 +247,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 24 unmodified lines
227247 readonly cursor?: number;
228248 readonly limit?: number;
229249 readonly append?: boolean;
250+ readonly preserveLoadedRefs?: boolean;
230251 },
231252 ): void {
232253 void load(target, client, options).catch((error: unknown) => {
@@ -264,9 +285,21 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 31 unmodified lines
264285 }
265286
266287 let teardown: () => void;
288+ const shouldRefresh = () => {
289+ if (config.staleTimeMs === undefined) {
290+ return true;
291+ }
292+ const lastLoaded = lastLoadedAt.get(targetKey);
293+ return (
294+ lastLoaded === undefined ||
295+ Effect.runSync(Clock.currentTimeMillis) - lastLoaded >= config.staleTimeMs
296+ );
297+ };
267298
268299 if (client) {
269− loadInBackground(target, client, watchLoadOptions);
300+ if (shouldRefresh()) {
301+ loadInBackground(target, client, watchLoadOptions);
302+ }
270303 teardown = NOOP;
271304 } else if (config.subscribeClientChanges) {
272305 let currentClient: VcsRefClient | null = null;
@@ -281,7 +314,9 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 8 unmodified lines
281314 }
282315
283316 currentClient = resolved;
284− loadInBackground(target, resolved, watchLoadOptions);
317+ if (shouldRefresh()) {
318+ loadInBackground(target, resolved, watchLoadOptions);
319+ }
285320 };
286321
287322 const unsubscribe = config.subscribeClientChanges(sync);
@@ -292,7 +327,9 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 4 unmodified lines
292327 if (!resolved) {
293328 return NOOP;
294329 }
295− loadInBackground(target, resolved, watchLoadOptions);
330+ if (shouldRefresh()) {
331+ loadInBackground(target, resolved, watchLoadOptions);
332+ }
296333 teardown = NOOP;
297334 }
298335
@@ -329,6 +366,26 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 30 unmodified lines
329366 }
330367 }
331368
369+ function invalidateScope(scope: VcsRefScope): void {
370+ if (scope.environmentId === null || scope.cwd === null) {
371+ return;
372+ }
373+
374+ const keyPrefix = `${scope.environmentId}:${scope.cwd}:`;
375+ for (const key of knownVcsRefKeys) {
376+ if (key.startsWith(keyPrefix)) {
377+ bumpLoadVersion(key);
378+ setState(key, EMPTY_VCS_REF_STATE);
379+ }
380+ }
381+
382+ for (const key of inFlight.keys()) {
383+ if (key.startsWith(keyPrefix)) {
384+ inFlight.delete(key);
385+ }
386+ }
387+ }
388+
332389 function reset(): void {
333390 for (const entry of watched.values()) {
334391 entry.teardown();
@@ -336,6 +393,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 1 unmodified line
336393 watched.clear();
337394 inFlight.clear();
338395 loadVersions.clear();
396+ lastLoadedAt.clear();
339397 invalidate();
340398 }
341399
@@ -345,6 +403,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 3 unmodified lines
345403 load,
346404 loadNext,
347405 invalidate,
406+ invalidateScope,
348407 reset,
349408 };
350409 }