Unified · Split
| @@ -1,6 +1,8 @@ | |||
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 1 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { useQuery, useQueryClient } from "@tanstack/react-query"; | 2 | import { type GitBranch } from "@t3tools/contracts"; |
| 3 | import { useVirtualizer } from "@tanstack/react-virtual"; | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 4 | import { ChevronDownIcon } from "lucide-react"; | 6 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | import { | 7 | import { |
| 6 | type CSSProperties, | 8 | type CSSProperties, |
| @@ -14,14 +16,14 @@ import { · 7 unmodified lines | |||
| 14 | useTransition, | 16 | useTransition, |
| 15 | } from "react"; | 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 | import { parsePullRequestReference } from "../pullRequestReference"; | 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 | import { | 27 | import { |
| 26 | dedupeRemoteBranchesWithLocalMatches, | 28 | dedupeRemoteBranchesWithLocalMatches, |
| 27 | deriveLocalBranchNameFromRemoteRef, | 29 | deriveLocalBranchNameFromRemoteRef, |
| @@ -84,19 +86,23 @@ export function BranchToolbarBranchSelector({ · 56 unmodified lines | |||
| 84 | onCheckoutPullRequestRequest, | 86 | onCheckoutPullRequestRequest, |
| 85 | onComposerFocusRequest, | 87 | onComposerFocusRequest, |
| 86 | }: BranchToolbarBranchSelectorProps) { | 88 | }: BranchToolbarBranchSelectorProps) { |
| 87 | const queryClient = useQueryClient(); | ||
| 88 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 89 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| 89 | const [branchQuery, setBranchQuery] = useState(""); | 90 | const [branchQuery, setBranchQuery] = useState(""); |
| 90 | const deferredBranchQuery = useDeferredValue(branchQuery); | 91 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 91 | 92 | const branchesResult = useAtomValue(gitBranchesAtom(branchCwd)); | |
| 92 | const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); | 93 | const branchStatusResult = useAtomValue(gitStatusAtom(branchCwd)); |
| 93 | const branchStatusQuery = useQuery(gitStatusQueryOptions(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 | const branches = useMemo( | 100 | const branches = useMemo( |
| 95 | () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), | 101 | () => dedupeRemoteBranchesWithLocalMatches(branchesData?.branches ?? []), |
| 96 | [branchesQuery.data?.branches], | 102 | [branchesData?.branches], |
| 97 | ); | 103 | ); |
| 98 | const currentGitBranch = | 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 | const canonicalActiveBranch = resolveBranchToolbarValue({ | 106 | const canonicalActiveBranch = resolveBranchToolbarValue({ |
| 101 | envMode: effectiveEnvMode, | 107 | envMode: effectiveEnvMode, |
| 102 | activeWorktreePath, | 108 | activeWorktreePath, |
| @@ -155,18 +161,18 @@ export function BranchToolbarBranchSelector({ · 52 unmodified lines | |||
| 155 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, | 161 | (_currentBranch: string | null, optimisticBranch: string | null) => optimisticBranch, |
| 156 | ); | 162 | ); |
| 157 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); | 163 | const [isBranchActionPending, startBranchActionTransition] = useTransition(); |
| 164 | const isBranchesLoading = | ||
| 165 | branches.length === 0 && (AsyncResult.isInitial(branchesResult) || branchesResult.waiting); | ||
| 158 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; | 166 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 159 | 167 | ||
| 160 | const runBranchAction = (action: () => Promise<void>) => { | 168 | const runBranchAction = (action: () => Promise<void>) => { |
| 161 | startBranchActionTransition(async () => { | 169 | startBranchActionTransition(async () => { |
| 162 | await action().catch(() => undefined); | 170 | await action().catch(() => undefined); |
| 163 | await invalidateGitQueries(queryClient).catch(() => undefined); | ||
| 164 | }); | 171 | }); |
| 165 | }; | 172 | }; |
| 166 | 173 | ||
| 167 | const selectBranch = (branch: GitBranch) => { | 174 | const selectBranch = (branch: GitBranch) => { |
| 168 | const api = readNativeApi(); | 175 | if (!branchCwd || isBranchActionPending) return; |
| 169 | if (!api || !branchCwd || isBranchActionPending) return; | ||
| 170 | 176 | ||
| 171 | // In new-worktree mode, selecting a branch sets the base branch. | 177 | // In new-worktree mode, selecting a branch sets the base branch. |
| 172 | if (isSelectingWorktreeBase) { | 178 | if (isSelectingWorktreeBase) { |
| @@ -200,8 +206,13 @@ export function BranchToolbarBranchSelector({ · 27 unmodified lines | |||
| 200 | runBranchAction(async () => { | 206 | runBranchAction(async () => { |
| 201 | setOptimisticBranch(selectedBranchName); | 207 | setOptimisticBranch(selectedBranchName); |
| 202 | try { | 208 | try { |
| 203 | await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); | 209 | await checkoutBranch({ |
| 204 | await invalidateGitQueries(queryClient); | 210 | payload: { |
| 211 | cwd: selectionTarget.checkoutCwd, | ||
| 212 | branch: branch.name, | ||
| 213 | }, | ||
| 214 | reactivityKeys: [REACTIVITY_KEYS.git(selectionTarget.checkoutCwd)], | ||
| 215 | }); | ||
| 205 | } catch (error) { | 216 | } catch (error) { |
| 206 | toastManager.add({ | 217 | toastManager.add({ |
| 207 | type: "error", | 218 | type: "error", |
| @@ -211,23 +222,14 @@ export function BranchToolbarBranchSelector({ · 3 unmodified lines | |||
| 211 | return; | 222 | return; |
| 212 | } | 223 | } |
| 213 | 224 | ||
| 214 | let nextBranchName = selectedBranchName; | 225 | setOptimisticBranch(selectedBranchName); |
| 215 | if (branch.isRemote) { | 226 | onSetThreadBranch(selectedBranchName, selectionTarget.nextWorktreePath); |
| 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); | ||
| 224 | }); | 227 | }); |
| 225 | }; | 228 | }; |
| 226 | 229 | ||
| 227 | const createBranch = (rawName: string) => { | 230 | const createBranch = (rawName: string) => { |
| 228 | const name = rawName.trim(); | 231 | const name = rawName.trim(); |
| 229 | const api = readNativeApi(); | 232 | if (!branchCwd || !name || isBranchActionPending) return; |
| 230 | if (!api || !branchCwd || !name || isBranchActionPending) return; | ||
| 231 | 233 | ||
| 232 | setIsBranchMenuOpen(false); | 234 | setIsBranchMenuOpen(false); |
| 233 | onComposerFocusRequest?.(); | 235 | onComposerFocusRequest?.(); |
| @@ -236,9 +238,14 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines | |||
| 236 | setOptimisticBranch(name); | 238 | setOptimisticBranch(name); |
| 237 | 239 | ||
| 238 | try { | 240 | try { |
| 239 | await api.git.createBranch({ cwd: branchCwd, branch: name }); | 241 | await createGitBranch({ |
| 242 | payload: { cwd: branchCwd, branch: name }, | ||
| 243 | }); | ||
| 240 | try { | 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 | } catch (error) { | 249 | } catch (error) { |
| 243 | toastManager.add({ | 250 | toastManager.add({ |
| 244 | type: "error", | 251 | type: "error", |
| @@ -287,11 +294,12 @@ export function BranchToolbarBranchSelector({ · 42 unmodified lines | |||
| 287 | setBranchQuery(""); | 294 | setBranchQuery(""); |
| 288 | return; | 295 | return; |
| 289 | } | 296 | } |
| 290 | void queryClient.invalidateQueries({ | 297 | if (branchCwd) { |
| 291 | queryKey: gitQueryKeys.branches(branchCwd), | 298 | refreshBranches(); |
| 292 | }); | 299 | refreshBranchStatus(); |
| 300 | } | ||
| 293 | }, | 301 | }, |
| 294 | [branchCwd, queryClient], | 302 | [branchCwd, refreshBranchStatus, refreshBranches], |
| 295 | ); | 303 | ); |
| 296 | 304 | ||
| 297 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); | 305 | const branchListScrollElementRef = useRef<HTMLDivElement | null>(null); |
| @@ -425,7 +433,7 @@ export function BranchToolbarBranchSelector({ · 127 unmodified lines | |||
| 425 | <ComboboxTrigger | 433 | <ComboboxTrigger |
| 426 | render={<Button variant="ghost" size="xs" />} | 434 | render={<Button variant="ghost" size="xs" />} |
| 427 | className="text-muted-foreground/70 hover:text-foreground/80" | 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 | <span className="max-w-[240px] truncate">{triggerLabel}</span> | 438 | <span className="max-w-[240px] truncate">{triggerLabel}</span> |
| 431 | <ChevronDownIcon /> | 439 | <ChevronDownIcon /> |
| @@ -32,6 +32,7 @@ import { · 31 unmodified lines | |||
| 32 | import { isMacPlatform } from "../lib/utils"; | 32 | import { isMacPlatform } from "../lib/utils"; |
| 33 | import { __resetNativeApiForTests } from "../nativeApi"; | 33 | import { __resetNativeApiForTests } from "../nativeApi"; |
| 34 | import { getRouter } from "../router"; | 34 | import { getRouter } from "../router"; |
| 35 | import { resetAppAtomRegistryForTests } from "../rpc/atomRegistry"; | ||
| 35 | import { useStore } from "../store"; | 36 | import { useStore } from "../store"; |
| 36 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; | 37 | import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test/wsRpcHarness"; |
| 37 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 38 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| @@ -1121,6 +1122,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 1083 unmodified lines | |||
| 1121 | }, | 1122 | }, |
| 1122 | }); | 1123 | }); |
| 1123 | __resetNativeApiForTests(); | 1124 | __resetNativeApiForTests(); |
| 1125 | resetAppAtomRegistryForTests(); | ||
| 1124 | await setViewport(DEFAULT_VIEWPORT); | 1126 | await setViewport(DEFAULT_VIEWPORT); |
| 1125 | localStorage.clear(); | 1127 | localStorage.clear(); |
| 1126 | document.body.innerHTML = ""; | 1128 | document.body.innerHTML = ""; |
| @@ -1143,6 +1145,7 @@ describe("ChatView timeline estimator parity (full app)", () => { · 16 unmodified lines | |||
| 1143 | afterEach(() => { | 1145 | afterEach(() => { |
| 1144 | customWsRpcResolver = null; | 1146 | customWsRpcResolver = null; |
| 1145 | document.body.innerHTML = ""; | 1147 | document.body.innerHTML = ""; |
| 1148 | resetAppAtomRegistryForTests(); | ||
| 1146 | }); | 1149 | }); |
| 1147 | 1150 | ||
| 1148 | it.each(TEXT_VIEWPORT_MATRIX)( | 1151 | it.each(TEXT_VIEWPORT_MATRIX)( |
| @@ -1,3 +1,4 @@ | |||
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { | 2 | import { |
| 2 | type ApprovalRequestId, | 3 | type ApprovalRequestId, |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| @@ -21,12 +22,13 @@ import { · 17 unmodified lines | |||
| 21 | TerminalOpenInput, | 22 | TerminalOpenInput, |
| 22 | } from "@t3tools/contracts"; | 23 | } from "@t3tools/contracts"; |
| 23 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; | 24 | import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; |
| 25 | import * as Option from "effect/Option"; | ||
| 24 | import { truncate } from "@t3tools/shared/String"; | 26 | import { truncate } from "@t3tools/shared/String"; |
| 25 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 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 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 29 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 28 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 30 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 29 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; | 31 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 30 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 32 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 31 | import { isElectron } from "../env"; | 33 | import { isElectron } from "../env"; |
| 32 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 34 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| @@ -106,6 +108,8 @@ import { Separator } from "./ui/separator"; · 73 unmodified lines | |||
| 106 | import { cn, randomUUID } from "~/lib/utils"; | 108 | import { cn, randomUUID } from "~/lib/utils"; |
| 107 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 109 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 108 | import { toastManager } from "./ui/toast"; | 110 | import { toastManager } from "./ui/toast"; |
| 111 | import { gitBranchesAtom, gitCreateWorktreeMutationAtom } from "~/rpc/gitAtoms"; | ||
| 112 | import { REACTIVITY_KEYS } from "~/rpc/client"; | ||
| 109 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; | 113 | import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; |
| 110 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; | 114 | import { type NewProjectScriptInput } from "./ProjectScriptsControl"; |
| 111 | import { | 115 | import { |
| @@ -423,8 +427,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 311 unmodified lines | |||
| 423 | select: (params) => parseDiffRouteSearch(params), | 427 | select: (params) => parseDiffRouteSearch(params), |
| 424 | }); | 428 | }); |
| 425 | const { resolvedTheme } = useTheme(); | 429 | const { resolvedTheme } = useTheme(); |
| 426 | const queryClient = useQueryClient(); | 430 | const createWorktree = useAtomSet(gitCreateWorktreeMutationAtom, { mode: "promise" }); |
| 427 | const createWorktreeMutation = useMutation(gitCreateWorktreeMutationOptions({ queryClient })); | ||
| 428 | const composerDraft = useComposerThreadDraft(threadId); | 431 | const composerDraft = useComposerThreadDraft(threadId); |
| 429 | const prompt = composerDraft.prompt; | 432 | const prompt = composerDraft.prompt; |
| 430 | const composerImages = composerDraft.images; | 433 | const composerImages = composerDraft.images; |
| @@ -1201,7 +1204,8 @@ export default function ChatView({ threadId }: ChatViewProps) { · 770 unmodified lines | |||
| 1201 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 1204 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1202 | ); | 1205 | ); |
| 1203 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 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 | const keybindings = useServerKeybindings(); | 1209 | const keybindings = useServerKeybindings(); |
| 1206 | const availableEditors = useServerAvailableEditors(); | 1210 | const availableEditors = useServerAvailableEditors(); |
| 1207 | const modelOptionsByProvider = useMemo( | 1211 | const modelOptionsByProvider = useMemo( |
| @@ -1338,7 +1342,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 130 unmodified lines | |||
| 1338 | }); | 1342 | }); |
| 1339 | }, [activeProjectCwd, activeThreadWorktreePath]); | 1343 | }, [activeProjectCwd, activeThreadWorktreePath]); |
| 1340 | // Default true while loading to avoid toolbar flicker. | 1344 | // Default true while loading to avoid toolbar flicker. |
| 1341 | const isGitRepo = branchesQuery.data?.isRepo ?? true; | 1345 | const isGitRepo = branches?.isRepo ?? true; |
| 1342 | const terminalShortcutLabelOptions = useMemo( | 1346 | const terminalShortcutLabelOptions = useMemo( |
| 1343 | () => ({ | 1347 | () => ({ |
| 1344 | context: { | 1348 | context: { |
| @@ -2787,10 +2791,17 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1442 unmodified lines | |||
| 2787 | if (baseBranchForWorktree) { | 2791 | if (baseBranchForWorktree) { |
| 2788 | beginLocalDispatch({ preparingWorktree: true }); | 2792 | beginLocalDispatch({ preparingWorktree: true }); |
| 2789 | const newBranch = buildTemporaryWorktreeBranchName(); | 2793 | const newBranch = buildTemporaryWorktreeBranchName(); |
| 2790 | const result = await createWorktreeMutation.mutateAsync({ | 2794 | const result = await createWorktree({ |
| 2791 | cwd: activeProject.cwd, | 2795 | payload: { |
| 2792 | branch: baseBranchForWorktree, | 2796 | cwd: activeProject.cwd, |
| 2793 | newBranch, | 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 | nextThreadBranch = result.worktree.branch; | 2806 | nextThreadBranch = result.worktree.branch; |
| 2796 | nextThreadWorktreePath = result.worktree.path; | 2807 | nextThreadWorktreePath = result.worktree.path; |
| @@ -1,8 +1,11 @@ | |||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 1 | import { parsePatchFiles } from "@pierre/diffs"; | 2 | import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | 3 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; |
| 3 | import { useQuery } from "@tanstack/react-query"; | 4 | import { useQuery } from "@tanstack/react-query"; |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | 5 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; |
| 5 | import { ThreadId, type TurnId } from "@t3tools/contracts"; | 6 | import { ThreadId, type TurnId } from "@t3tools/contracts"; |
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 6 | import { | 9 | import { |
| 7 | ChevronLeftIcon, | 10 | ChevronLeftIcon, |
| 8 | ChevronRightIcon, | 11 | ChevronRightIcon, |
| @@ -19,10 +22,10 @@ import { · 10 unmodified lines | |||
| 19 | useState, | 22 | useState, |
| 20 | } from "react"; | 23 | } from "react"; |
| 21 | import { openInPreferredEditor } from "../editorPreferences"; | 24 | import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; | ||
| 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 25 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | import { cn } from "~/lib/utils"; | 26 | import { cn } from "~/lib/utils"; |
| 25 | import { readNativeApi } from "../nativeApi"; | 27 | import { readNativeApi } from "../nativeApi"; |
| 28 | import { gitBranchesAtom } from "../rpc/gitAtoms"; | ||
| 26 | import { resolvePathLinkTarget } from "../terminal-links"; | 29 | import { resolvePathLinkTarget } from "../terminal-links"; |
| 27 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 30 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| 28 | import { useTheme } from "../hooks/useTheme"; | 31 | import { useTheme } from "../hooks/useTheme"; |
| @@ -189,8 +192,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { · 160 unmodified lines | |||
| 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | 192 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 190 | ); | 193 | ); |
| 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 194 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); | 195 | const gitBranchesResult = useAtomValue(gitBranchesAtom(activeCwd ?? null)); |
| 193 | const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; | 196 | const gitBranches = Option.getOrUndefined(AsyncResult.value(gitBranchesResult)); |
| 197 | const isGitRepo = gitBranches?.isRepo ?? true; | ||
| 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 198 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 195 | useTurnDiffSummaries(activeThread); | 199 | useTurnDiffSummaries(activeThread); |
| 196 | const orderedTurnDiffSummaries = useMemo( | 200 | const orderedTurnDiffSummaries = useMemo( |
| @@ -1,10 +1,13 @@ | |||
| 1 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 1 | import type { | 2 | import type { |
| 2 | GitActionProgressEvent, | 3 | GitRunStackedActionInput, |
| 3 | GitStackedAction, | 4 | GitStackedAction, |
| 4 | GitStatusResult, | 5 | GitStatusResult, |
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 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 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 11 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 9 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 12 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 10 | import { GitHubIcon } from "./Icons"; | 13 | import { GitHubIcon } from "./Icons"; |
| @@ -38,18 +41,18 @@ import { ScrollArea } from "~/components/ui/scroll-area"; · 27 unmodified lines | |||
| 38 | import { Textarea } from "~/components/ui/textarea"; | 41 | import { Textarea } from "~/components/ui/textarea"; |
| 39 | import { toastManager } from "~/components/ui/toast"; | 42 | import { toastManager } from "~/components/ui/toast"; |
| 40 | import { openInPreferredEditor } from "~/editorPreferences"; | 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 | import { randomUUID } from "~/lib/utils"; | 44 | import { randomUUID } from "~/lib/utils"; |
| 51 | import { resolvePathLinkTarget } from "~/terminal-links"; | 45 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | import { readNativeApi } from "~/nativeApi"; | 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 | interface GitActionsControlProps { | 57 | interface GitActionsControlProps { |
| 55 | gitCwd: string | null; | 58 | gitCwd: string | null; |
| @@ -112,6 +115,13 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | · 56 unmodified lines | |||
| 112 | return formatElapsedDescription(progress.hookStartedAtMs ?? progress.phaseStartedAtMs); | 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 | function getMenuActionDisabledReason({ | 125 | function getMenuActionDisabledReason({ |
| 116 | item, | 126 | item, |
| 117 | gitStatus, | 127 | gitStatus, |
| @@ -208,7 +218,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 90 unmodified lines | |||
| 208 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), | 218 | () => (activeThreadId ? { threadId: activeThreadId } : undefined), |
| 209 | [activeThreadId], | 219 | [activeThreadId], |
| 210 | ); | 220 | ); |
| 211 | const queryClient = useQueryClient(); | ||
| 212 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); | 221 | const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); |
| 213 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); | 222 | const [dialogCommitMessage, setDialogCommitMessage] = useState(""); |
| 214 | const [excludedFiles, setExcludedFiles] = useState<ReadonlySet<string>>(new Set()); | 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 | }, [threadToastData]); | 241 | }, [threadToastData]); |
| 233 | 242 | ||
| 234 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | 243 | const gitStatusResult = useAtomValue(gitStatusAtom(gitCwd)); |
| 235 | 244 | const branchesResult = useAtomValue(gitBranchesAtom(gitCwd)); | |
| 236 | const { data: branchList = null } = useQuery(gitBranchesQueryOptions(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 | // Default to true while loading so we don't flash init controls. | 259 | // Default to true while loading so we don't flash init controls. |
| 238 | const isRepo = branchList?.isRepo ?? true; | 260 | const isRepo = branchList?.isRepo ?? true; |
| 239 | const hasOriginRemote = branchList?.hasOriginRemote ?? false; | 261 | const hasOriginRemote = branchList?.hasOriginRemote ?? false; |
| @@ -242,9 +264,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 2 unmodified lines | |||
| 242 | !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; | 264 | !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; |
| 243 | 265 | ||
| 244 | useEffect(() => { | 266 | useEffect(() => { |
| 245 | if (!isGitStatusOutOfSync) return; | 267 | if (!isGitStatusOutOfSync || !gitCwd) return; |
| 246 | void invalidateGitQueries(queryClient); | 268 | refreshGitStatus(); |
| 247 | }, [isGitStatusOutOfSync, queryClient]); | 269 | refreshGitBranches(); |
| 270 | }, [gitCwd, isGitStatusOutOfSync, refreshGitBranches, refreshGitStatus]); | ||
| 248 | 271 | ||
| 249 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; | 272 | const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; |
| 250 | 273 | ||
| @@ -252,21 +275,40 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 1 unmodified line | |||
| 252 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); | 275 | const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); |
| 253 | const allSelected = excludedFiles.size === 0; | 276 | const allSelected = excludedFiles.size === 0; |
| 254 | const noneSelected = selectedFiles.length === 0; | 277 | const noneSelected = selectedFiles.length === 0; |
| 255 | 278 | const isRunStackedActionRunning = runStackedActionResult.waiting; | |
| 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; | ||
| 269 | const isGitActionRunning = isRunStackedActionRunning || isPullRunning; | 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 | const isDefaultBranch = useMemo(() => { | 312 | const isDefaultBranch = useMemo(() => { |
| 271 | const branchName = gitStatusForActions?.branch; | 313 | const branchName = gitStatusForActions?.branch; |
| 272 | if (!branchName) return false; | 314 | if (!branchName) return false; |
| @@ -307,6 +349,59 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 34 unmodified lines | |||
| 307 | }; | 349 | }; |
| 308 | }, [updateActiveProgressToast]); | 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 | const openExistingPr = useCallback(async () => { | 405 | const openExistingPr = useCallback(async () => { |
| 311 | const api = readNativeApi(); | 406 | const api = readNativeApi(); |
| 312 | if (!api) { | 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) => { | 513 | if (!gitCwd) { |
| 419 | const progress = activeGitActionProgressRef.current; | 514 | return; |
| 420 | if (!progress) { | 515 | } |
| 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 | }; | ||
| 473 | 516 | ||
| 474 | const promise = runImmediateGitActionMutation.mutateAsync({ | 517 | const promise = runImmediateGitAction({ |
| 475 | actionId, | 518 | actionId, |
| 519 | cwd: gitCwd, | ||
| 476 | action, | 520 | action, |
| 477 | ...(commitMessage ? { commitMessage } : {}), | 521 | ...(commitMessage ? { commitMessage } : {}), |
| 478 | ...(featureBranch ? { featureBranch } : {}), | 522 | ...(featureBranch ? { featureBranch } : {}), |
| 479 | ...(filePaths ? { filePaths } : {}), | 523 | ...(filePaths ? { filePaths } : {}), |
| 480 | onProgress: applyProgressEvent, | ||
| 481 | }); | 524 | }); |
| 482 | 525 | ||
| 483 | try { | 526 | try { |
| @@ -624,7 +667,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 140 unmodified lines | |||
| 624 | return; | 667 | return; |
| 625 | } | 668 | } |
| 626 | if (quickAction.kind === "run_pull") { | 669 | if (quickAction.kind === "run_pull") { |
| 627 | const promise = pullMutation.mutateAsync(); | 670 | if (!gitCwd) { |
| 671 | return; | ||
| 672 | } | ||
| 673 | const promise = pullCurrentBranch(gitCwd); | ||
| 628 | toastManager.promise(promise, { | 674 | toastManager.promise(promise, { |
| 629 | loading: { title: "Pulling...", data: threadToastData }, | 675 | loading: { title: "Pulling...", data: threadToastData }, |
| 630 | success: (result) => ({ | 676 | success: (result) => ({ |
| @@ -656,7 +702,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 25 unmodified lines | |||
| 656 | if (quickAction.action) { | 702 | if (quickAction.action) { |
| 657 | void runGitActionWithToast({ action: quickAction.action }); | 703 | void runGitActionWithToast({ action: quickAction.action }); |
| 658 | } | 704 | } |
| 659 | }, [openExistingPr, pullMutation, quickAction, threadToastData]); | 705 | }, [gitCwd, openExistingPr, pullCurrentBranch, quickAction, threadToastData]); |
| 660 | 706 | ||
| 661 | const openDialogForMenuItem = useCallback( | 707 | const openDialogForMenuItem = useCallback( |
| 662 | (item: GitActionMenuItem) => { | 708 | (item: GitActionMenuItem) => { |
| @@ -733,10 +779,15 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 70 unmodified lines | |||
| 733 | <Button | 779 | <Button |
| 734 | variant="outline" | 780 | variant="outline" |
| 735 | size="xs" | 781 | size="xs" |
| 736 | disabled={initMutation.isPending} | 782 | disabled={isInitPending} |
| 737 | onClick={() => initMutation.mutate()} | 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 | </Button> | 791 | </Button> |
| 741 | ) : ( | 792 | ) : ( |
| 742 | <Group aria-label="Git actions" className="shrink-0"> | 793 | <Group aria-label="Git actions" className="shrink-0"> |
| @@ -778,7 +829,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions · 35 unmodified lines | |||
| 778 | <GroupSeparator className="hidden @3xl/header-actions:block" /> | 829 | <GroupSeparator className="hidden @3xl/header-actions:block" /> |
| 779 | <Menu | 830 | <Menu |
| 780 | onOpenChange={(open) => { | 831 | onOpenChange={(open) => { |
| 781 | if (open) void invalidateGitQueries(queryClient); | 832 | if (open && gitCwd) { |
| 833 | refreshGitStatus(); | ||
| 834 | refreshGitBranches(); | ||
| 835 | } | ||
| 782 | }} | 836 | }} |
| 783 | > | 837 | > |
| 784 | <MenuTrigger | 838 | <MenuTrigger |
| @@ -1,14 +1,13 @@ | |||
| 1 | import type { GitResolvePullRequestResult } from "@t3tools/contracts"; | 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 2 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 2 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 3 | import { Cause, Option } from "effect"; | ||
| 4 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ||
| 4 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 5 | 6 | ||
| 6 | import { | ||
| 7 | gitPreparePullRequestThreadMutationOptions, | ||
| 8 | gitResolvePullRequestQueryOptions, | ||
| 9 | } from "~/lib/gitReactQuery"; | ||
| 10 | import { cn } from "~/lib/utils"; | 7 | import { cn } from "~/lib/utils"; |
| 11 | import { parsePullRequestReference } from "~/pullRequestReference"; | 8 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 9 | import { gitResolvePullRequestAtom, gitPreparePullRequestThreadMutationAtom } from "~/rpc/gitAtoms"; | ||
| 10 | import { REACTIVITY_KEYS } from "~/rpc/client"; | ||
| 12 | import { Button } from "./ui/button"; | 11 | import { Button } from "./ui/button"; |
| 13 | import { | 12 | import { |
| 14 | Dialog, | 13 | Dialog, |
| @@ -30,6 +29,10 @@ interface PullRequestThreadDialogProps { · 15 unmodified lines | |||
| 30 | onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise<void> | void; | 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 | export function PullRequestThreadDialog({ | 36 | export function PullRequestThreadDialog({ |
| 34 | open, | 37 | open, |
| 35 | cwd, | 38 | cwd, |
| @@ -37,23 +40,28 @@ export function PullRequestThreadDialog({ · 1 unmodified line | |||
| 37 | onOpenChange, | 40 | onOpenChange, |
| 38 | onPrepared, | 41 | onPrepared, |
| 39 | }: PullRequestThreadDialogProps) { | 42 | }: PullRequestThreadDialogProps) { |
| 40 | const queryClient = useQueryClient(); | ||
| 41 | const referenceInputRef = useRef<HTMLInputElement>(null); | 43 | const referenceInputRef = useRef<HTMLInputElement>(null); |
| 42 | const [reference, setReference] = useState(initialReference ?? ""); | 44 | const [reference, setReference] = useState(initialReference ?? ""); |
| 43 | const [referenceDirty, setReferenceDirty] = useState(false); | 45 | const [referenceDirty, setReferenceDirty] = useState(false); |
| 44 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); | 46 | const [preparingMode, setPreparingMode] = useState<"local" | "worktree" | null>(null); |
| 47 | const [prepareFollowupErrorMessage, setPrepareFollowupErrorMessage] = useState<string | null>( | ||
| 48 | null, | ||
| 49 | ); | ||
| 45 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( | 50 | const [debouncedReference, referenceDebouncer] = useDebouncedValue( |
| 46 | reference, | 51 | reference, |
| 47 | { wait: 450 }, | 52 | { wait: 450 }, |
| 48 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 53 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 49 | ); | 54 | ); |
| 55 | const resetPreparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom); | ||
| 50 | 56 | ||
| 51 | useEffect(() => { | 57 | useEffect(() => { |
| 52 | if (!open) return; | 58 | if (!open) return; |
| 53 | setReference(initialReference ?? ""); | 59 | setReference(initialReference ?? ""); |
| 54 | setReferenceDirty(false); | 60 | setReferenceDirty(false); |
| 55 | setPreparingMode(null); | 61 | setPreparingMode(null); |
| 56 | }, [initialReference, open]); | 62 | setPrepareFollowupErrorMessage(null); |
| 63 | resetPreparePullRequestThread(Atom.Reset); | ||
| 64 | }, [initialReference, open, resetPreparePullRequestThread]); | ||
| 57 | 65 | ||
| 58 | useEffect(() => { | 66 | useEffect(() => { |
| 59 | if (!open) return; | 67 | if (!open) return; |
| @@ -68,41 +76,35 @@ export function PullRequestThreadDialog({ · 8 unmodified lines | |||
| 68 | 76 | ||
| 69 | const parsedReference = parsePullRequestReference(reference); | 77 | const parsedReference = parsePullRequestReference(reference); |
| 70 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); | 78 | const parsedDebouncedReference = parsePullRequestReference(debouncedReference); |
| 71 | const resolvePullRequestQuery = useQuery( | 79 | const resolvePullRequestResult = useAtomValue( |
| 72 | gitResolvePullRequestQueryOptions({ | 80 | gitResolvePullRequestAtom({ |
| 73 | cwd, | 81 | cwd: open ? cwd : null, |
| 74 | reference: open ? parsedDebouncedReference : null, | 82 | reference: open ? parsedDebouncedReference : null, |
| 75 | }), | 83 | }), |
| 76 | ); | 84 | ); |
| 77 | const cachedPullRequest = useMemo(() => { | 85 | const preparePullRequestThread = useAtomSet(gitPreparePullRequestThreadMutationAtom, { |
| 78 | if (!cwd || !parsedReference) { | 86 | mode: "promise", |
| 79 | return null; | 87 | }); |
| 80 | } | 88 | const preparePullRequestThreadResult = useAtomValue(gitPreparePullRequestThreadMutationAtom); |
| 81 | const cached = queryClient.getQueryData<GitResolvePullRequestResult>([ | 89 | const resolvePullRequestData = Option.getOrUndefined(AsyncResult.value(resolvePullRequestResult)); |
| 82 | "git", | 90 | const resolvePullRequestError = Option.getOrNull( |
| 83 | "pull-request", | 91 | Option.map(AsyncResult.cause(resolvePullRequestResult), (cause) => Cause.squash(cause)), |
| 84 | cwd, | ||
| 85 | parsedReference, | ||
| 86 | ]); | ||
| 87 | return cached?.pullRequest ?? null; | ||
| 88 | }, [cwd, parsedReference, queryClient]); | ||
| 89 | const preparePullRequestThreadMutation = useMutation( | ||
| 90 | gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), | ||
| 91 | ); | 92 | ); |
| 93 | const isPreparingPullRequestThread = preparingMode !== null; | ||
| 92 | 94 | ||
| 93 | const liveResolvedPullRequest = | 95 | const liveResolvedPullRequest = |
| 94 | parsedReference !== null && parsedReference === parsedDebouncedReference | 96 | parsedReference !== null && parsedReference === parsedDebouncedReference |
| 95 | ? (resolvePullRequestQuery.data?.pullRequest ?? null) | 97 | ? (resolvePullRequestData?.pullRequest ?? null) |
| 96 | : null; | 98 | : null; |
| 97 | const resolvedPullRequest = liveResolvedPullRequest ?? cachedPullRequest; | 99 | const resolvedPullRequest = liveResolvedPullRequest; |
| 98 | const isResolving = | 100 | const isResolving = |
| 99 | open && | 101 | open && |
| 100 | parsedReference !== null && | 102 | parsedReference !== null && |
| 101 | resolvedPullRequest === null && | 103 | resolvedPullRequest === null && |
| 102 | (referenceDebouncer.state.isPending || | 104 | (referenceDebouncer.state.isPending || |
| 103 | parsedReference !== parsedDebouncedReference || | 105 | parsedReference !== parsedDebouncedReference || |
| 104 | resolvePullRequestQuery.isPending || | 106 | AsyncResult.isInitial(resolvePullRequestResult) || |
| 105 | resolvePullRequestQuery.isFetching); | 107 | resolvePullRequestResult.waiting); |
| 106 | const statusTone = useMemo(() => { | 108 | const statusTone = useMemo(() => { |
| 107 | switch (resolvedPullRequest?.state) { | 109 | switch (resolvedPullRequest?.state) { |
| 108 | case "merged": | 110 | case "merged": |
| @@ -115,6 +117,11 @@ export function PullRequestThreadDialog({ · 6 unmodified lines | |||
| 115 | return "text-muted-foreground"; | 117 | return "text-muted-foreground"; |
| 116 | } | 118 | } |
| 117 | }, [resolvedPullRequest?.state]); | 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 | const handleConfirm = useCallback( | 126 | const handleConfirm = useCallback( |
| 120 | async (mode: "local" | "worktree") => { | 127 | async (mode: "local" | "worktree") => { |
| @@ -125,29 +132,36 @@ export function PullRequestThreadDialog({ · 4 unmodified lines | |||
| 125 | if (!parsedReference || !resolvedPullRequest || !cwd) { | 132 | if (!parsedReference || !resolvedPullRequest || !cwd) { |
| 126 | return; | 133 | return; |
| 127 | } | 134 | } |
| 135 | setPrepareFollowupErrorMessage(null); | ||
| 128 | setPreparingMode(mode); | 136 | setPreparingMode(mode); |
| 129 | try { | 137 | try { |
| 130 | const result = await preparePullRequestThreadMutation.mutateAsync({ | 138 | const result = await preparePullRequestThread({ |
| 131 | reference: parsedReference, | 139 | payload: { |
| 132 | mode, | 140 | cwd, |
| 141 | reference: parsedReference, | ||
| 142 | mode, | ||
| 143 | }, | ||
| 144 | reactivityKeys: [REACTIVITY_KEYS.git(cwd)], | ||
| 133 | }); | 145 | }); |
| 134 | await onPrepared({ | 146 | |
| 135 | branch: result.branch, | 147 | try { |
| 136 | worktreePath: result.worktreePath, | 148 | await onPrepared({ |
| 137 | }); | 149 | branch: result.branch, |
| 138 | onOpenChange(false); | 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 | } finally { | 160 | } finally { |
| 140 | setPreparingMode(null); | 161 | setPreparingMode(null); |
| 141 | } | 162 | } |
| 142 | }, | 163 | }, |
| 143 | [ | 164 | [cwd, onOpenChange, onPrepared, parsedReference, preparePullRequestThread, resolvedPullRequest], |
| 144 | cwd, | ||
| 145 | onOpenChange, | ||
| 146 | onPrepared, | ||
| 147 | parsedReference, | ||
| 148 | preparePullRequestThreadMutation, | ||
| 149 | resolvedPullRequest, | ||
| 150 | ], | ||
| 151 | ); | 165 | ); |
| 152 | 166 | ||
| 153 | const validationMessage = !referenceDirty | 167 | const validationMessage = !referenceDirty |
| @@ -159,21 +173,17 @@ export function PullRequestThreadDialog({ · 5 unmodified lines | |||
| 159 | : null; | 173 | : null; |
| 160 | const errorMessage = | 174 | const errorMessage = |
| 161 | validationMessage ?? | 175 | validationMessage ?? |
| 162 | (resolvedPullRequest === null && resolvePullRequestQuery.isError | 176 | (resolvedPullRequest === null && AsyncResult.isFailure(resolvePullRequestResult) |
| 163 | ? resolvePullRequestQuery.error instanceof Error | 177 | ? resolvePullRequestError instanceof Error |
| 164 | ? resolvePullRequestQuery.error.message | 178 | ? resolvePullRequestError.message |
| 165 | : "Failed to resolve pull request." | 179 | : "Failed to resolve pull request." |
| 166 | : preparePullRequestThreadMutation.error instanceof Error | 180 | : (prepareFollowupErrorMessage ?? preparePullRequestThreadErrorMessage)); |
| 167 | ? preparePullRequestThreadMutation.error.message | ||
| 168 | : preparePullRequestThreadMutation.error | ||
| 169 | ? "Failed to prepare pull request thread." | ||
| 170 | : null); | ||
| 171 | 181 | ||
| 172 | return ( | 182 | return ( |
| 173 | <Dialog | 183 | <Dialog |
| 174 | open={open} | 184 | open={open} |
| 175 | onOpenChange={(nextOpen) => { | 185 | onOpenChange={(nextOpen) => { |
| 176 | if (!preparePullRequestThreadMutation.isPending) { | 186 | if (!isPreparingPullRequestThread) { |
| 177 | onOpenChange(nextOpen); | 187 | onOpenChange(nextOpen); |
| 178 | } | 188 | } |
| 179 | }} | 189 | }} |
| @@ -202,7 +212,7 @@ export function PullRequestThreadDialog({ · 22 unmodified lines | |||
| 202 | return; | 212 | return; |
| 203 | } | 213 | } |
| 204 | event.preventDefault(); | 214 | event.preventDefault(); |
| 205 | if (!isResolving && !preparePullRequestThreadMutation.isPending) { | 215 | if (!isResolving && !isPreparingPullRequestThread) { |
| 206 | void handleConfirm("local"); | 216 | void handleConfirm("local"); |
| 207 | } | 217 | } |
| 208 | }} | 218 | }} |
| @@ -241,7 +251,7 @@ export function PullRequestThreadDialog({ · 32 unmodified lines | |||
| 241 | variant="outline" | 251 | variant="outline" |
| 242 | size="sm" | 252 | size="sm" |
| 243 | onClick={() => onOpenChange(false)} | 253 | onClick={() => onOpenChange(false)} |
| 244 | disabled={preparePullRequestThreadMutation.isPending} | 254 | disabled={isPreparingPullRequestThread} |
| 245 | > | 255 | > |
| 246 | Cancel | 256 | Cancel |
| 247 | </Button> | 257 | </Button> |
| @@ -252,12 +262,7 @@ export function PullRequestThreadDialog({ · 4 unmodified lines | |||
| 252 | onClick={() => { | 262 | onClick={() => { |
| 253 | void handleConfirm("local"); | 263 | void handleConfirm("local"); |
| 254 | }} | 264 | }} |
| 255 | disabled={ | 265 | disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread} |
| 256 | !cwd || | ||
| 257 | !resolvedPullRequest || | ||
| 258 | isResolving || | ||
| 259 | preparePullRequestThreadMutation.isPending | ||
| 260 | } | ||
| 261 | > | 266 | > |
| 262 | {preparingMode === "local" ? "Preparing local..." : "Local"} | 267 | {preparingMode === "local" ? "Preparing local..." : "Local"} |
| 263 | </Button> | 268 | </Button> |
| @@ -267,12 +272,7 @@ export function PullRequestThreadDialog({ · 3 unmodified lines | |||
| 267 | onClick={() => { | 272 | onClick={() => { |
| 268 | void handleConfirm("worktree"); | 273 | void handleConfirm("worktree"); |
| 269 | }} | 274 | }} |
| 270 | disabled={ | 275 | disabled={!cwd || !resolvedPullRequest || isResolving || isPreparingPullRequestThread} |
| 271 | !cwd || | ||
| 272 | !resolvedPullRequest || | ||
| 273 | isResolving || | ||
| 274 | preparePullRequestThreadMutation.isPending | ||
| 275 | } | ||
| 276 | > | 276 | > |
| 277 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} | 277 | {preparingMode === "worktree" ? "Preparing worktree..." : "Worktree"} |
| 278 | </Button> | 278 | </Button> |
| @@ -12,6 +12,8 @@ import { · 11 unmodified lines | |||
| 12 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 13 | import { ProjectFavicon } from "./ProjectFavicon"; | 13 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 14 | import { autoAnimate } from "@formkit/auto-animate"; | 14 | import { autoAnimate } from "@formkit/auto-animate"; |
| 15 | import { Option } from "effect"; | ||
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 15 | import { | 17 | import { |
| 16 | useCallback, | 18 | useCallback, |
| 17 | useEffect, | 19 | useEffect, |
| @@ -44,7 +46,6 @@ import { · 26 unmodified lines | |||
| 44 | ThreadId, | 46 | ThreadId, |
| 45 | type GitStatusResult, | 47 | type GitStatusResult, |
| 46 | } from "@t3tools/contracts"; | 48 | } from "@t3tools/contracts"; |
| 47 | import { useQueries } from "@tanstack/react-query"; | ||
| 48 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; | 49 | import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 49 | import { | 50 | import { |
| 50 | type SidebarProjectSortOrder, | 51 | type SidebarProjectSortOrder, |
| @@ -65,8 +66,8 @@ import { · 14 unmodified lines | |||
| 65 | threadTraversalDirectionFromCommand, | 66 | threadTraversalDirectionFromCommand, |
| 66 | } from "../keybindings"; | 67 | } from "../keybindings"; |
| 67 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; | 68 | import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; |
| 68 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; | ||
| 69 | import { readNativeApi } from "../nativeApi"; | 69 | import { readNativeApi } from "../nativeApi"; |
| 70 | import { useGitStatuses } from "../rpc/gitAtoms"; | ||
| 70 | import { useComposerDraftStore } from "../composerDraftStore"; | 71 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 71 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 72 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 72 | 73 | ||
| @@ -556,19 +557,15 @@ export default function Sidebar() { · 483 unmodified lines | |||
| 556 | ], | 557 | ], |
| 557 | [threadGitTargets], | 558 | [threadGitTargets], |
| 558 | ); | 559 | ); |
| 559 | const threadGitStatusQueries = useQueries({ | 560 | const threadGitStatusResults = useGitStatuses(threadGitStatusCwds); |
| 560 | queries: threadGitStatusCwds.map((cwd) => ({ | ||
| 561 | ...gitStatusQueryOptions(cwd), | ||
| 562 | staleTime: 30_000, | ||
| 563 | refetchInterval: 60_000, | ||
| 564 | })), | ||
| 565 | }); | ||
| 566 | const prByThreadId = useMemo(() => { | 561 | const prByThreadId = useMemo(() => { |
| 567 | const statusByCwd = new Map<string, GitStatusResult>(); | 562 | const statusByCwd = new Map<string, GitStatusResult>(); |
| 568 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { | 563 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { |
| 569 | const cwd = threadGitStatusCwds[index]; | 564 | const cwd = threadGitStatusCwds[index]; |
| 570 | if (!cwd) continue; | 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 | if (status) { | 569 | if (status) { |
| 573 | statusByCwd.set(cwd, status); | 570 | statusByCwd.set(cwd, status); |
| 574 | } | 571 | } |
| @@ -582,7 +579,7 @@ export default function Sidebar() { · 7 unmodified lines | |||
| 582 | map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); | 579 | map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); |
| 583 | } | 580 | } |
| 584 | return map; | 581 | return map; |
| 585 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); | 582 | }, [threadGitStatusCwds, threadGitStatusResults, threadGitTargets]); |
| 586 | 583 | ||
| 587 | const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { | 584 | const openPrLink = useCallback((event: React.MouseEvent<HTMLElement>, prUrl: string) => { |
| 588 | event.preventDefault(); | 585 | event.preventDefault(); |
| @@ -1,14 +1,15 @@ | |||
| 1 | import { useAtomSet } from "@effect/atom-react"; | ||
| 1 | import { ThreadId } from "@t3tools/contracts"; | 2 | import { ThreadId } from "@t3tools/contracts"; |
| 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; | 3 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | import { useCallback } from "react"; | 4 | import { useCallback } from "react"; |
| 5 | 5 | ||
| 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; | 6 | import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; |
| 7 | import { useComposerDraftStore } from "../composerDraftStore"; | 7 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 8 | import { useHandleNewThread } from "./useHandleNewThread"; | 8 | import { useHandleNewThread } from "./useHandleNewThread"; |
| 9 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | ||
| 10 | import { newCommandId } from "../lib/utils"; | 9 | import { newCommandId } from "../lib/utils"; |
| 11 | import { readNativeApi } from "../nativeApi"; | 10 | import { readNativeApi } from "../nativeApi"; |
| 11 | import { gitRemoveWorktreeMutationAtom } from "../rpc/gitAtoms"; | ||
| 12 | import { REACTIVITY_KEYS } from "../rpc/client"; | ||
| 12 | import { useStore } from "../store"; | 13 | import { useStore } from "../store"; |
| 13 | import { useTerminalStateStore } from "../terminalStateStore"; | 14 | import { useTerminalStateStore } from "../terminalStateStore"; |
| 14 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 15 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| @@ -28,8 +29,7 @@ export function useThreadActions() { · 13 unmodified lines | |||
| 28 | }); | 29 | }); |
| 29 | const navigate = useNavigate(); | 30 | const navigate = useNavigate(); |
| 30 | const { handleNewThread } = useHandleNewThread(); | 31 | const { handleNewThread } = useHandleNewThread(); |
| 31 | const queryClient = useQueryClient(); | 32 | const removeWorktree = useAtomSet(gitRemoveWorktreeMutationAtom, { mode: "promise" }); |
| 32 | const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); | ||
| 33 | 33 | ||
| 34 | const archiveThread = useCallback( | 34 | const archiveThread = useCallback( |
| 35 | async (threadId: ThreadId) => { | 35 | async (threadId: ThreadId) => { |
| @@ -144,10 +144,16 @@ export function useThreadActions() { · 108 unmodified lines | |||
| 144 | } | 144 | } |
| 145 | 145 | ||
| 146 | try { | 146 | try { |
| 147 | await removeWorktreeMutation.mutateAsync({ | 147 | await removeWorktree({ |
| 148 | cwd: threadProject.cwd, | 148 | payload: { |
| 149 | path: orphanedWorktreePath, | 149 | cwd: threadProject.cwd, |
| 150 | force: true, | 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 | } catch (error) { | 158 | } catch (error) { |
| 153 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; | 159 | const message = error instanceof Error ? error.message : "Unknown error removing worktree."; |
| @@ -170,7 +176,7 @@ export function useThreadActions() { · 16 unmodified lines | |||
| 170 | clearTerminalState, | 176 | clearTerminalState, |
| 171 | appSettings.sidebarThreadSortOrder, | 177 | appSettings.sidebarThreadSortOrder, |
| 172 | navigate, | 178 | navigate, |
| 173 | removeWorktreeMutation, | 179 | removeWorktree, |
| 174 | routeThreadId, | 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 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 3 | import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; | 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 | type WsEventType = "open" | "message" | "close" | "error"; | 7 | type WsEventType = "open" | "message" | "close" | "error"; |
| 8 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; | 8 | type WsEvent = { code?: number; data?: unknown; reason?: string; type?: string }; |
| @@ -110,62 +110,11 @@ beforeEach(() => { · 101 unmodified lines | |||
| 110 | }); | 110 | }); |
| 111 | 111 | ||
| 112 | afterEach(() => { | 112 | afterEach(() => { |
| 113 | __resetWsRpcAtomClientForTests(); | ||
| 114 | globalThis.WebSocket = originalWebSocket; | 113 | globalThis.WebSocket = originalWebSocket; |
| 115 | vi.restoreAllMocks(); | 114 | vi.restoreAllMocks(); |
| 116 | }); | 115 | }); |
| 117 | 116 | ||
| 118 | describe("WsRpcAtomClient", () => { | 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 | it("exposes atom-backed query state for unary RPC methods", async () => { | 118 | it("exposes atom-backed query state for unary RPC methods", async () => { |
| 170 | const expectedSettings = { | 119 | const expectedSettings = { |
| 171 | ...DEFAULT_SERVER_SETTINGS, | 120 | ...DEFAULT_SERVER_SETTINGS, |
| @@ -1,33 +1,31 @@ | |||
| 1 | import { WsRpcGroup } from "@t3tools/contracts"; | 1 | import { WsRpcGroup } from "@t3tools/contracts"; |
| 2 | import { Effect, Layer, ManagedRuntime } from "effect"; | 2 | import { Layer } from "effect"; |
| 3 | import { AtomRpc } from "effect/unstable/reactivity"; | 3 | import { AtomRpc } from "effect/unstable/reactivity"; |
| 4 | 4 | ||
| 5 | import { createWsRpcProtocolLayer } from "./protocol"; | 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 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { | 28 | export class WsRpcAtomClient extends AtomRpc.Service<WsRpcAtomClient>()("WsRpcAtomClient", { |
| 8 | group: WsRpcGroup, | 29 | group: WsRpcGroup, |
| 9 | protocol: Layer.suspend(() => createWsRpcProtocolLayer()), | 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 | git: { | 55 | git: { |
| 56 | pull: vi.fn(), | 56 | pull: vi.fn(), |
| 57 | status: vi.fn(), | 57 | status: vi.fn(), |
| 58 | runStackedAction: vi.fn(), | ||
| 59 | listBranches: vi.fn(), | 58 | listBranches: vi.fn(), |
| 60 | createWorktree: vi.fn(), | 59 | createWorktree: vi.fn(), |
| 61 | removeWorktree: vi.fn(), | 60 | removeWorktree: vi.fn(), |
| @@ -1,7 +1,4 @@ | |||
| 1 | import { | 1 | import { |
| 2 | type GitActionProgressEvent, | ||
| 3 | type GitRunStackedActionInput, | ||
| 4 | type GitRunStackedActionResult, | ||
| 5 | type NativeApi, | 2 | type NativeApi, |
| 6 | ORCHESTRATION_WS_METHODS, | 3 | ORCHESTRATION_WS_METHODS, |
| 7 | type ServerSettingsPatch, | 4 | type ServerSettingsPatch, |
| @@ -31,10 +28,6 @@ type RpcStreamMethod<TTag extends RpcTag> = · 23 unmodified lines | |||
| 31 | ? (listener: (event: TEvent) => void) => () => void | 28 | ? (listener: (event: TEvent) => void) => () => void |
| 32 | : never; | 29 | : never; |
| 33 | 30 | ||
| 34 | interface GitRunStackedActionOptions { | ||
| 35 | readonly onProgress?: (event: GitActionProgressEvent) => void; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface WsRpcClient { | 31 | export interface WsRpcClient { |
| 39 | readonly dispose: () => Promise<void>; | 32 | readonly dispose: () => Promise<void>; |
| 40 | readonly terminal: { | 33 | readonly terminal: { |
| @@ -59,10 +52,6 @@ export interface WsRpcClient { · 18 unmodified lines | |||
| 59 | readonly git: { | 52 | readonly git: { |
| 60 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | 53 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; |
| 61 | readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>; | 54 | readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>; |
| 62 | readonly runStackedAction: ( | ||
| 63 | input: GitRunStackedActionInput, | ||
| 64 | options?: GitRunStackedActionOptions, | ||
| 65 | ) => Promise<GitRunStackedActionResult>; | ||
| 66 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; | 55 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; |
| 67 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; | 56 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; |
| 68 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; | 57 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; |
| @@ -136,25 +125,6 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { · 67 unmodified lines | |||
| 136 | git: { | 125 | git: { |
| 137 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | 126 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), |
| 138 | status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), | 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 | listBranches: (input) => | 128 | listBranches: (input) => |
| 159 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), | 129 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), |
| 160 | createWorktree: (input) => | 130 | createWorktree: (input) => |