Unified · Split
| @@ -1,6 +1,8 @@ | ||
| 1 | −import type { GitBranch } from "@t3tools/contracts"; | |
| 2 | −import { useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 1 | +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 2 | +import { type GitBranch } from "@t3tools/contracts"; | |
| 3 | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 4 | 6 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | 7 | import { |
| 6 | 8 | type CSSProperties, |
| @@ -14,14 +16,14 @@ import { · 7 unmodified lines | ||
| 14 | 16 | useTransition, |
| 15 | 17 | } from "react"; |
| 16 | 18 | |
| 17 | −import { | |
| 18 | − gitBranchesQueryOptions, | |
| 19 | − gitQueryKeys, | |
| 20 | − gitStatusQueryOptions, | |
| 21 | − invalidateGitQueries, | |
| 22 | −} from "../lib/gitReactQuery"; | |
| 23 | −import { readNativeApi } from "../nativeApi"; | |
| 24 | 19 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 20 | +import { REACTIVITY_KEYS } from "../rpc/client"; | |
| 21 | +import { | |
| 22 | + gitBranchesAtom, | |
| 23 | + gitCheckoutMutationAtom, | |
| 24 | + gitCreateBranchMutationAtom, | |
| 25 | + gitStatusAtom, | |
| 26 | +} from "../rpc/gitAtoms"; | |
| 25 | 27 | import { |
| 26 | 28 | dedupeRemoteBranchesWithLocalMatches, |
| 27 | 29 | deriveLocalBranchNameFromRemoteRef, |
| @@ -84,19 +86,23 @@ export function BranchToolbarBranchSelector({ · 56 unmodified lines | ||
| 84 | 86 | onCheckoutPullRequestRequest, |
| 85 | 87 | onComposerFocusRequest, |
| 86 | 88 | }: BranchToolbarBranchSelectorProps) { |
| 87 | − const queryClient = useQueryClient(); | |
| 88 | 89 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 89 | 90 | const [branchQuery, setBranchQuery] = useState(""); |
| 90 | 91 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 91 | − | |
| 92 | − const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | |
| 93 | − const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); | |
| 92 | + const branchesResult = useAtomValue(gitBranchesAtom(branchCwd)); | |
| 93 | + const branchStatusResult = useAtomValue(gitStatusAtom(branchCwd)); | |
| 94 | + const refreshBranches = useAtomRefresh(gitBranchesAtom(branchCwd)); | |
| 95 | + const refreshBranchStatus = useAtomRefresh(gitStatusAtom(branchCwd)); | |
| 96 | + const checkoutBranch = useAtomSet(gitCheckoutMutationAtom, { mode: "promise" }); | |
| 97 | + const createGitBranch = useAtomSet(gitCreateBranchMutationAtom, { mode: "promise" }); | |
| 98 | + const branchesData = Option.getOrUndefined(AsyncResult.value(branchesResult)); | |
| 99 | + const branchStatus = Option.getOrUndefined(AsyncResult.value(branchStatusResult)); | |
| 94 | 100 | const branches = useMemo( |
| 95 | − () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | |
| 96 | − [branchesQuery.data?.branches], | |
| 101 | + () => dedupeRemoteBranchesWithLocalMatches(branchesData?.branches ?? []), | |
| 102 | + [branchesData?.branches], | |
| 97 | 103 | ); |
| 98 | 104 | const currentGitBranch = |
| 99 | − branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; | |
| 105 | + branchStatus?.branch ?? branches.find((branch) => branch.current)?.name ?? null; | |
| 100 | 106 | const canonicalActiveBranch = resolveBranchToolbarValue({ |
| 101 | 107 | envMode: effectiveEnvMode, |
| 102 | 108 | activeWorktreePath, |
| @@ -155,18 +161,18 @@ export function BranchToolbarBranchSelector({ · 52 unmodified lines | ||
| 155 | 161 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 156 | 162 | ); |
| 157 | 163 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 164 | + const isBranchesLoading = | |
| 165 | + branches.length === 0 && (AsyncResult.isInitial(branchesResult) || branchesResult.waiting); | |
| 158 | 166 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 159 | 167 | |
| 160 | 168 | const runBranchAction = (action: () => Promise<void>) => { |
| 161 | 169 | startBranchActionTransition(async () => { |
| 162 | 170 | await action().catch(() => undefined); |
| 163 | − await invalidateGitQueries(queryClient).catch(() => undefined); | |
| 164 | 171 | }); |
| 165 | 172 | }; |
| 166 | 173 | |
| 167 | 174 | const selectBranch = (branch: GitBranch) => { |
| 168 | − const api = readNativeApi(); | |
| 169 | − if (!api || !branchCwd || isBranchActionPending) return; | |
| 175 | + if (!branchCwd || isBranchActionPending) return; | |
| 170 | 176 | |
| 171 | 177 | // In new-worktree mode, selecting a branch sets the base branch. |
| 172 | 178 | if (isSelectingWorktreeBase) { |
| @@ -200,8 +206,13 @@ export function BranchToolbarBranchSelector({ · 27 unmodified lines | ||
| 200 | 206 | runBranchAction(async () => { |
| 201 | 207 | setOptimisticBranch(selectedBranchName); |
| 202 | 208 | try { |
| 203 | − await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); | |
| 204 | − await invalidateGitQueries(queryClient); | |
| 209 | + await checkoutBranch({ | |
| 210 | + payload: { | |
| 211 | + cwd: selectionTarget.checkoutCwd, | |
| 212 | + branch: branch.name, | |
| 213 | + }, | |
| 214 | + reactivityKeys: [REACTIVITY_KEYS.git(selectionTarget.checkoutCwd)], | |
| 215 | + }); | |
| 205 | 216 | } catch (error) { |
| 206 | 217 | toastManager.add({ |
| 207 | 218 | type: "error", |
| @@ -211,23 +222,14 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines | ||
| 211 | 222 | return; |
| 212 | 223 | } |
| 213 | 224 | |
| 214 | − let nextBranchName = selectedBranchName; | |
| 215 | − if (branch.isRemote) { | |
| 216 | − const status = await api.git.status({ cwd: branchCwd }).catch(() => null); | |
| 217 | − if (status?.branch) { | |
| 218 | − nextBranchName = status.branch; | |
| 219 | − } | |
| 220 | − } | |
| 221 | − | |
| 222 | − setOptimisticBranch(nextBranchName); | |
| 223 | − onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | |
| 225 | + setOptimisticBranch(selectedBranchName); | |
| 226 | + onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); | |
| 224 | 227 | }); |
| 225 | 228 | }; |
| 226 | 229 | |
| 227 | 230 | const createBranch = (rawName: string) => { |
| 228 | 231 | const name = rawName.trim(); |
| 229 | − const api = readNativeApi(); | |
| 230 | − if (!api || !branchCwd || !name || isBranchActionPending) return; | |
| 232 | + if (!branchCwd || !name || isBranchActionPending) return; | |
| 231 | 233 | |
| 232 | 234 | setIsBranchMenuOpen(false); |
| 233 | 235 | onComposerFocusRequest?.(); |
| @@ -236,9 +238,14 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines | ||
| 236 | 238 | setOptimisticBranch(name); |
| 237 | 239 | |
| 238 | 240 | try { |
| 239 | − await api.git.createBranch({ cwd: branchCwd, branch: name }); | |
| 241 | + await createGitBranch({ | |
| 242 | + payload: { cwd: branchCwd, branch: name }, | |
| 243 | + }); | |
| 240 | 244 | try { |
| 241 | − await api.git.checkout({ cwd: branchCwd, branch: name }); | |
| 245 | + await checkoutBranch({ | |
| 246 | + payload: { cwd: branchCwd, branch: name }, | |
| 247 | + reactivityKeys: [REACTIVITY_KEYS.git(branchCwd)], | |
| 248 | + }); | |
| 242 | 249 | } catch (error) { |
| 243 | 250 | toastManager.add({ |
| 244 | 251 | type: "error", |
| @@ -287,11 +294,12 @@ export function BranchToolbarBranchSelector({ · 42 unmodified lines | ||
| 287 | 294 | setBranchQuery(""); |
| 288 | 295 | return; |
| 289 | 296 | } |
| 290 | − void queryClient.invalidateQueries({ | |
| 291 | − queryKey: gitQueryKeys.branches(branchCwd), | |
| 292 | − }); | |
| 297 | + if (branchCwd) { | |
| 298 | + refreshBranches(); | |
| 299 | + refreshBranchStatus(); | |
| 300 | + } | |
| 293 | 301 | }, |
| 294 | − [branchCwd, queryClient], | |
| 302 | + [branchCwd, refreshBranchStatus, refreshBranches], | |
| 295 | 303 | ); |
| 296 | 304 | |
| 297 | 305 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| @@ -425,7 +433,7 @@ export function BranchToolbarBranchSelector({ · 127 unmodified lines | ||
| 425 | 433 | <ComboboxTrigger |
| 426 | 434 | render={<Button variant="ghost" size="xs" />} |
| 427 | 435 | className="text-muted-foreground/70 hover:text-foreground/80" |
| 428 | − disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} | |
| 436 | + disabled={isBranchesLoading || isBranchActionPending} | |
| 429 | 437 | > |
| 430 | 438 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 431 | 439 | <ChevronDownIcon /> |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | ||
| 32 | 32 | import { isMacPlatform } from "../lib/utils"; |
| 33 | 33 | import { __resetNativeApiForTests } from "../nativeApi"; |
| 34 | 34 | import { getRouter } from "../router"; |
| 35 | +import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; | |
| 35 | 36 | import { useStore } from "../store"; |
| 36 | 37 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 37 | 38 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| @@ -1121,6 +1122,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1083 unmodified lines | ||
| 1121 | 1122 | }, |
| 1122 | 1123 | }); |
| 1123 | 1124 | __resetNativeApiForTests(); |
| 1125 | + resetAppAtomRegistryForTests(); | |
| 1124 | 1126 | await setViewport(DEFAULT_VIEWPORT); |
| 1125 | 1127 | localStorage.clear(); |
| 1126 | 1128 | document.body.innerHTML = ""; |
| @@ -1143,6 +1145,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 16 unmodified lines | ||
| 1143 | 1145 | afterEach(() => { |
| 1144 | 1146 | customWsRpcResolver = null; |
| 1145 | 1147 | document.body.innerHTML = ""; |
| 1148 | + resetAppAtomRegistryForTests(); | |
| 1146 | 1149 | }); |
| 1147 | 1150 | |
| 1148 | 1151 | it.each(TEXT_VIEWPORT_MATRIX)( |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { |
| 2 | 3 | type ApprovalRequestId, |
| 3 | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| @@ -21,12 +22,13 @@ import { · 17 unmodified lines | ||
| 21 | 22 | TerminalOpenInput, |
| 22 | 23 | } from "@t3tools/contracts"; |
| 23 | 24 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 25 | +import * as Option from "effect/Option"; | |
| 24 | 26 | import { truncate } from "@t3tools/shared/String"; |
| 25 | 27 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 26 | −import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 28 | +import { useQuery } from "@tanstack/react-query"; | |
| 27 | 29 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 28 | 30 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 29 | −import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | |
| 31 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 30 | 32 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 31 | 33 | import { isElectron } from "../env"; |
| 32 | 34 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| @@ -106,6 +108,8 @@ import { Separator } from "./ui/separator"; · 73 unmodified lines | ||
| 106 | 108 | import { cn, randomUUID } from "~/lib/utils"; |
| 107 | 109 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 108 | 110 | import { toastManager } from "./ui/toast"; |
| 111 | +import { gitBranchesAtom, gitCreateWorktreeMutationAtom } from "~/rpc/gitAtoms"; | |
| 112 | +import { REACTIVITY_KEYS } from "~/rpc/client"; | |
| 109 | 113 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 110 | 114 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 111 | 115 | import { |
| @@ -423,8 +427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 311 unmodified lines | ||
| 423 | 427 | select: (params) => parseDiffRouteSearch(params), |
| 424 | 428 | }); |
| 425 | 429 | const { resolvedTheme } = useTheme(); |
| 426 | − const queryClient = useQueryClient(); | |
| 427 | − const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); | |
| 430 | + const createWorktree = useAtomSet(gitCreateWorktreeMutationAtom, { mode: "promise" }); | |
| 428 | 431 | const composerDraft = useComposerThreadDraft(threadId); |
| 429 | 432 | const prompt = composerDraft.prompt; |
| 430 | 433 | const composerImages = composerDraft.images; |
| @@ -1201,7 +1204,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 770 unmodified lines | ||
| 1201 | 1204 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1202 | 1205 | ); |
| 1203 | 1206 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1204 | − const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 1207 | + const branchesResult = useAtomValue(gitBranchesAtom(gitCwd)); | |
| 1208 | + const branches = Option.getOrUndefined(AsyncResult.value(branchesResult)); | |
| 1205 | 1209 | const keybindings = useServerKeybindings(); |
| 1206 | 1210 | const availableEditors = useServerAvailableEditors(); |
| 1207 | 1211 | const modelOptionsByProvider = useMemo( |
| @@ -1338,7 +1342,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines | ||
| 1338 | 1342 | }); |
| 1339 | 1343 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1340 | 1344 | // Default true while loading to avoid toolbar flicker. |
| 1341 | − const isGitRepo = branchesQuery.data?.isRepo ?? true; | |
| 1345 | + const isGitRepo = branches?.isRepo ?? true; | |
| 1342 | 1346 | const terminalShortcutLabelOptions = useMemo( |
| 1343 | 1347 | () => ({ |
| 1344 | 1348 | context: { |
| @@ -2787,10 +2791,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1442 unmodified lines | ||
| 2787 | 2791 | if (baseBranchForWorktree) { |
| 2788 | 2792 | beginLocalDispatch({ preparingWorktree: true }); |
| 2789 | 2793 | const newBranch = buildTemporaryWorktreeBranchName(); |
| 2790 | − const result = await createWorktreeMutation.mutateAsync({ | |
| 2791 | − cwd: activeProject.cwd, | |
| 2792 | − branch: baseBranchForWorktree, | |
| 2793 | − newBranch, | |
| 2794 | + const result = await createWorktree({ | |
| 2795 | + payload: { | |
| 2796 | + cwd: activeProject.cwd, | |
| 2797 | + branch: baseBranchForWorktree, | |
| 2798 | + path: null, | |
| 2799 | + newBranch, | |
| 2800 | + }, | |
| 2801 | + reactivityKeys: [ | |
| 2802 | + REACTIVITY_KEYS.git(activeProject.cwd), | |
| 2803 | + REACTIVITY_KEYS.project(activeProject.cwd), | |
| 2804 | + ], | |
| 2794 | 2805 | }); |
| 2795 | 2806 | nextThreadBranch = result.worktree.branch; |
| 2796 | 2807 | nextThreadWorktreePath = result.worktree.path; |
| @@ -1,8 +1,11 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | 3 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | 4 | import { useQuery } from "@tanstack/react-query"; |
| 4 | 5 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | 6 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 7 | +import * as Option from "effect/Option"; | |
| 8 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 6 | 9 | import { |
| 7 | 10 | ChevronLeftIcon, |
| 8 | 11 | ChevronRightIcon, |
| @@ -19,10 +22,10 @@ import { · 10 unmodified lines | ||
| 19 | 22 | useState, |
| 20 | 23 | } from "react"; |
| 21 | 24 | import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | −import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | |
| 23 | 25 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | 26 | import { cn } from "~/lib/utils"; |
| 25 | 27 | import { readNativeApi } from "../nativeApi"; |
| 28 | +import { gitBranchesAtom } from "../rpc/gitAtoms"; | |
| 26 | 29 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 27 | 30 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 28 | 31 | import { useTheme } from "../hooks/useTheme"; |
| @@ -189,8 +192,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines | ||
| 189 | 192 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 190 | 193 | ); |
| 191 | 194 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | − const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); | |
| 193 | − const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; | |
| 195 | + const gitBranchesResult = useAtomValue(gitBranchesAtom(activeCwd ?? null)); | |
| 196 | + const gitBranches = Option.getOrUndefined(AsyncResult.value(gitBranchesResult)); | |
| 197 | + const isGitRepo = gitBranches?.isRepo ?? true; | |
| 194 | 198 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 195 | 199 | useTurnDiffSummaries(activeThread); |
| 196 | 200 | const orderedTurnDiffSummaries = useMemo( |
| @@ -1,10 +1,13 @@ | ||
| 1 | +import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 1 | 2 | import type { |
| 2 | − GitActionProgressEvent, | |
| 3 | + GitRunStackedActionInput, | |
| 3 | 4 | GitStackedAction, |
| 4 | 5 | GitStatusResult, |
| 5 | 6 | ThreadId, |
| 6 | 7 | } from "@t3tools/contracts"; |
| 7 | −import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 8 | +import { Cause } from "effect"; | |
| 9 | +import * as Option from "effect/Option"; | |
| 10 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 8 | 11 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 9 | 12 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 10 | 13 | import { GitHubIcon } from "./Icons"; |
| @@ -38,18 +41,18 @@ import { ScrollArea } from "~/components/ui/scroll-area"; · 27 unmodified lines | ||
| 38 | 41 | import { Textarea } from "~/components/ui/textarea"; |
| 39 | 42 | import { toastManager } from "~/components/ui/toast"; |
| 40 | 43 | import { openInPreferredEditor } from "~/editorPreferences"; |
| 41 | −import { | |
| 42 | − gitBranchesQueryOptions, | |
| 43 | − gitInitMutationOptions, | |
| 44 | − gitMutationKeys, | |
| 45 | − gitPullMutationOptions, | |
| 46 | − gitRunStackedActionMutationOptions, | |
| 47 | − gitStatusQueryOptions, | |
| 48 | − invalidateGitQueries, | |
| 49 | −} from "~/lib/gitReactQuery"; | |
| 50 | 44 | import { randomUUID } from "~/lib/utils"; |
| 51 | 45 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | 46 | import { readNativeApi } from "~/nativeApi"; |
| 47 | +import { | |
| 48 | + gitBranchesAtom, | |
| 49 | + gitInitMutationAtom, | |
| 50 | + gitPullMutationAtom, | |
| 51 | + gitRunStackedActionMutationAtom, | |
| 52 | + gitRunStackedActionProgressAtom, | |
| 53 | + gitStatusAtom, | |
| 54 | +} from "~/rpc/gitAtoms"; | |
| 55 | +import { REACTIVITY_KEYS } from "~/rpc/client"; | |
| 53 | 56 | |
| 54 | 57 | interface GitActionsControlProps { |
| 55 | 58 | gitCwd: string | null; |
| @@ -112,6 +115,13 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 56 unmodified lines | ||
| 112 | 115 | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); |
| 113 | 116 | } |
| 114 | 117 | |
| 118 | +function toError(error: unknown): Error { | |
| 119 | + if (error instanceof Error) { | |
| 120 | + return error; | |
| 121 | + } | |
| 122 | + return new Error(typeof error === "string" ? error : "Unknown error"); | |
| 123 | +} | |
| 124 | + | |
| 115 | 125 | function getMenuActionDisabledReason({ |
| 116 | 126 | item, |
| 117 | 127 | gitStatus, |
| @@ -208,7 +218,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 90 unmodified lines | ||
| 208 | 218 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 209 | 219 | [activeThreadId], |
| 210 | 220 | ); |
| 211 | − const queryClient = useQueryClient(); | |
| 212 | 221 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 213 | 222 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 214 | 223 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); |
| @@ -231,9 +240,22 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 16 unmodified lines | ||
| 231 | 240 | }); |
| 232 | 241 | }, [threadToastData]); |
| 233 | 242 | |
| 234 | − const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | |
| 235 | − | |
| 236 | − const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); | |
| 243 | + const gitStatusResult = useAtomValue(gitStatusAtom(gitCwd)); | |
| 244 | + const branchesResult = useAtomValue(gitBranchesAtom(gitCwd)); | |
| 245 | + const refreshGitStatus = useAtomRefresh(gitStatusAtom(gitCwd)); | |
| 246 | + const refreshGitBranches = useAtomRefresh(gitBranchesAtom(gitCwd)); | |
| 247 | + const initGit = useAtomSet(gitInitMutationAtom, { mode: "promise" }); | |
| 248 | + const pullGit = useAtomSet(gitPullMutationAtom, { mode: "promise" }); | |
| 249 | + const runStackedAction = useAtomSet(gitRunStackedActionMutationAtom, { mode: "promise" }); | |
| 250 | + const runStackedActionResult = useAtomValue(gitRunStackedActionMutationAtom); | |
| 251 | + const stackedActionProgressEvent = useAtomValue(gitRunStackedActionProgressAtom); | |
| 252 | + const [isInitPending, setIsInitPending] = useState(false); | |
| 253 | + const [isPullRunning, setIsPullRunning] = useState(false); | |
| 254 | + const gitStatus = Option.getOrNull(AsyncResult.value(gitStatusResult)); | |
| 255 | + const gitStatusError = Option.getOrNull( | |
| 256 | + Option.map(AsyncResult.cause(gitStatusResult), (cause) => toError(Cause.squash(cause))), | |
| 257 | + ); | |
| 258 | + const branchList = Option.getOrNull(AsyncResult.value(branchesResult)); | |
| 237 | 259 | // Default to true while loading so we don't flash init controls. |
| 238 | 260 | const isRepo = branchList?.isRepo ?? true; |
| 239 | 261 | const hasOriginRemote = branchList?.hasOriginRemote ?? false; |
| @@ -242,9 +264,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 2 unmodified lines | ||
| 242 | 264 | !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; |
| 243 | 265 | |
| 244 | 266 | useEffect(() => { |
| 245 | − if (!isGitStatusOutOfSync) return; | |
| 246 | − void invalidateGitQueries(queryClient); | |
| 247 | − }, [isGitStatusOutOfSync, queryClient]); | |
| 267 | + if (!isGitStatusOutOfSync || !gitCwd) return; | |
| 268 | + refreshGitStatus(); | |
| 269 | + refreshGitBranches(); | |
| 270 | + }, [gitCwd, isGitStatusOutOfSync, refreshGitBranches, refreshGitStatus]); | |
| 248 | 271 | |
| 249 | 272 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 250 | 273 | |
| @@ -252,21 +275,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 1 unmodified line | ||
| 252 | 275 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| 253 | 276 | const allSelected = excludedFiles.size === 0; |
| 254 | 277 | const noneSelected = selectedFiles.length === 0; |
| 255 | − | |
| 256 | − const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); | |
| 257 | − | |
| 258 | − const runImmediateGitActionMutation = useMutation( | |
| 259 | − gitRunStackedActionMutationOptions({ | |
| 260 | − cwd: gitCwd, | |
| 261 | − queryClient, | |
| 262 | − }), | |
| 263 | − ); | |
| 264 | − const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); | |
| 265 | − | |
| 266 | − const isRunStackedActionRunning = | |
| 267 | − useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; | |
| 268 | − const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; | |
| 278 | + const isRunStackedActionRunning = runStackedActionResult.waiting; | |
| 269 | 279 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; |
| 280 | + const runImmediateGitAction = useCallback( | |
| 281 | + (input: GitRunStackedActionInput) => runStackedAction(input), | |
| 282 | + [runStackedAction], | |
| 283 | + ); | |
| 284 | + const pullCurrentBranch = useCallback( | |
| 285 | + async (cwd: string) => { | |
| 286 | + setIsPullRunning(true); | |
| 287 | + try { | |
| 288 | + return await pullGit({ | |
| 289 | + payload: { cwd }, | |
| 290 | + reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | |
| 291 | + }); | |
| 292 | + } finally { | |
| 293 | + setIsPullRunning(false); | |
| 294 | + } | |
| 295 | + }, | |
| 296 | + [pullGit], | |
| 297 | + ); | |
| 298 | + const initializeGit = useCallback( | |
| 299 | + async (cwd: string) => { | |
| 300 | + setIsInitPending(true); | |
| 301 | + try { | |
| 302 | + await initGit({ | |
| 303 | + payload: { cwd }, | |
| 304 | + reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | |
| 305 | + }); | |
| 306 | + } finally { | |
| 307 | + setIsInitPending(false); | |
| 308 | + } | |
| 309 | + }, | |
| 310 | + [initGit], | |
| 311 | + ); | |
| 270 | 312 | const isDefaultBranch = useMemo(() => { |
| 271 | 313 | const branchName = gitStatusForActions?.branch; |
| 272 | 314 | if (!branchName) return false; |
| @@ -307,6 +349,59 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 34 unmodified lines | ||
| 307 | 349 | }; |
| 308 | 350 | }, [updateActiveProgressToast]); |
| 309 | 351 | |
| 352 | + useEffect(() => { | |
| 353 | + const event = stackedActionProgressEvent; | |
| 354 | + const progress = activeGitActionProgressRef.current; | |
| 355 | + if (!event || !progress) { | |
| 356 | + return; | |
| 357 | + } | |
| 358 | + if (gitCwd && event.cwd !== gitCwd) { | |
| 359 | + return; | |
| 360 | + } | |
| 361 | + if (progress.actionId !== event.actionId) { | |
| 362 | + return; | |
| 363 | + } | |
| 364 | + | |
| 365 | + const now = Date.now(); | |
| 366 | + switch (event.kind) { | |
| 367 | + case "action_started": | |
| 368 | + progress.phaseStartedAtMs = now; | |
| 369 | + progress.hookStartedAtMs = null; | |
| 370 | + progress.hookName = null; | |
| 371 | + progress.lastOutputLine = null; | |
| 372 | + break; | |
| 373 | + case "phase_started": | |
| 374 | + progress.title = event.label; | |
| 375 | + progress.currentPhaseLabel = event.label; | |
| 376 | + progress.phaseStartedAtMs = now; | |
| 377 | + progress.hookStartedAtMs = null; | |
| 378 | + progress.hookName = null; | |
| 379 | + progress.lastOutputLine = null; | |
| 380 | + break; | |
| 381 | + case "hook_started": | |
| 382 | + progress.title = `Running ${event.hookName}...`; | |
| 383 | + progress.hookName = event.hookName; | |
| 384 | + progress.hookStartedAtMs = now; | |
| 385 | + progress.lastOutputLine = null; | |
| 386 | + break; | |
| 387 | + case "hook_output": | |
| 388 | + progress.lastOutputLine = event.text; | |
| 389 | + break; | |
| 390 | + case "hook_finished": | |
| 391 | + progress.title = progress.currentPhaseLabel ?? "Committing..."; | |
| 392 | + progress.hookName = null; | |
| 393 | + progress.hookStartedAtMs = null; | |
| 394 | + progress.lastOutputLine = null; | |
| 395 | + break; | |
| 396 | + case "action_finished": | |
| 397 | + return; | |
| 398 | + case "action_failed": | |
| 399 | + return; | |
| 400 | + } | |
| 401 | + | |
| 402 | + updateActiveProgressToast(); | |
| 403 | + }, [gitCwd, stackedActionProgressEvent, updateActiveProgressToast]); | |
| 404 | + | |
| 310 | 405 | const openExistingPr = useCallback(async () => { |
| 311 | 406 | const api = readNativeApi(); |
| 312 | 407 | if (!api) { |
| @@ -415,69 +510,17 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 102 unmodified lines | ||
| 415 | 510 | }); |
| 416 | 511 | } |
| 417 | 512 | |
| 418 | − const applyProgressEvent = (event: GitActionProgressEvent) => { | |
| 419 | − const progress = activeGitActionProgressRef.current; | |
| 420 | − if (!progress) { | |
| 421 | − return; | |
| 422 | − } | |
| 423 | − if (gitCwd && event.cwd !== gitCwd) { | |
| 424 | − return; | |
| 425 | − } | |
| 426 | − if (progress.actionId !== event.actionId) { | |
| 427 | − return; | |
| 428 | − } | |
| 429 | − | |
| 430 | − const now = Date.now(); | |
| 431 | − switch (event.kind) { | |
| 432 | − case "action_started": | |
| 433 | − progress.phaseStartedAtMs = now; | |
| 434 | − progress.hookStartedAtMs = null; | |
| 435 | − progress.hookName = null; | |
| 436 | − progress.lastOutputLine = null; | |
| 437 | − break; | |
| 438 | − case "phase_started": | |
| 439 | − progress.title = event.label; | |
| 440 | − progress.currentPhaseLabel = event.label; | |
| 441 | − progress.phaseStartedAtMs = now; | |
| 442 | − progress.hookStartedAtMs = null; | |
| 443 | − progress.hookName = null; | |
| 444 | − progress.lastOutputLine = null; | |
| 445 | − break; | |
| 446 | − case "hook_started": | |
| 447 | − progress.title = `Running ${event.hookName}...`; | |
| 448 | − progress.hookName = event.hookName; | |
| 449 | − progress.hookStartedAtMs = now; | |
| 450 | − progress.lastOutputLine = null; | |
| 451 | − break; | |
| 452 | − case "hook_output": | |
| 453 | − progress.lastOutputLine = event.text; | |
| 454 | − break; | |
| 455 | − case "hook_finished": | |
| 456 | − progress.title = progress.currentPhaseLabel ?? "Committing..."; | |
| 457 | − progress.hookName = null; | |
| 458 | − progress.hookStartedAtMs = null; | |
| 459 | − progress.lastOutputLine = null; | |
| 460 | − break; | |
| 461 | − case "action_finished": | |
| 462 | − // Let the resolved mutation update the toast so we keep the | |
| 463 | − // elapsed description visible until the final success state renders. | |
| 464 | − return; | |
| 465 | − case "action_failed": | |
| 466 | − // Let the rejected mutation publish the error toast to avoid a | |
| 467 | − // transient intermediate state before the final failure message. | |
| 468 | − return; | |
| 469 | − } | |
| 470 | − | |
| 471 | − updateActiveProgressToast(); | |
| 472 | − }; | |
| 513 | + if (!gitCwd) { | |
| 514 | + return; | |
| 515 | + } | |
| 473 | 516 | |
| 474 | − const promise = runImmediateGitActionMutation.mutateAsync({ | |
| 517 | + const promise = runImmediateGitAction({ | |
| 475 | 518 | actionId, |
| 519 | + cwd: gitCwd, | |
| 476 | 520 | action, |
| 477 | 521 | ...(commitMessage ? { commitMessage } : {}), |
| 478 | 522 | ...(featureBranch ? { featureBranch } : {}), |
| 479 | 523 | ...(filePaths ? { filePaths } : {}), |
| 480 | − onProgress: applyProgressEvent, | |
| 481 | 524 | }); |
| 482 | 525 | |
| 483 | 526 | try { |
| @@ -624,7 +667,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 140 unmodified lines | ||
| 624 | 667 | return; |
| 625 | 668 | } |
| 626 | 669 | if (quickAction.kind === "run_pull") { |
| 627 | − const promise = pullMutation.mutateAsync(); | |
| 670 | + if (!gitCwd) { | |
| 671 | + return; | |
| 672 | + } | |
| 673 | + const promise = pullCurrentBranch(gitCwd); | |
| 628 | 674 | toastManager.promise(promise, { |
| 629 | 675 | loading: { title: "Pulling...", data: threadToastData }, |
| 630 | 676 | success: (result) => ({ |
| @@ -656,7 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 25 unmodified lines | ||
| 656 | 702 | if (quickAction.action) { |
| 657 | 703 | void runGitActionWithToast({ action: quickAction.action }); |
| 658 | 704 | } |
| 659 | − }, [openExistingPr, pullMutation, quickAction, threadToastData]); | |
| 705 | + }, [gitCwd, openExistingPr, pullCurrentBranch, quickAction, threadToastData]); | |
| 660 | 706 | |
| 661 | 707 | const openDialogForMenuItem = useCallback( |
| 662 | 708 | (item: GitActionMenuItem) => { |
| @@ -733,10 +779,15 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 70 unmodified lines | ||
| 733 | 779 | <Button |
| 734 | 780 | variant="outline" |
| 735 | 781 | size="xs" |
| 736 | − disabled={initMutation.isPending} | |
| 737 | − onClick={() => initMutation.mutate()} | |
| 782 | + disabled={isInitPending} | |
| 783 | + onClick={() => { | |
| 784 | + if (!gitCwd) { | |
| 785 | + return; | |
| 786 | + } | |
| 787 | + void initializeGit(gitCwd).catch(() => undefined); | |
| 788 | + }} | |
| 738 | 789 | > |
| 739 | − {initMutation.isPending ? "Initializing..." : "Initialize Git"} | |
| 790 | + {isInitPending ? "Initializing..." : "Initialize Git"} | |
| 740 | 791 | </Button> |
| 741 | 792 | ) : ( |
| 742 | 793 | <Group aria-label="Git actions" className="shrink-0"> |
| @@ -778,7 +829,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 35 unmodified lines | ||
| 778 | 829 | <GroupSeparator className="hidden @3xl/header-actions:block" /> |
| 779 | 830 | <Menu |
| 780 | 831 | onOpenChange={(open) => { |
| 781 | − if (open) void invalidateGitQueries(queryClient); | |
| 832 | + if (open && gitCwd) { | |
| 833 | + refreshGitStatus(); | |
| 834 | + refreshGitBranches(); | |
| 835 | + } | |
| 782 | 836 | }} |
| 783 | 837 | > |
| 784 | 838 | <MenuTrigger |
| @@ -1,14 +1,13 @@ | ||
| 1 | −import type { GitResolvePullRequestResult } from "@t3tools/contracts"; | |
| 2 | −import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 1 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | 2 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 3 | +import { Cause, Option } from "effect"; | |
| 4 | +import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 4 | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 6 | |
| 6 | −import { | |
| 7 | − gitPreparePullRequestThreadMutationOptions, | |
| 8 | − gitResolvePullRequestQueryOptions, | |
| 9 | −} from "~/lib/gitReactQuery"; | |
| 10 | 7 | import { cn } from "~/lib/utils"; |
| 11 | 8 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 9 | +import { gitResolvePullRequestAtom, gitPreparePullRequestThreadMutationAtom } from "~/rpc/gitAtoms"; | |
| 10 | +import { REACTIVITY_KEYS } from "~/rpc/client"; | |
| 12 | 11 | import { Button } from "./ui/button"; |
| 13 | 12 | import { |
| 14 | 13 | Dialog, |
| @@ -30,6 +29,10 @@ interface PullRequestThreadDialogProps { · 15 unmodified lines | ||
| 30 | 29 | onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void; |
| 31 | 30 | } |
| 32 | 31 | |
| 32 | +function toErrorMessage(error: unknown, fallback: string): string { | |
| 33 | + return error instanceof Error ? error.message : fallback; | |
| 34 | +} | |
| 35 | + | |
| 33 | 36 | export function PullRequestThreadDialog({ |
| 34 | 37 | open, |
| 35 | 38 | cwd, |
| @@ -37,23 +40,28 @@ export function PullRequestThreadDialog({ · 1 unmodified line | ||
| 37 | 40 | onOpenChange, |
| 38 | 41 | onPrepared, |
| 39 | 42 | }: PullRequestThreadDialogProps) { |
| 40 | − const queryClient = useQueryClient(); | |
| 41 | 43 | const referenceInputRef = useRef<HTMLInputElement>(null); |
| 42 | 44 | const [reference, setReference] = useState(initialReference ?? ""); |
| 43 | 45 | const [referenceDirty, setReferenceDirty] = useState(false); |
| 44 | 46 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); |
| 47 | + const [prepareFollowupErrorMessage, setPrepareFollowupErrorMessage] = useState<string | null>( | |
| 48 | + null, | |
| 49 | + ); | |
| 45 | 50 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( |
| 46 | 51 | reference, |
| 47 | 52 | { wait: 450 }, |
| 48 | 53 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 49 | 54 | ); |
| 55 | + const resetPreparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom); | |
| 50 | 56 | |
| 51 | 57 | useEffect(() => { |
| 52 | 58 | if (!open) return; |
| 53 | 59 | setReference(initialReference ?? ""); |
| 54 | 60 | setReferenceDirty(false); |
| 55 | 61 | setPreparingMode(null); |
| 56 | − }, [initialReference, open]); | |
| 62 | + setPrepareFollowupErrorMessage(null); | |
| 63 | + resetPreparePullRequestThread(Atom.Reset); | |
| 64 | + }, [initialReference, open, resetPreparePullRequestThread]); | |
| 57 | 65 | |
| 58 | 66 | useEffect(() => { |
| 59 | 67 | if (!open) return; |
| @@ -68,41 +76,35 @@ export function PullRequestThreadDialog({ · 8 unmodified lines | ||
| 68 | 76 | |
| 69 | 77 | const parsedReference = parsePullRequestReference(reference); |
| 70 | 78 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 71 | − const resolvePullRequestQuery = useQuery( | |
| 72 | − gitResolvePullRequestQueryOptions({ | |
| 73 | − cwd, | |
| 79 | + const resolvePullRequestResult = useAtomValue( | |
| 80 | + gitResolvePullRequestAtom({ | |
| 81 | + cwd: open ? cwd : null, | |
| 74 | 82 | reference: open ? parsedDebouncedReference : null, |
| 75 | 83 | }), |
| 76 | 84 | ); |
| 77 | − const cachedPullRequest = useMemo(() => { | |
| 78 | − if (!cwd || !parsedReference) { | |
| 79 | − return null; | |
| 80 | − } | |
| 81 | − const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ | |
| 82 | − "git", | |
| 83 | − "pull-request", | |
| 84 | − cwd, | |
| 85 | − parsedReference, | |
| 86 | − ]); | |
| 87 | − return cached?.pullRequest ?? null; | |
| 88 | − }, [cwd, parsedReference, queryClient]); | |
| 89 | − const preparePullRequestThreadMutation = useMutation( | |
| 90 | − gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), | |
| 85 | + const preparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom, { | |
| 86 | + mode: "promise", | |
| 87 | + }); | |
| 88 | + const preparePullRequestThreadResult = useAtomValue(gitPreparePullRequestThreadMutationAtom); | |
| 89 | + const resolvePullRequestData = Option.getOrUndefined(AsyncResult.value(resolvePullRequestResult)); | |
| 90 | + const resolvePullRequestError = Option.getOrNull( | |
| 91 | + Option.map(AsyncResult.cause(resolvePullRequestResult), (cause) => Cause.squash(cause)), | |
| 91 | 92 | ); |
| 93 | + const isPreparingPullRequestThread = preparingMode !== null; | |
| 92 | 94 | |
| 93 | 95 | const liveResolvedPullRequest = |
| 94 | 96 | parsedReference !== null && parsedReference === parsedDebouncedReference |
| 95 | − ? (resolvePullRequestQuery.data?.pullRequest ?? null) | |
| 97 | + ? (resolvePullRequestData?.pullRequest ?? null) | |
| 96 | 98 | : null; |
| 97 | − const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; | |
| 99 | + const resolvedPullRequest = liveResolvedPullRequest; | |
| 98 | 100 | const isResolving = |
| 99 | 101 | open && |
| 100 | 102 | parsedReference !== null && |
| 101 | 103 | resolvedPullRequest === null && |
| 102 | 104 | (referenceDebouncer.state.isPending || |
| 103 | 105 | parsedReference !== parsedDebouncedReference || |
| 104 | − resolvePullRequestQuery.isPending || | |
| 105 | − resolvePullRequestQuery.isFetching); | |
| 106 | + AsyncResult.isInitial(resolvePullRequestResult) || | |
| 107 | + resolvePullRequestResult.waiting); | |
| 106 | 108 | const statusTone = useMemo(() => { |
| 107 | 109 | switch (resolvedPullRequest?.state) { |
| 108 | 110 | case "merged": |
| @@ -115,6 +117,11 @@ export function PullRequestThreadDialog({ · 6 unmodified lines | ||
| 115 | 117 | return "text-muted-foreground"; |
| 116 | 118 | } |
| 117 | 119 | }, [resolvedPullRequest?.state]); |
| 120 | + const preparePullRequestThreadErrorMessage = AsyncResult.match(preparePullRequestThreadResult, { | |
| 121 | + onInitial: () => null, | |
| 122 | + onSuccess: () => null, | |
| 123 | + onFailure: (error) => Cause.pretty(error.cause), | |
| 124 | + }); | |
| 118 | 125 | |
| 119 | 126 | const handleConfirm = useCallback( |
| 120 | 127 | async (mode: "local" | "worktree") => { |
| @@ -125,29 +132,36 @@ export function PullRequestThreadDialog({ · 4 unmodified lines | ||
| 125 | 132 | if (!parsedReference || !resolvedPullRequest || !cwd) { |
| 126 | 133 | return; |
| 127 | 134 | } |
| 135 | + setPrepareFollowupErrorMessage(null); | |
| 128 | 136 | setPreparingMode(mode); |
| 129 | 137 | try { |
| 130 | − const result = await preparePullRequestThreadMutation.mutateAsync({ | |
| 131 | − reference: parsedReference, | |
| 132 | − mode, | |
| 138 | + const result = await preparePullRequestThread({ | |
| 139 | + payload: { | |
| 140 | + cwd, | |
| 141 | + reference: parsedReference, | |
| 142 | + mode, | |
| 143 | + }, | |
| 144 | + reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | |
| 133 | 145 | }); |
| 134 | − await onPrepared({ | |
| 135 | − branch: result.branch, | |
| 136 | − worktreePath: result.worktreePath, | |
| 137 | − }); | |
| 138 | − onOpenChange(false); | |
| 146 | + | |
| 147 | + try { | |
| 148 | + await onPrepared({ | |
| 149 | + branch: result.branch, | |
| 150 | + worktreePath: result.worktreePath, | |
| 151 | + }); | |
| 152 | + onOpenChange(false); | |
| 153 | + } catch (error) { | |
| 154 | + setPrepareFollowupErrorMessage( | |
| 155 | + toErrorMessage(error, "Failed to prepare pull request thread."), | |
| 156 | + ); | |
| 157 | + } | |
| 158 | + } catch { | |
| 159 | + return; | |
| 139 | 160 | } finally { |
| 140 | 161 | setPreparingMode(null); |
| 141 | 162 | } |
| 142 | 163 | }, |
| 143 | − [ | |
| 144 | − cwd, | |
| 145 | − onOpenChange, | |
| 146 | − onPrepared, | |
| 147 | − parsedReference, | |
| 148 | − preparePullRequestThreadMutation, | |
| 149 | − resolvedPullRequest, | |
| 150 | − ], | |
| 164 | + [cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThread, resolvedPullRequest], | |
| 151 | 165 | ); |
| 152 | 166 | |
| 153 | 167 | const validationMessage = !referenceDirty |
| @@ -159,21 +173,17 @@ export function PullRequestThreadDialog({ · 5 unmodified lines | ||
| 159 | 173 | : null; |
| 160 | 174 | const errorMessage = |
| 161 | 175 | validationMessage ?? |
| 162 | − (resolvedPullRequest === null && resolvePullRequestQuery.isError | |
| 163 | − ? resolvePullRequestQuery.error instanceof Error | |
| 164 | − ? resolvePullRequestQuery.error.message | |
| 176 | + (resolvedPullRequest === null && AsyncResult.isFailure(resolvePullRequestResult) | |
| 177 | + ? resolvePullRequestError instanceof Error | |
| 178 | + ? resolvePullRequestError.message | |
| 165 | 179 | : "Failed to resolve pull request." |
| 166 | − : preparePullRequestThreadMutation.error instanceof Error | |
| 167 | − ? preparePullRequestThreadMutation.error.message | |
| 168 | − : preparePullRequestThreadMutation.error | |
| 169 | − ? "Failed to prepare pull request thread." | |
| 170 | − : null); | |
| 180 | + : (prepareFollowupErrorMessage ?? preparePullRequestThreadErrorMessage)); | |
| 171 | 181 | |
| 172 | 182 | return ( |
| 173 | 183 | <Dialog |
| 174 | 184 | open={open} |
| 175 | 185 | onOpenChange={(nextOpen) => { |
| 176 | − if (!preparePullRequestThreadMutation.isPending) { | |
| 186 | + if (!isPreparingPullRequestThread) { | |
| 177 | 187 | onOpenChange(nextOpen); |
| 178 | 188 | } |
| 179 | 189 | }} |
| @@ -202,7 +212,7 @@ export function PullRequestThreadDialog({ · 22 unmodified lines | ||
| 202 | 212 | return; |
| 203 | 213 | } |
| 204 | 214 | event.preventDefault(); |
| 205 | − if (!isResolving && !preparePullRequestThreadMutation.isPending) { | |
| 215 | + if (!isResolving && !isPreparingPullRequestThread) { | |
| 206 | 216 | void handleConfirm("local"); |
| 207 | 217 | } |
| 208 | 218 | }} |
| @@ -241,7 +251,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines | ||
| 241 | 251 | variant="outline" |
| 242 | 252 | size="sm" |
| 243 | 253 | onClick={() => onOpenChange(false)} |
| 244 | − disabled={preparePullRequestThreadMutation.isPending} | |
| 254 | + disabled={isPreparingPullRequestThread} | |
| 245 | 255 | > |
| 246 | 256 | Cancel |
| 247 | 257 | </Button> |
| @@ -252,12 +262,7 @@ export function PullRequestThreadDialog({ · 4 unmodified lines | ||
| 252 | 262 | onClick={() => { |
| 253 | 263 | void handleConfirm("local"); |
| 254 | 264 | }} |
| 255 | − disabled={ | |
| 256 | − !cwd || | |
| 257 | − !resolvedPullRequest || | |
| 258 | − isResolving || | |
| 259 | − preparePullRequestThreadMutation.isPending | |
| 260 | − } | |
| 265 | + disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread} | |
| 261 | 266 | > |
| 262 | 267 | {preparingMode === "local" ? "Preparing local..." : "Local"} |
| 263 | 268 | </Button> |
| @@ -267,12 +272,7 @@ export function PullRequestThreadDialog({ · 3 unmodified lines | ||
| 267 | 272 | onClick={() => { |
| 268 | 273 | void handleConfirm("worktree"); |
| 269 | 274 | }} |
| 270 | − disabled={ | |
| 271 | − !cwd || | |
| 272 | − !resolvedPullRequest || | |
| 273 | − isResolving || | |
| 274 | − preparePullRequestThreadMutation.isPending | |
| 275 | − } | |
| 275 | + disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread} | |
| 276 | 276 | > |
| 277 | 277 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} |
| 278 | 278 | </Button> |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "lucide-react"; |
| 13 | 13 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 14 | 14 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | +import { Option } from "effect"; | |
| 16 | +import { AsyncResult } from "effect/unstable/reactivity"; | |
| 15 | 17 | import { |
| 16 | 18 | useCallback, |
| 17 | 19 | useEffect, |
| @@ -44,7 +46,6 @@ import { · 26 unmodified lines | ||
| 44 | 46 | ThreadId, |
| 45 | 47 | type GitStatusResult, |
| 46 | 48 | } from "@t3tools/contracts"; |
| 47 | −import { useQueries } from "@tanstack/react-query"; | |
| 48 | 49 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 49 | 50 | import { |
| 50 | 51 | type SidebarProjectSortOrder, |
| @@ -65,8 +66,8 @@ import { · 14 unmodified lines | ||
| 65 | 66 | threadTraversalDirectionFromCommand, |
| 66 | 67 | } from "../keybindings"; |
| 67 | 68 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 68 | −import { gitStatusQueryOptions } from "../lib/gitReactQuery"; | |
| 69 | 69 | import { readNativeApi } from "../nativeApi"; |
| 70 | +import { useGitStatuses } from "../rpc/gitAtoms"; | |
| 70 | 71 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 71 | 72 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 72 | 73 | |
| @@ -556,19 +557,15 @@ export default function Sidebar() { · 483 unmodified lines | ||
| 556 | 557 | ], |
| 557 | 558 | [threadGitTargets], |
| 558 | 559 | ); |
| 559 | − const threadGitStatusQueries = useQueries({ | |
| 560 | − queries: threadGitStatusCwds.map((cwd) => ({ | |
| 561 | − ...gitStatusQueryOptions(cwd), | |
| 562 | − staleTime: 30_000, | |
| 563 | − refetchInterval: 60_000, | |
| 564 | − })), | |
| 565 | − }); | |
| 560 | + const threadGitStatusResults = useGitStatuses(threadGitStatusCwds); | |
| 566 | 561 | const prByThreadId = useMemo(() => { |
| 567 | 562 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 568 | 563 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 569 | 564 | const cwd = threadGitStatusCwds[index]; |
| 570 | 565 | if (!cwd) continue; |
| 571 | − const status = threadGitStatusQueries[index]?.data; | |
| 566 | + const status = threadGitStatusResults[index] | |
| 567 | + ? Option.getOrUndefined(AsyncResult.value(threadGitStatusResults[index]!)) | |
| 568 | + : undefined; | |
| 572 | 569 | if (status) { |
| 573 | 570 | statusByCwd.set(cwd, status); |
| 574 | 571 | } |
| @@ -582,7 +579,7 @@ export default function Sidebar() { · 7 unmodified lines | ||
| 582 | 579 | map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); |
| 583 | 580 | } |
| 584 | 581 | return map; |
| 585 | − }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); | |
| 582 | + }, [threadGitStatusCwds, threadGitStatusResults, threadGitTargets]); | |
| 586 | 583 | |
| 587 | 584 | const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { |
| 588 | 585 | event.preventDefault(); |
| @@ -1,14 +1,15 @@ | ||
| 1 | +import { useAtomSet } from "@effect/atom-react"; | |
| 1 | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | −import { useMutation, useQueryClient } from "@tanstack/react-query"; | |
| 3 | 3 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | 4 | import { useCallback } from "react"; |
| 5 | 5 | |
| 6 | 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 7 | 7 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 8 | 8 | import { useHandleNewThread } from "./useHandleNewThread"; |
| 9 | −import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | |
| 10 | 9 | import { newCommandId } from "../lib/utils"; |
| 11 | 10 | import { readNativeApi } from "../nativeApi"; |
| 11 | +import { gitRemoveWorktreeMutationAtom } from "../rpc/gitAtoms"; | |
| 12 | +import { REACTIVITY_KEYS } from "../rpc/client"; | |
| 12 | 13 | import { useStore } from "../store"; |
| 13 | 14 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 14 | 15 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| @@ -28,8 +29,7 @@ export function useThreadActions() { · 13 unmodified lines | ||
| 28 | 29 | }); |
| 29 | 30 | const navigate = useNavigate(); |
| 30 | 31 | const { handleNewThread } = useHandleNewThread(); |
| 31 | − const queryClient = useQueryClient(); | |
| 32 | − const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | |
| 32 | + const removeWorktree = useAtomSet(gitRemoveWorktreeMutationAtom, { mode: "promise" }); | |
| 33 | 33 | |
| 34 | 34 | const archiveThread = useCallback( |
| 35 | 35 | async (threadId: ThreadId) => { |
| @@ -144,10 +144,16 @@ export function useThreadActions() { · 108 unmodified lines | ||
| 144 | 144 | } |
| 145 | 145 | |
| 146 | 146 | try { |
| 147 | − await removeWorktreeMutation.mutateAsync({ | |
| 148 | − cwd: threadProject.cwd, | |
| 149 | − path: orphanedWorktreePath, | |
| 150 | − force: true, | |
| 147 | + await removeWorktree({ | |
| 148 | + payload: { | |
| 149 | + cwd: threadProject.cwd, | |
| 150 | + path: orphanedWorktreePath, | |
| 151 | + force: true, | |
| 152 | + }, | |
| 153 | + reactivityKeys: [ | |
| 154 | + REACTIVITY_KEYS.git(threadProject.cwd), | |
| 155 | + REACTIVITY_KEYS.project(threadProject.cwd), | |
| 156 | + ], | |
| 151 | 157 | }); |
| 152 | 158 | } catch (error) { |
| 153 | 159 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; |
| @@ -170,7 +176,7 @@ export function useThreadActions() { · 16 unmodified lines | ||
| 170 | 176 | clearTerminalState, |
| 171 | 177 | appSettings.sidebarThreadSortOrder, |
| 172 | 178 | navigate, |
| 173 | − removeWorktreeMutation, | |
| 179 | + removeWorktree, | |
| 174 | 180 | routeThreadId, |
| 175 | 181 | ], |
| 176 | 182 | ); |
| @@ -1,51 +0,0 @@ | ||
| 1 | −import { QueryClient } from "@tanstack/react-query"; | |
| 2 | −import { describe, expect, it } from "vitest"; | |
| 3 | −import { | |
| 4 | − gitMutationKeys, | |
| 5 | − gitPreparePullRequestThreadMutationOptions, | |
| 6 | − gitPullMutationOptions, | |
| 7 | − gitRunStackedActionMutationOptions, | |
| 8 | −} from "./gitReactQuery"; | |
| 9 | − | |
| 10 | −describe("gitMutationKeys", () => { | |
| 11 | − it("scopes stacked action keys by cwd", () => { | |
| 12 | − expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( | |
| 13 | − gitMutationKeys.runStackedAction("/repo/b"), | |
| 14 | − ); | |
| 15 | − }); | |
| 16 | − | |
| 17 | − it("scopes pull keys by cwd", () => { | |
| 18 | − expect(gitMutationKeys.pull("/repo/a")).not.toEqual(gitMutationKeys.pull("/repo/b")); | |
| 19 | − }); | |
| 20 | − | |
| 21 | − it("scopes pull request thread preparation keys by cwd", () => { | |
| 22 | − expect(gitMutationKeys.preparePullRequestThread("/repo/a")).not.toEqual( | |
| 23 | − gitMutationKeys.preparePullRequestThread("/repo/b"), | |
| 24 | − ); | |
| 25 | − }); | |
| 26 | −}); | |
| 27 | − | |
| 28 | −describe("git mutation options", () => { | |
| 29 | − const queryClient = new QueryClient(); | |
| 30 | − | |
| 31 | − it("attaches cwd-scoped mutation key for runStackedAction", () => { | |
| 32 | − const options = gitRunStackedActionMutationOptions({ | |
| 33 | − cwd: "/repo/a", | |
| 34 | − queryClient, | |
| 35 | − }); | |
| 36 | − expect(options.mutationKey).toEqual(gitMutationKeys.runStackedAction("/repo/a")); | |
| 37 | − }); | |
| 38 | − | |
| 39 | − it("attaches cwd-scoped mutation key for pull", () => { | |
| 40 | − const options = gitPullMutationOptions({ cwd: "/repo/a", queryClient }); | |
| 41 | − expect(options.mutationKey).toEqual(gitMutationKeys.pull("/repo/a")); | |
| 42 | − }); | |
| 43 | − | |
| 44 | − it("attaches cwd-scoped mutation key for preparePullRequestThread", () => { | |
| 45 | − const options = gitPreparePullRequestThreadMutationOptions({ | |
| 46 | − cwd: "/repo/a", | |
| 47 | − queryClient, | |
| 48 | − }); | |
| 49 | − expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a")); | |
| 50 | − }); | |
| 51 | −}); | |
| @@ -1,224 +0,0 @@ | ||
| 1 | −import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; | |
| 2 | −import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; | |
| 3 | −import { ensureNativeApi } from "../nativeApi"; | |
| 4 | −import { getWsRpcClient } from "../wsRpcClient"; | |
| 5 | − | |
| 6 | −const GIT_STATUS_STALE_TIME_MS = 5_000; | |
| 7 | −const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; | |
| 8 | −const GIT_BRANCHES_STALE_TIME_MS = 15_000; | |
| 9 | −const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | |
| 10 | − | |
| 11 | −export const gitQueryKeys = { | |
| 12 | − all: ["git"] as const, | |
| 13 | − status: (cwd: string | null) => ["git", "status", cwd] as const, | |
| 14 | − branches: (cwd: string | null) => ["git", "branches", cwd] as const, | |
| 15 | −}; | |
| 16 | − | |
| 17 | −export const gitMutationKeys = { | |
| 18 | − init: (cwd: string | null) => ["git", "mutation", "init", cwd] as const, | |
| 19 | − checkout: (cwd: string | null) => ["git", "mutation", "checkout", cwd] as const, | |
| 20 | − runStackedAction: (cwd: string | null) => ["git", "mutation", "run-stacked-action", cwd] as const, | |
| 21 | − pull: (cwd: string | null) => ["git", "mutation", "pull", cwd] as const, | |
| 22 | − preparePullRequestThread: (cwd: string | null) => | |
| 23 | − ["git", "mutation", "prepare-pull-request-thread", cwd] as const, | |
| 24 | −}; | |
| 25 | − | |
| 26 | −export function invalidateGitQueries(queryClient: QueryClient) { | |
| 27 | − return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | |
| 28 | −} | |
| 29 | − | |
| 30 | −export function gitStatusQueryOptions(cwd: string | null) { | |
| 31 | − return queryOptions({ | |
| 32 | − queryKey: gitQueryKeys.status(cwd), | |
| 33 | − queryFn: async () => { | |
| 34 | − const api = ensureNativeApi(); | |
| 35 | − if (!cwd) throw new Error("Git status is unavailable."); | |
| 36 | − return api.git.status({ cwd }); | |
| 37 | − }, | |
| 38 | − enabled: cwd !== null, | |
| 39 | − staleTime: GIT_STATUS_STALE_TIME_MS, | |
| 40 | − refetchOnWindowFocus: "always", | |
| 41 | − refetchOnReconnect: "always", | |
| 42 | − refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, | |
| 43 | − }); | |
| 44 | −} | |
| 45 | − | |
| 46 | −export function gitBranchesQueryOptions(cwd: string | null) { | |
| 47 | − return queryOptions({ | |
| 48 | − queryKey: gitQueryKeys.branches(cwd), | |
| 49 | − queryFn: async () => { | |
| 50 | − const api = ensureNativeApi(); | |
| 51 | − if (!cwd) throw new Error("Git branches are unavailable."); | |
| 52 | − return api.git.listBranches({ cwd }); | |
| 53 | − }, | |
| 54 | − enabled: cwd !== null, | |
| 55 | − staleTime: GIT_BRANCHES_STALE_TIME_MS, | |
| 56 | − refetchOnWindowFocus: true, | |
| 57 | − refetchOnReconnect: true, | |
| 58 | − refetchInterval: GIT_BRANCHES_REFETCH_INTERVAL_MS, | |
| 59 | − }); | |
| 60 | −} | |
| 61 | − | |
| 62 | −export function gitResolvePullRequestQueryOptions(input: { | |
| 63 | − cwd: string | null; | |
| 64 | − reference: string | null; | |
| 65 | −}) { | |
| 66 | − return queryOptions({ | |
| 67 | − queryKey: ["git", "pull-request", input.cwd, input.reference] as const, | |
| 68 | − queryFn: async () => { | |
| 69 | − const api = ensureNativeApi(); | |
| 70 | − if (!input.cwd || !input.reference) { | |
| 71 | − throw new Error("Pull request lookup is unavailable."); | |
| 72 | − } | |
| 73 | − return api.git.resolvePullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 74 | − }, | |
| 75 | − enabled: input.cwd !== null && input.reference !== null, | |
| 76 | − staleTime: 30_000, | |
| 77 | − refetchOnWindowFocus: false, | |
| 78 | − refetchOnReconnect: false, | |
| 79 | − }); | |
| 80 | −} | |
| 81 | − | |
| 82 | −export function gitInitMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 83 | − return mutationOptions({ | |
| 84 | − mutationKey: gitMutationKeys.init(input.cwd), | |
| 85 | − mutationFn: async () => { | |
| 86 | − const api = ensureNativeApi(); | |
| 87 | − if (!input.cwd) throw new Error("Git init is unavailable."); | |
| 88 | − return api.git.init({ cwd: input.cwd }); | |
| 89 | − }, | |
| 90 | − onSuccess: async () => { | |
| 91 | − await invalidateGitQueries(input.queryClient); | |
| 92 | − }, | |
| 93 | − }); | |
| 94 | −} | |
| 95 | − | |
| 96 | −export function gitCheckoutMutationOptions(input: { | |
| 97 | − cwd: string | null; | |
| 98 | − queryClient: QueryClient; | |
| 99 | −}) { | |
| 100 | − return mutationOptions({ | |
| 101 | − mutationKey: gitMutationKeys.checkout(input.cwd), | |
| 102 | − mutationFn: async (branch: string) => { | |
| 103 | − const api = ensureNativeApi(); | |
| 104 | − if (!input.cwd) throw new Error("Git checkout is unavailable."); | |
| 105 | − return api.git.checkout({ cwd: input.cwd, branch }); | |
| 106 | − }, | |
| 107 | − onSuccess: async () => { | |
| 108 | − await invalidateGitQueries(input.queryClient); | |
| 109 | − }, | |
| 110 | − }); | |
| 111 | −} | |
| 112 | − | |
| 113 | −export function gitRunStackedActionMutationOptions(input: { | |
| 114 | − cwd: string | null; | |
| 115 | − queryClient: QueryClient; | |
| 116 | −}) { | |
| 117 | − return mutationOptions({ | |
| 118 | − mutationKey: gitMutationKeys.runStackedAction(input.cwd), | |
| 119 | − mutationFn: async ({ | |
| 120 | − actionId, | |
| 121 | − action, | |
| 122 | − commitMessage, | |
| 123 | − featureBranch, | |
| 124 | − filePaths, | |
| 125 | − onProgress, | |
| 126 | − }: { | |
| 127 | − actionId: string; | |
| 128 | − action: GitStackedAction; | |
| 129 | − commitMessage?: string; | |
| 130 | − featureBranch?: boolean; | |
| 131 | − filePaths?: string[]; | |
| 132 | − onProgress?: (event: GitActionProgressEvent) => void; | |
| 133 | − }) => { | |
| 134 | − if (!input.cwd) throw new Error("Git action is unavailable."); | |
| 135 | − return getWsRpcClient().git.runStackedAction( | |
| 136 | − { | |
| 137 | − actionId, | |
| 138 | − cwd: input.cwd, | |
| 139 | − action, | |
| 140 | − ...(commitMessage ? { commitMessage } : {}), | |
| 141 | − ...(featureBranch ? { featureBranch } : {}), | |
| 142 | − ...(filePaths ? { filePaths } : {}), | |
| 143 | − }, | |
| 144 | − ...(onProgress ? [{ onProgress }] : []), | |
| 145 | − ); | |
| 146 | − }, | |
| 147 | − onSettled: async () => { | |
| 148 | − await invalidateGitQueries(input.queryClient); | |
| 149 | − }, | |
| 150 | − }); | |
| 151 | −} | |
| 152 | − | |
| 153 | −export function gitPullMutationOptions(input: { cwd: string | null; queryClient: QueryClient }) { | |
| 154 | − return mutationOptions({ | |
| 155 | − mutationKey: gitMutationKeys.pull(input.cwd), | |
| 156 | − mutationFn: async () => { | |
| 157 | − const api = ensureNativeApi(); | |
| 158 | − if (!input.cwd) throw new Error("Git pull is unavailable."); | |
| 159 | − return api.git.pull({ cwd: input.cwd }); | |
| 160 | − }, | |
| 161 | − onSettled: async () => { | |
| 162 | − await invalidateGitQueries(input.queryClient); | |
| 163 | − }, | |
| 164 | − }); | |
| 165 | −} | |
| 166 | − | |
| 167 | −export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 168 | − return mutationOptions({ | |
| 169 | − mutationFn: async ({ | |
| 170 | − cwd, | |
| 171 | − branch, | |
| 172 | − newBranch, | |
| 173 | − path, | |
| 174 | − }: { | |
| 175 | − cwd: string; | |
| 176 | − branch: string; | |
| 177 | − newBranch: string; | |
| 178 | − path?: string | null; | |
| 179 | − }) => { | |
| 180 | − const api = ensureNativeApi(); | |
| 181 | − if (!cwd) throw new Error("Git worktree creation is unavailable."); | |
| 182 | − return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | |
| 183 | − }, | |
| 184 | − mutationKey: ["git", "mutation", "create-worktree"] as const, | |
| 185 | − onSettled: async () => { | |
| 186 | − await invalidateGitQueries(input.queryClient); | |
| 187 | − }, | |
| 188 | − }); | |
| 189 | −} | |
| 190 | − | |
| 191 | −export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { | |
| 192 | − return mutationOptions({ | |
| 193 | − mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { | |
| 194 | − const api = ensureNativeApi(); | |
| 195 | − if (!cwd) throw new Error("Git worktree removal is unavailable."); | |
| 196 | − return api.git.removeWorktree({ cwd, path, force }); | |
| 197 | − }, | |
| 198 | − mutationKey: ["git", "mutation", "remove-worktree"] as const, | |
| 199 | − onSettled: async () => { | |
| 200 | − await invalidateGitQueries(input.queryClient); | |
| 201 | − }, | |
| 202 | − }); | |
| 203 | −} | |
| 204 | − | |
| 205 | −export function gitPreparePullRequestThreadMutationOptions(input: { | |
| 206 | − cwd: string | null; | |
| 207 | − queryClient: QueryClient; | |
| 208 | −}) { | |
| 209 | − return mutationOptions({ | |
| 210 | − mutationFn: async ({ reference, mode }: { reference: string; mode: "local" | "worktree" }) => { | |
| 211 | − const api = ensureNativeApi(); | |
| 212 | − if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); | |
| 213 | − return api.git.preparePullRequestThread({ | |
| 214 | − cwd: input.cwd, | |
| 215 | − reference, | |
| 216 | − mode, | |
| 217 | − }); | |
| 218 | − }, | |
| 219 | − mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), | |
| 220 | − onSettled: async () => { | |
| 221 | − await invalidateGitQueries(input.queryClient); | |
| 222 | − }, | |
| 223 | − }); | |
| 224 | −} | |
| @@ -2,7 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, WS_METHODS } from "@t3tools/contracts"; · 1 unmodified line | ||
| 2 | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | −import { __resetWsRpcAtomClientForTests, runRpc, WsRpcAtomClient } from "./client"; | |
| 5 | +import { WsRpcAtomClient } from "./client"; | |
| 6 | 6 | |
| 7 | 7 | type WsEventType = "open" | "message" | "close" | "error"; |
| 8 | 8 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; |
| @@ -110,62 +110,11 @@ beforeEach(() => { · 101 unmodified lines | ||
| 110 | 110 | }); |
| 111 | 111 | |
| 112 | 112 | afterEach(() => { |
| 113 | − __resetWsRpcAtomClientForTests(); | |
| 114 | 113 | globalThis.WebSocket = originalWebSocket; |
| 115 | 114 | vi.restoreAllMocks(); |
| 116 | 115 | }); |
| 117 | 116 | |
| 118 | 117 | describe("WsRpcAtomClient", () => { |
| 119 | − it("runs unary requests through the AtomRpc service", async () => { | |
| 120 | − const expectedSettings = { | |
| 121 | − ...DEFAULT_SERVER_SETTINGS, | |
| 122 | − enableAssistantStreaming: true, | |
| 123 | − defaultThreadEnvMode: "worktree" as const, | |
| 124 | − textGenerationModelSelection: { | |
| 125 | − provider: "codex" as const, | |
| 126 | − model: "gpt-5.4", | |
| 127 | − }, | |
| 128 | − providers: { | |
| 129 | − codex: { | |
| 130 | − ...DEFAULT_SERVER_SETTINGS.providers.codex, | |
| 131 | − homePath: "/tmp/codex-home", | |
| 132 | − }, | |
| 133 | − claudeAgent: { | |
| 134 | − ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, | |
| 135 | − enabled: false, | |
| 136 | − }, | |
| 137 | − }, | |
| 138 | − }; | |
| 139 | − const requestPromise = runRpc((client) => client(WS_METHODS.serverGetSettings, {})); | |
| 140 | − | |
| 141 | − await waitFor(() => { | |
| 142 | − expect(sockets).toHaveLength(1); | |
| 143 | − }); | |
| 144 | − | |
| 145 | − const socket = getSocket(); | |
| 146 | − socket.open(); | |
| 147 | − | |
| 148 | − await waitFor(() => { | |
| 149 | − expect(socket.sent).toHaveLength(1); | |
| 150 | − }); | |
| 151 | − | |
| 152 | − const requestMessage = JSON.parse(socket.sent[0] ?? "{}") as { id: string; tag: string }; | |
| 153 | − expect(requestMessage.tag).toBe(WS_METHODS.serverGetSettings); | |
| 154 | − | |
| 155 | − socket.serverMessage( | |
| 156 | − JSON.stringify({ | |
| 157 | − _tag: "Exit", | |
| 158 | − requestId: requestMessage.id, | |
| 159 | − exit: { | |
| 160 | − _tag: "Success", | |
| 161 | − value: expectedSettings, | |
| 162 | − }, | |
| 163 | − }), | |
| 164 | − ); | |
| 165 | − | |
| 166 | − await expect(requestPromise).resolves.toEqual(expectedSettings); | |
| 167 | − }); | |
| 168 | − | |
| 169 | 118 | it("exposes atom-backed query state for unary RPC methods", async () => { |
| 170 | 119 | const expectedSettings = { |
| 171 | 120 | ...DEFAULT_SERVER_SETTINGS, |
| @@ -1,33 +1,31 @@ | ||
| 1 | 1 | import { WsRpcGroup } from "@t3tools/contracts"; |
| 2 | −import { Effect, Layer, ManagedRuntime } from "effect"; | |
| 2 | +import { Layer } from "effect"; | |
| 3 | 3 | import { AtomRpc } from "effect/unstable/reactivity"; |
| 4 | 4 | |
| 5 | 5 | import { createWsRpcProtocolLayer } from "./protocol"; |
| 6 | 6 | |
| 7 | +import { Atom } from "effect/unstable/reactivity"; | |
| 8 | +import { Duration } from "effect"; | |
| 9 | + | |
| 10 | +export const REACTIVITY_KEYS = { | |
| 11 | + git: (cwd: string) => `git:${cwd}` as const, | |
| 12 | + project: (cwd: string) => `project:${cwd}` as const, | |
| 13 | +} as const; | |
| 14 | + | |
| 15 | +export const refreshIntervalSignalAtom = Atom.family((interval: Duration.Duration) => | |
| 16 | + Atom.readable((get) => { | |
| 17 | + let count = 0; | |
| 18 | + const intervalId = window.setInterval(() => { | |
| 19 | + get.setSelf(++count); | |
| 20 | + }, Duration.toMillis(interval)); | |
| 21 | + get.addFinalizer(() => { | |
| 22 | + window.clearInterval(intervalId); | |
| 23 | + }); | |
| 24 | + return count; | |
| 25 | + }), | |
| 26 | +); | |
| 27 | + | |
| 7 | 28 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { |
| 8 | 29 | group: WsRpcGroup, |
| 9 | 30 | protocol: Layer.suspend(() => createWsRpcProtocolLayer()), |
| 10 | 31 | }) {} |
| 11 | − | |
| 12 | −let sharedRuntime: ManagedRuntime.ManagedRuntime<WsRpcAtomClient, never> | null = null; | |
| 13 | − | |
| 14 | −function getRuntime() { | |
| 15 | − if (sharedRuntime !== null) { | |
| 16 | − return sharedRuntime; | |
| 17 | − } | |
| 18 | − | |
| 19 | − sharedRuntime = ManagedRuntime.make(WsRpcAtomClient.layer); | |
| 20 | − return sharedRuntime; | |
| 21 | −} | |
| 22 | − | |
| 23 | −export function runRpc<TSuccess, TError = never>( | |
| 24 | − execute: (client: typeof WsRpcAtomClient.Service) => Effect.Effect<TSuccess, TError, never>, | |
| 25 | −): Promise<TSuccess> { | |
| 26 | − return getRuntime().runPromise(WsRpcAtomClient.use(execute)); | |
| 27 | −} | |
| 28 | − | |
| 29 | −export async function __resetWsRpcAtomClientForTests() { | |
| 30 | − const runtime = sharedRuntime; | |
| 31 | − sharedRuntime = null; | |
| 32 | − await runtime?.dispose(); | |
| 33 | −} | |
| @@ -0,0 +1,170 @@ | ||
| 1 | +import { | |
| 2 | + type GitActionProgressEvent, | |
| 3 | + type GitListBranchesResult, | |
| 4 | + type GitResolvePullRequestResult, | |
| 5 | + type GitRunStackedActionInput, | |
| 6 | + type GitRunStackedActionResult, | |
| 7 | + type GitStatusResult, | |
| 8 | + WS_METHODS, | |
| 9 | +} from "@t3tools/contracts"; | |
| 10 | +import { Duration, Effect, Stream } from "effect"; | |
| 11 | +import { AsyncResult, Atom, Reactivity } from "effect/unstable/reactivity"; | |
| 12 | + | |
| 13 | +import { refreshIntervalSignalAtom, WsRpcAtomClient } from "./client"; | |
| 14 | +import { REACTIVITY_KEYS } from "./client"; | |
| 15 | +import { useMemo } from "react"; | |
| 16 | +import { useAtomValue } from "@effect/atom-react"; | |
| 17 | + | |
| 18 | +const GIT_ACTION_RESULT_MISSING_ERROR = { | |
| 19 | + _tag: "GitActionResultMissing", | |
| 20 | + message: "Git action stream completed without a final result.", | |
| 21 | +} as const; | |
| 22 | +export const gitRunStackedActionProgressAtom = Atom.make<GitActionProgressEvent | null>(null).pipe( | |
| 23 | + Atom.keepAlive, | |
| 24 | + Atom.withLabel("git-run-stacked-action-progress"), | |
| 25 | +); | |
| 26 | + | |
| 27 | +const gitStatusQueryAtom = Atom.family((cwd: string) => | |
| 28 | + WsRpcAtomClient.query( | |
| 29 | + WS_METHODS.gitStatus, | |
| 30 | + { cwd }, | |
| 31 | + { | |
| 32 | + timeToLive: "5 minutes", | |
| 33 | + reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | |
| 34 | + }, | |
| 35 | + ), | |
| 36 | +); | |
| 37 | + | |
| 38 | +export const gitStatusAtom = Atom.family((cwd: string | null) => | |
| 39 | + Atom.readable( | |
| 40 | + (get): AsyncResult.AsyncResult<GitStatusResult, unknown> => { | |
| 41 | + if (cwd === null) { | |
| 42 | + return AsyncResult.initial(); | |
| 43 | + } | |
| 44 | + return get(gitStatusQueryAtom(cwd)); | |
| 45 | + }, | |
| 46 | + (refresh) => { | |
| 47 | + if (cwd !== null) { | |
| 48 | + refresh(gitStatusQueryAtom(cwd)); | |
| 49 | + } | |
| 50 | + }, | |
| 51 | + ), | |
| 52 | +); | |
| 53 | + | |
| 54 | +const gitBranchesQueryAtom = Atom.family((cwd: string) => | |
| 55 | + WsRpcAtomClient.query( | |
| 56 | + WS_METHODS.gitListBranches, | |
| 57 | + { cwd }, | |
| 58 | + { | |
| 59 | + timeToLive: "5 minutes", | |
| 60 | + reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | |
| 61 | + }, | |
| 62 | + ), | |
| 63 | +); | |
| 64 | + | |
| 65 | +export const gitBranchesAtom = Atom.family((cwd: string | null) => | |
| 66 | + Atom.readable( | |
| 67 | + (get): AsyncResult.AsyncResult<GitListBranchesResult, unknown> => { | |
| 68 | + if (cwd === null) { | |
| 69 | + return AsyncResult.initial(); | |
| 70 | + } | |
| 71 | + return get(gitBranchesQueryAtom(cwd)); | |
| 72 | + }, | |
| 73 | + (refresh) => { | |
| 74 | + if (cwd !== null) { | |
| 75 | + refresh(gitBranchesQueryAtom(cwd)); | |
| 76 | + } | |
| 77 | + }, | |
| 78 | + ), | |
| 79 | +); | |
| 80 | + | |
| 81 | +export const gitCheckoutMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCheckout); | |
| 82 | + | |
| 83 | +export const gitCreateBranchMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateBranch); | |
| 84 | + | |
| 85 | +export const gitCreateWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitCreateWorktree); | |
| 86 | + | |
| 87 | +export const gitInitMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitInit); | |
| 88 | + | |
| 89 | +export const gitPreparePullRequestThreadMutationAtom = WsRpcAtomClient.mutation( | |
| 90 | + WS_METHODS.gitPreparePullRequestThread, | |
| 91 | +); | |
| 92 | + | |
| 93 | +export const gitPullMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitPull); | |
| 94 | + | |
| 95 | +export const gitRemoveWorktreeMutationAtom = WsRpcAtomClient.mutation(WS_METHODS.gitRemoveWorktree); | |
| 96 | + | |
| 97 | +export const gitRunStackedActionMutationAtom = WsRpcAtomClient.runtime | |
| 98 | + .fn<GitRunStackedActionInput>()( | |
| 99 | + Effect.fn( | |
| 100 | + function* (input, get) { | |
| 101 | + get.set(gitRunStackedActionProgressAtom, null); | |
| 102 | + const client = yield* WsRpcAtomClient; | |
| 103 | + return yield* client(WS_METHODS.gitRunStackedAction, input).pipe( | |
| 104 | + Stream.runFoldEffect( | |
| 105 | + () => null as GitRunStackedActionResult | null, | |
| 106 | + (result, event) => | |
| 107 | + Effect.sync(() => { | |
| 108 | + get.set(gitRunStackedActionProgressAtom, event); | |
| 109 | + return event.kind === "action_finished" ? event.result : result; | |
| 110 | + }), | |
| 111 | + ), | |
| 112 | + Effect.flatMap((result) => | |
| 113 | + result !== null ? Effect.succeed(result) : Effect.fail(GIT_ACTION_RESULT_MISSING_ERROR), | |
| 114 | + ), | |
| 115 | + ); | |
| 116 | + }, | |
| 117 | + (effect, input) => | |
| 118 | + Effect.ensuring(effect, Reactivity.invalidate([REACTIVITY_KEYS.git(input.cwd)])), | |
| 119 | + ), | |
| 120 | + ) | |
| 121 | + .pipe(Atom.withLabel("git-run-stacked-action")); | |
| 122 | + | |
| 123 | +const gitResolvePullRequestQueryAtom = Atom.family( | |
| 124 | + (key: { readonly cwd: string; readonly reference: string }) => | |
| 125 | + WsRpcAtomClient.query(WS_METHODS.gitResolvePullRequest, key, { | |
| 126 | + timeToLive: "30 minutes", | |
| 127 | + reactivityKeys: [REACTIVITY_KEYS.git(key.cwd)], | |
| 128 | + }), | |
| 129 | +); | |
| 130 | + | |
| 131 | +export const gitResolvePullRequestAtom = Atom.family( | |
| 132 | + (input: { readonly cwd: string | null; readonly reference: string | null }) => | |
| 133 | + Atom.readable( | |
| 134 | + (get): AsyncResult.AsyncResult<GitResolvePullRequestResult, unknown> => { | |
| 135 | + if (input.cwd === null || input.reference === null) { | |
| 136 | + return AsyncResult.initial(); | |
| 137 | + } | |
| 138 | + return get( | |
| 139 | + gitResolvePullRequestQueryAtom({ | |
| 140 | + cwd: input.cwd, | |
| 141 | + reference: input.reference, | |
| 142 | + }), | |
| 143 | + ); | |
| 144 | + }, | |
| 145 | + (refresh) => { | |
| 146 | + if (input.cwd !== null && input.reference !== null) { | |
| 147 | + refresh( | |
| 148 | + gitResolvePullRequestQueryAtom({ | |
| 149 | + cwd: input.cwd, | |
| 150 | + reference: input.reference, | |
| 151 | + }), | |
| 152 | + ); | |
| 153 | + } | |
| 154 | + }, | |
| 155 | + ), | |
| 156 | +); | |
| 157 | + | |
| 158 | +export function useGitStatuses( | |
| 159 | + cwds: ReadonlyArray<string>, | |
| 160 | +): ReadonlyArray<AsyncResult.AsyncResult<GitStatusResult, unknown>> { | |
| 161 | + const atoms = useMemo(() => { | |
| 162 | + const refreshInterval = refreshIntervalSignalAtom(Duration.seconds(60)); | |
| 163 | + return cwds.map((cwd) => gitStatusAtom(cwd).pipe(Atom.makeRefreshOnSignal(refreshInterval))); | |
| 164 | + }, [cwds]); | |
| 165 | + const statusesAtom = useMemo( | |
| 166 | + () => Atom.readable((get) => atoms.map((atom) => get(atom))), | |
| 167 | + [atoms], | |
| 168 | + ); | |
| 169 | + return useAtomValue(statusesAtom); | |
| 170 | +} | |
| @@ -55,7 +55,6 @@ const rpcClientMock = { · 54 unmodified lines | ||
| 55 | 55 | git: { |
| 56 | 56 | pull: vi.fn(), |
| 57 | 57 | status: vi.fn(), |
| 58 | − runStackedAction: vi.fn(), | |
| 59 | 58 | listBranches: vi.fn(), |
| 60 | 59 | createWorktree: vi.fn(), |
| 61 | 60 | removeWorktree: vi.fn(), |
| @@ -1,7 +1,4 @@ | ||
| 1 | 1 | import { |
| 2 | − type GitActionProgressEvent, | |
| 3 | − type GitRunStackedActionInput, | |
| 4 | − type GitRunStackedActionResult, | |
| 5 | 2 | type NativeApi, |
| 6 | 3 | ORCHESTRATION_WS_METHODS, |
| 7 | 4 | type ServerSettingsPatch, |
| @@ -31,10 +28,6 @@ type RpcStreamMethod<TTag extends RpcTag> = · 23 unmodified lines | ||
| 31 | 28 | ? (listener: (event: TEvent) => void) => () => void |
| 32 | 29 | : never; |
| 33 | 30 | |
| 34 | −interface GitRunStackedActionOptions { | |
| 35 | − readonly onProgress?: (event: GitActionProgressEvent) => void; | |
| 36 | −} | |
| 37 | − | |
| 38 | 31 | export interface WsRpcClient { |
| 39 | 32 | readonly dispose: () => Promise<void>; |
| 40 | 33 | readonly terminal: { |
| @@ -59,10 +52,6 @@ export interface WsRpcClient { · 18 unmodified lines | ||
| 59 | 52 | readonly git: { |
| 60 | 53 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; |
| 61 | 54 | readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>; |
| 62 | − readonly runStackedAction: ( | |
| 63 | − input: GitRunStackedActionInput, | |
| 64 | − options?: GitRunStackedActionOptions, | |
| 65 | − ) => Promise<GitRunStackedActionResult>; | |
| 66 | 55 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; |
| 67 | 56 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; |
| 68 | 57 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; |
| @@ -136,25 +125,6 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { · 67 unmodified lines | ||
| 136 | 125 | git: { |
| 137 | 126 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), |
| 138 | 127 | status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), |
| 139 | − runStackedAction: async (input, options) => { | |
| 140 | − let result: GitRunStackedActionResult | null = null; | |
| 141 | − | |
| 142 | − await transport.requestStream( | |
| 143 | − (client) => client[WS_METHODS.gitRunStackedAction](input), | |
| 144 | − (event) => { | |
| 145 | − options?.onProgress?.(event); | |
| 146 | − if (event.kind === "action_finished") { | |
| 147 | − result = event.result; | |
| 148 | − } | |
| 149 | − }, | |
| 150 | − ); | |
| 151 | − | |
| 152 | − if (result) { | |
| 153 | − return result; | |
| 154 | − } | |
| 155 | − | |
| 156 | − throw new Error("Git action stream completed without a final result."); | |
| 157 | − }, | |
| 158 | 128 | listBranches: (input) => |
| 159 | 129 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), |
| 160 | 130 | createWorktree: (input) => |