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