| 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,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => { |
| 949 | yield* git(source, ["checkout", defaultBranch]); | 949 | yield* git(source, ["checkout", defaultBranch]); |
| 950 | yield* git(source, ["branch", "-D", featureBranch]); | 950 | yield* git(source, ["branch", "-D", featureBranch]); |
| 951 | 951 | ||
| 952 | yield* (yield* GitCore).checkoutBranch({ | 952 | const checkoutResult = yield* (yield* GitCore).checkoutBranch({ |
| 953 | cwd: source, | 953 | cwd: source, |
| 954 | branch: `${remoteName}/${featureBranch}`, | 954 | branch: `${remoteName}/${featureBranch}`, |
| 955 | }); | 955 | }); |
| 956 | 956 | ||
| 957 | expect(checkoutResult.branch).toBe("upstream/feature"); | ||
| 957 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); | 958 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); |
| 958 | const realGitCore = yield* GitCore; | 959 | const realGitCore = yield* GitCore; |
| 959 | let fetchArgs: readonly string[] | null = null; | 960 | let fetchArgs: readonly string[] | null = null; |
apps/server/src/git/Layers/GitCore.ts+32-9| ... | @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1177 | return branchLastCommit; | 1177 | return branchLastCommit; |
| 1178 | }); | 1178 | }); |
| 1179 | 1179 | ||
| 1180 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | 1180 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { |
| 1181 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); | ||
| 1182 | |||
| 1183 | const statusResult = yield* executeGit( | 1181 | const statusResult = yield* executeGit( |
| 1184 | "GitCore.statusDetails.status", | 1182 | "GitCore.statusDetails.status", |
| 1185 | cwd, | 1183 | cwd, |
| ... | @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 1312 | }; | 1310 | }; |
| 1313 | }); | 1311 | }); |
| 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 | const status: GitCoreShape["status"] = (input) => | 1324 | const status: GitCoreShape["status"] = (input) => |
| 1316 | statusDetails(input.cwd).pipe( | 1325 | statusDetails(input.cwd).pipe( |
| 1317 | Effect.map((details) => ({ | 1326 | Effect.map((details) => ({ |
| ... | @@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -2000,12 +2009,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 2000 | return { branch: targetBranch }; | 2009 | return { branch: targetBranch }; |
| 2001 | }); | 2010 | }); |
| 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 | const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( | 2012 | const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( |
| 2010 | function* (input) { | 2013 | function* (input) { |
| 2011 | const [localInputExists, remoteExists] = yield* Effect.all( | 2014 | const [localInputExists, remoteExists] = yield* Effect.all( |
| ... | @@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -2078,9 +2081,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 2078 | timeoutMs: 10_000, | 2081 | timeoutMs: 10_000, |
| 2079 | fallbackErrorMessage: "git checkout failed", | 2082 | fallbackErrorMessage: "git checkout failed", |
| 2080 | }); | 2083 | }); |
| 2084 | |||
| 2085 | const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ | ||
| 2086 | "branch", | ||
| 2087 | "--show-current", | ||
| 2088 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); | ||
| 2089 | |||
| 2090 | return { branch }; | ||
| 2081 | }, | 2091 | }, |
| 2082 | ); | 2092 | ); |
| 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 | const initRepo: GitCoreShape["initRepo"] = (input) => | 2106 | const initRepo: GitCoreShape["initRepo"] = (input) => |
| 2085 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | 2107 | executeGit("GitCore.initRepo", input.cwd, ["init"], { |
| 2086 | timeoutMs: 10_000, | 2108 | timeoutMs: 10_000, |
| ... | @@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ... | @@ -2106,6 +2128,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { |
| 2106 | execute, | 2128 | execute, |
| 2107 | status, | 2129 | status, |
| 2108 | statusDetails, | 2130 | statusDetails, |
| 2131 | statusDetailsLocal, | ||
| 2109 | prepareCommitContext, | 2132 | prepareCommitContext, |
| 2110 | commit, | 2133 | commit, |
| 2111 | pushCurrentBranch, | 2134 | pushCurrentBranch, |
apps/server/src/git/Layers/GitManager.ts+104-32| ... | @@ -8,9 +8,13 @@ import { | ... | @@ -8,9 +8,13 @@ import { |
| 8 | GitCommandError, | 8 | GitCommandError, |
| 9 | GitRunStackedActionResult, | 9 | GitRunStackedActionResult, |
| 10 | GitStackedAction, | 10 | GitStackedAction, |
| 11 | type GitStatusLocalResult, | ||
| 12 | type GitStatusRemoteResult, | ||
| 11 | ModelSelection, | 13 | ModelSelection, |
| 12 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 13 | import { | 15 | import { |
| 16 | detectGitHostingProviderFromRemoteUrl, | ||
| 17 | mergeGitStatusParts, | ||
| 14 | resolveAutoFeatureBranchName, | 18 | resolveAutoFeatureBranchName, |
| 15 | sanitizeBranchFragment, | 19 | sanitizeBranchFragment, |
| 16 | sanitizeFeatureBranchName, | 20 | sanitizeFeatureBranchName, |
| ... | @@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 695 | 699 | ||
| 696 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | 700 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; |
| 697 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); | 701 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); |
| 698 | const readStatus = Effect.fn("readStatus")(function* (cwd: string) { | 702 | const nonRepositoryStatusDetails = { |
| 699 | const details = yield* gitCore.statusDetails(cwd).pipe( | 703 | isRepo: false, |
| 700 | Effect.catchIf(isNotGitRepositoryError, () => | 704 | hasOriginRemote: false, |
| 701 | Effect.succeed({ | 705 | isDefaultBranch: false, |
| 702 | isRepo: false, | 706 | branch: null, |
| 703 | hasOriginRemote: false, | 707 | upstreamRef: null, |
| 704 | isDefaultBranch: false, | 708 | hasWorkingTreeChanges: false, |
| 705 | branch: null, | 709 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 706 | upstreamRef: null, | 710 | hasUpstream: false, |
| 707 | hasWorkingTreeChanges: false, | 711 | aheadCount: 0, |
| 708 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 712 | behindCount: 0, |
| 709 | hasUpstream: false, | 713 | } satisfies GitStatusDetails; |
| 710 | aheadCount: 0, | 714 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { |
| 711 | behindCount: 0, | 715 | const details = yield* gitCore |
| 712 | } satisfies GitStatusDetails), | 716 | .statusDetailsLocal(cwd) |
| 713 | ), | 717 | .pipe( |
| 714 | ); | 718 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), |
| 719 | ); | ||
| 720 | const hostingProvider = details.isRepo | ||
| 721 | ? yield* resolveHostingProvider(cwd, details.branch) | ||
| 722 | : null; | ||
| 723 | |||
| 724 | return { | ||
| 725 | isRepo: details.isRepo, | ||
| 726 | ...(hostingProvider ? { hostingProvider } : {}), | ||
| 727 | hasOriginRemote: details.hasOriginRemote, | ||
| 728 | isDefaultBranch: details.isDefaultBranch, | ||
| 729 | branch: details.branch, | ||
| 730 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 731 | workingTree: details.workingTree, | ||
| 732 | } satisfies GitStatusLocalResult; | ||
| 733 | }); | ||
| 734 | const localStatusResultCache = yield* Cache.makeWith({ | ||
| 735 | capacity: STATUS_RESULT_CACHE_CAPACITY, | ||
| 736 | lookup: readLocalStatus, | ||
| 737 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | ||
| 738 | }); | ||
| 739 | const invalidateLocalStatusResultCache = (cwd: string) => | ||
| 740 | Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); | ||
| 741 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { | ||
| 742 | const details = yield* gitCore | ||
| 743 | .statusDetails(cwd) | ||
| 744 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); | ||
| 745 | if (details === null || !details.isRepo) { | ||
| 746 | return null; | ||
| 747 | } | ||
| 715 | 748 | ||
| 716 | const pr = | 749 | const pr = |
| 717 | details.isRepo && details.branch !== null | 750 | details.branch !== null |
| 718 | ? yield* findLatestPr(cwd, { | 751 | ? yield* findLatestPr(cwd, { |
| 719 | branch: details.branch, | 752 | branch: details.branch, |
| 720 | upstreamRef: details.upstreamRef, | 753 | upstreamRef: details.upstreamRef, |
| ... | @@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 725 | : null; | 758 | : null; |
| 726 | 759 | ||
| 727 | return { | 760 | return { |
| 728 | isRepo: details.isRepo, | ||
| 729 | hasOriginRemote: details.hasOriginRemote, | ||
| 730 | isDefaultBranch: details.isDefaultBranch, | ||
| 731 | branch: details.branch, | ||
| 732 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 733 | workingTree: details.workingTree, | ||
| 734 | hasUpstream: details.hasUpstream, | 761 | hasUpstream: details.hasUpstream, |
| 735 | aheadCount: details.aheadCount, | 762 | aheadCount: details.aheadCount, |
| 736 | behindCount: details.behindCount, | 763 | behindCount: details.behindCount, |
| 737 | pr, | 764 | pr, |
| 738 | }; | 765 | } satisfies GitStatusRemoteResult; |
| 739 | }); | 766 | }); |
| 740 | const statusResultCache = yield* Cache.makeWith({ | 767 | const remoteStatusResultCache = yield* Cache.makeWith({ |
| 741 | capacity: STATUS_RESULT_CACHE_CAPACITY, | 768 | capacity: STATUS_RESULT_CACHE_CAPACITY, |
| 742 | lookup: readStatus, | 769 | lookup: readRemoteStatus, |
| 743 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | 770 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), |
| 744 | }); | 771 | }); |
| 745 | const invalidateStatusResultCache = (cwd: string) => | 772 | const invalidateRemoteStatusResultCache = (cwd: string) => |
| 746 | Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); | 773 | Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); |
| 747 | 774 | ||
| 748 | const readConfigValueNullable = (cwd: string, key: string) => | 775 | const readConfigValueNullable = (cwd: string, key: string) => |
| 749 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | 776 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); |
| 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 | const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( | 793 | const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( |
| 752 | cwd: string, | 794 | cwd: string, |
| 753 | remoteName: string | null, | 795 | remoteName: string | null, |
| ... | @@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1311 | }; | 1353 | }; |
| 1312 | }); | 1354 | }); |
| 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 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { | 1364 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { |
| 1315 | return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); | 1365 | const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); |
| 1366 | return mergeGitStatusParts(local, remote); | ||
| 1316 | }); | 1367 | }); |
| 1368 | const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( | ||
| 1369 | "invalidateLocalStatus", | ||
| 1370 | )(function* (cwd) { | ||
| 1371 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1372 | }); | ||
| 1373 | const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( | ||
| 1374 | "invalidateRemoteStatus", | ||
| 1375 | )(function* (cwd) { | ||
| 1376 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1377 | }); | ||
| 1378 | const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( | ||
| 1379 | function* (cwd) { | ||
| 1380 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1381 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1382 | }, | ||
| 1383 | ); | ||
| 1317 | 1384 | ||
| 1318 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( | 1385 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( |
| 1319 | function* (input) { | 1386 | function* (input) { |
| ... | @@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1488 | branch: worktree.worktree.branch, | 1555 | branch: worktree.worktree.branch, |
| 1489 | worktreePath: worktree.worktree.path, | 1556 | worktreePath: worktree.worktree.path, |
| 1490 | }; | 1557 | }; |
| 1491 | }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); | 1558 | }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); |
| 1492 | }); | 1559 | }); |
| 1493 | 1560 | ||
| 1494 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( | 1561 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( |
| ... | @@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1692 | }); | 1759 | }); |
| 1693 | 1760 | ||
| 1694 | return yield* runAction().pipe( | 1761 | return yield* runAction().pipe( |
| 1695 | Effect.ensuring(invalidateStatusResultCache(input.cwd)), | 1762 | Effect.ensuring(invalidateStatus(input.cwd)), |
| 1696 | Effect.tapError((error) => | 1763 | Effect.tapError((error) => |
| 1697 | Effect.flatMap(Ref.get(currentPhase), (phase) => | 1764 | Effect.flatMap(Ref.get(currentPhase), (phase) => |
| 1698 | progress.emit({ | 1765 | progress.emit({ |
| ... | @@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ... | @@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { |
| 1707 | ); | 1774 | ); |
| 1708 | 1775 | ||
| 1709 | return { | 1776 | return { |
| 1777 | localStatus, | ||
| 1778 | remoteStatus, | ||
| 1710 | status, | 1779 | status, |
| 1780 | invalidateLocalStatus, | ||
| 1781 | invalidateRemoteStatus, | ||
| 1782 | invalidateStatus, | ||
| 1711 | resolvePullRequest, | 1783 | resolvePullRequest, |
| 1712 | preparePullRequestThread, | 1784 | preparePullRequestThread, |
| 1713 | runStackedAction, | 1785 | runStackedAction, |
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts created+276| ... | @@ -0,0 +1,276 @@ | ||
| 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,10 +7,12 @@ |
| 7 | * @module GitCore | 7 | * @module GitCore |
| 8 | */ | 8 | */ |
| 9 | import { ServiceMap } from "effect"; | 9 | import { ServiceMap } from "effect"; |
| 10 | import type { Effect, Scope } from "effect"; | 10 | import type { Effect } from "effect"; |
| 11 | import type { | 11 | import type { |
| 12 | GitCheckoutInput, | 12 | GitCheckoutInput, |
| 13 | GitCheckoutResult, | ||
| 13 | GitCreateBranchInput, | 14 | GitCreateBranchInput, |
| 15 | GitCreateBranchResult, | ||
| 14 | GitCreateWorktreeInput, | 16 | GitCreateWorktreeInput, |
| 15 | GitCreateWorktreeResult, | 17 | GitCreateWorktreeResult, |
| 16 | GitInitInput, | 18 | GitInitInput, |
| ... | @@ -156,6 +158,11 @@ export interface GitCoreShape { | ... | @@ -156,6 +158,11 @@ export interface GitCoreShape { |
| 156 | */ | 158 | */ |
| 157 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | 159 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; |
| 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 | * Build staged change context for commit generation. | 167 | * Build staged change context for commit generation. |
| 161 | */ | 168 | */ |
| ... | @@ -278,14 +285,16 @@ export interface GitCoreShape { | ... | @@ -278,14 +285,16 @@ export interface GitCoreShape { |
| 278 | /** | 285 | /** |
| 279 | * Create a local branch. | 286 | * Create a local branch. |
| 280 | */ | 287 | */ |
| 281 | readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>; | 288 | readonly createBranch: ( |
| 289 | input: GitCreateBranchInput, | ||
| 290 | ) => Effect.Effect<GitCreateBranchResult, GitCommandError>; | ||
| 282 | 291 | ||
| 283 | /** | 292 | /** |
| 284 | * Checkout an existing branch and refresh its upstream metadata in background. | 293 | * Checkout an existing branch and refresh its upstream metadata in background. |
| 285 | */ | 294 | */ |
| 286 | readonly checkoutBranch: ( | 295 | readonly checkoutBranch: ( |
| 287 | input: GitCheckoutInput, | 296 | input: GitCheckoutInput, |
| 288 | ) => Effect.Effect<void, GitCommandError, Scope.Scope>; | 297 | ) => Effect.Effect<GitCheckoutResult, GitCommandError>; |
| 289 | 298 | ||
| 290 | /** | 299 | /** |
| 291 | * Initialize a repository in the provided directory. | 300 | * Initialize a repository in the provided directory. |
apps/server/src/git/Services/GitManager.ts+31| ... | @@ -14,6 +14,8 @@ import { | ... | @@ -14,6 +14,8 @@ import { |
| 14 | GitResolvePullRequestResult, | 14 | GitResolvePullRequestResult, |
| 15 | GitRunStackedActionInput, | 15 | GitRunStackedActionInput, |
| 16 | GitRunStackedActionResult, | 16 | GitRunStackedActionResult, |
| 17 | GitStatusLocalResult, | ||
| 18 | GitStatusRemoteResult, | ||
| 17 | GitStatusInput, | 19 | GitStatusInput, |
| 18 | GitStatusResult, | 20 | GitStatusResult, |
| 19 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| ... | @@ -41,6 +43,35 @@ export interface GitManagerShape { | ... | @@ -41,6 +43,35 @@ export interface GitManagerShape { |
| 41 | input: GitStatusInput, | 43 | input: GitStatusInput, |
| 42 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | 44 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; |
| 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 | * Resolve a pull request by URL/number against the current repository. | 76 | * Resolve a pull request by URL/number against the current repository. |
| 46 | */ | 77 | */ |
apps/server/src/git/Services/GitStatusBroadcaster.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 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,7 +22,16 @@ import { |
| 22 | } from "@t3tools/contracts"; | 22 | } from "@t3tools/contracts"; |
| 23 | import { assert, it } from "@effect/vitest"; | 23 | import { assert, it } from "@effect/vitest"; |
| 24 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | 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 | import { | 35 | import { |
| 27 | FetchHttpClient, | 36 | FetchHttpClient, |
| 28 | HttpBody, | 37 | HttpBody, |
| ... | @@ -44,6 +53,7 @@ import { | ... | @@ -44,6 +53,7 @@ import { |
| 44 | } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | 53 | } from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| 45 | import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; | 54 | import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; |
| 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 55 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 56 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; | ||
| 47 | import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; | 57 | import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; |
| 48 | import { Open, type OpenShape } from "./open.ts"; | 58 | import { Open, type OpenShape } from "./open.ts"; |
| 49 | import { | 59 | import { |
| ... | @@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: { | ... | @@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: { |
| 294 | ...options?.config, | 304 | ...options?.config, |
| 295 | }; | 305 | }; |
| 296 | const layerConfig = Layer.succeed(ServerConfig, config); | 306 | const layerConfig = Layer.succeed(ServerConfig, config); |
| 307 | const gitManagerLayer = Layer.mock(GitManager)({ | ||
| 308 | ...options?.layers?.gitManager, | ||
| 309 | }); | ||
| 310 | const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); | ||
| 297 | 311 | ||
| 298 | const appLayer = HttpRouter.serve(makeRoutesLayer, { | 312 | const appLayer = HttpRouter.serve(makeRoutesLayer, { |
| 299 | disableListenLog: true, | 313 | disableListenLog: true, |
| ... | @@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: { | ... | @@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: { |
| 333 | ...options?.layers?.gitCore, | 347 | ...options?.layers?.gitCore, |
| 334 | }), | 348 | }), |
| 335 | ), | 349 | ), |
| 336 | Layer.provide( | 350 | Layer.provide(gitManagerLayer), |
| 337 | Layer.mock(GitManager)({ | 351 | Layer.provideMerge(gitStatusBroadcasterLayer), |
| 338 | ...options?.layers?.gitManager, | ||
| 339 | }), | ||
| 340 | ), | ||
| 341 | Layer.provide( | 352 | Layer.provide( |
| 342 | Layer.mock(ProjectSetupScriptRunner)({ | 353 | Layer.mock(ProjectSetupScriptRunner)({ |
| 343 | runForThread: () => Effect.succeed({ status: "no-script" as const }), | 354 | runForThread: () => Effect.succeed({ status: "no-script" as const }), |
| ... | @@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1260 | yield* buildAppUnderTest({ | 1271 | yield* buildAppUnderTest({ |
| 1261 | layers: { | 1272 | layers: { |
| 1262 | gitManager: { | 1273 | gitManager: { |
| 1274 | invalidateLocalStatus: () => Effect.void, | ||
| 1275 | invalidateRemoteStatus: () => Effect.void, | ||
| 1276 | invalidateStatus: () => Effect.void, | ||
| 1277 | localStatus: () => | ||
| 1278 | Effect.succeed({ | ||
| 1279 | isRepo: true, | ||
| 1280 | hasOriginRemote: true, | ||
| 1281 | isDefaultBranch: true, | ||
| 1282 | branch: "main", | ||
| 1283 | hasWorkingTreeChanges: false, | ||
| 1284 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1285 | }), | ||
| 1286 | remoteStatus: () => | ||
| 1287 | Effect.succeed({ | ||
| 1288 | hasUpstream: true, | ||
| 1289 | aheadCount: 0, | ||
| 1290 | behindCount: 0, | ||
| 1291 | pr: null, | ||
| 1292 | }), | ||
| 1263 | status: () => | 1293 | status: () => |
| 1264 | Effect.succeed({ | 1294 | Effect.succeed({ |
| 1265 | isRepo: true, | 1295 | isRepo: true, |
| ... | @@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1373,8 +1403,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1373 | worktree: { path: "/tmp/wt", branch: "feature/demo" }, | 1403 | worktree: { path: "/tmp/wt", branch: "feature/demo" }, |
| 1374 | }), | 1404 | }), |
| 1375 | removeWorktree: () => Effect.void, | 1405 | removeWorktree: () => Effect.void, |
| 1376 | createBranch: () => Effect.void, | 1406 | createBranch: (input) => Effect.succeed({ branch: input.branch }), |
| 1377 | checkoutBranch: () => Effect.void, | 1407 | checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), |
| 1378 | initRepo: () => Effect.void, | 1408 | initRepo: () => Effect.void, |
| 1379 | }, | 1409 | }, |
| 1380 | }, | 1410 | }, |
| ... | @@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1382 | 1412 | ||
| 1383 | const wsUrl = yield* getWsServerUrl("/ws"); | 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 | const pull = yield* Effect.scoped( | 1415 | const pull = yield* Effect.scoped( |
| 1391 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), | 1416 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), |
| 1392 | ); | 1417 | ); |
| 1393 | assert.equal(pull.status, "pulled"); | 1418 | assert.equal(pull.status, "pulled"); |
| 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 | const stackedEvents = yield* Effect.scoped( | 1427 | const stackedEvents = yield* Effect.scoped( |
| 1396 | withWsRpcClient(wsUrl, (client) => | 1428 | withWsRpcClient(wsUrl, (client) => |
| 1397 | client[WS_METHODS.gitRunStackedAction]({ | 1429 | client[WS_METHODS.gitRunStackedAction]({ |
| ... | @@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1494 | cwd: "/tmp/repo", | 1526 | cwd: "/tmp/repo", |
| 1495 | detail: "upstream missing", | 1527 | detail: "upstream missing", |
| 1496 | }); | 1528 | }); |
| 1529 | let invalidationCalls = 0; | ||
| 1530 | let statusCalls = 0; | ||
| 1497 | yield* buildAppUnderTest({ | 1531 | yield* buildAppUnderTest({ |
| 1498 | layers: { | 1532 | layers: { |
| 1499 | gitCore: { | 1533 | gitCore: { |
| 1500 | pullCurrentBranch: () => Effect.fail(gitError), | 1534 | pullCurrentBranch: () => Effect.fail(gitError), |
| 1501 | }, | 1535 | }, |
| 1536 | gitManager: { | ||
| 1537 | invalidateLocalStatus: () => | ||
| 1538 | Effect.sync(() => { | ||
| 1539 | invalidationCalls += 1; | ||
| 1540 | }), | ||
| 1541 | invalidateRemoteStatus: () => | ||
| 1542 | Effect.sync(() => { | ||
| 1543 | invalidationCalls += 1; | ||
| 1544 | }), | ||
| 1545 | invalidateStatus: () => | ||
| 1546 | Effect.sync(() => { | ||
| 1547 | invalidationCalls += 1; | ||
| 1548 | }), | ||
| 1549 | localStatus: () => | ||
| 1550 | Effect.succeed({ | ||
| 1551 | isRepo: true, | ||
| 1552 | hasOriginRemote: true, | ||
| 1553 | isDefaultBranch: true, | ||
| 1554 | branch: "main", | ||
| 1555 | hasWorkingTreeChanges: true, | ||
| 1556 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1557 | }), | ||
| 1558 | remoteStatus: () => | ||
| 1559 | Effect.sync(() => { | ||
| 1560 | statusCalls += 1; | ||
| 1561 | return { | ||
| 1562 | hasUpstream: true, | ||
| 1563 | aheadCount: 0, | ||
| 1564 | behindCount: 0, | ||
| 1565 | pr: null, | ||
| 1566 | }; | ||
| 1567 | }), | ||
| 1568 | status: () => | ||
| 1569 | Effect.sync(() => { | ||
| 1570 | statusCalls += 1; | ||
| 1571 | return { | ||
| 1572 | isRepo: true, | ||
| 1573 | hasOriginRemote: true, | ||
| 1574 | isDefaultBranch: true, | ||
| 1575 | branch: "main", | ||
| 1576 | hasWorkingTreeChanges: true, | ||
| 1577 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1578 | hasUpstream: true, | ||
| 1579 | aheadCount: 0, | ||
| 1580 | behindCount: 0, | ||
| 1581 | pr: null, | ||
| 1582 | }; | ||
| 1583 | }), | ||
| 1584 | }, | ||
| 1502 | }, | 1585 | }, |
| 1503 | }); | 1586 | }); |
| 1504 | 1587 | ||
| ... | @@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1510 | ); | 1593 | ); |
| 1511 | 1594 | ||
| 1512 | assertFailure(result, gitError); | 1595 | assertFailure(result, gitError); |
| 1596 | assert.equal(invalidationCalls, 0); | ||
| 1597 | assert.equal(statusCalls, 0); | ||
| 1598 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1599 | ); | ||
| 1600 | |||
| 1601 | it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => | ||
| 1602 | Effect.gen(function* () { | ||
| 1603 | const gitError = new GitCommandError({ | ||
| 1604 | operation: "commit", | ||
| 1605 | command: "git commit", | ||
| 1606 | cwd: "/tmp/repo", | ||
| 1607 | detail: "nothing to commit", | ||
| 1608 | }); | ||
| 1609 | let invalidationCalls = 0; | ||
| 1610 | let statusCalls = 0; | ||
| 1611 | yield* buildAppUnderTest({ | ||
| 1612 | layers: { | ||
| 1613 | gitManager: { | ||
| 1614 | invalidateLocalStatus: () => | ||
| 1615 | Effect.sync(() => { | ||
| 1616 | invalidationCalls += 1; | ||
| 1617 | }), | ||
| 1618 | invalidateRemoteStatus: () => | ||
| 1619 | Effect.sync(() => { | ||
| 1620 | invalidationCalls += 1; | ||
| 1621 | }), | ||
| 1622 | invalidateStatus: () => | ||
| 1623 | Effect.sync(() => { | ||
| 1624 | invalidationCalls += 1; | ||
| 1625 | }), | ||
| 1626 | localStatus: () => | ||
| 1627 | Effect.succeed({ | ||
| 1628 | isRepo: true, | ||
| 1629 | hasOriginRemote: true, | ||
| 1630 | isDefaultBranch: false, | ||
| 1631 | branch: "feature/demo", | ||
| 1632 | hasWorkingTreeChanges: true, | ||
| 1633 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1634 | }), | ||
| 1635 | remoteStatus: () => | ||
| 1636 | Effect.sync(() => { | ||
| 1637 | statusCalls += 1; | ||
| 1638 | return { | ||
| 1639 | hasUpstream: true, | ||
| 1640 | aheadCount: 0, | ||
| 1641 | behindCount: 0, | ||
| 1642 | pr: null, | ||
| 1643 | }; | ||
| 1644 | }), | ||
| 1645 | status: () => | ||
| 1646 | Effect.sync(() => { | ||
| 1647 | statusCalls += 1; | ||
| 1648 | return { | ||
| 1649 | isRepo: true, | ||
| 1650 | hasOriginRemote: true, | ||
| 1651 | isDefaultBranch: false, | ||
| 1652 | branch: "feature/demo", | ||
| 1653 | hasWorkingTreeChanges: true, | ||
| 1654 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1655 | hasUpstream: true, | ||
| 1656 | aheadCount: 0, | ||
| 1657 | behindCount: 0, | ||
| 1658 | pr: null, | ||
| 1659 | }; | ||
| 1660 | }), | ||
| 1661 | runStackedAction: () => Effect.fail(gitError), | ||
| 1662 | }, | ||
| 1663 | }, | ||
| 1664 | }); | ||
| 1665 | |||
| 1666 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 1667 | const result = yield* Effect.scoped( | ||
| 1668 | withWsRpcClient(wsUrl, (client) => | ||
| 1669 | client[WS_METHODS.gitRunStackedAction]({ | ||
| 1670 | actionId: "action-1", | ||
| 1671 | cwd: "/tmp/repo", | ||
| 1672 | action: "commit", | ||
| 1673 | }).pipe(Stream.runCollect, Effect.result), | ||
| 1674 | ), | ||
| 1675 | ); | ||
| 1676 | |||
| 1677 | assertFailure(result, gitError); | ||
| 1678 | assert.equal(invalidationCalls, 0); | ||
| 1679 | assert.equal(statusCalls, 0); | ||
| 1680 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | ||
| 1681 | ); | ||
| 1682 | |||
| 1683 | it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => | ||
| 1684 | Effect.gen(function* () { | ||
| 1685 | yield* buildAppUnderTest({ | ||
| 1686 | layers: { | ||
| 1687 | gitCore: { | ||
| 1688 | pullCurrentBranch: () => | ||
| 1689 | Effect.succeed({ | ||
| 1690 | status: "pulled" as const, | ||
| 1691 | branch: "main", | ||
| 1692 | upstreamBranch: "origin/main", | ||
| 1693 | }), | ||
| 1694 | }, | ||
| 1695 | gitManager: { | ||
| 1696 | invalidateLocalStatus: () => Effect.void, | ||
| 1697 | invalidateRemoteStatus: () => Effect.void, | ||
| 1698 | localStatus: () => | ||
| 1699 | Effect.succeed({ | ||
| 1700 | isRepo: true, | ||
| 1701 | hasOriginRemote: true, | ||
| 1702 | isDefaultBranch: true, | ||
| 1703 | branch: "main", | ||
| 1704 | hasWorkingTreeChanges: false, | ||
| 1705 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 1706 | }), | ||
| 1707 | remoteStatus: () => | ||
| 1708 | Effect.sleep(Duration.seconds(2)).pipe( | ||
| 1709 | Effect.as({ | ||
| 1710 | hasUpstream: true, | ||
| 1711 | aheadCount: 0, | ||
| 1712 | behindCount: 0, | ||
| 1713 | pr: null, | ||
| 1714 | }), | ||
| 1715 | ), | ||
| 1716 | }, | ||
| 1717 | }, | ||
| 1718 | }); | ||
| 1719 | |||
| 1720 | const wsUrl = yield* getWsServerUrl("/ws"); | ||
| 1721 | const startedAt = Date.now(); | ||
| 1722 | const result = yield* Effect.scoped( | ||
| 1723 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), | ||
| 1724 | ); | ||
| 1725 | const elapsedMs = Date.now() - startedAt; | ||
| 1726 | |||
| 1727 | assert.equal(result.status, "pulled"); | ||
| 1728 | assertTrue(elapsedMs < 1_000); | ||
| 1513 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 1729 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1514 | ); | 1730 | ); |
| 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 | it.effect("routes websocket rpc orchestration methods", () => | 1879 | it.effect("routes websocket rpc orchestration methods", () => |
| 1517 | Effect.gen(function* () { | 1880 | Effect.gen(function* () { |
| 1518 | const now = new Date().toISOString(); | 1881 | const now = new Date().toISOString(); |
apps/server/src/server.ts+10-8| ... | @@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers | ... | @@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers |
| 30 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; | 30 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; |
| 31 | import { GitCoreLive } from "./git/Layers/GitCore"; | 31 | import { GitCoreLive } from "./git/Layers/GitCore"; |
| 32 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; | 32 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; |
| 33 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; | ||
| 33 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; | 34 | import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; |
| 34 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; | 35 | import { TerminalManagerLive } from "./terminal/Layers/Manager"; |
| 35 | import { GitManagerLive } from "./git/Layers/GitManager"; | 36 | import { GitManagerLive } from "./git/Layers/GitManager"; |
| ... | @@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap( | ... | @@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap( |
| 161 | 162 | ||
| 162 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); | 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 | const GitLayerLive = Layer.empty.pipe( | 172 | const GitLayerLive = Layer.empty.pipe( |
| 165 | Layer.provideMerge( | 173 | Layer.provideMerge(GitManagerLayerLive), |
| 166 | GitManagerLive.pipe( | 174 | Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), |
| 167 | Layer.provideMerge(ProjectSetupScriptRunnerLive), | ||
| 168 | Layer.provideMerge(GitCoreLive), | ||
| 169 | Layer.provideMerge(GitHubCliLive), | ||
| 170 | Layer.provideMerge(RoutingTextGenerationLive), | ||
| 171 | ), | ||
| 172 | ), | ||
| 173 | Layer.provideMerge(GitCoreLive), | 175 | Layer.provideMerge(GitCoreLive), |
| 174 | ); | 176 | ); |
| 175 | 177 |
apps/server/src/ws.ts+62-21| ... | @@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer | ... | @@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer |
| 27 | import { ServerConfig } from "./config"; | 27 | import { ServerConfig } from "./config"; |
| 28 | import { GitCore } from "./git/Services/GitCore"; | 28 | import { GitCore } from "./git/Services/GitCore"; |
| 29 | import { GitManager } from "./git/Services/GitManager"; | 29 | import { GitManager } from "./git/Services/GitManager"; |
| 30 | import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; | ||
| 30 | import { Keybindings } from "./keybindings"; | 31 | import { Keybindings } from "./keybindings"; |
| 31 | import { Open, resolveAvailableEditors } from "./open"; | 32 | import { Open, resolveAvailableEditors } from "./open"; |
| 32 | import { normalizeDispatchCommand } from "./orchestration/Normalizer"; | 33 | import { normalizeDispatchCommand } from "./orchestration/Normalizer"; |
| ... | @@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 56 | const open = yield* Open; | 57 | const open = yield* Open; |
| 57 | const gitManager = yield* GitManager; | 58 | const gitManager = yield* GitManager; |
| 58 | const git = yield* GitCore; | 59 | const git = yield* GitCore; |
| 60 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | ||
| 59 | const terminalManager = yield* TerminalManager; | 61 | const terminalManager = yield* TerminalManager; |
| 60 | const providerRegistry = yield* ProviderRegistry; | 62 | const providerRegistry = yield* ProviderRegistry; |
| 61 | const config = yield* ServerConfig; | 63 | const config = yield* ServerConfig; |
| ... | @@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 65 | const workspaceEntries = yield* WorkspaceEntries; | 67 | const workspaceEntries = yield* WorkspaceEntries; |
| 66 | const workspaceFileSystem = yield* WorkspaceFileSystem; | 68 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 67 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | 69 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 68 | |||
| 69 | const serverCommandId = (tag: string) => | 70 | const serverCommandId = (tag: string) => |
| 70 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); | 71 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); |
| 71 | 72 | ||
| ... | @@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -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 | return WsRpcGroup.of({ | 357 | return WsRpcGroup.of({ |
| 352 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => | 358 | [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => |
| 353 | observeRpcEffect( | 359 | observeRpcEffect( |
| ... | @@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 559 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { | 565 | observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { |
| 560 | "rpc.aggregate": "workspace", | 566 | "rpc.aggregate": "workspace", |
| 561 | }), | 567 | }), |
| 562 | [WS_METHODS.gitStatus]: (input) => | 568 | [WS_METHODS.subscribeGitStatus]: (input) => |
| 563 | observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { | 569 | observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { |
| 564 | "rpc.aggregate": "git", | 570 | "rpc.aggregate": "git", |
| 565 | }), | 571 | }), |
| 572 | [WS_METHODS.gitRefreshStatus]: (input) => | ||
| 573 | observeRpcEffect( | ||
| 574 | WS_METHODS.gitRefreshStatus, | ||
| 575 | gitStatusBroadcaster.refreshStatus(input.cwd), | ||
| 576 | { | ||
| 577 | "rpc.aggregate": "git", | ||
| 578 | }, | ||
| 579 | ), | ||
| 566 | [WS_METHODS.gitPull]: (input) => | 580 | [WS_METHODS.gitPull]: (input) => |
| 567 | observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { | 581 | observeRpcEffect( |
| 568 | "rpc.aggregate": "git", | 582 | WS_METHODS.gitPull, |
| 569 | }), | 583 | git.pullCurrentBranch(input.cwd).pipe( |
| 584 | Effect.matchCauseEffect({ | ||
| 585 | onFailure: (cause) => Effect.failCause(cause), | ||
| 586 | onSuccess: (result) => | ||
| 587 | refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), | ||
| 588 | }), | ||
| 589 | ), | ||
| 590 | { "rpc.aggregate": "git" }, | ||
| 591 | ), | ||
| 570 | [WS_METHODS.gitRunStackedAction]: (input) => | 592 | [WS_METHODS.gitRunStackedAction]: (input) => |
| 571 | observeRpcStream( | 593 | observeRpcStream( |
| 572 | WS_METHODS.gitRunStackedAction, | 594 | WS_METHODS.gitRunStackedAction, |
| ... | @@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 581 | .pipe( | 603 | .pipe( |
| 582 | Effect.matchCauseEffect({ | 604 | Effect.matchCauseEffect({ |
| 583 | onFailure: (cause) => Queue.failCause(queue, cause), | 605 | onFailure: (cause) => Queue.failCause(queue, cause), |
| 584 | onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), | 606 | onSuccess: () => |
| 607 | refreshGitStatus(input.cwd).pipe( | ||
| 608 | Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), | ||
| 609 | ), | ||
| 585 | }), | 610 | }), |
| 586 | ), | 611 | ), |
| 587 | ), | 612 | ), |
| ... | @@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 594 | [WS_METHODS.gitPreparePullRequestThread]: (input) => | 619 | [WS_METHODS.gitPreparePullRequestThread]: (input) => |
| 595 | observeRpcEffect( | 620 | observeRpcEffect( |
| 596 | WS_METHODS.gitPreparePullRequestThread, | 621 | WS_METHODS.gitPreparePullRequestThread, |
| 597 | gitManager.preparePullRequestThread(input), | 622 | gitManager |
| 623 | .preparePullRequestThread(input) | ||
| 624 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | ||
| 598 | { "rpc.aggregate": "git" }, | 625 | { "rpc.aggregate": "git" }, |
| 599 | ), | 626 | ), |
| 600 | [WS_METHODS.gitListBranches]: (input) => | 627 | [WS_METHODS.gitListBranches]: (input) => |
| ... | @@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( | ... | @@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( |
| 602 | "rpc.aggregate": "git", | 629 | "rpc.aggregate": "git", |
| 603 | }), | 630 | }), |
| 604 | [WS_METHODS.gitCreateWorktree]: (input) => | 631 | [WS_METHODS.gitCreateWorktree]: (input) => |
| 605 | observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { | 632 | observeRpcEffect( |
| 606 | "rpc.aggregate": "git", | 633 | WS_METHODS.gitCreateWorktree, |
| 607 | }), | 634 | git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 635 | { "rpc.aggregate": "git" }, | ||
| 636 | ), | ||
| 608 | [WS_METHODS.gitRemoveWorktree]: (input) => | 637 | [WS_METHODS.gitRemoveWorktree]: (input) => |
| 609 | observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { | 638 | observeRpcEffect( |
| 610 | "rpc.aggregate": "git", | 639 | WS_METHODS.gitRemoveWorktree, |
| 611 | }), | 640 | git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 641 | { "rpc.aggregate": "git" }, | ||
| 642 | ), | ||
| 612 | [WS_METHODS.gitCreateBranch]: (input) => | 643 | [WS_METHODS.gitCreateBranch]: (input) => |
| 613 | observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { | 644 | observeRpcEffect( |
| 614 | "rpc.aggregate": "git", | 645 | WS_METHODS.gitCreateBranch, |
| 615 | }), | 646 | git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 647 | { "rpc.aggregate": "git" }, | ||
| 648 | ), | ||
| 616 | [WS_METHODS.gitCheckout]: (input) => | 649 | [WS_METHODS.gitCheckout]: (input) => |
| 617 | observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { | 650 | observeRpcEffect( |
| 618 | "rpc.aggregate": "git", | 651 | WS_METHODS.gitCheckout, |
| 619 | }), | 652 | Effect.scoped(git.checkoutBranch(input)).pipe( |
| 653 | Effect.tap(() => refreshGitStatus(input.cwd)), | ||
| 654 | ), | ||
| 655 | { "rpc.aggregate": "git" }, | ||
| 656 | ), | ||
| 620 | [WS_METHODS.gitInit]: (input) => | 657 | [WS_METHODS.gitInit]: (input) => |
| 621 | observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), | 658 | observeRpcEffect( |
| 659 | WS_METHODS.gitInit, | ||
| 660 | git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | ||
| 661 | { "rpc.aggregate": "git" }, | ||
| 662 | ), | ||
| 622 | [WS_METHODS.terminalOpen]: (input) => | 663 | [WS_METHODS.terminalOpen]: (input) => |
| 623 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { | 664 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { |
| 624 | "rpc.aggregate": "terminal", | 665 | "rpc.aggregate": "terminal", |
apps/web/src/components/BranchToolbarBranchSelector.tsx+28-42| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import type { GitBranch } from "@t3tools/contracts"; | 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 | import { useVirtualizer } from "@tanstack/react-virtual"; | 3 | import { useVirtualizer } from "@tanstack/react-virtual"; |
| 4 | import { ChevronDownIcon } from "lucide-react"; | 4 | import { ChevronDownIcon } from "lucide-react"; |
| 5 | import { | 5 | import { |
| ... | @@ -14,12 +14,8 @@ import { | ... | @@ -14,12 +14,8 @@ import { |
| 14 | useTransition, | 14 | useTransition, |
| 15 | } from "react"; | 15 | } from "react"; |
| 16 | 16 | ||
| 17 | import { | 17 | import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; |
| 18 | gitBranchSearchInfiniteQueryOptions, | 18 | import { useGitStatus } from "../lib/gitStatusState"; |
| 19 | gitQueryKeys, | ||
| 20 | gitStatusQueryOptions, | ||
| 21 | invalidateGitQueries, | ||
| 22 | } from "../lib/gitReactQuery"; | ||
| 23 | import { readNativeApi } from "../nativeApi"; | 19 | import { readNativeApi } from "../nativeApi"; |
| 24 | import { parsePullRequestReference } from "../pullRequestReference"; | 20 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 25 | import { | 21 | import { |
| ... | @@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({ |
| 89 | const [branchQuery, setBranchQuery] = useState(""); | 85 | const [branchQuery, setBranchQuery] = useState(""); |
| 90 | const deferredBranchQuery = useDeferredValue(branchQuery); | 86 | const deferredBranchQuery = useDeferredValue(branchQuery); |
| 91 | 87 | ||
| 92 | const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); | 88 | const branchStatusQuery = useGitStatus(branchCwd); |
| 93 | const trimmedBranchQuery = branchQuery.trim(); | 89 | const trimmedBranchQuery = branchQuery.trim(); |
| 94 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); | 90 | const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); |
| 95 | 91 | ||
| ... | @@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({ | ... | @@ -110,7 +106,6 @@ export function BranchToolbarBranchSelector({ |
| 110 | gitBranchSearchInfiniteQueryOptions({ | 106 | gitBranchSearchInfiniteQueryOptions({ |
| 111 | cwd: branchCwd, | 107 | cwd: branchCwd, |
| 112 | query: deferredTrimmedBranchQuery, | 108 | query: deferredTrimmedBranchQuery, |
| 113 | enabled: isBranchMenuOpen, | ||
| 114 | }), | 109 | }), |
| 115 | ); | 110 | ); |
| 116 | const branches = useMemo( | 111 | const branches = useMemo( |
| ... | @@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({ | ... | @@ -188,7 +183,9 @@ export function BranchToolbarBranchSelector({ |
| 188 | const runBranchAction = (action: () => Promise<void>) => { | 183 | const runBranchAction = (action: () => Promise<void>) => { |
| 189 | startBranchActionTransition(async () => { | 184 | startBranchActionTransition(async () => { |
| 190 | await action().catch(() => undefined); | 185 | await action().catch(() => undefined); |
| 191 | await invalidateGitQueries(queryClient).catch(() => undefined); | 186 | await queryClient |
| 187 | .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) | ||
| 188 | .catch(() => undefined); | ||
| 192 | }); | 189 | }); |
| 193 | }; | 190 | }; |
| 194 | 191 | ||
| ... | @@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({ | ... | @@ -226,29 +223,26 @@ export function BranchToolbarBranchSelector({ |
| 226 | onComposerFocusRequest?.(); | 223 | onComposerFocusRequest?.(); |
| 227 | 224 | ||
| 228 | runBranchAction(async () => { | 225 | runBranchAction(async () => { |
| 226 | const previousBranch = resolvedActiveBranch; | ||
| 229 | setOptimisticBranch(selectedBranchName); | 227 | setOptimisticBranch(selectedBranchName); |
| 230 | try { | 228 | try { |
| 231 | await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); | 229 | const checkoutResult = await api.git.checkout({ |
| 232 | await invalidateGitQueries(queryClient); | 230 | cwd: selectionTarget.checkoutCwd, |
| 231 | branch: branch.name, | ||
| 232 | }); | ||
| 233 | const nextBranchName = branch.isRemote | ||
| 234 | ? (checkoutResult.branch ?? selectedBranchName) | ||
| 235 | : selectedBranchName; | ||
| 236 | setOptimisticBranch(nextBranchName); | ||
| 237 | onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | ||
| 233 | } catch (error) { | 238 | } catch (error) { |
| 239 | setOptimisticBranch(previousBranch); | ||
| 234 | toastManager.add({ | 240 | toastManager.add({ |
| 235 | type: "error", | 241 | type: "error", |
| 236 | title: "Failed to checkout branch.", | 242 | title: "Failed to checkout branch.", |
| 237 | description: toBranchActionErrorMessage(error), | 243 | description: toBranchActionErrorMessage(error), |
| 238 | }); | 244 | }); |
| 239 | return; | ||
| 240 | } | 245 | } |
| 241 | |||
| 242 | let nextBranchName = selectedBranchName; | ||
| 243 | if (branch.isRemote) { | ||
| 244 | const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); | ||
| 245 | if (status?.branch) { | ||
| 246 | nextBranchName = status.branch; | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | setOptimisticBranch(nextBranchName); | ||
| 251 | onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | ||
| 252 | }); | 246 | }); |
| 253 | }; | 247 | }; |
| 254 | 248 | ||
| ... | @@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({ | ... | @@ -261,32 +255,24 @@ export function BranchToolbarBranchSelector({ |
| 261 | onComposerFocusRequest?.(); | 255 | onComposerFocusRequest?.(); |
| 262 | 256 | ||
| 263 | runBranchAction(async () => { | 257 | runBranchAction(async () => { |
| 258 | const previousBranch = resolvedActiveBranch; | ||
| 264 | setOptimisticBranch(name); | 259 | setOptimisticBranch(name); |
| 265 | |||
| 266 | try { | 260 | try { |
| 267 | await api.git.createBranch({ cwd: branchCwd, branch: name }); | 261 | const createBranchResult = await api.git.createBranch({ |
| 268 | try { | 262 | cwd: branchCwd, |
| 269 | await api.git.checkout({ cwd: branchCwd, branch: name }); | 263 | branch: name, |
| 270 | } catch (error) { | 264 | checkout: true, |
| 271 | toastManager.add({ | 265 | }); |
| 272 | type: "error", | 266 | setOptimisticBranch(createBranchResult.branch); |
| 273 | title: "Failed to checkout branch.", | 267 | onSetThreadBranch(createBranchResult.branch, activeWorktreePath); |
| 274 | description: toBranchActionErrorMessage(error), | ||
| 275 | }); | ||
| 276 | return; | ||
| 277 | } | ||
| 278 | } catch (error) { | 268 | } catch (error) { |
| 269 | setOptimisticBranch(previousBranch); | ||
| 279 | toastManager.add({ | 270 | toastManager.add({ |
| 280 | type: "error", | 271 | type: "error", |
| 281 | title: "Failed to create branch.", | 272 | title: "Failed to create and checkout branch.", |
| 282 | description: toBranchActionErrorMessage(error), | 273 | description: toBranchActionErrorMessage(error), |
| 283 | }); | 274 | }); |
| 284 | return; | ||
| 285 | } | 275 | } |
| 286 | |||
| 287 | setOptimisticBranch(name); | ||
| 288 | onSetThreadBranch(name, activeWorktreePath); | ||
| 289 | setBranchQuery(""); | ||
| 290 | }); | 276 | }); |
| 291 | }; | 277 | }; |
| 292 | 278 |
apps/web/src/components/ChatView.browser.tsx+43-28| ... | @@ -31,6 +31,8 @@ import { | ... | @@ -31,6 +31,8 @@ import { |
| 31 | } from "../lib/terminalContext"; | 31 | } from "../lib/terminalContext"; |
| 32 | import { isMacPlatform } from "../lib/utils"; | 32 | import { isMacPlatform } from "../lib/utils"; |
| 33 | import { __resetNativeApiForTests } from "../nativeApi"; | 33 | import { __resetNativeApiForTests } from "../nativeApi"; |
| 34 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 35 | import { getServerConfig } from "../rpc/serverState"; | ||
| 34 | import { getRouter } from "../router"; | 36 | import { getRouter } from "../router"; |
| 35 | import { useStore } from "../store"; | 37 | import { useStore } from "../store"; |
| 36 | import { useTerminalStateStore } from "../terminalStateStore"; | 38 | import { useTerminalStateStore } from "../terminalStateStore"; |
| ... | @@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test | ... | @@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test |
| 38 | import { estimateTimelineMessageHeight } from "./timelineHeight"; | 40 | import { estimateTimelineMessageHeight } from "./timelineHeight"; |
| 39 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; | 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 | const THREAD_ID = "thread-browser-test" as ThreadId; | 50 | const THREAD_ID = "thread-browser-test" as ThreadId; |
| 42 | const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; | 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 | const PROJECT_ID = "project-1" as ProjectId; | 52 | const PROJECT_ID = "project-1" as ProjectId; |
| 44 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 53 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| 45 | const BASE_TIME_MS = Date.parse(NOW_ISO); | 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 | interface TestFixture { | 57 | interface TestFixture { |
| 49 | snapshot: OrchestrationReadModel; | 58 | snapshot: OrchestrationReadModel; |
| ... | @@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [ | ... | @@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [ |
| 93 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, | 102 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, |
| 94 | ] as const satisfies readonly ViewportSpec[]; | 103 | ] as const satisfies readonly ViewportSpec[]; |
| 95 | const ATTACHMENT_VIEWPORT_MATRIX = [ | 104 | const ATTACHMENT_VIEWPORT_MATRIX = [ |
| 96 | DEFAULT_VIEWPORT, | 105 | { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, |
| 97 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, | 106 | { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, |
| 98 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, | 107 | { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, |
| 99 | ] as const satisfies readonly ViewportSpec[]; | 108 | ] as const satisfies readonly ViewportSpec[]; |
| 100 | 109 | ||
| 101 | interface UserRowMeasurement { | 110 | interface UserRowMeasurement { |
| ... | @@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: { | ... | @@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: { |
| 224 | name: `attachment-${attachmentIndex + 1}.png`, | 233 | name: `attachment-${attachmentIndex + 1}.png`, |
| 225 | mimeType: "image/png", | 234 | mimeType: "image/png", |
| 226 | sizeBytes: 128, | 235 | sizeBytes: 128, |
| 236 | previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, | ||
| 227 | })) | 237 | })) |
| 228 | : undefined; | 238 | : undefined; |
| 229 | 239 | ||
| ... | @@ -397,6 +407,22 @@ async function waitForWsClient(): Promise<void> { | ... | @@ -397,6 +407,22 @@ async function waitForWsClient(): Promise<void> { |
| 397 | (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, | 407 | (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, |
| 398 | ), | 408 | ), |
| 399 | ).toBe(true); | 409 | ).toBe(true); |
| 410 | expect( | ||
| 411 | wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), | ||
| 412 | ).toBe(true); | ||
| 413 | expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( | ||
| 414 | true, | ||
| 415 | ); | ||
| 416 | }, | ||
| 417 | { timeout: 8_000, interval: 16 }, | ||
| 418 | ); | ||
| 419 | } | ||
| 420 | |||
| 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 | { timeout: 8_000, interval: 16 }, | 427 | { timeout: 8_000, interval: 16 }, |
| 402 | ); | 428 | ); |
| ... | @@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 651 | ], | 677 | ], |
| 652 | }; | 678 | }; |
| 653 | } | 679 | } |
| 654 | if (tag === WS_METHODS.gitStatus) { | ||
| 655 | return { | ||
| 656 | isRepo: true, | ||
| 657 | hasOriginRemote: true, | ||
| 658 | isDefaultBranch: true, | ||
| 659 | branch: "main", | ||
| 660 | hasWorkingTreeChanges: false, | ||
| 661 | workingTree: { | ||
| 662 | files: [], | ||
| 663 | insertions: 0, | ||
| 664 | deletions: 0, | ||
| 665 | }, | ||
| 666 | hasUpstream: true, | ||
| 667 | aheadCount: 0, | ||
| 668 | behindCount: 0, | ||
| 669 | pr: null, | ||
| 670 | }; | ||
| 671 | } | ||
| 672 | if (tag === WS_METHODS.projectsSearchEntries) { | 680 | if (tag === WS_METHODS.projectsSearchEntries) { |
| 673 | return { | 681 | return { |
| 674 | entries: [], | 682 | entries: [], |
| ... | @@ -1044,10 +1052,17 @@ async function mountChatView(options: { | ... | @@ -1044,10 +1052,17 @@ async function mountChatView(options: { |
| 1044 | }), | 1052 | }), |
| 1045 | ); | 1053 | ); |
| 1046 | 1054 | ||
| 1047 | const screen = await render(<RouterProvider router={router} />, { | 1055 | const screen = await render( |
| 1048 | container: host, | 1056 | <AppAtomRegistryProvider> |
| 1049 | }); | 1057 | <RouterProvider router={router} /> |
| 1058 | </AppAtomRegistryProvider>, | ||
| 1059 | { | ||
| 1060 | container: host, | ||
| 1061 | }, | ||
| 1062 | ); | ||
| 1050 | 1063 | ||
| 1064 | await waitForWsClient(); | ||
| 1065 | await waitForAppBootstrap(); | ||
| 1051 | await waitForLayout(); | 1066 | await waitForLayout(); |
| 1052 | 1067 | ||
| 1053 | const cleanup = async () => { | 1068 | const cleanup = async () => { |
| ... | @@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1138 | return []; | 1153 | return []; |
| 1139 | }, | 1154 | }, |
| 1140 | }); | 1155 | }); |
| 1141 | __resetNativeApiForTests(); | 1156 | await __resetNativeApiForTests(); |
| 1142 | await setViewport(DEFAULT_VIEWPORT); | 1157 | await setViewport(DEFAULT_VIEWPORT); |
| 1143 | localStorage.clear(); | 1158 | localStorage.clear(); |
| 1144 | document.body.innerHTML = ""; | 1159 | document.body.innerHTML = ""; |
| ... | @@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1299 | snapshot: createSnapshotForTargetUser({ | 1314 | snapshot: createSnapshotForTargetUser({ |
| 1300 | targetMessageId, | 1315 | targetMessageId, |
| 1301 | targetText: userText, | 1316 | targetText: userText, |
| 1302 | targetAttachmentCount: 3, | 1317 | targetAttachmentCount: 2, |
| 1303 | }), | 1318 | }), |
| 1304 | }); | 1319 | }); |
| 1305 | 1320 | ||
| ... | @@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1313 | { | 1328 | { |
| 1314 | role: "user", | 1329 | role: "user", |
| 1315 | text: userText, | 1330 | text: userText, |
| 1316 | attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], | 1331 | attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], |
| 1317 | }, | 1332 | }, |
| 1318 | { timelineWidthPx: timelineWidthMeasuredPx }, | 1333 | { timelineWidthPx: timelineWidthMeasuredPx }, |
| 1319 | ); | 1334 | ); |
apps/web/src/components/ChatView.tsx+2-2| ... | @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr | ... | @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr |
| 27 | import { useQuery } from "@tanstack/react-query"; | 27 | import { useQuery } from "@tanstack/react-query"; |
| 28 | import { useDebouncedValue } from "@tanstack/react-pacer"; | 28 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 29 | import { useNavigate, useSearch } from "@tanstack/react-router"; | 29 | import { useNavigate, useSearch } from "@tanstack/react-router"; |
| 30 | import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; | 30 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 31 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; | 31 | import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 32 | import { isElectron } from "../env"; | 32 | import { isElectron } from "../env"; |
| 33 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | 33 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; |
| ... | @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1399 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 1399 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 1400 | ); | 1400 | ); |
| 1401 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; | 1401 | const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; |
| 1402 | const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); | 1402 | const gitStatusQuery = useGitStatus(gitCwd); |
| 1403 | const keybindings = useServerKeybindings(); | 1403 | const keybindings = useServerKeybindings(); |
| 1404 | const availableEditors = useServerAvailableEditors(); | 1404 | const availableEditors = useServerAvailableEditors(); |
| 1405 | const modelOptionsByProvider = useMemo( | 1405 | const modelOptionsByProvider = useMemo( |
apps/web/src/components/DiffPanel.tsx+2-2| ... | @@ -19,7 +19,7 @@ import { | ... | @@ -19,7 +19,7 @@ import { |
| 19 | useState, | 19 | useState, |
| 20 | } from "react"; | 20 | } from "react"; |
| 21 | import { openInPreferredEditor } from "../editorPreferences"; | 21 | import { openInPreferredEditor } from "../editorPreferences"; |
| 22 | import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; | 22 | import { useGitStatus } from "~/lib/gitStatusState"; |
| 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; | 23 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 24 | import { cn } from "~/lib/utils"; | 24 | import { cn } from "~/lib/utils"; |
| 25 | import { readNativeApi } from "../nativeApi"; | 25 | import { readNativeApi } from "../nativeApi"; |
| ... | @@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { | ... | @@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, | 189 | activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, |
| 190 | ); | 190 | ); |
| 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; | 191 | const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; |
| 192 | const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); | 192 | const gitStatusQuery = useGitStatus(activeCwd ?? null); |
| 193 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; | 193 | const isGitRepo = gitStatusQuery.data?.isRepo ?? true; |
| 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = | 194 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 195 | useTurnDiffSummaries(activeThread); | 195 | useTurnDiffSummaries(activeThread); |
apps/web/src/components/GitActionsControl.browser.tsx+58-20| ... | @@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope"; | ... | @@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope"; |
| 10 | 10 | ||
| 11 | const { | 11 | const { |
| 12 | invalidateGitQueriesSpy, | 12 | invalidateGitQueriesSpy, |
| 13 | invalidateGitStatusQuerySpy, | 13 | refreshGitStatusSpy, |
| 14 | runStackedActionMutateAsyncSpy, | 14 | runStackedActionMutateAsyncSpy, |
| 15 | setThreadBranchSpy, | 15 | setThreadBranchSpy, |
| 16 | toastAddSpy, | 16 | toastAddSpy, |
| ... | @@ -19,7 +19,7 @@ const { | ... | @@ -19,7 +19,7 @@ const { |
| 19 | toastUpdateSpy, | 19 | toastUpdateSpy, |
| 20 | } = vi.hoisted(() => ({ | 20 | } = vi.hoisted(() => ({ |
| 21 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), | 21 | invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), |
| 22 | invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), | 22 | refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), |
| 23 | runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)), | 23 | runStackedActionMutateAsyncSpy: vi.fn(() => new Promise<never>(() => undefined)), |
| 24 | setThreadBranchSpy: vi.fn(), | 24 | setThreadBranchSpy: vi.fn(), |
| 25 | toastAddSpy: vi.fn(() => "toast-1"), | 25 | toastAddSpy: vi.fn(() => "toast-1"), |
| ... | @@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => { | ... | @@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => { |
| 57 | }; | 57 | }; |
| 58 | }), | 58 | }), |
| 59 | useQuery: vi.fn((options: { queryKey?: string[] }) => { | 59 | useQuery: vi.fn((options: { queryKey?: string[] }) => { |
| 60 | if (options.queryKey?.[0] === "git-status") { | ||
| 61 | return { | ||
| 62 | data: { | ||
| 63 | branch: BRANCH_NAME, | ||
| 64 | hasWorkingTreeChanges: false, | ||
| 65 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 66 | hasUpstream: true, | ||
| 67 | aheadCount: 1, | ||
| 68 | behindCount: 0, | ||
| 69 | pr: null, | ||
| 70 | }, | ||
| 71 | error: null, | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | if (options.queryKey?.[0] === "git-branches") { | 60 | if (options.queryKey?.[0] === "git-branches") { |
| 76 | return { | 61 | return { |
| 77 | data: { | 62 | data: { |
| ... | @@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({ | ... | @@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({ |
| 110 | })); | 95 | })); |
| 111 | 96 | ||
| 112 | vi.mock("~/lib/gitReactQuery", () => ({ | 97 | vi.mock("~/lib/gitReactQuery", () => ({ |
| 113 | gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), | ||
| 114 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), | 98 | gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), |
| 115 | gitMutationKeys: { | 99 | gitMutationKeys: { |
| 116 | pull: vi.fn(() => ["pull"]), | 100 | pull: vi.fn(() => ["pull"]), |
| ... | @@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({ | ... | @@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({ |
| 118 | }, | 102 | }, |
| 119 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), | 103 | gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), |
| 120 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), | 104 | gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), |
| 121 | gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), | ||
| 122 | invalidateGitQueries: invalidateGitQueriesSpy, | 105 | invalidateGitQueries: invalidateGitQueriesSpy, |
| 123 | invalidateGitStatusQuery: invalidateGitStatusQuerySpy, | 106 | })); |
| 107 | |||
| 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 | vi.mock("~/lib/utils", async () => { | 126 | vi.mock("~/lib/utils", async () => { |
| ... | @@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { | ... | @@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { |
| 235 | host.remove(); | 235 | host.remove(); |
| 236 | } | 236 | } |
| 237 | }); | 237 | }); |
| 238 | |||
| 239 | it("debounces focus-driven git status refreshes", async () => { | ||
| 240 | vi.useFakeTimers(); | ||
| 241 | |||
| 242 | const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); | ||
| 243 | let visibilityState: DocumentVisibilityState = "hidden"; | ||
| 244 | Object.defineProperty(document, "visibilityState", { | ||
| 245 | configurable: true, | ||
| 246 | get: () => visibilityState, | ||
| 247 | }); | ||
| 248 | |||
| 249 | const host = document.createElement("div"); | ||
| 250 | document.body.append(host); | ||
| 251 | const screen = await render(<GitActionsControl gitCwd={GIT_CWD} activeThreadId={THREAD_A} />, { | ||
| 252 | container: host, | ||
| 253 | }); | ||
| 254 | |||
| 255 | try { | ||
| 256 | window.dispatchEvent(new Event("focus")); | ||
| 257 | visibilityState = "visible"; | ||
| 258 | document.dispatchEvent(new Event("visibilitychange")); | ||
| 259 | |||
| 260 | expect(refreshGitStatusSpy).not.toHaveBeenCalled(); | ||
| 261 | |||
| 262 | await vi.advanceTimersByTimeAsync(249); | ||
| 263 | expect(refreshGitStatusSpy).not.toHaveBeenCalled(); | ||
| 264 | |||
| 265 | await vi.advanceTimersByTimeAsync(1); | ||
| 266 | expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); | ||
| 267 | expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); | ||
| 268 | } finally { | ||
| 269 | if (originalVisibilityState) { | ||
| 270 | Object.defineProperty(document, "visibilityState", originalVisibilityState); | ||
| 271 | } | ||
| 272 | await screen.unmount(); | ||
| 273 | host.remove(); | ||
| 274 | } | ||
| 275 | }); | ||
| 238 | }); | 276 | }); |
apps/web/src/components/GitActionsControl.tsx+41-5| ... | @@ -5,7 +5,7 @@ import type { | ... | @@ -5,7 +5,7 @@ import type { |
| 5 | GitStatusResult, | 5 | GitStatusResult, |
| 6 | ThreadId, | 6 | ThreadId, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 10 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 10 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 11 | import { GitHubIcon } from "./Icons"; | 11 | import { GitHubIcon } from "./Icons"; |
| ... | @@ -45,9 +45,8 @@ import { | ... | @@ -45,9 +45,8 @@ import { |
| 45 | gitMutationKeys, | 45 | gitMutationKeys, |
| 46 | gitPullMutationOptions, | 46 | gitPullMutationOptions, |
| 47 | gitRunStackedActionMutationOptions, | 47 | gitRunStackedActionMutationOptions, |
| 48 | gitStatusQueryOptions, | ||
| 49 | invalidateGitStatusQuery, | ||
| 50 | } from "~/lib/gitReactQuery"; | 48 | } from "~/lib/gitReactQuery"; |
| 49 | import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; | ||
| 51 | import { newCommandId, randomUUID } from "~/lib/utils"; | 50 | import { newCommandId, randomUUID } from "~/lib/utils"; |
| 52 | import { resolvePathLinkTarget } from "~/terminal-links"; | 51 | import { resolvePathLinkTarget } from "~/terminal-links"; |
| 53 | import { readNativeApi } from "~/nativeApi"; | 52 | import { readNativeApi } from "~/nativeApi"; |
| ... | @@ -92,6 +91,8 @@ interface RunGitActionWithToastInput { | ... | @@ -92,6 +91,8 @@ interface RunGitActionWithToastInput { |
| 92 | filePaths?: string[]; | 91 | filePaths?: string[]; |
| 93 | } | 92 | } |
| 94 | 93 | ||
| 94 | const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; | ||
| 95 | |||
| 95 | function formatElapsedDescription(startedAtMs: number | null): string | undefined { | 96 | function formatElapsedDescription(startedAtMs: number | null): string | undefined { |
| 96 | if (startedAtMs === null) { | 97 | if (startedAtMs === null) { |
| 97 | return undefined; | 98 | return undefined; |
| ... | @@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 275 | [persistThreadBranchSync], | 276 | [persistThreadBranchSync], |
| 276 | ); | 277 | ); |
| 277 | 278 | ||
| 278 | const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); | 279 | const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); |
| 279 | // Default to true while loading so we don't flash init controls. | 280 | // Default to true while loading so we don't flash init controls. |
| 280 | const isRepo = gitStatus?.isRepo ?? true; | 281 | const isRepo = gitStatus?.isRepo ?? true; |
| 281 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | 282 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; |
| ... | @@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 359 | }; | 360 | }; |
| 360 | }, [updateActiveProgressToast]); | 361 | }, [updateActiveProgressToast]); |
| 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 | const openExistingPr = useCallback(async () => { | 396 | const openExistingPr = useCallback(async () => { |
| 363 | const api = readNativeApi(); | 397 | const api = readNativeApi(); |
| 364 | if (!api) { | 398 | if (!api) { |
| ... | @@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions | ... | @@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 801 | <GroupSeparator className="hidden @3xl/header-actions:block" /> | 835 | <GroupSeparator className="hidden @3xl/header-actions:block" /> |
| 802 | <Menu | 836 | <Menu |
| 803 | onOpenChange={(open) => { | 837 | onOpenChange={(open) => { |
| 804 | if (open) void invalidateGitStatusQuery(queryClient, gitCwd); | 838 | if (open) { |
| 839 | void refreshGitStatus(gitCwd).catch(() => undefined); | ||
| 840 | } | ||
| 805 | }} | 841 | }} |
| 806 | > | 842 | > |
| 807 | <MenuTrigger | 843 | <MenuTrigger |
apps/web/src/components/KeybindingsToast.browser.tsx+41-16| ... | @@ -19,10 +19,19 @@ import { render } from "vitest-browser-react"; | ... | @@ -19,10 +19,19 @@ import { render } from "vitest-browser-react"; |
| 19 | 19 | ||
| 20 | import { useComposerDraftStore } from "../composerDraftStore"; | 20 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 21 | import { __resetNativeApiForTests } from "../nativeApi"; | 21 | import { __resetNativeApiForTests } from "../nativeApi"; |
| 22 | import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; | ||
| 23 | import { getServerConfig } from "../rpc/serverState"; | ||
| 22 | import { getRouter } from "../router"; | 24 | import { getRouter } from "../router"; |
| 23 | import { useStore } from "../store"; | 25 | import { useStore } from "../store"; |
| 24 | import { BrowserWsRpcHarness } from "../../test/wsRpcHarness"; | 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 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; | 35 | const THREAD_ID = "thread-kb-toast-test" as ThreadId; |
| 27 | const PROJECT_ID = "project-1" as ProjectId; | 36 | const PROJECT_ID = "project-1" as ProjectId; |
| 28 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; | 37 | const NOW_ISO = "2026-03-04T12:00:00.000Z"; |
| ... | @@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown { | ... | @@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown { |
| 170 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | 179 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], |
| 171 | }; | 180 | }; |
| 172 | } | 181 | } |
| 173 | if (tag === WS_METHODS.gitStatus) { | ||
| 174 | return { | ||
| 175 | isRepo: true, | ||
| 176 | hasOriginRemote: true, | ||
| 177 | isDefaultBranch: true, | ||
| 178 | branch: "main", | ||
| 179 | hasWorkingTreeChanges: false, | ||
| 180 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 181 | hasUpstream: true, | ||
| 182 | aheadCount: 0, | ||
| 183 | behindCount: 0, | ||
| 184 | pr: null, | ||
| 185 | }; | ||
| 186 | } | ||
| 187 | if (tag === WS_METHODS.projectsSearchEntries) { | 182 | if (tag === WS_METHODS.projectsSearchEntries) { |
| 188 | return { entries: [], truncated: false }; | 183 | return { entries: [], truncated: false }; |
| 189 | } | 184 | } |
| ... | @@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise<void> { | ... | @@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise<void> { |
| 258 | ); | 253 | ); |
| 259 | } | 254 | } |
| 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 | async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | 279 | async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { |
| 262 | const host = document.createElement("div"); | 280 | const host = document.createElement("div"); |
| 263 | host.style.position = "fixed"; | 281 | host.style.position = "fixed"; |
| ... | @@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { | ... | @@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise<void> }> { |
| 270 | 288 | ||
| 271 | const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); | 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 | await waitForComposerEditor(); | 297 | await waitForComposerEditor(); |
| 298 | await waitForInitialWsSubscriptions(); | ||
| 299 | await waitForServerConfigSnapshot(); | ||
| 275 | 300 | ||
| 276 | return { | 301 | return { |
| 277 | cleanup: async () => { | 302 | cleanup: async () => { |
| ... | @@ -322,7 +347,7 @@ describe("Keybindings update toast", () => { | ... | @@ -322,7 +347,7 @@ describe("Keybindings update toast", () => { |
| 322 | return []; | 347 | return []; |
| 323 | }, | 348 | }, |
| 324 | }); | 349 | }); |
| 325 | __resetNativeApiForTests(); | 350 | await __resetNativeApiForTests(); |
| 326 | localStorage.clear(); | 351 | localStorage.clear(); |
| 327 | document.body.innerHTML = ""; | 352 | document.body.innerHTML = ""; |
| 328 | useComposerDraftStore.setState({ | 353 | useComposerDraftStore.setState({ |
apps/web/src/components/Sidebar.tsx+18-53| ... | @@ -49,7 +49,6 @@ import { | ... | @@ -49,7 +49,6 @@ import { |
| 49 | ThreadId, | 49 | ThreadId, |
| 50 | type GitStatusResult, | 50 | type GitStatusResult, |
| 51 | } from "@t3tools/contracts"; | 51 | } from "@t3tools/contracts"; |
| 52 | import { useQueries } from "@tanstack/react-query"; | ||
| 53 | import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; | 52 | import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 54 | import { | 53 | import { |
| 55 | type SidebarProjectSortOrder, | 54 | type SidebarProjectSortOrder, |
| ... | @@ -70,7 +69,7 @@ import { | ... | @@ -70,7 +69,7 @@ import { |
| 70 | threadJumpIndexFromCommand, | 69 | threadJumpIndexFromCommand, |
| 71 | threadTraversalDirectionFromCommand, | 70 | threadTraversalDirectionFromCommand, |
| 72 | } from "../keybindings"; | 71 | } from "../keybindings"; |
| 73 | import { gitStatusQueryOptions } from "../lib/gitReactQuery"; | 72 | import { useGitStatus } from "../lib/gitStatusState"; |
| 74 | import { readNativeApi } from "../nativeApi"; | 73 | import { readNativeApi } from "../nativeApi"; |
| 75 | import { useComposerDraftStore } from "../composerDraftStore"; | 74 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 76 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 75 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| ... | @@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { | ... | @@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { |
| 245 | return null; | 244 | return null; |
| 246 | } | 245 | } |
| 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 | interface SidebarThreadRowProps { | 258 | interface SidebarThreadRowProps { |
| 249 | threadId: ThreadId; | 259 | threadId: ThreadId; |
| 260 | projectCwd: string | null; | ||
| 250 | orderedProjectThreadIds: readonly ThreadId[]; | 261 | orderedProjectThreadIds: readonly ThreadId[]; |
| 251 | routeThreadId: ThreadId | null; | 262 | routeThreadId: ThreadId | null; |
| 252 | selectedThreadIds: ReadonlySet<ThreadId>; | 263 | selectedThreadIds: ReadonlySet<ThreadId>; |
| ... | @@ -277,7 +288,6 @@ interface SidebarThreadRowProps { | ... | @@ -277,7 +288,6 @@ interface SidebarThreadRowProps { |
| 277 | cancelRename: () => void; | 288 | cancelRename: () => void; |
| 278 | attemptArchiveThread: (threadId: ThreadId) => Promise<void>; | 289 | attemptArchiveThread: (threadId: ThreadId) => Promise<void>; |
| 279 | openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void; | 290 | openPrLink: (event: MouseEvent<HTMLElement>, prUrl: string) => void; |
| 280 | pr: ThreadPr | null; | ||
| 281 | } | 291 | } |
| 282 | 292 | ||
| 283 | function SidebarThreadRow(props: SidebarThreadRowProps) { | 293 | function SidebarThreadRow(props: SidebarThreadRowProps) { |
| ... | @@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 287 | (state) => | 297 | (state) => |
| 288 | selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, | 298 | selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, |
| 289 | ); | 299 | ); |
| 300 | const gitCwd = thread?.worktreePath ?? props.projectCwd; | ||
| 301 | const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); | ||
| 290 | 302 | ||
| 291 | if (!thread) { | 303 | if (!thread) { |
| 292 | return null; | 304 | return null; |
| ... | @@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { | ... | @@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { |
| 303 | lastVisitedAt, | 315 | lastVisitedAt, |
| 304 | }, | 316 | }, |
| 305 | }); | 317 | }); |
| 306 | const prStatus = prStatusIndicator(props.pr); | 318 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 319 | const prStatus = prStatusIndicator(pr); | ||
| 307 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 320 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 308 | const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; | 321 | const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; |
| 309 | const threadMetaClassName = isConfirmingArchive | 322 | const threadMetaClassName = isConfirmingArchive |
| ... | @@ -762,54 +775,6 @@ export default function Sidebar() { | ... | @@ -762,54 +775,6 @@ export default function Sidebar() { |
| 762 | }), | 775 | }), |
| 763 | [platform, routeTerminalOpen], | 776 | [platform, routeTerminalOpen], |
| 764 | ); | 777 | ); |
| 765 | const threadGitTargets = useMemo( | ||
| 766 | () => | ||
| 767 | sidebarThreads.map((thread) => ({ | ||
| 768 | threadId: thread.id, | ||
| 769 | branch: thread.branch, | ||
| 770 | cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, | ||
| 771 | })), | ||
| 772 | [projectCwdById, sidebarThreads], | ||
| 773 | ); | ||
| 774 | const threadGitStatusCwds = useMemo( | ||
| 775 | () => [ | ||
| 776 | ...new Set( | ||
| 777 | threadGitTargets | ||
| 778 | .filter((target) => target.branch !== null) | ||
| 779 | .map((target) => target.cwd) | ||
| 780 | .filter((cwd): cwd is string => cwd !== null), | ||
| 781 | ), | ||
| 782 | ], | ||
| 783 | [threadGitTargets], | ||
| 784 | ); | ||
| 785 | const threadGitStatusQueries = useQueries({ | ||
| 786 | queries: threadGitStatusCwds.map((cwd) => ({ | ||
| 787 | ...gitStatusQueryOptions(cwd), | ||
| 788 | staleTime: 30_000, | ||
| 789 | refetchInterval: 60_000, | ||
| 790 | })), | ||
| 791 | }); | ||
| 792 | const prByThreadId = useMemo(() => { | ||
| 793 | const statusByCwd = new Map<string, GitStatusResult>(); | ||
| 794 | for (let index = 0; index < threadGitStatusCwds.length; index += 1) { | ||
| 795 | const cwd = threadGitStatusCwds[index]; | ||
| 796 | if (!cwd) continue; | ||
| 797 | const status = threadGitStatusQueries[index]?.data; | ||
| 798 | if (status) { | ||
| 799 | statusByCwd.set(cwd, status); | ||
| 800 | } | ||
| 801 | } | ||
| 802 | |||
| 803 | const map = new Map<ThreadId, ThreadPr>(); | ||
| 804 | for (const target of threadGitTargets) { | ||
| 805 | const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; | ||
| 806 | const branchMatches = | ||
| 807 | target.branch !== null && status?.branch !== null && status?.branch === target.branch; | ||
| 808 | map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); | ||
| 809 | } | ||
| 810 | return map; | ||
| 811 | }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); | ||
| 812 | |||
| 813 | const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => { | 778 | const openPrLink = useCallback((event: MouseEvent<HTMLElement>, prUrl: string) => { |
| 814 | event.preventDefault(); | 779 | event.preventDefault(); |
| 815 | event.stopPropagation(); | 780 | event.stopPropagation(); |
| ... | @@ -1718,6 +1683,7 @@ export default function Sidebar() { | ... | @@ -1718,6 +1683,7 @@ export default function Sidebar() { |
| 1718 | <SidebarThreadRow | 1683 | <SidebarThreadRow |
| 1719 | key={threadId} | 1684 | key={threadId} |
| 1720 | threadId={threadId} | 1685 | threadId={threadId} |
| 1686 | projectCwd={project.cwd} | ||
| 1721 | orderedProjectThreadIds={orderedProjectThreadIds} | 1687 | orderedProjectThreadIds={orderedProjectThreadIds} |
| 1722 | routeThreadId={routeThreadId} | 1688 | routeThreadId={routeThreadId} |
| 1723 | selectedThreadIds={selectedThreadIds} | 1689 | selectedThreadIds={selectedThreadIds} |
| ... | @@ -1741,7 +1707,6 @@ export default function Sidebar() { | ... | @@ -1741,7 +1707,6 @@ export default function Sidebar() { |
| 1741 | cancelRename={cancelRename} | 1707 | cancelRename={cancelRename} |
| 1742 | attemptArchiveThread={attemptArchiveThread} | 1708 | attemptArchiveThread={attemptArchiveThread} |
| 1743 | openPrLink={openPrLink} | 1709 | openPrLink={openPrLink} |
| 1744 | pr={prByThreadId.get(threadId) ?? null} | ||
| 1745 | /> | 1710 | /> |
| 1746 | ))} | 1711 | ))} |
| 1747 | 1712 |
apps/web/src/components/chat/MessagesTimeline.tsx+1-1| ... | @@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 385 | <img | 385 | <img |
| 386 | src={image.previewUrl} | 386 | src={image.previewUrl} |
| 387 | alt={image.name} | 387 | alt={image.name} |
| 388 | className="h-full max-h-[220px] w-full object-cover" | 388 | className="block h-auto max-h-[220px] w-full object-cover" |
| 389 | onLoad={onTimelineImageLoad} | 389 | onLoad={onTimelineImageLoad} |
| 390 | onError={onTimelineImageLoad} | 390 | onError={onTimelineImageLoad} |
| 391 | /> | 391 | /> |
apps/web/src/components/settings/SettingsPanels.browser.tsx+4-4| ... | @@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig { | ... | @@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig { |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | describe("GeneralSettingsPanel observability", () => { | 32 | describe("GeneralSettingsPanel observability", () => { |
| 33 | beforeEach(() => { | 33 | beforeEach(async () => { |
| 34 | resetServerStateForTests(); | 34 | resetServerStateForTests(); |
| 35 | __resetNativeApiForTests(); | 35 | await __resetNativeApiForTests(); |
| 36 | localStorage.clear(); | 36 | localStorage.clear(); |
| 37 | document.body.innerHTML = ""; | 37 | document.body.innerHTML = ""; |
| 38 | }); | 38 | }); |
| 39 | 39 | ||
| 40 | afterEach(() => { | 40 | afterEach(async () => { |
| 41 | resetServerStateForTests(); | 41 | resetServerStateForTests(); |
| 42 | __resetNativeApiForTests(); | 42 | await __resetNativeApiForTests(); |
| 43 | document.body.innerHTML = ""; | 43 | document.body.innerHTML = ""; |
| 44 | }); | 44 | }); |
| 45 | 45 |
apps/web/src/components/timelineHeight.test.ts+4-4| ... | @@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 30 | text: "hello", | 30 | text: "hello", |
| 31 | attachments: [{ id: "1" }], | 31 | attachments: [{ id: "1" }], |
| 32 | }), | 32 | }), |
| 33 | ).toBe(346); | 33 | ).toBe(234); |
| 34 | 34 | ||
| 35 | expect( | 35 | expect( |
| 36 | estimateTimelineMessageHeight({ | 36 | estimateTimelineMessageHeight({ |
| ... | @@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 38 | text: "hello", | 38 | text: "hello", |
| 39 | attachments: [{ id: "1" }, { id: "2" }], | 39 | attachments: [{ id: "1" }, { id: "2" }], |
| 40 | }), | 40 | }), |
| 41 | ).toBe(346); | 41 | ).toBe(234); |
| 42 | }); | 42 | }); |
| 43 | 43 | ||
| 44 | it("adds a second attachment row for three or four user attachments", () => { | 44 | it("adds a second attachment row for three or four user attachments", () => { |
| ... | @@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 48 | text: "hello", | 48 | text: "hello", |
| 49 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], | 49 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], |
| 50 | }), | 50 | }), |
| 51 | ).toBe(574); | 51 | ).toBe(350); |
| 52 | 52 | ||
| 53 | expect( | 53 | expect( |
| 54 | estimateTimelineMessageHeight({ | 54 | estimateTimelineMessageHeight({ |
| ... | @@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => { | ... | @@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => { |
| 56 | text: "hello", | 56 | text: "hello", |
| 57 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], | 57 | attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], |
| 58 | }), | 58 | }), |
| 59 | ).toBe(574); | 59 | ).toBe(350); |
| 60 | }); | 60 | }); |
| 61 | 61 | ||
| 62 | it("does not cap long user message estimates", () => { | 62 | it("does not cap long user message estimates", () => { |
apps/web/src/components/timelineHeight.ts+3-2| ... | @@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75; | ... | @@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75; |
| 11 | const ASSISTANT_BASE_HEIGHT_PX = 41; | 11 | const ASSISTANT_BASE_HEIGHT_PX = 41; |
| 12 | const USER_BASE_HEIGHT_PX = 96; | 12 | const USER_BASE_HEIGHT_PX = 96; |
| 13 | const ATTACHMENTS_PER_ROW = 2; | 13 | const ATTACHMENTS_PER_ROW = 2; |
| 14 | // Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. | 14 | // Full-app browser measurements land closer to a ~116px attachment row once |
| 15 | const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; | 15 | // the bubble shrinks to content width, so calibrate the estimate to that DOM. |
| 16 | const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; | ||
| 16 | const USER_BUBBLE_WIDTH_RATIO = 0.8; | 17 | const USER_BUBBLE_WIDTH_RATIO = 0.8; |
| 17 | const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; | 18 | const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; |
| 18 | const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; | 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,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts"; |
| 15 | import { | 15 | import { |
| 16 | gitBranchSearchInfiniteQueryOptions, | 16 | gitBranchSearchInfiniteQueryOptions, |
| 17 | gitMutationKeys, | 17 | gitMutationKeys, |
| 18 | gitQueryKeys, | ||
| 19 | gitPreparePullRequestThreadMutationOptions, | 18 | gitPreparePullRequestThreadMutationOptions, |
| 20 | gitPullMutationOptions, | 19 | gitPullMutationOptions, |
| 21 | gitRunStackedActionMutationOptions, | 20 | gitRunStackedActionMutationOptions, |
| 22 | invalidateGitStatusQuery, | ||
| 23 | gitStatusQueryOptions, | ||
| 24 | invalidateGitQueries, | 21 | invalidateGitQueries, |
| 25 | } from "./gitReactQuery"; | 22 | } from "./gitReactQuery"; |
| 26 | 23 | ||
| ... | @@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => { | ... | @@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => { |
| 84 | it("can invalidate a single cwd without blasting other git query scopes", async () => { | 81 | it("can invalidate a single cwd without blasting other git query scopes", async () => { |
| 85 | const queryClient = new QueryClient(); | 82 | const queryClient = new QueryClient(); |
| 86 | 83 | ||
| 87 | queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); | ||
| 88 | queryClient.setQueryData( | 84 | queryClient.setQueryData( |
| 89 | gitBranchSearchInfiniteQueryOptions({ | 85 | gitBranchSearchInfiniteQueryOptions({ |
| 90 | cwd: "/repo/a", | 86 | cwd: "/repo/a", |
| ... | @@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => { | ... | @@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => { |
| 92 | }).queryKey, | 88 | }).queryKey, |
| 93 | BRANCH_SEARCH_RESULT, | 89 | BRANCH_SEARCH_RESULT, |
| 94 | ); | 90 | ); |
| 95 | queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); | ||
| 96 | queryClient.setQueryData( | 91 | queryClient.setQueryData( |
| 97 | gitBranchSearchInfiniteQueryOptions({ | 92 | gitBranchSearchInfiniteQueryOptions({ |
| 98 | cwd: "/repo/b", | 93 | cwd: "/repo/b", |
| ... | @@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => { | ... | @@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => { |
| 103 | 98 | ||
| 104 | await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); | 99 | await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); |
| 105 | 100 | ||
| 106 | expect( | ||
| 107 | queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, | ||
| 108 | ).toBe(true); | ||
| 109 | expect( | 101 | expect( |
| 110 | queryClient.getQueryState( | 102 | queryClient.getQueryState( |
| 111 | gitBranchSearchInfiniteQueryOptions({ | 103 | gitBranchSearchInfiniteQueryOptions({ |
| ... | @@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => { | ... | @@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => { |
| 114 | }).queryKey, | 106 | }).queryKey, |
| 115 | )?.isInvalidated, | 107 | )?.isInvalidated, |
| 116 | ).toBe(true); | 108 | ).toBe(true); |
| 117 | expect( | ||
| 118 | queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, | ||
| 119 | ).toBe(false); | ||
| 120 | expect( | 109 | expect( |
| 121 | queryClient.getQueryState( | 110 | queryClient.getQueryState( |
| 122 | gitBranchSearchInfiniteQueryOptions({ | 111 | gitBranchSearchInfiniteQueryOptions({ |
| ... | @@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => { | ... | @@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => { |
| 127 | ).toBe(false); | 116 | ).toBe(false); |
| 128 | }); | 117 | }); |
| 129 | }); | 118 | }); |
| 130 | |||
| 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,15 +12,12 @@ import { |
| 12 | import { ensureNativeApi } from "../nativeApi"; | 12 | import { ensureNativeApi } from "../nativeApi"; |
| 13 | import { getWsRpcClient } from "../wsRpcClient"; | 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 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; | 15 | const GIT_BRANCHES_STALE_TIME_MS = 15_000; |
| 18 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; | 16 | const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; |
| 19 | const GIT_BRANCHES_PAGE_SIZE = 100; | 17 | const GIT_BRANCHES_PAGE_SIZE = 100; |
| 20 | 18 | ||
| 21 | export const gitQueryKeys = { | 19 | export const gitQueryKeys = { |
| 22 | all: ["git"] as const, | 20 | all: ["git"] as const, |
| 23 | status: (cwd: string | null) => ["git", "status", cwd] as const, | ||
| 24 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, | 21 | branches: (cwd: string | null) => ["git", "branches", cwd] as const, |
| 25 | branchSearch: (cwd: string | null, query: string) => | 22 | branchSearch: (cwd: string | null, query: string) => |
| 26 | ["git", "branches", cwd, "search", query] as const, | 23 | ["git", "branches", cwd, "search", query] as const, |
| ... | @@ -38,37 +35,18 @@ export const gitMutationKeys = { | ... | @@ -38,37 +35,18 @@ export const gitMutationKeys = { |
| 38 | export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { | 35 | export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { |
| 39 | const cwd = input?.cwd ?? null; | 36 | const cwd = input?.cwd ?? null; |
| 40 | if (cwd !== null) { | 37 | if (cwd !== null) { |
| 41 | return Promise.all([ | 38 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); |
| 42 | queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }), | ||
| 43 | queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }), | ||
| 44 | ]); | ||
| 45 | } | 39 | } |
| 46 | 40 | ||
| 47 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | 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 | if (cwd === null) { | 45 | if (cwd === null) { |
| 52 | return Promise.resolve(); | 46 | return Promise.resolve(); |
| 53 | } | 47 | } |
| 54 | 48 | ||
| 55 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }); | 49 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(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 | }); | ||
| 72 | } | 50 | } |
| 73 | 51 | ||
| 74 | export function gitBranchSearchInfiniteQueryOptions(input: { | 52 | export function gitBranchSearchInfiniteQueryOptions(input: { |
| ... | @@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: | ... | @@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: |
| 128 | if (!input.cwd) throw new Error("Git init is unavailable."); | 106 | if (!input.cwd) throw new Error("Git init is unavailable."); |
| 129 | return api.git.init({ cwd: input.cwd }); | 107 | return api.git.init({ cwd: input.cwd }); |
| 130 | }, | 108 | }, |
| 131 | onSuccess: async () => { | 109 | onSettled: async () => { |
| 132 | await invalidateGitQueries(input.queryClient); | 110 | await invalidateGitBranchQueries(input.queryClient, input.cwd); |
| 133 | }, | 111 | }, |
| 134 | }); | 112 | }); |
| 135 | } | 113 | } |
| ... | @@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: { | ... | @@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: { |
| 145 | if (!input.cwd) throw new Error("Git checkout is unavailable."); | 123 | if (!input.cwd) throw new Error("Git checkout is unavailable."); |
| 146 | return api.git.checkout({ cwd: input.cwd, branch }); | 124 | return api.git.checkout({ cwd: input.cwd, branch }); |
| 147 | }, | 125 | }, |
| 148 | onSuccess: async () => { | 126 | onSettled: async () => { |
| 149 | await invalidateGitQueries(input.queryClient); | 127 | await invalidateGitBranchQueries(input.queryClient, input.cwd); |
| 150 | }, | 128 | }, |
| 151 | }); | 129 | }); |
| 152 | } | 130 | } |
| ... | @@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: { |
| 175 | if (!input.cwd) throw new Error("Git action is unavailable."); | 153 | if (!input.cwd) throw new Error("Git action is unavailable."); |
| 176 | return getWsRpcClient().git.runStackedAction( | 154 | return getWsRpcClient().git.runStackedAction( |
| 177 | { | 155 | { |
| 156 | action, | ||
| 178 | actionId, | 157 | actionId, |
| 179 | cwd: input.cwd, | 158 | cwd: input.cwd, |
| 180 | action, | ||
| 181 | ...(commitMessage ? { commitMessage } : {}), | 159 | ...(commitMessage ? { commitMessage } : {}), |
| 182 | ...(featureBranch ? { featureBranch } : {}), | 160 | ...(featureBranch ? { featureBranch: true } : {}), |
| 183 | ...(filePaths ? { filePaths } : {}), | 161 | ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), |
| 184 | }, | 162 | }, |
| 185 | ...(onProgress ? [{ onProgress }] : []), | 163 | ...(onProgress ? [{ onProgress }] : []), |
| 186 | ); | 164 | ); |
| 187 | }, | 165 | }, |
| 188 | onSettled: async () => { | 166 | onSuccess: async () => { |
| 189 | await invalidateGitQueries(input.queryClient); | 167 | await invalidateGitBranchQueries(input.queryClient, input.cwd); |
| 190 | }, | 168 | }, |
| 191 | }); | 169 | }); |
| 192 | } | 170 | } |
| ... | @@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient: | ... | @@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient: |
| 199 | if (!input.cwd) throw new Error("Git pull is unavailable."); | 177 | if (!input.cwd) throw new Error("Git pull is unavailable."); |
| 200 | return api.git.pull({ cwd: input.cwd }); | 178 | return api.git.pull({ cwd: input.cwd }); |
| 201 | }, | 179 | }, |
| 202 | onSettled: async () => { | 180 | onSuccess: async () => { |
| 203 | await invalidateGitQueries(input.queryClient); | 181 | await invalidateGitBranchQueries(input.queryClient, input.cwd); |
| 204 | }, | 182 | }, |
| 205 | }); | 183 | }); |
| 206 | } | 184 | } |
| 207 | 185 | ||
| 208 | export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { | 186 | export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { |
| 209 | return mutationOptions({ | 187 | return mutationOptions({ |
| 210 | mutationFn: async ({ | ||
| 211 | cwd, | ||
| 212 | branch, | ||
| 213 | newBranch, | ||
| 214 | path, | ||
| 215 | }: { | ||
| 216 | cwd: string; | ||
| 217 | branch: string; | ||
| 218 | newBranch: string; | ||
| 219 | path?: string | null; | ||
| 220 | }) => { | ||
| 221 | const api = ensureNativeApi(); | ||
| 222 | if (!cwd) throw new Error("Git worktree creation is unavailable."); | ||
| 223 | return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); | ||
| 224 | }, | ||
| 225 | mutationKey: ["git", "mutation", "create-worktree"] as const, | 188 | mutationKey: ["git", "mutation", "create-worktree"] as const, |
| 226 | onSettled: async () => { | 189 | mutationFn: ( |
| 190 | args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["createWorktree"]>[0], | ||
| 191 | ) => ensureNativeApi().git.createWorktree(args), | ||
| 192 | onSuccess: async () => { | ||
| 227 | await invalidateGitQueries(input.queryClient); | 193 | await invalidateGitQueries(input.queryClient); |
| 228 | }, | 194 | }, |
| 229 | }); | 195 | }); |
| ... | @@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie | ... | @@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie |
| 231 | 197 | ||
| 232 | export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { | 198 | export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { |
| 233 | return mutationOptions({ | 199 | return mutationOptions({ |
| 234 | mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { | ||
| 235 | const api = ensureNativeApi(); | ||
| 236 | if (!cwd) throw new Error("Git worktree removal is unavailable."); | ||
| 237 | return api.git.removeWorktree({ cwd, path, force }); | ||
| 238 | }, | ||
| 239 | mutationKey: ["git", "mutation", "remove-worktree"] as const, | 200 | mutationKey: ["git", "mutation", "remove-worktree"] as const, |
| 240 | onSettled: async () => { | 201 | mutationFn: ( |
| 202 | args: Parameters<ReturnType<typeof ensureNativeApi>["git"]["removeWorktree"]>[0], | ||
| 203 | ) => ensureNativeApi().git.removeWorktree(args), | ||
| 204 | onSuccess: async () => { | ||
| 241 | await invalidateGitQueries(input.queryClient); | 205 | await invalidateGitQueries(input.queryClient); |
| 242 | }, | 206 | }, |
| 243 | }); | 207 | }); |
| ... | @@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: { | ... | @@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: { |
| 248 | queryClient: QueryClient; | 212 | queryClient: QueryClient; |
| 249 | }) { | 213 | }) { |
| 250 | return mutationOptions({ | 214 | return mutationOptions({ |
| 251 | mutationFn: async ({ | 215 | mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), |
| 252 | reference, | 216 | mutationFn: async (args: { |
| 253 | mode, | ||
| 254 | threadId, | ||
| 255 | }: { | ||
| 256 | reference: string; | 217 | reference: string; |
| 257 | mode: "local" | "worktree"; | 218 | mode: "local" | "worktree"; |
| 258 | threadId?: ThreadId; | 219 | threadId?: ThreadId; |
| ... | @@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: { | ... | @@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: { |
| 261 | if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); | 222 | if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); |
| 262 | return api.git.preparePullRequestThread({ | 223 | return api.git.preparePullRequestThread({ |
| 263 | cwd: input.cwd, | 224 | cwd: input.cwd, |
| 264 | reference, | 225 | reference: args.reference, |
| 265 | mode, | 226 | mode: args.mode, |
| 266 | ...(threadId ? { threadId } : {}), | 227 | ...(args.threadId ? { threadId: args.threadId } : {}), |
| 267 | }); | 228 | }); |
| 268 | }, | 229 | }, |
| 269 | mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), | 230 | onSuccess: async () => { |
| 270 | onSettled: async () => { | 231 | await invalidateGitBranchQueries(input.queryClient, input.cwd); |
| 271 | await invalidateGitQueries(input.queryClient); | ||
| 272 | }, | 232 | }, |
| 273 | }); | 233 | }); |
| 274 | } | 234 | } |
apps/web/src/lib/gitStatusState.test.ts created+112| ... | @@ -0,0 +1,112 @@ | ||
| 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,7 +25,7 @@ export function ensureNativeApi(): NativeApi { |
| 25 | return api; | 25 | return api; |
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | export function __resetNativeApiForTests() { | 28 | export async function __resetNativeApiForTests() { |
| 29 | cachedApi = undefined; | 29 | cachedApi = undefined; |
| 30 | __resetWsNativeApiForTests(); | 30 | await __resetWsNativeApiForTests(); |
| 31 | } | 31 | } |
apps/web/src/wsNativeApi.test.ts+46-1| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type DesktopBridge, | 4 | type DesktopBridge, |
| 5 | EventId, | 5 | EventId, |
| 6 | type GitStatusResult, | ||
| 6 | ProjectId, | 7 | ProjectId, |
| 7 | type OrchestrationEvent, | 8 | type OrchestrationEvent, |
| 8 | type ServerConfig, | 9 | type ServerConfig, |
| ... | @@ -31,6 +32,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -31,6 +32,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 31 | 32 | ||
| 32 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | 33 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); |
| 33 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); | 34 | const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); |
| 35 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | ||
| 34 | 36 | ||
| 35 | const rpcClientMock = { | 37 | const rpcClientMock = { |
| 36 | dispose: vi.fn(), | 38 | dispose: vi.fn(), |
| ... | @@ -54,7 +56,10 @@ const rpcClientMock = { | ... | @@ -54,7 +56,10 @@ const rpcClientMock = { |
| 54 | }, | 56 | }, |
| 55 | git: { | 57 | git: { |
| 56 | pull: vi.fn(), | 58 | pull: vi.fn(), |
| 57 | status: vi.fn(), | 59 | refreshStatus: vi.fn(), |
| 60 | onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => | ||
| 61 | registerListener(gitStatusListeners, listener), | ||
| 62 | ), | ||
| 58 | runStackedAction: vi.fn(), | 63 | runStackedAction: vi.fn(), |
| 59 | listBranches: vi.fn(), | 64 | listBranches: vi.fn(), |
| 60 | createWorktree: vi.fn(), | 65 | createWorktree: vi.fn(), |
| ... | @@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = { | ... | @@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = { |
| 168 | settings: DEFAULT_SERVER_SETTINGS, | 173 | settings: DEFAULT_SERVER_SETTINGS, |
| 169 | }; | 174 | }; |
| 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 | beforeEach(() => { | 189 | beforeEach(() => { |
| 172 | vi.resetModules(); | 190 | vi.resetModules(); |
| 173 | vi.clearAllMocks(); | 191 | vi.clearAllMocks(); |
| 174 | showContextMenuFallbackMock.mockReset(); | 192 | showContextMenuFallbackMock.mockReset(); |
| 175 | terminalEventListeners.clear(); | 193 | terminalEventListeners.clear(); |
| 176 | orchestrationEventListeners.clear(); | 194 | orchestrationEventListeners.clear(); |
| 195 | gitStatusListeners.clear(); | ||
| 177 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); | 196 | Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); |
| 178 | }); | 197 | }); |
| 179 | 198 | ||
| ... | @@ -243,6 +262,32 @@ describe("wsNativeApi", () => { | ... | @@ -243,6 +262,32 @@ describe("wsNativeApi", () => { |
| 243 | expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); | 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 | it("forwards orchestration stream subscription options to the RPC client", async () => { | 291 | it("forwards orchestration stream subscription options to the RPC client", async () => { |
| 247 | const { createWsNativeApi } = await import("./wsNativeApi"); | 292 | const { createWsNativeApi } = await import("./wsNativeApi"); |
| 248 | 293 |
apps/web/src/wsNativeApi.ts+8-3| ... | @@ -1,6 +1,8 @@ | ... | @@ -1,6 +1,8 @@ |
| 1 | import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; | 1 | import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | ||
| 3 | import { showContextMenuFallback } from "./contextMenuFallback"; | 4 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 5 | import { __resetWsRpcAtomClientForTests } from "./rpc/client"; | ||
| 4 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 5 | import { resetServerStateForTests } from "./rpc/serverState"; | 7 | import { resetServerStateForTests } from "./rpc/serverState"; |
| 6 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | 8 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; |
| ... | @@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; | ... | @@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; |
| 8 | 10 | ||
| 9 | let instance: { api: NativeApi } | null = null; | 11 | let instance: { api: NativeApi } | null = null; |
| 10 | 12 | ||
| 11 | export function __resetWsNativeApiForTests() { | 13 | export async function __resetWsNativeApiForTests() { |
| 12 | instance = null; | 14 | instance = null; |
| 13 | __resetWsRpcClientForTests(); | 15 | await __resetWsRpcAtomClientForTests(); |
| 16 | await __resetWsRpcClientForTests(); | ||
| 17 | resetGitStatusStateForTests(); | ||
| 14 | resetRequestLatencyStateForTests(); | 18 | resetRequestLatencyStateForTests(); |
| 15 | resetServerStateForTests(); | 19 | resetServerStateForTests(); |
| 16 | resetWsConnectionStateForTests(); | 20 | resetWsConnectionStateForTests(); |
| ... | @@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi { | ... | @@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi { |
| 65 | }, | 69 | }, |
| 66 | git: { | 70 | git: { |
| 67 | pull: rpcClient.git.pull, | 71 | pull: rpcClient.git.pull, |
| 68 | status: rpcClient.git.status, | 72 | refreshStatus: rpcClient.git.refreshStatus, |
| 73 | onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), | ||
| 69 | listBranches: rpcClient.git.listBranches, | 74 | listBranches: rpcClient.git.listBranches, |
| 70 | createWorktree: rpcClient.git.createWorktree, | 75 | createWorktree: rpcClient.git.createWorktree, |
| 71 | removeWorktree: rpcClient.git.removeWorktree, | 76 | removeWorktree: rpcClient.git.removeWorktree, |
apps/web/src/wsRpcClient.test.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 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,11 +2,14 @@ import { |
| 2 | type GitActionProgressEvent, | 2 | type GitActionProgressEvent, |
| 3 | type GitRunStackedActionInput, | 3 | type GitRunStackedActionInput, |
| 4 | type GitRunStackedActionResult, | 4 | type GitRunStackedActionResult, |
| 5 | type GitStatusResult, | ||
| 6 | type GitStatusStreamEvent, | ||
| 5 | type NativeApi, | 7 | type NativeApi, |
| 6 | ORCHESTRATION_WS_METHODS, | 8 | ORCHESTRATION_WS_METHODS, |
| 7 | type ServerSettingsPatch, | 9 | type ServerSettingsPatch, |
| 8 | WS_METHODS, | 10 | WS_METHODS, |
| 9 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| 12 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | ||
| 10 | import { Effect, Stream } from "effect"; | 13 | import { Effect, Stream } from "effect"; |
| 11 | 14 | ||
| 12 | import { type WsRpcProtocolClient } from "./rpc/protocol"; | 15 | import { type WsRpcProtocolClient } from "./rpc/protocol"; |
| ... | @@ -64,7 +67,12 @@ export interface WsRpcClient { | ... | @@ -64,7 +67,12 @@ export interface WsRpcClient { |
| 64 | }; | 67 | }; |
| 65 | readonly git: { | 68 | readonly git: { |
| 66 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | 69 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; |
| 67 | readonly status: RpcUnaryMethod<typeof WS_METHODS.gitStatus>; | 70 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>; |
| 71 | readonly onStatus: ( | ||
| 72 | input: RpcInput<typeof WS_METHODS.subscribeGitStatus>, | ||
| 73 | listener: (status: GitStatusResult) => void, | ||
| 74 | options?: StreamSubscriptionOptions, | ||
| 75 | ) => () => void; | ||
| 68 | readonly runStackedAction: ( | 76 | readonly runStackedAction: ( |
| 69 | input: GitRunStackedActionInput, | 77 | input: GitRunStackedActionInput, |
| 70 | options?: GitRunStackedActionOptions, | 78 | options?: GitRunStackedActionOptions, |
| ... | @@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { | ... | @@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { |
| 149 | }, | 157 | }, |
| 150 | git: { | 158 | git: { |
| 151 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | 159 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), |
| 152 | status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), | 160 | refreshStatus: (input) => |
| 161 | transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), | ||
| 162 | onStatus: (input, listener, options) => { | ||
| 163 | let current: GitStatusResult | null = null; | ||
| 164 | return transport.subscribe( | ||
| 165 | (client) => client[WS_METHODS.subscribeGitStatus](input), | ||
| 166 | (event: GitStatusStreamEvent) => { | ||
| 167 | current = applyGitStatusStreamEvent(current, event); | ||
| 168 | listener(current); | ||
| 169 | }, | ||
| 170 | options, | ||
| 171 | ); | ||
| 172 | }, | ||
| 153 | runStackedAction: async (input, options) => { | 173 | runStackedAction: async (input, options) => { |
| 154 | let result: GitRunStackedActionResult | null = null; | 174 | let result: GitRunStackedActionResult | null = null; |
| 155 | 175 |
apps/web/test/wsRpcHarness.ts+2-1| ... | @@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions { | ... | @@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions { |
| 24 | 24 | ||
| 25 | const STREAM_METHODS = new Set<string>([ | 25 | const STREAM_METHODS = new Set<string>([ |
| 26 | WS_METHODS.gitRunStackedAction, | 26 | WS_METHODS.gitRunStackedAction, |
| 27 | WS_METHODS.subscribeGitStatus, | ||
| 27 | WS_METHODS.subscribeOrchestrationDomainEvents, | 28 | WS_METHODS.subscribeOrchestrationDomainEvents, |
| 28 | WS_METHODS.subscribeTerminalEvents, | 29 | WS_METHODS.subscribeTerminalEvents, |
| 29 | WS_METHODS.subscribeServerConfig, | 30 | WS_METHODS.subscribeServerConfig, |
| ... | @@ -108,7 +109,7 @@ export class BrowserWsRpcHarness { | ... | @@ -108,7 +109,7 @@ export class BrowserWsRpcHarness { |
| 108 | async onMessage(rawData: string): Promise<void> { | 109 | async onMessage(rawData: string): Promise<void> { |
| 109 | const server = await this.serverReady; | 110 | const server = await this.serverReady; |
| 110 | if (!server) { | 111 | if (!server) { |
| 111 | throw new Error("RPC test server is not connected"); | 112 | return; |
| 112 | } | 113 | } |
| 113 | const messages = this.parser.decode(rawData); | 114 | const messages = this.parser.decode(rawData); |
| 114 | for (const message of messages) { | 115 | for (const message of messages) { |
packages/contracts/src/git.ts+49-1| ... | @@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | ... | @@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); |
| 44 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; | 44 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; |
| 45 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); | 45 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); |
| 46 | const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); | 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 | export const GitRunStackedActionToastRunAction = Schema.Struct({ | 55 | export const GitRunStackedActionToastRunAction = Schema.Struct({ |
| 48 | kind: GitStackedAction, | 56 | kind: GitStackedAction, |
| 49 | }); | 57 | }); |
| ... | @@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; | ... | @@ -161,9 +169,15 @@ export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; |
| 161 | export const GitCreateBranchInput = Schema.Struct({ | 169 | export const GitCreateBranchInput = Schema.Struct({ |
| 162 | cwd: TrimmedNonEmptyStringSchema, | 170 | cwd: TrimmedNonEmptyStringSchema, |
| 163 | branch: TrimmedNonEmptyStringSchema, | 171 | branch: TrimmedNonEmptyStringSchema, |
| 172 | checkout: Schema.optional(Schema.Boolean), | ||
| 164 | }); | 173 | }); |
| 165 | export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; | 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 | export const GitCheckoutInput = Schema.Struct({ | 181 | export const GitCheckoutInput = Schema.Struct({ |
| 168 | cwd: TrimmedNonEmptyStringSchema, | 182 | cwd: TrimmedNonEmptyStringSchema, |
| 169 | branch: TrimmedNonEmptyStringSchema, | 183 | branch: TrimmedNonEmptyStringSchema, |
| ... | @@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({ | ... | @@ -186,8 +200,9 @@ const GitStatusPr = Schema.Struct({ |
| 186 | state: GitStatusPrState, | 200 | state: GitStatusPrState, |
| 187 | }); | 201 | }); |
| 188 | 202 | ||
| 189 | export const GitStatusResult = Schema.Struct({ | 203 | const GitStatusLocalShape = { |
| 190 | isRepo: Schema.Boolean, | 204 | isRepo: Schema.Boolean, |
| 205 | hostingProvider: Schema.optional(GitHostingProvider), | ||
| 191 | hasOriginRemote: Schema.Boolean, | 206 | hasOriginRemote: Schema.Boolean, |
| 192 | isDefaultBranch: Schema.Boolean, | 207 | isDefaultBranch: Schema.Boolean, |
| 193 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | 208 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| ... | @@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({ | ... | @@ -203,13 +218,41 @@ export const GitStatusResult = Schema.Struct({ |
| 203 | insertions: NonNegativeInt, | 218 | insertions: NonNegativeInt, |
| 204 | deletions: NonNegativeInt, | 219 | deletions: NonNegativeInt, |
| 205 | }), | 220 | }), |
| 221 | }; | ||
| 222 | |||
| 223 | const GitStatusRemoteShape = { | ||
| 206 | hasUpstream: Schema.Boolean, | 224 | hasUpstream: Schema.Boolean, |
| 207 | aheadCount: NonNegativeInt, | 225 | aheadCount: NonNegativeInt, |
| 208 | behindCount: NonNegativeInt, | 226 | behindCount: NonNegativeInt, |
| 209 | pr: Schema.NullOr(GitStatusPr), | 227 | pr: Schema.NullOr(GitStatusPr), |
| 228 | }; | ||
| 229 | |||
| 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 | export type GitStatusResult = typeof GitStatusResult.Type; | 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 | export const GitListBranchesResult = Schema.Struct({ | 256 | export const GitListBranchesResult = Schema.Struct({ |
| 214 | branches: Schema.Array(GitBranch), | 257 | branches: Schema.Array(GitBranch), |
| 215 | isRepo: Schema.Boolean, | 258 | isRepo: Schema.Boolean, |
| ... | @@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ | ... | @@ -236,6 +279,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ |
| 236 | }); | 279 | }); |
| 237 | export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; | 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 | export const GitRunStackedActionResult = Schema.Struct({ | 287 | export const GitRunStackedActionResult = Schema.Struct({ |
| 240 | action: GitStackedAction, | 288 | action: GitStackedAction, |
| 241 | branch: Schema.Struct({ | 289 | branch: Schema.Struct({ |
packages/contracts/src/ipc.ts+12-3| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitCheckoutInput, | 2 | GitCheckoutInput, |
| 3 | GitCheckoutResult, | ||
| 3 | GitCreateBranchInput, | 4 | GitCreateBranchInput, |
| 4 | GitPreparePullRequestThreadInput, | 5 | GitPreparePullRequestThreadInput, |
| 5 | GitPreparePullRequestThreadResult, | 6 | GitPreparePullRequestThreadResult, |
| ... | @@ -15,6 +16,7 @@ import type { | ... | @@ -15,6 +16,7 @@ import type { |
| 15 | GitResolvePullRequestResult, | 16 | GitResolvePullRequestResult, |
| 16 | GitStatusInput, | 17 | GitStatusInput, |
| 17 | GitStatusResult, | 18 | GitStatusResult, |
| 19 | GitCreateBranchResult, | ||
| 18 | } from "./git"; | 20 | } from "./git"; |
| 19 | import type { | 21 | import type { |
| 20 | ProjectSearchEntriesInput, | 22 | ProjectSearchEntriesInput, |
| ... | @@ -148,8 +150,8 @@ export interface NativeApi { | ... | @@ -148,8 +150,8 @@ export interface NativeApi { |
| 148 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; | 150 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; |
| 149 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; | 151 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; |
| 150 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; | 152 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; |
| 151 | createBranch: (input: GitCreateBranchInput) => Promise<void>; | 153 | createBranch: (input: GitCreateBranchInput) => Promise<GitCreateBranchResult>; |
| 152 | checkout: (input: GitCheckoutInput) => Promise<void>; | 154 | checkout: (input: GitCheckoutInput) => Promise<GitCheckoutResult>; |
| 153 | init: (input: GitInitInput) => Promise<void>; | 155 | init: (input: GitInitInput) => Promise<void>; |
| 154 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; | 156 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; |
| 155 | preparePullRequestThread: ( | 157 | preparePullRequestThread: ( |
| ... | @@ -157,7 +159,14 @@ export interface NativeApi { | ... | @@ -157,7 +159,14 @@ export interface NativeApi { |
| 157 | ) => Promise<GitPreparePullRequestThreadResult>; | 159 | ) => Promise<GitPreparePullRequestThreadResult>; |
| 158 | // Stacked action API | 160 | // Stacked action API |
| 159 | pull: (input: GitPullInput) => Promise<GitPullResult>; | 161 | pull: (input: GitPullInput) => Promise<GitPullResult>; |
| 160 | status: (input: GitStatusInput) => Promise<GitStatusResult>; | 162 | refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>; |
| 163 | onStatus: ( | ||
| 164 | input: GitStatusInput, | ||
| 165 | callback: (status: GitStatusResult) => void, | ||
| 166 | options?: { | ||
| 167 | onResubscribe?: () => void; | ||
| 168 | }, | ||
| 169 | ) => () => void; | ||
| 161 | }; | 170 | }; |
| 162 | contextMenu: { | 171 | contextMenu: { |
| 163 | show: <T extends string>( | 172 | show: <T extends string>( |
packages/contracts/src/rpc.ts+18-4| ... | @@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor"; | ... | @@ -6,8 +6,10 @@ import { OpenError, OpenInEditorInput } from "./editor"; |
| 6 | import { | 6 | import { |
| 7 | GitActionProgressEvent, | 7 | GitActionProgressEvent, |
| 8 | GitCheckoutInput, | 8 | GitCheckoutInput, |
| 9 | GitCheckoutResult, | ||
| 9 | GitCommandError, | 10 | GitCommandError, |
| 10 | GitCreateBranchInput, | 11 | GitCreateBranchInput, |
| 12 | GitCreateBranchResult, | ||
| 11 | GitCreateWorktreeInput, | 13 | GitCreateWorktreeInput, |
| 12 | GitCreateWorktreeResult, | 14 | GitCreateWorktreeResult, |
| 13 | GitInitInput, | 15 | GitInitInput, |
| ... | @@ -24,6 +26,7 @@ import { | ... | @@ -24,6 +26,7 @@ import { |
| 24 | GitRunStackedActionInput, | 26 | GitRunStackedActionInput, |
| 25 | GitStatusInput, | 27 | GitStatusInput, |
| 26 | GitStatusResult, | 28 | GitStatusResult, |
| 29 | GitStatusStreamEvent, | ||
| 27 | } from "./git"; | 30 | } from "./git"; |
| 28 | import { KeybindingsConfigError } from "./keybindings"; | 31 | import { KeybindingsConfigError } from "./keybindings"; |
| 29 | import { | 32 | import { |
| ... | @@ -83,7 +86,7 @@ export const WS_METHODS = { | ... | @@ -83,7 +86,7 @@ export const WS_METHODS = { |
| 83 | 86 | ||
| 84 | // Git methods | 87 | // Git methods |
| 85 | gitPull: "git.pull", | 88 | gitPull: "git.pull", |
| 86 | gitStatus: "git.status", | 89 | gitRefreshStatus: "git.refreshStatus", |
| 87 | gitRunStackedAction: "git.runStackedAction", | 90 | gitRunStackedAction: "git.runStackedAction", |
| 88 | gitListBranches: "git.listBranches", | 91 | gitListBranches: "git.listBranches", |
| 89 | gitCreateWorktree: "git.createWorktree", | 92 | gitCreateWorktree: "git.createWorktree", |
| ... | @@ -110,6 +113,7 @@ export const WS_METHODS = { | ... | @@ -110,6 +113,7 @@ export const WS_METHODS = { |
| 110 | serverUpdateSettings: "server.updateSettings", | 113 | serverUpdateSettings: "server.updateSettings", |
| 111 | 114 | ||
| 112 | // Streaming subscriptions | 115 | // Streaming subscriptions |
| 116 | subscribeGitStatus: "subscribeGitStatus", | ||
| 113 | subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", | 117 | subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", |
| 114 | subscribeTerminalEvents: "subscribeTerminalEvents", | 118 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 115 | subscribeServerConfig: "subscribeServerConfig", | 119 | subscribeServerConfig: "subscribeServerConfig", |
| ... | @@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { | ... | @@ -162,10 +166,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { |
| 162 | error: OpenError, | 166 | error: OpenError, |
| 163 | }); | 167 | }); |
| 164 | 168 | ||
| 165 | export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { | 169 | export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { |
| 166 | payload: GitStatusInput, | 170 | payload: GitStatusInput, |
| 167 | success: GitStatusResult, | 171 | success: GitStatusStreamEvent, |
| 168 | error: GitManagerServiceError, | 172 | error: GitManagerServiceError, |
| 173 | stream: true, | ||
| 169 | }); | 174 | }); |
| 170 | 175 | ||
| 171 | export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { | 176 | export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { |
| ... | @@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { | ... | @@ -174,6 +179,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { |
| 174 | error: GitCommandError, | 179 | error: GitCommandError, |
| 175 | }); | 180 | }); |
| 176 | 181 | ||
| 182 | export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { | ||
| 183 | payload: GitStatusInput, | ||
| 184 | success: GitStatusResult, | ||
| 185 | error: GitManagerServiceError, | ||
| 186 | }); | ||
| 187 | |||
| 177 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { | 188 | export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { |
| 178 | payload: GitRunStackedActionInput, | 189 | payload: GitRunStackedActionInput, |
| 179 | success: GitActionProgressEvent, | 190 | success: GitActionProgressEvent, |
| ... | @@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { | ... | @@ -212,11 +223,13 @@ export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { |
| 212 | 223 | ||
| 213 | export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { | 224 | export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { |
| 214 | payload: GitCreateBranchInput, | 225 | payload: GitCreateBranchInput, |
| 226 | success: GitCreateBranchResult, | ||
| 215 | error: GitCommandError, | 227 | error: GitCommandError, |
| 216 | }); | 228 | }); |
| 217 | 229 | ||
| 218 | export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { | 230 | export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { |
| 219 | payload: GitCheckoutInput, | 231 | payload: GitCheckoutInput, |
| 232 | success: GitCheckoutResult, | ||
| 220 | error: GitCommandError, | 233 | error: GitCommandError, |
| 221 | }); | 234 | }); |
| 222 | 235 | ||
| ... | @@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -330,8 +343,9 @@ export const WsRpcGroup = RpcGroup.make( |
| 330 | WsProjectsSearchEntriesRpc, | 343 | WsProjectsSearchEntriesRpc, |
| 331 | WsProjectsWriteFileRpc, | 344 | WsProjectsWriteFileRpc, |
| 332 | WsShellOpenInEditorRpc, | 345 | WsShellOpenInEditorRpc, |
| 333 | WsGitStatusRpc, | 346 | WsSubscribeGitStatusRpc, |
| 334 | WsGitPullRpc, | 347 | WsGitPullRpc, |
| 348 | WsGitRefreshStatusRpc, | ||
| 335 | WsGitRunStackedActionRpc, | 349 | WsGitRunStackedActionRpc, |
| 336 | WsGitResolvePullRequestRpc, | 350 | WsGitResolvePullRequestRpc, |
| 337 | WsGitPreparePullRequestThreadRpc, | 351 | WsGitPreparePullRequestThreadRpc, |
packages/shared/src/git.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 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,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 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. | 11 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. |
| ... | @@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches( | ... | @@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches( |
| 119 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); | 126 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); |
| 120 | }); | 127 | }); |
| 121 | } | 128 | } |
| 129 | |||
| 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 | } |