| author | |
| committer | |
| log | 1db585878ebc85927ddfe8a2bf50764e8e56ae4e |
| tree | 939e9a773f952b5c52de1e931513ca98b305feac |
| parent | 56e57de9478423becb33723a460eecf508fe1d5d |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 125 insertions(+), 18 deletions(-)
apps/web/src/components/ChatView.browser.tsx+1| ... | ... | @@ -100,6 +100,7 @@ vi.mock("../lib/vcsStatusState", () => { |
| 100 | 100 | }; |
| 101 | 101 | |
| 102 | 102 | return { |
| 103 | getVcsStatusDataForTarget: (state: typeof status) => state.data, | |
| 103 | 104 | getVcsStatusSnapshot: () => status, |
| 104 | 105 | useVcsStatus: () => status, |
| 105 | 106 | useVcsStatuses: () => new Map(), |
apps/web/src/components/GitActionsControl.browser.tsx+1| ... | ... | @@ -94,6 +94,7 @@ vi.mock("~/lib/sourceControlActions", () => ({ |
| 94 | 94 | })); |
| 95 | 95 | |
| 96 | 96 | vi.mock("~/lib/vcsStatusState", () => ({ |
| 97 | getVcsStatusDataForTarget: (state: { data: unknown }) => state.data, | |
| 97 | 98 | refreshVcsStatus: refreshVcsStatusSpy, |
| 98 | 99 | resetVcsStatusStateForTests: () => undefined, |
| 99 | 100 | useVcsStatus: vi.fn(() => ({ |
apps/web/src/components/GitActionsControl.logic.test.ts+9| ... | ... | @@ -1100,6 +1100,15 @@ describe("resolveLiveThreadBranchUpdate", () => { |
| 1100 | 1100 | |
| 1101 | 1101 | assert.equal(update, null); |
| 1102 | 1102 | }); |
| 1103 | ||
| 1104 | it("allows a temporary worktree ref to reconcile to a semantic branch", () => { | |
| 1105 | const update = resolveLiveThreadBranchUpdate({ | |
| 1106 | threadBranch: "t3code/a9628676", | |
| 1107 | gitStatus: status({ refName: "feature/diff-panel-toggle" }), | |
| 1108 | }); | |
| 1109 | ||
| 1110 | assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); | |
| 1111 | }); | |
| 1103 | 1112 | }); |
| 1104 | 1113 | |
| 1105 | 1114 | describe("resolveAutoFeatureBranchName", () => { |
apps/web/src/components/GitActionsControl.tsx+8-5| ... | ... | @@ -71,7 +71,7 @@ import { |
| 71 | 71 | useVcsInitAction, |
| 72 | 72 | useVcsPullAction, |
| 73 | 73 | } from "~/lib/sourceControlActions"; |
| 74 | import { refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; | |
| 74 | import { getVcsStatusDataForTarget, refreshVcsStatus, useVcsStatus } from "~/lib/vcsStatusState"; | |
| 75 | 75 | import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; |
| 76 | 76 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 77 | 77 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| ... | ... | @@ -1058,10 +1058,13 @@ export default function GitActionsControl({ |
| 1058 | 1058 | [persistThreadBranchSync], |
| 1059 | 1059 | ); |
| 1060 | 1060 | |
| 1061 | const { data: gitStatus, error: gitStatusError } = useVcsStatus({ | |
| 1062 | environmentId: activeEnvironmentId, | |
| 1063 | cwd: gitCwd, | |
| 1064 | }); | |
| 1061 | const vcsStatusTarget = useMemo( | |
| 1062 | () => ({ environmentId: activeEnvironmentId, cwd: gitCwd }), | |
| 1063 | [activeEnvironmentId, gitCwd], | |
| 1064 | ); | |
| 1065 | const gitStatusQuery = useVcsStatus(vcsStatusTarget); | |
| 1066 | const { error: gitStatusError } = gitStatusQuery; | |
| 1067 | const gitStatus = getVcsStatusDataForTarget(gitStatusQuery, vcsStatusTarget); | |
| 1065 | 1068 | const sourceControlPresentation = useMemo( |
| 1066 | 1069 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), |
| 1067 | 1070 | [gitStatus?.sourceControlProvider], |
apps/web/src/components/KeybindingsToast.browser.tsx+1| ... | ... | @@ -67,6 +67,7 @@ vi.mock("../lib/vcsStatusState", () => { |
| 67 | 67 | }; |
| 68 | 68 | |
| 69 | 69 | return { |
| 70 | getVcsStatusDataForTarget: (state: typeof status) => state.data, | |
| 70 | 71 | getVcsStatusSnapshot: () => status, |
| 71 | 72 | useVcsStatus: () => status, |
| 72 | 73 | useVcsStatuses: () => new Map(), |
apps/web/src/lib/vcsStatusState.ts+2| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | EMPTY_VCS_STATUS_ATOM, |
| 7 | 7 | EMPTY_VCS_STATUS_STATE, |
| 8 | 8 | createVcsStatusManager, |
| 9 | getVcsStatusDataForTarget, | |
| 9 | 10 | getVcsStatusTargetKey, |
| 10 | 11 | vcsStatusStateAtom, |
| 11 | 12 | } from "@t3tools/client-runtime"; |
| ... | ... | @@ -19,6 +20,7 @@ import { |
| 19 | 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 20 | 21 | |
| 21 | 22 | export type { VcsStatusState, VcsStatusTarget }; |
| 23 | export { getVcsStatusDataForTarget }; | |
| 22 | 24 | |
| 23 | 25 | const manager = createVcsStatusManager({ |
| 24 | 26 | getRegistry: () => appAtomRegistry, |
packages/client-runtime/src/gitActions.test.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | import { assert, describe, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { resolveLiveThreadBranchUpdate } from "./gitActions.js"; | |
| 5 | ||
| 6 | function status(refName: string): VcsStatusResult { | |
| 7 | return { | |
| 8 | isRepo: true, | |
| 9 | hasPrimaryRemote: true, | |
| 10 | isDefaultRef: refName === "main", | |
| 11 | refName, | |
| 12 | hasWorkingTreeChanges: false, | |
| 13 | workingTree: { | |
| 14 | files: [], | |
| 15 | insertions: 0, | |
| 16 | deletions: 0, | |
| 17 | }, | |
| 18 | hasUpstream: true, | |
| 19 | aheadCount: 0, | |
| 20 | behindCount: 0, | |
| 21 | pr: null, | |
| 22 | }; | |
| 23 | } | |
| 24 | ||
| 25 | describe("resolveLiveThreadBranchUpdate", () => { | |
| 26 | it("allows a temporary worktree ref to reconcile to a semantic branch", () => { | |
| 27 | const update = resolveLiveThreadBranchUpdate({ | |
| 28 | threadBranch: "t3code/a9628676", | |
| 29 | gitStatus: status("feature/diff-panel-toggle"), | |
| 30 | }); | |
| 31 | ||
| 32 | assert.deepEqual(update, { branch: "feature/diff-panel-toggle" }); | |
| 33 | }); | |
| 34 | ||
| 35 | it("still reconciles ordinary semantic branch changes", () => { | |
| 36 | const update = resolveLiveThreadBranchUpdate({ | |
| 37 | threadBranch: "feature/old", | |
| 38 | gitStatus: status("feature/new"), | |
| 39 | }); | |
| 40 | ||
| 41 | assert.deepEqual(update, { branch: "feature/new" }); | |
| 42 | }); | |
| 43 | }); |
packages/client-runtime/src/vcsStatusState.test.ts+37-4| ... | ... | @@ -2,7 +2,11 @@ import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | 2 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| 3 | 3 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { type VcsStatusClient, createVcsStatusManager } from "./vcsStatusState.ts"; | |
| 5 | import { | |
| 6 | type VcsStatusClient, | |
| 7 | createVcsStatusManager, | |
| 8 | getVcsStatusDataForTarget, | |
| 9 | } from "./vcsStatusState.ts"; | |
| 6 | 10 | |
| 7 | 11 | /* ─── Test helpers ──────────────────────────────────────────────────── */ |
| 8 | 12 | |
| ... | ... | @@ -57,12 +61,24 @@ function createMockClient(): { |
| 57 | 61 | }; |
| 58 | 62 | } |
| 59 | 63 | |
| 60 | const PENDING = { data: null, error: null, cause: null, isPending: true }; | |
| 61 | const EMPTY = { data: null, error: null, cause: null, isPending: false }; | |
| 62 | ||
| 63 | 64 | const TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/repo" } as const; |
| 64 | 65 | const FRESH_TARGET = { environmentId: EnvironmentId.make("env-local"), cwd: "/fresh" } as const; |
| 65 | 66 | const OTHER_ENV_TARGET = { environmentId: EnvironmentId.make("env-remote"), cwd: "/repo" } as const; |
| 67 | const TARGET_KEY = "env-local:/repo"; | |
| 68 | const PENDING = { | |
| 69 | targetKey: TARGET_KEY, | |
| 70 | data: null, | |
| 71 | error: null, | |
| 72 | cause: null, | |
| 73 | isPending: true, | |
| 74 | }; | |
| 75 | const EMPTY = { | |
| 76 | targetKey: null, | |
| 77 | data: null, | |
| 78 | error: null, | |
| 79 | cause: null, | |
| 80 | isPending: false, | |
| 81 | }; | |
| 66 | 82 | |
| 67 | 83 | /* ─── Tests ─────────────────────────────────────────────────────────── */ |
| 68 | 84 | |
| ... | ... | @@ -99,6 +115,7 @@ describe("createVcsStatusManager", () => { |
| 99 | 115 | |
| 100 | 116 | emit(BASE_STATUS); |
| 101 | 117 | expect(manager.getSnapshot(TARGET)).toEqual({ |
| 118 | targetKey: TARGET_KEY, | |
| 102 | 119 | data: BASE_STATUS, |
| 103 | 120 | error: null, |
| 104 | 121 | cause: null, |
| ... | ... | @@ -130,6 +147,7 @@ describe("createVcsStatusManager", () => { |
| 130 | 147 | |
| 131 | 148 | // Snapshot still reflects stream data, not the refresh response |
| 132 | 149 | expect(manager.getSnapshot(TARGET)).toEqual({ |
| 150 | targetKey: TARGET_KEY, | |
| 133 | 151 | data: BASE_STATUS, |
| 134 | 152 | error: null, |
| 135 | 153 | cause: null, |
| ... | ... | @@ -160,6 +178,19 @@ describe("createVcsStatusManager", () => { |
| 160 | 178 | releaseRemote(); |
| 161 | 179 | }); |
| 162 | 180 | |
| 181 | it("rejects status data from a previous cwd during target transitions", () => { | |
| 182 | const staleState = { | |
| 183 | targetKey: TARGET_KEY, | |
| 184 | data: BASE_STATUS, | |
| 185 | error: null, | |
| 186 | cause: null, | |
| 187 | isPending: false, | |
| 188 | }; | |
| 189 | ||
| 190 | expect(getVcsStatusDataForTarget(staleState, FRESH_TARGET)).toBeNull(); | |
| 191 | expect(getVcsStatusDataForTarget(staleState, TARGET)).toBe(BASE_STATUS); | |
| 192 | }); | |
| 193 | ||
| 163 | 194 | it("returns null from refresh when no client is available", async () => { |
| 164 | 195 | const manager = createVcsStatusManager({ |
| 165 | 196 | getRegistry: () => atomRegistry, |
| ... | ... | @@ -204,6 +235,7 @@ describe("createVcsStatusManager", () => { |
| 204 | 235 | |
| 205 | 236 | mock.emit(BASE_STATUS); |
| 206 | 237 | expect(manager.getSnapshot(TARGET)).toEqual({ |
| 238 | targetKey: TARGET_KEY, | |
| 207 | 239 | data: BASE_STATUS, |
| 208 | 240 | error: null, |
| 209 | 241 | cause: null, |
| ... | ... | @@ -241,6 +273,7 @@ describe("createVcsStatusManager", () => { |
| 241 | 273 | for (const listener of connectionListeners) listener(); |
| 242 | 274 | |
| 243 | 275 | expect(manager.getSnapshot(TARGET)).toEqual({ |
| 276 | targetKey: TARGET_KEY, | |
| 244 | 277 | data: BASE_STATUS, |
| 245 | 278 | error: null, |
| 246 | 279 | cause: null, |
packages/client-runtime/src/vcsStatusState.ts+23-9| ... | ... | @@ -7,6 +7,7 @@ import type { WsRpcClient } from "./wsRpcClient.ts"; |
| 7 | 7 | /* ─── Types ─────────────────────────────────────────────────────────── */ |
| 8 | 8 | |
| 9 | 9 | export interface VcsStatusState { |
| 10 | readonly targetKey: string | null; | |
| 10 | 11 | readonly data: VcsStatusResult | null; |
| 11 | 12 | readonly error: GitManagerServiceError | null; |
| 12 | 13 | readonly cause: Cause.Cause<GitManagerServiceError> | null; |
| ... | ... | @@ -30,18 +31,22 @@ interface WatchedEntry { |
| 30 | 31 | const NOOP: () => void = () => undefined; |
| 31 | 32 | |
| 32 | 33 | export const EMPTY_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({ |
| 34 | targetKey: null, | |
| 33 | 35 | data: null, |
| 34 | 36 | error: null, |
| 35 | 37 | cause: null, |
| 36 | 38 | isPending: false, |
| 37 | 39 | }); |
| 38 | 40 | |
| 39 | const INITIAL_VCS_STATUS_STATE = Object.freeze<VcsStatusState>({ | |
| 40 | data: null, | |
| 41 | error: null, | |
| 42 | cause: null, | |
| 43 | isPending: true, | |
| 44 | }); | |
| 41 | function initialVcsStatusState(targetKey: string): VcsStatusState { | |
| 42 | return { | |
| 43 | targetKey, | |
| 44 | data: null, | |
| 45 | error: null, | |
| 46 | cause: null, | |
| 47 | isPending: true, | |
| 48 | }; | |
| 49 | } | |
| 45 | 50 | |
| 46 | 51 | /* ─── Atoms ─────────────────────────────────────────────────────────── */ |
| 47 | 52 | |
| ... | ... | @@ -49,7 +54,7 @@ const knownVcsStatusKeys = new Set<string>(); |
| 49 | 54 | |
| 50 | 55 | export const vcsStatusStateAtom = Atom.family((key: string) => { |
| 51 | 56 | knownVcsStatusKeys.add(key); |
| 52 | return Atom.make(INITIAL_VCS_STATUS_STATE).pipe( | |
| 57 | return Atom.make(initialVcsStatusState(key)).pipe( | |
| 53 | 58 | Atom.keepAlive, |
| 54 | 59 | Atom.withLabel(`vcs-status:${key}`), |
| 55 | 60 | ); |
| ... | ... | @@ -69,6 +74,14 @@ export function getVcsStatusTargetKey(target: VcsStatusTarget): string | null { |
| 69 | 74 | return `${target.environmentId}:${target.cwd}`; |
| 70 | 75 | } |
| 71 | 76 | |
| 77 | export function getVcsStatusDataForTarget( | |
| 78 | state: VcsStatusState, | |
| 79 | target: VcsStatusTarget, | |
| 80 | ): VcsStatusResult | null { | |
| 81 | const targetKey = getVcsStatusTargetKey(target); | |
| 82 | return targetKey !== null && state.targetKey === targetKey ? state.data : null; | |
| 83 | } | |
| 84 | ||
| 72 | 85 | /* ─── Subscription manager ──────────────────────────────────────────── */ |
| 73 | 86 | |
| 74 | 87 | export interface VcsStatusManagerConfig { |
| ... | ... | @@ -107,7 +120,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) { |
| 107 | 120 | const current = config.getRegistry().get(atom); |
| 108 | 121 | const next: VcsStatusState = |
| 109 | 122 | current.data === null |
| 110 | ? INITIAL_VCS_STATUS_STATE | |
| 123 | ? initialVcsStatusState(targetKey) | |
| 111 | 124 | : { ...current, error: null, cause: null, isPending: true }; |
| 112 | 125 | if ( |
| 113 | 126 | current.data === next.data && |
| ... | ... | @@ -122,6 +135,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) { |
| 122 | 135 | |
| 123 | 136 | function setData(targetKey: string, status: VcsStatusResult): void { |
| 124 | 137 | config.getRegistry().set(vcsStatusStateAtom(targetKey), { |
| 138 | targetKey, | |
| 125 | 139 | data: status, |
| 126 | 140 | error: null, |
| 127 | 141 | cause: null, |
| ... | ... | @@ -283,7 +297,7 @@ export function createVcsStatusManager(config: VcsStatusManagerConfig) { |
| 283 | 297 | refreshInFlight.clear(); |
| 284 | 298 | lastRefreshAt.clear(); |
| 285 | 299 | for (const key of knownVcsStatusKeys) { |
| 286 | config.getRegistry().set(vcsStatusStateAtom(key), INITIAL_VCS_STATUS_STATE); | |
| 300 | config.getRegistry().set(vcsStatusStateAtom(key), initialVcsStatusState(key)); | |
| 287 | 301 | } |
| 288 | 302 | knownVcsStatusKeys.clear(); |
| 289 | 303 | } |