diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 5e4416d8b9..5ff2714b61 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -949,11 +949,12 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ + const checkoutResult = yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: `${remoteName}/${featureBranch}`, }); + expect(checkoutResult.branch).toBe("upstream/feature"); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..3bb905f305 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1177,9 +1177,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return branchLastCommit; }); - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( "GitCore.statusDetails.status", cwd, @@ -1312,6 +1310,17 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); + const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }, + ); + + const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + return yield* readStatusDetailsLocal(cwd); + }); + const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ @@ -2078,6 +2087,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); + + const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { branch }; }, ); @@ -2106,6 +2122,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { execute, status, statusDetails, + statusDetailsLocal, prepareCommitContext, commit, pushCurrentBranch, diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 7fedb15714..d5e7eca217 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -8,9 +8,13 @@ import { GitCommandError, GitRunStackedActionResult, GitStackedAction, + type GitStatusLocalResult, + type GitStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { + detectGitHostingProviderFromRemoteUrl, + mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -695,26 +699,55 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd).pipe( - Effect.catchIf(isNotGitRepositoryError, () => - Effect.succeed({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } satisfies GitStatusDetails), - ), - ); + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; + + return { + isRepo: details.isRepo, + ...(hostingProvider ? { hostingProvider } : {}), + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + } satisfies GitStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readLocalStatus, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetails(cwd) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } const pr = - details.isRepo && details.branch !== null + details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, @@ -725,29 +758,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { - isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - }; + } satisfies GitStatusRemoteResult; }); - const statusResultCache = yield* Cache.makeWith({ + const remoteStatusResultCache = yield* Cache.makeWith({ capacity: STATUS_RESULT_CACHE_CAPACITY, - lookup: readStatus, + lookup: readRemoteStatus, timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); - const invalidateStatusResultCache = (cwd: string) => - Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); + const invalidateRemoteStatusResultCache = (cwd: string) => + Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + }); + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( cwd: string, remoteName: string | null, @@ -1311,9 +1353,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; }); + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { + return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }); + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input) { + return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + }, + ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); }); + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + }); + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); + }); + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); + }, + ); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { @@ -1488,7 +1555,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { branch: worktree.worktree.branch, worktreePath: worktree.worktree.path, }; - }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1692,7 +1759,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( - Effect.ensuring(invalidateStatusResultCache(input.cwd)), + Effect.ensuring(invalidateStatus(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ @@ -1707,7 +1774,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); return { + localStatus, + remoteStatus, status, + invalidateLocalStatus, + invalidateRemoteStatus, + invalidateStatus, resolvePullRequest, preparePullRequestThread, runStackedAction, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts new file mode 100644 index 0000000000..fbe418ab7d --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -0,0 +1,276 @@ +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe } from "vitest"; + +import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; +import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; +import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const baseStatus: GitStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + +function makeTestLayer(state: { + currentLocalStatus: GitStatusLocalResult; + currentRemoteStatus: GitStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; +}) { + const gitManager: GitManagerShape = { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + }; + + return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); +} + +describe("GitStatusBroadcasterLive", () => { + it.effect("reuses the cached git status across repeated reads", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + branch: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies GitStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies GitStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = GitStatusBroadcasterLive.pipe( + Layer.provide( + Layer.succeed(GitManager, { + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + status: () => Effect.die("status should not be called in this test"), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), + resolvePullRequest: () => + Effect.die("resolvePullRequest should not be called in this test"), + preparePullRequestThread: () => + Effect.die("preparePullRequestThread should not be called in this test"), + runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), + } satisfies GitManagerShape), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* GitStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..78d4abf40d --- /dev/null +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -0,0 +1,307 @@ +import { realpathSync } from "node:fs"; + +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitStatusInput, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import { + GitStatusBroadcaster, + type GitStatusBroadcasterShape, +} from "../Services/GitStatusBroadcaster.ts"; +import { GitManager } from "../Services/GitManager.ts"; + +const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface GitStatusChange { + readonly cwd: string; + readonly event: GitStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedGitStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +export const GitStatusBroadcasterLive = Layer.effect( + GitStatusBroadcaster, + Effect.gen(function* () { + const gitManager = yield* GitManager; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( + cwd: string, + local: GitStatusLocalResult, + options?: { publish?: boolean }, + ) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }); + + const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( + cwd: string, + remote: GitStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }); + + const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }); + + const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( + input: GitStatusInput, + ) { + const normalizedCwd = normalizeCwd(input.cwd); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(normalizedCwd), + getOrLoadRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus = Effect.fn("refreshLocalStatus")(function* (cwd: string) { + yield* gitManager.invalidateLocalStatus(cwd); + const local = yield* gitManager.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { + yield* gitManager.invalidateRemoteStatus(cwd); + const remote = yield* gitManager.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( + function* (cwd) { + const normalizedCwd = normalizeCwd(cwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(normalizedCwd), + refreshRemoteStatus(normalizedCwd), + ]); + return mergeGitStatusParts(local, remote); + }, + ); + + const makeRemoteRefreshLoop = (cwd: string) => { + const logRefreshFailure = (error: Error) => + Effect.logWarning("git remote status refresh failed", { + cwd, + detail: error.message, + }); + + return refreshRemoteStatus(cwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + ), + ), + ), + ); + }; + + const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const normalizedCwd = normalizeCwd(input.cwd); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); + const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; + yield* retainRemotePoller(normalizedCwd); + + const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === normalizedCwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return { + getStatus, + refreshStatus, + streamStatus, + } satisfies GitStatusBroadcasterShape; + }), +); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..32cd8f6160 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -10,6 +10,7 @@ import { ServiceMap } from "effect"; import type { Effect, Scope } from "effect"; import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, GitCreateWorktreeInput, GitCreateWorktreeResult, @@ -156,6 +157,11 @@ export interface GitCoreShape { */ readonly statusDetails: (cwd: string) => Effect.Effect; + /** + * Read detailed working tree / branch status without refreshing remote tracking refs. + */ + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + /** * Build staged change context for commit generation. */ @@ -285,7 +291,7 @@ export interface GitCoreShape { */ readonly checkoutBranch: ( input: GitCheckoutInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Initialize a repository in the provided directory. diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 86842257b4..0e04ceedcb 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,6 +14,8 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, + GitStatusLocalResult, + GitStatusRemoteResult, GitStatusInput, GitStatusResult, } from "@t3tools/contracts"; @@ -41,6 +43,35 @@ export interface GitManagerShape { input: GitStatusInput, ) => Effect.Effect; + /** + * Read local repository status without remote hosting enrichment. + */ + readonly localStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Read remote tracking / PR status for a repository. + */ + readonly remoteStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + + /** + * Clear any cached local status snapshot for a repository. + */ + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached remote status snapshot for a repository. + */ + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + + /** + * Clear any cached status snapshot for a repository so the next read is fresh. + */ + readonly invalidateStatus: (cwd: string) => Effect.Effect; + /** * Resolve a pull request by URL/number against the current repository. */ diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts new file mode 100644 index 0000000000..0f3f622d17 --- /dev/null +++ b/apps/server/src/git/Services/GitStatusBroadcaster.ts @@ -0,0 +1,23 @@ +import { ServiceMap } from "effect"; +import type { Effect, Stream } from "effect"; +import type { + GitManagerServiceError, + GitStatusInput, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; + +export interface GitStatusBroadcasterShape { + readonly getStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: GitStatusInput, + ) => Stream.Stream; +} + +export class GitStatusBroadcaster extends ServiceMap.Service< + GitStatusBroadcaster, + GitStatusBroadcasterShape +>()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..a93f545d8d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -22,7 +22,16 @@ import { } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; -import { Effect, FileSystem, Layer, ManagedRuntime, Path, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Path, + Stream, +} from "effect"; import { FetchHttpClient, HttpBody, @@ -44,6 +53,7 @@ import { } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -294,6 +304,10 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); + const gitManagerLayer = Layer.mock(GitManager)({ + ...options?.layers?.gitManager, + }); + const gitStatusBroadcasterLayer = GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); const appLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -333,11 +347,8 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), - Layer.provide( - Layer.mock(GitManager)({ - ...options?.layers?.gitManager, - }), - ), + Layer.provide(gitManagerLayer), + Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -1260,6 +1271,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.succeed({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), status: () => Effect.succeed({ isRepo: true, @@ -1374,7 +1404,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), removeWorktree: () => Effect.void, createBranch: () => Effect.void, - checkoutBranch: () => Effect.void, + checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), initRepo: () => Effect.void, }, }, @@ -1382,16 +1412,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); - const status = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitStatus]({ cwd: "/tmp/repo" })), - ); - assert.equal(status.branch, "main"); - const pull = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); + const refreshedStatus = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + ), + ); + assert.equal(refreshedStatus.isRepo, true); + const stackedEvents = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitRunStackedAction]({ @@ -1494,11 +1526,62 @@ it.layer(NodeServices.layer)("server router seam", (it) => { cwd: "/tmp/repo", detail: "upstream missing", }); + let invalidationCalls = 0; + let statusCalls = 0; yield* buildAppUnderTest({ layers: { gitCore: { pullCurrentBranch: () => Effect.fail(gitError), }, + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + }, }, }); @@ -1510,9 +1593,289 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc git.runStackedAction errors after refreshing git status", () => + Effect.gen(function* () { + const gitError = new GitCommandError({ + operation: "commit", + command: "git commit", + cwd: "/tmp/repo", + detail: "nothing to commit", + }); + let invalidationCalls = 0; + let statusCalls = 0; + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + invalidationCalls += 1; + }), + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sync(() => { + statusCalls += 1; + return { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + status: () => + Effect.sync(() => { + statusCalls += 1; + return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + runStackedAction: () => Effect.fail(gitError), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect, Effect.result), + ), + ); + + assertFailure(result, gitError); + assert.equal(invalidationCalls, 0); + assert.equal(statusCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("completes websocket rpc git.pull before background git status refresh finishes", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitCore: { + pullCurrentBranch: () => + Effect.succeed({ + status: "pulled" as const, + branch: "main", + upstreamBranch: "origin/main", + }), + }, + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + ); + const elapsedMs = Date.now() - startedAt; + + assert.equal(result.status, "pulled"); + assertTrue(elapsedMs < 1_000); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect( + "completes websocket rpc git.runStackedAction before background git status refresh finishes", + () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const startedAt = Date.now(); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + const elapsedMs = Date.now() - startedAt; + + assertTrue(elapsedMs < 1_000); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "starts a background local git status refresh after a successful git.runStackedAction", + () => + Effect.gen(function* () { + const localRefreshStarted = yield* Deferred.make(); + + yield* buildAppUnderTest({ + layers: { + gitManager: { + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + localStatus: () => + Deferred.succeed(localRefreshStarted, undefined).pipe( + Effect.ignore, + Effect.andThen( + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + ), + ), + remoteStatus: () => + Effect.sleep(Duration.seconds(2)).pipe( + Effect.as({ + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + runStackedAction: () => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc123", + subject: "feat: demo", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.gitRunStackedAction]({ + actionId: "action-1", + cwd: "/tmp/repo", + action: "commit", + }).pipe(Stream.runCollect), + ), + ); + + yield* Deferred.await(localRefreshStarted); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc orchestration methods", () => Effect.gen(function* () { const now = new Date().toISOString(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..1d6f6ac66e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -30,6 +30,7 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; @@ -161,15 +162,16 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitManagerLayerLive = GitManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(RoutingTextGenerationLive), +); + const GitLayerLive = Layer.empty.pipe( - Layer.provideMerge( - GitManagerLive.pipe( - Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(RoutingTextGenerationLive), - ), - ), + Layer.provideMerge(GitManagerLayerLive), + Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), Layer.provideMerge(GitCoreLive), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..ca096bff33 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -27,6 +27,7 @@ import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuer import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore"; import { GitManager } from "./git/Services/GitManager"; +import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -56,6 +57,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( const open = yield* Open; const gitManager = yield* GitManager; const git = yield* GitCore; + const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -65,7 +67,6 @@ const WsRpcLayer = WsRpcGroup.toLayer( const workspaceEntries = yield* WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const serverCommandId = (tag: string) => CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); @@ -348,6 +349,11 @@ const WsRpcLayer = WsRpcGroup.toLayer( }; }); + const refreshGitStatus = (cwd: string) => + gitStatusBroadcaster + .refreshStatus(cwd) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); + return WsRpcGroup.of({ [ORCHESTRATION_WS_METHODS.getSnapshot]: (_input) => observeRpcEffect( @@ -559,14 +565,30 @@ const WsRpcLayer = WsRpcGroup.toLayer( observeRpcEffect(WS_METHODS.shellOpenInEditor, open.openInEditor(input), { "rpc.aggregate": "workspace", }), - [WS_METHODS.gitStatus]: (input) => - observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { + [WS_METHODS.subscribeGitStatus]: (input) => + observeRpcStream(WS_METHODS.subscribeGitStatus, gitStatusBroadcaster.streamStatus(input), { "rpc.aggregate": "git", }), + [WS_METHODS.gitRefreshStatus]: (input) => + observeRpcEffect( + WS_METHODS.gitRefreshStatus, + gitStatusBroadcaster.refreshStatus(input.cwd), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPull]: (input) => - observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitPull, + git.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, @@ -581,7 +603,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( .pipe( Effect.matchCauseEffect({ onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => Queue.end(queue).pipe(Effect.asVoid), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), }), ), ), @@ -594,7 +619,9 @@ const WsRpcLayer = WsRpcGroup.toLayer( [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager.preparePullRequestThread(input), + gitManager + .preparePullRequestThread(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitListBranches]: (input) => @@ -602,23 +629,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( "rpc.aggregate": "git", }), [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateWorktree, + git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitRemoveWorktree, + git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCreateBranch, + git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitCheckout, + Effect.scoped(git.checkoutBranch(input)).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitInit]: (input) => - observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), + observeRpcEffect( + WS_METHODS.gitInit, + git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..0d3ed6a659 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import type { GitBranch } from "@t3tools/contracts"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -14,12 +14,8 @@ import { useTransition, } from "react"; -import { - gitBranchSearchInfiniteQueryOptions, - gitQueryKeys, - gitStatusQueryOptions, - invalidateGitQueries, -} from "../lib/gitReactQuery"; +import { gitBranchSearchInfiniteQueryOptions, gitQueryKeys } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { @@ -89,7 +85,7 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const branchStatusQuery = useGitStatus(branchCwd); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); @@ -188,7 +184,9 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); + await queryClient + .invalidateQueries({ queryKey: gitQueryKeys.branches(branchCwd) }) + .catch(() => undefined); }); }; @@ -226,29 +224,26 @@ export function BranchToolbarBranchSelector({ onComposerFocusRequest?.(); runBranchAction(async () => { + const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); + const checkoutResult = await api.git.checkout({ + cwd: selectionTarget.checkoutCwd, + branch: branch.name, + }); + const nextBranchName = branch.isRemote + ? (checkoutResult.branch ?? selectedBranchName) + : selectedBranchName; + setOptimisticBranch(nextBranchName); + onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); } catch (error) { + setOptimisticBranch(previousBranch); toastManager.add({ type: "error", title: "Failed to checkout branch.", description: toBranchActionErrorMessage(error), }); - return; } - - let nextBranchName = selectedBranchName; - if (branch.isRemote) { - const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); - if (status?.branch) { - nextBranchName = status.branch; - } - } - - setOptimisticBranch(nextBranchName); - onSetThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); }); }; diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 84302386dc..7fc9f621b0 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -31,6 +31,8 @@ import { } from "../lib/terminalContext"; import { isMacPlatform } from "../lib/utils"; import { __resetNativeApiForTests } from "../nativeApi"; +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { getServerConfig } from "../rpc/serverState"; import { getRouter } from "../router"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; @@ -38,12 +40,19 @@ import { BrowserWsRpcHarness, type NormalizedWsRpcRequestBody } from "../../test import { estimateTimelineMessageHeight } from "./timelineHeight"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +vi.mock("../lib/gitStatusState", () => ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, +})); + const THREAD_ID = "thread-browser-test" as ThreadId; const UUID_ROUTE_RE = /^\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; const BASE_TIME_MS = Date.parse(NOW_ISO); -const ATTACHMENT_SVG = ""; +const ATTACHMENT_SVG = ""; interface TestFixture { snapshot: OrchestrationReadModel; @@ -93,9 +102,9 @@ const TEXT_VIEWPORT_MATRIX = [ { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ - DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { ...DEFAULT_VIEWPORT, attachmentTolerancePx: 120 }, + { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 120 }, + { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 120 }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -224,6 +233,7 @@ function createSnapshotForTargetUser(options: { name: `attachment-${attachmentIndex + 1}.png`, mimeType: "image/png", sizeBytes: 128, + previewUrl: `/attachments/attachment-${attachmentIndex + 1}`, })) : undefined; @@ -397,6 +407,22 @@ async function waitForWsClient(): Promise { (request) => request._tag === WS_METHODS.subscribeOrchestrationDomainEvents, ), ).toBe(true); + expect( + wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect(wsRequests.some((request) => request._tag === WS_METHODS.subscribeServerConfig)).toBe( + true, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForAppBootstrap(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + expect(useStore.getState().bootstrapComplete).toBe(true); }, { timeout: 8_000, interval: 16 }, ); @@ -651,24 +677,6 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { ], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], @@ -1044,10 +1052,17 @@ async function mountChatView(options: { }), ); - const screen = await render(, { - container: host, - }); + const screen = await render( + + + , + { + container: host, + }, + ); + await waitForWsClient(); + await waitForAppBootstrap(); await waitForLayout(); const cleanup = async () => { @@ -1138,7 +1153,7 @@ describe("ChatView timeline estimator parity (full app)", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); await setViewport(DEFAULT_VIEWPORT); localStorage.clear(); document.body.innerHTML = ""; @@ -1299,7 +1314,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createSnapshotForTargetUser({ targetMessageId, targetText: userText, - targetAttachmentCount: 3, + targetAttachmentCount: 2, }), }); @@ -1313,7 +1328,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { role: "user", text: userText, - attachments: [{ id: "attachment-1" }, { id: "attachment-2" }, { id: "attachment-3" }], + attachments: [{ id: "attachment-1" }, { id: "attachment-2" }], }, { timelineWidthPx: timelineWidthMeasuredPx }, ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f995bb4ce7..f8ae73cbce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const gitStatusQuery = useGitStatus(gitCwd); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index dc376a5b3d..ff216baed7 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { useGitStatus } from "~/lib/gitStatusState"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -189,7 +189,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const gitStatusQuery = useGitStatus(activeCwd ?? null); const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a975a65bbe..92874f7404 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -10,7 +10,7 @@ const BRANCH_NAME = "feature/toast-scope"; const { invalidateGitQueriesSpy, - invalidateGitStatusQuerySpy, + refreshGitStatusSpy, runStackedActionMutateAsyncSpy, setThreadBranchSpy, toastAddSpy, @@ -19,7 +19,7 @@ const { toastUpdateSpy, } = vi.hoisted(() => ({ invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), + refreshGitStatusSpy: vi.fn(() => Promise.resolve(null)), runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -57,21 +57,6 @@ vi.mock("@tanstack/react-query", async () => { }; }), useQuery: vi.fn((options: { queryKey?: string[] }) => { - if (options.queryKey?.[0] === "git-status") { - return { - data: { - branch: BRANCH_NAME, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 1, - behindCount: 0, - pr: null, - }, - error: null, - }; - } - if (options.queryKey?.[0] === "git-branches") { return { data: { @@ -110,7 +95,6 @@ vi.mock("~/editorPreferences", () => ({ })); vi.mock("~/lib/gitReactQuery", () => ({ - gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { pull: vi.fn(() => ["pull"]), @@ -118,9 +102,25 @@ vi.mock("~/lib/gitReactQuery", () => ({ }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), invalidateGitQueries: invalidateGitQueriesSpy, - invalidateGitStatusQuery: invalidateGitStatusQuerySpy, +})); + +vi.mock("~/lib/gitStatusState", () => ({ + refreshGitStatus: refreshGitStatusSpy, + resetGitStatusStateForTests: () => undefined, + useGitStatus: vi.fn(() => ({ + data: { + branch: BRANCH_NAME, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 1, + behindCount: 0, + pr: null, + }, + error: null, + isPending: false, + })), })); vi.mock("~/lib/utils", async () => { @@ -235,4 +235,42 @@ describe("GitActionsControl thread-scoped progress toast", () => { host.remove(); } }); + + it("debounces focus-driven git status refreshes", async () => { + vi.useFakeTimers(); + + const originalVisibilityState = Object.getOwnPropertyDescriptor(document, "visibilityState"); + let visibilityState: DocumentVisibilityState = "hidden"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibilityState, + }); + + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { + container: host, + }); + + try { + window.dispatchEvent(new Event("focus")); + visibilityState = "visible"; + document.dispatchEvent(new Event("visibilitychange")); + + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(249); + expect(refreshGitStatusSpy).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshGitStatusSpy).toHaveBeenCalledTimes(1); + expect(refreshGitStatusSpy).toHaveBeenCalledWith(GIT_CWD); + } finally { + if (originalVisibilityState) { + Object.defineProperty(document, "visibilityState", originalVisibilityState); + } + await screen.unmount(); + host.remove(); + } + }); }); diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..d641d4c36b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -5,7 +5,7 @@ import type { GitStatusResult, ThreadId, } from "@t3tools/contracts"; -import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; import { GitHubIcon } from "./Icons"; @@ -45,9 +45,8 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitStatusQuery, } from "~/lib/gitReactQuery"; +import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -92,6 +91,8 @@ interface RunGitActionWithToastInput { filePaths?: string[]; } +const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; + function formatElapsedDescription(startedAtMs: number | null): string | undefined { if (startedAtMs === null) { return undefined; @@ -275,7 +276,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { data: gitStatus = null, error: gitStatusError } = useGitStatus(gitCwd); // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; @@ -359,6 +360,39 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }; }, [updateActiveProgressToast]); + useEffect(() => { + if (gitCwd === null) { + return; + } + + let refreshTimeout: number | null = null; + const scheduleRefreshCurrentGitStatus = () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + refreshTimeout = window.setTimeout(() => { + refreshTimeout = null; + void refreshGitStatus(gitCwd).catch(() => undefined); + }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + scheduleRefreshCurrentGitStatus(); + } + }; + + window.addEventListener("focus", scheduleRefreshCurrentGitStatus); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + if (refreshTimeout !== null) { + window.clearTimeout(refreshTimeout); + } + window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [gitCwd]); + const openExistingPr = useCallback(async () => { const api = readNativeApi(); if (!api) { @@ -801,7 +835,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { - if (open) void invalidateGitStatusQuery(queryClient, gitCwd); + if (open) { + void refreshGitStatus(gitCwd).catch(() => undefined); + } }} > ({ + useGitStatus: () => ({ data: null, error: null, cause: null, isPending: false }), + useGitStatuses: () => new Map(), + refreshGitStatus: () => Promise.resolve(null), + resetGitStatusStateForTests: () => undefined, +})); + const THREAD_ID = "thread-kb-toast-test" as ThreadId; const PROJECT_ID = "project-1" as ProjectId; const NOW_ISO = "2026-03-04T12:00:00.000Z"; @@ -170,20 +179,6 @@ function resolveWsRpc(tag: string): unknown { branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } - if (tag === WS_METHODS.gitStatus) { - return { - isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, - }; - } if (tag === WS_METHODS.projectsSearchEntries) { return { entries: [], truncated: false }; } @@ -258,6 +253,29 @@ async function waitForNoToast(title: string): Promise { ); } +async function waitForInitialWsSubscriptions(): Promise { + await vi.waitFor( + () => { + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerLifecycle), + ).toBe(true); + expect( + rpcHarness.requests.some((request) => request._tag === WS_METHODS.subscribeServerConfig), + ).toBe(true); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function waitForServerConfigSnapshot(): Promise { + await vi.waitFor( + () => { + expect(getServerConfig()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); +} + async function mountApp(): Promise<{ cleanup: () => Promise }> { const host = document.createElement("div"); host.style.position = "fixed"; @@ -270,8 +288,15 @@ async function mountApp(): Promise<{ cleanup: () => Promise }> { const router = getRouter(createMemoryHistory({ initialEntries: [`/${THREAD_ID}`] })); - const screen = await render(, { container: host }); + const screen = await render( + + + , + { container: host }, + ); await waitForComposerEditor(); + await waitForInitialWsSubscriptions(); + await waitForServerConfigSnapshot(); return { cleanup: async () => { @@ -322,7 +347,7 @@ describe("Keybindings update toast", () => { return []; }, }); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; useComposerDraftStore.setState({ diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..d227e3a803 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -49,7 +49,6 @@ import { ThreadId, type GitStatusResult, } from "@t3tools/contracts"; -import { useQueries } from "@tanstack/react-query"; import { Link, useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { type SidebarProjectSortOrder, @@ -70,7 +69,7 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { useGitStatus } from "../lib/gitStatusState"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -245,8 +244,20 @@ function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { return null; } +function resolveThreadPr( + threadBranch: string | null, + gitStatus: GitStatusResult | null, +): ThreadPr | null { + if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + return null; + } + + return gitStatus.pr ?? null; +} + interface SidebarThreadRowProps { threadId: ThreadId; + projectCwd: string | null; orderedProjectThreadIds: readonly ThreadId[]; routeThreadId: ThreadId | null; selectedThreadIds: ReadonlySet; @@ -277,7 +288,6 @@ interface SidebarThreadRowProps { cancelRename: () => void; attemptArchiveThread: (threadId: ThreadId) => Promise; openPrLink: (event: MouseEvent, prUrl: string) => void; - pr: ThreadPr | null; } function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -287,6 +297,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { (state) => selectThreadTerminalState(state.terminalStateByThreadId, props.threadId).runningTerminalIds, ); + const gitCwd = thread?.worktreePath ?? props.projectCwd; + const gitStatus = useGitStatus(thread?.branch != null ? gitCwd : null); if (!thread) { return null; @@ -303,7 +315,8 @@ function SidebarThreadRow(props: SidebarThreadRowProps) { lastVisitedAt, }, }); - const prStatus = prStatusIndicator(props.pr); + const pr = resolveThreadPr(thread.branch, gitStatus.data); + const prStatus = prStatusIndicator(pr); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = props.confirmingArchiveThreadId === thread.id && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -762,54 +775,6 @@ export default function Sidebar() { }), [platform, routeTerminalOpen], ); - const threadGitTargets = useMemo( - () => - sidebarThreads.map((thread) => ({ - threadId: thread.id, - branch: thread.branch, - cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, - })), - [projectCwdById, sidebarThreads], - ); - const threadGitStatusCwds = useMemo( - () => [ - ...new Set( - threadGitTargets - .filter((target) => target.branch !== null) - .map((target) => target.cwd) - .filter((cwd): cwd is string => cwd !== null), - ), - ], - [threadGitTargets], - ); - const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), - staleTime: 30_000, - refetchInterval: 60_000, - })), - }); - const prByThreadId = useMemo(() => { - const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; - if (!cwd) continue; - const status = threadGitStatusQueries[index]?.data; - if (status) { - statusByCwd.set(cwd, status); - } - } - - const map = new Map(); - for (const target of threadGitTargets) { - const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; - const branchMatches = - target.branch !== null && status?.branch !== null && status?.branch === target.branch; - map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); - } - return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); - const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); event.stopPropagation(); @@ -1718,6 +1683,7 @@ export default function Sidebar() { ))} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8cb8b89684..e823569c13 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -385,7 +385,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {image.name} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 090f6f12ad..f0ea32d4be 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -30,16 +30,16 @@ function createBaseServerConfig(): ServerConfig { } describe("GeneralSettingsPanel observability", () => { - beforeEach(() => { + beforeEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); localStorage.clear(); document.body.innerHTML = ""; }); - afterEach(() => { + afterEach(async () => { resetServerStateForTests(); - __resetNativeApiForTests(); + await __resetNativeApiForTests(); document.body.innerHTML = ""; }); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index df2a21dab1..35c90d0120 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -30,7 +30,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }], }), - ).toBe(346); + ).toBe(234); expect( estimateTimelineMessageHeight({ @@ -38,7 +38,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }], }), - ).toBe(346); + ).toBe(234); }); it("adds a second attachment row for three or four user attachments", () => { @@ -48,7 +48,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }], }), - ).toBe(574); + ).toBe(350); expect( estimateTimelineMessageHeight({ @@ -56,7 +56,7 @@ describe("estimateTimelineMessageHeight", () => { text: "hello", attachments: [{ id: "1" }, { id: "2" }, { id: "3" }, { id: "4" }], }), - ).toBe(574); + ).toBe(350); }); it("does not cap long user message estimates", () => { diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 57a15f26ed..776fe9ad88 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -11,8 +11,9 @@ const ASSISTANT_LINE_HEIGHT_PX = 22.75; const ASSISTANT_BASE_HEIGHT_PX = 41; const USER_BASE_HEIGHT_PX = 96; const ATTACHMENTS_PER_ROW = 2; -// Attachment thumbnails render with `max-h-[220px]` plus ~8px row gap. -const USER_ATTACHMENT_ROW_HEIGHT_PX = 228; +// Full-app browser measurements land closer to a ~116px attachment row once +// the bubble shrinks to content width, so calibrate the estimate to that DOM. +const USER_ATTACHMENT_ROW_HEIGHT_PX = 116; const USER_BUBBLE_WIDTH_RATIO = 0.8; const USER_BUBBLE_HORIZONTAL_PADDING_PX = 32; const ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX = 8; diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index d260c2aee8..254b93eb6d 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -15,12 +15,9 @@ import type { GitListBranchesResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, gitMutationKeys, - gitQueryKeys, gitPreparePullRequestThreadMutationOptions, gitPullMutationOptions, gitRunStackedActionMutationOptions, - invalidateGitStatusQuery, - gitStatusQueryOptions, invalidateGitQueries, } from "./gitReactQuery"; @@ -84,7 +81,6 @@ describe("invalidateGitQueries", () => { it("can invalidate a single cwd without blasting other git query scopes", async () => { const queryClient = new QueryClient(); - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/a", @@ -92,7 +88,6 @@ describe("invalidateGitQueries", () => { }).queryKey, BRANCH_SEARCH_RESULT, ); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); queryClient.setQueryData( gitBranchSearchInfiniteQueryOptions({ cwd: "/repo/b", @@ -103,9 +98,6 @@ describe("invalidateGitQueries", () => { await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -114,9 +106,6 @@ describe("invalidateGitQueries", () => { }).queryKey, )?.isInvalidated, ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); expect( queryClient.getQueryState( gitBranchSearchInfiniteQueryOptions({ @@ -127,21 +116,3 @@ describe("invalidateGitQueries", () => { ).toBe(false); }); }); - -describe("invalidateGitStatusQuery", () => { - it("invalidates only status for the selected cwd", async () => { - const queryClient = new QueryClient(); - - queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); - - await invalidateGitStatusQuery(queryClient, "/repo/a"); - - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(true); - expect( - queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, - ).toBe(false); - }); -}); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index bfac623db9..a2611ebe25 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -12,15 +12,12 @@ import { import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; -const GIT_STATUS_STALE_TIME_MS = 5_000; -const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, branchSearch: (cwd: string | null, query: string) => ["git", "branches", cwd, "search", query] as const, @@ -38,37 +35,18 @@ export const gitMutationKeys = { export function invalidateGitQueries(queryClient: QueryClient, input?: { cwd?: string | null }) { const cwd = input?.cwd ?? null; if (cwd !== null) { - return Promise.all([ - queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }), - queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }), - ]); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); } -export function invalidateGitStatusQuery(queryClient: QueryClient, cwd: string | null) { +function invalidateGitBranchQueries(queryClient: QueryClient, cwd: string | null) { if (cwd === null) { return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.status(cwd) }); -} - -export function gitStatusQueryOptions(cwd: string | null) { - return queryOptions({ - queryKey: gitQueryKeys.status(cwd), - queryFn: async () => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git status is unavailable."); - return api.git.status({ cwd }); - }, - enabled: cwd !== null, - staleTime: GIT_STATUS_STALE_TIME_MS, - refetchOnWindowFocus: "always", - refetchOnReconnect: "always", - refetchInterval: GIT_STATUS_REFETCH_INTERVAL_MS, - }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(cwd) }); } export function gitBranchSearchInfiniteQueryOptions(input: { @@ -128,8 +106,8 @@ export function gitInitMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git init is unavailable."); return api.git.init({ cwd: input.cwd }); }, - onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + onSettled: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -145,8 +123,8 @@ export function gitCheckoutMutationOptions(input: { if (!input.cwd) throw new Error("Git checkout is unavailable."); return api.git.checkout({ cwd: input.cwd, branch }); }, - onSuccess: async () => { - await invalidateGitQueries(input.queryClient); + onSettled: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -175,18 +153,18 @@ export function gitRunStackedActionMutationOptions(input: { if (!input.cwd) throw new Error("Git action is unavailable."); return getWsRpcClient().git.runStackedAction( { + action, actionId, cwd: input.cwd, - action, ...(commitMessage ? { commitMessage } : {}), - ...(featureBranch ? { featureBranch } : {}), - ...(filePaths ? { filePaths } : {}), + ...(featureBranch ? { featureBranch: true } : {}), + ...(filePaths && filePaths.length > 0 ? { filePaths } : {}), }, ...(onProgress ? [{ onProgress }] : []), ); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } @@ -199,31 +177,19 @@ export function gitPullMutationOptions(input: { cwd: string | null; queryClient: if (!input.cwd) throw new Error("Git pull is unavailable."); return api.git.pull({ cwd: input.cwd }); }, - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ - cwd, - branch, - newBranch, - path, - }: { - cwd: string; - branch: string; - newBranch: string; - path?: string | null; - }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree creation is unavailable."); - return api.git.createWorktree({ cwd, branch, newBranch, path: path ?? null }); - }, mutationKey: ["git", "mutation", "create-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["createWorktree"]>[0], + ) => ensureNativeApi().git.createWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); @@ -231,13 +197,11 @@ export function gitCreateWorktreeMutationOptions(input: { queryClient: QueryClie export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClient }) { return mutationOptions({ - mutationFn: async ({ cwd, path, force }: { cwd: string; path: string; force?: boolean }) => { - const api = ensureNativeApi(); - if (!cwd) throw new Error("Git worktree removal is unavailable."); - return api.git.removeWorktree({ cwd, path, force }); - }, mutationKey: ["git", "mutation", "remove-worktree"] as const, - onSettled: async () => { + mutationFn: ( + args: Parameters["git"]["removeWorktree"]>[0], + ) => ensureNativeApi().git.removeWorktree(args), + onSuccess: async () => { await invalidateGitQueries(input.queryClient); }, }); @@ -248,11 +212,8 @@ export function gitPreparePullRequestThreadMutationOptions(input: { queryClient: QueryClient; }) { return mutationOptions({ - mutationFn: async ({ - reference, - mode, - threadId, - }: { + mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), + mutationFn: async (args: { reference: string; mode: "local" | "worktree"; threadId?: ThreadId; @@ -261,14 +222,13 @@ export function gitPreparePullRequestThreadMutationOptions(input: { if (!input.cwd) throw new Error("Pull request thread preparation is unavailable."); return api.git.preparePullRequestThread({ cwd: input.cwd, - reference, - mode, - ...(threadId ? { threadId } : {}), + reference: args.reference, + mode: args.mode, + ...(args.threadId ? { threadId: args.threadId } : {}), }); }, - mutationKey: gitMutationKeys.preparePullRequestThread(input.cwd), - onSettled: async () => { - await invalidateGitQueries(input.queryClient); + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.cwd); }, }); } diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts new file mode 100644 index 0000000000..757130db9b --- /dev/null +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -0,0 +1,112 @@ +import type { GitStatusResult } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + getGitStatusSnapshot, + resetGitStatusStateForTests, + refreshGitStatus, + watchGitStatus, +} from "./gitStatusState"; + +function registerListener(listeners: Set<(event: T) => void>, listener: (event: T) => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); + +const BASE_STATUS: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/push-status", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const gitClient = { + refreshStatus: vi.fn(async (input: { cwd: string }) => ({ + ...BASE_STATUS, + branch: `${input.cwd}-refreshed`, + })), + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), +}; + +function emitGitStatus(event: GitStatusResult) { + for (const listener of gitStatusListeners) { + listener(event); + } +} + +afterEach(() => { + gitStatusListeners.clear(); + gitClient.onStatus.mockClear(); + gitClient.refreshStatus.mockClear(); + resetGitStatusStateForTests(); +}); + +describe("gitStatusState", () => { + it("starts fresh cwd state in a pending state", () => { + expect(getGitStatusSnapshot("/fresh")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); + }); + + it("shares one live subscription per cwd and updates the per-cwd atom snapshot", () => { + const releaseA = watchGitStatus("/repo", gitClient); + const releaseB = watchGitStatus("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: null, + error: null, + cause: null, + isPending: true, + }); + + emitGitStatus(BASE_STATUS); + + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: false, + }); + + releaseA(); + expect(gitStatusListeners.size).toBe(1); + + releaseB(); + expect(gitStatusListeners.size).toBe(0); + }); + + it("refreshes git status through the unary RPC without restarting the stream", async () => { + const release = watchGitStatus("/repo", gitClient); + + emitGitStatus(BASE_STATUS); + const refreshed = await refreshGitStatus("/repo", gitClient); + + expect(gitClient.onStatus).toHaveBeenCalledOnce(); + expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); + expect(getGitStatusSnapshot("/repo")).toEqual({ + data: BASE_STATUS, + error: null, + cause: null, + isPending: false, + }); + + release(); + }); +}); diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts new file mode 100644 index 0000000000..1c1cf00864 --- /dev/null +++ b/apps/web/src/lib/gitStatusState.ts @@ -0,0 +1,223 @@ +import { useAtomValue } from "@effect/atom-react"; +import { type GitManagerServiceError, type GitStatusResult } from "@t3tools/contracts"; +import { Cause } from "effect"; +import { Atom } from "effect/unstable/reactivity"; +import { useEffect } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { getWsRpcClient, type WsRpcClient } from "../wsRpcClient"; + +export type GitStatusStreamError = GitManagerServiceError; + +export interface GitStatusState { + readonly data: GitStatusResult | null; + readonly error: GitStatusStreamError | null; + readonly cause: Cause.Cause | null; + readonly isPending: boolean; +} + +type GitStatusClient = Pick; + +interface WatchedGitStatus { + refCount: number; + unsubscribe: () => void; +} + +const EMPTY_GIT_STATUS_STATE = Object.freeze({ + data: null, + error: null, + cause: null, + isPending: false, +}); +const INITIAL_GIT_STATUS_STATE = Object.freeze({ + ...EMPTY_GIT_STATUS_STATE, + isPending: true, +}); +const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel("git-status:null"), +); + +const NOOP: () => void = () => undefined; +const watchedGitStatuses = new Map(); +const knownGitStatusCwds = new Set(); +const gitStatusRefreshInFlight = new Map>(); +const gitStatusLastRefreshAtByCwd = new Map(); + +const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; + +let sharedGitStatusClient: GitStatusClient | null = null; + +const gitStatusStateAtom = Atom.family((cwd: string) => { + knownGitStatusCwds.add(cwd); + return Atom.make(INITIAL_GIT_STATUS_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`git-status:${cwd}`), + ); +}); + +export function getGitStatusSnapshot(cwd: string | null): GitStatusState { + if (cwd === null) { + return EMPTY_GIT_STATUS_STATE; + } + + return appAtomRegistry.get(gitStatusStateAtom(cwd)); +} + +export function watchGitStatus( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): () => void { + if (cwd === null) { + return NOOP; + } + + ensureGitStatusClient(client); + + const watched = watchedGitStatuses.get(cwd); + if (watched) { + watched.refCount += 1; + return () => unwatchGitStatus(cwd); + } + + watchedGitStatuses.set(cwd, { + refCount: 1, + unsubscribe: subscribeToGitStatus(cwd), + }); + + return () => unwatchGitStatus(cwd); +} + +export function refreshGitStatus( + cwd: string | null, + client: GitStatusClient = getWsRpcClient().git, +): Promise { + if (cwd === null) { + return Promise.resolve(null); + } + + ensureGitStatusClient(client); + + const currentInFlight = gitStatusRefreshInFlight.get(cwd); + if (currentInFlight) { + return currentInFlight; + } + + const lastRequestedAt = gitStatusLastRefreshAtByCwd.get(cwd) ?? 0; + if (Date.now() - lastRequestedAt < GIT_STATUS_REFRESH_DEBOUNCE_MS) { + return Promise.resolve(getGitStatusSnapshot(cwd).data); + } + + gitStatusLastRefreshAtByCwd.set(cwd, Date.now()); + const refreshPromise = client.refreshStatus({ cwd }).finally(() => { + gitStatusRefreshInFlight.delete(cwd); + }); + gitStatusRefreshInFlight.set(cwd, refreshPromise); + return refreshPromise; +} + +export function resetGitStatusStateForTests(): void { + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); + } + watchedGitStatuses.clear(); + gitStatusRefreshInFlight.clear(); + gitStatusLastRefreshAtByCwd.clear(); + sharedGitStatusClient = null; + + for (const cwd of knownGitStatusCwds) { + appAtomRegistry.set(gitStatusStateAtom(cwd), INITIAL_GIT_STATUS_STATE); + } + knownGitStatusCwds.clear(); +} + +export function useGitStatus(cwd: string | null): GitStatusState { + useEffect(() => watchGitStatus(cwd), [cwd]); + + const state = useAtomValue(cwd !== null ? gitStatusStateAtom(cwd) : EMPTY_GIT_STATUS_ATOM); + return cwd === null ? EMPTY_GIT_STATUS_STATE : state; +} + +function ensureGitStatusClient(client: GitStatusClient): void { + if (sharedGitStatusClient === client) { + return; + } + + if (sharedGitStatusClient !== null) { + resetLiveGitStatusSubscriptions(); + } + + sharedGitStatusClient = client; +} + +function resetLiveGitStatusSubscriptions(): void { + for (const watched of watchedGitStatuses.values()) { + watched.unsubscribe(); + } + watchedGitStatuses.clear(); +} + +function unwatchGitStatus(cwd: string): void { + const watched = watchedGitStatuses.get(cwd); + if (!watched) { + return; + } + + watched.refCount -= 1; + if (watched.refCount > 0) { + return; + } + + watched.unsubscribe(); + watchedGitStatuses.delete(cwd); +} + +function subscribeToGitStatus(cwd: string): () => void { + const client = sharedGitStatusClient; + if (!client) { + return NOOP; + } + + markGitStatusPending(cwd); + return client.onStatus( + { cwd }, + (status) => { + appAtomRegistry.set(gitStatusStateAtom(cwd), { + data: status, + error: null, + cause: null, + isPending: false, + }); + }, + { + onResubscribe: () => { + markGitStatusPending(cwd); + }, + }, + ); +} + +function markGitStatusPending(cwd: string): void { + const atom = gitStatusStateAtom(cwd); + const current = appAtomRegistry.get(atom); + const next = + current.data === null + ? INITIAL_GIT_STATUS_STATE + : { + ...current, + error: null, + cause: null, + isPending: true, + }; + + if ( + current.data === next.data && + current.error === next.error && + current.cause === next.cause && + current.isPending === next.isPending + ) { + return; + } + + appAtomRegistry.set(atom, next); +} diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts index 9f528b6342..f9b0607347 100644 --- a/apps/web/src/nativeApi.ts +++ b/apps/web/src/nativeApi.ts @@ -25,7 +25,7 @@ export function ensureNativeApi(): NativeApi { return api; } -export function __resetNativeApiForTests() { +export async function __resetNativeApiForTests() { cachedApi = undefined; - __resetWsNativeApiForTests(); + await __resetWsNativeApiForTests(); } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index cfa6ca6942..ae56f85991 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EventId, + type GitStatusResult, ProjectId, type OrchestrationEvent, type ServerConfig, @@ -31,6 +32,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const orchestrationEventListeners = new Set<(event: OrchestrationEvent) => void>(); +const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -54,7 +56,10 @@ const rpcClientMock = { }, git: { pull: vi.fn(), - status: vi.fn(), + refreshStatus: vi.fn(), + onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + registerListener(gitStatusListeners, listener), + ), runStackedAction: vi.fn(), listBranches: vi.fn(), createWorktree: vi.fn(), @@ -168,12 +173,26 @@ const baseServerConfig: ServerConfig = { settings: DEFAULT_SERVER_SETTINGS, }; +const baseGitStatus: GitStatusResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/streamed", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); showContextMenuFallbackMock.mockReset(); terminalEventListeners.clear(); orchestrationEventListeners.clear(); + gitStatusListeners.clear(); Reflect.deleteProperty(getWindowForTest(), "desktopBridge"); }); @@ -243,6 +262,32 @@ describe("wsNativeApi", () => { expect(onDomainEvent).toHaveBeenCalledWith(orchestrationEvent); }); + it("forwards git status stream events", async () => { + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + const onStatus = vi.fn(); + + api.git.onStatus({ cwd: "/repo" }, onStatus); + + const gitStatus = baseGitStatus; + emitEvent(gitStatusListeners, gitStatus); + + expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); + expect(onStatus).toHaveBeenCalledWith(gitStatus); + }); + + it("forwards git status refreshes directly to the RPC client", async () => { + rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + + await api.git.refreshStatus({ cwd: "/repo" }); + + expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + }); + it("forwards orchestration stream subscription options to the RPC client", async () => { const { createWsNativeApi } = await import("./wsNativeApi"); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 99045dbf07..3cfb976e09 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,6 +1,8 @@ import { type ContextMenuItem, type NativeApi } from "@t3tools/contracts"; +import { resetGitStatusStateForTests } from "./lib/gitStatusState"; import { showContextMenuFallback } from "./contextMenuFallback"; +import { __resetWsRpcAtomClientForTests } from "./rpc/client"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; import { resetServerStateForTests } from "./rpc/serverState"; import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; @@ -8,9 +10,11 @@ import { __resetWsRpcClientForTests, getWsRpcClient } from "./wsRpcClient"; let instance: { api: NativeApi } | null = null; -export function __resetWsNativeApiForTests() { +export async function __resetWsNativeApiForTests() { instance = null; - __resetWsRpcClientForTests(); + await __resetWsRpcAtomClientForTests(); + await __resetWsRpcClientForTests(); + resetGitStatusStateForTests(); resetRequestLatencyStateForTests(); resetServerStateForTests(); resetWsConnectionStateForTests(); @@ -65,7 +69,8 @@ export function createWsNativeApi(): NativeApi { }, git: { pull: rpcClient.git.pull, - status: rpcClient.git.status, + refreshStatus: rpcClient.git.refreshStatus, + onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), listBranches: rpcClient.git.listBranches, createWorktree: rpcClient.git.createWorktree, removeWorktree: rpcClient.git.removeWorktree, diff --git a/apps/web/src/wsRpcClient.test.ts b/apps/web/src/wsRpcClient.test.ts new file mode 100644 index 0000000000..36467eed9a --- /dev/null +++ b/apps/web/src/wsRpcClient.test.ts @@ -0,0 +1,94 @@ +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; + +import { createWsRpcClient } from "./wsRpcClient"; +import { type WsTransport } from "./wsTransport"; + +const baseLocalStatus: GitStatusLocalResult = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +describe("wsRpcClient", () => { + it("reduces git status stream events into flat status snapshots", () => { + const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { + for (const event of [ + { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + }, + { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + }, + { + _tag: "localUpdated", + local: { + ...baseLocalStatus, + hasWorkingTreeChanges: true, + }, + }, + ] satisfies GitStatusStreamEvent[]) { + listener(event as TValue); + } + return () => undefined; + }); + + const transport = { + dispose: vi.fn(async () => undefined), + reconnect: vi.fn(async () => undefined), + request: vi.fn(), + requestStream: vi.fn(), + subscribe, + } satisfies Pick< + WsTransport, + "dispose" | "reconnect" | "request" | "requestStream" | "subscribe" + >; + + const client = createWsRpcClient(transport as unknown as WsTransport); + const listener = vi.fn(); + + client.git.onStatus({ cwd: "/repo" }, listener); + + expect(listener.mock.calls).toEqual([ + [ + { + ...baseLocalStatus, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + }, + ], + [ + { + ...baseLocalStatus, + ...baseRemoteStatus, + hasWorkingTreeChanges: true, + }, + ], + ]); + }); +}); diff --git a/apps/web/src/wsRpcClient.ts b/apps/web/src/wsRpcClient.ts index 1d411aa1b9..997b83d2d7 100644 --- a/apps/web/src/wsRpcClient.ts +++ b/apps/web/src/wsRpcClient.ts @@ -2,11 +2,14 @@ import { type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, + type GitStatusResult, + type GitStatusStreamEvent, type NativeApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, WS_METHODS, } from "@t3tools/contracts"; +import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; import { Effect, Stream } from "effect"; import { type WsRpcProtocolClient } from "./rpc/protocol"; @@ -64,7 +67,12 @@ export interface WsRpcClient { }; readonly git: { readonly pull: RpcUnaryMethod; - readonly status: RpcUnaryMethod; + readonly refreshStatus: RpcUnaryMethod; + readonly onStatus: ( + input: RpcInput, + listener: (status: GitStatusResult) => void, + options?: StreamSubscriptionOptions, + ) => () => void; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, @@ -149,7 +157,19 @@ export function createWsRpcClient(transport = new WsTransport()): WsRpcClient { }, git: { pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), - status: (input) => transport.request((client) => client[WS_METHODS.gitStatus](input)), + refreshStatus: (input) => + transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), + onStatus: (input, listener, options) => { + let current: GitStatusResult | null = null; + return transport.subscribe( + (client) => client[WS_METHODS.subscribeGitStatus](input), + (event: GitStatusStreamEvent) => { + current = applyGitStatusStreamEvent(current, event); + listener(current); + }, + options, + ); + }, runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index dcb6dc7252..aeae92d101 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -24,6 +24,7 @@ interface BrowserWsRpcHarnessOptions { const STREAM_METHODS = new Set([ WS_METHODS.gitRunStackedAction, + WS_METHODS.subscribeGitStatus, WS_METHODS.subscribeOrchestrationDomainEvents, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, @@ -108,7 +109,7 @@ export class BrowserWsRpcHarness { async onMessage(rawData: string): Promise { const server = await this.serverReady; if (!server) { - throw new Error("RPC test server is not connected"); + return; } const messages = this.parser.decode(rawData); for (const message of messages) { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index f28a74f6de..1703251e17 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -44,6 +44,14 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); +export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; +export const GitHostingProvider = Schema.Struct({ + kind: GitHostingProviderKind, + name: TrimmedNonEmptyStringSchema, + baseUrl: Schema.String, +}); +export type GitHostingProvider = typeof GitHostingProvider.Type; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -186,8 +194,9 @@ const GitStatusPr = Schema.Struct({ state: GitStatusPrState, }); -export const GitStatusResult = Schema.Struct({ +const GitStatusLocalShape = { isRepo: Schema.Boolean, + hostingProvider: Schema.optional(GitHostingProvider), hasOriginRemote: Schema.Boolean, isDefaultBranch: Schema.Boolean, branch: Schema.NullOr(TrimmedNonEmptyStringSchema), @@ -203,13 +212,41 @@ export const GitStatusResult = Schema.Struct({ insertions: NonNegativeInt, deletions: NonNegativeInt, }), +}; + +const GitStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, pr: Schema.NullOr(GitStatusPr), +}; + +export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); +export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; + +export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); +export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; + +export const GitStatusResult = Schema.Struct({ + ...GitStatusLocalShape, + ...GitStatusRemoteShape, }); export type GitStatusResult = typeof GitStatusResult.Type; +export const GitStatusStreamEvent = Schema.Union([ + Schema.TaggedStruct("snapshot", { + local: GitStatusLocalResult, + remote: Schema.NullOr(GitStatusRemoteResult), + }), + Schema.TaggedStruct("localUpdated", { + local: GitStatusLocalResult, + }), + Schema.TaggedStruct("remoteUpdated", { + remote: Schema.NullOr(GitStatusRemoteResult), + }), +]); +export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; + export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, @@ -236,6 +273,11 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; +export const GitCheckoutResult = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitCheckoutResult = typeof GitCheckoutResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, branch: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 57a1c4c3dc..93886c746d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,5 +1,6 @@ import type { GitCheckoutInput, + GitCheckoutResult, GitCreateBranchInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -149,7 +150,7 @@ export interface NativeApi { createWorktree: (input: GitCreateWorktreeInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; - checkout: (input: GitCheckoutInput) => Promise; + checkout: (input: GitCheckoutInput) => Promise; init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( @@ -157,7 +158,14 @@ export interface NativeApi { ) => Promise; // Stacked action API pull: (input: GitPullInput) => Promise; - status: (input: GitStatusInput) => Promise; + refreshStatus: (input: GitStatusInput) => Promise; + onStatus: ( + input: GitStatusInput, + callback: (status: GitStatusResult) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; }; contextMenu: { show: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 34968e66ec..e7fcee847f 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -6,6 +6,7 @@ import { OpenError, OpenInEditorInput } from "./editor"; import { GitActionProgressEvent, GitCheckoutInput, + GitCheckoutResult, GitCommandError, GitCreateBranchInput, GitCreateWorktreeInput, @@ -24,6 +25,7 @@ import { GitRunStackedActionInput, GitStatusInput, GitStatusResult, + GitStatusStreamEvent, } from "./git"; import { KeybindingsConfigError } from "./keybindings"; import { @@ -83,7 +85,7 @@ export const WS_METHODS = { // Git methods gitPull: "git.pull", - gitStatus: "git.status", + gitRefreshStatus: "git.refreshStatus", gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", @@ -110,6 +112,7 @@ export const WS_METHODS = { serverUpdateSettings: "server.updateSettings", // Streaming subscriptions + subscribeGitStatus: "subscribeGitStatus", subscribeOrchestrationDomainEvents: "subscribeOrchestrationDomainEvents", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", @@ -162,10 +165,11 @@ export const WsShellOpenInEditorRpc = Rpc.make(WS_METHODS.shellOpenInEditor, { error: OpenError, }); -export const WsGitStatusRpc = Rpc.make(WS_METHODS.gitStatus, { +export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { payload: GitStatusInput, - success: GitStatusResult, + success: GitStatusStreamEvent, error: GitManagerServiceError, + stream: true, }); export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { @@ -174,6 +178,12 @@ export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { error: GitCommandError, }); +export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { + payload: GitStatusInput, + success: GitStatusResult, + error: GitManagerServiceError, +}); + export const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -217,6 +227,7 @@ export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { payload: GitCheckoutInput, + success: GitCheckoutResult, error: GitCommandError, }); @@ -330,8 +341,9 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, - WsGitStatusRpc, + WsSubscribeGitStatusRpc, WsGitPullRpc, + WsGitRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts new file mode 100644 index 0000000000..7beb7a75de --- /dev/null +++ b/packages/shared/src/git.test.ts @@ -0,0 +1,67 @@ +import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { applyGitStatusStreamEvent } from "./git"; + +describe("applyGitStatusStreamEvent", () => { + it("treats a remote-only update as a repository when local state is missing", () => { + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); + + it("preserves local-only fields when applying a remote update", () => { + const current: GitStatusResult = { + isRepo: true, + hostingProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + const remote: GitStatusRemoteResult = { + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }; + + expect(applyGitStatusStreamEvent(current, { _tag: "remoteUpdated", remote })).toEqual({ + ...current, + hasUpstream: true, + aheadCount: 2, + behindCount: 1, + pr: null, + }); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 90bc655a76..16171315b7 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,4 +1,11 @@ -import type { GitBranch } from "@t3tools/contracts"; +import type { + GitBranch, + GitHostingProvider, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} from "@t3tools/contracts"; /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. @@ -119,3 +126,133 @@ export function dedupeRemoteBranchesWithLocalMatches( return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } + +function parseGitRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + if (separatorIndex <= 0) { + return null; + } + return hostWithPath.slice(0, separatorIndex).toLowerCase(); + } + + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +function toBaseUrl(host: string): string { + return `https://${host}`; +} + +function isGitHubHost(host: string): boolean { + return host === "github.com" || host.includes("github"); +} + +function isGitLabHost(host: string): boolean { + return host === "gitlab.com" || host.includes("gitlab"); +} + +export function detectGitHostingProviderFromRemoteUrl( + remoteUrl: string, +): GitHostingProvider | null { + const host = parseGitRemoteHost(remoteUrl); + if (!host) { + return null; + } + + if (isGitHubHost(host)) { + return { + kind: "github", + name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitLabHost(host)) { + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + return { + kind: "unknown", + name: host, + baseUrl: toBaseUrl(host), + }; +} + +const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +export function mergeGitStatusParts( + local: GitStatusLocalResult, + remote: GitStatusRemoteResult | null, +): GitStatusResult { + return { + ...local, + ...(remote ?? EMPTY_GIT_STATUS_REMOTE), + }; +} + +function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { + return { + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pr: status.pr, + }; +} + +function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { + return { + isRepo: status.isRepo, + ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), + hasOriginRemote: status.hasOriginRemote, + isDefaultBranch: status.isDefaultBranch, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + workingTree: status.workingTree, + }; +} + +export function applyGitStatusStreamEvent( + current: GitStatusResult | null, + event: GitStatusStreamEvent, +): GitStatusResult { + switch (event._tag) { + case "snapshot": + return mergeGitStatusParts(event.local, event.remote); + case "localUpdated": + return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); + case "remoteUpdated": + if (current === null) { + return mergeGitStatusParts( + { + isRepo: true, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + event.remote, + ); + } + return mergeGitStatusParts(toLocalStatusPart(current), event.remote); + } +}