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
16 subscribeEnvironmentConnections,16 subscribeEnvironmentConnections,
17} from "./environment-session-registry";17} from "./environment-session-registry";
18 18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
19export const vcsRefManager = createVcsRefManager({22export const vcsRefManager = createVcsRefManager({
20 getRegistry: () => appAtomRegistry,23 getRegistry: () => appAtomRegistry,
21 getClient: (environmentId) => {24 getClient: (environmentId) => {
@@ -23,7 +26,8 @@ export const vcsRefManager = createVcsRefManager({ · 1 unmodified line
23 return client ? client.vcs : null;26 return client ? client.vcs : null;
24 },27 },
25 subscribeClientChanges: subscribeEnvironmentConnections,28 subscribeClientChanges: subscribeEnvironmentConnections,
26 watchLimit: 100,29 watchLimit: VCS_REF_LIST_LIMIT,
30 staleTimeMs: VCS_REF_STALE_TIME_MS,
27 onBackgroundError: (error) => {31 onBackgroundError: (error) => {
28 console.warn("[vcs-refs] background refresh failed", error);32 console.warn("[vcs-refs] background refresh failed", error);
29 },33 },
@@ -39,7 +43,9 @@ export function useVcsRefs(target: VcsRefTarget): VcsRefState { · 9 unmodified lines
39 [target.cwd, target.environmentId, target.query],43 [target.cwd, target.environmentId, target.query],
40 );44 );
41 const targetKey = getVcsRefTargetKey(stableTarget);45 const targetKey = getVcsRefTargetKey(stableTarget);
46
42 useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);47 useEffect(() => vcsRefManager.watch(stableTarget), [stableTarget]);
48
43 const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);49 const state = useAtomValue(targetKey !== null ? vcsRefStateAtom(targetKey) : EMPTY_VCS_REF_ATOM);
44 return targetKey === null ? EMPTY_VCS_REF_STATE : state;50 return targetKey === null ? EMPTY_VCS_REF_STATE : state;
45}51}
apps/web/src/components/BranchToolbarBranchSelector.tsx+35−35
@@ -1,6 +1,5 @@
1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
3import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
4import { LegendList, type LegendListRef } from "@legendapp/list/react";3import { LegendList, type LegendListRef } from "@legendapp/list/react";
5import { ChevronDownIcon } from "lucide-react";4import { ChevronDownIcon } from "lucide-react";
6import {5import {
@@ -16,8 +15,8 @@ import { · 9 unmodified lines
16 15
17import { useComposerDraftStore, type DraftId } from "../composerDraftStore";16import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
18import { readEnvironmentApi } from "../environmentApi";17import { readEnvironmentApi } from "../environmentApi";
19import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
20import { useGitStatus } from "../lib/gitStatusState";18import { useGitStatus } from "../lib/gitStatusState";
19import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState";
21import { newCommandId } from "../lib/utils";20import { newCommandId } from "../lib/utils";
22import { cn } from "../lib/utils";21import { cn } from "../lib/utils";
23import { parsePullRequestReference } from "../pullRequestReference";22import { parsePullRequestReference } from "../pullRequestReference";
@@ -59,6 +58,8 @@ interface BranchToolbarBranchSelectorProps { · 35 unmodified lines
59 onComposerFocusRequest?: () => void;58 onComposerFocusRequest?: () => void;
60}59}
61 60
61const EMPTY_REFS: ReadonlyArray<VcsRef> = [];
62
62function toBranchActionErrorMessage(error: unknown): string {63function toBranchActionErrorMessage(error: unknown): string {
63 return error instanceof Error ? error.message : "An error occurred.";64 return error instanceof Error ? error.message : "An error occurred.";
64}65}
@@ -196,7 +197,6 @@ export function BranchToolbarBranchSelector({ · 131 unmodified lines
196 // ---------------------------------------------------------------------------197 // ---------------------------------------------------------------------------
197 // Git ref queries198 // Git ref queries
198 // ---------------------------------------------------------------------------199 // ---------------------------------------------------------------------------
199 const queryClient = useQueryClient();
200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
201 const [branchQuery, setBranchQuery] = useState("");201 const [branchQuery, setBranchQuery] = useState("");
202 const deferredBranchQuery = useDeferredValue(branchQuery);202 const deferredBranchQuery = useDeferredValue(branchQuery);
@@ -204,31 +204,20 @@ export function BranchToolbarBranchSelector({ · 1 unmodified line
204 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });204 const branchStatusQuery = useGitStatus({ environmentId, cwd: branchCwd });
205 const trimmedBranchQuery = branchQuery.trim();205 const trimmedBranchQuery = branchQuery.trim();
206 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();206 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
207 207 const branchRefTarget = useMemo(
208 useEffect(() => {208 () => ({
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({
223 environmentId,209 environmentId,
224 cwd: branchCwd,210 cwd: branchCwd,
225 query: deferredTrimmedBranchQuery,211 query: deferredTrimmedBranchQuery,
226 }),212 }),
213 [branchCwd, deferredTrimmedBranchQuery, environmentId],
227 );214 );
228 const refs = useMemo(215 const branchRefState = useVcsRefs(branchRefTarget);
229 () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [],216 const refs = branchRefState.data?.refs ?? EMPTY_REFS;
230 [branchesSearchData?.pages],217 const hasNextPage =
231 );218 branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined;
219 const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
220 const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null;
232 const currentGitBranch =221 const currentGitBranch =
233 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;222 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
234 const sourceControlPresentation = useMemo(223 const sourceControlPresentation = useMemo(
@@ -293,8 +282,8 @@ export function BranchToolbarBranchSelector({ · 58 unmodified lines
293 );282 );
294 const [isBranchActionPending, startBranchActionTransition] = useTransition();283 const [isBranchActionPending, startBranchActionTransition] = useTransition();
295 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;284 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
296 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;285 const totalBranchCount = branchRefState.data?.totalCount ?? 0;
297 const branchStatusText = isBranchesSearchPending286 const branchStatusText = isInitialBranchesLoadPending
298 ? "Loading refs..."287 ? "Loading refs..."
299 : isFetchingNextPage288 : isFetchingNextPage
300 ? "Loading more refs..."289 ? "Loading more refs..."
@@ -308,8 +297,8 @@ export function BranchToolbarBranchSelector({ · 7 unmodified lines
308 const runBranchAction = (action: () => Promise<void>) => {297 const runBranchAction = (action: () => Promise<void>) => {
309 startBranchActionTransition(async () => {298 startBranchActionTransition(async () => {
310 await action().catch(() => undefined);299 await action().catch(() => undefined);
311 await queryClient300 await vcsRefManager
312 .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) })301 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
313 .catch(() => undefined);302 .catch(() => undefined);
314 });303 });
315 };304 };
@@ -425,14 +414,25 @@ export function BranchToolbarBranchSelector({ · 109 unmodified lines
425 setBranchQuery("");414 setBranchQuery("");
426 return;415 return;
427 }416 }
428 void queryClient.invalidateQueries({417 void vcsRefManager
429 queryKey: gitQueryKeys.refs(environmentId, branchCwd),418 .load(branchRefTarget, undefined, { limit: 100, preserveLoadedRefs: true })
430 });419 .catch(() => undefined);
431 },420 },
432 [branchCwd, environmentId, queryClient],421 [branchRefTarget],
433 );422 );
434 423
435 const branchListScrollElementRef = useRef<HTMLDivElement | null>(null);424 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]);
436 const maybeFetchNextBranchPage = useCallback(() => {436 const maybeFetchNextBranchPage = useCallback(() => {
437 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {437 if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) {
438 return;438 return;
@@ -449,8 +449,8 @@ export function BranchToolbarBranchSelector({ · 10 unmodified lines
449 return;449 return;
450 }450 }
451 451
452 void fetchNextPage().catch(() => undefined);452 fetchNextBranchPage();
453 }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);453 }, [fetchNextBranchPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]);
454 const branchListRef = useRef<LegendListRef | null>(null);454 const branchListRef = useRef<LegendListRef | null>(null);
455 const setBranchListRef = useCallback((element: HTMLDivElement | null) => {455 const setBranchListRef = useCallback((element: HTMLDivElement | null) => {
456 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;456 branchListScrollElementRef.current = (element?.parentElement as HTMLDivElement | null) ?? null;
@@ -592,7 +592,7 @@ export function BranchToolbarBranchSelector({ · 135 unmodified lines
592 <ComboboxTrigger592 <ComboboxTrigger
593 render={<Button variant="ghost" size="xs" />}593 render={<Button variant="ghost" size="xs" />}
594 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}594 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}
596 >596 >
597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
598 <ChevronDownIcon className="shrink-0" />598 <ChevronDownIcon className="shrink-0" />
@@ -622,7 +622,7 @@ export function BranchToolbarBranchSelector({ · 23 unmodified lines
622 drawDistance={336}622 drawDistance={336}
623 onEndReached={() => {623 onEndReached={() => {
624 if (hasNextPage && !isFetchingNextPage) {624 if (hasNextPage && !isFetchingNextPage) {
625 void fetchNextPage().catch(() => undefined);625 fetchNextBranchPage();
626 }626 }
627 }}627 }}
628 style={{ maxHeight: "14rem" }}628 style={{ maxHeight: "14rem" }}
apps/web/src/components/GitActionsControl.browser.tsx+31−48
@@ -26,9 +26,9 @@ const { · 25 unmodified lines
26 activeRunStackedActionDeferredRef,26 activeRunStackedActionDeferredRef,
27 activeDraftThreadRef,27 activeDraftThreadRef,
28 hasServerThreadRef,28 hasServerThreadRef,
29 invalidateGitQueriesSpy,29 invalidateSourceControlStateSpy,
30 refreshGitStatusSpy,30 refreshGitStatusSpy,
31 runStackedActionMutateAsyncSpy,31 runStackedActionSpy,
32 setDraftThreadContextSpy,32 setDraftThreadContextSpy,
33 setThreadBranchSpy,33 setThreadBranchSpy,
34 toastAddSpy,34 toastAddSpy,
@@ -39,9 +39,9 @@ const { · 4 unmodified lines
39 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },39 activeRunStackedActionDeferredRef: { current: createDeferredPromise<never>() },
40 activeDraftThreadRef: { current: null as unknown },40 activeDraftThreadRef: { current: null as unknown },
41 hasServerThreadRef: { current: true },41 hasServerThreadRef: { current: true },
42 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),42 invalidateSourceControlStateSpy: vi.fn(() => Promise.resolve()),
43 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),43 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
44 runStackedActionMutateAsyncSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),44 runStackedActionSpy: vi.fn(() => activeRunStackedActionDeferredRef.current.promise),
45 setDraftThreadContextSpy: vi.fn(),45 setDraftThreadContextSpy: vi.fn(),
46 setThreadBranchSpy: vi.fn(),46 setThreadBranchSpy: vi.fn(),
47 toastAddSpy: vi.fn(() => "toast-1"),47 toastAddSpy: vi.fn(() => "toast-1"),
@@ -50,39 +50,6 @@ const { · 2 unmodified lines
50 toastUpdateSpy: vi.fn(),50 toastUpdateSpy: vi.fn(),
51}));51}));
52 52
53vi.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
86vi.mock("~/components/ui/toast", () => ({53vi.mock("~/components/ui/toast", () => ({
87 toastManager: {54 toastManager: {
88 add: toastAddSpy,55 add: toastAddSpy,
@@ -97,17 +64,33 @@ vi.mock("~/editorPreferences", () => ({ · 8 unmodified lines
97 openInPreferredEditor: vi.fn(),64 openInPreferredEditor: vi.fn(),
98}));65}));
99 66
100vi.mock("~/lib/gitReactQuery", () => ({67vi.mock("~/lib/sourceControlActions", () => ({
101 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),68 invalidateSourceControlState: invalidateSourceControlStateSpy,
102 gitMutationKeys: {69 useGitStackedAction: vi.fn(() => ({
103 publishRepository: vi.fn(() => ["publish-repository"]),70 error: null,
104 pull: vi.fn(() => ["pull"]),71 isPending: false,
105 runStackedAction: vi.fn(() => ["run-stacked-action"]),72 resetError: vi.fn(),
106 },73 run: runStackedActionSpy,
107 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),74 })),
108 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),75 useSourceControlActionRunning: vi.fn(() => false),
109 invalidateGitQueries: invalidateGitQueriesSpy,76 useSourceControlPublishRepositoryAction: vi.fn(() => ({
110 sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })),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 })),
111}));94}));
112 95
113vi.mock("~/lib/gitStatusState", () => ({96vi.mock("~/lib/gitStatusState", () => ({
apps/web/src/components/GitActionsControl.tsx+60−74
@@ -10,7 +10,6 @@ import type { · 9 unmodified lines
10 SourceControlRepositoryVisibility,10 SourceControlRepositoryVisibility,
11 VcsStatusResult,11 VcsStatusResult,
12} from "@t3tools/contracts";12} from "@t3tools/contracts";
13import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
14import { useNavigate } from "@tanstack/react-router";13import { useNavigate } from "@tanstack/react-router";
15import * as Option from "effect/Option";14import * as Option from "effect/Option";
16import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";15import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
@@ -66,12 +65,12 @@ import { stackedThreadToast, toastManager, type ThreadToastData } from "~/compon · 49 unmodified lines
66import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";65import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
67import { openInPreferredEditor } from "~/editorPreferences";66import { openInPreferredEditor } from "~/editorPreferences";
68import {67import {
69 gitInitMutationOptions,68 useGitStackedAction,
70 gitMutationKeys,69 useSourceControlActionRunning,
71 gitPullMutationOptions,70 useSourceControlPublishRepositoryAction,
72 gitRunStackedActionMutationOptions,71 useVcsInitAction,
73 sourceControlPublishRepositoryMutationOptions,72 useVcsPullAction,
74} from "~/lib/gitReactQuery";73} from "~/lib/sourceControlActions";
75import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";74import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
76import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";75import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState";
77import { newCommandId, randomUUID } from "~/lib/utils";76import { newCommandId, randomUUID } from "~/lib/utils";
@@ -129,6 +128,7 @@ interface RunGitActionWithToastInput { · 51 unmodified lines
129}128}
130 129
131const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;130const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
131const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
132 132
133const PUBLISH_PROVIDER_OPTIONS = [133const PUBLISH_PROVIDER_OPTIONS = [
134 {134 {
@@ -278,7 +278,6 @@ interface PublishRepositoryDialogProps { · 143 unmodified lines
278}278}
279 279
280function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {280function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
281 const queryClient = useQueryClient();
282 const navigate = useNavigate();281 const navigate = useNavigate();
283 const sourceControlDiscovery = useSourceControlDiscovery();282 const sourceControlDiscovery = useSourceControlDiscovery();
284 const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");283 const [publishProvider, setPublishProvider] = useState<PublishProviderKind>("github");
@@ -294,13 +293,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 9 unmodified lines
294 null,293 null,
295 );294 );
296 const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);295 const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false);
297 const publishRepositoryMutation = useMutation(296 const sourceControlScope = useMemo(
298 sourceControlPublishRepositoryMutationOptions({297 () => ({
299 environmentId: props.environmentId,298 environmentId: props.environmentId,
300 cwd: props.gitCwd,299 cwd: props.gitCwd,
301 queryClient,
302 }),300 }),
301 [props.environmentId, props.gitCwd],
303 );302 );
303 const publishRepositoryAction = useSourceControlPublishRepositoryAction(sourceControlScope);
304 const publishAccountByProvider = useMemo(() => {304 const publishAccountByProvider = useMemo(() => {
305 const accounts: Record<PublishProviderKind, string | null> = {305 const accounts: Record<PublishProviderKind, string | null> = {
306 github: null,306 github: null,
@@ -367,13 +367,13 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 60 unmodified lines
367 367
368 const canSubmitPublishRepository = useMemo(() => {368 const canSubmitPublishRepository = useMemo(() => {
369 if (!selectedPublishProviderReadiness.ready) return false;369 if (!selectedPublishProviderReadiness.ready) return false;
370 if (publishRepositoryMutation.isPending) return false;370 if (publishRepositoryAction.isPending) return false;
371 const repositoryParts = publishRepository.trim().split("/");371 const repositoryParts = publishRepository.trim().split("/");
372 const owner = repositoryParts[0]?.trim() ?? "";372 const owner = repositoryParts[0]?.trim() ?? "";
373 const rest = repositoryParts.slice(1);373 const rest = repositoryParts.slice(1);
374 const name = rest.join("/").trim();374 const name = rest.join("/").trim();
375 return owner.length > 0 && name.length > 0;375 return owner.length > 0 && name.length > 0;
376 }, [publishRepository, publishRepositoryMutation.isPending, selectedPublishProviderReadiness]);376 }, [publishRepository, publishRepositoryAction.isPending, selectedPublishProviderReadiness]);
377 377
378 useEffect(() => {378 useEffect(() => {
379 if (!props.open) {379 if (!props.open) {
@@ -397,8 +397,8 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 17 unmodified lines
397 397
398 setPublishError(null);398 setPublishError(null);
399 399
400 void publishRepositoryMutation400 void publishRepositoryAction
401 .mutateAsync({401 .run({
402 provider: publishProvider,402 provider: publishProvider,
403 repository: publishRepository.trim(),403 repository: publishRepository.trim(),
404 visibility: publishVisibility,404 visibility: publishVisibility,
@@ -425,7 +425,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 20 unmodified lines
425 publishProvider,425 publishProvider,
426 publishRemoteName,426 publishRemoteName,
427 publishRepository,427 publishRepository,
428 publishRepositoryMutation,428 publishRepositoryAction,
429 publishVisibility,429 publishVisibility,
430 ]);430 ]);
431 431
@@ -621,7 +621,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 189 unmodified lines
621 }621 }
622 }}622 }}
623 placeholder={publishPathPlaceholder}623 placeholder={publishPathPlaceholder}
624 disabled={publishRepositoryMutation.isPending}624 disabled={publishRepositoryAction.isPending}
625 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"625 className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none"
626 />626 />
627 </div>627 </div>
@@ -640,7 +640,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 12 unmodified lines
640 setPublishVisibility(value as SourceControlRepositoryVisibility)640 setPublishVisibility(value as SourceControlRepositoryVisibility)
641 }641 }
642 aria-labelledby="publish-visibility-cards-label"642 aria-labelledby="publish-visibility-cards-label"
643 disabled={publishRepositoryMutation.isPending}643 disabled={publishRepositoryAction.isPending}
644 className="grid grid-cols-2 gap-2.5"644 className="grid grid-cols-2 gap-2.5"
645 >645 >
646 {[646 {[
@@ -712,7 +712,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 65 unmodified lines
712 value={publishRemoteName}712 value={publishRemoteName}
713 onChange={(event) => setPublishRemoteName(event.target.value)}713 onChange={(event) => setPublishRemoteName(event.target.value)}
714 placeholder="origin"714 placeholder="origin"
715 disabled={publishRepositoryMutation.isPending}715 disabled={publishRepositoryAction.isPending}
716 />716 />
717 </label>717 </label>
718 <div className="space-y-1.5">718 <div className="space-y-1.5">
@@ -728,7 +728,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 9 unmodified lines
728 setPublishProtocol(value as SourceControlCloneProtocol)728 setPublishProtocol(value as SourceControlCloneProtocol)
729 }729 }
730 aria-labelledby="publish-protocol-label"730 aria-labelledby="publish-protocol-label"
731 disabled={publishRepositoryMutation.isPending}731 disabled={publishRepositoryAction.isPending}
732 className="grid grid-cols-2 gap-2"732 className="grid grid-cols-2 gap-2"
733 >733 >
734 {(["ssh", "https"] as const).map((value) => {734 {(["ssh", "https"] as const).map((value) => {
@@ -755,7 +755,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 20 unmodified lines
755 ) : null}755 ) : null}
756 </div>756 </div>
757 757
758 {publishRepositoryMutation.isPending ? (758 {publishRepositoryAction.isPending ? (
759 <div759 <div
760 role="status"760 role="status"
761 aria-live="polite"761 aria-live="polite"
@@ -765,7 +765,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 3 unmodified lines
765 Publishing repository to {publishProviderLabel}...765 Publishing repository to {publishProviderLabel}...
766 </div>766 </div>
767 ) : null}767 ) : null}
768 {publishError && !publishRepositoryMutation.isPending ? (768 {publishError && !publishRepositoryAction.isPending ? (
769 <div769 <div
770 role="alert"770 role="alert"
771 className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"771 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
833 <Button833 <Button
834 variant="outline"834 variant="outline"
835 size="sm"835 size="sm"
836 disabled={publishRepositoryMutation.isPending}836 disabled={publishRepositoryAction.isPending}
837 onClick={() => {837 onClick={() => {
838 if (publishWizardStep === 0) {838 if (publishWizardStep === 0) {
839 handleOpenChange(false);839 handleOpenChange(false);
@@ -858,7 +858,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { · 18 unmodified lines
858 disabled={!canSubmitPublishRepository}858 disabled={!canSubmitPublishRepository}
859 onClick={submitPublishRepository}859 onClick={submitPublishRepository}
860 >860 >
861 {publishRepositoryMutation.isPending ? (861 {publishRepositoryAction.isPending ? (
862 <>862 <>
863 <Spinner className="size-3.5" aria-hidden />863 <Spinner className="size-3.5" aria-hidden />
864 Publishing...864 Publishing...
@@ -901,7 +901,6 @@ export default function GitActionsControl({ · 36 unmodified lines
901 );901 );
902 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);902 const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext);
903 const setThreadBranch = useStore((store) => store.setThreadBranch);903 const setThreadBranch = useStore((store) => store.setThreadBranch);
904 const queryClient = useQueryClient();
905 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);904 const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false);
906 const [dialogCommitMessage, setDialogCommitMessage] = useState("");905 const [dialogCommitMessage, setDialogCommitMessage] = useState("");
907 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());906 const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set());
@@ -910,6 +909,10 @@ export default function GitActionsControl({ · 2 unmodified lines
910 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =909 const [pendingDefaultBranchAction, setPendingDefaultBranchAction] =
911 useState<PendingDefaultBranchAction | null>(null);910 useState<PendingDefaultBranchAction | null>(null);
912 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);911 const activeGitActionProgressRef = useRef<ActiveGitActionProgress | null>(null);
912 const sourceControlScope = useMemo(
913 () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }),
914 [activeEnvironmentId, gitCwd],
915 );
913 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;916 let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise<void>;
914 917
915 const updateActiveProgressToast = useCallback(() => {918 const updateActiveProgressToast = useCallback(() => {
@@ -1006,32 +1009,13 @@ export default function GitActionsControl({ · 90 unmodified lines
1006 const allSelected = excludedFiles.size === 0;1009 const allSelected = excludedFiles.size === 0;
1007 const noneSelected = selectedFiles.length === 0;1010 const noneSelected = selectedFiles.length === 0;
1008 1011
1009 const initMutation = useMutation(1012 const initAction = useVcsInitAction(sourceControlScope);
1010 gitInitMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }),1013 const runImmediateGitAction = useGitStackedAction(sourceControlScope);
1011 );1014 const pullAction = useVcsPullAction(sourceControlScope);
1012 1015 const isGitActionRunning = useSourceControlActionRunning(
1013 const runImmediateGitActionMutation = useMutation(1016 sourceControlScope,
1014 gitRunStackedActionMutationOptions({1017 RUNNING_SOURCE_CONTROL_ACTIONS,
1015 environmentId: activeEnvironmentId,
1016 cwd: gitCwd,
1017 queryClient,
1018 }),
1019 );1018 );
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;
1035 const isSelectingWorktreeBase =1019 const isSelectingWorktreeBase =
1036 !activeServerThread &&1020 !activeServerThread &&
1037 activeDraftThread?.envMode === "worktree" &&1021 activeDraftThread?.envMode === "worktree" &&
@@ -1308,7 +1292,7 @@ export default function GitActionsControl({ · 270 unmodified lines
1308 updateActiveProgressToast();1292 updateActiveProgressToast();
1309 };1293 };
1310 1294
1311 const promise = runImmediateGitActionMutation.mutateAsync({1295 const promise = runImmediateGitAction.run({
1312 actionId,1296 actionId,
1313 action,1297 action,
1314 ...(commitMessage ? { commitMessage } : {}),1298 ...(commitMessage ? { commitMessage } : {}),
@@ -1449,26 +1433,26 @@ export default function GitActionsControl({ · 134 unmodified lines
1449 return;1433 return;
1450 }1434 }
1451 if (quickAction.kind === "run_pull") {1435 if (quickAction.kind === "run_pull") {
1452 const promise = pullMutation.mutateAsync();1436 const promise = pullAction.run();
1453 void toastManager.promise<1437 void toastManager.promise<Awaited<ReturnType<typeof pullAction.run>>, ThreadToastData>(
1454 Awaited<ReturnType<typeof pullMutation.mutateAsync>>,1438 promise,
1455 ThreadToastData1439 {
1456 >(promise, {1440 loading: { title: "Pulling...", data: threadToastData },
1457 loading: { title: "Pulling...", data: threadToastData },1441 success: (result) => ({
1458 success: (result) => ({1442 title: result.status === "pulled" ? "Pulled" : "Already up to date",
1459 title: result.status === "pulled" ? "Pulled" : "Already up to date",1443 description:
1460 description:1444 result.status === "pulled"
1461 result.status === "pulled"1445 ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`
1462 ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`1446 : `${result.refName} is already synchronized.`,
1463 : `${result.refName} is already synchronized.`,1447 data: threadToastData,
1464 data: threadToastData,1448 }),
1465 }),1449 error: (err) => ({
1466 error: (err) => ({1450 title: "Pull failed",
1467 title: "Pull failed",1451 description: err instanceof Error ? err.message : "An error occurred.",
1468 description: err instanceof Error ? err.message : "An error occurred.",1452 data: threadToastData,
1469 data: threadToastData,1453 }),
1470 }),1454 },
1471 });1455 );
1472 void promise.catch(() => undefined);1456 void promise.catch(() => undefined);
1473 return;1457 return;
1474 }1458 }
@@ -1555,10 +1539,12 @@ export default function GitActionsControl({ · 80 unmodified lines
1555 <Button1539 <Button
1556 variant="outline"1540 variant="outline"
1557 size="xs"1541 size="xs"
1558 disabled={initMutation.isPending}1542 disabled={initAction.isPending}
1559 onClick={() => initMutation.mutate()}1543 onClick={() => {
1544 void initAction.run();
1545 }}
1560 >1546 >
1561 {initMutation.isPending ? "Initializing..." : "Initialize Git"}1547 {initAction.isPending ? "Initializing..." : "Initialize Git"}
1562 </Button>1548 </Button>
1563 ) : (1549 ) : (
1564 <Group aria-label="Git actions" className="shrink-0">1550 <Group aria-label="Git actions" className="shrink-0">
apps/web/src/components/PullRequestThreadDialog.tsx+36−40
@@ -1,12 +1,12 @@
1import type { EnvironmentId, GitResolvePullRequestResult, ThreadId } from "@t3tools/contracts";1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useDebouncedValue } from "@tanstack/react-pacer";2import { useDebouncedValue } from "@tanstack/react-pacer";
4import { useCallback, useEffect, useMemo, useRef, useState } from "react";3import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5 4
6import {5import {
7 gitPreparePullRequestThreadMutationOptions,6 readCachedPullRequestResolution,
8 gitResolvePullRequestQueryOptions,7 usePreparePullRequestThreadAction,
9} from "~/lib/gitReactQuery";8 usePullRequestResolution,
9} from "~/lib/sourceControlActions";
10import { useGitStatus } from "~/lib/gitStatusState";10import { useGitStatus } from "~/lib/gitStatusState";
11import { cn } from "~/lib/utils";11import { cn } from "~/lib/utils";
12import { parsePullRequestReference } from "~/pullRequestReference";12import { parsePullRequestReference } from "~/pullRequestReference";
@@ -43,7 +43,6 @@ export function PullRequestThreadDialog({ · 30 unmodified lines
43 onOpenChange,43 onOpenChange,
44 onPrepared,44 onPrepared,
45}: PullRequestThreadDialogProps) {45}: PullRequestThreadDialogProps) {
46 const queryClient = useQueryClient();
47 const referenceInputRef = useRef<HTMLInputElement>(null);46 const referenceInputRef = useRef<HTMLInputElement>(null);
48 const [reference, setReference] = useState(initialReference ?? "");47 const [reference, setReference] = useState(initialReference ?? "");
49 const [referenceDirty, setReferenceDirty] = useState(false);48 const [referenceDirty, setReferenceDirty] = useState(false);
@@ -81,33 +80,30 @@ export function PullRequestThreadDialog({ · 31 unmodified lines
81 80
82 const parsedReference = parsePullRequestReference(reference);81 const parsedReference = parsePullRequestReference(reference);
83 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);82 const parsedDebouncedReference = parsePullRequestReference(debouncedReference);
84 const resolvePullRequestQuery = useQuery(83 const sourceControlScope = useMemo(
85 gitResolvePullRequestQueryOptions({84 () => ({
86 environmentId,85 environmentId,
87 cwd,86 cwd,
88 reference: open ? parsedDebouncedReference : null,
89 }),87 }),
88 [cwd, environmentId],
90 );89 );
90 const pullRequestResolution = usePullRequestResolution({
91 ...sourceControlScope,
92 reference: open ? parsedDebouncedReference : null,
93 });
91 const cachedPullRequest = useMemo(() => {94 const cachedPullRequest = useMemo(() => {
92 if (!cwd || !parsedReference) {95 return (
93 return null;96 readCachedPullRequestResolution({
94 }97 ...sourceControlScope,
95 const cached = queryClient.getQueryData<GitResolvePullRequestResult>([98 reference: parsedReference,
96 "git",99 })?.pullRequest ?? null
97 "pull-request",100 );
98 environmentId,101 }, [parsedReference, sourceControlScope]);
99 cwd,102 const preparePullRequestThreadAction = usePreparePullRequestThreadAction(sourceControlScope);
100 parsedReference,
101 ]);
102 return cached?.pullRequest ?? null;
103 }, [cwd, environmentId, parsedReference, queryClient]);
104 const preparePullRequestThreadMutation = useMutation(
105 gitPreparePullRequestThreadMutationOptions({ environmentId, cwd, queryClient }),
106 );
107 103
108 const liveResolvedPullRequest =104 const liveResolvedPullRequest =
109 parsedReference !== null && parsedReference === parsedDebouncedReference105 parsedReference !== null && parsedReference === parsedDebouncedReference
110 ? (resolvePullRequestQuery.data?.pullRequest ?? null)106 ? (pullRequestResolution.data?.pullRequest ?? null)
111 : null;107 : null;
112 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;108 const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest;
113 const isResolving =109 const isResolving =
@@ -116,8 +112,8 @@ export function PullRequestThreadDialog({ · 2 unmodified lines
116 resolvedPullRequest === null &&112 resolvedPullRequest === null &&
117 (referenceDebouncer.state.isPending ||113 (referenceDebouncer.state.isPending ||
118 parsedReference !== parsedDebouncedReference ||114 parsedReference !== parsedDebouncedReference ||
119 resolvePullRequestQuery.isPending ||115 pullRequestResolution.isPending ||
120 resolvePullRequestQuery.isFetching);116 pullRequestResolution.isFetching);
121 const statusTone = useMemo(() => {117 const statusTone = useMemo(() => {
122 switch (resolvedPullRequest?.state) {118 switch (resolvedPullRequest?.state) {
123 case "merged":119 case "merged":
@@ -142,7 +138,7 @@ export function PullRequestThreadDialog({ · 18 unmodified lines
142 }138 }
143 setPreparingMode(mode);139 setPreparingMode(mode);
144 try {140 try {
145 const result = await preparePullRequestThreadMutation.mutateAsync({141 const result = await preparePullRequestThreadAction.run({
146 reference: parsedReference,142 reference: parsedReference,
147 mode,143 mode,
148 ...(mode === "worktree" ? { threadId } : {}),144 ...(mode === "worktree" ? { threadId } : {}),
@@ -161,7 +157,7 @@ export function PullRequestThreadDialog({ · 12 unmodified lines
161 onOpenChange,157 onOpenChange,
162 onPrepared,158 onPrepared,
163 parsedReference,159 parsedReference,
164 preparePullRequestThreadMutation,160 preparePullRequestThreadAction,
165 resolvedPullRequest,161 resolvedPullRequest,
166 threadId,162 threadId,
167 ],163 ],
@@ -176,13 +172,13 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
176 : null;172 : null;
177 const errorMessage =173 const errorMessage =
178 validationMessage ??174 validationMessage ??
179 (resolvedPullRequest === null && resolvePullRequestQuery.isError175 (resolvedPullRequest === null && pullRequestResolution.error
180 ? resolvePullRequestQuery.error instanceof Error176 ? pullRequestResolution.error instanceof Error
181 ? resolvePullRequestQuery.error.message177 ? pullRequestResolution.error.message
182 : `Failed to resolve ${terminology.singular}.`178 : `Failed to resolve ${terminology.singular}.`
183 : preparePullRequestThreadMutation.error instanceof Error179 : preparePullRequestThreadAction.error instanceof Error
184 ? preparePullRequestThreadMutation.error.message180 ? preparePullRequestThreadAction.error.message
185 : preparePullRequestThreadMutation.error181 : preparePullRequestThreadAction.error
186 ? `Failed to prepare ${terminology.singular} thread.`182 ? `Failed to prepare ${terminology.singular} thread.`
187 : null);183 : null);
188 184
@@ -190,7 +186,7 @@ export function PullRequestThreadDialog({ · 1 unmodified line
190 <Dialog186 <Dialog
191 open={open}187 open={open}
192 onOpenChange={(nextOpen) => {188 onOpenChange={(nextOpen) => {
193 if (!preparePullRequestThreadMutation.isPending) {189 if (!preparePullRequestThreadAction.isPending) {
194 onOpenChange(nextOpen);190 onOpenChange(nextOpen);
195 }191 }
196 }}192 }}
@@ -224,7 +220,7 @@ export function PullRequestThreadDialog({ · 27 unmodified lines
224 return;220 return;
225 }221 }
226 event.preventDefault();222 event.preventDefault();
227 if (!isResolving && !preparePullRequestThreadMutation.isPending) {223 if (!isResolving && !preparePullRequestThreadAction.isPending) {
228 void handleConfirm("local");224 void handleConfirm("local");
229 }225 }
230 }}226 }}
@@ -263,7 +259,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines
263 variant="outline"259 variant="outline"
264 size="sm"260 size="sm"
265 onClick={() => onOpenChange(false)}261 onClick={() => onOpenChange(false)}
266 disabled={preparePullRequestThreadMutation.isPending}262 disabled={preparePullRequestThreadAction.isPending}
267 >263 >
268 Cancel264 Cancel
269 </Button>265 </Button>
@@ -278,7 +274,7 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
278 !cwd ||274 !cwd ||
279 !resolvedPullRequest ||275 !resolvedPullRequest ||
280 isResolving ||276 isResolving ||
281 preparePullRequestThreadMutation.isPending277 preparePullRequestThreadAction.isPending
282 }278 }
283 >279 >
284 {preparingMode === "local" ? "Preparing local..." : "Local"}280 {preparingMode === "local" ? "Preparing local..." : "Local"}
@@ -293,7 +289,7 @@ export function PullRequestThreadDialog({ · 8 unmodified lines
293 !cwd ||289 !cwd ||
294 !resolvedPullRequest ||290 !resolvedPullRequest ||
295 isResolving ||291 isResolving ||
296 preparePullRequestThreadMutation.isPending292 preparePullRequestThreadAction.isPending
297 }293 }
298 >294 >
299 {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"}295 {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
108 })),108 })),
109}));109}));
110 110
111vi.mock("~/lib/projectReactQuery", () => ({
112 projectQueryKeys: {
113 all: ["projects"],
114 },
115}));
116
117vi.mock("~/lib/providerReactQuery", () => ({111vi.mock("~/lib/providerReactQuery", () => ({
118 providerQueryKeys: {112 providerQueryKeys: {
119 all: ["providers"],113 all: ["providers"],
apps/web/src/environments/runtime/service.ts+0−2
@@ -33,7 +33,6 @@ import { · 32 unmodified lines
33import { ensureLocalApi } from "~/localApi";33import { ensureLocalApi } from "~/localApi";
34import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";34import { collectActiveTerminalUiThreadKeys } from "~/lib/terminalUiStateCleanup";
35import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";35import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects";
36import { projectQueryKeys } from "~/lib/projectReactQuery";
37import { providerQueryKeys } from "~/lib/providerReactQuery";36import { providerQueryKeys } from "~/lib/providerReactQuery";
38import { getPrimaryKnownEnvironment } from "../primary";37import { getPrimaryKnownEnvironment } from "../primary";
39import {38import {
@@ -1770,7 +1769,6 @@ export function startEnvironmentConnectionService(queryClient: QueryClient): () · 1730 unmodified lines
1770 }1769 }
1771 needsProviderInvalidation = false;1770 needsProviderInvalidation = false;
1772 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });1771 void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all });
1773 void queryClient.invalidateQueries({ queryKey: projectQueryKeys.all });
1774 },1772 },
1775 {1773 {
1776 wait: 100,1774 wait: 100,
apps/web/src/hooks/useThreadActions.ts+2−5
@@ -1,6 +1,5 @@
1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";1import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";2import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
3import { useQueryClient } from "@tanstack/react-query";
4import { useRouter } from "@tanstack/react-router";3import { useRouter } from "@tanstack/react-router";
5import { useCallback, useRef } from "react";4import { useCallback, useRef } from "react";
6 5
@@ -8,7 +7,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; · 1 unmodified line
8import { useComposerDraftStore } from "../composerDraftStore";7import { useComposerDraftStore } from "../composerDraftStore";
9import { useNewThreadHandler } from "./useHandleNewThread";8import { useNewThreadHandler } from "./useHandleNewThread";
10import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";9import { ensureEnvironmentApi, readEnvironmentApi } from "../environmentApi";
11import { invalidateGitQueries } from "../lib/gitReactQuery";10import { invalidateSourceControlState } from "../lib/sourceControlActions";
12import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";11import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState";
13import { newCommandId } from "../lib/utils";12import { newCommandId } from "../lib/utils";
14import { readLocalApi } from "../localApi";13import { readLocalApi } from "../localApi";
@@ -40,7 +39,6 @@ export function useThreadActions() { · 25 unmodified lines
40 // sidebar row via archiveThread → attemptArchiveThread.39 // sidebar row via archiveThread → attemptArchiveThread.
41 const handleNewThreadRef = useRef(handleNewThread);40 const handleNewThreadRef = useRef(handleNewThread);
42 handleNewThreadRef.current = handleNewThread;41 handleNewThreadRef.current = handleNewThread;
43 const queryClient = useQueryClient();
44 42
45 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {43 const resolveThreadTarget = useCallback((target: ScopedThreadRef) => {
46 const state = useStore.getState();44 const state = useStore.getState();
@@ -229,7 +227,7 @@ export function useThreadActions() { · 182 unmodified lines
229 path: orphanedWorktreePath,227 path: orphanedWorktreePath,
230 force: true,228 force: true,
231 });229 });
232 await invalidateGitQueries(queryClient, {230 await invalidateSourceControlState({
233 environmentId: threadRef.environmentId,231 environmentId: threadRef.environmentId,
234 });232 });
235 } catch (error) {233 } catch (error) {
@@ -255,7 +253,6 @@ export function useThreadActions() { · 19 unmodified lines
255 clearTerminalUiState,253 clearTerminalUiState,
256 getCurrentRouteThreadRef,254 getCurrentRouteThreadRef,
257 router,255 router,
258 queryClient,
259 resolveThreadTarget,256 resolveThreadTarget,
260 sidebarThreadSortOrder,257 sidebarThreadSortOrder,
261 ],258 ],
apps/web/src/lib/gitReactQuery.test.ts+0−135
@@ -1,135 +0,0 @@
1import { QueryClient } from "@tanstack/react-query";
2import { describe, expect, it, vi } from "vitest";
3
4vi.mock("../environmentApi", () => ({
5 ensureEnvironmentApi: vi.fn(),
6}));
7
8vi.mock("../wsRpcClient", () => ({
9 getWsRpcClient: vi.fn(),
10 getWsRpcClientForEnvironment: vi.fn(),
11}));
12
13import type { InfiniteData } from "@tanstack/react-query";
14import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
15
16import {
17 gitBranchSearchInfiniteQueryOptions,
18 gitMutationKeys,
19 gitPreparePullRequestThreadMutationOptions,
20 gitPullMutationOptions,
21 gitRunStackedActionMutationOptions,
22 invalidateGitQueries,
23} from "./gitReactQuery";
24
25const BRANCH_QUERY_RESULT: VcsListRefsResult = {
26 refs: [],
27 isRepo: true,
28 hasPrimaryRemote: true,
29 nextCursor: null,
30 totalCount: 0,
31};
32
33const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = {
34 pages: [BRANCH_QUERY_RESULT],
35 pageParams: [0],
36};
37const ENVIRONMENT_A = EnvironmentId.make("environment-a");
38const ENVIRONMENT_B = EnvironmentId.make("environment-b");
39
40describe("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
60describe("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
93describe("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 @@
1import {
2 type EnvironmentId,
3 type GitActionProgressEvent,
4 type GitStackedAction,
5 type SourceControlPublishRepositoryInput,
6 type ThreadId,
7} from "@t3tools/contracts";
8import {
9 infiniteQueryOptions,
10 mutationOptions,
11 queryOptions,
12 type QueryClient,
13} from "@tanstack/react-query";
14import { ensureEnvironmentApi } from "../environmentApi";
15import { requireEnvironmentConnection } from "../environments/runtime";
16
17const GIT_BRANCHES_STALE_TIME_MS = 15_000;
18const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
19const GIT_BRANCHES_PAGE_SIZE = 100;
20
21export 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
29export 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
44export 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
57function 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 */
72export 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
103export 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 */
133export 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 */
154export 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
172export 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 */
216export 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
234export 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 */
257export 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 */
280export 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
300export 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 @@
1import type { EnvironmentId, ProjectSearchEntriesResult } from "@t3tools/contracts";
2import { queryOptions } from "@tanstack/react-query";
3import { ensureEnvironmentApi } from "~/environmentApi";
4
5export 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
15const DEFAULT_SEARCH_ENTRIES_LIMIT = 80;
16const DEFAULT_SEARCH_ENTRIES_STALE_TIME = 15_000;
17const EMPTY_SEARCH_ENTRIES_RESULT: ProjectSearchEntriesResult = {
18 entries: [],
19 truncated: false,
20};
21
22export 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 @@
1import {
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";
13import {
14 useCallback,
15 useEffect,
16 useMemo,
17 useState,
18 useSyncExternalStore,
19 useTransition,
20} from "react";
21
22import { ensureEnvironmentApi } from "../environmentApi";
23import { requireEnvironmentConnection } from "../environments/runtime";
24import { refreshGitStatus } from "./gitStatusState";
25import { vcsRefManager } from "./vcsRefState";
26
27type SourceControlActionKind =
28 | "init"
29 | "pull"
30 | "publishRepository"
31 | "runStackedAction"
32 | "preparePullRequestThread";
33
34interface SourceControlActionScope {
35 readonly environmentId: EnvironmentId | null;
36 readonly cwd: string | null;
37}
38
39interface 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
46const actionListeners = new Set<() => void>();
47const activeActionCounts = new Map<string, number>();
48
49function notifyActionListeners(): void {
50 for (const listener of actionListeners) {
51 listener();
52 }
53}
54
55function subscribeActionState(listener: () => void): () => void {
56 actionListeners.add(listener);
57 return () => {
58 actionListeners.delete(listener);
59 };
60}
61
62function actionKey(kind: SourceControlActionKind, scope: SourceControlActionScope): string {
63 return `${kind}:${scope.environmentId ?? ""}:${scope.cwd ?? ""}`;
64}
65
66function 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
85function isAnyActionRunning(
86 kinds: ReadonlyArray<SourceControlActionKind>,
87 scope: SourceControlActionScope,
88): boolean {
89 return kinds.some((kind) => (activeActionCounts.get(actionKey(kind, scope)) ?? 0) > 0);
90}
91
92export 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
113function 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
161export 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
173export 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
186export 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
226export 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
239export 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
266export 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
289interface PullRequestResolutionTarget {
290 readonly environmentId: EnvironmentId | null;
291 readonly cwd: string | null;
292 readonly reference: string | null;
293}
294
295interface PullRequestResolutionState {
296 readonly data: GitResolvePullRequestResult | null;
297 readonly error: unknown;
298 readonly isPending: boolean;
299 readonly isFetching: boolean;
300}
301
302const EMPTY_PULL_REQUEST_RESOLUTION: PullRequestResolutionState = {
303 data: null,
304 error: null,
305 isPending: false,
306 isFetching: false,
307};
308
309const pullRequestResolutionCache = new Map<string, GitResolvePullRequestResult>();
310
311function 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
318export function readCachedPullRequestResolution(
319 target: PullRequestResolutionTarget,
320): GitResolvePullRequestResult | null {
321 const key = pullRequestResolutionKey(target);
322 return key ? (pullRequestResolutionCache.get(key) ?? null) : null;
323}
324
325export 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 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
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";
11import { useEffect, useMemo } from "react";
12
13import {
14 readEnvironmentConnection,
15 subscribeEnvironmentConnections,
16} from "../environments/runtime";
17import { appAtomRegistry } from "../rpc/atomRegistry";
18
19const VCS_REF_LIST_LIMIT = 100;
20const VCS_REF_STALE_TIME_MS = 5_000;
21
22export 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
33export 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
102 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });102 expect(mock.listRefs).toHaveBeenCalledWith({ cwd: "/repo", limit: 100 });
103 });103 });
104 104
105 it("loads the next page and appends branches", async () => {105 it("loads the next page and appends refs", async () => {
106 const mock = createMockClient();106 const mock = createMockClient();
107 const manager = createVcsRefManager({107 const manager = createVcsRefManager({
108 getRegistry: () => atomRegistry,108 getRegistry: () => atomRegistry,
@@ -126,6 +126,70 @@ describe("createVcsRefManager", () => { · 17 unmodified lines
126 });126 });
127 });127 });
128 128
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
129 it("stores query-specific state independently", async () => {193 it("stores query-specific state independently", async () => {
130 const mock = createMockClient();194 const mock = createMockClient();
131 const manager = createVcsRefManager({195 const manager = createVcsRefManager({
@@ -171,7 +235,24 @@ describe("createVcsRefManager", () => { · 39 unmodified lines
171 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);235 expect(manager.getSnapshot(TARGET)).toEqual(EMPTY_VCS_REF_STATE);
172 });236 });
173 237
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 () => {
175 const mock = createMockClient();256 const mock = createMockClient();
176 let listener: () => void = noop;257 let listener: () => void = noop;
177 const unsubscribe = vi.fn();258 const unsubscribe = vi.fn();
@@ -202,6 +283,29 @@ describe("createVcsRefManager", () => { · 24 unmodified lines
202 expect(unsubscribe).toHaveBeenCalledTimes(1);283 expect(unsubscribe).toHaveBeenCalledTimes(1);
203 });284 });
204 285
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
205 it("swallows watched refresh failures after storing error state", async () => {309 it("swallows watched refresh failures after storing error state", async () => {
206 const refreshError = new Error("backend unavailable");310 const refreshError = new Error("backend unavailable");
207 const listRefs = vi.fn(async () => {311 const listRefs = vi.fn(async () => {
packages/client-runtime/src/vcsRefState.ts+64−5
@@ -4,6 +4,8 @@ import type { · 3 unmodified lines
4 VcsListRefsResult,4 VcsListRefsResult,
5 VcsRef as ContractVcsRef,5 VcsRef as ContractVcsRef,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Clock from "effect/Clock";
8import * as Effect from "effect/Effect";
7import { Atom, type AtomRegistry } from "effect/unstable/reactivity";9import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
8 10
9import type { WsRpcClient } from "./wsRpcClient.ts";11import type { WsRpcClient } from "./wsRpcClient.ts";
@@ -14,6 +16,11 @@ export interface VcsRefTarget { · 4 unmodified lines
14 readonly query?: string | null;16 readonly query?: string | null;
15}17}
16 18
19export interface VcsRefScope {
20 readonly environmentId: EnvironmentId | null;
21 readonly cwd: string | null;
22}
23
17export interface VcsRefState {24export interface VcsRefState {
18 readonly data: VcsListRefsResult | null;25 readonly data: VcsListRefsResult | null;
19 readonly isPending: boolean;26 readonly isPending: boolean;
@@ -82,6 +89,7 @@ export interface VcsRefManagerConfig { · 62 unmodified lines
82 readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null;89 readonly getClient: (environmentId: EnvironmentId) => VcsRefClient | null;
83 readonly subscribeClientChanges?: (listener: () => void) => () => void;90 readonly subscribeClientChanges?: (listener: () => void) => () => void;
84 readonly watchLimit?: number;91 readonly watchLimit?: number;
92 readonly staleTimeMs?: number;
85 readonly onBackgroundError?: (error: unknown) => void;93 readonly onBackgroundError?: (error: unknown) => void;
86}94}
87 95
@@ -102,8 +110,11 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 14 unmodified lines
102 >();110 >();
103 const loadVersions = new Map<string, number>();111 const loadVersions = new Map<string, number>();
104 const watched = new Map<string, WatchedEntry>();112 const watched = new Map<string, WatchedEntry>();
113 const lastLoadedAt = new Map<string, number>();
105 const watchLoadOptions =114 const watchLoadOptions =
106 config.watchLimit === undefined ? undefined : { limit: config.watchLimit };115 config.watchLimit === undefined
116 ? undefined
117 : { limit: config.watchLimit, preserveLoadedRefs: true };
107 118
108 function getLoadVersion(targetKey: string): number {119 function getLoadVersion(targetKey: string): number {
109 return loadVersions.get(targetKey) ?? 0;120 return loadVersions.get(targetKey) ?? 0;
@@ -136,6 +147,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 26 unmodified lines
136 }147 }
137 148
138 function setData(targetKey: string, data: VcsListRefsResult): void {149 function setData(targetKey: string, data: VcsListRefsResult): void {
150 lastLoadedAt.set(targetKey, Effect.runSync(Clock.currentTimeMillis));
139 setState(targetKey, {151 setState(targetKey, {
140 data,152 data,
141 isPending: false,153 isPending: false,
@@ -159,6 +171,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 17 unmodified lines
159 readonly cursor?: number;171 readonly cursor?: number;
160 readonly limit?: number;172 readonly limit?: number;
161 readonly append?: boolean;173 readonly append?: boolean;
174 readonly preserveLoadedRefs?: boolean;
162 },175 },
163 ): Promise<VcsListRefsResult | null> {176 ): Promise<VcsListRefsResult | null> {
164 const targetKey = getVcsRefTargetKey(target);177 const targetKey = getVcsRefTargetKey(target);
@@ -196,7 +209,14 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 31 unmodified lines
196 ...result,209 ...result,
197 refs: mergeRefs(current.refs, result.refs),210 refs: mergeRefs(current.refs, result.refs),
198 }211 }
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;
200 if (getLoadVersion(targetKey) === loadVersion) {220 if (getLoadVersion(targetKey) === loadVersion) {
201 setData(targetKey, nextData);221 setData(targetKey, nextData);
202 }222 }
@@ -227,6 +247,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 24 unmodified lines
227 readonly cursor?: number;247 readonly cursor?: number;
228 readonly limit?: number;248 readonly limit?: number;
229 readonly append?: boolean;249 readonly append?: boolean;
250 readonly preserveLoadedRefs?: boolean;
230 },251 },
231 ): void {252 ): void {
232 void load(target, client, options).catch((error: unknown) => {253 void load(target, client, options).catch((error: unknown) => {
@@ -264,9 +285,21 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 31 unmodified lines
264 }285 }
265 286
266 let teardown: () => void;287 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 };
267 298
268 if (client) {299 if (client) {
269 loadInBackground(target, client, watchLoadOptions);300 if (shouldRefresh()) {
301 loadInBackground(target, client, watchLoadOptions);
302 }
270 teardown = NOOP;303 teardown = NOOP;
271 } else if (config.subscribeClientChanges) {304 } else if (config.subscribeClientChanges) {
272 let currentClient: VcsRefClient | null = null;305 let currentClient: VcsRefClient | null = null;
@@ -281,7 +314,9 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 8 unmodified lines
281 }314 }
282 315
283 currentClient = resolved;316 currentClient = resolved;
284 loadInBackground(target, resolved, watchLoadOptions);317 if (shouldRefresh()) {
318 loadInBackground(target, resolved, watchLoadOptions);
319 }
285 };320 };
286 321
287 const unsubscribe = config.subscribeClientChanges(sync);322 const unsubscribe = config.subscribeClientChanges(sync);
@@ -292,7 +327,9 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 4 unmodified lines
292 if (!resolved) {327 if (!resolved) {
293 return NOOP;328 return NOOP;
294 }329 }
295 loadInBackground(target, resolved, watchLoadOptions);330 if (shouldRefresh()) {
331 loadInBackground(target, resolved, watchLoadOptions);
332 }
296 teardown = NOOP;333 teardown = NOOP;
297 }334 }
298 335
@@ -329,6 +366,26 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 30 unmodified lines
329 }366 }
330 }367 }
331 368
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
332 function reset(): void {389 function reset(): void {
333 for (const entry of watched.values()) {390 for (const entry of watched.values()) {
334 entry.teardown();391 entry.teardown();
@@ -336,6 +393,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 1 unmodified line
336 watched.clear();393 watched.clear();
337 inFlight.clear();394 inFlight.clear();
338 loadVersions.clear();395 loadVersions.clear();
396 lastLoadedAt.clear();
339 invalidate();397 invalidate();
340 }398 }
341 399
@@ -345,6 +403,7 @@ export function createVcsRefManager(config: VcsRefManagerConfig) { · 3 unmodified lines
345 load,403 load,
346 loadNext,404 loadNext,
347 invalidate,405 invalidate,
406 invalidateScope,
348 reset,407 reset,
349 };408 };
350}409}