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