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