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) => {...@@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => {
949 yield* git(source, ["checkout", defaultBranch]);949 yield* git(source, ["checkout", defaultBranch]);
950 yield* git(source, ["branch", "-D", featureBranch]);950 yield* git(source, ["branch", "-D", featureBranch]);
951951
952 yield* (yield* GitCore).checkoutBranch({952 const checkoutResult = yield* (yield* GitCore).checkoutBranch({
953 cwd: source,953 cwd: source,
954 branch: `${remoteName}/${featureBranch}`,954 branch: `${remoteName}/${featureBranch}`,
955 });955 });
956956
957 expect(checkoutResult.branch).toBe("upstream/feature");
957 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");958 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
958 const realGitCore = yield* GitCore;959 const realGitCore = yield* GitCore;
959 let fetchArgs: readonly string[] | null = null;960 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?: {...@@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1177 return branchLastCommit;1177 return branchLastCommit;
1178 });1178 });
11791179
1180 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {1180 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
1181 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1182
1183 const statusResult = yield* executeGit(1181 const statusResult = yield* executeGit(
1184 "GitCore.statusDetails.status",1182 "GitCore.statusDetails.status",
1185 cwd,1183 cwd,
...@@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
1312 };1310 };
1313 });1311 });
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
1315 const status: GitCoreShape["status"] = (input) =>1324 const status: GitCoreShape["status"] = (input) =>
1316 statusDetails(input.cwd).pipe(1325 statusDetails(input.cwd).pipe(
1317 Effect.map((details) => ({1326 Effect.map((details) => ({
...@@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
2000 return { branch: targetBranch };2009 return { branch: targetBranch };
2001 });2010 });
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
2009 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(2012 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
2010 function* (input) {2013 function* (input) {
2011 const [localInputExists, remoteExists] = yield* Effect.all(2014 const [localInputExists, remoteExists] = yield* Effect.all(
...@@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
2078 timeoutMs: 10_000,2081 timeoutMs: 10_000,
2079 fallbackErrorMessage: "git checkout failed",2082 fallbackErrorMessage: "git checkout failed",
2080 });2083 });
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 };
2081 },2091 },
2082 );2092 );
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
2084 const initRepo: GitCoreShape["initRepo"] = (input) =>2106 const initRepo: GitCoreShape["initRepo"] = (input) =>
2085 executeGit("GitCore.initRepo", input.cwd, ["init"], {2107 executeGit("GitCore.initRepo", input.cwd, ["init"], {
2086 timeoutMs: 10_000,2108 timeoutMs: 10_000,
...@@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {...@@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
2106 execute,2128 execute,
2107 status,2129 status,
2108 statusDetails,2130 statusDetails,
2131 statusDetailsLocal,
2109 prepareCommitContext,2132 prepareCommitContext,
2110 commit,2133 commit,
2111 pushCurrentBranch,2134 pushCurrentBranch,
apps/server/src/git/Layers/GitManager.ts+104-32
...@@ -8,9 +8,13 @@ import {...@@ -8,9 +8,13 @@ import {
8 GitCommandError,8 GitCommandError,
9 GitRunStackedActionResult,9 GitRunStackedActionResult,
10 GitStackedAction,10 GitStackedAction,
11 type GitStatusLocalResult,
12 type GitStatusRemoteResult,
11 ModelSelection,13 ModelSelection,
12} from "@t3tools/contracts";14} from "@t3tools/contracts";
13import {15import {
16 detectGitHostingProviderFromRemoteUrl,
17 mergeGitStatusParts,
14 resolveAutoFeatureBranchName,18 resolveAutoFeatureBranchName,
15 sanitizeBranchFragment,19 sanitizeBranchFragment,
16 sanitizeFeatureBranchName,20 sanitizeFeatureBranchName,
...@@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
695699
696 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";700 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
697 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);701 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
698 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {702 const nonRepositoryStatusDetails = {
699 const details = yield* gitCore.statusDetails(cwd).pipe(703 isRepo: false,
700 Effect.catchIf(isNotGitRepositoryError, () =>704 hasOriginRemote: false,
701 Effect.succeed({705 isDefaultBranch: false,
702 isRepo: false,706 branch: null,
703 hasOriginRemote: false,707 upstreamRef: null,
704 isDefaultBranch: false,708 hasWorkingTreeChanges: false,
705 branch: null,709 workingTree: { files: [], insertions: 0, deletions: 0 },
706 upstreamRef: null,710 hasUpstream: false,
707 hasWorkingTreeChanges: false,711 aheadCount: 0,
708 workingTree: { files: [], insertions: 0, deletions: 0 },712 behindCount: 0,
709 hasUpstream: false,713 } satisfies GitStatusDetails;
710 aheadCount: 0,714 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
711 behindCount: 0,715 const details = yield* gitCore
712 } satisfies GitStatusDetails),716 .statusDetailsLocal(cwd)
713 ),717 .pipe(
714 );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
716 const pr =749 const pr =
717 details.isRepo && details.branch !== null750 details.branch !== null
718 ? yield* findLatestPr(cwd, {751 ? yield* findLatestPr(cwd, {
719 branch: details.branch,752 branch: details.branch,
720 upstreamRef: details.upstreamRef,753 upstreamRef: details.upstreamRef,
...@@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
725 : null;758 : null;
726759
727 return {760 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,
734 hasUpstream: details.hasUpstream,761 hasUpstream: details.hasUpstream,
735 aheadCount: details.aheadCount,762 aheadCount: details.aheadCount,
736 behindCount: details.behindCount,763 behindCount: details.behindCount,
737 pr,764 pr,
738 };765 } satisfies GitStatusRemoteResult;
739 });766 });
740 const statusResultCache = yield* Cache.makeWith({767 const remoteStatusResultCache = yield* Cache.makeWith({
741 capacity: STATUS_RESULT_CACHE_CAPACITY,768 capacity: STATUS_RESULT_CACHE_CAPACITY,
742 lookup: readStatus,769 lookup: readRemoteStatus,
743 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),770 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
744 });771 });
745 const invalidateStatusResultCache = (cwd: string) =>772 const invalidateRemoteStatusResultCache = (cwd: string) =>
746 Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));773 Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
747774
748 const readConfigValueNullable = (cwd: string, key: string) =>775 const readConfigValueNullable = (cwd: string, key: string) =>
749 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));776 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
751 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (793 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
752 cwd: string,794 cwd: string,
753 remoteName: string | null,795 remoteName: string | null,
...@@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1311 };1353 };
1312 });1354 });
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 );
1314 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {1364 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);
1316 });1367 });
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
1318 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(1385 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
1319 function* (input) {1386 function* (input) {
...@@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1488 branch: worktree.worktree.branch,1555 branch: worktree.worktree.branch,
1489 worktreePath: worktree.worktree.path,1556 worktreePath: worktree.worktree.path,
1490 };1557 };
1491 }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));1558 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
1492 });1559 });
14931560
1494 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (1561 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
...@@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1692 });1759 });
16931760
1694 return yield* runAction().pipe(1761 return yield* runAction().pipe(
1695 Effect.ensuring(invalidateStatusResultCache(input.cwd)),1762 Effect.ensuring(invalidateStatus(input.cwd)),
1696 Effect.tapError((error) =>1763 Effect.tapError((error) =>
1697 Effect.flatMap(Ref.get(currentPhase), (phase) =>1764 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1698 progress.emit({1765 progress.emit({
...@@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {...@@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
1707 );1774 );
17081775
1709 return {1776 return {
1777 localStatus,
1778 remoteStatus,
1710 status,1779 status,
1780 invalidateLocalStatus,
1781 invalidateRemoteStatus,
1782 invalidateStatus,
1711 resolvePullRequest,1783 resolvePullRequest,
1712 preparePullRequestThread,1784 preparePullRequestThread,
1713 runStackedAction,1785 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 @@...@@ -7,10 +7,12 @@
7 * @module GitCore7 * @module GitCore
8 */8 */
9import { ServiceMap } from "effect";9import { ServiceMap } from "effect";
10import type { Effect, Scope } from "effect";10import type { Effect } from "effect";
11import type {11import type {
12 GitCheckoutInput,12 GitCheckoutInput,
13 GitCheckoutResult,
13 GitCreateBranchInput,14 GitCreateBranchInput,
15 GitCreateBranchResult,
14 GitCreateWorktreeInput,16 GitCreateWorktreeInput,
15 GitCreateWorktreeResult,17 GitCreateWorktreeResult,
16 GitInitInput,18 GitInitInput,
...@@ -156,6 +158,11 @@ export interface GitCoreShape {...@@ -156,6 +158,11 @@ export interface GitCoreShape {
156 */158 */
157 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;159 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
159 /**166 /**
160 * Build staged change context for commit generation.167 * Build staged change context for commit generation.
161 */168 */
...@@ -278,14 +285,16 @@ export interface GitCoreShape {...@@ -278,14 +285,16 @@ export interface GitCoreShape {
278 /**285 /**
279 * Create a local branch.286 * Create a local branch.
280 */287 */
281 readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>;288 readonly createBranch: (
289 input: GitCreateBranchInput,
290 ) => Effect.Effect<GitCreateBranchResult, GitCommandError>;
282291
283 /**292 /**
284 * Checkout an existing branch and refresh its upstream metadata in background.293 * Checkout an existing branch and refresh its upstream metadata in background.
285 */294 */
286 readonly checkoutBranch: (295 readonly checkoutBranch: (
287 input: GitCheckoutInput,296 input: GitCheckoutInput,
288 ) => Effect.Effect<void, GitCommandError, Scope.Scope>;297 ) => Effect.Effect<GitCheckoutResult, GitCommandError>;
289298
290 /**299 /**
291 * Initialize a repository in the provided directory.300 * Initialize a repository in the provided directory.
apps/server/src/git/Services/GitManager.ts+31
...@@ -14,6 +14,8 @@ import {...@@ -14,6 +14,8 @@ import {
14 GitResolvePullRequestResult,14 GitResolvePullRequestResult,
15 GitRunStackedActionInput,15 GitRunStackedActionInput,
16 GitRunStackedActionResult,16 GitRunStackedActionResult,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
17 GitStatusInput,19 GitStatusInput,
18 GitStatusResult,20 GitStatusResult,
19} from "@t3tools/contracts";21} from "@t3tools/contracts";
...@@ -41,6 +43,35 @@ export interface GitManagerShape {...@@ -41,6 +43,35 @@ export interface GitManagerShape {
41 input: GitStatusInput,43 input: GitStatusInput,
42 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;44 ) => 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
44 /**75 /**
45 * Resolve a pull request by URL/number against the current repository.76 * Resolve a pull request by URL/number against the current repository.
46 */77 */
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 {...@@ -22,7 +22,16 @@ import {
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import { assert, it } from "@effect/vitest";23import { assert, it } from "@effect/vitest";
24import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";24import { 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";
26import {35import {
27 FetchHttpClient,36 FetchHttpClient,
28 HttpBody,37 HttpBody,
...@@ -44,6 +53,7 @@ import {...@@ -44,6 +53,7 @@ import {
44} from "./checkpointing/Services/CheckpointDiffQuery.ts";53} from "./checkpointing/Services/CheckpointDiffQuery.ts";
45import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";54import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";55import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
56import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";
47import { Keybindings, type KeybindingsShape } from "./keybindings.ts";57import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
48import { Open, type OpenShape } from "./open.ts";58import { Open, type OpenShape } from "./open.ts";
49import {59import {
...@@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: {...@@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: {
294 ...options?.config,304 ...options?.config,
295 };305 };
296 const layerConfig = Layer.succeed(ServerConfig, config);306 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
298 const appLayer = HttpRouter.serve(makeRoutesLayer, {312 const appLayer = HttpRouter.serve(makeRoutesLayer, {
299 disableListenLog: true,313 disableListenLog: true,
...@@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: {...@@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: {
333 ...options?.layers?.gitCore,347 ...options?.layers?.gitCore,
334 }),348 }),
335 ),349 ),
336 Layer.provide(350 Layer.provide(gitManagerLayer),
337 Layer.mock(GitManager)({351 Layer.provideMerge(gitStatusBroadcasterLayer),
338 ...options?.layers?.gitManager,
339 }),
340 ),
341 Layer.provide(352 Layer.provide(
342 Layer.mock(ProjectSetupScriptRunner)({353 Layer.mock(ProjectSetupScriptRunner)({
343 runForThread: () => Effect.succeed({ status: "no-script" as const }),354 runForThread: () => Effect.succeed({ status: "no-script" as const }),
...@@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1260 yield* buildAppUnderTest({1271 yield* buildAppUnderTest({
1261 layers: {1272 layers: {
1262 gitManager: {1273 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 }),
1263 status: () =>1293 status: () =>
1264 Effect.succeed({1294 Effect.succeed({
1265 isRepo: true,1295 isRepo: true,
...@@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1373 worktree: { path: "/tmp/wt", branch: "feature/demo" },1403 worktree: { path: "/tmp/wt", branch: "feature/demo" },
1374 }),1404 }),
1375 removeWorktree: () => Effect.void,1405 removeWorktree: () => Effect.void,
1376 createBranch: () => Effect.void,1406 createBranch: (input) => Effect.succeed({ branch: input.branch }),
1377 checkoutBranch: () => Effect.void,1407 checkoutBranch: (input) => Effect.succeed({ branch: input.branch }),
1378 initRepo: () => Effect.void,1408 initRepo: () => Effect.void,
1379 },1409 },
1380 },1410 },
...@@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
13821412
1383 const wsUrl = yield* getWsServerUrl("/ws");1413 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
1390 const pull = yield* Effect.scoped(1415 const pull = yield* Effect.scoped(
1391 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),1416 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
1392 );1417 );
1393 assert.equal(pull.status, "pulled");1418 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
1395 const stackedEvents = yield* Effect.scoped(1427 const stackedEvents = yield* Effect.scoped(
1396 withWsRpcClient(wsUrl, (client) =>1428 withWsRpcClient(wsUrl, (client) =>
1397 client[WS_METHODS.gitRunStackedAction]({1429 client[WS_METHODS.gitRunStackedAction]({
...@@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1494 cwd: "/tmp/repo",1526 cwd: "/tmp/repo",
1495 detail: "upstream missing",1527 detail: "upstream missing",
1496 });1528 });
1529 let invalidationCalls = 0;
1530 let statusCalls = 0;
1497 yield* buildAppUnderTest({1531 yield* buildAppUnderTest({
1498 layers: {1532 layers: {
1499 gitCore: {1533 gitCore: {
1500 pullCurrentBranch: () => Effect.fail(gitError),1534 pullCurrentBranch: () => Effect.fail(gitError),
1501 },1535 },
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 },
1502 },1585 },
1503 });1586 });
15041587
...@@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1510 );1593 );
15111594
1512 assertFailure(result, gitError);1595 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);
1513 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1729 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1514 );1730 );
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
1516 it.effect("routes websocket rpc orchestration methods", () =>1879 it.effect("routes websocket rpc orchestration methods", () =>
1517 Effect.gen(function* () {1880 Effect.gen(function* () {
1518 const now = new Date().toISOString();1881 const now = new Date().toISOString();
apps/server/src/server.ts+10-8
...@@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers...@@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers
30import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";30import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore";
31import { GitCoreLive } from "./git/Layers/GitCore";31import { GitCoreLive } from "./git/Layers/GitCore";
32import { GitHubCliLive } from "./git/Layers/GitHubCli";32import { GitHubCliLive } from "./git/Layers/GitHubCli";
33import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster";
33import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";34import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration";
34import { TerminalManagerLive } from "./terminal/Layers/Manager";35import { TerminalManagerLive } from "./terminal/Layers/Manager";
35import { GitManagerLive } from "./git/Layers/GitManager";36import { GitManagerLive } from "./git/Layers/GitManager";
...@@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap(...@@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap(
161162
162const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));163const 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
164const GitLayerLive = Layer.empty.pipe(172const GitLayerLive = Layer.empty.pipe(
165 Layer.provideMerge(173 Layer.provideMerge(GitManagerLayerLive),
166 GitManagerLive.pipe(174 Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))),
167 Layer.provideMerge(ProjectSetupScriptRunnerLive),
168 Layer.provideMerge(GitCoreLive),
169 Layer.provideMerge(GitHubCliLive),
170 Layer.provideMerge(RoutingTextGenerationLive),
171 ),
172 ),
173 Layer.provideMerge(GitCoreLive),175 Layer.provideMerge(GitCoreLive),
174);176);
175177
apps/server/src/ws.ts+62-21
...@@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer...@@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer
27import { ServerConfig } from "./config";27import { ServerConfig } from "./config";
28import { GitCore } from "./git/Services/GitCore";28import { GitCore } from "./git/Services/GitCore";
29import { GitManager } from "./git/Services/GitManager";29import { GitManager } from "./git/Services/GitManager";
30import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster";
30import { Keybindings } from "./keybindings";31import { Keybindings } from "./keybindings";
31import { Open, resolveAvailableEditors } from "./open";32import { Open, resolveAvailableEditors } from "./open";
32import { normalizeDispatchCommand } from "./orchestration/Normalizer";33import { normalizeDispatchCommand } from "./orchestration/Normalizer";
...@@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer(
56 const open = yield* Open;57 const open = yield* Open;
57 const gitManager = yield* GitManager;58 const gitManager = yield* GitManager;
58 const git = yield* GitCore;59 const git = yield* GitCore;
60 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
59 const terminalManager = yield* TerminalManager;61 const terminalManager = yield* TerminalManager;
60 const providerRegistry = yield* ProviderRegistry;62 const providerRegistry = yield* ProviderRegistry;
61 const config = yield* ServerConfig;63 const config = yield* ServerConfig;
...@@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer(
65 const workspaceEntries = yield* WorkspaceEntries;67 const workspaceEntries = yield* WorkspaceEntries;
66 const workspaceFileSystem = yield* WorkspaceFileSystem;68 const workspaceFileSystem = yield* WorkspaceFileSystem;
67 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;69 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
68
69 const serverCommandId = (tag: string) =>70 const serverCommandId = (tag: string) =>
70 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);71 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
7172
...@@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer(
348 };349 };
349 });350 });
350351
352 const refreshGitStatus = (cwd: string) =>
353 gitStatusBroadcaster
354 .refreshStatus(cwd)
355 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
356
351 return WsRpcGroup.of({357 return WsRpcGroup.of({
352 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>358 [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) =>
353 observeRpcEffect(359 observeRpcEffect(
...@@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer(
559 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {565 observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), {
560 "rpc.aggregate": "workspace",566 "rpc.aggregate": "workspace",
561 }),567 }),
562 [WS_METHODS.gitStatus]: (input) =>568 [WS_METHODS.subscribeGitStatus]: (input) =>
563 observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), {569 observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), {
564 "rpc.aggregate": "git",570 "rpc.aggregate": "git",
565 }),571 }),
572 [WS_METHODS.gitRefreshStatus]: (input) =>
573 observeRpcEffect(
574 WS_METHODS.gitRefreshStatus,
575 gitStatusBroadcaster.refreshStatus(input.cwd),
576 {
577 "rpc.aggregate": "git",
578 },
579 ),
566 [WS_METHODS.gitPull]: (input) =>580 [WS_METHODS.gitPull]: (input) =>
567 observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), {581 observeRpcEffect(
568 "rpc.aggregate": "git",582 WS_METHODS.gitPull,
569 }),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 ),
570 [WS_METHODS.gitRunStackedAction]: (input) =>592 [WS_METHODS.gitRunStackedAction]: (input) =>
571 observeRpcStream(593 observeRpcStream(
572 WS_METHODS.gitRunStackedAction,594 WS_METHODS.gitRunStackedAction,
...@@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(
581 .pipe(603 .pipe(
582 Effect.matchCauseEffect({604 Effect.matchCauseEffect({
583 onFailure: (cause) => Queue.failCause(queue, cause),605 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 ),
585 }),610 }),
586 ),611 ),
587 ),612 ),
...@@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer(
594 [WS_METHODS.gitPreparePullRequestThread]: (input) =>619 [WS_METHODS.gitPreparePullRequestThread]: (input) =>
595 observeRpcEffect(620 observeRpcEffect(
596 WS_METHODS.gitPreparePullRequestThread,621 WS_METHODS.gitPreparePullRequestThread,
597 gitManager.preparePullRequestThread(input),622 gitManager
623 .preparePullRequestThread(input)
624 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
598 { "rpc.aggregate": "git" },625 { "rpc.aggregate": "git" },
599 ),626 ),
600 [WS_METHODS.gitListBranches]: (input) =>627 [WS_METHODS.gitListBranches]: (input) =>
...@@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer(...@@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer(
602 "rpc.aggregate": "git",629 "rpc.aggregate": "git",
603 }),630 }),
604 [WS_METHODS.gitCreateWorktree]: (input) =>631 [WS_METHODS.gitCreateWorktree]: (input) =>
605 observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), {632 observeRpcEffect(
606 "rpc.aggregate": "git",633 WS_METHODS.gitCreateWorktree,
607 }),634 git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
635 { "rpc.aggregate": "git" },
636 ),
608 [WS_METHODS.gitRemoveWorktree]: (input) =>637 [WS_METHODS.gitRemoveWorktree]: (input) =>
609 observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), {638 observeRpcEffect(
610 "rpc.aggregate": "git",639 WS_METHODS.gitRemoveWorktree,
611 }),640 git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
641 { "rpc.aggregate": "git" },
642 ),
612 [WS_METHODS.gitCreateBranch]: (input) =>643 [WS_METHODS.gitCreateBranch]: (input) =>
613 observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), {644 observeRpcEffect(
614 "rpc.aggregate": "git",645 WS_METHODS.gitCreateBranch,
615 }),646 git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
647 { "rpc.aggregate": "git" },
648 ),
616 [WS_METHODS.gitCheckout]: (input) =>649 [WS_METHODS.gitCheckout]: (input) =>
617 observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), {650 observeRpcEffect(
618 "rpc.aggregate": "git",651 WS_METHODS.gitCheckout,
619 }),652 Effect.scoped(git.checkoutBranch(input)).pipe(
653 Effect.tap(() => refreshGitStatus(input.cwd)),
654 ),
655 { "rpc.aggregate": "git" },
656 ),
620 [WS_METHODS.gitInit]: (input) =>657 [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 ),
622 [WS_METHODS.terminalOpen]: (input) =>663 [WS_METHODS.terminalOpen]: (input) =>
623 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {664 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
624 "rpc.aggregate": "terminal",665 "rpc.aggregate": "terminal",
apps/web/src/components/BranchToolbarBranchSelector.tsx+28-42
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { GitBranch } from "@t3tools/contracts";1import type { GitBranch } from "@t3tools/contracts";
2import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";2import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
3import { useVirtualizer } from "@tanstack/react-virtual";3import { useVirtualizer } from "@tanstack/react-virtual";
4import { ChevronDownIcon } from "lucide-react";4import { ChevronDownIcon } from "lucide-react";
5import {5import {
...@@ -14,12 +14,8 @@ import {...@@ -14,12 +14,8 @@ import {
14 useTransition,14 useTransition,
15} from "react";15} from "react";
1616
17import {17import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery";
18 gitBranchSearchInfiniteQueryOptions,18import { useGitStatus } from "../lib/gitStatusState";
19 gitQueryKeys,
20 gitStatusQueryOptions,
21 invalidateGitQueries,
22} from "../lib/gitReactQuery";
23import { readNativeApi } from "../nativeApi";19import { readNativeApi } from "../nativeApi";
24import { parsePullRequestReference } from "../pullRequestReference";20import { parsePullRequestReference } from "../pullRequestReference";
25import {21import {
...@@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({...@@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({
89 const [branchQuery, setBranchQuery] = useState("");85 const [branchQuery, setBranchQuery] = useState("");
90 const deferredBranchQuery = useDeferredValue(branchQuery);86 const deferredBranchQuery = useDeferredValue(branchQuery);
9187
92 const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd));88 const branchStatusQuery = useGitStatus(branchCwd);
93 const trimmedBranchQuery = branchQuery.trim();89 const trimmedBranchQuery = branchQuery.trim();
94 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();90 const deferredTrimmedBranchQuery = deferredBranchQuery.trim();
9591
...@@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({...@@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({
110 gitBranchSearchInfiniteQueryOptions({106 gitBranchSearchInfiniteQueryOptions({
111 cwd: branchCwd,107 cwd: branchCwd,
112 query: deferredTrimmedBranchQuery,108 query: deferredTrimmedBranchQuery,
113 enabled: isBranchMenuOpen,
114 }),109 }),
115 );110 );
116 const branches = useMemo(111 const branches = useMemo(
...@@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({...@@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({
188 const runBranchAction = (action: () => Promise<void>) => {183 const runBranchAction = (action: () => Promise<void>) => {
189 startBranchActionTransition(async () => {184 startBranchActionTransition(async () => {
190 await action().catch(() => undefined);185 await action().catch(() => undefined);
191 await invalidateGitQueries(queryClient).catch(() => undefined);186 await queryClient
187 .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) })
188 .catch(() => undefined);
192 });189 });
193 };190 };
194191
...@@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({...@@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({
226 onComposerFocusRequest?.();223 onComposerFocusRequest?.();
227224
228 runBranchAction(async () => {225 runBranchAction(async () => {
226 const previousBranch = resolvedActiveBranch;
229 setOptimisticBranch(selectedBranchName);227 setOptimisticBranch(selectedBranchName);
230 try {228 try {
231 await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name });229 const checkoutResult = await api.git.checkout({
232 await invalidateGitQueries(queryClient);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);
233 } catch (error) {238 } catch (error) {
239 setOptimisticBranch(previousBranch);
234 toastManager.add({240 toastManager.add({
235 type: "error",241 type: "error",
236 title: "Failed to checkout branch.",242 title: "Failed to checkout branch.",
237 description: toBranchActionErrorMessage(error),243 description: toBranchActionErrorMessage(error),
238 });244 });
239 return;
240 }245 }
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);
252 });246 });
253 };247 };
254248
...@@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({...@@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({
261 onComposerFocusRequest?.();255 onComposerFocusRequest?.();
262256
263 runBranchAction(async () => {257 runBranchAction(async () => {
258 const previousBranch = resolvedActiveBranch;
264 setOptimisticBranch(name);259 setOptimisticBranch(name);
265
266 try {260 try {
267 await api.git.createBranch({ cwd: branchCwd, branch: name });261 const createBranchResult = await api.git.createBranch({
268 try {262 cwd: branchCwd,
269 await api.git.checkout({ cwd: branchCwd, branch: name });263 branch: name,
270 } catch (error) {264 checkout: true,
271 toastManager.add({265 });
272 type: "error",266 setOptimisticBranch(createBranchResult.branch);
273 title: "Failed to checkout branch.",267 onSetThreadBranch(createBranchResult.branch, activeWorktreePath);
274 description: toBranchActionErrorMessage(error),
275 });
276 return;
277 }
278 } catch (error) {268 } catch (error) {
269 setOptimisticBranch(previousBranch);
279 toastManager.add({270 toastManager.add({
280 type: "error",271 type: "error",
281 title: "Failed to create branch.",272 title: "Failed to create and checkout branch.",
282 description: toBranchActionErrorMessage(error),273 description: toBranchActionErrorMessage(error),
283 });274 });
284 return;
285 }275 }
286
287 setOptimisticBranch(name);
288 onSetThreadBranch(name, activeWorktreePath);
289 setBranchQuery("");
290 });276 });
291 };277 };
292278
apps/web/src/components/ChatView.browser.tsx+43-28
...@@ -31,6 +31,8 @@ import {...@@ -31,6 +31,8 @@ import {
31} from "../lib/terminalContext";31} from "../lib/terminalContext";
32import { isMacPlatform } from "../lib/utils";32import { isMacPlatform } from "../lib/utils";
33import { __resetNativeApiForTests } from "../nativeApi";33import { __resetNativeApiForTests } from "../nativeApi";
34import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
35import { getServerConfig } from "../rpc/serverState";
34import { getRouter } from "../router";36import { getRouter } from "../router";
35import { useStore } from "../store";37import { useStore } from "../store";
36import { useTerminalStateStore } from "../terminalStateStore";38import { useTerminalStateStore } from "../terminalStateStore";
...@@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test...@@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test
38import { estimateTimelineMessageHeight } from "./timelineHeight";40import { estimateTimelineMessageHeight } from "./timelineHeight";
39import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";41import { 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
41const THREAD_ID = "thread-browser-test" as ThreadId;50const THREAD_ID = "thread-browser-test" as ThreadId;
42const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;51const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
43const PROJECT_ID = "project-1" as ProjectId;52const PROJECT_ID = "project-1" as ProjectId;
44const NOW_ISO = "2026-03-04T12:00:00.000Z";53const NOW_ISO = "2026-03-04T12:00:00.000Z";
45const BASE_TIME_MS = Date.parse(NOW_ISO);54const 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
48interface TestFixture {57interface TestFixture {
49 snapshot: OrchestrationReadModel;58 snapshot: OrchestrationReadModel;
...@@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [...@@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [
93 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },102 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },
94] as const satisfies readonly ViewportSpec[];103] as const satisfies readonly ViewportSpec[];
95const ATTACHMENT_VIEWPORT_MATRIX = [104const ATTACHMENT_VIEWPORT_MATRIX = [
96 DEFAULT_VIEWPORT,105 { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 },
97 { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 },106 { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 },
98 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 },107 { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 },
99] as const satisfies readonly ViewportSpec[];108] as const satisfies readonly ViewportSpec[];
100109
101interface UserRowMeasurement {110interface UserRowMeasurement {
...@@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: {...@@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: {
224 name: `attachment-${attachmentIndex + 1}.png`,233 name: `attachment-${attachmentIndex + 1}.png`,
225 mimeType: "image/png",234 mimeType: "image/png",
226 sizeBytes: 128,235 sizeBytes: 128,
236 previewUrl: `/attachments/attachment-${attachmentIndex + 1}`,
227 }))237 }))
228 : undefined;238 : undefined;
229239
...@@ -397,6 +407,22 @@ async function waitForWsClient(): Promise<void> {...@@ -397,6 +407,22 @@ async function waitForWsClient(): Promise<void> {
397 (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,407 (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents,
398 ),408 ),
399 ).toBe(true);409 ).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);
400 },426 },
401 { timeout: 8_000, interval: 16 },427 { timeout: 8_000, interval: 16 },
402 );428 );
...@@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {...@@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
651 ],677 ],
652 };678 };
653 }679 }
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 }
672 if (tag === WS_METHODS.projectsSearchEntries) {680 if (tag === WS_METHODS.projectsSearchEntries) {
673 return {681 return {
674 entries: [],682 entries: [],
...@@ -1044,10 +1052,17 @@ async function mountChatView(options: {...@@ -1044,10 +1052,17 @@ async function mountChatView(options: {
1044 }),1052 }),
1045 );1053 );
10461054
1047 const screen = await render(<RouterProvider router={router} />, {1055 const screen = await render(
1048 container: host,1056 <AppAtomRegistryProvider>
1049 });1057 <RouterProvider router={router} />
1058 </AppAtomRegistryProvider>,
1059 {
1060 container: host,
1061 },
1062 );
10501063
1064 await waitForWsClient();
1065 await waitForAppBootstrap();
1051 await waitForLayout();1066 await waitForLayout();
10521067
1053 const cleanup = async () => {1068 const cleanup = async () => {
...@@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1138 return [];1153 return [];
1139 },1154 },
1140 });1155 });
1141 __resetNativeApiForTests();1156 await __resetNativeApiForTests();
1142 await setViewport(DEFAULT_VIEWPORT);1157 await setViewport(DEFAULT_VIEWPORT);
1143 localStorage.clear();1158 localStorage.clear();
1144 document.body.innerHTML = "";1159 document.body.innerHTML = "";
...@@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1299 snapshot: createSnapshotForTargetUser({1314 snapshot: createSnapshotForTargetUser({
1300 targetMessageId,1315 targetMessageId,
1301 targetText: userText,1316 targetText: userText,
1302 targetAttachmentCount: 3,1317 targetAttachmentCount: 2,
1303 }),1318 }),
1304 });1319 });
13051320
...@@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
1313 {1328 {
1314 role: "user",1329 role: "user",
1315 text: userText,1330 text: userText,
1316 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }],1331 attachments: [{ id: "attachment-1" }, { id: "attachment-2" }],
1317 },1332 },
1318 { timelineWidthPx: timelineWidthMeasuredPx },1333 { timelineWidthPx: timelineWidthMeasuredPx },
1319 );1334 );
apps/web/src/components/ChatView.tsx+2-2
...@@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr...@@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
27import { useQuery } from "@tanstack/react-query";27import { useQuery } from "@tanstack/react-query";
28import { useDebouncedValue } from "@tanstack/react-pacer";28import { useDebouncedValue } from "@tanstack/react-pacer";
29import { useNavigate, useSearch } from "@tanstack/react-router";29import { useNavigate, useSearch } from "@tanstack/react-router";
30import { gitStatusQueryOptions } from "~/lib/gitReactQuery";30import { useGitStatus } from "~/lib/gitStatusState";
31import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";31import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
32import { isElectron } from "../env";32import { isElectron } from "../env";
33import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";33import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
...@@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1399 (debouncerState) => ({ isPending: debouncerState.isPending }),1399 (debouncerState) => ({ isPending: debouncerState.isPending }),
1400 );1400 );
1401 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";1401 const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
1402 const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd));1402 const gitStatusQuery = useGitStatus(gitCwd);
1403 const keybindings = useServerKeybindings();1403 const keybindings = useServerKeybindings();
1404 const availableEditors = useServerAvailableEditors();1404 const availableEditors = useServerAvailableEditors();
1405 const modelOptionsByProvider = useMemo(1405 const modelOptionsByProvider = useMemo(
apps/web/src/components/DiffPanel.tsx+2-2
...@@ -19,7 +19,7 @@ import {...@@ -19,7 +19,7 @@ import {
19 useState,19 useState,
20} from "react";20} from "react";
21import { openInPreferredEditor } from "../editorPreferences";21import { openInPreferredEditor } from "../editorPreferences";
22import { gitStatusQueryOptions } from "~/lib/gitReactQuery";22import { useGitStatus } from "~/lib/gitStatusState";
23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";23import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
24import { cn } from "~/lib/utils";24import { cn } from "~/lib/utils";
25import { readNativeApi } from "../nativeApi";25import { readNativeApi } from "../nativeApi";
...@@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {...@@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,189 activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined,
190 );190 );
191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;191 const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd;
192 const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null));192 const gitStatusQuery = useGitStatus(activeCwd ?? null);
193 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;193 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =194 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
195 useTurnDiffSummaries(activeThread);195 useTurnDiffSummaries(activeThread);
apps/web/src/components/GitActionsControl.browser.tsx+58-20
...@@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope";...@@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope";
1010
11const {11const {
12 invalidateGitQueriesSpy,12 invalidateGitQueriesSpy,
13 invalidateGitStatusQuerySpy,13 refreshGitStatusSpy,
14 runStackedActionMutateAsyncSpy,14 runStackedActionMutateAsyncSpy,
15 setThreadBranchSpy,15 setThreadBranchSpy,
16 toastAddSpy,16 toastAddSpy,
...@@ -19,7 +19,7 @@ const {...@@ -19,7 +19,7 @@ const {
19 toastUpdateSpy,19 toastUpdateSpy,
20} = vi.hoisted(() => ({20} = vi.hoisted(() => ({
21 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),21 invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()),
22 invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()),22 refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)),
23 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),23 runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)),
24 setThreadBranchSpy: vi.fn(),24 setThreadBranchSpy: vi.fn(),
25 toastAddSpy: vi.fn(() => "toast-1"),25 toastAddSpy: vi.fn(() => "toast-1"),
...@@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => {...@@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => {
57 };57 };
58 }),58 }),
59 useQuery: vi.fn((options: { queryKey?: string[] }) => {59 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
75 if (options.queryKey?.[0] === "git-branches") {60 if (options.queryKey?.[0] === "git-branches") {
76 return {61 return {
77 data: {62 data: {
...@@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({...@@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({
110}));95}));
11196
112vi.mock("~/lib/gitReactQuery", () => ({97vi.mock("~/lib/gitReactQuery", () => ({
113 gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })),
114 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),98 gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })),
115 gitMutationKeys: {99 gitMutationKeys: {
116 pull: vi.fn(() => ["pull"]),100 pull: vi.fn(() => ["pull"]),
...@@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({...@@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({
118 },102 },
119 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),103 gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })),
120 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),104 gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })),
121 gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })),
122 invalidateGitQueries: invalidateGitQueriesSpy,105 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 })),
124}));124}));
125125
126vi.mock("~/lib/utils", async () => {126vi.mock("~/lib/utils", async () => {
...@@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {...@@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => {
235 host.remove();235 host.remove();
236 }236 }
237 });237 });
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 });
238});276});
apps/web/src/components/GitActionsControl.tsx+41-5
...@@ -5,7 +5,7 @@ import type {...@@ -5,7 +5,7 @@ import type {
5 GitStatusResult,5 GitStatusResult,
6 ThreadId,6 ThreadId,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";8import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
9import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";9import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
10import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";10import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
11import { GitHubIcon } from "./Icons";11import { GitHubIcon } from "./Icons";
...@@ -45,9 +45,8 @@ import {...@@ -45,9 +45,8 @@ import {
45 gitMutationKeys,45 gitMutationKeys,
46 gitPullMutationOptions,46 gitPullMutationOptions,
47 gitRunStackedActionMutationOptions,47 gitRunStackedActionMutationOptions,
48 gitStatusQueryOptions,
49 invalidateGitStatusQuery,
50} from "~/lib/gitReactQuery";48} from "~/lib/gitReactQuery";
49import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState";
51import { newCommandId, randomUUID } from "~/lib/utils";50import { newCommandId, randomUUID } from "~/lib/utils";
52import { resolvePathLinkTarget } from "~/terminal-links";51import { resolvePathLinkTarget } from "~/terminal-links";
53import { readNativeApi } from "~/nativeApi";52import { readNativeApi } from "~/nativeApi";
...@@ -92,6 +91,8 @@ interface RunGitActionWithToastInput {...@@ -92,6 +91,8 @@ interface RunGitActionWithToastInput {
92 filePaths?: string[];91 filePaths?: string[];
93}92}
9493
94const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250;
95
95function formatElapsedDescription(startedAtMs: number | null): string | undefined {96function formatElapsedDescription(startedAtMs: number | null): string | undefined {
96 if (startedAtMs === null) {97 if (startedAtMs === null) {
97 return undefined;98 return undefined;
...@@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
275 [persistThreadBranchSync],276 [persistThreadBranchSync],
276 );277 );
277278
278 const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd));279 const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd);
279 // Default to true while loading so we don't flash init controls.280 // Default to true while loading so we don't flash init controls.
280 const isRepo = gitStatus?.isRepo ?? true;281 const isRepo = gitStatus?.isRepo ?? true;
281 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;282 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
...@@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
359 };360 };
360 }, [updateActiveProgressToast]);361 }, [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
362 const openExistingPr = useCallback(async () => {396 const openExistingPr = useCallback(async () => {
363 const api = readNativeApi();397 const api = readNativeApi();
364 if (!api) {398 if (!api) {
...@@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
801 <GroupSeparator className="hidden @3xl/header-actions:block" />835 <GroupSeparator className="hidden @3xl/header-actions:block" />
802 <Menu836 <Menu
803 onOpenChange={(open) => {837 onOpenChange={(open) => {
804 if (open) void invalidateGitStatusQuery(queryClient, gitCwd);838 if (open) {
839 void refreshGitStatus(gitCwd).catch(() => undefined);
840 }
805 }}841 }}
806 >842 >
807 <MenuTrigger843 <MenuTrigger
apps/web/src/components/KeybindingsToast.browser.tsx+41-16
...@@ -19,10 +19,19 @@ import { render } from "vitest-browser-react";...@@ -19,10 +19,19 @@ import { render } from "vitest-browser-react";
1919
20import { useComposerDraftStore } from "../composerDraftStore";20import { useComposerDraftStore } from "../composerDraftStore";
21import { __resetNativeApiForTests } from "../nativeApi";21import { __resetNativeApiForTests } from "../nativeApi";
22import { AppAtomRegistryProvider } from "../rpc/atomRegistry";
23import { getServerConfig } from "../rpc/serverState";
22import { getRouter } from "../router";24import { getRouter } from "../router";
23import { useStore } from "../store";25import { useStore } from "../store";
24import { BrowserWsRpcHarness } from "../../test/wsRpcHarness";26import { 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
26const THREAD_ID = "thread-kb-toast-test" as ThreadId;35const THREAD_ID = "thread-kb-toast-test" as ThreadId;
27const PROJECT_ID = "project-1" as ProjectId;36const PROJECT_ID = "project-1" as ProjectId;
28const NOW_ISO = "2026-03-04T12:00:00.000Z";37const NOW_ISO = "2026-03-04T12:00:00.000Z";
...@@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown {...@@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown {
170 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],179 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
171 };180 };
172 }181 }
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 }
187 if (tag === WS_METHODS.projectsSearchEntries) {182 if (tag === WS_METHODS.projectsSearchEntries) {
188 return { entries: [], truncated: false };183 return { entries: [], truncated: false };
189 }184 }
...@@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise<void> {...@@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise<void> {
258 );253 );
259}254}
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
261async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {279async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
262 const host = document.createElement("div");280 const host = document.createElement("div");
263 host.style.position = "fixed";281 host.style.position = "fixed";
...@@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {...@@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> {
270288
271 const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] }));289 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 );
274 await waitForComposerEditor();297 await waitForComposerEditor();
298 await waitForInitialWsSubscriptions();
299 await waitForServerConfigSnapshot();
275300
276 return {301 return {
277 cleanup: async () => {302 cleanup: async () => {
...@@ -322,7 +347,7 @@ describe("Keybindings update toast", () => {...@@ -322,7 +347,7 @@ describe("Keybindings update toast", () => {
322 return [];347 return [];
323 },348 },
324 });349 });
325 __resetNativeApiForTests();350 await __resetNativeApiForTests();
326 localStorage.clear();351 localStorage.clear();
327 document.body.innerHTML = "";352 document.body.innerHTML = "";
328 useComposerDraftStore.setState({353 useComposerDraftStore.setState({
apps/web/src/components/Sidebar.tsx+18-53
...@@ -49,7 +49,6 @@ import {...@@ -49,7 +49,6 @@ import {
49 ThreadId,49 ThreadId,
50 type GitStatusResult,50 type GitStatusResult,
51} from "@t3tools/contracts";51} from "@t3tools/contracts";
52import { useQueries } from "@tanstack/react-query";
53import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";52import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router";
54import {53import {
55 type SidebarProjectSortOrder,54 type SidebarProjectSortOrder,
...@@ -70,7 +69,7 @@ import {...@@ -70,7 +69,7 @@ import {
70 threadJumpIndexFromCommand,69 threadJumpIndexFromCommand,
71 threadTraversalDirectionFromCommand,70 threadTraversalDirectionFromCommand,
72} from "../keybindings";71} from "../keybindings";
73import { gitStatusQueryOptions } from "../lib/gitReactQuery";72import { useGitStatus } from "../lib/gitStatusState";
74import { readNativeApi } from "../nativeApi";73import { readNativeApi } from "../nativeApi";
75import { useComposerDraftStore } from "../composerDraftStore";74import { useComposerDraftStore } from "../composerDraftStore";
76import { useHandleNewThread } from "../hooks/useHandleNewThread";75import { useHandleNewThread } from "../hooks/useHandleNewThread";
...@@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {...@@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
245 return null;244 return null;
246}245}
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
248interface SidebarThreadRowProps {258interface SidebarThreadRowProps {
249 threadId: ThreadId;259 threadId: ThreadId;
260 projectCwd: string | null;
250 orderedProjectThreadIds: readonly ThreadId[];261 orderedProjectThreadIds: readonly ThreadId[];
251 routeThreadId: ThreadId | null;262 routeThreadId: ThreadId | null;
252 selectedThreadIds: ReadonlySet<ThreadId>;263 selectedThreadIds: ReadonlySet<ThreadId>;
...@@ -277,7 +288,6 @@ interface SidebarThreadRowProps {...@@ -277,7 +288,6 @@ interface SidebarThreadRowProps {
277 cancelRename: () => void;288 cancelRename: () => void;
278 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;289 attemptArchiveThread: (threadId: ThreadId) => Promise<void>;
279 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;290 openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void;
280 pr: ThreadPr | null;
281}291}
282292
283function SidebarThreadRow(props: SidebarThreadRowProps) {293function SidebarThreadRow(props: SidebarThreadRowProps) {
...@@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {...@@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
287 (state) =>297 (state) =>
288 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,298 selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds,
289 );299 );
300 const gitCwd = thread?.worktreePath ?? props.projectCwd;
301 const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null);
290302
291 if (!thread) {303 if (!thread) {
292 return null;304 return null;
...@@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {...@@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) {
303 lastVisitedAt,315 lastVisitedAt,
304 },316 },
305 });317 });
306 const prStatus = prStatusIndicator(props.pr);318 const pr = resolveThreadPr(thread.branch, gitStatus.data);
319 const prStatus = prStatusIndicator(pr);
307 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);320 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
308 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;321 const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning;
309 const threadMetaClassName = isConfirmingArchive322 const threadMetaClassName = isConfirmingArchive
...@@ -762,54 +775,6 @@ export default function Sidebar() {...@@ -762,54 +775,6 @@ export default function Sidebar() {
762 }),775 }),
763 [platform, routeTerminalOpen],776 [platform, routeTerminalOpen],
764 );777 );
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
813 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {778 const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => {
814 event.preventDefault();779 event.preventDefault();
815 event.stopPropagation();780 event.stopPropagation();
...@@ -1718,6 +1683,7 @@ export default function Sidebar() {...@@ -1718,6 +1683,7 @@ export default function Sidebar() {
1718 <SidebarThreadRow1683 <SidebarThreadRow
1719 key={threadId}1684 key={threadId}
1720 threadId={threadId}1685 threadId={threadId}
1686 projectCwd={project.cwd}
1721 orderedProjectThreadIds={orderedProjectThreadIds}1687 orderedProjectThreadIds={orderedProjectThreadIds}
1722 routeThreadId={routeThreadId}1688 routeThreadId={routeThreadId}
1723 selectedThreadIds={selectedThreadIds}1689 selectedThreadIds={selectedThreadIds}
...@@ -1741,7 +1707,6 @@ export default function Sidebar() {...@@ -1741,7 +1707,6 @@ export default function Sidebar() {
1741 cancelRename={cancelRename}1707 cancelRename={cancelRename}
1742 attemptArchiveThread={attemptArchiveThread}1708 attemptArchiveThread={attemptArchiveThread}
1743 openPrLink={openPrLink}1709 openPrLink={openPrLink}
1744 pr={prByThreadId.get(threadId) ?? null}
1745 />1710 />
1746 ))}1711 ))}
17471712
apps/web/src/components/chat/MessagesTimeline.tsx+1-1
...@@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
385 <img385 <img
386 src={image.previewUrl}386 src={image.previewUrl}
387 alt={image.name}387 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"
389 onLoad={onTimelineImageLoad}389 onLoad={onTimelineImageLoad}
390 onError={onTimelineImageLoad}390 onError={onTimelineImageLoad}
391 />391 />
apps/web/src/components/settings/SettingsPanels.browser.tsx+4-4
...@@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig {...@@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig {
30}30}
3131
32describe("GeneralSettingsPanel observability", () => {32describe("GeneralSettingsPanel observability", () => {
33 beforeEach(() => {33 beforeEach(async () => {
34 resetServerStateForTests();34 resetServerStateForTests();
35 __resetNativeApiForTests();35 await __resetNativeApiForTests();
36 localStorage.clear();36 localStorage.clear();
37 document.body.innerHTML = "";37 document.body.innerHTML = "";
38 });38 });
3939
40 afterEach(() => {40 afterEach(async () => {
41 resetServerStateForTests();41 resetServerStateForTests();
42 __resetNativeApiForTests();42 await __resetNativeApiForTests();
43 document.body.innerHTML = "";43 document.body.innerHTML = "";
44 });44 });
4545
apps/web/src/components/timelineHeight.test.ts+4-4
...@@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => {...@@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => {
30 text: "hello",30 text: "hello",
31 attachments: [{ id: "1" }],31 attachments: [{ id: "1" }],
32 }),32 }),
33 ).toBe(346);33 ).toBe(234);
3434
35 expect(35 expect(
36 estimateTimelineMessageHeight({36 estimateTimelineMessageHeight({
...@@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => {...@@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => {
38 text: "hello",38 text: "hello",
39 attachments: [{ id: "1" }, { id: "2" }],39 attachments: [{ id: "1" }, { id: "2" }],
40 }),40 }),
41 ).toBe(346);41 ).toBe(234);
42 });42 });
4343
44 it("adds a second attachment row for three or four user attachments", () => {44 it("adds a second attachment row for three or four user attachments", () => {
...@@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => {...@@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => {
48 text: "hello",48 text: "hello",
49 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }],49 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }],
50 }),50 }),
51 ).toBe(574);51 ).toBe(350);
5252
53 expect(53 expect(
54 estimateTimelineMessageHeight({54 estimateTimelineMessageHeight({
...@@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => {...@@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => {
56 text: "hello",56 text: "hello",
57 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],57 attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }],
58 }),58 }),
59 ).toBe(574);59 ).toBe(350);
60 });60 });
6161
62 it("does not cap long user message estimates", () => {62 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;...@@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75;
11const ASSISTANT_BASE_HEIGHT_PX = 41;11const ASSISTANT_BASE_HEIGHT_PX = 41;
12const USER_BASE_HEIGHT_PX = 96;12const USER_BASE_HEIGHT_PX = 96;
13const ATTACHMENTS_PER_ROW = 2;13const ATTACHMENTS_PER_ROW = 2;
14// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap.14// Full-app browser measurements land closer to a ~116px attachment row once
15const USER_ATTACHMENT_ROW_HEIGHT_PX = 228;15// the bubble shrinks to content width, so calibrate the estimate to that DOM.
16const USER_ATTACHMENT_ROW_HEIGHT_PX = 116;
16const USER_BUBBLE_WIDTH_RATIO = 0.8;17const USER_BUBBLE_WIDTH_RATIO = 0.8;
17const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;18const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32;
18const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;19const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8;
apps/web/src/lib/gitReactQuery.test.ts-29
...@@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts";...@@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts";
15import {15import {
16 gitBranchSearchInfiniteQueryOptions,16 gitBranchSearchInfiniteQueryOptions,
17 gitMutationKeys,17 gitMutationKeys,
18 gitQueryKeys,
19 gitPreparePullRequestThreadMutationOptions,18 gitPreparePullRequestThreadMutationOptions,
20 gitPullMutationOptions,19 gitPullMutationOptions,
21 gitRunStackedActionMutationOptions,20 gitRunStackedActionMutationOptions,
22 invalidateGitStatusQuery,
23 gitStatusQueryOptions,
24 invalidateGitQueries,21 invalidateGitQueries,
25} from "./gitReactQuery";22} from "./gitReactQuery";
2623
...@@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => {...@@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => {
84 it("can invalidate a single cwd without blasting other git query scopes", async () => {81 it("can invalidate a single cwd without blasting other git query scopes", async () => {
85 const queryClient = new QueryClient();82 const queryClient = new QueryClient();
8683
87 queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" });
88 queryClient.setQueryData(84 queryClient.setQueryData(
89 gitBranchSearchInfiniteQueryOptions({85 gitBranchSearchInfiniteQueryOptions({
90 cwd: "/repo/a",86 cwd: "/repo/a",
...@@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => {...@@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => {
92 }).queryKey,88 }).queryKey,
93 BRANCH_SEARCH_RESULT,89 BRANCH_SEARCH_RESULT,
94 );90 );
95 queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" });
96 queryClient.setQueryData(91 queryClient.setQueryData(
97 gitBranchSearchInfiniteQueryOptions({92 gitBranchSearchInfiniteQueryOptions({
98 cwd: "/repo/b",93 cwd: "/repo/b",
...@@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => {...@@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => {
10398
104 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });99 await invalidateGitQueries(queryClient, { cwd: "/repo/a" });
105100
106 expect(
107 queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated,
108 ).toBe(true);
109 expect(101 expect(
110 queryClient.getQueryState(102 queryClient.getQueryState(
111 gitBranchSearchInfiniteQueryOptions({103 gitBranchSearchInfiniteQueryOptions({
...@@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => {...@@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => {
114 }).queryKey,106 }).queryKey,
115 )?.isInvalidated,107 )?.isInvalidated,
116 ).toBe(true);108 ).toBe(true);
117 expect(
118 queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated,
119 ).toBe(false);
120 expect(109 expect(
121 queryClient.getQueryState(110 queryClient.getQueryState(
122 gitBranchSearchInfiniteQueryOptions({111 gitBranchSearchInfiniteQueryOptions({
...@@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => {...@@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => {
127 ).toBe(false);116 ).toBe(false);
128 });117 });
129});118});
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 {...@@ -12,15 +12,12 @@ import {
12import { ensureNativeApi } from "../nativeApi";12import { ensureNativeApi } from "../nativeApi";
13import { getWsRpcClient } from "../wsRpcClient";13import { getWsRpcClient } from "../wsRpcClient";
1414
15const GIT_STATUS_STALE_TIME_MS = 5_000;
16const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000;
17const GIT_BRANCHES_STALE_TIME_MS = 15_000;15const GIT_BRANCHES_STALE_TIME_MS = 15_000;
18const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;16const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000;
19const GIT_BRANCHES_PAGE_SIZE = 100;17const GIT_BRANCHES_PAGE_SIZE = 100;
2018
21export const gitQueryKeys = {19export const gitQueryKeys = {
22 all: ["git"] as const,20 all: ["git"] as const,
23 status: (cwd: string | null) => ["git", "status", cwd] as const,
24 branches: (cwd: string | null) => ["git", "branches", cwd] as const,21 branches: (cwd: string | null) => ["git", "branches", cwd] as const,
25 branchSearch: (cwd: string | null, query: string) =>22 branchSearch: (cwd: string | null, query: string) =>
26 ["git", "branches", cwd, "search", query] as const,23 ["git", "branches", cwd, "search", query] as const,
...@@ -38,37 +35,18 @@ export const gitMutationKeys = {...@@ -38,37 +35,18 @@ export const gitMutationKeys = {
38export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) {35export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) {
39 const cwd = input?.cwd ?? null;36 const cwd = input?.cwd ?? null;
40 if (cwd !== null) {37 if (cwd !== null) {
41 return Promise.all([38 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) });
42 queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }),
43 queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }),
44 ]);
45 }39 }
4640
47 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });41 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
48}42}
4943
50export function invalidateGitStatusQuery(queryClient: QueryClient, cwd: string | null) {44function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) {
51 if (cwd === null) {45 if (cwd === null) {
52 return Promise.resolve();46 return Promise.resolve();
53 }47 }
5448
55 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) });49 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(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 });
72}50}
7351
74export function gitBranchSearchInfiniteQueryOptions(input: {52export function gitBranchSearchInfiniteQueryOptions(input: {
...@@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient:...@@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient:
128 if (!input.cwd) throw new Error("Git init is unavailable.");106 if (!input.cwd) throw new Error("Git init is unavailable.");
129 return api.git.init({ cwd: input.cwd });107 return api.git.init({ cwd: input.cwd });
130 },108 },
131 onSuccess: async () => {109 onSettled: async () => {
132 await invalidateGitQueries(input.queryClient);110 await invalidateGitBranchQueries(input.queryClient, input.cwd);
133 },111 },
134 });112 });
135}113}
...@@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: {...@@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: {
145 if (!input.cwd) throw new Error("Git checkout is unavailable.");123 if (!input.cwd) throw new Error("Git checkout is unavailable.");
146 return api.git.checkout({ cwd: input.cwd, branch });124 return api.git.checkout({ cwd: input.cwd, branch });
147 },125 },
148 onSuccess: async () => {126 onSettled: async () => {
149 await invalidateGitQueries(input.queryClient);127 await invalidateGitBranchQueries(input.queryClient, input.cwd);
150 },128 },
151 });129 });
152}130}
...@@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: {...@@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: {
175 if (!input.cwd) throw new Error("Git action is unavailable.");153 if (!input.cwd) throw new Error("Git action is unavailable.");
176 return getWsRpcClient().git.runStackedAction(154 return getWsRpcClient().git.runStackedAction(
177 {155 {
156 action,
178 actionId,157 actionId,
179 cwd: input.cwd,158 cwd: input.cwd,
180 action,
181 ...(commitMessage ? { commitMessage } : {}),159 ...(commitMessage ? { commitMessage } : {}),
182 ...(featureBranch ? { featureBranch } : {}),160 ...(featureBranch ? { featureBranch: true } : {}),
183 ...(filePaths ? { filePaths } : {}),161 ...(filePaths && filePaths.length > 0 ? { filePaths } : {}),
184 },162 },
185 ...(onProgress ? [{ onProgress }] : []),163 ...(onProgress ? [{ onProgress }] : []),
186 );164 );
187 },165 },
188 onSettled: async () => {166 onSuccess: async () => {
189 await invalidateGitQueries(input.queryClient);167 await invalidateGitBranchQueries(input.queryClient, input.cwd);
190 },168 },
191 });169 });
192}170}
...@@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient:...@@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient:
199 if (!input.cwd) throw new Error("Git pull is unavailable.");177 if (!input.cwd) throw new Error("Git pull is unavailable.");
200 return api.git.pull({ cwd: input.cwd });178 return api.git.pull({ cwd: input.cwd });
201 },179 },
202 onSettled: async () => {180 onSuccess: async () => {
203 await invalidateGitQueries(input.queryClient);181 await invalidateGitBranchQueries(input.queryClient, input.cwd);
204 },182 },
205 });183 });
206}184}
207185
208export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {186export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) {
209 return mutationOptions({187 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 },
225 mutationKey: ["git", "mutation", "create-worktree"] as const,188 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 () => {
227 await invalidateGitQueries(input.queryClient);193 await invalidateGitQueries(input.queryClient);
228 },194 },
229 });195 });
...@@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie...@@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie
231197
232export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {198export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) {
233 return mutationOptions({199 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 },
239 mutationKey: ["git", "mutation", "remove-worktree"] as const,200 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 () => {
241 await invalidateGitQueries(input.queryClient);205 await invalidateGitQueries(input.queryClient);
242 },206 },
243 });207 });
...@@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: {...@@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
248 queryClient: QueryClient;212 queryClient: QueryClient;
249}) {213}) {
250 return mutationOptions({214 return mutationOptions({
251 mutationFn: async ({215 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),
252 reference,216 mutationFn: async (args: {
253 mode,
254 threadId,
255 }: {
256 reference: string;217 reference: string;
257 mode: "local" | "worktree";218 mode: "local" | "worktree";
258 threadId?: ThreadId;219 threadId?: ThreadId;
...@@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: {...@@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: {
261 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");222 if (!input.cwd) throw new Error("Pull request thread preparation is unavailable.");
262 return api.git.preparePullRequestThread({223 return api.git.preparePullRequestThread({
263 cwd: input.cwd,224 cwd: input.cwd,
264 reference,225 reference: args.reference,
265 mode,226 mode: args.mode,
266 ...(threadId ? { threadId } : {}),227 ...(args.threadId ? { threadId: args.threadId } : {}),
267 });228 });
268 },229 },
269 mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd),230 onSuccess: async () => {
270 onSettled: async () => {231 await invalidateGitBranchQueries(input.queryClient, input.cwd);
271 await invalidateGitQueries(input.queryClient);
272 },232 },
273 });233 });
274}234}
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 {...@@ -25,7 +25,7 @@ export function ensureNativeApi(): NativeApi {
25 return api;25 return api;
26}26}
2727
28export function __resetNativeApiForTests() {28export async function __resetNativeApiForTests() {
29 cachedApi = undefined;29 cachedApi = undefined;
30 __resetWsNativeApiForTests();30 await __resetWsNativeApiForTests();
31}31}
apps/web/src/wsNativeApi.test.ts+46-1
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 DEFAULT_SERVER_SETTINGS,3 DEFAULT_SERVER_SETTINGS,
4 type DesktopBridge,4 type DesktopBridge,
5 EventId,5 EventId,
6 type GitStatusResult,
6 ProjectId,7 ProjectId,
7 type OrchestrationEvent,8 type OrchestrationEvent,
8 type ServerConfig,9 type ServerConfig,
...@@ -31,6 +32,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even...@@ -31,6 +32,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
3132
32const terminalEventListeners = new Set<(event: TerminalEvent) => void>();33const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
33const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();34const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>();
35const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
3436
35const rpcClientMock = {37const rpcClientMock = {
36 dispose: vi.fn(),38 dispose: vi.fn(),
...@@ -54,7 +56,10 @@ const rpcClientMock = {...@@ -54,7 +56,10 @@ const rpcClientMock = {
54 },56 },
55 git: {57 git: {
56 pull: vi.fn(),58 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 ),
58 runStackedAction: vi.fn(),63 runStackedAction: vi.fn(),
59 listBranches: vi.fn(),64 listBranches: vi.fn(),
60 createWorktree: vi.fn(),65 createWorktree: vi.fn(),
...@@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = {...@@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = {
168 settings: DEFAULT_SERVER_SETTINGS,173 settings: DEFAULT_SERVER_SETTINGS,
169};174};
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
171beforeEach(() => {189beforeEach(() => {
172 vi.resetModules();190 vi.resetModules();
173 vi.clearAllMocks();191 vi.clearAllMocks();
174 showContextMenuFallbackMock.mockReset();192 showContextMenuFallbackMock.mockReset();
175 terminalEventListeners.clear();193 terminalEventListeners.clear();
176 orchestrationEventListeners.clear();194 orchestrationEventListeners.clear();
195 gitStatusListeners.clear();
177 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");196 Reflect.deleteProperty(getWindowForTest(), "desktopBridge");
178});197});
179198
...@@ -243,6 +262,32 @@ describe("wsNativeApi", () => {...@@ -243,6 +262,32 @@ describe("wsNativeApi", () => {
243 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);262 expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent);
244 });263 });
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
246 it("forwards orchestration stream subscription options to the RPC client", async () => {291 it("forwards orchestration stream subscription options to the RPC client", async () => {
247 const { createWsNativeApi } = await import("./wsNativeApi");292 const { createWsNativeApi } = await import("./wsNativeApi");
248293
apps/web/src/wsNativeApi.ts+8-3
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";1import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts";
22
3import { resetGitStatusStateForTests } from "./lib/gitStatusState";
3import { showContextMenuFallback } from "./contextMenuFallback";4import { showContextMenuFallback } from "./contextMenuFallback";
5import { __resetWsRpcAtomClientForTests } from "./rpc/client";
4import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
5import { resetServerStateForTests } from "./rpc/serverState";7import { resetServerStateForTests } from "./rpc/serverState";
6import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";8import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
...@@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";...@@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient";
810
9let instance: { api: NativeApi } | null = null;11let instance: { api: NativeApi } | null = null;
1012
11export function __resetWsNativeApiForTests() {13export async function __resetWsNativeApiForTests() {
12 instance = null;14 instance = null;
13 __resetWsRpcClientForTests();15 await __resetWsRpcAtomClientForTests();
16 await __resetWsRpcClientForTests();
17 resetGitStatusStateForTests();
14 resetRequestLatencyStateForTests();18 resetRequestLatencyStateForTests();
15 resetServerStateForTests();19 resetServerStateForTests();
16 resetWsConnectionStateForTests();20 resetWsConnectionStateForTests();
...@@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi {...@@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi {
65 },69 },
66 git: {70 git: {
67 pull: rpcClient.git.pull,71 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),
69 listBranches: rpcClient.git.listBranches,74 listBranches: rpcClient.git.listBranches,
70 createWorktree: rpcClient.git.createWorktree,75 createWorktree: rpcClient.git.createWorktree,
71 removeWorktree: rpcClient.git.removeWorktree,76 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 {...@@ -2,11 +2,14 @@ import {
2 type GitActionProgressEvent,2 type GitActionProgressEvent,
3 type GitRunStackedActionInput,3 type GitRunStackedActionInput,
4 type GitRunStackedActionResult,4 type GitRunStackedActionResult,
5 type GitStatusResult,
6 type GitStatusStreamEvent,
5 type NativeApi,7 type NativeApi,
6 ORCHESTRATION_WS_METHODS,8 ORCHESTRATION_WS_METHODS,
7 type ServerSettingsPatch,9 type ServerSettingsPatch,
8 WS_METHODS,10 WS_METHODS,
9} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
10import { Effect, Stream } from "effect";13import { Effect, Stream } from "effect";
1114
12import { type WsRpcProtocolClient } from "./rpc/protocol";15import { type WsRpcProtocolClient } from "./rpc/protocol";
...@@ -64,7 +67,12 @@ export interface WsRpcClient {...@@ -64,7 +67,12 @@ export interface WsRpcClient {
64 };67 };
65 readonly git: {68 readonly git: {
66 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;69 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;
68 readonly runStackedAction: (76 readonly runStackedAction: (
69 input: GitRunStackedActionInput,77 input: GitRunStackedActionInput,
70 options?: GitRunStackedActionOptions,78 options?: GitRunStackedActionOptions,
...@@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {...@@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient {
149 },157 },
150 git: {158 git: {
151 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),159 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 },
153 runStackedAction: async (input, options) => {173 runStackedAction: async (input, options) => {
154 let result: GitRunStackedActionResult | null = null;174 let result: GitRunStackedActionResult | null = null;
155175
apps/web/test/wsRpcHarness.ts+2-1
...@@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions {...@@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions {
2424
25const STREAM_METHODS = new Set<string>([25const STREAM_METHODS = new Set<string>([
26 WS_METHODS.gitRunStackedAction,26 WS_METHODS.gitRunStackedAction,
27 WS_METHODS.subscribeGitStatus,
27 WS_METHODS.subscribeOrchestrationDomainEvents,28 WS_METHODS.subscribeOrchestrationDomainEvents,
28 WS_METHODS.subscribeTerminalEvents,29 WS_METHODS.subscribeTerminalEvents,
29 WS_METHODS.subscribeServerConfig,30 WS_METHODS.subscribeServerConfig,
...@@ -108,7 +109,7 @@ export class BrowserWsRpcHarness {...@@ -108,7 +109,7 @@ export class BrowserWsRpcHarness {
108 async onMessage(rawData: string): Promise<void> {109 async onMessage(rawData: string): Promise<void> {
109 const server = await this.serverReady;110 const server = await this.serverReady;
110 if (!server) {111 if (!server) {
111 throw new Error("RPC test server is not connected");112 return;
112 }113 }
113 const messages = this.parser.decode(rawData);114 const messages = this.parser.decode(rawData);
114 for (const message of messages) {115 for (const message of messages) {
packages/contracts/src/git.ts+49-1
...@@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);...@@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
44const GitPullRequestReference = TrimmedNonEmptyStringSchema;44const GitPullRequestReference = TrimmedNonEmptyStringSchema;
45const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);45const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
46const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);46const 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;
47export const GitRunStackedActionToastRunAction = Schema.Struct({55export const GitRunStackedActionToastRunAction = Schema.Struct({
48 kind: GitStackedAction,56 kind: GitStackedAction,
49});57});
...@@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;...@@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
161export const GitCreateBranchInput = Schema.Struct({169export const GitCreateBranchInput = Schema.Struct({
162 cwd: TrimmedNonEmptyStringSchema,170 cwd: TrimmedNonEmptyStringSchema,
163 branch: TrimmedNonEmptyStringSchema,171 branch: TrimmedNonEmptyStringSchema,
172 checkout: Schema.optional(Schema.Boolean),
164});173});
165export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;174export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
166175
176export const GitCreateBranchResult = Schema.Struct({
177 branch: TrimmedNonEmptyStringSchema,
178});
179export type GitCreateBranchResult = typeof GitCreateBranchResult.Type;
180
167export const GitCheckoutInput = Schema.Struct({181export const GitCheckoutInput = Schema.Struct({
168 cwd: TrimmedNonEmptyStringSchema,182 cwd: TrimmedNonEmptyStringSchema,
169 branch: TrimmedNonEmptyStringSchema,183 branch: TrimmedNonEmptyStringSchema,
...@@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({...@@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({
186 state: GitStatusPrState,200 state: GitStatusPrState,
187});201});
188202
189export const GitStatusResult = Schema.Struct({203const GitStatusLocalShape = {
190 isRepo: Schema.Boolean,204 isRepo: Schema.Boolean,
205 hostingProvider: Schema.optional(GitHostingProvider),
191 hasOriginRemote: Schema.Boolean,206 hasOriginRemote: Schema.Boolean,
192 isDefaultBranch: Schema.Boolean,207 isDefaultBranch: Schema.Boolean,
193 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),208 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
...@@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({...@@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({
203 insertions: NonNegativeInt,218 insertions: NonNegativeInt,
204 deletions: NonNegativeInt,219 deletions: NonNegativeInt,
205 }),220 }),
221};
222
223const GitStatusRemoteShape = {
206 hasUpstream: Schema.Boolean,224 hasUpstream: Schema.Boolean,
207 aheadCount: NonNegativeInt,225 aheadCount: NonNegativeInt,
208 behindCount: NonNegativeInt,226 behindCount: NonNegativeInt,
209 pr: Schema.NullOr(GitStatusPr),227 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,
210});239});
211export type GitStatusResult = typeof GitStatusResult.Type;240export 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
213export const GitListBranchesResult = Schema.Struct({256export const GitListBranchesResult = Schema.Struct({
214 branches: Schema.Array(GitBranch),257 branches: Schema.Array(GitBranch),
215 isRepo: Schema.Boolean,258 isRepo: Schema.Boolean,
...@@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({...@@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({
236});279});
237export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type;280export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type;
238281
282export const GitCheckoutResult = Schema.Struct({
283 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
284});
285export type GitCheckoutResult = typeof GitCheckoutResult.Type;
286
239export const GitRunStackedActionResult = Schema.Struct({287export const GitRunStackedActionResult = Schema.Struct({
240 action: GitStackedAction,288 action: GitStackedAction,
241 branch: Schema.Struct({289 branch: Schema.Struct({
packages/contracts/src/ipc.ts+12-3
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import type {1import type {
2 GitCheckoutInput,2 GitCheckoutInput,
3 GitCheckoutResult,
3 GitCreateBranchInput,4 GitCreateBranchInput,
4 GitPreparePullRequestThreadInput,5 GitPreparePullRequestThreadInput,
5 GitPreparePullRequestThreadResult,6 GitPreparePullRequestThreadResult,
...@@ -15,6 +16,7 @@ import type {...@@ -15,6 +16,7 @@ import type {
15 GitResolvePullRequestResult,16 GitResolvePullRequestResult,
16 GitStatusInput,17 GitStatusInput,
17 GitStatusResult,18 GitStatusResult,
19 GitCreateBranchResult,
18} from "./git";20} from "./git";
19import type {21import type {
20 ProjectSearchEntriesInput,22 ProjectSearchEntriesInput,
...@@ -148,8 +150,8 @@ export interface NativeApi {...@@ -148,8 +150,8 @@ export interface NativeApi {
148 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;150 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
149 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;151 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
150 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;152 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
151 createBranch: (input: GitCreateBranchInput) => Promise<void>;153 createBranch: (input: GitCreateBranchInput) => Promise<GitCreateBranchResult>;
152 checkout: (input: GitCheckoutInput) => Promise<void>;154 checkout: (input: GitCheckoutInput) => Promise<GitCheckoutResult>;
153 init: (input: GitInitInput) => Promise<void>;155 init: (input: GitInitInput) => Promise<void>;
154 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;156 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
155 preparePullRequestThread: (157 preparePullRequestThread: (
...@@ -157,7 +159,14 @@ export interface NativeApi {...@@ -157,7 +159,14 @@ export interface NativeApi {
157 ) => Promise<GitPreparePullRequestThreadResult>;159 ) => Promise<GitPreparePullRequestThreadResult>;
158 // Stacked action API160 // Stacked action API
159 pull: (input: GitPullInput) => Promise<GitPullResult>;161 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;
161 };170 };
162 contextMenu: {171 contextMenu: {
163 show: <T extends string>(172 show: <T extends string>(
packages/contracts/src/rpc.ts+18-4
...@@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor";...@@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor";
6import {6import {
7 GitActionProgressEvent,7 GitActionProgressEvent,
8 GitCheckoutInput,8 GitCheckoutInput,
9 GitCheckoutResult,
9 GitCommandError,10 GitCommandError,
10 GitCreateBranchInput,11 GitCreateBranchInput,
12 GitCreateBranchResult,
11 GitCreateWorktreeInput,13 GitCreateWorktreeInput,
12 GitCreateWorktreeResult,14 GitCreateWorktreeResult,
13 GitInitInput,15 GitInitInput,
...@@ -24,6 +26,7 @@ import {...@@ -24,6 +26,7 @@ import {
24 GitRunStackedActionInput,26 GitRunStackedActionInput,
25 GitStatusInput,27 GitStatusInput,
26 GitStatusResult,28 GitStatusResult,
29 GitStatusStreamEvent,
27} from "./git";30} from "./git";
28import { KeybindingsConfigError } from "./keybindings";31import { KeybindingsConfigError } from "./keybindings";
29import {32import {
...@@ -83,7 +86,7 @@ export const WS_METHODS = {...@@ -83,7 +86,7 @@ export const WS_METHODS = {
8386
84 // Git methods87 // Git methods
85 gitPull: "git.pull",88 gitPull: "git.pull",
86 gitStatus: "git.status",89 gitRefreshStatus: "git.refreshStatus",
87 gitRunStackedAction: "git.runStackedAction",90 gitRunStackedAction: "git.runStackedAction",
88 gitListBranches: "git.listBranches",91 gitListBranches: "git.listBranches",
89 gitCreateWorktree: "git.createWorktree",92 gitCreateWorktree: "git.createWorktree",
...@@ -110,6 +113,7 @@ export const WS_METHODS = {...@@ -110,6 +113,7 @@ export const WS_METHODS = {
110 serverUpdateSettings: "server.updateSettings",113 serverUpdateSettings: "server.updateSettings",
111114
112 // Streaming subscriptions115 // Streaming subscriptions
116 subscribeGitStatus: "subscribeGitStatus",
113 subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",117 subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents",
114 subscribeTerminalEvents: "subscribeTerminalEvents",118 subscribeTerminalEvents: "subscribeTerminalEvents",
115 subscribeServerConfig: "subscribeServerConfig",119 subscribeServerConfig: "subscribeServerConfig",
...@@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {...@@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, {
162 error: OpenError,166 error: OpenError,
163});167});
164168
165export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, {169export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {
166 payload: GitStatusInput,170 payload: GitStatusInput,
167 success: GitStatusResult,171 success: GitStatusStreamEvent,
168 error: GitManagerServiceError,172 error: GitManagerServiceError,
173 stream: true,
169});174});
170175
171export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {176export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
...@@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {...@@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
174 error: GitCommandError,179 error: GitCommandError,
175});180});
176181
182export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, {
183 payload: GitStatusInput,
184 success: GitStatusResult,
185 error: GitManagerServiceError,
186});
187
177export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {188export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, {
178 payload: GitRunStackedActionInput,189 payload: GitRunStackedActionInput,
179 success: GitActionProgressEvent,190 success: GitActionProgressEvent,
...@@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {...@@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
212223
213export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {224export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
214 payload: GitCreateBranchInput,225 payload: GitCreateBranchInput,
226 success: GitCreateBranchResult,
215 error: GitCommandError,227 error: GitCommandError,
216});228});
217229
218export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {230export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
219 payload: GitCheckoutInput,231 payload: GitCheckoutInput,
232 success: GitCheckoutResult,
220 error: GitCommandError,233 error: GitCommandError,
221});234});
222235
...@@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make(...@@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make(
330 WsProjectsSearchEntriesRpc,343 WsProjectsSearchEntriesRpc,
331 WsProjectsWriteFileRpc,344 WsProjectsWriteFileRpc,
332 WsShellOpenInEditorRpc,345 WsShellOpenInEditorRpc,
333 WsGitStatusRpc,346 WsSubscribeGitStatusRpc,
334 WsGitPullRpc,347 WsGitPullRpc,
348 WsGitRefreshStatusRpc,
335 WsGitRunStackedActionRpc,349 WsGitRunStackedActionRpc,
336 WsGitResolvePullRequestRpc,350 WsGitResolvePullRequestRpc,
337 WsGitPreparePullRequestThreadRpc,351 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 @@...@@ -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
3/**10/**
4 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.11 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
...@@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches(...@@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches(
119 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));126 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
120 });127 });
121}128}
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}