authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-06 09:26:29-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-06 09:26:29-07:00
log53a552e80d5783c1824a81dbe0d8e49302601e31
treeddc70effa1f45eebff912321b22568c6bcfc2261
parentcf2c628bc005f3c88911a6fcb957f56e6d1ce45f
signature Signed by PGP key B5690EEEBB952194

Stream git status updates over WebSocket (#1763)

Co-authored-by: codex <codex@users.noreply.github.com>

38 files changed, 2302 insertions(+), 382 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+2-1
......@@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => {
949949 yield* git(source, ["checkout", defaultBranch]);
950950 yield* git(source, ["branch", "-D", featureBranch]);
951951
952 yield* (yield* GitCore).checkoutBranch({
952 const checkoutResult = yield* (yield* GitCore).checkoutBranch({
953953 cwd: source,
954954 branch: `${remoteName}/${featureBranch}`,
955955 });
956956
957 expect(checkoutResult.branch).toBe("upstream/feature");
957958 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
958959 const realGitCore = yield* GitCore;
959960 let fetchArgs: readonly string[] | null = null;
apps/server/src/git/Layers/GitCore.ts+32-9
......@@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
11771177 return branchLastCommit;
11781178 });
11791179
1180 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1181 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1182
1180 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
11831181 const statusResult = yield* executeGit(
11841182 "GitCore.statusDetails.status",
11851183 cwd,
......@@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
13121310 };
13131311 });
13141312
1313 const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")(
1314 function* (cwd) {
1315 return yield* readStatusDetailsLocal(cwd);
1316 },
1317 );
1318
1319 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1320 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1321 return yield* readStatusDetailsLocal(cwd);
1322 });
1323
13151324 const status: GitCoreShape["status"] = (input) =>
13161325 statusDetails(input.cwd).pipe(
13171326 Effect.map((details) => ({
......@@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
20002009 return { branch: targetBranch };
20012010 });
20022011
2003 const createBranch: GitCoreShape["createBranch"] = (input) =>
2004 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2005 timeoutMs: 10_000,
2006 fallbackErrorMessage: "git branch create failed",
2007 }).pipe(Effect.asVoid);
2008
20092012 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
20102013 function* (input) {
20112014 const [localInputExists, remoteExists] = yield* Effect.all(
......@@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
20782081 timeoutMs: 10_000,
20792082 fallbackErrorMessage: "git checkout failed",
20802083 });
2084
2085 const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
2086 "branch",
2087 "--show-current",
2088 ]).pipe(Effect.map((stdout) => stdout.trim() || null));
2089
2090 return { branch };
20812091 },
20822092 );
20832093
2094 const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) {
2095 yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2096 timeoutMs: 10_000,
2097 fallbackErrorMessage: "git branch create failed",
2098 });
2099 if (input.checkout) {
2100 yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
2101 }
2102
2103 return { branch: input.branch };
2104 });
2105
20842106 const initRepo: GitCoreShape["initRepo"] = (input) =>
20852107 executeGit("GitCore.initRepo", input.cwd, ["init"], {
20862108 timeoutMs: 10_000,
......@@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
21062128 execute,
21072129 status,
21082130 statusDetails,
2131 statusDetailsLocal,
21092132 prepareCommitContext,
21102133 commit,
21112134 pushCurrentBranch,
apps/server/src/git/Layers/GitManager.ts+104-32
......@@ -8,9 +8,13 @@ import {
88 GitCommandError,
99 GitRunStackedActionResult,
1010 GitStackedAction,
11 type GitStatusLocalResult,
12 type GitStatusRemoteResult,
1113 ModelSelection,
1214} from "@t3tools/contracts";
1315import {
16 detectGitHostingProviderFromRemoteUrl,
17 mergeGitStatusParts,
1418 resolveAutoFeatureBranchName,
1519 sanitizeBranchFragment,
1620 sanitizeFeatureBranchName,
......@@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
695699
696700 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
697701 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
698 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {
699 const details = yield* gitCore.statusDetails(cwd).pipe(
700 Effect.catchIf(isNotGitRepositoryError, () =>
701 Effect.succeed({
702 isRepo: false,
703 hasOriginRemote: false,
704 isDefaultBranch: false,
705 branch: null,
706 upstreamRef: null,
707 hasWorkingTreeChanges: false,
708 workingTree: { files: [], insertions: 0, deletions: 0 },
709 hasUpstream: false,
710 aheadCount: 0,
711 behindCount: 0,
712 } satisfies GitStatusDetails),
713 ),
714 );
702 const nonRepositoryStatusDetails = {
703 isRepo: false,
704 hasOriginRemote: false,
705 isDefaultBranch: false,
706 branch: null,
707 upstreamRef: null,
708 hasWorkingTreeChanges: false,
709 workingTree: { files: [], insertions: 0, deletions: 0 },
710 hasUpstream: false,
711 aheadCount: 0,
712 behindCount: 0,
713 } satisfies GitStatusDetails;
714 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
715 const details = yield* gitCore
716 .statusDetailsLocal(cwd)
717 .pipe(
718 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
719 );
720 const hostingProvider = details.isRepo
721 ? yield* resolveHostingProvider(cwd, details.branch)
722 : null;
723
724 return {
725 isRepo: details.isRepo,
726 ...(hostingProvider ? { hostingProvider } : {}),
727 hasOriginRemote: details.hasOriginRemote,
728 isDefaultBranch: details.isDefaultBranch,
729 branch: details.branch,
730 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
731 workingTree: details.workingTree,
732 } satisfies GitStatusLocalResult;
733 });
734 const localStatusResultCache = yield* Cache.makeWith({
735 capacity: STATUS_RESULT_CACHE_CAPACITY,
736 lookup: readLocalStatus,
737 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
738 });
739 const invalidateLocalStatusResultCache = (cwd: string) =>
740 Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
741 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
742 const details = yield* gitCore
743 .statusDetails(cwd)
744 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
745 if (details === null || !details.isRepo) {
746 return null;
747 }
715748
716749 const pr =
717 details.isRepo && details.branch !== null
750 details.branch !== null
718751 ? yield* findLatestPr(cwd, {
719752 branch: details.branch,
720753 upstreamRef: details.upstreamRef,
......@@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
725758 : null;
726759
727760 return {
728 isRepo: details.isRepo,
729 hasOriginRemote: details.hasOriginRemote,
730 isDefaultBranch: details.isDefaultBranch,
731 branch: details.branch,
732 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
733 workingTree: details.workingTree,
734761 hasUpstream: details.hasUpstream,
735762 aheadCount: details.aheadCount,
736763 behindCount: details.behindCount,
737764 pr,
738 };
765 } satisfies GitStatusRemoteResult;
739766 });
740 const statusResultCache = yield* Cache.makeWith({
767 const remoteStatusResultCache = yield* Cache.makeWith({
741768 capacity: STATUS_RESULT_CACHE_CAPACITY,
742 lookup: readStatus,
769 lookup: readRemoteStatus,
743770 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
744771 });
745 const invalidateStatusResultCache = (cwd: string) =>
746 Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));
772 const invalidateRemoteStatusResultCache = (cwd: string) =>
773 Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
747774
748775 const readConfigValueNullable = (cwd: string, key: string) =>
749776 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
750777
778 const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
779 cwd: string,
780 branch: string | null,
781 ) {
782 const preferredRemoteName =
783 branch === null
784 ? "origin"
785 : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
786 const remoteUrl =
787 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
788 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
789
790 return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null;
791 });
792
751793 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
752794 cwd: string,
753795 remoteName: string | null,
......@@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
13111353 };
13121354 });
13131355
1356 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1357 return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
1358 });
1359 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1360 function* (input) {
1361 return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
1362 },
1363 );
13141364 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1315 return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));
1365 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
1366 return mergeGitStatusParts(local, remote);
13161367 });
1368 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1369 "invalidateLocalStatus",
1370 )(function* (cwd) {
1371 yield* invalidateLocalStatusResultCache(cwd);
1372 });
1373 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1374 "invalidateRemoteStatus",
1375 )(function* (cwd) {
1376 yield* invalidateRemoteStatusResultCache(cwd);
1377 });
1378 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1379 function* (cwd) {
1380 yield* invalidateLocalStatusResultCache(cwd);
1381 yield* invalidateRemoteStatusResultCache(cwd);
1382 },
1383 );
13171384
13181385 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
13191386 function* (input) {
......@@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
14881555 branch: worktree.worktree.branch,
14891556 worktreePath: worktree.worktree.path,
14901557 };
1491 }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));
1558 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
14921559 });
14931560
14941561 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
......@@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
16921759 });
16931760
16941761 return yield* runAction().pipe(
1695 Effect.ensuring(invalidateStatusResultCache(input.cwd)),
1762 Effect.ensuring(invalidateStatus(input.cwd)),
16961763 Effect.tapError((error) =>
16971764 Effect.flatMap(Ref.get(currentPhase), (phase) =>
16981765 progress.emit({
......@@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
17071774 );
17081775
17091776 return {
1777 localStatus,
1778 remoteStatus,
17101779 status,
1780 invalidateLocalStatus,
1781 invalidateRemoteStatus,
1782 invalidateStatus,
17111783 resolvePullRequest,
17121784 preparePullRequestThread,
17131785 runStackedAction,
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts created+276
......@@ -0,0 +1,276 @@
1import { assert, it } from "@effect/vitest";
2import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect";
3import type {
4 GitStatusLocalResult,
5 GitStatusRemoteResult,
6 GitStatusResult,
7 GitStatusStreamEvent,
8} from "@t3tools/contracts";
9import { describe } from "vitest";
10
11import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts";
12import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts";
13import { type GitManagerShape, GitManager } from "../Services/GitManager.ts";
14
15const baseLocalStatus: GitStatusLocalResult = {
16 isRepo: true,
17 hostingProvider: {
18 kind: "github",
19 name: "GitHub",
20 baseUrl: "https://github.com",
21 },
22 hasOriginRemote: true,
23 isDefaultBranch: false,
24 branch: "feature/status-broadcast",
25 hasWorkingTreeChanges: false,
26 workingTree: { files: [], insertions: 0, deletions: 0 },
27};
28
29const baseRemoteStatus: GitStatusRemoteResult = {
30 hasUpstream: true,
31 aheadCount: 0,
32 behindCount: 0,
33 pr: null,
34};
35
36const baseStatus: GitStatusResult = {
37 ...baseLocalStatus,
38 ...baseRemoteStatus,
39};
40
41function makeTestLayer(state: {
42 currentLocalStatus: GitStatusLocalResult;
43 currentRemoteStatus: GitStatusRemoteResult | null;
44 localStatusCalls: number;
45 remoteStatusCalls: number;
46 localInvalidationCalls: number;
47 remoteInvalidationCalls: number;
48}) {
49 const gitManager: GitManagerShape = {
50 localStatus: () =>
51 Effect.sync(() => {
52 state.localStatusCalls += 1;
53 return state.currentLocalStatus;
54 }),
55 remoteStatus: () =>
56 Effect.sync(() => {
57 state.remoteStatusCalls += 1;
58 return state.currentRemoteStatus;
59 }),
60 status: () => Effect.die("status should not be called in this test"),
61 invalidateLocalStatus: () =>
62 Effect.sync(() => {
63 state.localInvalidationCalls += 1;
64 }),
65 invalidateRemoteStatus: () =>
66 Effect.sync(() => {
67 state.remoteInvalidationCalls += 1;
68 }),
69 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
70 resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"),
71 preparePullRequestThread: () =>
72 Effect.die("preparePullRequestThread should not be called in this test"),
73 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
74 };
75
76 return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager)));
77}
78
79describe("GitStatusBroadcasterLive", () => {
80 it.effect("reuses the cached git status across repeated reads", () => {
81 const state = {
82 currentLocalStatus: baseLocalStatus,
83 currentRemoteStatus: baseRemoteStatus,
84 localStatusCalls: 0,
85 remoteStatusCalls: 0,
86 localInvalidationCalls: 0,
87 remoteInvalidationCalls: 0,
88 };
89
90 return Effect.gen(function* () {
91 const broadcaster = yield* GitStatusBroadcaster;
92
93 const first = yield* broadcaster.getStatus({ cwd: "/repo" });
94 const second = yield* broadcaster.getStatus({ cwd: "/repo" });
95
96 assert.deepStrictEqual(first, baseStatus);
97 assert.deepStrictEqual(second, baseStatus);
98 assert.equal(state.localStatusCalls, 1);
99 assert.equal(state.remoteStatusCalls, 1);
100 assert.equal(state.localInvalidationCalls, 0);
101 assert.equal(state.remoteInvalidationCalls, 0);
102 }).pipe(Effect.provide(makeTestLayer(state)));
103 });
104
105 it.effect("refreshes the cached snapshot after explicit invalidation", () => {
106 const state = {
107 currentLocalStatus: baseLocalStatus,
108 currentRemoteStatus: baseRemoteStatus,
109 localStatusCalls: 0,
110 remoteStatusCalls: 0,
111 localInvalidationCalls: 0,
112 remoteInvalidationCalls: 0,
113 };
114
115 return Effect.gen(function* () {
116 const broadcaster = yield* GitStatusBroadcaster;
117 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
118
119 state.currentLocalStatus = {
120 ...baseLocalStatus,
121 branch: "feature/updated-status",
122 };
123 state.currentRemoteStatus = {
124 ...baseRemoteStatus,
125 aheadCount: 2,
126 };
127 const refreshed = yield* broadcaster.refreshStatus("/repo");
128 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
129
130 assert.deepStrictEqual(initial, baseStatus);
131 assert.deepStrictEqual(refreshed, {
132 ...state.currentLocalStatus,
133 ...state.currentRemoteStatus,
134 });
135 assert.deepStrictEqual(cached, {
136 ...state.currentLocalStatus,
137 ...state.currentRemoteStatus,
138 });
139 assert.equal(state.localStatusCalls, 2);
140 assert.equal(state.remoteStatusCalls, 2);
141 assert.equal(state.localInvalidationCalls, 1);
142 assert.equal(state.remoteInvalidationCalls, 1);
143 }).pipe(Effect.provide(makeTestLayer(state)));
144 });
145
146 it.effect("streams a local snapshot first and remote updates later", () => {
147 const state = {
148 currentLocalStatus: baseLocalStatus,
149 currentRemoteStatus: baseRemoteStatus,
150 localStatusCalls: 0,
151 remoteStatusCalls: 0,
152 localInvalidationCalls: 0,
153 remoteInvalidationCalls: 0,
154 };
155
156 return Effect.gen(function* () {
157 const broadcaster = yield* GitStatusBroadcaster;
158 const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>();
159 const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>();
160 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
161 if (event._tag === "snapshot") {
162 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
163 }
164 if (event._tag === "remoteUpdated") {
165 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
166 }
167 return Effect.void;
168 }).pipe(Effect.forkScoped);
169
170 const snapshot = yield* Deferred.await(snapshotDeferred);
171 yield* broadcaster.refreshStatus("/repo");
172 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
173
174 assert.deepStrictEqual(snapshot, {
175 _tag: "snapshot",
176 local: baseLocalStatus,
177 remote: null,
178 } satisfies GitStatusStreamEvent);
179 assert.deepStrictEqual(remoteUpdated, {
180 _tag: "remoteUpdated",
181 remote: baseRemoteStatus,
182 } satisfies GitStatusStreamEvent);
183 }).pipe(Effect.provide(makeTestLayer(state)));
184 });
185
186 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
187 const state = {
188 currentLocalStatus: baseLocalStatus,
189 currentRemoteStatus: baseRemoteStatus,
190 localStatusCalls: 0,
191 remoteStatusCalls: 0,
192 localInvalidationCalls: 0,
193 remoteInvalidationCalls: 0,
194 };
195 let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null;
196 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
197 const testLayer = GitStatusBroadcasterLive.pipe(
198 Layer.provide(
199 Layer.succeed(GitManager, {
200 localStatus: () =>
201 Effect.sync(() => {
202 state.localStatusCalls += 1;
203 return state.currentLocalStatus;
204 }),
205 remoteStatus: () =>
206 Effect.sync(() => {
207 state.remoteStatusCalls += 1;
208 }).pipe(
209 Effect.andThen(
210 remoteStartedDeferred
211 ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore)
212 : Effect.void,
213 ),
214 Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>),
215 Effect.onInterrupt(() =>
216 remoteInterruptedDeferred
217 ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore)
218 : Effect.void,
219 ),
220 ),
221 status: () => Effect.die("status should not be called in this test"),
222 invalidateLocalStatus: () =>
223 Effect.sync(() => {
224 state.localInvalidationCalls += 1;
225 }),
226 invalidateRemoteStatus: () =>
227 Effect.sync(() => {
228 state.remoteInvalidationCalls += 1;
229 }),
230 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
231 resolvePullRequest: () =>
232 Effect.die("resolvePullRequest should not be called in this test"),
233 preparePullRequestThread: () =>
234 Effect.die("preparePullRequestThread should not be called in this test"),
235 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
236 } satisfies GitManagerShape),
237 ),
238 );
239
240 return Effect.gen(function* () {
241 const remoteInterrupted = yield* Deferred.make<void>();
242 const remoteStarted = yield* Deferred.make<void>();
243 remoteInterruptedDeferred = remoteInterrupted;
244 remoteStartedDeferred = remoteStarted;
245
246 const broadcaster = yield* GitStatusBroadcaster;
247 const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
248 const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
249 const firstScope = yield* Scope.make();
250 const secondScope = yield* Scope.make();
251 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
252 event._tag === "snapshot"
253 ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
254 : Effect.void,
255 ).pipe(Effect.forkIn(firstScope));
256 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
257 event._tag === "snapshot"
258 ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore)
259 : Effect.void,
260 ).pipe(Effect.forkIn(secondScope));
261
262 yield* Deferred.await(firstSnapshot);
263 yield* Deferred.await(secondSnapshot);
264 yield* Deferred.await(remoteStarted);
265
266 assert.equal(state.remoteStatusCalls, 1);
267
268 yield* Scope.close(firstScope, Exit.void);
269 assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true);
270
271 yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
272 yield* Deferred.await(remoteInterrupted);
273 assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true);
274 }).pipe(Effect.provide(testLayer));
275 });
276});
apps/server/src/git/Layers/GitStatusBroadcaster.ts created+307
......@@ -0,0 +1,307 @@
1import { realpathSync } from "node:fs";
2
3import {
4 Duration,
5 Effect,
6 Exit,
7 Fiber,
8 Layer,
9 PubSub,
10 Ref,
11 Scope,
12 Stream,
13 SynchronizedRef,
14} from "effect";
15import type {
16 GitStatusInput,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
19 GitStatusStreamEvent,
20} from "@t3tools/contracts";
21import { mergeGitStatusParts } from "@t3tools/shared/git";
22
23import {
24 GitStatusBroadcaster,
25 type GitStatusBroadcasterShape,
26} from "../Services/GitStatusBroadcaster.ts";
27import { GitManager } from "../Services/GitManager.ts";
28
29const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
30
31interface GitStatusChange {
32 readonly cwd: string;
33 readonly event: GitStatusStreamEvent;
34}
35
36interface CachedValue<T> {
37 readonly fingerprint: string;
38 readonly value: T;
39}
40
41interface CachedGitStatus {
42 readonly local: CachedValue<GitStatusLocalResult> | null;
43 readonly remote: CachedValue<GitStatusRemoteResult | null> | null;
44}
45
46interface ActiveRemotePoller {
47 readonly fiber: Fiber.Fiber<void, never>;
48 readonly subscriberCount: number;
49}
50
51function normalizeCwd(cwd: string): string {
52 try {
53 return realpathSync.native(cwd);
54 } catch {
55 return cwd;
56 }
57}
58
59function fingerprintStatusPart(status: unknown): string {
60 return JSON.stringify(status);
61}
62
63export const GitStatusBroadcasterLive = Layer.effect(
64 GitStatusBroadcaster,
65 Effect.gen(function* () {
66 const gitManager = yield* GitManager;
67 const changesPubSub = yield* Effect.acquireRelease(
68 PubSub.unbounded<GitStatusChange>(),
69 (pubsub) => PubSub.shutdown(pubsub),
70 );
71 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
72 Scope.close(scope, Exit.void),
73 );
74 const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>());
75 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
76
77 const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) {
78 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
79 });
80
81 const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* (
82 cwd: string,
83 local: GitStatusLocalResult,
84 options?: { publish?: boolean },
85 ) {
86 const nextLocal = {
87 fingerprint: fingerprintStatusPart(local),
88 value: local,
89 } satisfies CachedValue<GitStatusLocalResult>;
90 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
91 const previous = cache.get(cwd) ?? { local: null, remote: null };
92 const nextCache = new Map(cache);
93 nextCache.set(cwd, {
94 ...previous,
95 local: nextLocal,
96 });
97 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
98 });
99
100 if (options?.publish && shouldPublish) {
101 yield* PubSub.publish(changesPubSub, {
102 cwd,
103 event: {
104 _tag: "localUpdated",
105 local,
106 },
107 });
108 }
109
110 return local;
111 });
112
113 const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* (
114 cwd: string,
115 remote: GitStatusRemoteResult | null,
116 options?: { publish?: boolean },
117 ) {
118 const nextRemote = {
119 fingerprint: fingerprintStatusPart(remote),
120 value: remote,
121 } satisfies CachedValue<GitStatusRemoteResult | null>;
122 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
123 const previous = cache.get(cwd) ?? { local: null, remote: null };
124 const nextCache = new Map(cache);
125 nextCache.set(cwd, {
126 ...previous,
127 remote: nextRemote,
128 });
129 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
130 });
131
132 if (options?.publish && shouldPublish) {
133 yield* PubSub.publish(changesPubSub, {
134 cwd,
135 event: {
136 _tag: "remoteUpdated",
137 remote,
138 },
139 });
140 }
141
142 return remote;
143 });
144
145 const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) {
146 const local = yield* gitManager.localStatus({ cwd });
147 return yield* updateCachedLocalStatus(cwd, local);
148 });
149
150 const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) {
151 const remote = yield* gitManager.remoteStatus({ cwd });
152 return yield* updateCachedRemoteStatus(cwd, remote);
153 });
154
155 const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) {
156 const cached = yield* getCachedStatus(cwd);
157 if (cached?.local) {
158 return cached.local.value;
159 }
160 return yield* loadLocalStatus(cwd);
161 });
162
163 const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) {
164 const cached = yield* getCachedStatus(cwd);
165 if (cached?.remote) {
166 return cached.remote.value;
167 }
168 return yield* loadRemoteStatus(cwd);
169 });
170
171 const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* (
172 input: GitStatusInput,
173 ) {
174 const normalizedCwd = normalizeCwd(input.cwd);
175 const [local, remote] = yield* Effect.all([
176 getOrLoadLocalStatus(normalizedCwd),
177 getOrLoadRemoteStatus(normalizedCwd),
178 ]);
179 return mergeGitStatusParts(local, remote);
180 });
181
182 const refreshLocalStatus = Effect.fn("refreshLocalStatus")(function* (cwd: string) {
183 yield* gitManager.invalidateLocalStatus(cwd);
184 const local = yield* gitManager.localStatus({ cwd });
185 return yield* updateCachedLocalStatus(cwd, local, { publish: true });
186 });
187
188 const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) {
189 yield* gitManager.invalidateRemoteStatus(cwd);
190 const remote = yield* gitManager.remoteStatus({ cwd });
191 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
192 });
193
194 const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")(
195 function* (cwd) {
196 const normalizedCwd = normalizeCwd(cwd);
197 const [local, remote] = yield* Effect.all([
198 refreshLocalStatus(normalizedCwd),
199 refreshRemoteStatus(normalizedCwd),
200 ]);
201 return mergeGitStatusParts(local, remote);
202 },
203 );
204
205 const makeRemoteRefreshLoop = (cwd: string) => {
206 const logRefreshFailure = (error: Error) =>
207 Effect.logWarning("git remote status refresh failed", {
208 cwd,
209 detail: error.message,
210 });
211
212 return refreshRemoteStatus(cwd).pipe(
213 Effect.catch(logRefreshFailure),
214 Effect.andThen(
215 Effect.forever(
216 Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe(
217 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
218 ),
219 ),
220 ),
221 );
222 };
223
224 const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) {
225 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
226 const existing = activePollers.get(cwd);
227 if (existing) {
228 const nextPollers = new Map(activePollers);
229 nextPollers.set(cwd, {
230 ...existing,
231 subscriberCount: existing.subscriberCount + 1,
232 });
233 return Effect.succeed([undefined, nextPollers] as const);
234 }
235
236 return makeRemoteRefreshLoop(cwd).pipe(
237 Effect.forkIn(broadcasterScope),
238 Effect.map((fiber) => {
239 const nextPollers = new Map(activePollers);
240 nextPollers.set(cwd, {
241 fiber,
242 subscriberCount: 1,
243 });
244 return [undefined, nextPollers] as const;
245 }),
246 );
247 });
248 });
249
250 const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) {
251 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
252 const existing = activePollers.get(cwd);
253 if (!existing) {
254 return [null, activePollers] as const;
255 }
256
257 if (existing.subscriberCount > 1) {
258 const nextPollers = new Map(activePollers);
259 nextPollers.set(cwd, {
260 ...existing,
261 subscriberCount: existing.subscriberCount - 1,
262 });
263 return [null, nextPollers] as const;
264 }
265
266 const nextPollers = new Map(activePollers);
267 nextPollers.delete(cwd);
268 return [existing.fiber, nextPollers] as const;
269 });
270
271 if (pollerToInterrupt) {
272 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
273 }
274 });
275
276 const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) =>
277 Stream.unwrap(
278 Effect.gen(function* () {
279 const normalizedCwd = normalizeCwd(input.cwd);
280 const subscription = yield* PubSub.subscribe(changesPubSub);
281 const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd);
282 const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null;
283 yield* retainRemotePoller(normalizedCwd);
284
285 const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid);
286
287 return Stream.concat(
288 Stream.make({
289 _tag: "snapshot" as const,
290 local: initialLocal,
291 remote: initialRemote,
292 }),
293 Stream.fromSubscription(subscription).pipe(
294 Stream.filter((event) => event.cwd === normalizedCwd),
295 Stream.map((event) => event.event),
296 ),
297 ).pipe(Stream.ensuring(release));
298 }),
299 );
300
301 return {
302 getStatus,
303 refreshStatus,
304 streamStatus,
305 } satisfies GitStatusBroadcasterShape;
306 }),
307);
apps/server/src/git/Services/GitCore.ts+12-3
......@@ -7,10 +7,12 @@
77 * @module GitCore
88 */
99import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";
10import type { Effect } from "effect";
1111import type {
1212 GitCheckoutInput,
13 GitCheckoutResult,
1314 GitCreateBranchInput,
15 GitCreateBranchResult,
1416 GitCreateWorktreeInput,
1517 GitCreateWorktreeResult,
1618 GitInitInput,
......@@ -156,6 +158,11 @@ export interface GitCoreShape {
156158 */
157159 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
158160
161 /**
162 * Read detailed working tree / branch status without refreshing remote tracking refs.
163 */
164 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
165
159166 /**
160167 * Build staged change context for commit generation.
161168 */
......@@ -278,14 +285,16 @@ export interface GitCoreShape {
278285 /**
279286 * Create a local branch.
280287 */
281 readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>;
288 readonly createBranch: (
289 input: GitCreateBranchInput,
290 ) => Effect.Effect<GitCreateBranchResult, GitCommandError>;
282291
283292 /**
284293 * Checkout an existing branch and refresh its upstream metadata in background.
285294 */
286295 readonly checkoutBranch: (
287296 input: GitCheckoutInput,
288 ) => Effect.Effect<void, GitCommandError, Scope.Scope>;
297 ) => Effect.Effect<GitCheckoutResult, GitCommandError>;
289298
290299 /**
291300 * Initialize a repository in the provided directory.
apps/server/src/git/Services/GitManager.ts+31
......@@ -14,6 +14,8 @@ import {
1414 GitResolvePullRequestResult,
1515 GitRunStackedActionInput,
1616 GitRunStackedActionResult,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
1719 GitStatusInput,
1820 GitStatusResult,
1921} from "@t3tools/contracts";
......@@ -41,6 +43,35 @@ export interface GitManagerShape {
4143 input: GitStatusInput,
4244 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
4345
46 /**
47 * Read local repository status without remote hosting enrichment.
48 */
49 readonly localStatus: (
50 input: GitStatusInput,
51 ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
52
53 /**
54 * Read remote tracking / PR status for a repository.
55 */
56 readonly remoteStatus: (
57 input: GitStatusInput,
58 ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>;
59
60 /**
61 * Clear any cached local status snapshot for a repository.
62 */
63 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
64
65 /**
66 * Clear any cached remote status snapshot for a repository.
67 */
68 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
69
70 /**
71 * Clear any cached status snapshot for a repository so the next read is fresh.
72 */
73 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
74
4475 /**
4576 * Resolve a pull request by URL/number against the current repository.
4677 */
apps/server/src/git/Services/GitStatusBroadcaster.ts created+23
......@@ -0,0 +1,23 @@
1import { ServiceMap } from "effect";
2import type { Effect, Stream } from "effect";
3import type {
4 GitManagerServiceError,
5 GitStatusInput,
6 GitStatusResult,
7 GitStatusStreamEvent,
8} from "@t3tools/contracts";
9
10export interface GitStatusBroadcasterShape {
11 readonly getStatus: (
12 input: GitStatusInput,
13 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
14 readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
15 readonly streamStatus: (
16 input: GitStatusInput,
17 ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>;
18}
19
20export class GitStatusBroadcaster extends ServiceMap.Service<
21 GitStatusBroadcaster,
22 GitStatusBroadcasterShape
23>()("t3/git/Services/GitStatusBroadcaster") {}
apps/server/src/server.test.ts+376-13
......@@ -22,7 +22,16 @@ import {
2222} from "@t3tools/contracts";
2323import { assert, it } from "@effect/vitest";
2424import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
25import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect";
25import {
26 Deferred,
27 Duration,
28 Effect,
29 FileSystem,
30 Layer,
31 ManagedRuntime,
32 Path,
33 Stream,
34} from "effect";
2635import {
2736 FetchHttpClient,
2837 HttpBody,
......@@ -44,6 +53,7 @@ import {
4453} from "./checkpointing/Services/CheckpointDiffQuery.ts";
4554import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
4655import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
56import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";
4757import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
4858import { Open, type OpenShape } from "./open.ts";
4959import {
......@@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: {
294304 ...options?.config,
295305 };
296306 const layerConfig = Layer.succeed(ServerConfig, config);
307 const gitManagerLayer = Layer.mock(GitManager)({
308 ...options?.layers?.gitManager,
309 });
310 const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer));
297311
298312 const appLayer = HttpRouter.serve(makeRoutesLayer, {
299313 disableListenLog: true,
......@@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: {
333347 ...options?.layers?.gitCore,
334348 }),
335349 ),
336 Layer.provide(
337 Layer.mock(GitManager)({
338 ...options?.layers?.gitManager,
339 }),
340 ),
350 Layer.provide(gitManagerLayer),
351 Layer.provideMerge(gitStatusBroadcasterLayer),
341352 Layer.provide(
342353 Layer.mock(ProjectSetupScriptRunner)({
343354 runForThread: () => Effect.succeed({ status: "no-script" as const }),
......@@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
12601271 yield* buildAppUnderTest({
12611272 layers: {
12621273 gitManager: {
1274 invalidateLocalStatus: () => Effect.void,
1275 invalidateRemoteStatus: () => Effect.void,
1276 invalidateStatus: () => Effect.void,
1277 localStatus: () =>
1278 Effect.succeed({
1279 isRepo: true,
1280 hasOriginRemote: true,
1281 isDefaultBranch: true,
1282 branch: "main",
1283 hasWorkingTreeChanges: false,
1284 workingTree: { files: [], insertions: 0, deletions: 0 },
1285 }),
1286 remoteStatus: () =>
1287 Effect.succeed({
1288 hasUpstream: true,
1289 aheadCount: 0,
1290 behindCount: 0,
1291 pr: null,
1292 }),
12631293 status: () =>
12641294 Effect.succeed({
12651295 isRepo: true,
......@@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13731403 worktree: { path: "/tmp/wt", branch: "feature/demo" },
13741404 }),
13751405 removeWorktree: () => Effect.void,
1376 createBranch: () => Effect.void,
1377 checkoutBranch: () => Effect.void,
1406 createBranch: (input) => Effect.succeed({ branch: input.branch }),
1407 checkoutBranch: (input) => Effect.succeed({ branch: input.branch }),
13781408 initRepo: () => Effect.void,
13791409 },
13801410 },
......@@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13821412
13831413 const wsUrl = yield* getWsServerUrl("/ws");
13841414
1385 const status = yield* Effect.scoped(
1386 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })),
1387 );
1388 assert.equal(status.branch, "main");
1389
13901415 const pull = yield* Effect.scoped(
13911416 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
13921417 );
13931418 assert.equal(pull.status, "pulled");
13941419
1420 const refreshedStatus = yield* Effect.scoped(
1421 withWsRpcClient(wsUrl, (client) =>
1422 client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }),
1423 ),
1424 );
1425 assert.equal(refreshedStatus.isRepo, true);
1426
13951427 const stackedEvents = yield* Effect.scoped(
13961428 withWsRpcClient(wsUrl, (client) =>
13971429 client[WS_METHODS.gitRunStackedAction]({
......@@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
14941526 cwd: "/tmp/repo",
14951527 detail: "upstream missing",
14961528 });
1529 let invalidationCalls = 0;
1530 let statusCalls = 0;
14971531 yield* buildAppUnderTest({
14981532 layers: {
14991533 gitCore: {
15001534 pullCurrentBranch: () => Effect.fail(gitError),
15011535 },
1536 gitManager: {
1537 invalidateLocalStatus: () =>
1538 Effect.sync(() => {
1539 invalidationCalls += 1;
1540 }),
1541 invalidateRemoteStatus: () =>
1542 Effect.sync(() => {
1543 invalidationCalls += 1;
1544 }),
1545 invalidateStatus: () =>
1546 Effect.sync(() => {
1547 invalidationCalls += 1;
1548 }),
1549 localStatus: () =>
1550 Effect.succeed({
1551 isRepo: true,
1552 hasOriginRemote: true,
1553 isDefaultBranch: true,
1554 branch: "main",
1555 hasWorkingTreeChanges: true,
1556 workingTree: { files: [], insertions: 0, deletions: 0 },
1557 }),
1558 remoteStatus: () =>
1559 Effect.sync(() => {
1560 statusCalls += 1;
1561 return {
1562 hasUpstream: true,
1563 aheadCount: 0,
1564 behindCount: 0,
1565 pr: null,
1566 };
1567 }),
1568 status: () =>
1569 Effect.sync(() => {
1570 statusCalls += 1;
1571 return {
1572 isRepo: true,
1573 hasOriginRemote: true,
1574 isDefaultBranch: true,
1575 branch: "main",
1576 hasWorkingTreeChanges: true,
1577 workingTree: { files: [], insertions: 0, deletions: 0 },
1578 hasUpstream: true,
1579 aheadCount: 0,
1580 behindCount: 0,
1581 pr: null,
1582 };
1583 }),
1584 },
15021585 },
15031586 });
15041587
......@@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
15101593 );
15111594
15121595 assertFailure(result, gitError);
1596 assert.equal(invalidationCalls, 0);
1597 assert.equal(statusCalls, 0);
1598 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1599 );
1600
1601 it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () =>
1602 Effect.gen(function* () {
1603 const gitError = new GitCommandError({
1604 operation: "commit",
1605 command: "git commit",
1606 cwd: "/tmp/repo",
1607 detail: "nothing to commit",
1608 });
1609 let invalidationCalls = 0;
1610 let statusCalls = 0;
1611 yield* buildAppUnderTest({
1612 layers: {
1613 gitManager: {
1614 invalidateLocalStatus: () =>
1615 Effect.sync(() => {
1616 invalidationCalls += 1;
1617 }),
1618 invalidateRemoteStatus: () =>
1619 Effect.sync(() => {
1620 invalidationCalls += 1;
1621 }),
1622 invalidateStatus: () =>
1623 Effect.sync(() => {
1624 invalidationCalls += 1;
1625 }),
1626 localStatus: () =>
1627 Effect.succeed({
1628 isRepo: true,
1629 hasOriginRemote: true,
1630 isDefaultBranch: false,
1631 branch: "feature/demo",
1632 hasWorkingTreeChanges: true,
1633 workingTree: { files: [], insertions: 0, deletions: 0 },
1634 }),
1635 remoteStatus: () =>
1636 Effect.sync(() => {
1637 statusCalls += 1;
1638 return {
1639 hasUpstream: true,
1640 aheadCount: 0,
1641 behindCount: 0,
1642 pr: null,
1643 };
1644 }),
1645 status: () =>
1646 Effect.sync(() => {
1647 statusCalls += 1;
1648 return {
1649 isRepo: true,
1650 hasOriginRemote: true,
1651 isDefaultBranch: false,
1652 branch: "feature/demo",
1653 hasWorkingTreeChanges: true,
1654 workingTree: { files: [], insertions: 0, deletions: 0 },
1655 hasUpstream: true,
1656 aheadCount: 0,
1657 behindCount: 0,
1658 pr: null,
1659 };
1660 }),
1661 runStackedAction: () => Effect.fail(gitError),
1662 },
1663 },
1664 });
1665
1666 const wsUrl = yield* getWsServerUrl("/ws");
1667 const result = yield* Effect.scoped(
1668 withWsRpcClient(wsUrl, (client) =>
1669 client[WS_METHODS.gitRunStackedAction]({
1670 actionId: "action-1",
1671 cwd: "/tmp/repo",
1672 action: "commit",
1673 }).pipe(Stream.runCollect, Effect.result),
1674 ),
1675 );
1676
1677 assertFailure(result, gitError);
1678 assert.equal(invalidationCalls, 0);
1679 assert.equal(statusCalls, 0);
1680 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1681 );
1682
1683 it.effect("completes websocket rpc git.pull before background git status refresh finishes", () =>
1684 Effect.gen(function* () {
1685 yield* buildAppUnderTest({
1686 layers: {
1687 gitCore: {
1688 pullCurrentBranch: () =>
1689 Effect.succeed({
1690 status: "pulled" as const,
1691 branch: "main",
1692 upstreamBranch: "origin/main",
1693 }),
1694 },
1695 gitManager: {
1696 invalidateLocalStatus: () => Effect.void,
1697 invalidateRemoteStatus: () => Effect.void,
1698 localStatus: () =>
1699 Effect.succeed({
1700 isRepo: true,
1701 hasOriginRemote: true,
1702 isDefaultBranch: true,
1703 branch: "main",
1704 hasWorkingTreeChanges: false,
1705 workingTree: { files: [], insertions: 0, deletions: 0 },
1706 }),
1707 remoteStatus: () =>
1708 Effect.sleep(Duration.seconds(2)).pipe(
1709 Effect.as({
1710 hasUpstream: true,
1711 aheadCount: 0,
1712 behindCount: 0,
1713 pr: null,
1714 }),
1715 ),
1716 },
1717 },
1718 });
1719
1720 const wsUrl = yield* getWsServerUrl("/ws");
1721 const startedAt = Date.now();
1722 const result = yield* Effect.scoped(
1723 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
1724 );
1725 const elapsedMs = Date.now() - startedAt;
1726
1727 assert.equal(result.status, "pulled");
1728 assertTrue(elapsedMs < 1_000);
15131729 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
15141730 );
15151731
1732 it.effect(
1733 "completes websocket rpc git.runStackedAction before background git status refresh finishes",
1734 () =>
1735 Effect.gen(function* () {
1736 yield* buildAppUnderTest({
1737 layers: {
1738 gitManager: {
1739 invalidateLocalStatus: () => Effect.void,
1740 invalidateRemoteStatus: () => Effect.void,
1741 localStatus: () =>
1742 Effect.succeed({
1743 isRepo: true,
1744 hasOriginRemote: true,
1745 isDefaultBranch: false,
1746 branch: "feature/demo",
1747 hasWorkingTreeChanges: false,
1748 workingTree: { files: [], insertions: 0, deletions: 0 },
1749 }),
1750 remoteStatus: () =>
1751 Effect.sleep(Duration.seconds(2)).pipe(
1752 Effect.as({
1753 hasUpstream: true,
1754 aheadCount: 0,
1755 behindCount: 0,
1756 pr: null,
1757 }),
1758 ),
1759 runStackedAction: () =>
1760 Effect.succeed({
1761 action: "commit" as const,
1762 branch: { status: "skipped_not_requested" as const },
1763 commit: {
1764 status: "created" as const,
1765 commitSha: "abc123",
1766 subject: "feat: demo",
1767 },
1768 push: { status: "skipped_not_requested" as const },
1769 pr: { status: "skipped_not_requested" as const },
1770 toast: {
1771 title: "Committed abc123",
1772 description: "feat: demo",
1773 cta: {
1774 kind: "run_action" as const,
1775 label: "Push",
1776 action: {
1777 kind: "push" as const,
1778 },
1779 },
1780 },
1781 }),
1782 },
1783 },
1784 });
1785
1786 const wsUrl = yield* getWsServerUrl("/ws");
1787 const startedAt = Date.now();
1788 yield* Effect.scoped(
1789 withWsRpcClient(wsUrl, (client) =>
1790 client[WS_METHODS.gitRunStackedAction]({
1791 actionId: "action-1",
1792 cwd: "/tmp/repo",
1793 action: "commit",
1794 }).pipe(Stream.runCollect),
1795 ),
1796 );
1797 const elapsedMs = Date.now() - startedAt;
1798
1799 assertTrue(elapsedMs < 1_000);
1800 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1801 );
1802
1803 it.effect(
1804 "starts a background local git status refresh after a successful git.runStackedAction",
1805 () =>
1806 Effect.gen(function* () {
1807 const localRefreshStarted = yield* Deferred.make<void>();
1808
1809 yield* buildAppUnderTest({
1810 layers: {
1811 gitManager: {
1812 invalidateLocalStatus: () => Effect.void,
1813 invalidateRemoteStatus: () => Effect.void,
1814 localStatus: () =>
1815 Deferred.succeed(localRefreshStarted, undefined).pipe(
1816 Effect.ignore,
1817 Effect.andThen(
1818 Effect.succeed({
1819 isRepo: true,
1820 hasOriginRemote: true,
1821 isDefaultBranch: false,
1822 branch: "feature/demo",
1823 hasWorkingTreeChanges: false,
1824 workingTree: { files: [], insertions: 0, deletions: 0 },
1825 }),
1826 ),
1827 ),
1828 remoteStatus: () =>
1829 Effect.sleep(Duration.seconds(2)).pipe(
1830 Effect.as({
1831 hasUpstream: true,
1832 aheadCount: 0,
1833 behindCount: 0,
1834 pr: null,
1835 }),
1836 ),
1837 runStackedAction: () =>
1838 Effect.succeed({
1839 action: "commit" as const,
1840 branch: { status: "skipped_not_requested" as const },
1841 commit: {
1842 status: "created" as const,
1843 commitSha: "abc123",
1844 subject: "feat: demo",
1845 },
1846 push: { status: "skipped_not_requested" as const },
1847 pr: { status: "skipped_not_requested" as const },
1848 toast: {
1849 title: "Committed abc123",
1850 description: "feat: demo",
1851 cta: {
1852 kind: "run_action" as const,
1853 label: "Push",
1854 action: {
1855 kind: "push" as const,
1856 },
1857 },
1858 },
1859 }),
1860 },
1861 },
1862 });
1863
1864 const wsUrl = yield* getWsServerUrl("/ws");
1865 yield* Effect.scoped(
1866 withWsRpcClient(wsUrl, (client) =>
1867 client[WS_METHODS.gitRunStackedAction]({
1868 actionId: "action-1",
1869 cwd: "/tmp/repo",
1870 action: "commit",
1871 }).pipe(Stream.runCollect),
1872 ),
1873 );
1874
1875 yield* Deferred.await(localRefreshStarted);
1876 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1877 );
1878
15161879 it.effect("routes websocket rpc orchestration methods", () =>
15171880 Effect.gen(function* () {
15181881 const now = new Date().toISOString();
apps/server/src/server.ts+10-8
......@@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers
3030import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
3131import { GitCoreLive } from "./git/Layers/GitCore";
3232import { GitHubCliLive } from "./git/Layers/GitHubCli";
33import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster";
3334import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
3435import { TerminalManagerLive } from "./terminal/Layers/Manager";
3536import { GitManagerLive } from "./git/Layers/GitManager";
......@@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap(
161162
162163const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
163164
165const GitManagerLayerLive = GitManagerLive.pipe(
166 Layer.provideMerge(ProjectSetupScriptRunnerLive),
167 Layer.provideMerge(GitCoreLive),
168 Layer.provideMerge(GitHubCliLive),
169 Layer.provideMerge(RoutingTextGenerationLive),
170);
171
164172const GitLayerLive = Layer.empty.pipe(
165 Layer.provideMerge(
166 GitManagerLive.pipe(
167 Layer.provideMerge(ProjectSetupScriptRunnerLive),
168 Layer.provideMerge(GitCoreLive),
169 Layer.provideMerge(GitHubCliLive),
170 Layer.provideMerge(RoutingTextGenerationLive),
171 ),
172 ),
173 Layer.provideMerge(GitManagerLayerLive),
174 Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))),
173175 Layer.provideMerge(GitCoreLive),
174176);
175177
apps/server/src/ws.ts+62-21
......@@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer
2727import { ServerConfig } from "./config";
2828import { GitCore } from "./git/Services/GitCore";
2929import { GitManager } from "./git/Services/GitManager";
30import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster";
3031import { Keybindings } from "./keybindings";
3132import { Open, resolveAvailableEditors } from "./open";
3233import { normalizeDispatchCommand } from "./orchestration/Normalizer";
......@@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(
5657 const open = yield* Open;
5758 const gitManager = yield* GitManager;
5859 const git = yield* GitCore;
60 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
5961 const terminalManager = yield* TerminalManager;
6062 const providerRegistry = yield* ProviderRegistry;
6163 const config = yield* ServerConfig;
......@@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer(
6567 const workspaceEntries = yield* WorkspaceEntries;
6668 const workspaceFileSystem = yield* WorkspaceFileSystem;
6769 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
68
6970 const serverCommandId = (tag: string) =>
7071 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
7172
......@@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer(
348349 };
349350 });
350351
352 const refreshGitStatus = (cwd: string) =>
353 gitStatusBroadcaster
354 .refreshStatus(cwd)
355 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
356
351357 return WsRpcGroup.of({
352358 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
353359 observeRpcEffect(
......@@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer(
559565 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
560566 "rpc.aggregate": "workspace",
561567 }),
562 [WS_METHODS.gitStatus]: (input) =>
563 observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), {
568 [WS_METHODS.subscribeGitStatus]: (input) =>
569 observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), {
564570 "rpc.aggregate": "git",
565571 }),
572 [WS_METHODS.gitRefreshStatus]: (input) =>
573 observeRpcEffect(
574 WS_METHODS.gitRefreshStatus,
575 gitStatusBroadcaster.refreshStatus(input.cwd),
576 {
577 "rpc.aggregate": "git",
578 },
579 ),
566580 [WS_METHODS.gitPull]: (input) =>
567 observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), {
568 "rpc.aggregate": "git",
569 }),
581 observeRpcEffect(
582 WS_METHODS.gitPull,
583 git.pullCurrentBranch(input.cwd).pipe(
584 Effect.matchCauseEffect({
585 onFailure: (cause) => Effect.failCause(cause),
586 onSuccess: (result) =>
587 refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)),
588 }),
589 ),
590 { "rpc.aggregate": "git" },
591 ),
570592 [WS_METHODS.gitRunStackedAction]: (input) =>
571593 observeRpcStream(
572594 WS_METHODS.gitRunStackedAction,
......@@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(
581603 .pipe(
582604 Effect.matchCauseEffect({
583605 onFailure: (cause) => Queue.failCause(queue, cause),
584 onSuccess: () => Queue.end(queue).pipe(Effect.asVoid),
606 onSuccess: () =>
607 refreshGitStatus(input.cwd).pipe(
608 Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)),
609 ),
585610 }),
586611 ),
587612 ),
......@@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer(
594619 [WS_METHODS.gitPreparePullRequestThread]: (input) =>
595620 observeRpcEffect(
596621 WS_METHODS.gitPreparePullRequestThread,
597 gitManager.preparePullRequestThread(input),
622 gitManager
623 .preparePullRequestThread(input)
624 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
598625 { "rpc.aggregate": "git" },
599626 ),
600627 [WS_METHODS.gitListBranches]: (input) =>
......@@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer(
602629 "rpc.aggregate": "git",
603630 }),
604631 [WS_METHODS.gitCreateWorktree]: (input) =>
605 observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), {
606 "rpc.aggregate": "git",
607 }),
632 observeRpcEffect(
633 WS_METHODS.gitCreateWorktree,
634 git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
635 { "rpc.aggregate": "git" },
636 ),
608637 [WS_METHODS.gitRemoveWorktree]: (input) =>
609 observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), {
610 "rpc.aggregate": "git",
611 }),
638 observeRpcEffect(
639 WS_METHODS.gitRemoveWorktree,
640 git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
641 { "rpc.aggregate": "git" },
642 ),
612643 [WS_METHODS.gitCreateBranch]: (input) =>
613 observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), {
614 "rpc.aggregate": "git",
615 }),
644 observeRpcEffect(
645 WS_METHODS.gitCreateBranch,
646 git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
647 { "rpc.aggregate": "git" },
648 ),
616649 [WS_METHODS.gitCheckout]: (input) =>
617 observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), {
618 "rpc.aggregate": "git",
619 }),
650 observeRpcEffect(
651 WS_METHODS.gitCheckout,
652 Effect.scoped(git.checkoutBranch(input)).pipe(
653 Effect.tap(() => refreshGitStatus(input.cwd)),
654 ),
655 { "rpc.aggregate": "git" },
656 ),
620657 [WS_METHODS.gitInit]: (input) =>
621 observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }),
658 observeRpcEffect(
659 WS_METHODS.gitInit,
660 git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
661 { "rpc.aggregate": "git" },
662 ),
622663 [WS_METHODS.terminalOpen]: (input) =>
623664 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
624665 "rpc.aggregate": "terminal",
apps/web/src/components/BranchToolbarBranchSelector.tsx+28-42
......@@ -1,5 +1,5 @@
11import type { GitBranch } from "@t3tools/contracts";
2import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
2import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
33import { useVirtualizer } from "@tanstack/react-virtual";
44import { ChevronDownIcon } from "lucide-react";
55import {
......@@ -14,12 +14,8 @@ import {
1414 useTransition,
1515} from "react";
1616
17import {
18 gitBranchSearchInfiniteQueryOptions,
19 gitQueryKeys,
20 gitStatusQueryOptions,
21 invalidateGitQueries,
22} from "../lib/gitReactQuery";
17import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
18import { useGitStatus } from "../lib/gitStatusState";
2319import { readNativeApi } from "../nativeApi";
2420import { parsePullRequestReference } from "../pullRequestReference";
2521import {
......@@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({
8985 const [branchQuery, setBranchQuery] = useState("");
9086 const deferredBranchQuery = useDeferredValue(branchQuery);
9187
92 const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));
88 const branchStatusQuery = useGitStatus(branchCwd);
9389 const trimmedBranchQuery = branchQuery.trim();
9490 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
9591
......@@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({
110106 gitBranchSearchInfiniteQueryOptions({
111107 cwd: branchCwd,
112108 query: deferredTrimmedBranchQuery,
113 enabled: isBranchMenuOpen,
114109 }),
115110 );
116111 const branches = useMemo(
......@@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({
188183 const runBranchAction = (action: () => Promise<void>) => {
189184 startBranchActionTransition(async () => {
190185 await action().catch(() => undefined);
191 await invalidateGitQueries(queryClient).catch(() => undefined);
186 await queryClient
187 .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) })
188 .catch(() => undefined);
192189 });
193190 };
194191
......@@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({
226223 onComposerFocusRequest?.();
227224
228225 runBranchAction(async () => {
226 const previousBranch = resolvedActiveBranch;
229227 setOptimisticBranch(selectedBranchName);
230228 try {
231 await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name });
232 await invalidateGitQueries(queryClient);
229 const checkoutResult = await api.git.checkout({
230 cwd: selectionTarget.checkoutCwd,
231 branch: branch.name,
232 });
233 const nextBranchName = branch.isRemote
234 ? (checkoutResult.branch ?? selectedBranchName)
235 : selectedBranchName;
236 setOptimisticBranch(nextBranchName);
237 onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
233238 } catch (error) {
239 setOptimisticBranch(previousBranch);
234240 toastManager.add({
235241 type: "error",
236242 title: "Failed to checkout branch.",
237243 description: toBranchActionErrorMessage(error),
238244 });
239 return;
240245 }
241
242 let nextBranchName = selectedBranchName;
243 if (branch.isRemote) {
244 const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null);
245 if (status?.branch) {
246 nextBranchName = status.branch;
247 }
248 }
249
250 setOptimisticBranch(nextBranchName);
251 onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
252246 });
253247 };
254248
......@@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({
261255 onComposerFocusRequest?.();
262256
263257 runBranchAction(async () => {
258 const previousBranch = resolvedActiveBranch;
264259 setOptimisticBranch(name);
265
266260 try {
267 await api.git.createBranch({ cwd: branchCwd, branch: name });
268 try {
269 await api.git.checkout({ cwd: branchCwd, branch: name });
270 } catch (error) {
271 toastManager.add({
272 type: "error",
273 title: "Failed to checkout branch.",
274 description: toBranchActionErrorMessage(error),
275 });
276 return;
277 }
261 const createBranchResult = await api.git.createBranch({
262 cwd: branchCwd,
263 branch: name,
264 checkout: true,
265 });
266 setOptimisticBranch(createBranchResult.branch);
267 onSetThreadBranch(createBranchResult.branch, activeWorktreePath);
278268 } catch (error) {
269 setOptimisticBranch(previousBranch);
279270 toastManager.add({
280271 type: "error",
281 title: "Failed to create branch.",
272 title: "Failed to create and checkout branch.",
282273 description: toBranchActionErrorMessage(error),
283274 });
284 return;
285275 }
286
287 setOptimisticBranch(name);
288 onSetThreadBranch(name, activeWorktreePath);
289 setBranchQuery("");
290276 });
291277 };
292278
apps/web/src/components/ChatView.browser.tsx+43-28
......@@ -31,6 +31,8 @@ import {
3131} from "../lib/terminalContext";
3232import { isMacPlatform } from "../lib/utils";
3333import { __resetNativeApiForTests } from "../nativeApi";
34import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
35import { getServerConfig } from "../rpc/serverState";
3436import { getRouter } from "../router";
3537import { useStore } from "../store";
3638import { useTerminalStateStore } from "../terminalStateStore";
......@@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test
3840import { estimateTimelineMessageHeight } from "./timelineHeight";
3941import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
4042
43vi.mock("../lib/gitStatusState", () => ({
44 useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),
45 useGitStatuses: () => new Map(),
46 refreshGitStatus: () => Promise.resolve(null),
47 resetGitStatusStateForTests: () => undefined,
48}));
49
4150const THREAD_ID = "thread-browser-test" as ThreadId;
4251const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4352const PROJECT_ID = "project-1" as ProjectId;
4453const NOW_ISO = "2026-03-04T12:00:00.000Z";
4554const BASE_TIME_MS = Date.parse(NOW_ISO);
46const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='300'></svg>";
55const ATTACHMENT_SVG = "<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'></svg>";
4756
4857interface TestFixture {
4958 snapshot: OrchestrationReadModel;
......@@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [
93102 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },
94103] as const satisfies readonly ViewportSpec[];
95104const ATTACHMENT_VIEWPORT_MATRIX = [
96 DEFAULT_VIEWPORT,
97 { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 },
98 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },
105 { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 },
106 { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 },
107 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 },
99108] as const satisfies readonly ViewportSpec[];
100109
101110interface UserRowMeasurement {
......@@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: {
224233 name: `attachment-${attachmentIndex + 1}.png`,
225234 mimeType: "image/png",
226235 sizeBytes: 128,
236 previewUrl: `/attachments/attachment-${attachmentIndex + 1}`,
227237 }))
228238 : undefined;
229239
......@@ -397,6 +407,22 @@ async function waitForWsClient(): Promise<void> {
397407 (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
398408 ),
399409 ).toBe(true);
410 expect(
411 wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),
412 ).toBe(true);
413 expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe(
414 true,
415 );
416 },
417 { timeout: 8_000, interval: 16 },
418 );
419}
420
421async function waitForAppBootstrap(): Promise<void> {
422 await vi.waitFor(
423 () => {
424 expect(getServerConfig()).not.toBeNull();
425 expect(useStore.getState().bootstrapComplete).toBe(true);
400426 },
401427 { timeout: 8_000, interval: 16 },
402428 );
......@@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
651677 ],
652678 };
653679 }
654 if (tag === WS_METHODS.gitStatus) {
655 return {
656 isRepo: true,
657 hasOriginRemote: true,
658 isDefaultBranch: true,
659 branch: "main",
660 hasWorkingTreeChanges: false,
661 workingTree: {
662 files: [],
663 insertions: 0,
664 deletions: 0,
665 },
666 hasUpstream: true,
667 aheadCount: 0,
668 behindCount: 0,
669 pr: null,
670 };
671 }
672680 if (tag === WS_METHODS.projectsSearchEntries) {
673681 return {
674682 entries: [],
......@@ -1044,10 +1052,17 @@ async function mountChatView(options: {
10441052 }),
10451053 );
10461054
1047 const screen = await render(<RouterProvider router={router} />, {
1048 container: host,
1049 });
1055 const screen = await render(
1056 <AppAtomRegistryProvider>
1057 <RouterProvider router={router} />
1058 </AppAtomRegistryProvider>,
1059 {
1060 container: host,
1061 },
1062 );
10501063
1064 await waitForWsClient();
1065 await waitForAppBootstrap();
10511066 await waitForLayout();
10521067
10531068 const cleanup = async () => {
......@@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
11381153 return [];
11391154 },
11401155 });
1141 __resetNativeApiForTests();
1156 await __resetNativeApiForTests();
11421157 await setViewport(DEFAULT_VIEWPORT);
11431158 localStorage.clear();
11441159 document.body.innerHTML = "";
......@@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
12991314 snapshot: createSnapshotForTargetUser({
13001315 targetMessageId,
13011316 targetText: userText,
1302 targetAttachmentCount: 3,
1317 targetAttachmentCount: 2,
13031318 }),
13041319 });
13051320
......@@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
13131328 {
13141329 role: "user",
13151330 text: userText,
1316 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }],
1331 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }],
13171332 },
13181333 { timelineWidthPx: timelineWidthMeasuredPx },
13191334 );
apps/web/src/components/ChatView.tsx+2-2
......@@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
2727import { useQuery } from "@tanstack/react-query";
2828import { useDebouncedValue } from "@tanstack/react-pacer";
2929import { useNavigate, useSearch } from "@tanstack/react-router";
30import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
30import { useGitStatus } from "~/lib/gitStatusState";
3131import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3232import { isElectron } from "../env";
3333import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
......@@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
13991399 (debouncerState) => ({ isPending: debouncerState.isPending }),
14001400 );
14011401 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1402 const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd));
1402 const gitStatusQuery = useGitStatus(gitCwd);
14031403 const keybindings = useServerKeybindings();
14041404 const availableEditors = useServerAvailableEditors();
14051405 const modelOptionsByProvider = useMemo(
apps/web/src/components/DiffPanel.tsx+2-2
......@@ -19,7 +19,7 @@ import {
1919 useState,
2020} from "react";
2121import { openInPreferredEditor } from "../editorPreferences";
22import { gitStatusQueryOptions } from "~/lib/gitReactQuery";
22import { useGitStatus } from "~/lib/gitStatusState";
2323import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2424import { cn } from "~/lib/utils";
2525import { readNativeApi } from "../nativeApi";
......@@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
189189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190190 );
191191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192 const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null));
192 const gitStatusQuery = useGitStatus(activeCwd ?? null);
193193 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
194194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195195 useTurnDiffSummaries(activeThread);
apps/web/src/components/GitActionsControl.browser.tsx+58-20
......@@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope";
1010
1111const {
1212 invalidateGitQueriesSpy,
13 invalidateGitStatusQuerySpy,
13 refreshGitStatusSpy,
1414 runStackedActionMutateAsyncSpy,
1515 setThreadBranchSpy,
1616 toastAddSpy,
......@@ -19,7 +19,7 @@ const {
1919 toastUpdateSpy,
2020} = vi.hoisted(() => ({
2121 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
22 invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()),
22 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
2323 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),
2424 setThreadBranchSpy: vi.fn(),
2525 toastAddSpy: vi.fn(() => "toast-1"),
......@@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => {
5757 };
5858 }),
5959 useQuery: vi.fn((options: { queryKey?: string[] }) => {
60 if (options.queryKey?.[0] === "git-status") {
61 return {
62 data: {
63 branch: BRANCH_NAME,
64 hasWorkingTreeChanges: false,
65 workingTree: { files: [], insertions: 0, deletions: 0 },
66 hasUpstream: true,
67 aheadCount: 1,
68 behindCount: 0,
69 pr: null,
70 },
71 error: null,
72 };
73 }
74
7560 if (options.queryKey?.[0] === "git-branches") {
7661 return {
7762 data: {
......@@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({
11095}));
11196
11297vi.mock("~/lib/gitReactQuery", () => ({
113 gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })),
11498 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),
11599 gitMutationKeys: {
116100 pull: vi.fn(() => ["pull"]),
......@@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({
118102 },
119103 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),
120104 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),
121 gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })),
122105 invalidateGitQueries: invalidateGitQueriesSpy,
123 invalidateGitStatusQuery: invalidateGitStatusQuerySpy,
106}));
107
108vi.mock("~/lib/gitStatusState", () => ({
109 refreshGitStatus: refreshGitStatusSpy,
110 resetGitStatusStateForTests: () => undefined,
111 useGitStatus: vi.fn(() => ({
112 data: {
113 branch: BRANCH_NAME,
114 hasWorkingTreeChanges: false,
115 workingTree: { files: [], insertions: 0, deletions: 0 },
116 hasUpstream: true,
117 aheadCount: 1,
118 behindCount: 0,
119 pr: null,
120 },
121 error: null,
122 isPending: false,
123 })),
124124}));
125125
126126vi.mock("~/lib/utils", async () => {
......@@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {
235235 host.remove();
236236 }
237237 });
238
239 it("debounces focus-driven git status refreshes", async () => {
240 vi.useFakeTimers();
241
242 const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState");
243 let visibilityState: DocumentVisibilityState = "hidden";
244 Object.defineProperty(document, "visibilityState", {
245 configurable: true,
246 get: () => visibilityState,
247 });
248
249 const host = document.createElement("div");
250 document.body.append(host);
251 const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, {
252 container: host,
253 });
254
255 try {
256 window.dispatchEvent(new Event("focus"));
257 visibilityState = "visible";
258 document.dispatchEvent(new Event("visibilitychange"));
259
260 expect(refreshGitStatusSpy).not.toHaveBeenCalled();
261
262 await vi.advanceTimersByTimeAsync(249);
263 expect(refreshGitStatusSpy).not.toHaveBeenCalled();
264
265 await vi.advanceTimersByTimeAsync(1);
266 expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1);
267 expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD);
268 } finally {
269 if (originalVisibilityState) {
270 Object.defineProperty(document, "visibilityState", originalVisibilityState);
271 }
272 await screen.unmount();
273 host.remove();
274 }
275 });
238276});
apps/web/src/components/GitActionsControl.tsx+41-5
......@@ -5,7 +5,7 @@ import type {
55 GitStatusResult,
66 ThreadId,
77} from "@t3tools/contracts";
8import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
99import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1010import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
1111import { GitHubIcon } from "./Icons";
......@@ -45,9 +45,8 @@ import {
4545 gitMutationKeys,
4646 gitPullMutationOptions,
4747 gitRunStackedActionMutationOptions,
48 gitStatusQueryOptions,
49 invalidateGitStatusQuery,
5048} from "~/lib/gitReactQuery";
49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
5150import { newCommandId, randomUUID } from "~/lib/utils";
5251import { resolvePathLinkTarget } from "~/terminal-links";
5352import { readNativeApi } from "~/nativeApi";
......@@ -92,6 +91,8 @@ interface RunGitActionWithToastInput {
9291 filePaths?: string[];
9392}
9493
94const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
95
9596function formatElapsedDescription(startedAtMs: number | null): string | undefined {
9697 if (startedAtMs === null) {
9798 return undefined;
......@@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
275276 [persistThreadBranchSync],
276277 );
277278
278 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));
279 const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);
279280 // Default to true while loading so we don't flash init controls.
280281 const isRepo = gitStatus?.isRepo ?? true;
281282 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
......@@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
359360 };
360361 }, [updateActiveProgressToast]);
361362
363 useEffect(() => {
364 if (gitCwd === null) {
365 return;
366 }
367
368 let refreshTimeout: number | null = null;
369 const scheduleRefreshCurrentGitStatus = () => {
370 if (refreshTimeout !== null) {
371 window.clearTimeout(refreshTimeout);
372 }
373 refreshTimeout = window.setTimeout(() => {
374 refreshTimeout = null;
375 void refreshGitStatus(gitCwd).catch(() => undefined);
376 }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS);
377 };
378 const handleVisibilityChange = () => {
379 if (document.visibilityState === "visible") {
380 scheduleRefreshCurrentGitStatus();
381 }
382 };
383
384 window.addEventListener("focus", scheduleRefreshCurrentGitStatus);
385 document.addEventListener("visibilitychange", handleVisibilityChange);
386
387 return () => {
388 if (refreshTimeout !== null) {
389 window.clearTimeout(refreshTimeout);
390 }
391 window.removeEventListener("focus", scheduleRefreshCurrentGitStatus);
392 document.removeEventListener("visibilitychange", handleVisibilityChange);
393 };
394 }, [gitCwd]);
395
362396 const openExistingPr = useCallback(async () => {
363397 const api = readNativeApi();
364398 if (!api) {
......@@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
801835 <GroupSeparator className="hidden @3xl/header-actions:block" />
802836 <Menu
803837 onOpenChange={(open) => {
804 if (open) void invalidateGitStatusQuery(queryClient, gitCwd);
838 if (open) {
839 void refreshGitStatus(gitCwd).catch(() => undefined);
840 }
805841 }}
806842 >
807843 <MenuTrigger
apps/web/src/components/KeybindingsToast.browser.tsx+41-16
......@@ -19,10 +19,19 @@ import { render } from "vitest-browser-react";
1919
2020import { useComposerDraftStore } from "../composerDraftStore";
2121import { __resetNativeApiForTests } from "../nativeApi";
22import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
23import { getServerConfig } from "../rpc/serverState";
2224import { getRouter } from "../router";
2325import { useStore } from "../store";
2426import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";
2527
28vi.mock("../lib/gitStatusState", () => ({
29 useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }),
30 useGitStatuses: () => new Map(),
31 refreshGitStatus: () => Promise.resolve(null),
32 resetGitStatusStateForTests: () => undefined,
33}));
34
2635const THREAD_ID = "thread-kb-toast-test" as ThreadId;
2736const PROJECT_ID = "project-1" as ProjectId;
2837const NOW_ISO = "2026-03-04T12:00:00.000Z";
......@@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown {
170179 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
171180 };
172181 }
173 if (tag === WS_METHODS.gitStatus) {
174 return {
175 isRepo: true,
176 hasOriginRemote: true,
177 isDefaultBranch: true,
178 branch: "main",
179 hasWorkingTreeChanges: false,
180 workingTree: { files: [], insertions: 0, deletions: 0 },
181 hasUpstream: true,
182 aheadCount: 0,
183 behindCount: 0,
184 pr: null,
185 };
186 }
187182 if (tag === WS_METHODS.projectsSearchEntries) {
188183 return { entries: [], truncated: false };
189184 }
......@@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise<void> {
258253 );
259254}
260255
256async function waitForInitialWsSubscriptions(): Promise<void> {
257 await vi.waitFor(
258 () => {
259 expect(
260 rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle),
261 ).toBe(true);
262 expect(
263 rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig),
264 ).toBe(true);
265 },
266 { timeout: 8_000, interval: 16 },
267 );
268}
269
270async function waitForServerConfigSnapshot(): Promise<void> {
271 await vi.waitFor(
272 () => {
273 expect(getServerConfig()).not.toBeNull();
274 },
275 { timeout: 8_000, interval: 16 },
276 );
277}
278
261279async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
262280 const host = document.createElement("div");
263281 host.style.position = "fixed";
......@@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
270288
271289 const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));
272290
273 const screen = await render(<RouterProvider router={router} />, { container: host });
291 const screen = await render(
292 <AppAtomRegistryProvider>
293 <RouterProvider router={router} />
294 </AppAtomRegistryProvider>,
295 { container: host },
296 );
274297 await waitForComposerEditor();
298 await waitForInitialWsSubscriptions();
299 await waitForServerConfigSnapshot();
275300
276301 return {
277302 cleanup: async () => {
......@@ -322,7 +347,7 @@ describe("Keybindings update toast", () => {
322347 return [];
323348 },
324349 });
325 __resetNativeApiForTests();
350 await __resetNativeApiForTests();
326351 localStorage.clear();
327352 document.body.innerHTML = "";
328353 useComposerDraftStore.setState({
apps/web/src/components/Sidebar.tsx+18-53
......@@ -49,7 +49,6 @@ import {
4949 ThreadId,
5050 type GitStatusResult,
5151} from "@t3tools/contracts";
52import { useQueries } from "@tanstack/react-query";
5352import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";
5453import {
5554 type SidebarProjectSortOrder,
......@@ -70,7 +69,7 @@ import {
7069 threadJumpIndexFromCommand,
7170 threadTraversalDirectionFromCommand,
7271} from "../keybindings";
73import { gitStatusQueryOptions } from "../lib/gitReactQuery";
72import { useGitStatus } from "../lib/gitStatusState";
7473import { readNativeApi } from "../nativeApi";
7574import { useComposerDraftStore } from "../composerDraftStore";
7675import { useHandleNewThread } from "../hooks/useHandleNewThread";
......@@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
245244 return null;
246245}
247246
247function resolveThreadPr(
248 threadBranch: string | null,
249 gitStatus: GitStatusResult | null,
250): ThreadPr | null {
251 if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {
252 return null;
253 }
254
255 return gitStatus.pr ?? null;
256}
257
248258interface SidebarThreadRowProps {
249259 threadId: ThreadId;
260 projectCwd: string | null;
250261 orderedProjectThreadIds: readonly ThreadId[];
251262 routeThreadId: ThreadId | null;
252263 selectedThreadIds: ReadonlySet<ThreadId>;
......@@ -277,7 +288,6 @@ interface SidebarThreadRowProps {
277288 cancelRename: () => void;
278289 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;
279290 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;
280 pr: ThreadPr | null;
281291}
282292
283293function SidebarThreadRow(props: SidebarThreadRowProps) {
......@@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
287297 (state) =>
288298 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
289299 );
300 const gitCwd = thread?.worktreePath ?? props.projectCwd;
301 const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);
290302
291303 if (!thread) {
292304 return null;
......@@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
303315 lastVisitedAt,
304316 },
305317 });
306 const prStatus = prStatusIndicator(props.pr);
318 const pr = resolveThreadPr(thread.branch, gitStatus.data);
319 const prStatus = prStatusIndicator(pr);
307320 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
308321 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;
309322 const threadMetaClassName = isConfirmingArchive
......@@ -762,54 +775,6 @@ export default function Sidebar() {
762775 }),
763776 [platform, routeTerminalOpen],
764777 );
765 const threadGitTargets = useMemo(
766 () =>
767 sidebarThreads.map((thread) => ({
768 threadId: thread.id,
769 branch: thread.branch,
770 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
771 })),
772 [projectCwdById, sidebarThreads],
773 );
774 const threadGitStatusCwds = useMemo(
775 () => [
776 ...new Set(
777 threadGitTargets
778 .filter((target) => target.branch !== null)
779 .map((target) => target.cwd)
780 .filter((cwd): cwd is string => cwd !== null),
781 ),
782 ],
783 [threadGitTargets],
784 );
785 const threadGitStatusQueries = useQueries({
786 queries: threadGitStatusCwds.map((cwd) => ({
787 ...gitStatusQueryOptions(cwd),
788 staleTime: 30_000,
789 refetchInterval: 60_000,
790 })),
791 });
792 const prByThreadId = useMemo(() => {
793 const statusByCwd = new Map<string, GitStatusResult>();
794 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
795 const cwd = threadGitStatusCwds[index];
796 if (!cwd) continue;
797 const status = threadGitStatusQueries[index]?.data;
798 if (status) {
799 statusByCwd.set(cwd, status);
800 }
801 }
802
803 const map = new Map<ThreadId, ThreadPr>();
804 for (const target of threadGitTargets) {
805 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
806 const branchMatches =
807 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
808 map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null);
809 }
810 return map;
811 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
812
813778 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
814779 event.preventDefault();
815780 event.stopPropagation();
......@@ -1718,6 +1683,7 @@ export default function Sidebar() {
17181683 <SidebarThreadRow
17191684 key={threadId}
17201685 threadId={threadId}
1686 projectCwd={project.cwd}
17211687 orderedProjectThreadIds={orderedProjectThreadIds}
17221688 routeThreadId={routeThreadId}
17231689 selectedThreadIds={selectedThreadIds}
......@@ -1741,7 +1707,6 @@ export default function Sidebar() {
17411707 cancelRename={cancelRename}
17421708 attemptArchiveThread={attemptArchiveThread}
17431709 openPrLink={openPrLink}
1744 pr={prByThreadId.get(threadId) ?? null}
17451710 />
17461711 ))}
17471712
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
......@@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
385385 <img
386386 src={image.previewUrl}
387387 alt={image.name}
388 className="h-full max-h-[220px] w-full object-cover"
388 className="block h-auto max-h-[220px] w-full object-cover"
389389 onLoad={onTimelineImageLoad}
390390 onError={onTimelineImageLoad}
391391 />
apps/web/src/components/settings/SettingsPanels.browser.tsx+4-4
......@@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig {
3030}
3131
3232describe("GeneralSettingsPanel observability", () => {
33 beforeEach(() => {
33 beforeEach(async () => {
3434 resetServerStateForTests();
35 __resetNativeApiForTests();
35 await __resetNativeApiForTests();
3636 localStorage.clear();
3737 document.body.innerHTML = "";
3838 });
3939
40 afterEach(() => {
40 afterEach(async () => {
4141 resetServerStateForTests();
42 __resetNativeApiForTests();
42 await __resetNativeApiForTests();
4343 document.body.innerHTML = "";
4444 });
4545
apps/web/src/components/timelineHeight.test.ts+4-4
......@@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => {
3030 text: "hello",
3131 attachments: [{ id: "1" }],
3232 }),
33 ).toBe(346);
33 ).toBe(234);
3434
3535 expect(
3636 estimateTimelineMessageHeight({
......@@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => {
3838 text: "hello",
3939 attachments: [{ id: "1" }, { id: "2" }],
4040 }),
41 ).toBe(346);
41 ).toBe(234);
4242 });
4343
4444 it("adds a second attachment row for three or four user attachments", () => {
......@@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => {
4848 text: "hello",
4949 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }],
5050 }),
51 ).toBe(574);
51 ).toBe(350);
5252
5353 expect(
5454 estimateTimelineMessageHeight({
......@@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => {
5656 text: "hello",
5757 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],
5858 }),
59 ).toBe(574);
59 ).toBe(350);
6060 });
6161
6262 it("does not cap long user message estimates", () => {
apps/web/src/components/timelineHeight.ts+3-2
......@@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75;
1111const ASSISTANT_BASE_HEIGHT_PX = 41;
1212const USER_BASE_HEIGHT_PX = 96;
1313const ATTACHMENTS_PER_ROW = 2;
14// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap.
15const USER_ATTACHMENT_ROW_HEIGHT_PX = 228;
14// Full-app browser measurements land closer to a ~116px attachment row once
15// the bubble shrinks to content width, so calibrate the estimate to that DOM.
16const USER_ATTACHMENT_ROW_HEIGHT_PX = 116;
1617const USER_BUBBLE_WIDTH_RATIO = 0.8;
1718const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;
1819const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;
apps/web/src/lib/gitReactQuery.test.ts-29
......@@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts";
1515import {
1616 gitBranchSearchInfiniteQueryOptions,
1717 gitMutationKeys,
18 gitQueryKeys,
1918 gitPreparePullRequestThreadMutationOptions,
2019 gitPullMutationOptions,
2120 gitRunStackedActionMutationOptions,
22 invalidateGitStatusQuery,
23 gitStatusQueryOptions,
2421 invalidateGitQueries,
2522} from "./gitReactQuery";
2623
......@@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => {
8481 it("can invalidate a single cwd without blasting other git query scopes", async () => {
8582 const queryClient = new QueryClient();
8683
87 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
8884 queryClient.setQueryData(
8985 gitBranchSearchInfiniteQueryOptions({
9086 cwd: "/repo/a",
......@@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => {
9288 }).queryKey,
9389 BRANCH_SEARCH_RESULT,
9490 );
95 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
9691 queryClient.setQueryData(
9792 gitBranchSearchInfiniteQueryOptions({
9893 cwd: "/repo/b",
......@@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => {
10398
10499 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
105100
106 expect(
107 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
108 ).toBe(true);
109101 expect(
110102 queryClient.getQueryState(
111103 gitBranchSearchInfiniteQueryOptions({
......@@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => {
114106 }).queryKey,
115107 )?.isInvalidated,
116108 ).toBe(true);
117 expect(
118 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
119 ).toBe(false);
120109 expect(
121110 queryClient.getQueryState(
122111 gitBranchSearchInfiniteQueryOptions({
......@@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => {
127116 ).toBe(false);
128117 });
129118});
130
131describe("invalidateGitStatusQuery", () => {
132 it("invalidates only status for the selected cwd", async () => {
133 const queryClient = new QueryClient();
134
135 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
136 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
137
138 await invalidateGitStatusQuery(queryClient, "/repo/a");
139
140 expect(
141 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
142 ).toBe(true);
143 expect(
144 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
145 ).toBe(false);
146 });
147});
apps/web/src/lib/gitReactQuery.ts+29-69
......@@ -12,15 +12,12 @@ import {
1212import { ensureNativeApi } from "../nativeApi";
1313import { getWsRpcClient } from "../wsRpcClient";
1414
15const GIT_STATUS_STALE_TIME_MS = 5_000;
16const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
1715const GIT_BRANCHES_STALE_TIME_MS = 15_000;
1816const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
1917const GIT_BRANCHES_PAGE_SIZE = 100;
2018
2119export const gitQueryKeys = {
2220 all: ["git"] as const,
23 status: (cwd: string | null) => ["git", "status", cwd] as const,
2421 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
2522 branchSearch: (cwd: string | null, query: string) =>
2623 ["git", "branches", cwd, "search", query] as const,
......@@ -38,37 +35,18 @@ export const gitMutationKeys = {
3835export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) {
3936 const cwd = input?.cwd ?? null;
4037 if (cwd !== null) {
41 return Promise.all([
42 queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }),
43 queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }),
44 ]);
38 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
4539 }
4640
4741 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
4842}
4943
50export function invalidateGitStatusQuery(queryClient: QueryClient, cwd: string | null) {
44function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) {
5145 if (cwd === null) {
5246 return Promise.resolve();
5347 }
5448
55 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) });
56}
57
58export function gitStatusQueryOptions(cwd: string | null) {
59 return queryOptions({
60 queryKey: gitQueryKeys.status(cwd),
61 queryFn: async () => {
62 const api = ensureNativeApi();
63 if (!cwd) throw new Error("Git status is unavailable.");
64 return api.git.status({ cwd });
65 },
66 enabled: cwd !== null,
67 staleTime: GIT_STATUS_STALE_TIME_MS,
68 refetchOnWindowFocus: "always",
69 refetchOnReconnect: "always",
70 refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS,
71 });
49 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
7250}
7351
7452export function gitBranchSearchInfiniteQueryOptions(input: {
......@@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient:
128106 if (!input.cwd) throw new Error("Git init is unavailable.");
129107 return api.git.init({ cwd: input.cwd });
130108 },
131 onSuccess: async () => {
132 await invalidateGitQueries(input.queryClient);
109 onSettled: async () => {
110 await invalidateGitBranchQueries(input.queryClient, input.cwd);
133111 },
134112 });
135113}
......@@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: {
145123 if (!input.cwd) throw new Error("Git checkout is unavailable.");
146124 return api.git.checkout({ cwd: input.cwd, branch });
147125 },
148 onSuccess: async () => {
149 await invalidateGitQueries(input.queryClient);
126 onSettled: async () => {
127 await invalidateGitBranchQueries(input.queryClient, input.cwd);
150128 },
151129 });
152130}
......@@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: {
175153 if (!input.cwd) throw new Error("Git action is unavailable.");
176154 return getWsRpcClient().git.runStackedAction(
177155 {
156 action,
178157 actionId,
179158 cwd: input.cwd,
180 action,
181159 ...(commitMessage ? { commitMessage } : {}),
182 ...(featureBranch ? { featureBranch } : {}),
183 ...(filePaths ? { filePaths } : {}),
160 ...(featureBranch ? { featureBranch: true } : {}),
161 ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
184162 },
185163 ...(onProgress ? [{ onProgress }] : []),
186164 );
187165 },
188 onSettled: async () => {
189 await invalidateGitQueries(input.queryClient);
166 onSuccess: async () => {
167 await invalidateGitBranchQueries(input.queryClient, input.cwd);
190168 },
191169 });
192170}
......@@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient:
199177 if (!input.cwd) throw new Error("Git pull is unavailable.");
200178 return api.git.pull({ cwd: input.cwd });
201179 },
202 onSettled: async () => {
203 await invalidateGitQueries(input.queryClient);
180 onSuccess: async () => {
181 await invalidateGitBranchQueries(input.queryClient, input.cwd);
204182 },
205183 });
206184}
207185
208186export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
209187 return mutationOptions({
210 mutationFn: async ({
211 cwd,
212 branch,
213 newBranch,
214 path,
215 }: {
216 cwd: string;
217 branch: string;
218 newBranch: string;
219 path?: string | null;
220 }) => {
221 const api = ensureNativeApi();
222 if (!cwd) throw new Error("Git worktree creation is unavailable.");
223 return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null });
224 },
225188 mutationKey: ["git", "mutation", "create-worktree"] as const,
226 onSettled: async () => {
189 mutationFn: (
190 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0],
191 ) => ensureNativeApi().git.createWorktree(args),
192 onSuccess: async () => {
227193 await invalidateGitQueries(input.queryClient);
228194 },
229195 });
......@@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie
231197
232198export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
233199 return mutationOptions({
234 mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => {
235 const api = ensureNativeApi();
236 if (!cwd) throw new Error("Git worktree removal is unavailable.");
237 return api.git.removeWorktree({ cwd, path, force });
238 },
239200 mutationKey: ["git", "mutation", "remove-worktree"] as const,
240 onSettled: async () => {
201 mutationFn: (
202 args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0],
203 ) => ensureNativeApi().git.removeWorktree(args),
204 onSuccess: async () => {
241205 await invalidateGitQueries(input.queryClient);
242206 },
243207 });
......@@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
248212 queryClient: QueryClient;
249213}) {
250214 return mutationOptions({
251 mutationFn: async ({
252 reference,
253 mode,
254 threadId,
255 }: {
215 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
216 mutationFn: async (args: {
256217 reference: string;
257218 mode: "local" | "worktree";
258219 threadId?: ThreadId;
......@@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
261222 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
262223 return api.git.preparePullRequestThread({
263224 cwd: input.cwd,
264 reference,
265 mode,
266 ...(threadId ? { threadId } : {}),
225 reference: args.reference,
226 mode: args.mode,
227 ...(args.threadId ? { threadId: args.threadId } : {}),
267228 });
268229 },
269 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
270 onSettled: async () => {
271 await invalidateGitQueries(input.queryClient);
230 onSuccess: async () => {
231 await invalidateGitBranchQueries(input.queryClient, input.cwd);
272232 },
273233 });
274234}
apps/web/src/lib/gitStatusState.test.ts created+112
......@@ -0,0 +1,112 @@
1import type { GitStatusResult } from "@t3tools/contracts";
2import { afterEach, describe, expect, it, vi } from "vitest";
3
4import {
5 getGitStatusSnapshot,
6 resetGitStatusStateForTests,
7 refreshGitStatus,
8 watchGitStatus,
9} from "./gitStatusState";
10
11function registerListener<T>(listeners: Set<(event: T) => void>, listener: (event: T) => void) {
12 listeners.add(listener);
13 return () => {
14 listeners.delete(listener);
15 };
16}
17
18const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
19
20const BASE_STATUS: GitStatusResult = {
21 isRepo: true,
22 hasOriginRemote: true,
23 isDefaultBranch: false,
24 branch: "feature/push-status",
25 hasWorkingTreeChanges: false,
26 workingTree: { files: [], insertions: 0, deletions: 0 },
27 hasUpstream: true,
28 aheadCount: 0,
29 behindCount: 0,
30 pr: null,
31};
32
33const gitClient = {
34 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
35 ...BASE_STATUS,
36 branch: `${input.cwd}-refreshed`,
37 })),
38 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
39 registerListener(gitStatusListeners, listener),
40 ),
41};
42
43function emitGitStatus(event: GitStatusResult) {
44 for (const listener of gitStatusListeners) {
45 listener(event);
46 }
47}
48
49afterEach(() => {
50 gitStatusListeners.clear();
51 gitClient.onStatus.mockClear();
52 gitClient.refreshStatus.mockClear();
53 resetGitStatusStateForTests();
54});
55
56describe("gitStatusState", () => {
57 it("starts fresh cwd state in a pending state", () => {
58 expect(getGitStatusSnapshot("/fresh")).toEqual({
59 data: null,
60 error: null,
61 cause: null,
62 isPending: true,
63 });
64 });
65
66 it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => {
67 const releaseA = watchGitStatus("/repo", gitClient);
68 const releaseB = watchGitStatus("/repo", gitClient);
69
70 expect(gitClient.onStatus).toHaveBeenCalledOnce();
71 expect(getGitStatusSnapshot("/repo")).toEqual({
72 data: null,
73 error: null,
74 cause: null,
75 isPending: true,
76 });
77
78 emitGitStatus(BASE_STATUS);
79
80 expect(getGitStatusSnapshot("/repo")).toEqual({
81 data: BASE_STATUS,
82 error: null,
83 cause: null,
84 isPending: false,
85 });
86
87 releaseA();
88 expect(gitStatusListeners.size).toBe(1);
89
90 releaseB();
91 expect(gitStatusListeners.size).toBe(0);
92 });
93
94 it("refreshes git status through the unary RPC without restarting the stream", async () => {
95 const release = watchGitStatus("/repo", gitClient);
96
97 emitGitStatus(BASE_STATUS);
98 const refreshed = await refreshGitStatus("/repo", gitClient);
99
100 expect(gitClient.onStatus).toHaveBeenCalledOnce();
101 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
102 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
103 expect(getGitStatusSnapshot("/repo")).toEqual({
104 data: BASE_STATUS,
105 error: null,
106 cause: null,
107 isPending: false,
108 });
109
110 release();
111 });
112});
apps/web/src/lib/gitStatusState.ts created+223
......@@ -0,0 +1,223 @@
1import { useAtomValue } from "@effect/atom-react";
2import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts";
3import { Cause } from "effect";
4import { Atom } from "effect/unstable/reactivity";
5import { useEffect } from "react";
6
7import { appAtomRegistry } from "../rpc/atomRegistry";
8import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient";
9
10export type GitStatusStreamError = GitManagerServiceError;
11
12export interface GitStatusState {
13 readonly data: GitStatusResult | null;
14 readonly error: GitStatusStreamError | null;
15 readonly cause: Cause.Cause<GitStatusStreamError> | null;
16 readonly isPending: boolean;
17}
18
19type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
20
21interface WatchedGitStatus {
22 refCount: number;
23 unsubscribe: () => void;
24}
25
26const EMPTY_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
27 data: null,
28 error: null,
29 cause: null,
30 isPending: false,
31});
32const INITIAL_GIT_STATUS_STATE = Object.freeze<GitStatusState>({
33 ...EMPTY_GIT_STATUS_STATE,
34 isPending: true,
35});
36const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
37 Atom.keepAlive,
38 Atom.withLabel("git-status:null"),
39);
40
41const NOOP: () => void = () => undefined;
42const watchedGitStatuses = new Map<string, WatchedGitStatus>();
43const knownGitStatusCwds = new Set<string>();
44const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
45const gitStatusLastRefreshAtByCwd = new Map<string, number>();
46
47const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
48
49let sharedGitStatusClient: GitStatusClient | null = null;
50
51const gitStatusStateAtom = Atom.family((cwd: string) => {
52 knownGitStatusCwds.add(cwd);
53 return Atom.make(INITIAL_GIT_STATUS_STATE).pipe(
54 Atom.keepAlive,
55 Atom.withLabel(`git-status:${cwd}`),
56 );
57});
58
59export function getGitStatusSnapshot(cwd: string | null): GitStatusState {
60 if (cwd === null) {
61 return EMPTY_GIT_STATUS_STATE;
62 }
63
64 return appAtomRegistry.get(gitStatusStateAtom(cwd));
65}
66
67export function watchGitStatus(
68 cwd: string | null,
69 client: GitStatusClient = getWsRpcClient().git,
70): () => void {
71 if (cwd === null) {
72 return NOOP;
73 }
74
75 ensureGitStatusClient(client);
76
77 const watched = watchedGitStatuses.get(cwd);
78 if (watched) {
79 watched.refCount += 1;
80 return () => unwatchGitStatus(cwd);
81 }
82
83 watchedGitStatuses.set(cwd, {
84 refCount: 1,
85 unsubscribe: subscribeToGitStatus(cwd),
86 });
87
88 return () => unwatchGitStatus(cwd);
89}
90
91export function refreshGitStatus(
92 cwd: string | null,
93 client: GitStatusClient = getWsRpcClient().git,
94): Promise<GitStatusResult | null> {
95 if (cwd === null) {
96 return Promise.resolve(null);
97 }
98
99 ensureGitStatusClient(client);
100
101 const currentInFlight = gitStatusRefreshInFlight.get(cwd);
102 if (currentInFlight) {
103 return currentInFlight;
104 }
105
106 const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0;
107 if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) {
108 return Promise.resolve(getGitStatusSnapshot(cwd).data);
109 }
110
111 gitStatusLastRefreshAtByCwd.set(cwd, Date.now());
112 const refreshPromise = client.refreshStatus({ cwd }).finally(() => {
113 gitStatusRefreshInFlight.delete(cwd);
114 });
115 gitStatusRefreshInFlight.set(cwd, refreshPromise);
116 return refreshPromise;
117}
118
119export function resetGitStatusStateForTests(): void {
120 for (const watched of watchedGitStatuses.values()) {
121 watched.unsubscribe();
122 }
123 watchedGitStatuses.clear();
124 gitStatusRefreshInFlight.clear();
125 gitStatusLastRefreshAtByCwd.clear();
126 sharedGitStatusClient = null;
127
128 for (const cwd of knownGitStatusCwds) {
129 appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE);
130 }
131 knownGitStatusCwds.clear();
132}
133
134export function useGitStatus(cwd: string | null): GitStatusState {
135 useEffect(() => watchGitStatus(cwd), [cwd]);
136
137 const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM);
138 return cwd === null ? EMPTY_GIT_STATUS_STATE : state;
139}
140
141function ensureGitStatusClient(client: GitStatusClient): void {
142 if (sharedGitStatusClient === client) {
143 return;
144 }
145
146 if (sharedGitStatusClient !== null) {
147 resetLiveGitStatusSubscriptions();
148 }
149
150 sharedGitStatusClient = client;
151}
152
153function resetLiveGitStatusSubscriptions(): void {
154 for (const watched of watchedGitStatuses.values()) {
155 watched.unsubscribe();
156 }
157 watchedGitStatuses.clear();
158}
159
160function unwatchGitStatus(cwd: string): void {
161 const watched = watchedGitStatuses.get(cwd);
162 if (!watched) {
163 return;
164 }
165
166 watched.refCount -= 1;
167 if (watched.refCount > 0) {
168 return;
169 }
170
171 watched.unsubscribe();
172 watchedGitStatuses.delete(cwd);
173}
174
175function subscribeToGitStatus(cwd: string): () => void {
176 const client = sharedGitStatusClient;
177 if (!client) {
178 return NOOP;
179 }
180
181 markGitStatusPending(cwd);
182 return client.onStatus(
183 { cwd },
184 (status) => {
185 appAtomRegistry.set(gitStatusStateAtom(cwd), {
186 data: status,
187 error: null,
188 cause: null,
189 isPending: false,
190 });
191 },
192 {
193 onResubscribe: () => {
194 markGitStatusPending(cwd);
195 },
196 },
197 );
198}
199
200function markGitStatusPending(cwd: string): void {
201 const atom = gitStatusStateAtom(cwd);
202 const current = appAtomRegistry.get(atom);
203 const next =
204 current.data === null
205 ? INITIAL_GIT_STATUS_STATE
206 : {
207 ...current,
208 error: null,
209 cause: null,
210 isPending: true,
211 };
212
213 if (
214 current.data === next.data &&
215 current.error === next.error &&
216 current.cause === next.cause &&
217 current.isPending === next.isPending
218 ) {
219 return;
220 }
221
222 appAtomRegistry.set(atom, next);
223}
apps/web/src/nativeApi.ts+2-2
......@@ -25,7 +25,7 @@ export function ensureNativeApi(): NativeApi {
2525 return api;
2626}
2727
28export function __resetNativeApiForTests() {
28export async function __resetNativeApiForTests() {
2929 cachedApi = undefined;
30 __resetWsNativeApiForTests();
30 await __resetWsNativeApiForTests();
3131}
apps/web/src/wsNativeApi.test.ts+46-1
......@@ -3,6 +3,7 @@ import {
33 DEFAULT_SERVER_SETTINGS,
44 type DesktopBridge,
55 EventId,
6 type GitStatusResult,
67 ProjectId,
78 type OrchestrationEvent,
89 type ServerConfig,
......@@ -31,6 +32,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
3132
3233const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
3334const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
35const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
3436
3537const rpcClientMock = {
3638 dispose: vi.fn(),
......@@ -54,7 +56,10 @@ const rpcClientMock = {
5456 },
5557 git: {
5658 pull: vi.fn(),
57 status: vi.fn(),
59 refreshStatus: vi.fn(),
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
61 registerListener(gitStatusListeners, listener),
62 ),
5863 runStackedAction: vi.fn(),
5964 listBranches: vi.fn(),
6065 createWorktree: vi.fn(),
......@@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = {
168173 settings: DEFAULT_SERVER_SETTINGS,
169174};
170175
176const baseGitStatus: GitStatusResult = {
177 isRepo: true,
178 hasOriginRemote: true,
179 isDefaultBranch: false,
180 branch: "feature/streamed",
181 hasWorkingTreeChanges: false,
182 workingTree: { files: [], insertions: 0, deletions: 0 },
183 hasUpstream: true,
184 aheadCount: 0,
185 behindCount: 0,
186 pr: null,
187};
188
171189beforeEach(() => {
172190 vi.resetModules();
173191 vi.clearAllMocks();
174192 showContextMenuFallbackMock.mockReset();
175193 terminalEventListeners.clear();
176194 orchestrationEventListeners.clear();
195 gitStatusListeners.clear();
177196 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
178197});
179198
......@@ -243,6 +262,32 @@ describe("wsNativeApi", () => {
243262 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
244263 });
245264
265 it("forwards git status stream events", async () => {
266 const { createWsNativeApi } = await import("./wsNativeApi");
267
268 const api = createWsNativeApi();
269 const onStatus = vi.fn();
270
271 api.git.onStatus({ cwd: "/repo" }, onStatus);
272
273 const gitStatus = baseGitStatus;
274 emitEvent(gitStatusListeners, gitStatus);
275
276 expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined);
277 expect(onStatus).toHaveBeenCalledWith(gitStatus);
278 });
279
280 it("forwards git status refreshes directly to the RPC client", async () => {
281 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
282 const { createWsNativeApi } = await import("./wsNativeApi");
283
284 const api = createWsNativeApi();
285
286 await api.git.refreshStatus({ cwd: "/repo" });
287
288 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
289 });
290
246291 it("forwards orchestration stream subscription options to the RPC client", async () => {
247292 const { createWsNativeApi } = await import("./wsNativeApi");
248293
apps/web/src/wsNativeApi.ts+8-3
......@@ -1,6 +1,8 @@
11import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
22
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";
34import { showContextMenuFallback } from "./contextMenuFallback";
5import { __resetWsRpcAtomClientForTests } from "./rpc/client";
46import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
57import { resetServerStateForTests } from "./rpc/serverState";
68import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
......@@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
810
911let instance: { api: NativeApi } | null = null;
1012
11export function __resetWsNativeApiForTests() {
13export async function __resetWsNativeApiForTests() {
1214 instance = null;
13 __resetWsRpcClientForTests();
15 await __resetWsRpcAtomClientForTests();
16 await __resetWsRpcClientForTests();
17 resetGitStatusStateForTests();
1418 resetRequestLatencyStateForTests();
1519 resetServerStateForTests();
1620 resetWsConnectionStateForTests();
......@@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi {
6569 },
6670 git: {
6771 pull: rpcClient.git.pull,
68 status: rpcClient.git.status,
72 refreshStatus: rpcClient.git.refreshStatus,
73 onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options),
6974 listBranches: rpcClient.git.listBranches,
7075 createWorktree: rpcClient.git.createWorktree,
7176 removeWorktree: rpcClient.git.removeWorktree,
apps/web/src/wsRpcClient.test.ts created+94
......@@ -0,0 +1,94 @@
1import type {
2 GitStatusLocalResult,
3 GitStatusRemoteResult,
4 GitStatusStreamEvent,
5} from "@t3tools/contracts";
6import { describe, expect, it, vi } from "vitest";
7
8import { createWsRpcClient } from "./wsRpcClient";
9import { type WsTransport } from "./wsTransport";
10
11const baseLocalStatus: GitStatusLocalResult = {
12 isRepo: true,
13 hasOriginRemote: true,
14 isDefaultBranch: false,
15 branch: "feature/demo",
16 hasWorkingTreeChanges: false,
17 workingTree: { files: [], insertions: 0, deletions: 0 },
18};
19
20const baseRemoteStatus: GitStatusRemoteResult = {
21 hasUpstream: true,
22 aheadCount: 0,
23 behindCount: 0,
24 pr: null,
25};
26
27describe("wsRpcClient", () => {
28 it("reduces git status stream events into flat status snapshots", () => {
29 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
30 for (const event of [
31 {
32 _tag: "snapshot",
33 local: baseLocalStatus,
34 remote: null,
35 },
36 {
37 _tag: "remoteUpdated",
38 remote: baseRemoteStatus,
39 },
40 {
41 _tag: "localUpdated",
42 local: {
43 ...baseLocalStatus,
44 hasWorkingTreeChanges: true,
45 },
46 },
47 ] satisfies GitStatusStreamEvent[]) {
48 listener(event as TValue);
49 }
50 return () => undefined;
51 });
52
53 const transport = {
54 dispose: vi.fn(async () => undefined),
55 reconnect: vi.fn(async () => undefined),
56 request: vi.fn(),
57 requestStream: vi.fn(),
58 subscribe,
59 } satisfies Pick<
60 WsTransport,
61 "dispose" | "reconnect" | "request" | "requestStream" | "subscribe"
62 >;
63
64 const client = createWsRpcClient(transport as unknown as WsTransport);
65 const listener = vi.fn();
66
67 client.git.onStatus({ cwd: "/repo" }, listener);
68
69 expect(listener.mock.calls).toEqual([
70 [
71 {
72 ...baseLocalStatus,
73 hasUpstream: false,
74 aheadCount: 0,
75 behindCount: 0,
76 pr: null,
77 },
78 ],
79 [
80 {
81 ...baseLocalStatus,
82 ...baseRemoteStatus,
83 },
84 ],
85 [
86 {
87 ...baseLocalStatus,
88 ...baseRemoteStatus,
89 hasWorkingTreeChanges: true,
90 },
91 ],
92 ]);
93 });
94});
apps/web/src/wsRpcClient.ts+22-2
......@@ -2,11 +2,14 @@ import {
22 type GitActionProgressEvent,
33 type GitRunStackedActionInput,
44 type GitRunStackedActionResult,
5 type GitStatusResult,
6 type GitStatusStreamEvent,
57 type NativeApi,
68 ORCHESTRATION_WS_METHODS,
79 type ServerSettingsPatch,
810 WS_METHODS,
911} from "@t3tools/contracts";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
1013import { Effect, Stream } from "effect";
1114
1215import { type WsRpcProtocolClient } from "./rpc/protocol";
......@@ -64,7 +67,12 @@ export interface WsRpcClient {
6467 };
6568 readonly git: {
6669 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
67 readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>;
70 readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>;
71 readonly onStatus: (
72 input: RpcInput<typeof WS_METHODS.subscribeGitStatus>,
73 listener: (status: GitStatusResult) => void,
74 options?: StreamSubscriptionOptions,
75 ) => () => void;
6876 readonly runStackedAction: (
6977 input: GitRunStackedActionInput,
7078 options?: GitRunStackedActionOptions,
......@@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
149157 },
150158 git: {
151159 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
152 status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)),
160 refreshStatus: (input) =>
161 transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)),
162 onStatus: (input, listener, options) => {
163 let current: GitStatusResult | null = null;
164 return transport.subscribe(
165 (client) => client[WS_METHODS.subscribeGitStatus](input),
166 (event: GitStatusStreamEvent) => {
167 current = applyGitStatusStreamEvent(current, event);
168 listener(current);
169 },
170 options,
171 );
172 },
153173 runStackedAction: async (input, options) => {
154174 let result: GitRunStackedActionResult | null = null;
155175
apps/web/test/wsRpcHarness.ts+2-1
......@@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions {
2424
2525const STREAM_METHODS = new Set<string>([
2626 WS_METHODS.gitRunStackedAction,
27 WS_METHODS.subscribeGitStatus,
2728 WS_METHODS.subscribeOrchestrationDomainEvents,
2829 WS_METHODS.subscribeTerminalEvents,
2930 WS_METHODS.subscribeServerConfig,
......@@ -108,7 +109,7 @@ export class BrowserWsRpcHarness {
108109 async onMessage(rawData: string): Promise<void> {
109110 const server = await this.serverReady;
110111 if (!server) {
111 throw new Error("RPC test server is not connected");
112 return;
112113 }
113114 const messages = this.parser.decode(rawData);
114115 for (const message of messages) {
packages/contracts/src/git.ts+49-1
......@@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
4444const GitPullRequestReference = TrimmedNonEmptyStringSchema;
4545const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
4646const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);
47export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]);
48export type GitHostingProviderKind = typeof GitHostingProviderKind.Type;
49export const GitHostingProvider = Schema.Struct({
50 kind: GitHostingProviderKind,
51 name: TrimmedNonEmptyStringSchema,
52 baseUrl: Schema.String,
53});
54export type GitHostingProvider = typeof GitHostingProvider.Type;
4755export const GitRunStackedActionToastRunAction = Schema.Struct({
4856 kind: GitStackedAction,
4957});
......@@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
161169export const GitCreateBranchInput = Schema.Struct({
162170 cwd: TrimmedNonEmptyStringSchema,
163171 branch: TrimmedNonEmptyStringSchema,
172 checkout: Schema.optional(Schema.Boolean),
164173});
165174export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
166175
176export const GitCreateBranchResult = Schema.Struct({
177 branch: TrimmedNonEmptyStringSchema,
178});
179export type GitCreateBranchResult = typeof GitCreateBranchResult.Type;
180
167181export const GitCheckoutInput = Schema.Struct({
168182 cwd: TrimmedNonEmptyStringSchema,
169183 branch: TrimmedNonEmptyStringSchema,
......@@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({
186200 state: GitStatusPrState,
187201});
188202
189export const GitStatusResult = Schema.Struct({
203const GitStatusLocalShape = {
190204 isRepo: Schema.Boolean,
205 hostingProvider: Schema.optional(GitHostingProvider),
191206 hasOriginRemote: Schema.Boolean,
192207 isDefaultBranch: Schema.Boolean,
193208 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
......@@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({
203218 insertions: NonNegativeInt,
204219 deletions: NonNegativeInt,
205220 }),
221};
222
223const GitStatusRemoteShape = {
206224 hasUpstream: Schema.Boolean,
207225 aheadCount: NonNegativeInt,
208226 behindCount: NonNegativeInt,
209227 pr: Schema.NullOr(GitStatusPr),
228};
229
230export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape);
231export type GitStatusLocalResult = typeof GitStatusLocalResult.Type;
232
233export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape);
234export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type;
235
236export const GitStatusResult = Schema.Struct({
237 ...GitStatusLocalShape,
238 ...GitStatusRemoteShape,
210239});
211240export type GitStatusResult = typeof GitStatusResult.Type;
212241
242export const GitStatusStreamEvent = Schema.Union([
243 Schema.TaggedStruct("snapshot", {
244 local: GitStatusLocalResult,
245 remote: Schema.NullOr(GitStatusRemoteResult),
246 }),
247 Schema.TaggedStruct("localUpdated", {
248 local: GitStatusLocalResult,
249 }),
250 Schema.TaggedStruct("remoteUpdated", {
251 remote: Schema.NullOr(GitStatusRemoteResult),
252 }),
253]);
254export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type;
255
213256export const GitListBranchesResult = Schema.Struct({
214257 branches: Schema.Array(GitBranch),
215258 isRepo: Schema.Boolean,
......@@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({
236279});
237280export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type;
238281
282export const GitCheckoutResult = Schema.Struct({
283 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
284});
285export type GitCheckoutResult = typeof GitCheckoutResult.Type;
286
239287export const GitRunStackedActionResult = Schema.Struct({
240288 action: GitStackedAction,
241289 branch: Schema.Struct({
packages/contracts/src/ipc.ts+12-3
......@@ -1,5 +1,6 @@
11import type {
22 GitCheckoutInput,
3 GitCheckoutResult,
34 GitCreateBranchInput,
45 GitPreparePullRequestThreadInput,
56 GitPreparePullRequestThreadResult,
......@@ -15,6 +16,7 @@ import type {
1516 GitResolvePullRequestResult,
1617 GitStatusInput,
1718 GitStatusResult,
19 GitCreateBranchResult,
1820} from "./git";
1921import type {
2022 ProjectSearchEntriesInput,
......@@ -148,8 +150,8 @@ export interface NativeApi {
148150 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
149151 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
150152 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
151 createBranch: (input: GitCreateBranchInput) => Promise<void>;
152 checkout: (input: GitCheckoutInput) => Promise<void>;
153 createBranch: (input: GitCreateBranchInput) => Promise<GitCreateBranchResult>;
154 checkout: (input: GitCheckoutInput) => Promise<GitCheckoutResult>;
153155 init: (input: GitInitInput) => Promise<void>;
154156 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
155157 preparePullRequestThread: (
......@@ -157,7 +159,14 @@ export interface NativeApi {
157159 ) => Promise<GitPreparePullRequestThreadResult>;
158160 // Stacked action API
159161 pull: (input: GitPullInput) => Promise<GitPullResult>;
160 status: (input: GitStatusInput) => Promise<GitStatusResult>;
162 refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>;
163 onStatus: (
164 input: GitStatusInput,
165 callback: (status: GitStatusResult) => void,
166 options?: {
167 onResubscribe?: () => void;
168 },
169 ) => () => void;
161170 };
162171 contextMenu: {
163172 show: <T extends string>(
packages/contracts/src/rpc.ts+18-4
......@@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor";
66import {
77 GitActionProgressEvent,
88 GitCheckoutInput,
9 GitCheckoutResult,
910 GitCommandError,
1011 GitCreateBranchInput,
12 GitCreateBranchResult,
1113 GitCreateWorktreeInput,
1214 GitCreateWorktreeResult,
1315 GitInitInput,
......@@ -24,6 +26,7 @@ import {
2426 GitRunStackedActionInput,
2527 GitStatusInput,
2628 GitStatusResult,
29 GitStatusStreamEvent,
2730} from "./git";
2831import { KeybindingsConfigError } from "./keybindings";
2932import {
......@@ -83,7 +86,7 @@ export const WS_METHODS = {
8386
8487 // Git methods
8588 gitPull: "git.pull",
86 gitStatus: "git.status",
89 gitRefreshStatus: "git.refreshStatus",
8790 gitRunStackedAction: "git.runStackedAction",
8891 gitListBranches: "git.listBranches",
8992 gitCreateWorktree: "git.createWorktree",
......@@ -110,6 +113,7 @@ export const WS_METHODS = {
110113 serverUpdateSettings: "server.updateSettings",
111114
112115 // Streaming subscriptions
116 subscribeGitStatus: "subscribeGitStatus",
113117 subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",
114118 subscribeTerminalEvents: "subscribeTerminalEvents",
115119 subscribeServerConfig: "subscribeServerConfig",
......@@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
162166 error: OpenError,
163167});
164168
165export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, {
169export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {
166170 payload: GitStatusInput,
167 success: GitStatusResult,
171 success: GitStatusStreamEvent,
168172 error: GitManagerServiceError,
173 stream: true,
169174});
170175
171176export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
......@@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
174179 error: GitCommandError,
175180});
176181
182export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, {
183 payload: GitStatusInput,
184 success: GitStatusResult,
185 error: GitManagerServiceError,
186});
187
177188export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
178189 payload: GitRunStackedActionInput,
179190 success: GitActionProgressEvent,
......@@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
212223
213224export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
214225 payload: GitCreateBranchInput,
226 success: GitCreateBranchResult,
215227 error: GitCommandError,
216228});
217229
218230export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
219231 payload: GitCheckoutInput,
232 success: GitCheckoutResult,
220233 error: GitCommandError,
221234});
222235
......@@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make(
330343 WsProjectsSearchEntriesRpc,
331344 WsProjectsWriteFileRpc,
332345 WsShellOpenInEditorRpc,
333 WsGitStatusRpc,
346 WsSubscribeGitStatusRpc,
334347 WsGitPullRpc,
348 WsGitRefreshStatusRpc,
335349 WsGitRunStackedActionRpc,
336350 WsGitResolvePullRequestRpc,
337351 WsGitPreparePullRequestThreadRpc,
packages/shared/src/git.test.ts created+67
......@@ -0,0 +1,67 @@
1import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts";
2import { describe, expect, it } from "vitest";
3
4import { applyGitStatusStreamEvent } from "./git";
5
6describe("applyGitStatusStreamEvent", () => {
7 it("treats a remote-only update as a repository when local state is missing", () => {
8 const remote: GitStatusRemoteResult = {
9 hasUpstream: true,
10 aheadCount: 2,
11 behindCount: 1,
12 pr: null,
13 };
14
15 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({
16 isRepo: true,
17 hasOriginRemote: false,
18 isDefaultBranch: false,
19 branch: null,
20 hasWorkingTreeChanges: false,
21 workingTree: { files: [], insertions: 0, deletions: 0 },
22 hasUpstream: true,
23 aheadCount: 2,
24 behindCount: 1,
25 pr: null,
26 });
27 });
28
29 it("preserves local-only fields when applying a remote update", () => {
30 const current: GitStatusResult = {
31 isRepo: true,
32 hostingProvider: {
33 kind: "github",
34 name: "GitHub",
35 baseUrl: "https://github.com",
36 },
37 hasOriginRemote: true,
38 isDefaultBranch: false,
39 branch: "feature/demo",
40 hasWorkingTreeChanges: true,
41 workingTree: {
42 files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }],
43 insertions: 1,
44 deletions: 0,
45 },
46 hasUpstream: false,
47 aheadCount: 0,
48 behindCount: 0,
49 pr: null,
50 };
51
52 const remote: GitStatusRemoteResult = {
53 hasUpstream: true,
54 aheadCount: 2,
55 behindCount: 1,
56 pr: null,
57 };
58
59 expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({
60 ...current,
61 hasUpstream: true,
62 aheadCount: 2,
63 behindCount: 1,
64 pr: null,
65 });
66 });
67});
packages/shared/src/git.ts+138-1
......@@ -1,4 +1,11 @@
1import type { GitBranch } from "@t3tools/contracts";
1import type {
2 GitBranch,
3 GitHostingProvider,
4 GitStatusLocalResult,
5 GitStatusRemoteResult,
6 GitStatusResult,
7 GitStatusStreamEvent,
8} from "@t3tools/contracts";
29
310/**
411 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
......@@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches(
119126 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
120127 });
121128}
129
130function parseGitRemoteHost(remoteUrl: string): string | null {
131 const trimmed = remoteUrl.trim();
132 if (trimmed.length === 0) {
133 return null;
134 }
135
136 if (trimmed.startsWith("git@")) {
137 const hostWithPath = trimmed.slice("git@".length);
138 const separatorIndex = hostWithPath.search(/[:/]/);
139 if (separatorIndex <= 0) {
140 return null;
141 }
142 return hostWithPath.slice(0, separatorIndex).toLowerCase();
143 }
144
145 try {
146 return new URL(trimmed).hostname.toLowerCase();
147 } catch {
148 return null;
149 }
150}
151
152function toBaseUrl(host: string): string {
153 return `https://${host}`;
154}
155
156function isGitHubHost(host: string): boolean {
157 return host === "github.com" || host.includes("github");
158}
159
160function isGitLabHost(host: string): boolean {
161 return host === "gitlab.com" || host.includes("gitlab");
162}
163
164export function detectGitHostingProviderFromRemoteUrl(
165 remoteUrl: string,
166): GitHostingProvider | null {
167 const host = parseGitRemoteHost(remoteUrl);
168 if (!host) {
169 return null;
170 }
171
172 if (isGitHubHost(host)) {
173 return {
174 kind: "github",
175 name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted",
176 baseUrl: toBaseUrl(host),
177 };
178 }
179
180 if (isGitLabHost(host)) {
181 return {
182 kind: "gitlab",
183 name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
184 baseUrl: toBaseUrl(host),
185 };
186 }
187
188 return {
189 kind: "unknown",
190 name: host,
191 baseUrl: toBaseUrl(host),
192 };
193}
194
195const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = {
196 hasUpstream: false,
197 aheadCount: 0,
198 behindCount: 0,
199 pr: null,
200};
201
202export function mergeGitStatusParts(
203 local: GitStatusLocalResult,
204 remote: GitStatusRemoteResult | null,
205): GitStatusResult {
206 return {
207 ...local,
208 ...(remote ?? EMPTY_GIT_STATUS_REMOTE),
209 };
210}
211
212function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult {
213 return {
214 hasUpstream: status.hasUpstream,
215 aheadCount: status.aheadCount,
216 behindCount: status.behindCount,
217 pr: status.pr,
218 };
219}
220
221function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult {
222 return {
223 isRepo: status.isRepo,
224 ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}),
225 hasOriginRemote: status.hasOriginRemote,
226 isDefaultBranch: status.isDefaultBranch,
227 branch: status.branch,
228 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
229 workingTree: status.workingTree,
230 };
231}
232
233export function applyGitStatusStreamEvent(
234 current: GitStatusResult | null,
235 event: GitStatusStreamEvent,
236): GitStatusResult {
237 switch (event._tag) {
238 case "snapshot":
239 return mergeGitStatusParts(event.local, event.remote);
240 case "localUpdated":
241 return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null);
242 case "remoteUpdated":
243 if (current === null) {
244 return mergeGitStatusParts(
245 {
246 isRepo: true,
247 hasOriginRemote: false,
248 isDefaultBranch: false,
249 branch: null,
250 hasWorkingTreeChanges: false,
251 workingTree: { files: [], insertions: 0, deletions: 0 },
252 },
253 event.remote,
254 );
255 }
256 return mergeGitStatusParts(toLocalStatusPart(current), event.remote);
257 }
258}