diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 0c0da1f847d5..10ea02ae52dc 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -18,6 +18,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "./atom-registry"; +import { environmentCatalog } from "../connection/catalog"; import { orchestrationEnvironment } from "./orchestration"; import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; @@ -138,9 +139,13 @@ export function useBranches(input: { export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; + const connection = useEnvironmentQuery( + target.environmentId === null ? null : environmentCatalog.stateAtom(target.environmentId), + ).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; const targetKey = target.environmentId !== null && target.cwd !== null - ? JSON.stringify([target.environmentId, target.cwd, query]) + ? JSON.stringify([target.environmentId, target.cwd, query, connectionGeneration]) : null; const [pagination, setPagination] = useState<{ readonly targetKey: string | null; diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..f46c578631e6 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -63,6 +63,16 @@ describe("RPC authorization scopes", () => { ); }); + it("allows Git history reads with the orchestration read scope", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.vcsListHistoryRefs)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.vcsGetHistory)).toBe(AuthOrchestrationReadScope); + expect(requiredScopeForRpcMethod(WS_METHODS.vcsGetCommitDetails)).toBe( + AuthOrchestrationReadScope, + ); + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7080ba2bcf9e..3ba788ee6e78 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -136,6 +136,11 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.gitResolvePullRequest]: AuthOrchestrationOperateScope, [WS_METHODS.gitPreparePullRequestThread]: AuthOrchestrationOperateScope, [WS_METHODS.vcsListRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsListHistoryRefs]: AuthOrchestrationReadScope, + [WS_METHODS.vcsGetHistory]: AuthOrchestrationReadScope, + [WS_METHODS.vcsGetCommitDetails]: AuthOrchestrationReadScope, + [WS_METHODS.vcsListCommitFiles]: AuthOrchestrationReadScope, + [WS_METHODS.vcsGetCommitDiff]: AuthOrchestrationReadScope, [WS_METHODS.vcsCreateWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsRemoveWorktree]: AuthOrchestrationOperateScope, [WS_METHODS.vcsCreateRef]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a674a25c1ec8..5e66c56ea0ae 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -166,6 +166,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); + expect(second.capabilities.gitHistory).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2931e6f304b7..66f3c0b43a48 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -220,6 +220,7 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, issues: true, + gitHistory: true, threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..de38bb83b967 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -9,8 +11,11 @@ import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +const testCommitHash = "0123456789abcdef0123456789abcdef01234567"; + function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; + readonly git?: Partial; }) { return GitWorkflowService.layer.pipe( Layer.provide( @@ -18,7 +23,7 @@ function makeLayer(input: { detect: input.detect, }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(input.git ?? {})), Layer.provide(Layer.mock(GitManager.GitManager)({})), ); } @@ -135,6 +140,224 @@ describe("GitWorkflowService", () => { ), ); + it.effect("returns non-repository History results when no VCS repository is detected", () => { + const detect = vi.fn(() => Effect.succeed(null)); + const listHistoryRefs = vi.fn(() => + Effect.succeed({ + refs: [], + currentRef: null, + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }), + ); + const getHistory = vi.fn(() => + Effect.succeed({ + commits: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }), + ); + const getCommitDetails = vi.fn(() => Effect.succeed({ commit: null, isRepo: false })); + const listCommitFiles = vi.fn(() => + Effect.succeed({ files: [], isRepo: false, nextCursor: null, hasMore: false, capped: false }), + ); + const getCommitDiff = vi.fn(() => + Effect.succeed({ diff: "", truncated: false, isRepo: false }), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const refs = yield* workflow.listHistoryRefs({ cwd: "/not-a-repo" }); + const history = yield* workflow.getHistory({ cwd: "/not-a-repo" }); + const details = yield* workflow.getCommitDetails({ + cwd: "/not-a-repo", + hash: testCommitHash, + }); + const files = yield* workflow.listCommitFiles({ + cwd: "/not-a-repo", + hash: testCommitHash, + }); + const diff = yield* workflow.getCommitDiff({ + cwd: "/not-a-repo", + hash: testCommitHash, + }); + + assert.deepStrictEqual(refs, { + refs: [], + currentRef: null, + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }); + assert.deepStrictEqual(history, { + commits: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }); + assert.deepStrictEqual(details, { commit: null, isRepo: false }); + assert.deepStrictEqual(files, { + files: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }); + assert.deepStrictEqual(diff, { diff: "", truncated: false, isRepo: false }); + assert.equal(detect.mock.calls.length, 5); + assert.equal(listHistoryRefs.mock.calls.length, 0); + assert.equal(getHistory.mock.calls.length, 0); + assert.equal(getCommitDetails.mock.calls.length, 0); + assert.equal(listCommitFiles.mock.calls.length, 0); + assert.equal(getCommitDiff.mock.calls.length, 0); + }).pipe( + Effect.provide( + makeLayer({ + detect, + git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff }, + }), + ), + ); + }); + + it.effect("delegates each History operation to GitVcsDriver once for Git repositories", () => { + const detect = vi.fn(() => + Effect.succeed({ + kind: "git" as const, + repository: { + kind: "git" as const, + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as never, + } satisfies VcsDriverRegistry.VcsDriverHandle), + ); + const listHistoryRefs = vi.fn(() => Effect.die("unused")); + const getHistory = vi.fn(() => Effect.die("unused")); + const getCommitDetails = vi.fn(() => Effect.die("unused")); + const listCommitFiles = vi.fn(() => Effect.die("unused")); + const getCommitDiff = vi.fn(() => Effect.die("unused")); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const historyInput = { cwd: "/repo" }; + const commitInput = { + cwd: "/repo", + hash: testCommitHash, + }; + + yield* workflow.listHistoryRefs(historyInput).pipe(Effect.exit); + yield* workflow.getHistory(historyInput).pipe(Effect.exit); + yield* workflow.getCommitDetails(commitInput).pipe(Effect.exit); + yield* workflow.listCommitFiles(commitInput).pipe(Effect.exit); + yield* workflow.getCommitDiff(commitInput).pipe(Effect.exit); + + assert.deepStrictEqual(listHistoryRefs.mock.calls, [[historyInput]]); + assert.deepStrictEqual(getHistory.mock.calls, [[historyInput]]); + assert.deepStrictEqual(getCommitDetails.mock.calls, [[commitInput]]); + assert.deepStrictEqual(listCommitFiles.mock.calls, [[commitInput]]); + assert.deepStrictEqual(getCommitDiff.mock.calls, [[commitInput]]); + assert.equal(detect.mock.calls.length, 5); + }).pipe( + Effect.provide( + makeLayer({ + detect, + git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff }, + }), + ), + ); + }); + + it.effect( + "returns non-repository History results for an explicitly configured jj repository", + () => { + const detect = vi.fn(() => + Effect.succeed({ + kind: "jj" as const, + repository: { + kind: "jj" as const, + rootPath: "/jj-repo", + metadataPath: "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/jj-repo/.jj", + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }, + driver: {} as never, + } satisfies VcsDriverRegistry.VcsDriverHandle), + ); + const listHistoryRefs = vi.fn(() => Effect.die("must not call GitVcsDriver")); + const getHistory = vi.fn(() => Effect.die("must not call GitVcsDriver")); + const getCommitDetails = vi.fn(() => Effect.die("must not call GitVcsDriver")); + const listCommitFiles = vi.fn(() => Effect.die("must not call GitVcsDriver")); + const getCommitDiff = vi.fn(() => Effect.die("must not call GitVcsDriver")); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const historyInput = { cwd: "/jj-repo" }; + const commitInput = { cwd: "/jj-repo", hash: testCommitHash }; + + assert.deepStrictEqual(yield* workflow.listHistoryRefs(historyInput), { + refs: [], + currentRef: null, + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }); + assert.deepStrictEqual(yield* workflow.getHistory(historyInput), { + commits: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }); + assert.deepStrictEqual(yield* workflow.getCommitDetails(commitInput), { + commit: null, + isRepo: false, + }); + assert.deepStrictEqual(yield* workflow.listCommitFiles(commitInput), { + files: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }); + assert.deepStrictEqual(yield* workflow.getCommitDiff(commitInput), { + diff: "", + truncated: false, + isRepo: false, + }); + + assert.equal(detect.mock.calls.length, 5); + assert.equal(listHistoryRefs.mock.calls.length, 0); + assert.equal(getHistory.mock.calls.length, 0); + assert.equal(getCommitDetails.mock.calls.length, 0); + assert.equal(listCommitFiles.mock.calls.length, 0); + assert.equal(getCommitDiff.mock.calls.length, 0); + }).pipe( + Effect.provide( + makeLayer({ + detect, + git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff }, + }), + ), + ); + }, + ); + it.effect("structures workflow detection failures without exposing upstream details", () => { const cause = new VcsRepositoryDetectionError({ operation: "VcsDriverRegistry.detect", diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index e90a59fae8f1..f35e688ae7b2 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -5,14 +5,25 @@ import * as Layer from "effect/Layer"; import { GitManagerError, GitCommandError, + VcsSnapshotExpiredError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsGetHistoryInput, + type VcsGetHistoryResult, + type VcsGetCommitDetailsInput, + type VcsGetCommitDetailsResult, + type VcsListCommitFilesInput, + type VcsListCommitFilesResult, + type VcsGetCommitDiffInput, + type VcsGetCommitDiffResult, type VcsListRefsInput, type VcsListRefsResult, + type VcsListHistoryRefsInput, + type VcsListHistoryRefsResult, type GitManagerServiceError, type GitPreparePullRequestThreadInput, type GitPreparePullRequestThreadResult, @@ -62,6 +73,21 @@ export class GitWorkflowService extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listHistoryRefs: ( + input: VcsListHistoryRefsInput, + ) => Effect.Effect; + readonly getHistory: ( + input: VcsGetHistoryInput, + ) => Effect.Effect; + readonly getCommitDetails: ( + input: VcsGetCommitDetailsInput, + ) => Effect.Effect; + readonly listCommitFiles: ( + input: VcsListCommitFilesInput, + ) => Effect.Effect; + readonly getCommitDiff: ( + input: VcsGetCommitDiffInput, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; @@ -142,6 +168,33 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +function nonRepositoryHistoryRefs(): VcsListHistoryRefsResult { + return { + refs: [], + currentRef: null, + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }; +} + +function nonRepositoryHistory(): VcsGetHistoryResult { + return { commits: [], isRepo: false, nextCursor: null, hasMore: false, capped: false }; +} + +function nonRepositoryCommitDetails(): VcsGetCommitDetailsResult { + return { commit: null, isRepo: false }; +} + +function nonRepositoryCommitFiles(): VcsListCommitFilesResult { + return { files: [], isRepo: false, nextCursor: null, hasMore: false, capped: false }; +} + +function nonRepositoryCommitDiff(): VcsGetCommitDiffResult { + return { diff: "", truncated: false, isRepo: false }; +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; @@ -227,7 +280,7 @@ export const make = Effect.gen(function* () { const detectGitRepositoryForCommand = Effect.fn( "GitWorkflowService.detectGitRepositoryForCommand", - )(function* (operation: string, cwd: string) { + )(function* (operation: string, cwd: string, nonGitIsNotRepository = false) { const handle = yield* registry.detect({ cwd }).pipe( Effect.mapError( (cause) => @@ -244,6 +297,9 @@ export const make = Effect.gen(function* () { return false; } if (handle.kind !== "git") { + if (nonGitIsNotRepository) { + return false; + } return yield* new GitCommandError({ operation, command: "vcs-route", @@ -308,6 +364,38 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + listHistoryRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listHistoryRefs", input.cwd, true).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listHistoryRefs(input) : Effect.succeed(nonRepositoryHistoryRefs()), + ), + ), + getHistory: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getHistory", input.cwd, true).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.getHistory(input) : Effect.succeed(nonRepositoryHistory()), + ), + ), + getCommitDetails: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getCommitDetails", input.cwd, true).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? git.getCommitDetails(input) + : Effect.succeed(nonRepositoryCommitDetails()), + ), + ), + listCommitFiles: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listCommitFiles", input.cwd, true).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listCommitFiles(input) : Effect.succeed(nonRepositoryCommitFiles()), + ), + ), + getCommitDiff: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getCommitDiff", input.cwd, true).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.getCommitDiff(input) : Effect.succeed(nonRepositoryCommitDiff()), + ), + ), createWorktree: (input) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( Effect.andThen(git.createWorktree(input)), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6fe..b888f58e5259 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + VcsSnapshotExpiredError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -18,6 +19,16 @@ import { type VcsCreateRefResult, type VcsCreateWorktreeInput, type VcsCreateWorktreeResult, + type VcsGetHistoryInput, + type VcsGetHistoryResult, + type VcsGetCommitDetailsInput, + type VcsGetCommitDetailsResult, + type VcsListCommitFilesInput, + type VcsListCommitFilesResult, + type VcsGetCommitDiffInput, + type VcsGetCommitDiffResult, + type VcsListHistoryRefsInput, + type VcsListHistoryRefsResult, type ReviewDiffPreviewInput, type ReviewDiffPreviewResult, type ReviewDiffFileContentsInput, @@ -277,6 +288,21 @@ export class GitVcsDriver extends Context.Service< readonly listRefs: ( input: VcsListRefsInput, ) => Effect.Effect; + readonly listHistoryRefs: ( + input: VcsListHistoryRefsInput, + ) => Effect.Effect; + readonly getHistory: ( + input: VcsGetHistoryInput, + ) => Effect.Effect; + readonly getCommitDetails: ( + input: VcsGetCommitDetailsInput, + ) => Effect.Effect; + readonly listCommitFiles: ( + input: VcsListCommitFilesInput, + ) => Effect.Effect; + readonly getCommitDiff: ( + input: VcsGetCommitDiffInput, + ) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fb741b28ee7c..b1b9cf309677 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -3,6 +3,7 @@ import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { assert, it, describe } from "@effect/vitest"; +import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -61,6 +62,31 @@ const makeSuccessfulHandle = (stdout: string) => getOutputFd: () => Stream.empty, }); +const makeHangingHandle = () => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + +const makeGitHistoryOutput = (count: number): string => + Array.from( + { length: count }, + (_, index) => + `${index.toString(16).padStart(40, "0")}\x00\x00commit ${index}\x00Test\x00test@example.com\x002026-08-12T00:00:00Z\x00\x00`, + ).join(""); + +const makeGitCommitFilesOutput = (count: number): string => + Array.from({ length: count }, (_, index) => `A\x00file-${index}.ts\x00`).join(""); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -217,6 +243,35 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); +it.effect("times out getCommitDiff.show after its explicit 10-second command timeout", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const showSpawned = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("show") + ? Deferred.succeed(showSpawned, undefined).pipe(Effect.as(makeHangingHandle())) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const diff = yield* Effect.forkChild( + driver.getCommitDiff({ cwd, hash: "HEAD" }).pipe(Effect.exit), + ); + yield* Deferred.await(showSpawned); + yield* TestClock.adjust("10 seconds"); + const result = yield* Fiber.join(diff); + if (result._tag !== "Failure") return assert.fail("Expected getCommitDiff to time out."); + assert.match(Cause.pretty(result.cause), /GitVcsDriver\.getCommitDiff\.show/); + assert.match(Cause.pretty(result.cause), /Git command timed out\./); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.effect("invalidates origin remote cache when a driver mutation adds origin", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; @@ -501,6 +556,914 @@ it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); +it.effect("returns paginated commit history with author, parent, and decoration details", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "SECOND.md", "second\n"); + yield* git(cwd, ["add", "SECOND.md"]); + yield* git(cwd, ["commit", "-m", "second commit"], { + GIT_AUTHOR_NAME: "Ada Lovelace", + GIT_AUTHOR_EMAIL: "ada@example.com", + GIT_AUTHOR_DATE: "2026-08-09T12:00:00+00:00", + GIT_COMMITTER_NAME: "Ada Lovelace", + GIT_COMMITTER_EMAIL: "ada@example.com", + GIT_COMMITTER_DATE: "2026-08-09T12:00:00+00:00", + }); + yield* git(cwd, ["tag", "v2"]); + + const firstPage = yield* driver.getHistory({ cwd, limit: 1 }); + assert.equal(firstPage.isRepo, true); + assert.equal(firstPage.commits.length, 1); + assert.equal(firstPage.hasMore, true); + assert.equal(typeof firstPage.nextCursor, "string"); + assert.equal(firstPage.commits[0]?.subject, "second commit"); + assert.equal(firstPage.commits[0]?.authorName, "Ada Lovelace"); + assert.equal(firstPage.commits[0]?.authorEmail, "ada@example.com"); + assert.equal(firstPage.commits[0]?.authoredAt, "2026-08-09T12:00:00Z"); + assert.equal(firstPage.commits[0]?.parentHashes.length, 1); + assert.equal(firstPage.commits[0]?.refs.includes(`HEAD -> ${initialBranch}`), true); + assert.equal(firstPage.commits[0]?.refs.includes("tag: v2"), true); + + const fullPage = yield* driver.getHistory({ cwd, limit: 2 }); + assert.equal(fullPage.commits.length, 2); + assert.match(fullPage.commits[1]?.hash ?? "", /^[0-9a-f]{40}$/); + assert.equal(fullPage.commits[0]?.parentHashes[0], fullPage.commits[1]?.hash); + + assert.ok(firstPage.nextCursor); + const secondPage = yield* driver.getHistory({ cwd, cursor: firstPage.nextCursor, limit: 1 }); + assert.equal(secondPage.commits.length, 1); + assert.equal(secondPage.hasMore, false); + assert.equal(secondPage.nextCursor, null); + assert.equal(secondPage.commits[0]?.subject, "initial commit"); + + const reusedCursor = yield* driver.getHistory({ cwd, cursor: firstPage.nextCursor, limit: 1 }); + assert.deepEqual(reusedCursor.commits, secondPage.commits); + assert.equal(reusedCursor.hasMore, false); + assert.equal(reusedCursor.nextCursor, null); + + yield* git(cwd, ["branch", "history-test", "HEAD~1"]); + const branchHistory = yield* driver.getHistory({ + cwd, + revision: "refs/heads/history-test", + limit: 10, + }); + assert.deepEqual( + branchHistory.commits.map((commit) => commit.subject), + ["initial commit"], + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("returns history from refs when HEAD is unborn", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["symbolic-ref", "HEAD", "refs/heads/unborn"]); + + const history = yield* driver.getHistory({ cwd }); + + assert.equal(history.isRepo, true); + assert.equal(history.commits[0]?.subject, "initial commit"); + assert.include(history.commits[0]?.refs ?? [], initialBranch); + assert.equal(history.hasMore, false); + assert.equal(history.nextCursor, null); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("includes a detached HEAD commit in default history", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "--detach"]); + yield* writeTextFile(cwd, "detached.md", "detached\n"); + yield* git(cwd, ["add", "detached.md"]); + yield* git(cwd, ["commit", "-m", "detached history commit"]); + + const history = yield* driver.getHistory({ cwd }); + + assert.include( + history.commits.map((commit) => commit.subject), + "detached history commit", + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("does not resolve detached HEAD again when continuing a history snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const headResolutions = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.args.includes("rev-parse") && + command.args.includes("HEAD^{commit}") + ) { + return Ref.update(headResolutions, (count) => count + 1).pipe( + Effect.andThen(delegate.spawn(command)), + ); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* writeTextFile(cwd, "second.md", "second\n"); + yield* driver.execute({ + operation: "GitVcsDriver.test.secondCommit", + cwd, + args: ["add", "second.md"], + }); + yield* driver.execute({ + operation: "GitVcsDriver.test.secondCommit", + cwd, + args: ["commit", "-m", "second"], + }); + yield* driver.execute({ + operation: "GitVcsDriver.test.detachHead", + cwd, + args: ["checkout", "--detach"], + }); + + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + const initialResolutions = yield* Ref.get(headResolutions); + + yield* driver.getHistory({ cwd, cursor: first.nextCursor, limit: 1 }); + + assert.equal(yield* Ref.get(headResolutions), initialResolutions); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("keeps a history page stable after refs move", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const name of ["second", "third"]) { + yield* writeTextFile(cwd, `${name}.md`, `${name}\n`); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", name]); + } + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + yield* writeTextFile(cwd, "new.md", "new\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "new"]); + yield* git(cwd, ["branch", "moved-after-snapshot"]); + const second = yield* driver.getHistory({ cwd, cursor: first.nextCursor, limit: 2 }); + assert.deepEqual( + second.commits.map((commit) => commit.subject), + ["second", "initial commit"], + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("rejects an unknown history cursor with the typed expiry error", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const error = yield* driver + .getHistory({ cwd, cursor: "not-a-history-snapshot" }) + .pipe(Effect.flip); + assert.equal(error._tag, "VcsSnapshotExpiredError"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("rejects a history cursor reused for another revision", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "second.md", "second\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "second"]); + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + const error = yield* driver + .getHistory({ cwd, revision: `refs/heads/${initialBranch}`, cursor: first.nextCursor }) + .pipe(Effect.flip); + assert.equal(error._tag, "VcsSnapshotExpiredError"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("expires a history cursor after its TestClock TTL", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "second.md", "second\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "second"]); + + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + yield* TestClock.adjust("5 minutes"); + const error = yield* driver.getHistory({ cwd, cursor: first.nextCursor }).pipe(Effect.flip); + assert.equal(error._tag, "VcsSnapshotExpiredError"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("rejects a history cursor from another linked worktree", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const linkedWorktree = pathService.join(worktreesRoot, "linked"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "second.md", "second\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "second"]); + yield* git(cwd, ["worktree", "add", "-b", "history-linked", linkedWorktree]); + + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + const error = yield* driver + .getHistory({ cwd: linkedWorktree, cursor: first.nextCursor }) + .pipe(Effect.flip); + assert.equal(error._tag, "VcsSnapshotExpiredError"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("caps history output at complete NUL records", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "log" + ? Effect.succeed( + makeSuccessfulHandle(`${makeGitHistoryOutput(1)}${"x".repeat(512 * 1024 + 1)}`), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const history = yield* driver.getHistory({ cwd }); + assert.deepEqual( + history.commits.map((commit) => commit.subject), + ["commit 0"], + ); + assert.equal(history.capped, true); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("reports capped history snapshots without a phantom sentinel page", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const logCommands = yield* Ref.make>>([]); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "log" + ? Ref.update(logCommands, (commands) => [...commands, command.args]).pipe( + Effect.as(makeSuccessfulHandle(makeGitHistoryOutput(1_002))), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + let page = yield* driver.getHistory({ cwd, limit: 200 }); + assert.equal(page.commits.length, 200); + assert.equal(page.hasMore, true); + for (let index = 0; index < 4; index += 1) { + assert.ok(page.nextCursor); + page = yield* driver.getHistory({ cwd, cursor: page.nextCursor, limit: 200 }); + assert.equal(page.commits.length, 200); + assert.equal(page.hasMore, true); + } + assert.ok(page.nextCursor); + const finalPage = yield* driver.getHistory({ cwd, cursor: page.nextCursor, limit: 200 }); + assert.equal(finalPage.commits.length, 1); + assert.equal(finalPage.hasMore, false); + assert.equal(finalPage.nextCursor, null); + assert.equal(finalPage.capped, true); + assert.deepEqual(yield* Ref.get(logCommands), [ + [ + "log", + "-z", + "--date-order", + "--decorate=short", + "--max-count=1002", + "--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%aI%x00%D", + "--branches", + "--remotes", + "--tags", + ], + ]); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("evicts the oldest history snapshot and invalidates only its cursor", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "log" + ? Effect.succeed(makeSuccessfulHandle(makeGitHistoryOutput(2))) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + const cursors: Array = []; + for (let index = 0; index < 65; index += 1) { + const page = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(page.nextCursor); + cursors.push(page.nextCursor); + } + + const expired = yield* driver.getHistory({ cwd, cursor: cursors[0] }).pipe(Effect.flip); + assert.equal(expired._tag, "VcsSnapshotExpiredError"); + const retained = yield* driver.getHistory({ cwd, cursor: cursors[1], limit: 1 }); + assert.equal(retained.commits.length, 1); + assert.equal(retained.hasMore, false); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("does not retain completed history snapshots in the session cap", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const logCalls = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "log" + ? Ref.updateAndGet(logCalls, (count) => count + 1).pipe( + Effect.map((count) => + makeSuccessfulHandle(makeGitHistoryOutput(count === 1 ? 2 : 1)), + ), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const first = yield* driver.getHistory({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + for (let index = 0; index < 64; index += 1) { + const completed = yield* driver.getHistory({ cwd, limit: 1 }); + assert.equal(completed.hasMore, false); + } + + const retained = yield* driver.getHistory({ cwd, cursor: first.nextCursor, limit: 1 }); + assert.equal(retained.commits.length, 1); + assert.equal(retained.hasMore, false); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("returns full commit details and root commit changed files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "SECOND.md", "second\n"); + yield* git(cwd, ["add", "SECOND.md"]); + yield* git(cwd, ["commit", "-m", "second subject", "-m", "second body\nwith another line"]); + yield* git(cwd, ["tag", "v2"]); + + const hash = yield* git(cwd, ["rev-parse", "HEAD"]); + const details = yield* driver.getCommitDetails({ cwd, hash }); + + assert.equal(details.isRepo, true); + assert.equal(details.commit?.hash, hash); + assert.equal(details.commit?.subject, "second subject"); + assert.equal(details.commit?.body, "second body\nwith another line\n"); + assert.equal(details.commit?.authorName, "Test"); + assert.equal(details.commit?.authorEmail, "test@test.com"); + assert.equal(details.commit?.parentHashes.length, 1); + assert.include(details.commit?.refs ?? [], "tag: v2"); + const files = yield* driver.listCommitFiles({ cwd, hash }); + assert.deepEqual(files.files, [{ status: "A", path: "SECOND.md" }]); + + const paged = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.equal(paged.hasMore, false); + assert.equal(paged.nextCursor, null); + + const diff = yield* driver.getCommitDiff({ cwd, hash }); + assert.equal(diff.isRepo, true); + assert.equal(diff.truncated, false); + assert.include(diff.diff, "diff --git a/SECOND.md b/SECOND.md"); + assert.include(diff.diff, "+second"); + + const fileDiff = yield* driver.getCommitDiff({ cwd, hash, filePath: "SECOND.md" }); + assert.include(fileDiff.diff, "+second"); + + const initialHash = details.commit?.parentHashes[0]; + assert.ok(initialHash); + const initialFiles = yield* driver.listCommitFiles({ cwd, hash: initialHash }); + assert.deepEqual(initialFiles.files, [{ status: "A", path: "README.md" }]); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("lists only files represented by a merge commit's first-parent patch", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["checkout", "-b", "feature"]); + yield* writeTextFile(cwd, "feature.md", "feature\n"); + yield* git(cwd, ["add", "feature.md"]); + yield* git(cwd, ["commit", "-m", "feature change"]); + yield* git(cwd, ["checkout", initialBranch]); + yield* writeTextFile(cwd, "main.md", "main\n"); + yield* git(cwd, ["add", "main.md"]); + yield* git(cwd, ["commit", "-m", "main change"]); + yield* git(cwd, ["merge", "--no-ff", "feature", "-m", "merge feature"]); + const hash = yield* git(cwd, ["rev-parse", "HEAD"]); + + const files = yield* driver.listCommitFiles({ cwd, hash }); + const diff = yield* driver.getCommitDiff({ cwd, hash }); + + assert.deepEqual(files.files, [{ status: "A", path: "feature.md" }]); + assert.include(diff.diff, "diff --git a/feature.md b/feature.md"); + assert.notInclude(diff.diff, "diff --git a/main.md b/main.md"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("treats commit diff file paths containing pathspec magic as literals", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "report[1].md", "literal path\n"); + yield* writeTextFile(cwd, "report1.md", "wildcard match\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add similar report paths"]); + const hash = yield* git(cwd, ["rev-parse", "HEAD"]); + + const diff = yield* driver.getCommitDiff({ cwd, hash, filePath: "report[1].md" }); + + assert.include(diff.diff, "literal path"); + assert.notInclude(diff.diff, "wildcard match"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("pages commit files idempotently and retains the snapshot through its TTL", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + for (const name of ["second", "third"]) { + yield* writeTextFile(cwd, `${name}.md`, `${name}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "add files"]); + const hash = yield* git(cwd, ["rev-parse", "HEAD"]); + const first = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.deepEqual(first.files, [{ status: "A", path: "second.md" }]); + assert.ok(first.nextCursor); + const second = yield* driver.listCommitFiles({ cwd, hash, cursor: first.nextCursor, limit: 1 }); + assert.equal(second.hasMore, false); + assert.equal(second.nextCursor, null); + const replay = yield* driver.listCommitFiles({ cwd, hash, cursor: first.nextCursor, limit: 1 }); + assert.deepEqual(replay, second); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect( + "rejects commit-file cursors after TestClock expiry and across worktree or hash bindings", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const linked = pathService.join(worktreesRoot, "linked"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "second.md", "second\n"); + yield* writeTextFile(cwd, "third.md", "third\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "second"]); + const hash = yield* git(cwd, ["rev-parse", "HEAD"]); + yield* git(cwd, ["worktree", "add", "-b", "commit-files-linked", linked]); + const first = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.ok(first.nextCursor); + const wrongWorktree = yield* driver + .listCommitFiles({ cwd: linked, hash, cursor: first.nextCursor }) + .pipe(Effect.flip); + assert.equal(wrongWorktree._tag, "VcsSnapshotExpiredError"); + const second = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.ok(second.nextCursor); + const wrongHash = yield* driver + .listCommitFiles({ + cwd, + hash: yield* git(cwd, ["rev-parse", "HEAD~1"]), + cursor: second.nextCursor, + }) + .pipe(Effect.flip); + assert.equal(wrongHash._tag, "VcsSnapshotExpiredError"); + const third = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.ok(third.nextCursor); + yield* TestClock.adjust("1 minute"); + const refreshed = yield* driver.listCommitFiles({ cwd, hash, cursor: third.nextCursor }); + assert.equal(refreshed.hasMore, false); + yield* TestClock.adjust("90 seconds"); + const replayed = yield* driver.listCommitFiles({ cwd, hash, cursor: third.nextCursor }); + assert.deepEqual(replayed, refreshed); + yield* TestClock.adjust("2 minutes"); + const expired = yield* driver + .listCommitFiles({ cwd, hash, cursor: third.nextCursor }) + .pipe(Effect.flip); + assert.equal(expired._tag, "VcsSnapshotExpiredError"); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect( + "enforces commit-file retained bytes, file cap, and 64-session eviction through synthetic git output", + () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const output = makeGitCommitFilesOutput(2_001); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "diff-tree" + ? Effect.succeed(makeSuccessfulHandle(output)) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + const hash = "a".repeat(40); + const first = yield* driver.listCommitFiles({ cwd, hash, limit: 100 }); + assert.equal(first.files.length, 100); + assert.equal(first.capped, true); + assert.ok(first.nextCursor); + const cursors = [first.nextCursor]; + for (let index = 0; index < 64; index += 1) { + const page = yield* driver.listCommitFiles({ cwd, hash, limit: 1 }); + assert.ok(page.nextCursor); + cursors.push(page.nextCursor); + } + const evicted = yield* driver + .listCommitFiles({ cwd, hash, cursor: cursors[0] }) + .pipe(Effect.flip); + assert.equal(evicted._tag, "VcsSnapshotExpiredError"); + const retained = yield* driver.listCommitFiles({ cwd, hash, cursor: cursors[1], limit: 1 }); + assert.equal(retained.files.length, 1); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("caps commit-file output at complete NUL records", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args[0] === "diff-tree" + ? Effect.succeed( + makeSuccessfulHandle(`A\x00complete.ts\x00${"x".repeat(512 * 1024 + 1)}`), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + const files = yield* driver.listCommitFiles({ cwd, hash: "a".repeat(40) }); + assert.deepEqual(files.files, [{ status: "A", path: "complete.ts" }]); + assert.equal(files.capped, true); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("excludes internal refs from history while including normal branch history", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const currentHead = yield* git(cwd, ["rev-parse", "HEAD"]); + + const normalBranchCommit = yield* git(cwd, [ + "commit-tree", + "HEAD^{tree}", + "-m", + "normal branch history", + ]); + yield* git(cwd, ["update-ref", "refs/heads/feature/normal-history", normalBranchCommit]); + + const internalCommit = yield* git(cwd, [ + "commit-tree", + "HEAD^{tree}", + "-m", + "internal checkpoint history", + ]); + yield* git(cwd, ["update-ref", "refs/t3/checkpoints/test", internalCommit]); + + const mergeCommit = yield* git(cwd, [ + "commit-tree", + "HEAD^{tree}", + "-p", + "HEAD", + "-p", + normalBranchCommit, + "-m", + "visible merge history", + ]); + yield* git(cwd, ["update-ref", "HEAD", mergeCommit]); + + const history = yield* driver.getHistory({ cwd, limit: 10 }); + const subjects = history.commits.map((commit) => commit.subject); + const merge = history.commits.find((commit) => commit.hash === mergeCommit); + + assert.include(subjects, "normal branch history"); + assert.notInclude(subjects, "internal checkpoint history"); + assert.deepEqual(merge?.parentHashes, [currentHead, normalBranchCommit]); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("keeps ref namespaces isolated and marks current and origin default refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "feature/local"]); + yield* git(cwd, ["tag", "release/v1"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", initialBranch]); + yield* git(cwd, ["remote", "set-head", "origin", initialBranch]); + + const local = yield* driver.listHistoryRefs({ cwd, namespace: "local", refresh: true }); + const remotes = yield* driver.listHistoryRefs({ cwd, namespace: "remote" }); + const tags = yield* driver.listHistoryRefs({ cwd, namespace: "tag" }); + + assert.isTrue(local.refs.every((ref) => !ref.isRemote && !ref.isTag)); + assert.equal(local.currentRef?.name, initialBranch); + assert.isTrue(remotes.refs.every((ref) => ref.isRemote && !ref.isTag)); + assert.equal( + remotes.refs.find((ref) => ref.name === `origin/${initialBranch}`)?.isDefault, + true, + ); + assert.equal(remotes.currentRef, null); + assert.deepEqual( + tags.refs.map((ref) => ref.name), + ["release/v1"], + ); + assert.isTrue(tags.refs.every((ref) => ref.isTag && !ref.isRemote)); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("filters history refs with a case-insensitive substring query", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "feature/Valid-1"]); + yield* git(cwd, ["branch", "feature/other"]); + + const refs = yield* driver.listHistoryRefs({ cwd, query: "VALID-1" }); + + assert.deepEqual( + refs.refs.map((ref) => ref.name), + ["feature/Valid-1"], + ); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("reports the current history ref when a filter excludes it", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "feature/other"]); + + const refs = yield* driver.listHistoryRefs({ cwd, query: "other" }); + + assert.deepEqual( + refs.refs.map((ref) => ref.name), + ["feature/other"], + ); + assert.equal(refs.currentRef?.name, initialBranch); + }).pipe(Effect.provide(TestLayer)), +); + +it.effect("keeps every remote default ref and reports upstream divergence", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const remoteRefsOutput = [ + "refs/remotes/origin/HEAD\t0\trefs/remotes/origin/main\t\t\t", + "refs/remotes/origin/main\t0\t\t\t\t", + "refs/remotes/upstream/HEAD\t0\trefs/remotes/upstream/release\t\t\t", + "refs/remotes/upstream/release\t0\t\t\t\t", + ].join("\0"); + const localRefsOutput = "refs/heads/feature\t0\t\torigin/feature\t[ahead 2, behind 3]\t\0"; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("for-each-ref") + ? Effect.succeed( + makeSuccessfulHandle( + command.args.at(-1) === "refs/remotes/" ? remoteRefsOutput : localRefsOutput, + ), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const remotes = yield* driver.listHistoryRefs({ cwd, namespace: "remote" }); + const locals = yield* driver.listHistoryRefs({ cwd, namespace: "local" }); + + assert.isTrue(remotes.refs.every((ref) => ref.isDefault)); + assert.equal(locals.refs[0]?.aheadCount, 2); + assert.equal(locals.refs[0]?.behindCount, 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("does not retain completed history-ref snapshots in the session cap", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const refScans = yield* Ref.make(0); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("for-each-ref") + ? Ref.updateAndGet(refScans, (count) => count + 1).pipe( + Effect.map((count) => + makeSuccessfulHandle( + count === 1 + ? "refs/heads/feature/first\t0\t\t\t\t\0refs/heads/feature/second\t0\t\t\t\t\0" + : "refs/heads/feature/completed\t0\t\t\t\t\0", + ), + ), + ) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const first = yield* driver.listHistoryRefs({ cwd, limit: 1 }); + assert.ok(first.nextCursor); + for (let index = 0; index < 64; index += 1) { + const completed = yield* driver.listHistoryRefs({ cwd, limit: 1 }); + assert.equal(completed.nextCursor, null); + } + + const retained = yield* driver.listHistoryRefs({ cwd, cursor: first.nextCursor, limit: 1 }); + assert.equal(retained.refs.length, 1); + assert.equal(retained.nextCursor, null); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect( + "caps ref snapshots, expires cursors, evicts sessions, and invalidates on mutations", + () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const refOutput = [ + ...Array.from( + { length: 10_001 }, + (_, index) => + `refs/heads/feature/${index.toString().padStart(5, "0")}-${"x".repeat(128)}\t0\t\t\t`, + ), + "refs/heads/release/Needle\t0\t\t\t", + ].join("\0"); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("for-each-ref") + ? Effect.succeed(makeSuccessfulHandle(refOutput)) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const filtered = yield* driver.listHistoryRefs({ cwd, query: "needle", refresh: true }); + assert.deepEqual( + filtered.refs.map((ref) => ref.name), + ["release/Needle"], + ); + assert.equal(filtered.isComplete, true); + + const first = yield* driver.listHistoryRefs({ cwd, limit: 1, refresh: true }); + assert.equal(first.isComplete, false); + assert.equal(first.refs.length, 1); + assert.ok(first.nextCursor); + const unknown = yield* driver + .listHistoryRefs({ cwd, cursor: "unknown-cursor" }) + .pipe(Effect.flip); + assert.equal(unknown._tag, "VcsSnapshotExpiredError"); + const mismatch = yield* driver + .listHistoryRefs({ cwd, cursor: first.nextCursor, namespace: "tag" }) + .pipe(Effect.flip); + assert.equal(mismatch._tag, "VcsSnapshotExpiredError"); + const queryMismatch = yield* driver + .listHistoryRefs({ cwd, cursor: first.nextCursor, query: "feature" }) + .pipe(Effect.flip); + assert.equal(queryMismatch._tag, "VcsSnapshotExpiredError"); + const linkedWorktree = yield* makeTmpDir("git-vcs-driver-linked-"); + yield* git(cwd, ["worktree", "add", "-b", "feature/linked", linkedWorktree]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + const worktreeMismatch = yield* driver + .listHistoryRefs({ cwd: linkedWorktree, cursor: first.nextCursor }) + .pipe(Effect.flip); + assert.equal(worktreeMismatch._tag, "VcsSnapshotExpiredError"); + const second = yield* driver.listHistoryRefs({ cwd, cursor: first.nextCursor, limit: 1 }); + assert.equal(second.refs.length, 1); + const reused = yield* driver.listHistoryRefs({ cwd, cursor: first.nextCursor, limit: 1 }); + assert.deepEqual(reused.refs, second.refs); + assert.equal(reused.isComplete, second.isComplete); + assert.equal(reused.nextCursor, second.nextCursor); + assert.ok(second.nextCursor); + yield* TestClock.adjust("2 minutes"); + const expired = yield* driver + .listHistoryRefs({ cwd, cursor: second.nextCursor }) + .pipe(Effect.flip); + assert.equal(expired._tag, "VcsSnapshotExpiredError"); + + const cursors: Array = []; + for (let index = 0; index < 33; index += 1) { + const page = yield* driver.listHistoryRefs({ cwd, limit: 1, refresh: true }); + assert.ok(page.nextCursor); + cursors.push(page.nextCursor); + } + const evicted = yield* driver + .listHistoryRefs({ cwd, cursor: cursors[0] }) + .pipe(Effect.flip); + assert.equal(evicted._tag, "VcsSnapshotExpiredError"); + const retained = yield* driver.listHistoryRefs({ cwd, cursor: cursors[1], limit: 1 }); + assert.equal(retained.refs.length, 1); + + const mutationCursor = retained.nextCursor; + assert.ok(mutationCursor); + yield* driver.createRef({ cwd, refName: "feature/invalidate" }); + const invalidated = yield* driver + .listHistoryRefs({ cwd, cursor: mutationCursor }) + .pipe(Effect.flip); + assert.equal(invalidated._tag, "VcsSnapshotExpiredError"); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("caps ref output at complete NUL records", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const refOutput = [ + ...Array.from( + { length: 10_001 }, + (_, index) => `refs/heads/feature/${index.toString().padStart(5, "0")}\t0\t\t\t`, + ), + `refs/heads/overflow/${"x".repeat(2 * 1024 * 1024)}`, + ].join("\0"); + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && command.args.includes("for-each-ref") + ? Effect.succeed(makeSuccessfulHandle(refOutput)) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir(); + yield* driver.initRepo({ cwd }); + + const refs = yield* driver.listHistoryRefs({ cwd, refresh: true }); + + assert.equal(refs.refs.length, 100); + assert.equal(refs.isComplete, false); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.effect("marks the current branch when worktree metadata is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -683,7 +1646,68 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => yield* driver.statusDetailsRemote(cwd); assert.equal(yield* Ref.get(fetchAttempts), 3); }), - ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), + ).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer))), + ), +); + +it.effect("uses the original Git directory casing for Windows refresh commands", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchGitDirs = yield* Ref.make>([]); + const spawner = ChildProcessSpawner.make((command) => { + if ( + ChildProcess.isStandardCommand(command) && + command.args.includes("fetch") && + command.args.includes("--git-dir") + ) { + const gitDir = command.args[command.args.indexOf("--git-dir") + 1]; + if (gitDir) { + return Ref.update(fetchGitDirs, (current) => [...current, gitDir]).pipe( + Effect.as(makeSuccessfulHandle("")), + ); + } + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const cwd = yield* makeTmpDir("GitVcsDriverCase-"); + const remote = yield* makeTmpDir("GitVcsDriverRemote-"); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.windowsGitDirCasing", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const branch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", branch]); + const commonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); + const originalGitDir = yield* fileSystem.realPath(pathService.resolve(cwd, commonDir)); + + yield* driver.statusDetailsRemote(cwd); + + assert.deepEqual(yield* Ref.get(fetchGitDirs), [originalGitDir]); + }), + ).pipe( + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer))), + ), ); it.layer(TestLayer)("GitVcsDriver core integration", (it) => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3c7e018ddea7..fad4e42ba7af 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,13 +1,16 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Hash from "effect/Hash"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -17,16 +20,24 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as NodeCrypto from "node:crypto"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + VcsSnapshotExpiredError, + type GitCommitChangedFile, + type GitCommitDetails, + type GitHistoryCommit, + type VcsGetCommitDiffInput, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, + type VcsHistoryRef, type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -83,6 +94,55 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const GIT_REF_SNAPSHOT_MAX_REFS = 10_000; +const GIT_REF_SNAPSHOT_MAX_SESSIONS = 32; +const GIT_REF_SNAPSHOT_TTL_MS = 2 * 60 * 1000; +const GIT_REF_SNAPSHOT_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const GIT_HISTORY_DEFAULT_LIMIT = 100; +const GIT_HISTORY_SNAPSHOT_MAX_COMMITS = 1001; +const GIT_HISTORY_SNAPSHOT_MAX_SESSIONS = 64; +const GIT_HISTORY_SNAPSHOT_TTL_MS = 5 * 60 * 1000; +const GIT_HISTORY_MAX_OUTPUT_BYTES = 512 * 1024; +const GIT_HISTORY_RECORD_FIELD_COUNT = 7; +const GIT_COMMIT_DETAILS_RECORD_FIELD_COUNT = 8; +const GIT_COMMIT_DETAILS_MAX_OUTPUT_BYTES = 64 * 1024; +const GIT_COMMIT_FILES_MAX_OUTPUT_BYTES = 512 * 1024; +const GIT_COMMIT_FILES_MAX_FILES = 2_000; +const GIT_COMMIT_FILES_DEFAULT_LIMIT = 100; +const GIT_COMMIT_FILES_SNAPSHOT_MAX_SESSIONS = 64; +const GIT_COMMIT_FILES_SNAPSHOT_TTL_MS = 2 * 60 * 1000; + +interface GitHistorySnapshot { + readonly gitCommonDir: string; + readonly worktreePath: string; + readonly revision: string | null; + readonly commits: ReadonlyArray; + readonly capped: boolean; + expiresAt: number; +} + +type GitRefNamespace = "local" | "remote" | "tag"; + +interface GitRefSnapshot { + readonly gitCommonDir: string; + readonly worktreePath: string; + readonly namespace: GitRefNamespace; + readonly query: string | null; + readonly refs: ReadonlyArray; + readonly currentRef: VcsHistoryRef | null; + readonly hasPrimaryRemote: boolean; + readonly isComplete: boolean; + expiresAt: number; +} + +interface GitCommitFilesSnapshot { + readonly gitCommonDir: string; + readonly worktreePath: string | null; + readonly hash: string; + readonly files: ReadonlyArray; + readonly capped: boolean; + readonly expiresAt: number; +} const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -113,10 +173,36 @@ type TraceTailState = { remainder: string; }; +class GitCommonDirCacheKey extends Data.Class<{ + cacheIdentity: string; + gitCommonDir: string; +}> { + [Equal.symbol](that: Equal.Equal): boolean { + return that instanceof GitCommonDirCacheKey && this.cacheIdentity === that.cacheIdentity; + } + + [Hash.symbol](): number { + return Hash.hash(this.cacheIdentity); + } +} + class StatusRemoteRefreshCacheKey extends Data.Class<{ + cacheIdentity: string; gitCommonDir: string; remoteName: string; -}> {} +}> { + [Equal.symbol](that: Equal.Equal): boolean { + return ( + that instanceof StatusRemoteRefreshCacheKey && + this.cacheIdentity === that.cacheIdentity && + this.remoteName === that.remoteName + ); + } + + [Hash.symbol](): number { + return Hash.hash(`${this.cacheIdentity}\0${this.remoteName}`); + } +} function statusUpstreamRefreshFailureCooldown(consecutiveFailures: number): Duration.Duration { const exponent = Math.max(0, consecutiveFailures - 1); @@ -156,6 +242,9 @@ interface ExecuteGitOptions { maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined; + captureStdout?: boolean | undefined; + onStdoutRecord?: ((record: string) => Effect.Effect) | undefined; + stdoutRecordSeparator?: string | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -167,6 +256,49 @@ function parseBranchAb(value: string): { ahead: number; behind: number } { }; } +function parseRefUpstreamTrack(value: string): { aheadCount?: number; behindCount?: number } { + const aheadMatch = value.match(/ahead (\d+)/); + const behindMatch = value.match(/behind (\d+)/); + return { + ...(aheadMatch ? { aheadCount: Number.parseInt(aheadMatch[1] ?? "0", 10) } : {}), + ...(behindMatch ? { behindCount: Number.parseInt(behindMatch[1] ?? "0", 10) } : {}), + }; +} + +function filterBranchesForListQuery( + refs: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return refs; + } + + const normalizedQuery = query.toLowerCase(); + return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + refs: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}): { + refs: ReadonlyArray; + nextCursor: number | null; + totalCount: number; +} { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.refs.length; + const refs = input.refs.slice(cursor, cursor + limit); + const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; + + return { + refs, + nextCursor, + totalCount, + }; +} + function parseNumstatEntries( stdout: string, ): Array<{ path: string; insertions: number; deletions: number }> { @@ -213,40 +345,102 @@ function parsePorcelainPath(line: string): string | null { return filePath.length > 0 ? filePath : null; } -function filterBranchesForListQuery( - refs: ReadonlyArray, - query?: string, -): ReadonlyArray { - if (!query) { - return refs; +function parseGitHistory(stdout: string): ReadonlyArray { + const fields = stdout.split("\0"); + const commits: Array = []; + + for ( + let fieldIndex = 0; + fieldIndex + GIT_HISTORY_RECORD_FIELD_COUNT <= fields.length - 1; + fieldIndex += GIT_HISTORY_RECORD_FIELD_COUNT + ) { + const hash = fields[fieldIndex] ?? ""; + const parents = fields[fieldIndex + 1] ?? ""; + const subject = fields[fieldIndex + 2] ?? ""; + const authorName = fields[fieldIndex + 3] ?? ""; + const authorEmail = fields[fieldIndex + 4] ?? ""; + const authoredAt = fields[fieldIndex + 5] ?? ""; + const decorations = fields[fieldIndex + 6] ?? ""; + + if (hash.length === 0 || authoredAt.length === 0) { + continue; + } + + commits.push({ + hash, + parentHashes: parents.length === 0 ? [] : parents.split(" "), + subject, + authorName, + authorEmail, + authoredAt, + refs: decorations.length === 0 ? [] : decorations.split(", "), + }); } - const normalizedQuery = query.toLowerCase(); - return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); + return commits; } -function paginateBranches(input: { - refs: ReadonlyArray; - cursor?: number | undefined; - limit?: number | undefined; -}): { - refs: ReadonlyArray; - nextCursor: number | null; - totalCount: number; -} { - const cursor = input.cursor ?? 0; - const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; - const totalCount = input.refs.length; - const refs = input.refs.slice(cursor, cursor + limit); - const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; +function parseGitCommitDetails(stdout: string): GitCommitDetails | null { + const fields = stdout.split("\0"); + if (fields.length < GIT_COMMIT_DETAILS_RECORD_FIELD_COUNT + 1) { + return null; + } + + const [ + hash = "", + parents = "", + subject = "", + body = "", + authorName = "", + authorEmail = "", + authoredAt = "", + decorations = "", + ] = fields; + if (hash.length === 0 || authoredAt.length === 0) { + return null; + } return { - refs, - nextCursor, - totalCount, + hash, + parentHashes: parents.length === 0 ? [] : parents.split(" "), + subject, + body, + authorName, + authorEmail, + authoredAt, + refs: decorations.length === 0 ? [] : decorations.split(", "), }; } +function parseGitCommitChangedFiles(stdout: string): ReadonlyArray { + const files: Array = []; + const records = stdout.split("\0"); + for (let index = 0; index < records.length; index += 1) { + const record = records[index] ?? ""; + if (record.length === 0) continue; + const separatorIndex = record.indexOf("\t"); + const status = separatorIndex > 0 ? record.slice(0, separatorIndex) : record; + const path = separatorIndex > 0 ? record.slice(separatorIndex + 1) : (records[index + 1] ?? ""); + if (separatorIndex <= 0) index += 1; + if ( + (status !== "A" && + status !== "M" && + status !== "D" && + status !== "R" && + status !== "C" && + status !== "T" && + status !== "U" && + status !== "X" && + status !== "B") || + path.trim().length === 0 + ) { + continue; + } + files.push({ status, path }); + } + return files; +} + function parseWorktreeBranchPaths(stdout: string): ReadonlyMap { const worktreePaths = new Map(); let currentPath: string | null = null; @@ -647,22 +841,31 @@ const collectOutput = Effect.fnUntraced(function* ( maxOutputBytes: number, appendTruncationMarker: boolean, onLine: ((line: string) => Effect.Effect) | undefined, + captureText = true, + recordSeparator = "\n", + onRecord: ((record: string) => Effect.Effect) | undefined, ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { const decoder = new TextDecoder(); let bytes = 0; let text = ""; let lineBuffer = ""; let truncated = false; + let recordsComplete = false; const emitCompleteLines = Effect.fnUntraced(function* (flush: boolean) { - let newlineIndex = lineBuffer.indexOf("\n"); + let newlineIndex = lineBuffer.indexOf(recordSeparator); while (newlineIndex >= 0) { - const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + const line = lineBuffer + .slice(0, newlineIndex) + .replace(recordSeparator === "\n" ? /\r$/ : /$^/, ""); lineBuffer = lineBuffer.slice(newlineIndex + 1); if (line.length > 0 && onLine) { yield* onLine(line); } - newlineIndex = lineBuffer.indexOf("\n"); + if (!recordsComplete && onRecord) { + recordsComplete = !(yield* onRecord(line)); + } + newlineIndex = lineBuffer.indexOf(recordSeparator); } if (flush) { @@ -671,6 +874,9 @@ const collectOutput = Effect.fnUntraced(function* ( if (trailing.length > 0 && onLine) { yield* onLine(trailing); } + if (!recordsComplete && onRecord) { + recordsComplete = !(yield* onRecord(trailing)); + } } }); @@ -695,7 +901,7 @@ const collectOutput = Effect.fnUntraced(function* ( truncated = appendTruncationMarker && nextBytes > maxOutputBytes; const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); - text += decoded; + if (captureText) text += decoded; lineBuffer += decoded; yield* emitCompleteLines(false); }); @@ -711,10 +917,17 @@ const collectOutput = Effect.fnUntraced(function* ( }), ); - const remainder = truncated ? "" : decoder.decode(); - text += remainder; - lineBuffer += remainder; - yield* emitCompleteLines(true); + if (truncated) { + if (captureText && recordSeparator === "\0") { + const lastCompleteRecord = text.lastIndexOf(recordSeparator); + text = lastCompleteRecord < 0 ? "" : text.slice(0, lastCompleteRecord + 1); + } + } else { + const remainder = decoder.decode(); + if (captureText) text += remainder; + lineBuffer += remainder; + yield* emitCompleteLines(true); + } return { text, truncated, @@ -722,141 +935,288 @@ const collectOutput = Effect.fnUntraced(function* ( }); export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { + const hostPlatform = yield* HostProcessPlatform; + const historySnapshots = new Map(); + const historyCursors = new Map< + string, + { readonly snapshotId: string; readonly offset: number } + >(); + const historyCursorIdsBySnapshotOffset = new Map>(); + const commitFilesSnapshots = new Map(); + const commitFilesCursors = new Map< + string, + { readonly snapshotId: string; readonly offset: number } + >(); + const commitFilesCursorIdsBySnapshotOffset = new Map>(); + const deleteCommitFilesSnapshot = (snapshotId: string): void => { + commitFilesSnapshots.delete(snapshotId); + const cursorIdsByOffset = commitFilesCursorIdsBySnapshotOffset.get(snapshotId); + if (cursorIdsByOffset !== undefined) { + for (const cursor of cursorIdsByOffset.values()) { + commitFilesCursors.delete(cursor); + } + commitFilesCursorIdsBySnapshotOffset.delete(snapshotId); + } + }; + const pruneExpiredCommitFilesSnapshots = (now: number): void => { + for (const [snapshotId, snapshot] of commitFilesSnapshots) { + if (snapshot.expiresAt <= now) deleteCommitFilesSnapshot(snapshotId); + } + }; + const newCommitFilesCursor = (snapshotId: string, offset: number): string => { + const cursorIdsByOffset = + commitFilesCursorIdsBySnapshotOffset.get(snapshotId) ?? new Map(); + const existingCursor = cursorIdsByOffset.get(offset); + if (existingCursor !== undefined) return existingCursor; + const cursor = NodeCrypto.randomBytes(18).toString("base64url"); + commitFilesCursors.set(cursor, { snapshotId, offset }); + cursorIdsByOffset.set(offset, cursor); + commitFilesCursorIdsBySnapshotOffset.set(snapshotId, cursorIdsByOffset); + return cursor; + }; + const storeCommitFilesSnapshot = (snapshot: GitCommitFilesSnapshot, now: number): string => { + pruneExpiredCommitFilesSnapshots(now); + const snapshotId = NodeCrypto.randomBytes(18).toString("base64url"); + commitFilesSnapshots.set(snapshotId, snapshot); + while (commitFilesSnapshots.size > GIT_COMMIT_FILES_SNAPSHOT_MAX_SESSIONS) { + const oldest = commitFilesSnapshots.keys().next().value; + if (oldest === undefined) break; + deleteCommitFilesSnapshot(oldest); + } + return snapshotId; + }; + const deleteHistorySnapshot = (snapshotId: string): void => { + historySnapshots.delete(snapshotId); + const cursorIdsByOffset = historyCursorIdsBySnapshotOffset.get(snapshotId); + if (cursorIdsByOffset !== undefined) { + for (const cursor of cursorIdsByOffset.values()) { + historyCursors.delete(cursor); + } + historyCursorIdsBySnapshotOffset.delete(snapshotId); + } + }; + const pruneExpiredHistorySnapshots = (now: number): void => { + for (const [snapshotId, snapshot] of historySnapshots) { + if (snapshot.expiresAt <= now) deleteHistorySnapshot(snapshotId); + } + }; + const newHistoryCursor = (snapshotId: string, offset: number): string => { + const cursorIdsByOffset = + historyCursorIdsBySnapshotOffset.get(snapshotId) ?? new Map(); + const existingCursor = cursorIdsByOffset.get(offset); + if (existingCursor !== undefined) return existingCursor; + const cursor = NodeCrypto.randomBytes(18).toString("base64url"); + historyCursors.set(cursor, { snapshotId, offset }); + cursorIdsByOffset.set(offset, cursor); + historyCursorIdsBySnapshotOffset.set(snapshotId, cursorIdsByOffset); + return cursor; + }; + const storeHistorySnapshot = (snapshot: GitHistorySnapshot, now: number): string => { + pruneExpiredHistorySnapshots(now); + const snapshotId = NodeCrypto.randomBytes(18).toString("base64url"); + historySnapshots.set(snapshotId, snapshot); + while (historySnapshots.size > GIT_HISTORY_SNAPSHOT_MAX_SESSIONS) { + const oldest = historySnapshots.keys().next().value; + if (oldest === undefined) break; + deleteHistorySnapshot(oldest); + } + return snapshotId; + }; + const refSnapshots = new Map(); + const refCursors = new Map(); + const refCursorIdsBySnapshotOffset = new Map>(); + const refSnapshotIdsByGitCommonDir = new Map>(); + const deleteRefSnapshot = (snapshotId: string): void => { + const snapshot = refSnapshots.get(snapshotId); + refSnapshots.delete(snapshotId); + const cursorIdsByOffset = refCursorIdsBySnapshotOffset.get(snapshotId); + if (cursorIdsByOffset !== undefined) { + for (const cursor of cursorIdsByOffset.values()) { + refCursors.delete(cursor); + } + refCursorIdsBySnapshotOffset.delete(snapshotId); + } + if (snapshot !== undefined) { + const snapshotIds = refSnapshotIdsByGitCommonDir.get(snapshot.gitCommonDir); + snapshotIds?.delete(snapshotId); + if (snapshotIds?.size === 0) { + refSnapshotIdsByGitCommonDir.delete(snapshot.gitCommonDir); + } + } + }; + const pruneExpiredRefSnapshots = (now: number): void => { + for (const [snapshotId, snapshot] of refSnapshots) { + if (snapshot.expiresAt <= now) deleteRefSnapshot(snapshotId); + } + }; + const newRefCursor = (snapshotId: string, offset: number): string => { + const cursorIdsByOffset = + refCursorIdsBySnapshotOffset.get(snapshotId) ?? new Map(); + const existingCursor = cursorIdsByOffset.get(offset); + if (existingCursor !== undefined) return existingCursor; + const cursor = NodeCrypto.randomBytes(18).toString("base64url"); + refCursors.set(cursor, { snapshotId, offset }); + cursorIdsByOffset.set(offset, cursor); + refCursorIdsBySnapshotOffset.set(snapshotId, cursorIdsByOffset); + return cursor; + }; + const storeRefSnapshot = (snapshot: GitRefSnapshot, now: number): string => { + pruneExpiredRefSnapshots(now); + const snapshotId = NodeCrypto.randomBytes(18).toString("base64url"); + refSnapshots.set(snapshotId, snapshot); + const snapshotIds = + refSnapshotIdsByGitCommonDir.get(snapshot.gitCommonDir) ?? new Set(); + snapshotIds.add(snapshotId); + refSnapshotIdsByGitCommonDir.set(snapshot.gitCommonDir, snapshotIds); + while (refSnapshots.size > GIT_REF_SNAPSHOT_MAX_SESSIONS) { + const oldest = refSnapshots.keys().next().value; + if (oldest === undefined) break; + deleteRefSnapshot(oldest); + } + return snapshotId; + }; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( - function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const appendTruncationMarker = input.appendTruncationMarker ?? false; - - const runGitCommand = Effect.fn("runGitCommand")(function* () { - const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(FileSystem.FileSystem, fileSystem), + const executeRaw = Effect.fnUntraced(function* ( + input: GitVcsDriver.ExecuteGitInput & + Pick, + ) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : input.timeoutMs; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const appendTruncationMarker = input.appendTruncationMarker ?? false; + + const runGitCommand = Effect.fn("runGitCommand")(function* () { + const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to create Git trace monitor.", + cause, + }), + ), + ); + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + env: { + ...process.env, + ...input.env, + ...trace2Monitor.env, + }, + }), + ) + .pipe( Effect.mapError( (cause) => new GitCommandError({ ...gitCommandContext(commandInput), - detail: "Failed to create Git trace monitor.", + detail: "Failed to spawn Git process.", cause, }), ), ); - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - env: { - ...process.env, - ...input.env, - ...trace2Monitor.env, - }, - }), - ) - .pipe( + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + commandInput, + child.stdout, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStdoutLine, + input.captureStdout, + input.stdoutRecordSeparator, + input.onStdoutRecord, + ), + collectOutput( + commandInput, + child.stderr, + maxOutputBytes, + appendTruncationMarker, + input.progress?.onStderrLine, + true, + "\n", + undefined, + ), + child.exitCode.pipe( Effect.mapError( (cause) => new GitCommandError({ ...gitCommandContext(commandInput), - detail: "Failed to spawn Git process.", + detail: "Failed to read Git process exit code.", cause, }), ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput( - commandInput, - child.stdout, - maxOutputBytes, - appendTruncationMarker, - input.progress?.onStdoutLine, - ), - collectOutput( - commandInput, - child.stderr, - maxOutputBytes, - appendTruncationMarker, - input.progress?.onStderrLine, - ), - child.exitCode.pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to read Git process exit code.", - cause, - }), - ), - ), - input.stdin === undefined - ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError( - (cause) => - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Failed to write Git process input.", - cause, - }), - ), + ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError( + (cause) => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Failed to write Git process input.", + cause, + }), ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); - yield* trace2Monitor.flush; - - if (!input.allowNonZeroExit && exitCode !== 0) { - return yield* new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command exited with a non-zero status.", - exitCode, - stdoutLength: stdout.text.length, - stderrLength: stderr.text.length, - }); - } + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + yield* trace2Monitor.flush; - return { + if (!input.allowNonZeroExit && exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command exited with a non-zero status.", exitCode, - stdout: stdout.text, - stderr: stderr.text, - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated, - } satisfies GitVcsDriver.ExecuteGitResult; - }); - - const execution = runGitCommand().pipe(Effect.scoped); - if (timeoutMs === null) { - return yield* execution; + stdoutLength: stdout.text.length, + stderrLength: stderr.text.length, + }); } - return yield* execution.pipe( - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command timed out.", - }), - ), - onSome: Effect.succeed, - }), - ), - ); - }, - ); + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies GitVcsDriver.ExecuteGitResult; + }); + + const execution = runGitCommand().pipe(Effect.scoped); + if (timeoutMs === null) { + return yield* execution; + } + + return yield* execution.pipe( + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command timed out.", + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); const execute: GitVcsDriver.GitVcsDriver["Service"]["execute"] = (input) => executeRaw(input).pipe( @@ -883,7 +1243,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect => - execute({ + executeRaw({ operation, cwd, args, @@ -896,6 +1256,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? { appendTruncationMarker: options.appendTruncationMarker } : {}), ...(options.progress ? { progress: options.progress } : {}), + ...(options.captureStdout !== undefined ? { captureStdout: options.captureStdout } : {}), + ...(options.onStdoutRecord ? { onStdoutRecord: options.onStdoutRecord } : {}), + ...(options.stdoutRecordSeparator !== undefined + ? { stdoutRecordSeparator: options.stdoutRecordSeparator } + : {}), }).pipe( Effect.flatMap((result) => { if (options.allowNonZeroExit || result.exitCode === 0) { @@ -1145,14 +1510,22 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd)); + const normalizeGitCommonDirCacheKey = (gitCommonDir: string) => + hostPlatform === "win32" ? gitCommonDir.toLowerCase() : gitCommonDir; + const gitCommonDirCacheKey = (gitCommonDir: string) => + new GitCommonDirCacheKey({ + cacheIdentity: normalizeGitCommonDirCacheKey(gitCommonDir), + gitCommonDir, + }); const resolveRepositoryPaths = (cwd: string, refresh = false) => { const cacheKey = normalizeRepositoryPathsCacheKey(cwd); return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); }; const defaultBranchCache = yield* Cache.makeWith( - (gitCommonDir: string) => + (cacheKey: GitCommonDirCacheKey) => Effect.gen(function* () { + const { gitCommonDir } = cacheKey; const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; @@ -1177,8 +1550,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); const originExistsCache = yield* Cache.makeWith( - (gitCommonDir: string) => + (cacheKey: GitCommonDirCacheKey) => Effect.gen(function* () { + const { gitCommonDir } = cacheKey; const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; @@ -1202,7 +1576,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); - const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); + const cacheKey = repositoryPaths + ? gitCommonDirCacheKey(repositoryPaths.gitCommonDir) + : gitCommonDirCacheKey(normalizeRepositoryPathsCacheKey(cwd)); yield* Cache.invalidate(defaultBranchCache, cacheKey); yield* Cache.invalidate(originExistsCache, cacheKey); }); @@ -1224,7 +1600,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const statusRemoteRefreshFailureCounts = new Map(); const statusRemoteRefreshFailureKey = (cacheKey: StatusRemoteRefreshCacheKey) => - `${cacheKey.gitCommonDir}\0${cacheKey.remoteName}`; + `${cacheKey.cacheIdentity}\0${cacheKey.remoteName}`; const recordStatusRemoteRefreshFailure = (cacheKey: StatusRemoteRefreshCacheKey) => { const key = statusRemoteRefreshFailureKey(cacheKey); const nextCount = (statusRemoteRefreshFailureCounts.get(key) ?? 0) + 1; @@ -1273,6 +1649,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* Cache.get( statusRemoteRefreshCache, new StatusRemoteRefreshCacheKey({ + cacheIdentity: normalizeGitCommonDirCacheKey(gitCommonDir), gitCommonDir, remoteName: upstream.remoteName, }), @@ -1569,7 +1946,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? aheadCount : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) : 0; - return { isRepo: true, defaultBranch, @@ -1658,7 +2034,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), ); - const statusCacheKey = repositoryPaths?.gitCommonDir; + const statusCacheKey = repositoryPaths + ? gitCommonDirCacheKey(repositoryPaths.gitCommonDir) + : null; const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( [ executeGitWithStableDiagnostics( @@ -2767,11 +3145,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const currentEpoch = listRefsEpochByCommonDir.get(gitCommonDir); const snapshot = refresh || currentEpoch === undefined - ? // The refresh cache owns the complete snapshot read, rather than only the - // epoch bump. Slow repositories therefore remain singleflight for the - // entire Git scan even when more refresh requests arrive after the - // coalescing TTL would otherwise have elapsed. - yield* Cache.get( + ? yield* Cache.get( listRefsRefreshSnapshotCache, new GitRefsRefreshCacheKey({ gitCommonDir, generation }), ) @@ -2867,6 +3241,486 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + const listHistoryRefs: GitVcsDriver.GitVcsDriver["Service"]["listHistoryRefs"] = Effect.fn( + "listHistoryRefs", + )(function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { + refs: [], + currentRef: null, + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }; + } + const namespace: GitRefNamespace = input.namespace ?? "local"; + const query = input.query ?? null; + const worktreePath = repositoryPaths.worktreeRoot ?? path.normalize(input.cwd); + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + let snapshotId: string; + let offset: number; + let snapshot: GitRefSnapshot; + if (input.cursor) { + const continuation = refCursors.get(input.cursor); + const now = yield* Clock.currentTimeMillis; + pruneExpiredRefSnapshots(now); + const existingSnapshot = continuation ? refSnapshots.get(continuation.snapshotId) : undefined; + if ( + continuation === undefined || + existingSnapshot === undefined || + existingSnapshot.expiresAt <= now || + existingSnapshot.gitCommonDir !== repositoryPaths.gitCommonDir || + existingSnapshot.worktreePath !== worktreePath || + existingSnapshot.namespace !== namespace || + existingSnapshot.query !== query + ) { + return yield* new VcsSnapshotExpiredError({ + operation: "GitVcsDriver.listHistoryRefs", + cursor: input.cursor, + }); + } + snapshot = existingSnapshot; + refSnapshots.delete(continuation.snapshotId); + snapshot.expiresAt = now + GIT_REF_SNAPSHOT_TTL_MS; + refSnapshots.set(continuation.snapshotId, snapshot); + snapshotId = continuation.snapshotId; + offset = continuation.offset; + } else { + const refRoot = + namespace === "local" + ? "refs/heads/" + : namespace === "remote" + ? "refs/remotes/" + : "refs/tags/"; + const pattern = refRoot; + const parsedRefs: Array<{ readonly fullName: string; readonly ref: VcsHistoryRef }> = []; + const defaultRemoteRefs = new Set(); + const normalizedQuery = query?.toLowerCase() ?? null; + let currentRef: VcsHistoryRef | null = null; + const processRecord = (line: string) => + Effect.sync(() => { + const [ + fullName, + , + symbolicTarget = "", + upstreamName = "", + upstreamTrack = "", + worktreePath = "", + ] = line.replace(/^[\r\n]+/, "").split("\t"); + if (!fullName) return true; + if (namespace === "remote" && symbolicTarget.startsWith(refRoot)) { + defaultRemoteRefs.add(symbolicTarget); + return true; + } + if (symbolicTarget) return true; + const name = fullName.slice(refRoot.length); + if (name.length === 0) return true; + const { aheadCount, behindCount } = parseRefUpstreamTrack(upstreamTrack); + const ref: VcsHistoryRef = { + name, + current: namespace === "local" && name === repositoryPaths.currentBranch, + isDefault: false, + isRemote: namespace === "remote", + ...(namespace === "tag" ? { isTag: true } : {}), + worktreePath: worktreePath.length > 0 ? worktreePath : null, + ...(namespace === "local" && upstreamName.length > 0 ? { upstreamName } : {}), + ...(aheadCount === undefined ? {} : { aheadCount }), + ...(behindCount === undefined ? {} : { behindCount }), + }; + if (ref.current) currentRef = ref; + if (normalizedQuery !== null && !name.toLowerCase().includes(normalizedQuery)) + return true; + parsedRefs.push({ fullName, ref }); + return parsedRefs.length <= GIT_REF_SNAPSHOT_MAX_REFS; + }); + const refOutput = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.listHistoryRefs.snapshot", + input.cwd, + [ + "for-each-ref", + "--sort=refname", + "--format=%(refname)%09%(creatordate:unix)%09%(symref)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)%00", + pattern, + ], + { + captureStdout: false, + stdoutRecordSeparator: "\0", + onStdoutRecord: processRecord, + maxOutputBytes: GIT_REF_SNAPSHOT_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + fallbackErrorDetail: "Git ref snapshot enumeration failed.", + }, + ); + const isComplete = + !refOutput.stdoutTruncated && parsedRefs.length <= GIT_REF_SNAPSHOT_MAX_REFS; + const refs = parsedRefs.slice(0, GIT_REF_SNAPSHOT_MAX_REFS).map(({ fullName, ref }) => ({ + ...ref, + isDefault: namespace === "remote" && defaultRemoteRefs.has(fullName), + })); + const snapshotNow = yield* Clock.currentTimeMillis; + snapshot = { + gitCommonDir: repositoryPaths.gitCommonDir, + worktreePath, + namespace, + query, + refs, + currentRef, + hasPrimaryRemote: + namespace === "remote" && refs.some((ref) => ref.name.startsWith("origin/")), + isComplete, + expiresAt: snapshotNow + GIT_REF_SNAPSHOT_TTL_MS, + }; + snapshotId = storeRefSnapshot(snapshot, snapshotNow); + offset = 0; + } + const refs = snapshot.refs.slice(offset, offset + limit); + const hasMore = offset + refs.length < snapshot.refs.length; + if (!hasMore && input.cursor === undefined) deleteRefSnapshot(snapshotId); + return { + refs, + currentRef: snapshot.currentRef, + isRepo: true, + hasPrimaryRemote: snapshot.hasPrimaryRemote, + nextCursor: hasMore ? newRefCursor(snapshotId, offset + refs.length) : null, + isComplete: snapshot.isComplete, + }; + }); + + const getHistory: GitVcsDriver.GitVcsDriver["Service"]["getHistory"] = Effect.fn("getHistory")( + function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { + commits: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }; + } + + const limit = input.limit ?? GIT_HISTORY_DEFAULT_LIMIT; + const revision = input.revision ?? null; + const worktreePath = repositoryPaths.worktreeRoot ?? path.normalize(input.cwd); + let snapshotId: string; + let offset: number; + let snapshot: GitHistorySnapshot; + if (input.cursor) { + const continuation = historyCursors.get(input.cursor); + const now = yield* Clock.currentTimeMillis; + pruneExpiredHistorySnapshots(now); + const existingSnapshot = continuation + ? historySnapshots.get(continuation.snapshotId) + : undefined; + if ( + continuation === undefined || + existingSnapshot === undefined || + existingSnapshot.expiresAt <= now || + existingSnapshot.gitCommonDir !== repositoryPaths.gitCommonDir || + existingSnapshot.worktreePath !== worktreePath || + existingSnapshot.revision !== revision + ) { + return yield* new VcsSnapshotExpiredError({ + operation: "GitVcsDriver.getHistory", + cursor: input.cursor, + }); + } + snapshot = existingSnapshot; + historySnapshots.delete(continuation.snapshotId); + snapshot.expiresAt = now + GIT_HISTORY_SNAPSHOT_TTL_MS; + historySnapshots.set(continuation.snapshotId, snapshot); + snapshotId = continuation.snapshotId; + offset = continuation.offset; + } else { + const revisions = input.revision + ? ["--end-of-options", input.revision] + : repositoryPaths.currentBranch === null + ? yield* executeGitWithStableDiagnostics( + "GitVcsDriver.getHistory.head", + input.cwd, + ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + { allowNonZeroExit: true, fallbackErrorDetail: "git rev-parse HEAD failed" }, + ).pipe( + Effect.map((head) => [ + "--branches", + "--remotes", + "--tags", + ...(head.exitCode === 0 ? ["HEAD"] : []), + ]), + ) + : ["--branches", "--remotes", "--tags"]; + const output = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.getHistory.log", + input.cwd, + [ + "log", + "-z", + "--date-order", + "--decorate=short", + `--max-count=${GIT_HISTORY_SNAPSHOT_MAX_COMMITS + 1}`, + "--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%aI%x00%D", + ...revisions, + ], + { + allowNonZeroExit: true, + maxOutputBytes: GIT_HISTORY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + stdoutRecordSeparator: "\0", + fallbackErrorDetail: "git log failed", + }, + ); + if (output.exitCode !== 0) { + if (isUnbornHeadStderr(output.stderr)) { + return { + commits: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + }; + } + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.getHistory.log", + cwd: input.cwd, + args: [ + "log", + "-z", + "--date-order", + "--decorate=short", + `--max-count=${GIT_HISTORY_SNAPSHOT_MAX_COMMITS + 1}`, + "--format=%H%x00%P%x00%s%x00%an%x00%ae%x00%aI%x00%D", + ...revisions, + ], + }), + detail: "git log failed", + ...(output.exitCode === null ? {} : { exitCode: output.exitCode }), + stdoutLength: output.stdout.length, + stderrLength: output.stderr.length, + }); + } + const snapshotNow = yield* Clock.currentTimeMillis; + const commits = parseGitHistory(output.stdout); + snapshot = { + gitCommonDir: repositoryPaths.gitCommonDir, + worktreePath, + revision, + commits: commits.slice(0, GIT_HISTORY_SNAPSHOT_MAX_COMMITS), + capped: output.stdoutTruncated || commits.length > GIT_HISTORY_SNAPSHOT_MAX_COMMITS, + expiresAt: snapshotNow + GIT_HISTORY_SNAPSHOT_TTL_MS, + }; + snapshotId = storeHistorySnapshot(snapshot, snapshotNow); + offset = 0; + } + const page = snapshot.commits.slice(offset, offset + limit); + const hasMore = offset + page.length < snapshot.commits.length; + if (!hasMore && input.cursor === undefined) deleteHistorySnapshot(snapshotId); + + return { + commits: page, + isRepo: true, + nextCursor: hasMore ? newHistoryCursor(snapshotId, offset + page.length) : null, + hasMore, + capped: snapshot.capped, + }; + }, + ); + + const getCommitDetails: GitVcsDriver.GitVcsDriver["Service"]["getCommitDetails"] = Effect.fn( + "getCommitDetails", + )(function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { commit: null, isRepo: false }; + } + + const metadata = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.getCommitDetails.show", + input.cwd, + [ + "show", + "--no-patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--decorate=short", + "-z", + "--format=%H%x00%P%x00%s%x00%b%x00%an%x00%ae%x00%aI%x00%D", + input.hash, + ], + { + maxOutputBytes: GIT_COMMIT_DETAILS_MAX_OUTPUT_BYTES, + fallbackErrorDetail: "git show failed", + }, + ); + const commit = parseGitCommitDetails(metadata.stdout); + if (commit === null) { + return yield* new GitCommandError({ + operation: "GitVcsDriver.getCommitDetails.show", + command: "git show", + cwd: input.cwd, + detail: "git show returned incomplete commit metadata.", + }); + } + + return { + commit, + isRepo: true, + }; + }); + + const listCommitFiles: GitVcsDriver.GitVcsDriver["Service"]["listCommitFiles"] = Effect.fn( + "listCommitFiles", + )(function* (input) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { files: [], isRepo: false, nextCursor: null, hasMore: false, capped: false }; + } + const worktreePath = repositoryPaths.worktreeRoot ?? path.normalize(input.cwd); + const limit = input.limit ?? GIT_COMMIT_FILES_DEFAULT_LIMIT; + let snapshotId: string; + let offset: number; + let snapshot: GitCommitFilesSnapshot; + if (input.cursor) { + const continuation = commitFilesCursors.get(input.cursor); + const now = yield* Clock.currentTimeMillis; + pruneExpiredCommitFilesSnapshots(now); + const existing = continuation ? commitFilesSnapshots.get(continuation.snapshotId) : undefined; + if ( + continuation === undefined || + existing === undefined || + existing.expiresAt <= now || + existing.gitCommonDir !== repositoryPaths.gitCommonDir || + existing.worktreePath !== worktreePath || + existing.hash !== input.hash + ) { + return yield* new VcsSnapshotExpiredError({ + operation: "GitVcsDriver.listCommitFiles", + cursor: input.cursor, + }); + } + commitFilesSnapshots.delete(continuation.snapshotId); + snapshot = { + ...existing, + expiresAt: now + GIT_COMMIT_FILES_SNAPSHOT_TTL_MS, + }; + commitFilesSnapshots.set(continuation.snapshotId, snapshot); + snapshotId = continuation.snapshotId; + offset = continuation.offset; + } else { + const changes = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.listCommitFiles.diffTree", + input.cwd, + [ + "diff-tree", + "--no-commit-id", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--name-status", + "--no-renames", + "--diff-merges=first-parent", + "--root", + "-r", + "-z", + input.hash, + ], + { + maxOutputBytes: GIT_COMMIT_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + stdoutRecordSeparator: "\0", + fallbackErrorDetail: "git diff-tree failed", + }, + ); + const parsed = parseGitCommitChangedFiles(changes.stdout); + const snapshotNow = yield* Clock.currentTimeMillis; + snapshot = { + gitCommonDir: repositoryPaths.gitCommonDir, + worktreePath, + hash: input.hash, + files: parsed.slice(0, GIT_COMMIT_FILES_MAX_FILES), + capped: changes.stdoutTruncated || parsed.length > GIT_COMMIT_FILES_MAX_FILES, + expiresAt: snapshotNow + GIT_COMMIT_FILES_SNAPSHOT_TTL_MS, + }; + snapshotId = storeCommitFilesSnapshot(snapshot, snapshotNow); + offset = 0; + } + const files = snapshot.files.slice(offset, offset + limit); + const hasMore = offset + files.length < snapshot.files.length; + if (!hasMore && input.cursor === undefined) deleteCommitFilesSnapshot(snapshotId); + return { + files, + isRepo: true, + nextCursor: hasMore ? newCommitFilesCursor(snapshotId, offset + files.length) : null, + hasMore, + capped: snapshot.capped, + }; + }); + + const getCommitDiff: GitVcsDriver.GitVcsDriver["Service"]["getCommitDiff"] = Effect.fn( + "getCommitDiff", + )(function* (input: VcsGetCommitDiffInput) { + const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe( + Effect.catchTags({ + GitCommandError: (error) => + isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error), + }), + ); + if (repositoryPaths === null) { + return { diff: "", truncated: false, isRepo: false }; + } + + const output = yield* executeGitWithStableDiagnostics( + "GitVcsDriver.getCommitDiff.show", + input.cwd, + [ + "--literal-pathspecs", + "show", + "--format=", + "--patch", + "--diff-merges=first-parent", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), + input.hash, + "--", + ...(input.filePath ? [input.filePath] : []), + ], + { + timeoutMs: 10_000, + maxOutputBytes: REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + fallbackErrorDetail: "git show diff failed", + }, + ); + + return { diff: output.stdout, truncated: output.stdoutTruncated, isRepo: true }; + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input) { @@ -3312,18 +4166,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const invalidateRefSnapshots = (gitCommonDir: string | null): Effect.Effect => + Effect.sync(() => { + if (gitCommonDir === null) return; + const snapshotIds = refSnapshotIdsByGitCommonDir.get(gitCommonDir); + if (snapshotIds === undefined) return; + for (const snapshotId of snapshotIds) { + deleteRefSnapshot(snapshotId); + } + }); + + const captureRefSnapshotRepository = (cwd: string) => + resolveRepositoryPaths(cwd).pipe( + Effect.map((repositoryPaths) => repositoryPaths?.gitCommonDir ?? null), + Effect.catchTag("GitCommandError", (error) => + Effect.logWarning( + `GitVcsDriver: unable to resolve repository identity before ref mutation invalidation for ${cwd}: ${error.detail}`, + ).pipe(Effect.as(null)), + ), + ); + const withListRefsInvalidation = ( cwd: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe( - Effect.ensuring( - Effect.all([ - invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), - invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), - ]), - ), - ); + Effect.gen(function* () { + const refSnapshotGitCommonDir = yield* captureRefSnapshotRepository(cwd); + return yield* effect.pipe( + Effect.ensuring( + Effect.all([ + invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), + invalidateRefSnapshots(refSnapshotGitCommonDir), + invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), + ]), + ), + ); + }); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => @@ -3355,6 +4233,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* getReviewDiffFileContents, readConfigValue, listRefs, + listHistoryRefs, + getHistory, + getCommitDetails, + listCommitFiles, + getCommitDiff, createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)), fetchPullRequestBranch: (input) => withListRefsInvalidation(input.cwd, fetchPullRequestBranch(input)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a0c43a69a616..5afd3ea92c6a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2955,6 +2955,26 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", }), + [WS_METHODS.vcsListHistoryRefs]: (input) => + observeRpcEffect(WS_METHODS.vcsListHistoryRefs, gitWorkflow.listHistoryRefs(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsGetHistory]: (input) => + observeRpcEffect(WS_METHODS.vcsGetHistory, gitWorkflow.getHistory(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsGetCommitDetails]: (input) => + observeRpcEffect(WS_METHODS.vcsGetCommitDetails, gitWorkflow.getCommitDetails(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsListCommitFiles]: (input) => + observeRpcEffect(WS_METHODS.vcsListCommitFiles, gitWorkflow.listCommitFiles(input), { + "rpc.aggregate": "vcs", + }), + [WS_METHODS.vcsGetCommitDiff]: (input) => + observeRpcEffect(WS_METHODS.vcsGetCommitDiff, gitWorkflow.getCommitDiff(input), { + "rpc.aggregate": "vcs", + }), [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index aadc4570d00c..be72797a2b6f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -56,6 +56,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveGitHubIssueUrlPrefix, resolveDraftHeroState, scheduleEnvironmentReconnectWarning, startNewThreadForProject, @@ -543,6 +544,38 @@ describe("source-control surface capability", () => { }); }); +describe("resolveGitHubIssueUrlPrefix", () => { + it("uses the repository remote host for public and self-hosted GitHub issue citations", () => { + expect( + resolveGitHubIssueUrlPrefix({ + canonicalKey: "github.com/t3tools/t3code", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@github.com:t3tools/t3code.git", + }, + provider: "github", + owner: "t3tools", + name: "t3code", + }), + ).toBe("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/t3tools/t3code/issues/"); + + expect( + resolveGitHubIssueUrlPrefix({ + canonicalKey: "git.acme.test/platform/console", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://git.acme.test:8443/platform/console.git", + }, + provider: "github", + owner: "platform", + name: "console", + }), + ).toBe("https://git.acme.test:8443/platform/console/issues/"); + }); +}); + describe("draft hero submission transition", () => { it("does not dock the composer before a background submission", () => { expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b6f500f257bf..caa73a3d176a 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -6,6 +6,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type RepositoryIdentity, type MessageId, type ModelSelection, type ProviderInteractionMode, @@ -18,6 +19,7 @@ import { type ThreadLinkedPullRequest, type TurnId, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -70,6 +72,25 @@ export function resolveSourceControlSurfaceCapability(input: { return input.supported ? "ready" : "unavailable"; } +export function resolveGitHubIssueUrlPrefix( + repositoryIdentity: RepositoryIdentity | null | undefined, +): string | undefined { + if ( + repositoryIdentity?.provider !== "github" || + !repositoryIdentity.owner || + !repositoryIdentity.name + ) { + return undefined; + } + + const origin = detectSourceControlProviderFromRemoteUrl( + repositoryIdentity.locator.remoteUrl, + )?.baseUrl; + return origin + ? `${origin}/${repositoryIdentity.owner}/${repositoryIdentity.name}/issues/` + : undefined; +} + export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function agentControlledBrowserCloseConfirmation( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ce2a5b8c0d05..9622da00ea5a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -21,6 +21,8 @@ import { type ChatFileAttachment, DEFAULT_MODEL, type EnvironmentId, + type IssueLink, + type IssueLinkedPullRequest, type MessageId, type ModelSelection, type ProjectScript, @@ -421,6 +423,7 @@ import { resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveGitHubIssueUrlPrefix, resolveSourceControlSurfaceCapability, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -480,6 +483,7 @@ import { supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { hasGitHistoryCapability } from "../gitHistoryCapability"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; @@ -563,6 +567,7 @@ const PreviewPanel = lazy(() => import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), ); const DiffPanel = lazy(() => import("./DiffPanel")); +const RepositoryPanel = lazy(() => import("./RepositoryPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -2347,6 +2352,7 @@ export default function ChatView(props: ChatViewProps) { const issuesCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const supportsIssues = serverConfig?.environment.capabilities.issues === true; + const supportsGitHistory = hasGitHistoryCapability(serverConfig?.environment.capabilities); const issuesSurfaceCapabilityState = resolveSourceControlSurfaceCapability({ capabilityKnown: issuesCapabilityKnown, supported: supportsIssues, @@ -3280,6 +3286,7 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const gitHistoryIssueUrlPrefix = resolveGitHubIssueUrlPrefix(activeProject?.repositoryIdentity); const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null @@ -4139,6 +4146,27 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + const addRepositorySurface = useCallback(() => { + if (!activeThreadRef || !activeProject) return; + const view = + supportsGitHistory && isGitRepo && gitCwd !== null + ? "history" + : supportsIssues && activeProject.repositoryIdentity !== null + ? "issues" + : supportsPullRequests && activeProject.repositoryIdentity !== null + ? "pull-requests" + : null; + if (view === null) return; + useRightPanelStore.getState().openRepository(activeThreadRef, view); + }, [ + activeProject, + activeThreadRef, + gitCwd, + isGitRepo, + supportsGitHistory, + supportsIssues, + supportsPullRequests, + ]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -4242,6 +4270,67 @@ export default function ChatView(props: ChatViewProps) { openLinkedItem("pull-request", link), [openLinkedItem], ); + const selectRepositoryIssue = useCallback( + (target: { projectId: string; repository: string; number: number } | null) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().selectRepositoryIssue(activeThreadRef, target); + }, + [activeThreadRef], + ); + const selectRepositoryPullRequest = useCallback( + (target: { projectId: string; repository: string; number: number } | null) => { + if (!activeThreadRef) return; + useRightPanelStore.getState().selectRepositoryPullRequest(activeThreadRef, target); + }, + [activeThreadRef], + ); + const openLinkedItemInRepository = useCallback( + ( + kind: "issue" | "pull-request", + link: { provider?: string; repository: string; number: number; url: string }, + ) => { + const supported = kind === "issue" ? supportsIssues : supportsPullRequests; + const project = + activeThreadEnvironmentId === null + ? undefined + : findProjectForLink( + allProjects.filter( + (candidate) => candidate.environmentId === activeThreadEnvironmentId, + ), + link, + ); + if (!supported || project === undefined || project.id !== activeProject?.id) { + openLinkedItem(kind, link); + return; + } + const target = { + projectId: project.id, + ...(kind === "issue" && link.provider !== undefined ? { provider: link.provider } : {}), + repository: repositoryForProjectLink(project, link.repository), + number: link.number, + }; + if (kind === "issue") selectRepositoryIssue(target); + else selectRepositoryPullRequest(target); + }, + [ + activeProject?.id, + activeThreadEnvironmentId, + allProjects, + openLinkedItem, + selectRepositoryIssue, + selectRepositoryPullRequest, + supportsIssues, + supportsPullRequests, + ], + ); + const openLinkedIssueInRepository = useCallback( + (link: IssueLink) => openLinkedItemInRepository("issue", link), + [openLinkedItemInRepository], + ); + const openLinkedPullRequestInRepository = useCallback( + (link: IssueLinkedPullRequest) => openLinkedItemInRepository("pull-request", link), + [openLinkedItemInRepository], + ); const proactivePanelObservationRef = useRef | null>(null); @@ -8113,6 +8202,44 @@ export default function ChatView(props: ChatViewProps) { workspaceMutationId={workspaceMutationId} /> + ) : renderedRightPanelSurface?.kind === "git-history" && activeProject && activeProjectRef ? ( + + + useRightPanelStore.getState().selectRepositoryView(activeThreadRef, view) + } + selectedIssue={renderedRightPanelSurface.selectedIssue} + onSelectIssue={selectRepositoryIssue} + selectedPullRequest={renderedRightPanelSurface.selectedPullRequest} + onSelectPullRequest={selectRepositoryPullRequest} + handoffTarget={{ + kind: "existing-thread", + projectRef: activeProjectRef, + draftId: composerDraftTarget, + }} + onIssueStateChange={handleIssueTabStatusChange} + onOpenLinkedIssue={openLinkedIssueInRepository} + onOpenLinkedPullRequest={openLinkedPullRequestInRepository} + {...(gitHistoryIssueUrlPrefix ? { issueUrlPrefix: gitHistoryIssueUrlPrefix } : {})} + /> + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( @@ -8759,6 +8886,7 @@ export default function ChatView(props: ChatViewProps) { onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddRepository={addRepositorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -8766,6 +8894,11 @@ export default function ChatView(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + repositoryAvailable={ + (supportsGitHistory && isGitRepo && gitCwd !== null) || + ((supportsIssues || supportsPullRequests) && + (activeProject?.repositoryIdentity ?? null) !== null) + } filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} issueAvailable={issueSurfaceAvailable} @@ -8812,6 +8945,7 @@ export default function ChatView(props: ChatViewProps) { onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddRepository={addRepositorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -8819,6 +8953,11 @@ export default function ChatView(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + repositoryAvailable={ + (supportsGitHistory && isGitRepo && gitCwd !== null) || + ((supportsIssues || supportsPullRequests) && + (activeProject?.repositoryIdentity ?? null) !== null) + } filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} issueAvailable={issueSurfaceAvailable} diff --git a/apps/web/src/components/GitHistoryPanel.test.tsx b/apps/web/src/components/GitHistoryPanel.test.tsx new file mode 100644 index 000000000000..f314a5ab4d90 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.test.tsx @@ -0,0 +1,1899 @@ +import { + EnvironmentId, + VcsSnapshotExpiredError, + type GitCommitDetails, + type GitHistoryCommit, + type VcsGetHistoryResult, + type VcsListCommitFilesResult, + type VcsHistoryRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { visitElements } from "../test/reactElementTree"; + +type PageResult = + | { readonly _tag: "Failure"; readonly cause: Cause.Cause } + | { readonly _tag: "Success"; readonly waiting: false; readonly value: VcsGetHistoryResult }; + +type PageAtom = { readonly result: PageResult }; + +const effectQueue = vi.hoisted(() => ({ + cursor: 0, + dependencies: [] as Array | undefined>, + effects: [] as Array<() => void>, + stateUpdates: 0, +})); + +const historyState = vi.hoisted(() => ({ + commitDetails: null as GitCommitDetails | null, + diff: { diff: "", isRepo: true, truncated: false }, + getCommitDetails: vi.fn(), + listCommitFiles: vi.fn(), + commitFiles: { + files: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + } as VcsListCommitFilesResult, + commitFilesErrorCause: null as Cause.Cause | null, + commitFilesRefresh: vi.fn(), + getCommitDiff: vi.fn(), + getHistory: vi.fn(), + historyRevision: 0, + connection: { phase: "connected", generation: 1 } as { + readonly phase: string; + readonly generation: number; + }, + pages: new Map(), + refresh: vi.fn(), + refreshRefs: vi.fn(), + refreshRemoteRefs: vi.fn(), + refreshTags: vi.fn(), + toastAdd: vi.fn(), + refs: [] as ReadonlyArray, + refsResolved: true, + refsError: null as string | null, + remoteRefsError: null as string | null, + retryRefs: vi.fn(), + tags: [] as ReadonlyArray, + tagsError: null as string | null, + status: { aheadCount: 0, behindCount: 0 }, +})); + +const fontState = vi.hoisted(() => ({ interfaceSize: 16 })); + +vi.mock("../hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: (options?: { readonly onError?: (error: Error) => void }) => ({ + copyToClipboard: () => options?.onError?.(new Error("Clipboard permission was denied.")), + isCopied: false, + }), +})); + +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + selector: (settings: { readonly fontSizeInterface: number }) => Value, + ) => selector({ fontSizeInterface: fontState.interfaceSize }), +})); + +vi.mock("./ui/toast", () => ({ + stackedThreadToast: (toast: unknown) => toast, + toastManager: { add: historyState.toastAdd }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useDeferredValue: (value: Value) => value, + useEffect: (effect: () => void, dependencies?: ReadonlyArray) => { + const index = effectQueue.cursor++; + const previous = effectQueue.dependencies[index]; + if ( + previous !== undefined && + dependencies !== undefined && + previous.length === dependencies.length && + previous.every((value, dependencyIndex) => Object.is(value, dependencies[dependencyIndex])) + ) { + return; + } + effectQueue.dependencies[index] = dependencies; + effectQueue.effects.push(effect); + }, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: (initialValue: Value | (() => Value)) => { + const [value, setValue] = reactHookHarness.useState(initialValue); + return [ + value, + (nextValue: Value | ((previous: Value) => Value)) => { + effectQueue.stateUpdates += 1; + setValue(nextValue); + }, + ] as const; + }, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../hooks/useLocalStorage", () => ({ + useLocalStorage: () => [[], vi.fn()], +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, +})); + +vi.mock("effect/unstable/reactivity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AsyncResult: { + ...actual.AsyncResult, + value: (result: PageAtom["result"]) => + result._tag === "Success" ? Option.some(result.value) : Option.none(), + }, + Atom: { + ...actual.Atom, + make: ( + create: (get: (atom: PageAtom) => PageAtom["result"]) => ReadonlyArray, + ) => { + const value = create((atom) => atom.result); + return { + pipe: () => ({ value }), + value, + }; + }, + withLabel: () => (atom: unknown) => atom, + }, + }; +}); + +vi.mock("@legendapp/list/react", () => ({ + LegendList: () => null, +})); + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ resolvedTheme: "dark" }), +})); + +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: historyState.refresh }, +})); + +vi.mock("../state/queries", () => ({ + useDebouncedValue: (value: Value) => value, + usePaginatedHistoryRefs: (_target: unknown, options?: { readonly namespace?: string }) => { + const refs = options?.namespace === "tag" ? historyState.tags : historyState.refs; + return { + data: historyState.refsResolved + ? { + refs, + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + currentRef: refs.find((ref) => ref.current) ?? null, + isComplete: true, + } + : null, + refs, + error: + options?.namespace === "local" + ? historyState.refsError + : options?.namespace === "remote" + ? historyState.remoteRefsError + : historyState.tagsError, + isPending: false, + isFetchingNextPage: false, + loadNext: vi.fn(), + retry: historyState.retryRefs, + refresh: + options?.namespace === "tag" + ? historyState.refreshTags + : options?.namespace === "remote" + ? historyState.refreshRemoteRefs + : historyState.refreshRefs, + }; + }, +})); + +vi.mock("../state/query", () => ({ + useEnvironmentQuery: (target: { readonly kind?: string } | null) => { + const base = { error: null, errorCause: null, isPending: false, refresh: vi.fn() }; + if (target?.kind === "status") return { ...base, data: historyState.status }; + if (target?.kind === "commit-details") + return { ...base, data: { commit: historyState.commitDetails } }; + if (target?.kind === "commit-files") { + const errorCause = historyState.commitFilesErrorCause; + return { + ...base, + data: errorCause === null ? historyState.commitFiles : null, + error: errorCause === null ? null : "Git browsing snapshot expired.", + errorCause, + refresh: historyState.commitFilesRefresh, + }; + } + if (target?.kind === "commit-diff") return { ...base, data: historyState.diff }; + return { ...base, data: null }; + }, +})); + +vi.mock("../state/environments", () => ({ + useEnvironmentConnectionState: () => ({ data: historyState.connection }), +})); + +vi.mock("../state/vcs", () => ({ + vcsEnvironment: { + historyRevisionAtom: () => ({ value: historyState.historyRevision }), + getHistory: (target: { readonly input: { readonly cursor?: string } }) => { + historyState.getHistory(target); + const value = historyState.pages.get(target.input.cursor); + return { result: value ?? page([]) } satisfies PageAtom; + }, + getCommitDetails: (target: unknown) => { + historyState.getCommitDetails(target); + return { kind: "commit-details" }; + }, + listCommitFiles: (target: { readonly input: unknown }) => { + historyState.listCommitFiles(target); + return { kind: "commit-files", input: target.input }; + }, + getCommitDiff: (target: unknown) => { + historyState.getCommitDiff(target); + return { kind: "commit-diff" }; + }, + status: () => ({ kind: "status" }), + }, +})); + +import { + appendCommitFilesPage, + nextCommitFilesCursor, + nextCommitFilesRecoveryGeneration, + isWideHistoryLayout, +} from "./GitHistoryPanel"; +import GitHistoryPanel from "./GitHistoryPanel"; +import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; +import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; + +const environmentId = EnvironmentId.make("environment-local"); +const workspacePath = "C:/workspace"; +const historyPageSize = 100; +const primaryCommitHash = "aaaaaaaa11111111111111111111111111111111"; +const secondaryCommitHash = "bbbbbbbb22222222222222222222222222222222"; +const newestMatchingCommitHash = "cccccccc33333333333333333333333333333333"; + +function commit(hash: string, subject: string, authorName = "Ada Lovelace"): GitHistoryCommit { + return { + hash, + parentHashes: [], + subject, + authorName, + authorEmail: "ada@example.com", + authoredAt: "2026-08-01T12:00:00.000Z", + refs: [], + }; +} + +function page( + commits: ReadonlyArray, + options?: { + readonly capped?: boolean; + readonly hasMore?: boolean; + readonly nextCursor?: string | null; + }, +): PageResult { + return { + _tag: "Success", + waiting: false, + value: { + commits, + isRepo: true, + hasMore: options?.hasMore ?? false, + nextCursor: options?.nextCursor ?? null, + capped: options?.capped, + }, + }; +} + +const expiredHistoryPage = (): PageResult => ({ + _tag: "Failure", + cause: Cause.fail( + Object.assign(new Error("Git browsing snapshot expired."), { _tag: "VcsSnapshotExpiredError" }), + ), +}); + +function expiredSnapshotCause(): Cause.Cause { + return Cause.fail( + new VcsSnapshotExpiredError({ + operation: "GitVcsDriver.listCommitFiles", + cursor: "expired-cursor", + }), + ); +} + +function gitRef( + name: string, + options?: { + readonly aheadCount?: number; + readonly behindCount?: number; + readonly current?: boolean; + readonly isRemote?: boolean; + readonly isTag?: boolean; + readonly upstreamName?: string; + }, +): VcsHistoryRef { + return { + name, + current: options?.current ?? false, + isDefault: false, + isRemote: options?.isRemote ?? false, + ...(options?.isTag ? { isTag: true } : {}), + ...(options?.aheadCount === undefined ? {} : { aheadCount: options.aheadCount }), + ...(options?.behindCount === undefined ? {} : { behindCount: options.behindCount }), + ...(options?.upstreamName === undefined ? {} : { upstreamName: options.upstreamName }), + worktreePath: null, + }; +} + +function renderPanel(issueUrlPrefix?: string): ReactElement> { + hooks.beginRender(); + effectQueue.cursor = 0; + return GitHistoryPanel({ + environmentId, + cwd: workspacePath, + ...(issueUrlPrefix ? { issueUrlPrefix } : {}), + }) as ReactElement>; +} + +function flushEffects(): void { + const effects = effectQueue.effects.splice(0); + for (const effect of effects) effect(); +} + +function stubResizeObserver(initialWidth: number): (width: number) => void { + let notify: ((width: number) => void) | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (entries: ReadonlyArray<{ contentRect: { width: number } }>) => void) { + notify = (width) => callback([{ contentRect: { width } }]); + } + + disconnect() {} + + observe() { + notify?.(initialWidth); + } + }, + ); + return (width) => notify?.(width); +} + +function historyList(panel: ReactElement>) { + const list = visitElements( + panel, + (element) => + typeof element.props.estimatedItemSize === "number" && + typeof element.props.keyExtractor === "function", + ); + expect(list).not.toBeNull(); + return list as ReactElement<{ + readonly data: ReadonlyArray<{ + readonly commit: GitHistoryCommit; + readonly graph: { readonly edges: ReadonlyArray<{ readonly kind: string }> }; + }>; + readonly renderItem: (props: { + readonly item: { + readonly commit: GitHistoryCommit; + readonly graph: { readonly edges: ReadonlyArray }; + }; + }) => ReactElement>; + readonly estimatedItemSize: number; + readonly onEndReached?: () => void; + readonly recycleItems?: boolean; + }>; +} + +function loadMoreHistory(panel: ReactElement>): void { + const footer = visitElements( + panel, + (element) => + element.props.className === "flex shrink-0 justify-center border-t border-border/50 p-2", + ); + const loadMore = visitElements(footer, (element) => element.props.children === "Load more"); + expect(loadMore).not.toBeNull(); + (loadMore?.props.onClick as (() => void) | undefined)?.(); +} + +function renderComponent( + element: ReactElement>, +): ReactElement> { + const component = element.type as unknown as ( + props: Record, + ) => ReactElement>; + return component(element.props); +} + +function componentTree( + panel: ReactElement>, + componentName: string, + props?: Partial>, +): ReactElement> { + const component = visitElements( + panel, + (element) => + typeof element.type === "function" && + element.type.name === componentName && + Object.entries(props ?? {}).every(([key, value]) => element.props[key] === value), + ); + expect(component).not.toBeNull(); + return renderComponent(component as ReactElement>); +} + +function componentElement( + panel: ReactElement>, + componentName: string, +): ReactElement> { + const component = visitElements( + panel, + (element) => typeof element.type === "function" && element.type.name === componentName, + ); + expect(component).not.toBeNull(); + return component as ReactElement>; +} + +describe("GitHistoryPanel", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + hooks.reset(); + fontState.interfaceSize = 16; + effectQueue.cursor = 0; + effectQueue.dependencies.length = 0; + effectQueue.effects.length = 0; + effectQueue.stateUpdates = 0; + historyState.commitDetails = null; + historyState.diff = { diff: "", isRepo: true, truncated: false }; + historyState.getCommitDetails.mockReset(); + historyState.listCommitFiles.mockReset(); + historyState.commitFiles = { + files: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + }; + historyState.commitFilesErrorCause = null; + historyState.commitFilesRefresh.mockReset(); + historyState.getCommitDiff.mockReset(); + historyState.getHistory.mockReset(); + historyState.historyRevision = 0; + historyState.connection = { phase: "connected", generation: 1 }; + historyState.pages.clear(); + historyState.refresh.mockReset(); + historyState.refreshRefs.mockReset(); + historyState.refreshRemoteRefs.mockReset(); + historyState.refreshTags.mockReset(); + historyState.toastAdd.mockReset(); + historyState.refs = []; + historyState.refsResolved = true; + historyState.refsError = null; + historyState.remoteRefsError = null; + historyState.retryRefs.mockReset(); + historyState.tags = []; + historyState.tagsError = null; + historyState.status = { aheadCount: 0, behindCount: 0 }; + }); + + it("does not start an all-refs history request while the current ref is unresolved", () => { + historyState.refsResolved = false; + + const panel = renderPanel(); + + expect(historyState.getHistory).not.toHaveBeenCalled(); + expect( + visitElements(panel, (element) => element.props.children === "Loading history…"), + ).not.toBeNull(); + }); + + it("shows an initial ref failure with a reachable retry", () => { + historyState.refsResolved = false; + historyState.refsError = "Could not load refs."; + + const panel = renderPanel(); + const refsPane = componentElement(panel, "GitRefsPane"); + + expect(historyState.getHistory).not.toHaveBeenCalled(); + expect(refsPane.props.refPaginationError).toBe("Could not load refs."); + expect( + visitElements(panel, (element) => element.props.children === "Could not load refs."), + ).not.toBeNull(); + const retry = visitElements(panel, (element) => element.props.children === "Retry refs"); + expect(retry).not.toBeNull(); + (retry?.props.onClick as () => void)(); + expect(historyState.retryRefs).toHaveBeenCalledOnce(); + }); + + it("starts current branch history after an initial ref failure recovers", () => { + historyState.refsResolved = false; + historyState.refsError = "Could not load refs."; + + renderPanel(); + expect(historyState.getHistory).not.toHaveBeenCalled(); + + historyState.refsError = null; + historyState.refsResolved = true; + historyState.refs = [gitRef("main", { current: true })]; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("keeps the current branch history visible when a remote refs request fails", () => { + historyState.refs = [gitRef("main", { current: true })]; + historyState.remoteRefsError = "Could not load remote refs."; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("keeps the current branch history visible when a tag refs request fails", () => { + historyState.refs = [gitRef("main", { current: true })]; + historyState.tagsError = "Could not load tags."; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("notices a server-capped history result before the client page limit", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "Initial")], { capped: true }), + ); + + const panel = renderPanel(); + + expect( + visitElements( + panel, + (element) => element.props.children === "History results were capped by the server.", + ), + ).not.toBeNull(); + }); + + it("restarts history after the environment connection generation changes", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + renderPanel(); + historyState.getHistory.mockClear(); + historyState.connection = { phase: "connected", generation: 2 }; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); + + it("keeps the narrow branches sheet open when the history target rekeys", () => { + stubResizeObserver(539); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branches = visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + ); + (branches?.props.onClick as (() => void) | undefined)?.(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + )?.props["aria-expanded"], + ).toBe(true); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + )?.props["aria-expanded"], + ).toBe(true); + }); + + it("closes the narrow details sheet when the history target rekeys", () => { + stubResizeObserver(539); + const historyCommit = commit(primaryCommitHash, "Initial"); + historyState.pages.set(undefined, page([historyCommit])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + const details = visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + ); + (details?.props.onClick as (() => void) | undefined)?.(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + )?.props["aria-expanded"], + ).toBe(true); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + )?.props["aria-expanded"], + ).toBe(false); + }); + + it("restarts the first history page after a typed continuation expiry", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", expiredHistoryPage()); + + const first = renderPanel(); + loadMoreHistory(first); + renderPanel(); + flushEffects(); + renderPanel(); + + const requests = historyState.getHistory.mock.calls.map(([target]) => target); + expect(requests).toContainEqual({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, cursor: "history-page-2", limit: historyPageSize }, + }); + expect(requests.at(-1)).toEqual({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); + + it("recovers a second continuation expiry once after a successful recovery", () => { + const firstPage = page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }); + historyState.pages.set(undefined, firstPage); + historyState.pages.set("history-page-2", expiredHistoryPage()); + + const first = renderPanel(); + loadMoreHistory(first); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + + const recovered = renderPanel(); + loadMoreHistory(recovered); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + + const generations = historyState.getHistory.mock.calls.map(([target]) => target.cacheKey); + expect(generations).toContain(2); + expect(generations).not.toContain(3); + expect(generations.at(-1)).toBe(2); + }); + + it("rekeys open history reads when the shared VCS history revision changes", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + const details = renderPanel(); + const detailsPane = componentTree(details, "CommitDetailsPane"); + const showDiff = visitElements( + detailsPane, + (element) => + typeof element.props.onClick === "function" && + JSON.stringify(element.props.children).includes("View all changes"), + ); + (showDiff?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + + historyState.historyRevision = 1; + renderPanel(); + + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + expect(historyState.listCommitFiles).toHaveBeenLastCalledWith({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash, limit: 100 }, + }); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + }); + + it("discards loaded history cursor pages when the environment reconnects", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Second")])); + + const initial = renderPanel(); + loadMoreHistory(initial); + renderPanel(); + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + + expect( + historyState.getHistory.mock.calls.slice(-3).map(([target]) => target.input.cursor), + ).toEqual([undefined, "history-page-2", undefined]); + }); + + it("keeps graph rows stable when history query results have not changed", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "First")])); + + const first = historyList(renderPanel()); + const second = historyList(renderPanel()); + + expect(second.props.data).toBe(first.props.data); + }); + + it("keeps row separators out of the graph column", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + const content = visitElements( + historyRow, + (element) => + typeof element.props.className === "string" && + element.props.className.includes("grid-cols-") && + element.props.className.includes("border-b"), + ); + + expect(historyRow.props.className).not.toContain("border-b"); + expect(graph).not.toBeNull(); + expect(content).not.toBeNull(); + + const graphRoot = renderComponent(graph!); + const graphSvg = visitElements(graphRoot, (element) => element.type === "svg"); + expect(graphRoot.props.className).not.toContain("overflow-visible"); + expect(graphSvg).not.toBeNull(); + expect(graphSvg!.props.className).toBe("absolute inset-0"); + expect(graphSvg!.props.height).toBe(30); + expect(graphSvg!.props.viewBox).toBe("0 0 44 30"); + }); + + it("scales the list and graph geometry with the interface font size", () => { + fontState.interfaceSize = 20; + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + const graphRoot = renderComponent(graph!); + const graphSvg = visitElements(graphRoot, (element) => element.type === "svg"); + + expect(list.props.estimatedItemSize).toBe(37.5); + expect(historyRow.props.style).toMatchObject({ height: 37.5 }); + expect(graphSvg!.props.height).toBe(37.5); + expect(graphSvg!.props.viewBox).toBe("0 0 44 37.5"); + }); + + it("keeps graph paths within each paint-contained row while joining adjacent lanes", () => { + fontState.interfaceSize = 20; + const parent = commit(secondaryCommitHash, "Parent"); + const child = { + ...commit(primaryCommitHash, "Child"), + parentHashes: [parent.hash], + }; + historyState.pages.set(undefined, page([child, parent])); + + const list = historyList(renderPanel()); + const graphRoots = list.props.data.map((row) => { + const historyRow = renderComponent(list.props.renderItem({ item: row })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + return renderComponent(graph!); + }); + const childSvg = visitElements(graphRoots[0], (element) => element.type === "svg"); + const parentSvg = visitElements(graphRoots[1], (element) => element.type === "svg"); + const childParentEdge = visitElements( + graphRoots[0], + (element) => element.props["data-edge-kind"] === "parent", + ); + const parentIncoming = visitElements( + graphRoots[1], + (element) => element.type === "line" && element.props.y1 === "0", + ); + + expect(childSvg).not.toBeNull(); + expect(parentSvg).not.toBeNull(); + expect(childSvg!.props.className).toBe("absolute inset-0"); + expect(childSvg!.props.viewBox).toBe("0 0 44 37.5"); + expect(childSvg!.props.height).toBe(37.5); + expect(childParentEdge!.props.d).toContain("L 11.5 37.5"); + expect(childParentEdge!.props.strokeLinecap).toBe("square"); + expect(parentIncoming).not.toBeNull(); + expect(parentIncoming!.props.strokeLinecap).toBe("square"); + }); + + it("keeps missing-parent graph paths dashed without boundary overlays", () => { + const child = { + ...commit(primaryCommitHash, "Child"), + parentHashes: [secondaryCommitHash], + }; + historyState.pages.set(undefined, page([child])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + const graphRoot = renderComponent(graph!); + const missingParent = visitElements( + graphRoot, + (element) => + element.props["data-edge-kind"] === "parent" && element.props.strokeDasharray === "3 2", + ); + + expect(missingParent).not.toBeNull(); + expect(missingParent!.props.strokeLinecap).toBe("butt"); + }); + + it("creates a fresh changed-file first-page generation after each recovered snapshot expiry", () => { + const errorCause = expiredSnapshotCause(); + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + historyState.commitFiles = { + files: [{ status: "M", path: "stale.ts" }], + isRepo: true, + nextCursor: "stale-cursor", + hasMore: true, + capped: true, + }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + flushEffects(); + expect(componentElement(renderPanel(), "CommitDetailsPane").props).toMatchObject({ + files: [{ status: "M", path: "stale.ts" }], + filesCapped: true, + filesHasMore: true, + }); + + historyState.commitFilesErrorCause = errorCause; + renderPanel(); + flushEffects(); + expect(componentElement(renderPanel(), "CommitDetailsPane").props).toMatchObject({ + files: [], + filesCapped: false, + filesHasMore: false, + }); + + expect( + nextCommitFilesRecoveryGeneration({ errorCause, generation: 0, recoveryInFlight: false }), + ).toBe(1); + expect( + nextCommitFilesRecoveryGeneration({ errorCause, generation: 1, recoveryInFlight: true }), + ).toBeNull(); + expect( + nextCommitFilesRecoveryGeneration({ errorCause, generation: 1, recoveryInFlight: false }), + ).toBe(2); + }); + + it("renders populated history rows through the virtualized list", () => { + historyState.pages.set( + undefined, + page([ + commit(primaryCommitHash, "Add Git history panel"), + commit(secondaryCommitHash, "Expose commit graph", "Grace Hopper"), + ]), + ); + + const panel = renderPanel(); + const list = historyList(panel); + + expect(list.props.data.map((row) => row.commit.subject)).toEqual([ + "Add Git history panel", + "Expose commit graph", + ]); + expect(list.props.recycleItems).toBe(false); + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); + + it("keeps the desktop refs and details workflow available at ordinary desktop widths", () => { + expect(isWideHistoryLayout(1119)).toBe(false); + expect(isWideHistoryLayout(1120)).toBe(true); + }); + + it("coalesces pane pointer moves per frame and flushes the final move on pointer up", () => { + const onMove = vi.fn(); + const frames = new Map(); + const cancelAnimationFrame = vi.fn((id: number) => frames.delete(id)); + let nextFrameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frameId = ++nextFrameId; + frames.set(frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + hooks.beginRender(); + const handle = PaneResizeHandle({ + label: "Resize branches pane", + value: 320, + min: 240, + max: 480, + onMove, + onReset: vi.fn(), + }) as ReactElement>; + const target = { releasePointerCapture: vi.fn(), setPointerCapture: vi.fn() }; + const onPointerDown = handle.props.onPointerDown as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + const onPointerMove = handle.props.onPointerMove as (event: { + readonly clientX: number; + readonly pointerId: number; + }) => void; + const onPointerUp = handle.props.onPointerUp as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + + onPointerDown({ clientX: 100, currentTarget: target, pointerId: 1 }); + onPointerMove({ clientX: 104, pointerId: 1 }); + onPointerMove({ clientX: 110, pointerId: 1 }); + + expect(onMove).not.toHaveBeenCalled(); + expect(frames).toHaveLength(1); + const firstFrame = frames.get(1); + expect(firstFrame).toBeDefined(); + frames.delete(1); + firstFrame?.(0); + expect(onMove).toHaveBeenCalledOnce(); + expect(onMove).toHaveBeenLastCalledWith(10); + + onPointerMove({ clientX: 114, pointerId: 1 }); + onPointerUp({ clientX: 120, currentTarget: target, pointerId: 1 }); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(2); + expect(onMove).toHaveBeenCalledTimes(2); + expect(onMove).toHaveBeenLastCalledWith(10); + expect(frames).toHaveLength(0); + }); + + it("flushes pending pane movement once when pointer capture is lost before its frame", () => { + const onMove = vi.fn(); + const frames = new Map(); + const cancelAnimationFrame = vi.fn((id: number) => frames.delete(id)); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(1, callback); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + hooks.beginRender(); + const handle = PaneResizeHandle({ + label: "Resize branches pane", + value: 320, + min: 240, + max: 480, + onMove, + onReset: vi.fn(), + }) as ReactElement>; + const target = { setPointerCapture: vi.fn() }; + const onPointerDown = handle.props.onPointerDown as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + const onPointerMove = handle.props.onPointerMove as (event: { + readonly clientX: number; + readonly pointerId: number; + }) => void; + const onLostPointerCapture = handle.props.onLostPointerCapture as (event: { + readonly pointerId: number; + }) => void; + + onPointerDown({ clientX: 100, currentTarget: target, pointerId: 1 }); + onPointerMove({ clientX: 110, pointerId: 1 }); + const pendingFrame = frames.get(1); + onLostPointerCapture({ pointerId: 1 }); + pendingFrame?.(0); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove).toHaveBeenLastCalledWith(10); + }); + + it("does not rerender history children for repeated wide widths without pane clamping", () => { + const notify = stubResizeObserver(1400); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + effectQueue.stateUpdates = 0; + + notify(1399); + notify(1398); + notify(1397); + + expect(effectQueue.stateUpdates).toBe(0); + }); + + it("constrains both side panes when widening branches at the minimum wide layout", () => { + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branchHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + + const constrained = renderPanel(); + const refsPane = componentElement(constrained, "GitRefsPane"); + const detailsPane = componentElement(constrained, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(304); + }); + + it("constrains commit details when resetting at the minimum wide layout", () => { + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branchHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + const detailsHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + (detailsHandle?.props.onReset as (() => void) | undefined)?.(); + + const constrained = renderPanel(); + const refsPane = componentElement(constrained, "GitRefsPane"); + const detailsPane = componentElement(constrained, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(304); + }); + + it("clamps expanded side panes when a wide history panel shrinks", () => { + const notify = stubResizeObserver(1400); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const expanded = renderPanel(); + const branchHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + const detailsHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + expect(branchHandle).not.toBeNull(); + expect(detailsHandle).not.toBeNull(); + expect(renderComponent(branchHandle!).props.className).not.toContain("hidden"); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); + + notify(1120); + renderPanel(); + flushEffects(); + const shrunken = renderPanel(); + const refsPane = componentElement(shrunken, "GitRefsPane"); + const detailsPane = componentElement(shrunken, "CommitDetailsPane"); + const refsWidth = (refsPane.props.style as { width: number }).width; + const detailsWidth = (detailsPane.props.style as { width: number }).width; + + expect(refsWidth + detailsWidth).toBeLessThanOrEqual(784); + }); + + it("preserves wide pane widths through a narrow layout transition", () => { + const notify = stubResizeObserver(1600); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const expanded = renderPanel(); + const branchHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + const detailsHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); + + notify(1119); + renderPanel(); + flushEffects(); + notify(1600); + renderPanel(); + flushEffects(); + const restored = renderPanel(); + const refsPane = componentElement(restored, "GitRefsPane"); + const detailsPane = componentElement(restored, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(720); + }); + + it("filters history by commit message", () => { + historyState.pages.set( + undefined, + page([ + commit(primaryCommitHash, "Prepare release"), + commit(secondaryCommitHash, "Fix graph layout"), + ]), + ); + + const panel = renderPanel(); + const filter = visitElements( + panel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + expect(filter).not.toBeNull(); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ + target: { value: "release" }, + }); + + const filtered = historyList(renderPanel()); + expect(filtered.props.data.map((row) => row.commit.subject)).toEqual(["Prepare release"]); + }); + + it("keeps a history search to the loaded page until the user requests older commits", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "Fix graph layout")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Release notes")])); + + const initialPanel = renderPanel(); + flushEffects(); + const filter = visitElements( + initialPanel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ target: { value: "release" } }); + renderPanel(); + flushEffects(); + const filteredPanel = renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledTimes(1); + expect(historyState.getHistory).not.toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + cursor: "history-page-2", + limit: historyPageSize, + }, + }); + const searchOlder = visitElements( + filteredPanel, + (element) => element.props.children === "Search older commits", + ); + expect(searchOlder).not.toBeNull(); + }); + + it("clears a hash search from the clear button or Escape key", () => { + const matchingCommitHash = "0acf007c21111111111111111111111111111111"; + historyState.pages.set( + undefined, + page([ + commit(matchingCommitHash, "Matching commit"), + commit(secondaryCommitHash, "Other commit"), + ]), + ); + + const search = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const changeSearch = search?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined; + changeSearch?.({ target: { value: "0acf007c2" } }); + expect(historyList(renderPanel()).props.data).toHaveLength(1); + + const clear = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Clear Git history search", + ); + (clear?.props.onClick as (() => void) | undefined)?.(); + expect(historyList(renderPanel()).props.data).toHaveLength(2); + + changeSearch?.({ target: { value: "0acf007c2" } }); + const filteredSearch = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const preventDefault = vi.fn(); + ( + filteredSearch?.props.onKeyDown as + | ((event: { readonly key: string; readonly preventDefault: () => void }) => void) + | undefined + )?.({ key: "Escape", preventDefault }); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(historyList(renderPanel()).props.data).toHaveLength(2); + }); + + it("keeps a trailing-space search visible, literal, and clearable", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Add Git history panel")])); + + const search = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const changeSearch = search?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined; + const historyQueryCallCount = historyState.getHistory.mock.calls.length; + changeSearch?.({ target: { value: "fix " } }); + + const filteredPanel = renderPanel(); + const filteredSearch = visitElements( + filteredPanel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + expect(filteredSearch).not.toBeNull(); + expect(filteredSearch?.props.value).toBe("fix "); + expect(historyState.getHistory).toHaveBeenCalledTimes(historyQueryCallCount); + const clear = visitElements( + filteredPanel, + (element) => element.props["aria-label"] === "Clear Git history search", + ); + expect(clear).not.toBeNull(); + (clear?.props.onClick as (() => void) | undefined)?.(); + expect(historyList(renderPanel()).props.data).toHaveLength(1); + }); + + it("rebuilds the graph from the text-filtered commits", () => { + historyState.pages.set( + undefined, + page([ + { + ...commit(newestMatchingCommitHash, "Match newest"), + parentHashes: ["b"], + }, + { + ...commit(secondaryCommitHash, "Hidden parent"), + parentHashes: ["a"], + }, + commit(primaryCommitHash, "Match oldest"), + ]), + ); + + const filter = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ target: { value: "match" } }); + + const filtered = historyList(renderPanel()); + expect(filtered.props.data.map((row) => row.commit.hash)).toEqual([ + newestMatchingCommitHash, + primaryCommitHash, + ]); + expect(filtered.props.data.flatMap((row) => row.graph.edges)).not.toContainEqual( + expect.objectContaining({ kind: "parent" }), + ); + }); + + it("deduplicates overlapping pages and keeps Load more in the scrolling column footer", () => { + const duplicate = commit(primaryCommitHash, "Initial commit"); + historyState.pages.set( + undefined, + page([duplicate], { hasMore: true, nextCursor: "next-page" }), + ); + historyState.pages.set( + "next-page", + page([duplicate, commit(secondaryCommitHash, "Second page commit")]), + ); + + const panel = renderPanel(); + const scrollingColumn = visitElements( + panel, + (element) => element.props.className === "flex h-full min-w-0 flex-col", + ); + expect(scrollingColumn).not.toBeNull(); + const footer = visitElements( + scrollingColumn, + (element) => + element.props.className === "flex shrink-0 justify-center border-t border-border/50 p-2", + ); + expect(footer).not.toBeNull(); + const loadMore = visitElements(footer, (element) => element.props.children === "Load more"); + expect(loadMore).not.toBeNull(); + (loadMore?.props.onClick as (() => void) | undefined)?.(); + + const expanded = historyList(renderPanel()); + expect(expanded.props.data.map((row) => row.commit.hash)).toEqual([ + primaryCommitHash, + secondaryCommitHash, + ]); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + cursor: "next-page", + limit: historyPageSize, + }, + }); + }); + + it("filters, expands, and selects nested branches while showing the branch commit count", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + historyState.refs = [ + gitRef("feature/api"), + gitRef("feature/ui", { + aheadCount: 10, + current: true, + upstreamName: "origin/feature/ui", + }), + gitRef("development", { + aheadCount: 3, + behindCount: 2, + upstreamName: "origin/development", + }), + gitRef("main"), + ]; + historyState.status = { aheadCount: 3, behindCount: 2 }; + + const initial = renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/heads/feature/ui", + }, + }); + const initialPane = componentTree(initial, "GitRefsPane"); + const initialList = componentElement(initialPane, "LegendList"); + expect(initialList.props.recycleItems).toBe(true); + const initialRows = initialList.props.data as Array<{ + readonly key: string; + readonly open?: boolean; + }>; + const featureFolder = initialRows.find((row) => row.key === "local:feature"); + expect(featureFolder?.open).toBe(false); + + const renderInitialRow = initialList.props.renderItem as (props: { + readonly item: (typeof initialRows)[number]; + }) => ReactElement>; + const featureFolderRow = renderInitialRow({ item: featureFolder! }); + const featureFolderButton = visitElements( + featureFolderRow, + (element) => element.props["aria-expanded"] === false, + ); + (featureFolderButton?.props.onClick as (() => void) | undefined)?.(); + const expanded = renderPanel(); + const expandedPane = componentTree(expanded, "GitRefsPane"); + const expandedList = componentElement(expandedPane, "LegendList"); + const expandedRows = expandedList.props.data as Array<{ readonly key: string }>; + expect(expandedRows.map((row) => row.key)).toContain("refs/heads/feature/ui"); + const renderExpandedRow = expandedList.props.renderItem as (props: { + readonly item: (typeof expandedRows)[number]; + }) => ReactElement>; + const uiBranch = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/heads/feature/ui")!, + }); + expect( + visitElements( + uiBranch, + (element) => element.props.children === "10 commits ahead of origin/feature/ui", + ), + ).not.toBeNull(); + const developmentBranch = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/heads/development")!, + }); + expect( + visitElements( + developmentBranch, + (element) => element.props.children === "3 commits ahead of origin/development", + ), + ).not.toBeNull(); + const developmentButton = visitElements( + developmentBranch, + (element) => + element.props["aria-label"] === + "development. 3 commits ahead of upstream origin/development. 2 commits behind upstream origin/development.", + ); + expect(developmentButton?.props["aria-label"]).toBe( + "development. 3 commits ahead of upstream origin/development. 2 commits behind upstream origin/development.", + ); + expect( + visitElements( + developmentBranch, + (element) => element.props.children === "2 commits behind origin/development", + ), + ).not.toBeNull(); + const uiBranchButton = visitElements( + uiBranch, + (element) => + element.props["aria-label"] === + "feature/ui. 10 commits ahead of upstream origin/feature/ui.", + ); + (uiBranchButton?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/heads/feature/ui", + }, + }); + + const filter = visitElements( + expandedPane, + (element) => element.props["aria-label"] === "Filter branches and tags", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ + target: { value: "api" }, + }); + const filtered = renderPanel(); + const filteredPane = componentTree(filtered, "GitRefsPane"); + const filteredList = componentElement(filteredPane, "LegendList"); + const filteredRows = filteredList.props.data as Array<{ readonly key: string }>; + expect(filteredRows.map((row) => row.key)).toContain("refs/heads/feature/api"); + expect(filteredRows.map((row) => row.key)).not.toContain("refs/heads/feature/ui"); + }); + + it("lists and selects tags from the refs snapshot even when history has no tag decorations", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + historyState.tags = [gitRef("v1.2.3", { isTag: true })]; + + const initial = renderPanel(); + const initialPane = componentTree(initial, "GitRefsPane"); + const initialList = componentElement(initialPane, "LegendList"); + const initialRows = initialList.props.data as Array<{ readonly key: string }>; + const renderInitialRow = initialList.props.renderItem as (props: { + readonly item: (typeof initialRows)[number]; + }) => ReactElement>; + const tagsSectionRow = renderInitialRow({ + item: initialRows.find((row) => row.key === "section:tags")!, + }); + const tagsSectionButton = visitElements( + tagsSectionRow, + (element) => element.props["aria-expanded"] === false, + ); + (tagsSectionButton?.props.onClick as (() => void) | undefined)?.(); + + const expanded = renderPanel(); + const expandedPane = componentTree(expanded, "GitRefsPane"); + const expandedList = componentElement(expandedPane, "LegendList"); + const expandedRows = expandedList.props.data as Array<{ readonly key: string }>; + expect(expandedRows.map((row) => row.key)).toContain("refs/tags/v1.2.3"); + const renderExpandedRow = expandedList.props.renderItem as (props: { + readonly item: (typeof expandedRows)[number]; + }) => ReactElement>; + const tagRow = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/tags/v1.2.3")!, + }); + const tagButton = visitElements(tagRow, (element) => element.props["aria-label"] === "v1.2.3"); + (tagButton?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/tags/v1.2.3", + }, + }); + }); + + it("opens the full commit diff from selected commit details", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + + const detailsPane = componentTree(renderPanel(), "CommitDetailsPane"); + const showDiff = visitElements( + detailsPane, + (element) => + typeof element.props.onClick === "function" && + JSON.stringify(element.props.children).includes("View all changes"), + ); + expect(showDiff).not.toBeNull(); + (showDiff?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + }); + + it("shows the short commit hash in every history row", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const shortHash = visitElements( + historyRow, + (element) => element.props.children === historyCommit.hash.slice(0, 8), + ); + const shortHashTooltip = visitElements( + historyRow, + (element) => element.props.children === `Copy full commit hash ${historyCommit.hash}`, + ); + + expect(shortHash).not.toBeNull(); + expect(shortHash?.props["aria-label"]).toBe(`Copy commit hash ${historyCommit.hash}`); + expect(shortHashTooltip).not.toBeNull(); + }); + + it("shows an error toast when copying a history hash is rejected", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const copyHash = visitElements( + historyRow, + (element) => element.props["aria-label"] === `Copy commit hash ${historyCommit.hash}`, + ); + (copyHash?.props.onClick as (() => void) | undefined)?.(); + + expect(historyState.toastAdd).toHaveBeenCalledWith({ + type: "error", + title: "Could not copy commit hash", + description: "Clipboard permission was denied.", + }); + }); + + it("gives every selectable commit its author, date, and parent topology", () => { + const historyCommit = { + ...commit(primaryCommitHash, "Merge release", "Grace Hopper"), + parentHashes: ["parent-one", "parent-two"], + }; + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectableRow = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + + expect(selectableRow?.props["aria-label"]).toContain("Author Grace Hopper"); + expect(selectableRow?.props["aria-label"]).toContain("2-parent merge commit"); + }); + + it("refreshes history and every ref namespace", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const refresh = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Refresh Git history", + ); + (refresh?.props.onClick as (() => void) | undefined)?.(); + + expect(historyState.refreshRefs).toHaveBeenCalledOnce(); + expect(historyState.refreshRemoteRefs).toHaveBeenCalledOnce(); + expect(historyState.refreshTags).toHaveBeenCalledOnce(); + }); + + it("links issue references to the active GitHub repository", () => { + const historyCommit = commit(primaryCommitHash, "fix(repository): view (#602)"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel("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/VladsCoffeApp1/Argus/issues/")); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const subject = componentTree(historyRow, "CommitSubject"); + const issueLink = visitElements(subject, (element) => element.props.children === "#602"); + + expect(issueLink?.type).toBe("a"); + expect(issueLink?.props.href).toBe("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/VladsCoffeApp1/Argus/issues/602"); + }); + + it("opens a changed file diff from selected commit details", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { + ...historyCommit, + body: "", + }; + historyState.diff = { + diff: "diff --git a/src/panel.tsx b/src/panel.tsx\n+added line\n", + isRepo: true, + truncated: false, + }; + + const initial = renderPanel(); + const list = historyList(initial); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + + const details = renderPanel(); + const detailsPane = componentTree(details, "CommitDetailsPane"); + const fileTree = visitElements( + detailsPane, + (element) => typeof element.type === "function" && element.type.name === "CommitFilesTree", + ); + expect(fileTree).not.toBeNull(); + expect(renderComponent(fileTree!).props.recycleItems).toBe(true); + (fileTree?.props.onShowDiff as ((path: string) => void) | undefined)?.("src/panel.tsx"); + + const diff = renderPanel(); + expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + hash: historyCommit.hash, + filePath: "src/panel.tsx", + }, + }); + const diffView = visitElements( + diff, + (element) => typeof element.type === "function" && element.type.name === "CommitDiffView", + ); + expect(diffView?.props).toMatchObject({ hash: historyCommit.hash, filePath: "src/panel.tsx" }); + }); + + it("lets the diff load changed files beyond the first page", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + historyState.commitFiles = { + files: [{ status: "A", path: "first.ts" }], + isRepo: true, + nextCursor: "files-page-2", + hasMore: true, + capped: false, + }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + flushEffects(); + const detailsPane = componentElement(renderPanel(), "CommitDetailsPane"); + (detailsPane.props.onShowDiff as ((hash: string, filePath?: string) => void) | undefined)?.( + historyCommit.hash, + ); + + const diffView = componentElement(renderPanel(), "CommitDiffView"); + expect(diffView.props).toMatchObject({ filesHasMore: true, filesLoading: false }); + const diff = renderComponent(diffView); + const loadMore = visitElements(diff, (element) => element.props.children === "Load more files"); + + expect(loadMore).not.toBeNull(); + expect(loadMore?.props.onClick).toBe(diffView.props.onLoadMoreFiles); + }); + + it("lets the diff retry a failed changed-file continuation", () => { + const retryFiles = vi.fn(); + hooks.beginRender(); + const diff = CommitDiffView({ + hash: primaryCommitHash, + files: [{ status: "A", path: "first.ts" }], + filesError: true, + filesHasMore: true, + filesLoading: false, + diff: null, + truncated: false, + isPending: false, + error: null, + onBack: vi.fn(), + onSelectFile: vi.fn(), + onRetry: vi.fn(), + onLoadMoreFiles: vi.fn(), + onRetryFiles: retryFiles, + }); + const retry = visitElements( + diff, + (element) => element.props.children === "Retry loading files", + ); + + expect(retry).not.toBeNull(); + expect(retry?.props.onClick).toBe(retryFiles); + }); + + it("uses the returned changed-file cursor and accumulates its page", () => { + const firstPage = [{ status: "A" as const, path: "first.ts" }]; + const secondPage = [{ status: "M" as const, path: "second.ts" }]; + + expect(nextCommitFilesCursor("files-page-2")).toBe("files-page-2"); + expect(nextCommitFilesCursor(null)).toBeUndefined(); + expect(appendCommitFilesPage(firstPage, secondPage)).toEqual([ + { status: "A", path: "first.ts" }, + { status: "M", path: "second.ts" }, + ]); + }); +}); diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx new file mode 100644 index 000000000000..330a9f2061e5 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -0,0 +1,938 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, GitCommitChangedFile, GitHistoryCommit } from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { FileIcon, GitBranchIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type RefObject, +} from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { layoutGitHistoryGraph } from "../lib/gitHistoryGraph"; +import { cn } from "../lib/utils"; +import { useClientSettings } from "../hooks/useSettings"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironmentConnectionState } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { CommitDetailsPane } from "./git-history/GitHistoryCommitDetails"; +import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; +import { + CommitRow, + gitHistoryRowHeight, + currentHeadHash, + firstParentHashes, + graphColumnWidth, + queryErrorMessage, +} from "./git-history/GitHistoryCommitList"; +import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; +import { GitRefsPane } from "./git-history/GitHistoryRefsPane"; +import type { CommitRefKind, GitHistoryRow } from "./git-history/GitHistoryVisualTypes"; +import { useGitHistoryRefs } from "./git-history/useGitHistoryRefs"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Sheet, SheetPopup, SheetTitle } from "./ui/sheet"; + +const HISTORY_PAGE_SIZE = 100; +const MAX_HISTORY_PAGES = 10; +const INITIAL_CURSORS = [undefined] as const; +const WIDE_HISTORY_LAYOUT_MIN_WIDTH = 1120; +const REFS_PANE_MIN_WIDTH = 176; +const REFS_PANE_MAX_WIDTH = 480; +const DETAILS_PANE_MIN_WIDTH = 256; +const DETAILS_PANE_MAX_WIDTH = 720; +const HISTORY_CONTENT_MIN_WIDTH = 320; +const PANE_RESIZE_HANDLE_TOTAL_WIDTH = 16; + +function isHistorySnapshotExpired(cause: Cause.Cause): boolean { + const error = Option.getOrNull(Cause.findErrorOption(cause)); + const squashed = Cause.squash(cause); + return ( + (typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "VcsSnapshotExpiredError") || + (typeof squashed === "object" && + squashed !== null && + "_tag" in squashed && + squashed._tag === "VcsSnapshotExpiredError") + ); +} + +interface GitHistoryPanelProps { + environmentId: EnvironmentId; + cwd: string; + issueUrlPrefix?: string; + active?: boolean; +} + +export function isWideHistoryLayout(width: number): boolean { + return width >= WIDE_HISTORY_LAYOUT_MIN_WIDTH; +} + +export function appendCommitFilesPage( + current: ReadonlyArray, + page: ReadonlyArray, +): ReadonlyArray { + return [...current, ...page].slice(0, 2_000); +} + +export function nextCommitFilesCursor(nextCursor: string | null): string | undefined { + return nextCursor ?? undefined; +} + +export function nextCommitFilesRecoveryGeneration(input: { + readonly errorCause: Cause.Cause | null; + readonly generation: number; + readonly recoveryInFlight: boolean; +}): number | null { + return input.errorCause !== null && + isHistorySnapshotExpired(input.errorCause) && + !input.recoveryInFlight + ? input.generation + 1 + : null; +} + +function clampHistoryPaneWidths(input: { + readonly panelWidth: number; + readonly refsPaneWidth: number; + readonly detailsPaneWidth: number; +}): { readonly refsPaneWidth: number; readonly detailsPaneWidth: number } { + const maxSidePaneWidth = Math.max( + REFS_PANE_MIN_WIDTH + DETAILS_PANE_MIN_WIDTH, + input.panelWidth - HISTORY_CONTENT_MIN_WIDTH - PANE_RESIZE_HANDLE_TOTAL_WIDTH, + ); + const refsPaneWidth = Math.min( + Math.min(REFS_PANE_MAX_WIDTH, maxSidePaneWidth - DETAILS_PANE_MIN_WIDTH), + Math.max(REFS_PANE_MIN_WIDTH, input.refsPaneWidth), + ); + const detailsPaneWidth = Math.min( + Math.min(DETAILS_PANE_MAX_WIDTH, maxSidePaneWidth - refsPaneWidth), + Math.max(DETAILS_PANE_MIN_WIDTH, input.detailsPaneWidth), + ); + return { refsPaneWidth, detailsPaneWidth }; +} + +function useHistoryPanelLayout( + panelRef: RefObject, + onResize: (width: number) => void, +): { + readonly isWide: boolean; + readonly widthRef: RefObject; +} { + const widthRef = useRef(Number.POSITIVE_INFINITY); + const onResizeRef = useRef(onResize); + const [isWide, setIsWide] = useState(true); + onResizeRef.current = onResize; + + useEffect(() => { + const panel = panelRef.current; + if (!panel || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(([entry]) => { + const width = entry?.contentRect.width ?? 0; + const nextIsWide = isWideHistoryLayout(width); + if (nextIsWide !== isWideHistoryLayout(widthRef.current)) setIsWide(nextIsWide); + widthRef.current = width; + onResizeRef.current(width); + }); + observer.observe(panel); + return () => observer.disconnect(); + }, [panelRef]); + + return { isWide, widthRef }; +} + +export default function GitHistoryPanel(props: GitHistoryPanelProps) { + const panelRef = useRef(null); + const refsPaneWidthRef = useRef(256); + const detailsPaneWidthRef = useRef(384); + const interfaceFontSize = useClientSettings((settings) => settings.fontSizeInterface); + const rowHeight = gitHistoryRowHeight(interfaceFontSize); + const baseTargetKey = `${props.environmentId}:${props.cwd}`; + const connection = useEnvironmentConnectionState(props.environmentId).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; + const [refsPaneWidth, setRefsPaneWidth] = useState(256); + const [detailsPaneWidth, setDetailsPaneWidth] = useState(384); + const applyPaneWidths = (widths: { + readonly refsPaneWidth: number; + readonly detailsPaneWidth: number; + }) => { + if (widths.refsPaneWidth !== refsPaneWidthRef.current) { + refsPaneWidthRef.current = widths.refsPaneWidth; + setRefsPaneWidth(widths.refsPaneWidth); + } + if (widths.detailsPaneWidth !== detailsPaneWidthRef.current) { + detailsPaneWidthRef.current = widths.detailsPaneWidth; + setDetailsPaneWidth(widths.detailsPaneWidth); + } + }; + const { isWide: isWideLayout, widthRef: panelWidthRef } = useHistoryPanelLayout( + panelRef, + (panelWidth) => { + if (!isWideHistoryLayout(panelWidth)) return; + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth, + refsPaneWidth: refsPaneWidthRef.current, + detailsPaneWidth: detailsPaneWidthRef.current, + }), + ); + }, + ); + const [historyQueryGeneration, setHistoryQueryGeneration] = useState(0); + const vcsHistoryRevision = useAtomValue( + vcsEnvironment.historyRevisionAtom({ environmentId: props.environmentId, cwd: props.cwd }), + ); + const historyRefs = useGitHistoryRefs(props.environmentId, props.cwd, vcsHistoryRevision); + const { selectedRevision } = historyRefs; + const refSelectionError = historyRefs.initialLocalRefError; + const targetKey = `${baseTargetKey}:${selectedRevision?.revision ?? "all"}:${vcsHistoryRevision}:${connectionGeneration}`; + const [pagination, setPagination] = useState<{ + targetKey: string; + cursors: ReadonlyArray; + }>({ targetKey, cursors: INITIAL_CURSORS }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_CURSORS; + const pageAtoms = useMemo( + () => + selectedRevision === undefined || refSelectionError !== null + ? [] + : cursors.map((cursor) => + vcsEnvironment.getHistory({ + environmentId: props.environmentId, + cacheKey: historyQueryGeneration + vcsHistoryRevision, + input: { + cwd: props.cwd, + ...(selectedRevision === null ? {} : { revision: selectedRevision.revision }), + ...(cursor === undefined ? {} : { cursor }), + limit: HISTORY_PAGE_SIZE, + }, + }), + ), + [ + connectionGeneration, + cursors, + historyQueryGeneration, + props.cwd, + props.environmentId, + refSelectionError, + selectedRevision, + vcsHistoryRevision, + ], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:vcs-history-pages:${targetKey}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = useMemo( + () => + results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }), + [results], + ); + const failed = results.find((result) => result._tag === "Failure"); + const error = failed?._tag === "Failure" ? queryErrorMessage(failed.cause) : null; + const recoveredSnapshot = useRef<{ + readonly targetKey: string; + readonly generation: number; + } | null>(null); + useEffect(() => { + if (recoveredSnapshot.current?.targetKey !== targetKey) recoveredSnapshot.current = null; + if ( + recoveredSnapshot.current?.generation === historyQueryGeneration && + failed === undefined && + values.length > 0 + ) { + recoveredSnapshot.current = null; + return; + } + if ( + failed?._tag === "Failure" && + isHistorySnapshotExpired(failed.cause) && + recoveredSnapshot.current?.generation !== historyQueryGeneration + ) { + recoveredSnapshot.current = { targetKey, generation: historyQueryGeneration + 1 }; + setPagination({ targetKey, cursors: INITIAL_CURSORS }); + setHistoryQueryGeneration((generation) => generation + 1); + } + }, [failed, historyQueryGeneration, targetKey, values.length]); + const isPending = + refSelectionError === null && + (selectedRevision === undefined || results.some((result) => result.waiting)); + const isInitialLoad = + refSelectionError === null && + (selectedRevision === undefined || (values.length === 0 && isPending)); + const history = useMemo(() => { + const commitsByHash = new Map(); + for (const value of values) { + for (const commit of value.commits) { + if (!commitsByHash.has(commit.hash)) commitsByHash.set(commit.hash, commit); + } + } + return [...commitsByHash.values()]; + }, [values]); + const isRepo = values[0]?.isRepo ?? true; + const lastPage = values.at(-1) ?? null; + const nextCursor = lastPage?.nextCursor ?? null; + const hasMoreFromServer = lastPage?.hasMore === true && nextCursor !== null; + const historyCapped = values.some((value) => value.capped === true); + const historyLimitReached = cursors.length >= MAX_HISTORY_PAGES; + const hasMore = hasMoreFromServer && !historyLimitReached; + const isFetchingNextPage = results.at(-1)?.waiting === true && values.length > 0; + const [filter, setFilter] = useState(""); + const normalizedFilter = filter.trim().toLocaleLowerCase(); + const deferredFilter = useDeferredValue(normalizedFilter); + const activeFilter = normalizedFilter.length === 0 ? "" : deferredFilter; + const searchInputRef = useRef(null); + const [selectedHash, setSelectedHash] = useState(null); + const [mobilePane, setMobilePane] = useState<"refs" | "details" | null>(null); + const previousMobilePane = useRef(null); + const branchesButtonRef = useRef(null); + const detailsButtonRef = useRef(null); + const [commitDiffRequest, setCommitDiffRequest] = useState<{ + hash: string; + filePath?: string; + } | null>(null); + const commitDetailsQuery = useEnvironmentQuery( + selectedHash === null + ? null + : vcsEnvironment.getCommitDetails({ + environmentId: props.environmentId, + cacheKey: vcsHistoryRevision, + input: { cwd: props.cwd, hash: selectedHash }, + }), + ); + const selectedCommitDetails = commitDetailsQuery.data?.commit ?? null; + const [commitFilesCursor, setCommitFilesCursor] = useState(undefined); + const [commitFiles, setCommitFiles] = useState>([]); + const [commitFilesNextCursor, setCommitFilesNextCursor] = useState(null); + const [commitFilesHasMore, setCommitFilesHasMore] = useState(false); + const [commitFilesCapped, setCommitFilesCapped] = useState(false); + const [commitFilesQueryGeneration, setCommitFilesQueryGeneration] = useState(0); + const receivedCommitFilesPages = useRef(new Set()); + const commitFilesRecoveryInFlight = useRef(false); + useEffect(() => { + setCommitFilesCursor(undefined); + setCommitFiles([]); + setCommitFilesNextCursor(null); + setCommitFilesHasMore(false); + setCommitFilesCapped(false); + commitFilesRecoveryInFlight.current = false; + receivedCommitFilesPages.current.clear(); + }, [props.cwd, props.environmentId, selectedHash, vcsHistoryRevision]); + const commitFilesQuery = useEnvironmentQuery( + selectedHash === null + ? null + : vcsEnvironment.listCommitFiles({ + environmentId: props.environmentId, + cacheKey: commitFilesQueryGeneration + vcsHistoryRevision, + input: { + cwd: props.cwd, + hash: selectedHash, + limit: 100, + ...(commitFilesCursor ? { cursor: commitFilesCursor } : {}), + }, + }), + ); + useEffect(() => { + const page = commitFilesQuery.data; + if (!page || selectedHash === null) return; + const pageKey = `${selectedHash}:${commitFilesCursor ?? "first"}:${page.nextCursor ?? "last"}`; + if (receivedCommitFilesPages.current.has(pageKey)) return; + receivedCommitFilesPages.current.add(pageKey); + setCommitFiles((current) => appendCommitFilesPage(current, page.files)); + setCommitFilesHasMore(page.hasMore); + setCommitFilesCapped(page.capped); + setCommitFilesNextCursor(page.nextCursor); + if (commitFilesCursor === undefined) commitFilesRecoveryInFlight.current = false; + }, [commitFilesCursor, commitFilesQuery.data, selectedHash]); + useEffect(() => { + const recoveryGeneration = nextCommitFilesRecoveryGeneration({ + errorCause: commitFilesQuery.error === null ? null : commitFilesQuery.errorCause, + generation: commitFilesQueryGeneration, + recoveryInFlight: commitFilesRecoveryInFlight.current, + }); + if (recoveryGeneration !== null) { + receivedCommitFilesPages.current.clear(); + setCommitFiles([]); + setCommitFilesCursor(undefined); + setCommitFilesNextCursor(null); + setCommitFilesHasMore(false); + setCommitFilesCapped(false); + commitFilesRecoveryInFlight.current = true; + setCommitFilesQueryGeneration(recoveryGeneration); + } + }, [commitFilesQuery.error, commitFilesQuery.errorCause, commitFilesQueryGeneration]); + const selectedCommitFiles = commitFiles; + const loadMoreCommitFiles = () => { + const cursor = nextCommitFilesCursor(commitFilesNextCursor); + if (cursor) setCommitFilesCursor(cursor); + }; + const commitDiffQuery = useEnvironmentQuery( + commitDiffRequest === null + ? null + : vcsEnvironment.getCommitDiff({ + environmentId: props.environmentId, + cacheKey: vcsHistoryRevision, + input: { + cwd: props.cwd, + hash: commitDiffRequest.hash, + ...(commitDiffRequest.filePath ? { filePath: commitDiffRequest.filePath } : {}), + }, + }), + ); + const { + currentRef = null, + expandedRefKeys, + favoriteBranches, + favoriteRefs, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs, + onRetryRefs, + refreshRefs, + refPaginationError, + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs: selectAllHistoryRefs, + selectRef: selectHistoryRef, + setRefFilter, + tagRefTree, + tagRefs, + toggleFavorite, + toggleRefKey, + } = historyRefs; + const commitRefKinds = useMemo(() => { + const kinds = new Map(); + for (const ref of localRefs) kinds.set(ref.name, "local"); + for (const ref of remoteRefs) kinds.set(ref.name, "remote"); + for (const ref of tagRefs) kinds.set(ref.name, "tag"); + return kinds; + }, [localRefs, remoteRefs, tagRefs]); + const selectRef = useCallback( + (label: string, revision: string) => { + selectHistoryRef(label, revision); + setMobilePane(null); + }, + [selectHistoryRef], + ); + const selectAllRefs = useCallback(() => { + selectAllHistoryRefs(); + setMobilePane(null); + }, [selectAllHistoryRefs]); + const refPaneProps = { + refFilter, + onRefFilterChange: setRefFilter, + selectedRevision: selectedRevision ?? null, + onSelectAll: selectAllRefs, + currentRef, + onSelectRef: selectRef, + normalizedRefFilter, + localRefTree, + favoriteRefs, + favoriteBranches, + onToggleFavorite: toggleFavorite, + remoteRefTree, + tagRefTree, + expandedRefKeys, + onToggleRefKey: toggleRefKey, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + onLoadMoreRefs, + onRetryRefs, + refPaginationError, + } satisfies Omit, "className" | "id" | "onClose">; + + useEffect(() => { + setPagination({ targetKey, cursors: INITIAL_CURSORS }); + setFilter(""); + setSelectedHash(null); + setCommitDiffRequest(null); + setMobilePane((pane) => (pane === "details" ? null : pane)); + }, [targetKey]); + + useEffect(() => { + if (isWideLayout) setMobilePane(null); + }, [isWideLayout]); + + const headHash = useMemo(() => currentHeadHash(history), [history]); + const primaryHashes = useMemo(() => firstParentHashes(history, headHash), [headHash, history]); + const filteredHistory = useMemo(() => { + const query = activeFilter; + return history.filter( + (commit) => + query.length === 0 || + `${commit.hash} ${commit.subject} ${commit.authorName} ${commit.refs.join(" ")}` + .toLocaleLowerCase() + .includes(query), + ); + }, [activeFilter, history]); + const { laneCount, rows: graphRows } = useMemo( + () => + layoutGitHistoryGraph(filteredHistory, { + includeMissingParents: activeFilter.length === 0, + ...(headHash ? { primaryHash: headHash } : {}), + primaryHashes, + }), + [activeFilter, filteredHistory, headHash, primaryHashes], + ); + const filteredRows = useMemo(() => { + return filteredHistory.map((commit, index) => ({ commit, graph: graphRows[index]! })); + }, [filteredHistory, graphRows]); + + useEffect(() => { + if (selectedHash !== null && !history.some((commit) => commit.hash === selectedHash)) { + setSelectedHash(null); + } + }, [history, selectedHash]); + + useEffect(() => { + if (props.active === false) setMobilePane(null); + }, [props.active]); + + useEffect(() => { + const previous = previousMobilePane.current; + previousMobilePane.current = mobilePane; + if (mobilePane !== null || previous === null) return; + (previous === "refs" ? branchesButtonRef.current : detailsButtonRef.current)?.focus(); + }, [mobilePane]); + + const refresh = useCallback(() => { + setHistoryQueryGeneration((generation) => generation + 1); + setPagination({ targetKey, cursors: INITIAL_CURSORS }); + refreshRefs(); + }, [refreshRefs, targetKey]); + const loadNext = useCallback(() => { + if (!hasMore || nextCursor === null) return; + setPagination((current) => { + const currentCursors = current.targetKey === targetKey ? current.cursors : INITIAL_CURSORS; + return currentCursors.includes(nextCursor) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, nextCursor] }; + }); + }, [hasMore, nextCursor, targetKey]); + const retryFailedPage = useCallback(() => { + const failedIndex = results.findIndex((result) => result._tag === "Failure"); + const failedPageAtom = failedIndex === -1 ? undefined : pageAtoms[failedIndex]; + if (failedPageAtom) appAtomRegistry.refresh(failedPageAtom); + }, [pageAtoms, results]); + + return ( +
+
+
+ + + {selectedRevision?.label ?? "All refs"} + + {history.length > 0 ? ( + + {history.length} commits + + ) : null} +
+ {!isWideLayout ? ( + <> + + + + ) : null} + +
+ {commitDiffRequest ? ( + setCommitDiffRequest(null)} + onSelectFile={(filePath) => + setCommitDiffRequest( + filePath + ? { hash: commitDiffRequest.hash, filePath } + : { hash: commitDiffRequest.hash }, + ) + } + onRetry={commitDiffQuery.refresh} + onLoadMoreFiles={loadMoreCommitFiles} + onRetryFiles={() => void commitFilesQuery.refresh()} + /> + ) : refSelectionError || isInitialLoad ? ( +
+ {isWideLayout ? ( + + ) : null} +
+ {refSelectionError ? ( + <> +

{refSelectionError}

+ + + ) : ( +
+ + Loading history… +
+ )} +
+
+ ) : error && history.length === 0 ? ( +
+

{error}

+ +
+ ) : refPaginationError && history.length === 0 ? ( +
+

{refPaginationError}

+ +
+ ) : !isRepo ? ( +
+ This folder is not a Git repository. +
+ ) : history.length === 0 ? ( +
+ This repository has no commits yet. +
+ ) : ( +
+ {isWideLayout ? ( + + ) : null} + {isWideLayout ? ( + + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current + delta, + detailsPaneWidth: detailsPaneWidthRef.current, + }), + ) + } + onReset={() => + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: 256, + detailsPaneWidth: detailsPaneWidthRef.current, + }), + ) + } + /> + ) : null} +
+
+
+ + setFilter(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape" || filter.length === 0) return; + event.preventDefault(); + setFilter(""); + }} + placeholder="Text or hash" + aria-label="Filter Git history" + /> + {filter.length > 0 ? ( + + ) : null} +
+ {error ? ( +
+ {error} + +
+ ) : null} +
+
+
+ Subject + + Author + Date + Hash +
+
+ {filteredRows.length === 0 ? ( +
+ + {error ?? "No loaded commits match this filter."} + + {error || hasMore ? ( + + ) : null} +
+ ) : ( + + data={filteredRows} + keyExtractor={(row) => row.commit.hash} + renderItem={({ item }) => ( + + )} + estimatedItemSize={rowHeight} + drawDistance={rowHeight * 8} + recycleItems={false} + className="min-h-0 flex-1 overscroll-y-contain" + /> + )} + {filteredRows.length > 0 && (hasMore || isFetchingNextPage) ? ( +
+ +
+ ) : null} + {filteredRows.length > 0 && + (historyCapped || (historyLimitReached && hasMoreFromServer)) ? ( +
+ {historyCapped ? ( + History results were capped by the server. + ) : ( + <> + Showing the first {HISTORY_PAGE_SIZE * MAX_HISTORY_PAGES} commits. Choose a + branch or refine the search to keep history responsive. + + )} +
+ ) : null} +
+
+ {isWideLayout ? ( + + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current, + detailsPaneWidth: detailsPaneWidthRef.current - delta, + }), + ) + } + onReset={() => + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current, + detailsPaneWidth: 384, + }), + ) + } + /> + ) : null} + {isWideLayout ? ( + void commitFilesQuery.refresh()} + isPending={commitDetailsQuery.isPending} + hasError={commitDetailsQuery.error !== null} + hasSelection={selectedHash !== null} + onRetry={commitDetailsQuery.refresh} + onShowDiff={(hash, filePath) => + setCommitDiffRequest(filePath ? { hash, filePath } : { hash }) + } + /> + ) : null} + {!isWideLayout ? ( + !open && setMobilePane(null)} + > + + Branches and tags + setMobilePane(null)} + /> + + + ) : null} + {!isWideLayout ? ( + !open && setMobilePane(null)} + > + + Commit details + void commitFilesQuery.refresh()} + isPending={commitDetailsQuery.isPending} + hasError={commitDetailsQuery.error !== null} + hasSelection={selectedHash !== null} + onRetry={commitDetailsQuery.refresh} + onShowDiff={(hash, filePath) => + setCommitDiffRequest(filePath ? { hash, filePath } : { hash }) + } + /> + + + ) : null} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/RepositoryPanel.test.tsx b/apps/web/src/components/RepositoryPanel.test.tsx new file mode 100644 index 000000000000..2121f2f1d359 --- /dev/null +++ b/apps/web/src/components/RepositoryPanel.test.tsx @@ -0,0 +1,260 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const panelMounts = vi.hoisted(() => ({ + history: vi.fn(), + issues: vi.fn(), + pullRequests: vi.fn(), +})); +const panelModuleLoads = vi.hoisted(() => ({ + history: 0, + issues: 0, + pullRequests: 0, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Activity: ({ + children, + mode, + }: { + readonly children: ReactNode; + readonly mode: "hidden" | "visible"; + }) =>
{children}
, + }; +}); + +import { repositoryViewFromTabKey } from "./RepositoryPanel"; +import RepositoryPanel from "./RepositoryPanel"; + +vi.mock("./GitHistoryPanel", () => ({ + default: (() => { + panelModuleLoads.history += 1; + return () => { + panelMounts.history(); + return
History content
; + }; + })(), +})); + +vi.mock("./issue/IssuesPanel", () => ({ + IssuesPanel: (() => { + panelModuleLoads.issues += 1; + return () => { + panelMounts.issues(); + return
Issues content
; + }; + })(), +})); + +vi.mock("./pullRequest/PullRequestsPanel", () => ({ + PullRequestsPanel: (() => { + panelModuleLoads.pullRequests += 1; + return () => { + panelMounts.pullRequests(); + return
Pull requests content
; + }; + })(), +})); + +describe("RepositoryPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads only the History panel module on an initial History view", async () => { + renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + await vi.dynamicImportSettled(); + + expect(panelModuleLoads).toEqual({ history: 1, issues: 0, pullRequests: 0 }); + }); + + it("does not mount inactive repository views that would start hidden queries", () => { + renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(panelMounts.history).toHaveBeenCalledOnce(); + expect(panelMounts.issues).not.toHaveBeenCalled(); + expect(panelMounts.pullRequests).not.toHaveBeenCalled(); + }); + + it("keeps the initial history view in a state-preserving activity boundary", () => { + const markup = renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(markup).toContain('data-activity-mode="visible">
{ + const markup = renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(markup).toContain('aria-label="Repository history, issues, and pull requests"'); + expect(markup).toContain(">History"); + expect(markup).toContain(">Issues"); + expect(markup).toContain(">Pull Requests"); + expect(markup).toContain("History content"); + expect(markup).not.toContain("Issues content"); + expect(markup).not.toContain("Pull requests content"); + expect(markup).toContain('
{ + const markup = renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(markup).toContain('id="repository-tab-history"'); + expect(markup).toContain('aria-controls="repository-panel-history"'); + expect(markup).toContain('tabindex="-1"'); + expect(markup).toContain('id="repository-tab-issues"'); + expect(markup).toContain('aria-controls="repository-panel-issues"'); + expect(markup).toContain( + 'id="repository-tab-issues" aria-selected="true" aria-controls="repository-panel-issues" tabindex="0"', + ); + expect(markup).toContain('id="repository-tab-pull-requests"'); + expect(markup).toContain('aria-controls="repository-panel-pull-requests"'); + expect(markup).toContain('role="tabpanel"'); + expect(markup).toContain('aria-labelledby="repository-tab-issues"'); + expect(markup).toContain('
{ + expect(repositoryViewFromTabKey("history", "ArrowLeft")).toBe("pull-requests"); + expect(repositoryViewFromTabKey("pull-requests", "ArrowRight")).toBe("history"); + expect(repositoryViewFromTabKey("pull-requests", "Home")).toBe("history"); + expect(repositoryViewFromTabKey("history", "End")).toBe("pull-requests"); + expect(repositoryViewFromTabKey("issues", "Enter")).toBeNull(); + }); + + it("mounts the pull request view without mounting the other views", () => { + const markup = renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(markup).toContain("Loading pull requests…"); + expect(markup).not.toContain('data-history-panel="mounted"'); + expect(markup).toContain('
import("./GitHistoryPanel")); +const IssuesPanel = lazy(() => + import("./issue/IssuesPanel").then(({ IssuesPanel }) => ({ default: IssuesPanel })), +); +const PullRequestsPanel = lazy(() => + import("./pullRequest/PullRequestsPanel").then(({ PullRequestsPanel }) => ({ + default: PullRequestsPanel, + })), +); + +interface RepositoryPanelProps { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly issueUrlPrefix?: string; + readonly gitHistoryCapabilityState: "unavailable" | "ready"; + readonly issuesCapabilityState: "loading" | "unavailable" | "ready"; + readonly pullRequestsCapabilityState: "loading" | "unavailable" | "ready"; + readonly projectId: ProjectId; + readonly handoffTarget: IssueHandoffTarget; + readonly composerDraftTarget: ScopedThreadRef | DraftId; + readonly view: RepositoryView; + readonly onViewChange: (view: RepositoryView) => void; + readonly selectedIssue: RepositoryItemSelection | null; + readonly onSelectIssue: (target: RepositoryItemSelection | null) => void; + readonly selectedPullRequest: RepositoryItemSelection | null; + readonly onSelectPullRequest: (target: RepositoryItemSelection | null) => void; + readonly onIssueStateChange: (status: IssueTabStatus) => void; + readonly onOpenLinkedIssue: (link: IssueLink) => void; + readonly onOpenLinkedPullRequest: (link: IssueLinkedPullRequest) => void; +} + +const repositoryViews = [ + "history", + "issues", + "pull-requests", +] as const satisfies ReadonlyArray; + +export function repositoryViewFromTabKey(view: RepositoryView, key: string): RepositoryView | null { + const currentIndex = repositoryViews.indexOf(view); + if (key === "ArrowRight") { + return repositoryViews[(currentIndex + 1) % repositoryViews.length] ?? null; + } + if (key === "ArrowLeft") { + return ( + repositoryViews[(currentIndex - 1 + repositoryViews.length) % repositoryViews.length] ?? null + ); + } + if (key === "Home") return repositoryViews[0] ?? null; + if (key === "End") return repositoryViews[repositoryViews.length - 1] ?? null; + return null; +} + +function repositoryTabId(view: RepositoryView): string { + return `repository-tab-${view}`; +} + +function repositoryPanelId(view: RepositoryView): string { + return `repository-panel-${view}`; +} + +function RepositoryPanelLoadingFallback(props: { readonly children: string }) { + return ( +
+ {props.children} +
+ ); +} + +export default function RepositoryPanel(props: RepositoryPanelProps) { + const mode = props.view; + const tabRefs = useRef>([]); + const [historyActivated, setHistoryActivated] = useState(mode === "history"); + + useEffect(() => { + if (mode === "history") setHistoryActivated(true); + }, [mode]); + + const activateView = (view: RepositoryView) => { + if (view === "history") setHistoryActivated(true); + props.onViewChange(view); + }; + + const handleTabKeyDown = (event: KeyboardEvent, view: RepositoryView) => { + const nextView = repositoryViewFromTabKey(view, event.key); + if (nextView === null) return; + + event.preventDefault(); + activateView(nextView); + tabRefs.current[repositoryViews.indexOf(nextView)]?.focus(); + }; + + return ( +
+
+ + + +
+ + {historyActivated && ( + +
+ {props.gitHistoryCapabilityState === "ready" ? ( + + Loading Git History… + + } + > + + + ) : ( +
+ Update the environment server to browse Git History. +
+ )} +
+
+ )} + + {mode === "issues" && ( +
+ {props.issuesCapabilityState === "loading" ? ( +
+ Loading issues… +
+ ) : props.issuesCapabilityState === "unavailable" ? ( +
+ Update the environment server to browse issues. +
+ ) : ( + Loading issues… + } + > + + + )} +
+ )} + + {mode === "pull-requests" && ( +
+ {props.pullRequestsCapabilityState === "loading" ? ( +
+ Loading pull requests… +
+ ) : props.pullRequestsCapabilityState === "unavailable" ? ( +
+ Update the environment server to browse pull requests. +
+ ) : ( + + Loading pull requests… + + } + > + + + )} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 938208a8ed55..8faeeb89f266 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,6 +2,8 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; +import { issueSurfaceId } from "~/rightPanelStore"; + import { RightPanelTabs, shouldOpenDefaultBrowserProfileFromMenuClick, @@ -43,6 +45,16 @@ const secondSurface = { kind: "preview" as const, resourceId: "tab-2", }; +const issuesSurface = { + id: "issues" as const, + kind: "issues" as const, + selected: { + projectId: "project-1", + provider: "linear", + repository: "acme/project", + number: 42, + }, +}; const sessions: Readonly> = { "tab-1": { threadId: "thread-1", @@ -92,13 +104,20 @@ function renderTabs( second?: DesktopPreviewFavicon, audio?: { audible?: boolean; audioMuted?: boolean }, previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`, + issueStatus?: { state: "open" | "closed"; stateReason: null }, ) { + const surfaces = issueStatus + ? [issuesSurface] + : second + ? [previewSurface, secondSurface] + : [previewSurface]; + return renderToStaticMarkup( undefined} onCloseSurface={() => undefined} onCloseOtherSurfaces={() => undefined} @@ -156,6 +187,99 @@ describe("RightPanelTabs preview favicon", () => { const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/")); expect(html).not.toContain("data:image/png;base64,AAAA"); }); + + it("shows the selected issue's state on the shared Issues browser tab", () => { + const html = renderTabs(null, undefined, undefined, undefined, { + state: "closed", + stateReason: null, + }); + + expect(html).toContain("lucide-circle-check"); + expect(html).toContain("text-violet-600"); + }); + + it("offers the combined Repository surface only when its capability is enabled", () => { + const html = renderToStaticMarkup( + undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} + onAddTerminal={() => undefined} + onAddPullRequest={() => undefined} + onAddIssue={() => undefined} + onAddDiff={() => undefined} + onAddFiles={() => undefined} + onAddAgents={() => undefined} + liveAgentCount={0} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + issueAvailable={false} + agentsAvailable={false} + > +
+ , + ); + + expect(html).not.toContain(">Repository"); + + const enabledHtml = renderToStaticMarkup( + undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + onAddBrowser={() => undefined} + onAddBrowserInProfile={() => undefined} + onAddTerminal={() => undefined} + onAddPullRequest={() => undefined} + onAddRepository={() => undefined} + onAddIssue={() => undefined} + onAddDiff={() => undefined} + onAddFiles={() => undefined} + onAddAgents={() => undefined} + liveAgentCount={0} + browserAvailable={false} + terminalAvailable={false} + diffAvailable={false} + filesAvailable={false} + pullRequestAvailable={false} + repositoryAvailable + issueAvailable={false} + agentsAvailable={false} + > +
+ , + ); + + expect(enabledHtml).toContain(">Repository"); + expect(enabledHtml).toContain("Browse history, issues, and pull requests."); + }); }); describe("surface shortcuts", () => { diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f6ea48fef540..53bba9af06b7 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -16,6 +16,7 @@ import { ChevronRight, FileDiff, Files, + GitGraph, GitPullRequest, Globe2, Plus, @@ -106,6 +107,7 @@ interface RightPanelTabsProps { onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddRepository?: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; @@ -118,6 +120,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + repositoryAvailable?: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; agentsAvailable: boolean; @@ -284,7 +287,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) function SurfaceMenuItem(props: { available: boolean; - disabledReason?: string; + disabledReason: string | undefined; shortcut: string; onClick: () => void; children: ReactNode; @@ -317,6 +320,7 @@ function RightPanelEmptyState(props: { browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; onAddTerminal: () => void; onAddDiff: () => void; + onAddRepository: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; onAddIssue: () => void; @@ -324,6 +328,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + repositoryAvailable: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; issueAvailable: boolean; @@ -374,6 +379,20 @@ function RightPanelEmptyState(props: { onClick: props.onAddDiff, badgeCount: 0, }, + ...(props.repositoryAvailable && props.onAddRepository + ? [ + { + label: "Repository", + description: "Browse history, issues, and pull requests.", + icon: GitGraph, + shortcut: "G", + available: true, + disabledReason: undefined, + onClick: props.onAddRepository, + badgeCount: 0, + }, + ] + : []), { label: "Pull request", description: "Open this branch's pull request.", @@ -622,6 +641,8 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; + case "git-history": + return "Repository"; case "files": return "Files"; case "file": @@ -701,6 +722,8 @@ function SurfaceIcon({ } case "diff": return ; + case "git-history": + return ; case "files": return ; case "file": @@ -857,6 +880,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, + ...(props.repositoryAvailable && props.onAddRepository + ? [ + { + label: "Repository", + icon: GitGraph, + shortcut: "G", + available: true, + disabledReason: undefined, + onClick: props.onAddRepository, + }, + ] + : []), { label: "Pull request", icon: GitPullRequest, @@ -1319,6 +1354,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserProfiles={browserProfiles} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} + onAddRepository={props.onAddRepository} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddIssue={props.onAddIssue} @@ -1326,6 +1362,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} + repositoryAvailable={props.repositoryAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} issueAvailable={props.issueAvailable} diff --git a/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx b/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx new file mode 100644 index 000000000000..e6fe2cea6480 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx @@ -0,0 +1,274 @@ +import type { GitCommitChangedFile, GitCommitDetails } from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { CheckIcon, FileDiffIcon, RefreshCwIcon } from "lucide-react"; +import { type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { useTheme } from "../../hooks/useTheme"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { reportCommitHashCopyFailure } from "./gitHistoryClipboard"; +import { formatCommitDate } from "./GitHistoryCommitList"; + +const FILE_STATUS_COLORS = { + A: "text-emerald-500", + M: "text-amber-500", + D: "text-red-500", + R: "text-sky-500", + C: "text-sky-500", + T: "text-sky-500", + U: "text-red-500", + X: "text-muted-foreground", + B: "text-muted-foreground", +} as const; + +const FILE_STATUS_LABELS = { + A: "Added", + M: "Modified", + D: "Deleted", + R: "Renamed", + C: "Copied", + T: "Type changed", + U: "Unmerged", + X: "Unknown", + B: "Broken pairing", +} as const; + +function CommitFilesTree(props: { + files: ReadonlyArray; + onShowDiff: (path: string) => void; +}) { + const { resolvedTheme } = useTheme(); + return ( + + data={props.files} + keyExtractor={(file) => file.path} + estimatedItemSize={24} + drawDistance={192} + recycleItems + className="h-full" + renderItem={({ item: file }) => ( + + props.onShowDiff(file.path)} + aria-label={`${FILE_STATUS_LABELS[file.status]} ${file.path}; show diff`} + > + + {file.status} + + + {file.path} + + } + /> + {`${FILE_STATUS_LABELS[file.status]} ${file.path}; show diff`} + + )} + /> + ); +} + +export function CommitDetailsPane(props: { + className?: string; + style?: CSSProperties; + id?: string; + details: GitCommitDetails | null; + files: ReadonlyArray; + filesCapped: boolean; + filesHasMore: boolean; + filesError: boolean; + filesLoading: boolean; + onLoadMoreFiles: () => void; + onRetryFiles: () => void; + isPending: boolean; + hasError: boolean; + hasSelection: boolean; + onRetry: () => void; + onShowDiff: (hash: string, filePath?: string) => void; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "commit hash", + onError: reportCommitHashCopyFailure, + }); + if (!props.hasSelection) + return ( + + ); + if (props.hasError) + return ( + + ); + if (props.isPending || props.details === null) + return ( + + ); + const details = props.details; + return ( + + ); +} diff --git a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx new file mode 100644 index 000000000000..db7628cecea2 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx @@ -0,0 +1,142 @@ +import type { GitCommitChangedFile } from "@t3tools/contracts"; +import { FileDiff } from "@pierre/diffs/react"; +import { FileDiffIcon, RefreshCwIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { + getRenderablePatch, + DIFF_SURFACE_THEME_UNSAFE_CSS, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../../lib/diffRendering"; +import { useTheme } from "../../hooks/useTheme"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +export function CommitDiffView(props: { + hash: string; + filePath?: string; + files: ReadonlyArray; + filesError: boolean; + filesHasMore: boolean; + filesLoading: boolean; + diff: string | null; + truncated: boolean; + isPending: boolean; + error: string | null; + onBack: () => void; + onSelectFile: (filePath?: string) => void; + onRetry: () => void; + onLoadMoreFiles: () => void; + onRetryFiles: () => void; +}) { + const { resolvedTheme } = useTheme(); + const renderable = useMemo( + () => + getRenderablePatch( + props.diff ?? undefined, + `git-history:${props.hash}:${props.filePath ?? "all"}`, + ), + [props.diff, props.filePath, props.hash], + ); + return ( +
+
+ + + + {props.filePath ?? `Commit ${props.hash.slice(0, 8)}`} + + + {props.truncated ? truncated : null} + {!props.filesError && (props.filesHasMore || props.filesLoading) ? ( + + ) : null} + {props.filesError ? ( + + ) : null} +
+
+ {props.isPending ? ( +
+ Loading diff… +
+ ) : props.error ? ( +
+ {props.error} + +
+ ) : renderable?.kind === "files" ? ( +
+ {renderable.files.map((fileDiff) => ( +
+ + {fileDiff.hunks.length === 0 ? ( +
+ Binary or metadata-only change; no textual diff is available. +
+ ) : null} +
+ ))} +
+ ) : renderable?.kind === "raw" ? ( +
{renderable.text}
+ ) : ( +
+ This commit has no textual diff. +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryCommitList.tsx b/apps/web/src/components/git-history/GitHistoryCommitList.tsx new file mode 100644 index 000000000000..d9bfdab45f33 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitList.tsx @@ -0,0 +1,405 @@ +import type { GitHistoryCommit } from "@t3tools/contracts"; +import { + CheckIcon, + CircleDotIcon, + CloudIcon, + GitBranchIcon, + GitPullRequestIcon, + TagIcon, +} from "lucide-react"; +import * as Cause from "effect/Cause"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import type { GitHistoryGraphRow } from "../../lib/gitHistoryGraph"; +import { cn } from "../../lib/utils"; +import { reportCommitHashCopyFailure } from "./gitHistoryClipboard"; +import type { CommitRefKind, GitHistoryRow } from "./GitHistoryVisualTypes"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const GIT_HISTORY_ROW_HEIGHT_REM = 1.875; +const DEFAULT_INTERFACE_FONT_SIZE = 16; +export const GIT_HISTORY_ROW_HEIGHT = GIT_HISTORY_ROW_HEIGHT_REM * DEFAULT_INTERFACE_FONT_SIZE; +const LANE_WIDTH = 11; +const GRAPH_HORIZONTAL_PADDING = 6; +const MAX_GRAPH_WIDTH = 104; +const GRAPH_COLORS = ["#4f9cff", "#b26cff", "#f59e0b", "#22c55e", "#ec4899", "#14b8a6"] as const; + +export function queryErrorMessage(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not load Git history."; +} + +export function formatCommitDate(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + const now = new Date(); + const elapsedMs = now.valueOf() - date.valueOf(); + if (elapsedMs >= 0 && elapsedMs < 60 * 60 * 1_000) { + const minutes = Math.max(1, Math.floor(elapsedMs / 60_000)); + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + } + const time = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(date); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + if (sameDay) return `Today ${time}`; + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + const isYesterday = + date.getFullYear() === yesterday.getFullYear() && + date.getMonth() === yesterday.getMonth() && + date.getDate() === yesterday.getDate(); + if (isYesterday) return `Yesterday ${time}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${time}`; +} + +function pullRequestNumberFromSubject(subject: string): string | null { + return /^Merge pull request #(\d+)\b/i.exec(subject)?.[1] ?? null; +} + +function CommitRefDecoration(props: { + refName: string; + refKinds: ReadonlyMap; +}) { + const refTarget = props.refName.split(" -> ", 1)[0] ?? props.refName; + const explicitTag = props.refName.startsWith("tag: "); + const label = explicitTag ? props.refName.slice(5) : props.refName; + const kind = props.refName.startsWith("HEAD -> ") + ? "head" + : explicitTag + ? "tag" + : (props.refKinds.get(refTarget) ?? "unknown"); + const Icon = + kind === "head" + ? CircleDotIcon + : kind === "remote" + ? CloudIcon + : kind === "tag" + ? TagIcon + : GitBranchIcon; + return ( + + + + {label} + + } + /> + {`${kind === "head" ? "HEAD" : kind === "remote" ? "Remote branch" : kind === "tag" ? "Tag" : kind === "local" ? "Local branch" : "Unresolved branch"}: ${label}`} + + ); +} + +export function graphColumnWidth(laneCount: number): number { + return Math.min( + Math.max(LANE_WIDTH * laneCount + GRAPH_HORIZONTAL_PADDING * 2, 44), + MAX_GRAPH_WIDTH, + ); +} + +export function gitHistoryRowHeight(interfaceFontSize: number): number { + return GIT_HISTORY_ROW_HEIGHT_REM * interfaceFontSize; +} + +export function currentHeadHash(commits: ReadonlyArray): string | undefined { + return ( + commits.find((commit) => + commit.refs.some((ref) => ref === "HEAD" || ref.startsWith("HEAD -> ")), + )?.hash ?? commits[0]?.hash + ); +} + +export function firstParentHashes( + commits: ReadonlyArray, + headHash: string | undefined, +): ReadonlySet { + const commitsByHash = new Map(commits.map((commit) => [commit.hash, commit])); + const hashes = new Set(); + let hash = headHash; + while (hash && !hashes.has(hash)) { + hashes.add(hash); + hash = commitsByHash.get(hash)?.parentHashes[0]; + } + return hashes; +} + +function CommitSubject({ subject, issueUrlPrefix }: { subject: string; issueUrlPrefix?: string }) { + const issueReferences = Array.from(subject.matchAll(/#[0-9]+/g)); + let textEnd = 0; + const references = issueReferences.flatMap((reference) => { + const part = reference[0]; + const start = reference.index ?? 0; + const text = subject.slice(textEnd, start); + textEnd = start + part.length; + const className = "font-semibold text-violet-400 hover:text-violet-300 hover:underline"; + const referenceElement = issueUrlPrefix ? ( + + + {part} + + } + /> + {`Open ${part} on GitHub`} + + ) : ( + + {part} + + ); + return [text, referenceElement]; + }); + return [...references, subject.slice(textEnd)]; +} + +function GraphCell(props: { + graph: GitHistoryGraphRow; + laneCount: number; + rowHeight: number; + selected: boolean; + current: boolean; +}) { + const width = graphColumnWidth(props.laneCount); + const centerY = props.rowHeight / 2; + const laneWidth = Math.min( + LANE_WIDTH, + (width - GRAPH_HORIZONTAL_PADDING * 2) / Math.max(props.laneCount, 1), + ); + const x = (lane: number) => lane * laneWidth + GRAPH_HORIZONTAL_PADDING + laneWidth / 2; + const { edges } = props.graph; + return ( + + ); +} + +export function CommitRow(props: { + row: GitHistoryRow; + laneCount: number; + rowHeight?: number; + refKinds: ReadonlyMap; + issueUrlPrefix?: string; + selected: boolean; + onSelect: (hash: string) => void; +}) { + const { commit } = props.row; + const rowHeight = props.rowHeight ?? GIT_HISTORY_ROW_HEIGHT; + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "commit hash", + onError: reportCommitHashCopyFailure, + }); + const pullRequestNumber = pullRequestNumberFromSubject(commit.subject); + const isMergeCommit = commit.parentHashes.length > 1 || /^Merge\b/i.test(commit.subject); + return ( +
+ + } + /> + + {isCopied ? "Commit hash copied" : `Copy full commit hash ${commit.hash}`} + + + + {isCopied ? `Copied commit hash ${commit.hash}` : ""} + +
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx new file mode 100644 index 000000000000..594333f18169 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx @@ -0,0 +1,92 @@ +import { GripVerticalIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; + +export function PaneResizeHandle(props: { + label: string; + value: number; + min: number; + max: number; + onMove: (delta: number) => void; + onReset: () => void; +}) { + const onMoveRef = useRef(props.onMove); + const dragStateRef = useRef<{ + pointerId: number; + lastClientX: number; + pendingDelta: number; + rafId: number | null; + } | null>(null); + onMoveRef.current = props.onMove; + + useEffect(() => { + return () => { + const state = dragStateRef.current; + if (state && state.rafId !== null) cancelAnimationFrame(state.rafId); + dragStateRef.current = null; + }; + }, []); + + return ( +
{ + dragStateRef.current = { + pointerId: event.pointerId, + lastClientX: event.clientX, + pendingDelta: 0, + rafId: null, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + state.pendingDelta += event.clientX - state.lastClientX; + state.lastClientX = event.clientX; + if (state.rafId !== null) return; + state.rafId = requestAnimationFrame(() => { + const active = dragStateRef.current; + if (!active) return; + active.rafId = null; + const delta = active.pendingDelta; + active.pendingDelta = 0; + if (delta !== 0) onMoveRef.current(delta); + }); + }} + onPointerUp={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + state.pendingDelta += event.clientX - state.lastClientX; + if (state.rafId !== null) cancelAnimationFrame(state.rafId); + const delta = state.pendingDelta; + dragStateRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + if (delta !== 0) onMoveRef.current(delta); + }} + onLostPointerCapture={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + if (state.rafId !== null) cancelAnimationFrame(state.rafId); + const delta = state.pendingDelta; + dragStateRef.current = null; + if (delta !== 0) onMoveRef.current(delta); + }} + onDoubleClick={props.onReset} + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + props.onMove(event.key === "ArrowLeft" ? -16 : 16); + }} + > +
+ +
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts new file mode 100644 index 000000000000..7ac06365515a --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts @@ -0,0 +1,82 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildGitRefTree } from "../../lib/gitRefTree"; +import { buildRefPaneRows } from "./GitHistoryRefsPane"; + +function ref(name: string, isTag = false): VcsHistoryRef { + return { + current: false, + isDefault: false, + isRemote: false, + ...(isTag ? { isTag: true } : {}), + name, + worktreePath: null, + }; +} + +describe("buildRefPaneRows", () => { + it("keeps a large expanded tag hierarchy as list data instead of render items", () => { + const rows = buildRefPaneRows({ + localRefTree: [], + favoriteRefs: [], + remoteRefTree: [], + tagRefTree: buildGitRefTree( + Array.from({ length: 10_000 }, (_, index) => ref(`release/${index}`, true)), + ), + expandedRefKeys: new Set(["section:local", "section:tags", "tags:release"]), + filterActive: false, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows).toHaveLength(10_006); + expect(rows.filter((row) => row.kind === "ref")).toHaveLength(10_000); + expect(rows.find((row) => row.key === "section:tags")).toMatchObject({ + count: 10_000, + open: true, + }); + }); + + it("opens tag descendants while filtering without changing folder keys", () => { + const rows = buildRefPaneRows({ + localRefTree: [], + favoriteRefs: [], + remoteRefTree: [], + tagRefTree: buildGitRefTree([ref("release/v1", true)]), + expandedRefKeys: new Set(), + filterActive: true, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows.map((row) => row.key)).toContain("tags:release"); + expect(rows.map((row) => row.key)).toContain("refs/tags/release/v1"); + }); + + it("projects loaded favorite local refs before their canonical Local rows", () => { + const favorite = ref("feature/favorite"); + const rows = buildRefPaneRows({ + localRefTree: buildGitRefTree([favorite]), + favoriteRefs: [favorite], + remoteRefTree: [], + tagRefTree: [], + expandedRefKeys: new Set(["section:local", "local:feature"]), + filterActive: false, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows.map((row) => row.key)).toEqual([ + "all", + "current", + "favorite:refs/heads/feature/favorite", + "section:local", + "local:feature", + "refs/heads/feature/favorite", + "section:remote", + "section:tags", + ]); + expect(rows[2]).toMatchObject({ node: { name: "feature/favorite" } }); + }); +}); diff --git a/apps/web/src/components/git-history/GitHistoryRefsPane.tsx b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx new file mode 100644 index 000000000000..51a30d8640af --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx @@ -0,0 +1,498 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { + ArrowDownIcon, + ArrowUpIcon, + ChevronDownIcon, + ChevronRightIcon, + FolderIcon, + FolderOpenIcon, + GitBranchIcon, + GitCommitHorizontalIcon, + SearchIcon, + StarIcon, + TagIcon, + XIcon, +} from "lucide-react"; +import { type CSSProperties, useMemo } from "react"; + +import type { GitRefTreeNode } from "../../lib/gitRefTree"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +type RefNamespace = "heads" | "remotes" | "tags"; + +type RefPaneRow = + | { readonly kind: "all"; readonly key: "all" } + | { readonly kind: "current"; readonly key: "current" } + | { + readonly kind: "ref"; + readonly key: string; + readonly node: Extract; + readonly namespace: "heads"; + readonly depth: number; + readonly projectedFavorite: true; + } + | { + readonly kind: "section"; + readonly key: string; + readonly label: string; + readonly count: number; + readonly open: boolean; + } + | { + readonly kind: "folder"; + readonly key: string; + readonly label: string; + readonly path: string; + readonly depth: number; + readonly open: boolean; + } + | { + readonly kind: "ref"; + readonly key: string; + readonly node: Extract; + readonly namespace: RefNamespace; + readonly depth: number; + readonly projectedFavorite?: false; + } + | { readonly kind: "empty"; readonly key: "empty" } + | { readonly kind: "error"; readonly key: "error"; readonly message: string } + | { readonly kind: "load-more"; readonly key: "load-more" }; + +function appendRefTreeRows( + rows: RefPaneRow[], + nodes: ReadonlyArray, + section: string, + namespace: RefNamespace, + expanded: ReadonlySet, + filterActive: boolean, + depth = 0, +): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "ref") { + count += 1; + rows.push({ kind: "ref", key: `refs/${namespace}/${node.ref.name}`, node, namespace, depth }); + continue; + } + const key = `${section}:${node.path}`; + const open = filterActive || expanded.has(key); + rows.push({ kind: "folder", key, label: node.name, path: node.path, depth, open }); + if (open) + count += appendRefTreeRows( + rows, + node.children, + section, + namespace, + expanded, + filterActive, + depth + 1, + ); + else count += countRefTreeRefs(node.children); + } + return count; +} + +function countRefTreeRefs(nodes: ReadonlyArray): number { + return nodes.reduce( + (total, node) => total + (node.kind === "ref" ? 1 : countRefTreeRefs(node.children)), + 0, + ); +} + +export function buildRefPaneRows(props: { + readonly localRefTree: ReadonlyArray; + readonly favoriteRefs: ReadonlyArray; + readonly remoteRefTree: ReadonlyArray; + readonly tagRefTree: ReadonlyArray; + readonly expandedRefKeys: ReadonlySet; + readonly filterActive: boolean; + readonly hasMoreRefs: boolean; + readonly refPaginationError: string | null; +}): ReadonlyArray { + const rows: RefPaneRow[] = [ + { kind: "all", key: "all" }, + { kind: "current", key: "current" }, + ]; + rows.push( + ...props.favoriteRefs.map((ref) => ({ + kind: "ref" as const, + key: `favorite:refs/heads/${ref.name}`, + node: { kind: "ref" as const, name: ref.name, ref }, + namespace: "heads" as const, + depth: 0, + projectedFavorite: true as const, + })), + ); + for (const section of [ + { label: "Local", section: "local", nodes: props.localRefTree, namespace: "heads" as const }, + { + label: "Remote", + section: "remote", + nodes: props.remoteRefTree, + namespace: "remotes" as const, + }, + { label: "Tags", section: "tags", nodes: props.tagRefTree, namespace: "tags" as const }, + ]) { + const open = props.filterActive || props.expandedRefKeys.has(`section:${section.section}`); + const sectionRows: RefPaneRow[] = []; + const count = open + ? appendRefTreeRows( + sectionRows, + section.nodes, + section.section, + section.namespace, + props.expandedRefKeys, + props.filterActive, + ) + : countRefTreeRefs(section.nodes); + rows.push({ + kind: "section", + key: `section:${section.section}`, + label: section.label, + count, + open, + }); + if (open) rows.push(...sectionRows); + } + if ( + props.filterActive && + props.localRefTree.length + props.remoteRefTree.length + props.tagRefTree.length === 0 + ) + rows.push({ kind: "empty", key: "empty" }); + if (props.refPaginationError) + rows.push({ kind: "error", key: "error", message: props.refPaginationError }); + if (props.hasMoreRefs) rows.push({ kind: "load-more", key: "load-more" }); + return rows; +} + +export function GitRefsPane(props: { + className?: string; + style?: CSSProperties; + id?: string; + refFilter: string; + onRefFilterChange: (value: string) => void; + selectedRevision: { label: string; revision: string } | null; + onSelectAll: () => void; + currentRef: VcsHistoryRef | null; + onSelectRef: (label: string, revision: string) => void; + normalizedRefFilter: string; + localRefTree: ReadonlyArray; + favoriteRefs: ReadonlyArray; + favoriteBranches: ReadonlySet; + onToggleFavorite: (branch: string) => void; + remoteRefTree: ReadonlyArray; + tagRefTree: ReadonlyArray; + expandedRefKeys: ReadonlySet; + onToggleRefKey: (key: string) => void; + hasMoreRefs: boolean; + isFetchingMoreRefs: boolean; + isRefSnapshotComplete: boolean; + onLoadMoreRefs: () => void; + refPaginationError: string | null; + onRetryRefs: () => void; + onClose?: () => void; +}) { + const rows = useMemo( + () => + buildRefPaneRows({ + localRefTree: props.localRefTree, + favoriteRefs: props.favoriteRefs, + remoteRefTree: props.remoteRefTree, + tagRefTree: props.tagRefTree, + expandedRefKeys: props.expandedRefKeys, + filterActive: props.normalizedRefFilter.length > 0, + hasMoreRefs: props.hasMoreRefs, + refPaginationError: props.refPaginationError, + }), + [ + props.expandedRefKeys, + props.favoriteRefs, + props.hasMoreRefs, + props.localRefTree, + props.normalizedRefFilter, + props.refPaginationError, + props.remoteRefTree, + props.tagRefTree, + ], + ); + return ( + + ); +} diff --git a/apps/web/src/components/git-history/GitHistoryVisualTypes.ts b/apps/web/src/components/git-history/GitHistoryVisualTypes.ts new file mode 100644 index 000000000000..98e1978948c9 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryVisualTypes.ts @@ -0,0 +1,9 @@ +import type { GitHistoryCommit } from "@t3tools/contracts"; + +import type { GitHistoryGraphRow } from "../../lib/gitHistoryGraph"; +export interface GitHistoryRow { + commit: GitHistoryCommit; + graph: GitHistoryGraphRow; +} + +export type CommitRefKind = "head" | "local" | "remote" | "tag" | "unknown"; diff --git a/apps/web/src/components/git-history/gitHistoryClipboard.ts b/apps/web/src/components/git-history/gitHistoryClipboard.ts new file mode 100644 index 000000000000..636d4c67c555 --- /dev/null +++ b/apps/web/src/components/git-history/gitHistoryClipboard.ts @@ -0,0 +1,11 @@ +import { stackedThreadToast, toastManager } from "../ui/toast"; + +export function reportCommitHashCopyFailure(error: Error): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy commit hash", + description: error.message, + }), + ); +} diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx new file mode 100644 index 000000000000..f076a579a310 --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -0,0 +1,426 @@ +import { EnvironmentId, type VcsHistoryRef } from "@t3tools/contracts"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { visitElements } from "../../test/reactElementTree"; + +const refState = vi.hoisted(() => ({ + currentRefResolved: true, + currentRef: null as VcsHistoryRef | null, + localError: null as string | null, + debouncedRefFilter: "", + local: [] as ReadonlyArray, + remote: [] as ReadonlyArray, + tags: [] as ReadonlyArray, + favoriteBranches: [] as ReadonlyArray, + isComplete: true, + nextCursor: null as string | null, + refreshLocal: vi.fn(), + refreshRemote: vi.fn(), + refreshTags: vi.fn(), + remoteGeneration: 0, + remoteRequestKeys: [] as string[], + targets: [] as Array<{ + readonly target: unknown; + readonly namespace: string; + readonly revision: number | undefined; + }>, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useDeferredValue: (value: Value) => value, + useEffect: () => undefined, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useLocalStorage", () => ({ + useLocalStorage: () => [ + refState.favoriteBranches, + (next: ReadonlyArray | ((current: ReadonlyArray) => ReadonlyArray)) => { + refState.favoriteBranches = + typeof next === "function" ? next(refState.favoriteBranches) : next; + }, + ], +})); + +vi.mock("../../state/queries", () => ({ + useDebouncedValue: () => refState.debouncedRefFilter, + usePaginatedHistoryRefs: ( + _target: unknown, + options: { readonly namespace: string; readonly revision?: number }, + ) => { + refState.targets.push({ + target: _target, + namespace: options.namespace, + revision: options.revision, + }); + const refs = + options.namespace === "local" + ? refState.local + : options.namespace === "remote" + ? refState.remote + : refState.tags; + const target = _target as { readonly environmentId: EnvironmentId | null }; + if (options.namespace === "remote" && target.environmentId !== null) { + refState.remoteRequestKeys.push(`remote:${refState.remoteGeneration}`); + } + return { + data: + target.environmentId === null || + (options.namespace === "local" && !refState.currentRefResolved) + ? null + : { + currentRef: refState.currentRef, + nextCursor: refState.nextCursor, + isComplete: refState.isComplete, + }, + refs, + error: options.namespace === "local" ? refState.localError : null, + isFetchingNextPage: false, + loadNext: vi.fn(), + refresh: + options.namespace === "local" + ? refState.refreshLocal + : options.namespace === "remote" + ? () => { + refState.remoteGeneration += 1; + refState.refreshRemote(); + } + : refState.refreshTags, + retry: vi.fn(), + }; + }, +})); + +import { GitRefsPane } from "./GitHistoryRefsPane"; +import { + gitHistoryFavoriteStorageKey, + toggleGitHistoryFavorite, + useGitHistoryRefs, +} from "./useGitHistoryRefs"; + +const environmentId = EnvironmentId.make("environment-local"); +const repositoryCwd = "C:/workspace"; + +function ref(name: string, isRemote = false): VcsHistoryRef { + return { current: false, isDefault: false, isRemote, name, worktreePath: null }; +} + +function renderRefs(revision = 0) { + hooks.beginRender(); + const historyRefs = useGitHistoryRefs(environmentId, repositoryCwd, revision); + const pane = GitRefsPane({ + refFilter: historyRefs.refFilter, + onRefFilterChange: historyRefs.setRefFilter, + selectedRevision: historyRefs.selectedRevision ?? null, + onSelectAll: historyRefs.selectAllRefs, + currentRef: historyRefs.currentRef ?? null, + onSelectRef: historyRefs.selectRef, + normalizedRefFilter: historyRefs.normalizedRefFilter, + localRefTree: historyRefs.localRefTree, + favoriteRefs: historyRefs.favoriteRefs, + favoriteBranches: historyRefs.favoriteBranches, + onToggleFavorite: historyRefs.toggleFavorite, + remoteRefTree: historyRefs.remoteRefTree, + tagRefTree: historyRefs.tagRefTree, + expandedRefKeys: historyRefs.expandedRefKeys, + onToggleRefKey: historyRefs.toggleRefKey, + hasMoreRefs: historyRefs.hasMoreRefs, + isFetchingMoreRefs: historyRefs.isFetchingMoreRefs, + isRefSnapshotComplete: historyRefs.isRefSnapshotComplete, + onLoadMoreRefs: historyRefs.onLoadMoreRefs, + refPaginationError: historyRefs.refPaginationError, + onRetryRefs: historyRefs.onRetryRefs, + }) as ReactElement>; + const list = visitElements(pane, (element) => typeof element.props.keyExtractor === "function"); + expect(list).not.toBeNull(); + const capStatus = visitElements(pane, (element) => element.props.role === "status"); + return { historyRefs, rows: list!.props.data as ReadonlyArray, capStatus }; +} + +describe("useGitHistoryRefs", () => { + beforeEach(() => { + hooks.reset(); + refState.currentRefResolved = true; + refState.currentRef = null; + refState.localError = null; + refState.debouncedRefFilter = ""; + refState.local = []; + refState.remote = []; + refState.tags = []; + refState.favoriteBranches = []; + refState.isComplete = true; + refState.nextCursor = null; + refState.refreshLocal.mockReset(); + refState.refreshRemote.mockReset(); + refState.refreshTags.mockReset(); + refState.remoteGeneration = 0; + refState.remoteRequestKeys = []; + refState.targets = []; + }); + + it("scopes persisted favorites and toggles branch membership", () => { + expect(gitHistoryFavoriteStorageKey(environmentId, repositoryCwd)).toBe( + "t3code:git-history-favorites:v1:environment-local:C:/workspace", + ); + expect(toggleGitHistoryFavorite([], "feature/favorite")).toEqual(["feature/favorite"]); + expect(toggleGitHistoryFavorite(["feature/favorite"], "feature/favorite")).toEqual([]); + }); + + it("preserves ref trees and the 10k-row virtual-list model across unchanged rerenders", () => { + refState.local = Array.from({ length: 5_000 }, (_, index) => ref(`feature-${index}`)); + refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, true)); + + const collapsed = renderRefs(); + collapsed.historyRefs.toggleRefKey("section:remote"); + const first = renderRefs(); + const second = renderRefs(); + + expect(second.historyRefs.localRefs).toBe(first.historyRefs.localRefs); + expect(second.historyRefs.remoteRefs).toBe(first.historyRefs.remoteRefs); + expect(second.historyRefs.localRefTree).toBe(first.historyRefs.localRefTree); + expect(second.historyRefs.remoteRefTree).toBe(first.historyRefs.remoteRefTree); + expect(second.rows).toBe(first.rows); + expect(second.rows).toHaveLength(10_005); + }); + + it("states the first-10,000 cap when the server snapshot is incomplete", () => { + refState.local = Array.from({ length: 5_000 }, (_, index) => ref(`feature-${index}`)); + refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, true)); + refState.isComplete = false; + + const rendered = renderRefs(); + + expect(rendered.capStatus?.props.children).toBe("Showing the first 10,000 matching refs."); + }); + + it("refreshes every ref namespace", () => { + const rendered = renderRefs(); + + rendered.historyRefs.refreshRefs(); + + expect(refState.refreshLocal).toHaveBeenCalledOnce(); + expect(refState.refreshRemote).toHaveBeenCalledOnce(); + expect(refState.refreshTags).toHaveBeenCalledOnce(); + + rendered.historyRefs.toggleRefKey("section:remote"); + rendered.historyRefs.toggleRefKey("section:tags"); + const expanded = renderRefs(); + + expanded.historyRefs.refreshRefs(); + + expect(refState.refreshLocal).toHaveBeenCalledTimes(2); + expect(refState.refreshRemote).toHaveBeenCalledTimes(2); + expect(refState.refreshTags).toHaveBeenCalledTimes(2); + }); + + it("refreshes a collapsed remote namespace before loading it again", () => { + const initial = renderRefs(); + initial.historyRefs.toggleRefKey("section:remote"); + const expanded = renderRefs(); + expect(refState.remoteRequestKeys).toEqual(["remote:0"]); + + expanded.historyRefs.toggleRefKey("section:remote"); + const collapsed = renderRefs(); + expect(refState.remoteRequestKeys).toEqual(["remote:0"]); + + collapsed.historyRefs.refreshRefs(); + expect(refState.refreshRemote).toHaveBeenCalledOnce(); + expect(refState.remoteGeneration).toBe(1); + + collapsed.historyRefs.toggleRefKey("section:remote"); + renderRefs(); + + expect(refState.remoteRequestKeys).toEqual(["remote:0", "remote:1"]); + }); + + it("keeps the history revision unresolved until the local ref snapshot resolves", () => { + refState.currentRefResolved = false; + + const rendered = renderRefs(); + + expect(rendered.historyRefs.currentRef).toBeUndefined(); + expect(rendered.historyRefs.selectedRevision).toBeUndefined(); + }); + + it("falls back to all history when an unresolved local ref request fails after rendering", () => { + refState.currentRefResolved = false; + + expect(renderRefs().historyRefs.selectedRevision).toBeUndefined(); + + refState.localError = "Could not load refs."; + + expect(renderRefs().historyRefs.selectedRevision).toBeNull(); + }); + + it("keeps the resolved current branch selected while a ref filter refresh is pending", () => { + refState.currentRef = ref("main"); + + renderRefs(); + refState.currentRefResolved = false; + + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the resolved current branch selected when a local ref refresh fails", () => { + refState.currentRef = ref("main"); + refState.localError = "Could not refresh refs."; + + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("falls back to all history when the initial local ref request fails", () => { + refState.currentRefResolved = false; + refState.localError = "Could not load refs."; + + const rendered = renderRefs(); + + expect(rendered.historyRefs.selectedRevision).toBeNull(); + expect(rendered.historyRefs.refPaginationError).toBe("Could not load refs."); + }); + + it("keeps the current local branch loaded after collapsing Local", () => { + refState.currentRef = ref("main"); + + const initial = renderRefs(); + initial.historyRefs.toggleRefKey("section:local"); + refState.targets = []; + + const collapsed = renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "local", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + expect(collapsed.historyRefs.currentRef?.name).toBe("main"); + expect(collapsed.historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the default selected revision stable across unchanged rerenders", () => { + refState.currentRef = ref("main"); + + const first = renderRefs(); + const second = renderRefs(); + + expect(second.historyRefs.selectedRevision).toBe(first.historyRefs.selectedRevision); + }); + + it("falls back to the current branch when a completed snapshot removes the selected local ref", () => { + refState.currentRef = ref("main"); + refState.local = [ref("main"), ref("feature/renamed")]; + + const initial = renderRefs(); + initial.historyRefs.selectRef("feature/renamed", "refs/heads/feature/renamed"); + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + initial.historyRefs.setRefFilter("main"); + refState.local = [ref("main")]; + refState.debouncedRefFilter = "main"; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + initial.historyRefs.setRefFilter(""); + refState.debouncedRefFilter = ""; + refState.nextCursor = "cursor-2"; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + refState.nextCursor = null; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the selected remote or tag namespace loaded while its section is collapsed", () => { + const initial = renderRefs(); + initial.historyRefs.selectRef("origin/feature", "refs/remotes/origin/feature"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "remote", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + + initial.historyRefs.selectRef("v1.0.0", "refs/tags/v1.0.0"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "tag", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + }); + + it("passes a repository revision to every history ref namespace", () => { + renderRefs(3); + + expect(refState.targets).toEqual([ + { + namespace: "local", + revision: 3, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }, + { namespace: "remote", revision: 3, target: { environmentId: null, cwd: null } }, + { namespace: "tag", revision: 3, target: { environmentId: null, cwd: null } }, + ]); + }); + + it("waits for the ref filter debounce before querying collapsed remote and tag namespaces", () => { + const first = renderRefs(); + first.historyRefs.setRefFilter("release"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "remote", + revision: 0, + target: { environmentId: null, cwd: null }, + }); + expect(refState.targets).toContainEqual({ + namespace: "tag", + revision: 0, + target: { environmentId: null, cwd: null }, + }); + }); +}); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.ts b/apps/web/src/components/git-history/useGitHistoryRefs.ts new file mode 100644 index 000000000000..989296304ae6 --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -0,0 +1,261 @@ +import type { EnvironmentId, VcsHistoryRef } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { buildGitRefTree, filterGitRefTree } from "../../lib/gitRefTree"; +import { useDebouncedValue, usePaginatedHistoryRefs } from "../../state/queries"; + +const EMPTY_FAVORITE_BRANCHES: ReadonlyArray = []; +const FavoriteBranchesSchema = Schema.Array(Schema.String); +const REF_FILTER_DEBOUNCE_MS = 175; + +export interface GitHistoryRevision { + readonly label: string; + readonly revision: string; +} + +export function gitHistoryFavoriteStorageKey(environmentId: EnvironmentId, cwd: string): string { + return `t3code:git-history-favorites:v1:${environmentId}:${cwd}`; +} + +export function toggleGitHistoryFavorite( + favorites: ReadonlyArray, + branch: string, +): ReadonlyArray { + return favorites.includes(branch) + ? favorites.filter((value) => value !== branch) + : [...favorites, branch]; +} + +export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, revision: number) { + const scopeKey = JSON.stringify([environmentId, cwd]); + const [favoriteBranches, setFavoriteBranches] = useLocalStorage( + gitHistoryFavoriteStorageKey(environmentId, cwd), + EMPTY_FAVORITE_BRANCHES, + FavoriteBranchesSchema, + ); + const [refFilter, setRefFilter] = useState(""); + const [selectedRevisionState, setSelectedRevision] = useState<{ + readonly scopeKey: string; + readonly value: GitHistoryRevision | null | undefined; + }>(() => ({ scopeKey, value: undefined })); + const scopedSelectedRevision = + selectedRevisionState.scopeKey === scopeKey ? selectedRevisionState.value : undefined; + const [expandedRefKeys, setExpandedRefKeys] = useState>( + () => new Set(["section:local"]), + ); + const deferredRefFilter = useDebouncedValue(refFilter.trim(), REF_FILTER_DEBOUNCE_MS); + const normalizedRefFilter = refFilter.trim().toLocaleLowerCase(); + const shouldLoadRemote = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:remote") || + scopedSelectedRevision?.revision.startsWith("refs/remotes/") === true; + const shouldLoadTags = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:tags") || + scopedSelectedRevision?.revision.startsWith("refs/tags/") === true; + const refs = usePaginatedHistoryRefs( + { environmentId, cwd, query: deferredRefFilter }, + { limit: 200, namespace: "local", revision }, + ); + const remote = usePaginatedHistoryRefs( + shouldLoadRemote + ? { environmentId, cwd, query: deferredRefFilter } + : { environmentId: null, cwd: null }, + { limit: 200, namespace: "remote", revision }, + ); + const tags = usePaginatedHistoryRefs( + shouldLoadTags + ? { environmentId, cwd, query: deferredRefFilter } + : { environmentId: null, cwd: null }, + { limit: 200, namespace: "tag", revision }, + ); + const mergedRefs = useMemo(() => [...refs.refs, ...remote.refs], [refs.refs, remote.refs]); + const tagRefs = tags.refs; + const { localRefs, remoteRefs } = useMemo(() => { + const local: VcsHistoryRef[] = []; + const remote: VcsHistoryRef[] = []; + for (const ref of mergedRefs) { + if (ref.isRemote) remote.push(ref); + else if (!ref.isTag) local.push(ref); + } + return { localRefs: local, remoteRefs: remote }; + }, [mergedRefs]); + const localRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(localRefs), normalizedRefFilter), + [localRefs, normalizedRefFilter], + ); + const favoriteBranchSet = useMemo(() => new Set(favoriteBranches), [favoriteBranches]); + const favoriteRefs = useMemo( + () => + localRefs.filter( + (ref) => + favoriteBranchSet.has(ref.name) && + (normalizedRefFilter.length === 0 || + ref.name.toLocaleLowerCase().includes(normalizedRefFilter)), + ), + [favoriteBranchSet, localRefs, normalizedRefFilter], + ); + const remoteRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(remoteRefs), normalizedRefFilter), + [normalizedRefFilter, remoteRefs], + ); + const tagRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(tagRefs), normalizedRefFilter), + [normalizedRefFilter, tagRefs], + ); + const currentRefResult = refs.data?.currentRef; + const currentRefTargetKey = `${environmentId}:${cwd}`; + const lastResolvedCurrentRef = useRef< + | { + readonly targetKey: string; + currentRef: VcsHistoryRef | null | undefined; + } + | undefined + >(undefined); + if (lastResolvedCurrentRef.current?.targetKey !== currentRefTargetKey) { + lastResolvedCurrentRef.current = { targetKey: currentRefTargetKey, currentRef: undefined }; + } + if (currentRefResult !== undefined) lastResolvedCurrentRef.current.currentRef = currentRefResult; + const currentRef = + currentRefResult === undefined ? lastResolvedCurrentRef.current.currentRef : currentRefResult; + const defaultSelectedRevision = useMemo(() => { + if (currentRef === undefined) return refs.error === null ? undefined : null; + if (currentRef === null) return null; + return { label: currentRef.name, revision: `refs/heads/${currentRef.name}` }; + }, [currentRef, refs.error]); + const selectedRefWasRemoved = useMemo(() => { + if (scopedSelectedRevision === undefined || scopedSelectedRevision === null) return false; + if (deferredRefFilter.length > 0) return false; + const selectedRef = scopedSelectedRevision.revision; + if (selectedRef.startsWith("refs/heads/")) { + return ( + refs.data?.isComplete === true && + refs.data.nextCursor === null && + !localRefs.some((ref) => selectedRef === `refs/heads/${ref.name}`) + ); + } + if (selectedRef.startsWith("refs/remotes/")) { + return ( + remote.data?.isComplete === true && + remote.data.nextCursor === null && + !remoteRefs.some((ref) => selectedRef === `refs/remotes/${ref.name}`) + ); + } + if (selectedRef.startsWith("refs/tags/")) { + return ( + tags.data?.isComplete === true && + tags.data.nextCursor === null && + !tagRefs.some((ref) => selectedRef === `refs/tags/${ref.name}`) + ); + } + return false; + }, [ + localRefs, + deferredRefFilter, + refs.data?.isComplete, + refs.data?.nextCursor, + remote.data?.isComplete, + remote.data?.nextCursor, + remoteRefs, + scopedSelectedRevision, + tagRefs, + tags.data?.isComplete, + tags.data?.nextCursor, + ]); + const selectedRevision = + scopedSelectedRevision === undefined || selectedRefWasRemoved + ? defaultSelectedRevision + : scopedSelectedRevision; + const initialLocalRefError = + currentRef === undefined && scopedSelectedRevision === undefined ? refs.error : null; + const toggleRefKey = useCallback((key: string) => { + setExpandedRefKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + const selectRef = useCallback( + (label: string, revision: string) => { + setSelectedRevision({ scopeKey, value: { label, revision } }); + }, + [scopeKey], + ); + const selectAllRefs = useCallback(() => { + setSelectedRevision({ scopeKey, value: null }); + }, [scopeKey]); + const toggleFavorite = useCallback( + (branch: string) => { + setFavoriteBranches((current) => toggleGitHistoryFavorite(current, branch)); + }, + [setFavoriteBranches], + ); + + useEffect(() => { + setSelectedRevision({ scopeKey, value: undefined }); + setRefFilter(""); + setExpandedRefKeys(new Set(["section:local"])); + }, [cwd, environmentId, scopeKey]); + + useEffect(() => { + if (selectedRefWasRemoved) setSelectedRevision({ scopeKey, value: undefined }); + }, [scopeKey, selectedRefWasRemoved]); + + return { + currentRef, + expandedRefKeys, + favoriteBranches: favoriteBranchSet, + favoriteRefs, + hasMoreRefs: + (refs.data?.nextCursor !== null && refs.data?.nextCursor !== undefined) || + (shouldLoadRemote && + remote.data?.nextCursor !== null && + remote.data?.nextCursor !== undefined) || + (shouldLoadTags && tags.data?.nextCursor !== null && tags.data?.nextCursor !== undefined), + isFetchingMoreRefs: + refs.isFetchingNextPage || + (shouldLoadRemote && remote.isFetchingNextPage) || + (shouldLoadTags && tags.isFetchingNextPage), + isRefSnapshotComplete: + refs.data?.isComplete !== false && + (!shouldLoadRemote || remote.data?.isComplete !== false) && + (!shouldLoadTags || tags.data?.isComplete !== false), + initialLocalRefError, + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs: () => { + refs.loadNext(); + if (shouldLoadRemote) remote.loadNext(); + if (shouldLoadTags) tags.loadNext(); + }, + refreshRefs: () => { + refs.refresh(); + remote.refresh(); + tags.refresh(); + }, + onRetryRefs: () => { + if (refs.error) refs.retry(); + if (shouldLoadRemote && remote.error) remote.retry(); + if (shouldLoadTags && tags.error) tags.retry(); + }, + refPaginationError: + refs.error ?? + (shouldLoadRemote ? remote.error : null) ?? + (shouldLoadTags ? tags.error : null), + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs, + selectRef, + selectedRevision, + setRefFilter, + tagRefTree, + tagRefs, + toggleRefKey, + toggleFavorite, + }; +} diff --git a/apps/web/src/components/issue/IssuesPanel.tsx b/apps/web/src/components/issue/IssuesPanel.tsx index 22f7a1fa1aa2..afb63def0c61 100644 --- a/apps/web/src/components/issue/IssuesPanel.tsx +++ b/apps/web/src/components/issue/IssuesPanel.tsx @@ -1,6 +1,7 @@ import type { EnvironmentId, IssueInvolvement, + IssueLinkedPullRequest, IssueListOrder, IssueListSort, IssueListEntry, @@ -95,6 +96,7 @@ interface IssuesPanelProps { onSelect: (target: NonNullable | null) => void; handoffTarget: IssueHandoffTarget; onStateChange: (status: IssueTabStatus) => void; + onOpenLinkedPullRequest?: (link: IssueLinkedPullRequest) => void; } /** @@ -117,6 +119,7 @@ function ProjectIssues({ onSelect, handoffTarget, onStateChange, + onOpenLinkedPullRequest, }: IssuesPanelProps) { // Held here rather than in the list, so reading an issue and coming back does not throw away // the search that found it — the list is unmounted while the issue is open. @@ -158,6 +161,7 @@ function ProjectIssues({ }} handoffTarget={handoffTarget} onStateChange={onStateChange} + {...(onOpenLinkedPullRequest ? { onOpenLinkedPullRequest } : {})} // The panel is the narrowest place this reads, so the metadata folds into the top row // once the content scrolls — the same bargain the issues page makes. chromeVariant="collapse" diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index 29e398be6ad2..aefe1caf2309 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -2,7 +2,6 @@ import type { EnvironmentId, ProjectId, PullRequestInvolvement, - ProjectIconOverride, PullRequestListFilters, PullRequestListState, SourceControlProviderKind, @@ -368,6 +367,7 @@ export function PullRequestFiltersMenu({ projectEnvironmentId, unavailable, onProject, + showProjectScope = true, }: { onOpenChange?: (open: boolean) => void; state: PullRequestListState; @@ -411,6 +411,8 @@ export function PullRequestFiltersMenu({ unavailable: ReadonlyMap; /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; + /** A repository-scoped panel already knows its project and does not repeat that choice. */ + showProjectScope?: boolean; }) { const selectedLabels = (filters.labels ?? []).flatMap((group) => group); const filterCount = [ @@ -545,17 +547,23 @@ export function PullRequestFiltersMenu({ /> ) : null} - - { - const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if (project) onProject(project.id, project.environmentId); - else if (projectId !== undefined) onProject(undefined, undefined); - }} - /> + {showProjectScope ? ( + <> + + { + const project = projects.find( + (candidate) => pullRequestProjectKey(candidate) === next, + ); + if (project) onProject(project.id, project.environmentId); + else if (projectId !== undefined) onProject(undefined, undefined); + }} + /> + + ) : null} ); diff --git a/apps/web/src/components/pullRequest/PullRequestsPanel.test.tsx b/apps/web/src/components/pullRequest/PullRequestsPanel.test.tsx new file mode 100644 index 000000000000..9f7d8f5e1ac7 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.test.tsx @@ -0,0 +1,463 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { PullRequestsPanel } from "./PullRequestsPanel"; + +const hooks = vi.hoisted(() => { + let hookIndex = 0; + const state = new Map(); + const setters = new Map void>(); + const effects = new Map | undefined>(); + const memos = new Map< + number, + { readonly dependencies: ReadonlyArray; readonly value: unknown } + >(); + + return { + beginRender: () => { + hookIndex = 0; + }, + reset: () => { + hookIndex = 0; + state.clear(); + setters.clear(); + effects.clear(); + memos.clear(); + }, + unmountFrom: (firstIndex: number) => { + for (const index of state.keys()) { + if (index >= firstIndex) state.delete(index); + } + for (const index of setters.keys()) { + if (index >= firstIndex) setters.delete(index); + } + for (const index of effects.keys()) { + if (index >= firstIndex) effects.delete(index); + } + for (const index of memos.keys()) { + if (index >= firstIndex) memos.delete(index); + } + }, + useCallback: (callback: T) => { + hookIndex += 1; + return callback; + }, + useEffect: (effect: () => void | (() => void), dependencies: ReadonlyArray) => { + const index = hookIndex++; + const previous = effects.get(index); + effects.set(index, dependencies); + if ( + previous === undefined || + previous.length !== dependencies.length || + previous.some((value, dependencyIndex) => value !== dependencies[dependencyIndex]) + ) { + effect(); + } + }, + useMemo: (factory: () => T, dependencies: ReadonlyArray) => { + const index = hookIndex++; + const previous = memos.get(index); + if ( + previous !== undefined && + previous.dependencies.length === dependencies.length && + previous.dependencies.every( + (value, dependencyIndex) => value === dependencies[dependencyIndex], + ) + ) { + return previous.value as T; + } + const value = factory(); + memos.set(index, { dependencies, value }); + return value; + }, + useRef: (value: T) => { + const index = hookIndex++; + if (!state.has(index)) state.set(index, { current: value ?? ({} as T) }); + return state.get(index) as { current: T }; + }, + useState: (initial: T) => { + const index = hookIndex++; + if (!state.has(index)) { + state.set(index, typeof initial === "function" ? (initial as () => T)() : initial); + } + if (!setters.has(index)) { + setters.set(index, (next) => { + state.set( + index, + typeof next === "function" ? (next as (previous: T) => T)(state.get(index) as T) : next, + ); + }); + } + return [ + state.get(index) as T, + setters.get(index) as (next: T | ((previous: T) => T)) => void, + ] as const; + }, + }; +}); + +vi.mock("react", async (importOriginal) => ({ + ...(await importOriginal()), + useCallback: hooks.useCallback, + useEffect: hooks.useEffect, + useMemo: hooks.useMemo, + useRef: hooks.useRef, + useState: hooks.useState, +})); + +vi.mock("effect/Option", async (importOriginal) => ({ + ...(await importOriginal()), + getOrNull: (value: T) => value, +})); +vi.mock("effect/unstable/reactivity", () => ({ + AsyncResult: { value: (result: { readonly value: unknown }) => result.value }, +})); + +const queries = vi.hoisted(() => { + const initial = { + viewers: {}, + providers: [], + entries: Array.from({ length: 30 }, (_, index) => ({ + host: "github.com", + labels: [], + number: index + 1, + projectId: "project-1", + repository: "owner/repository", + state: "open", + updatedAt: "2026-08-20T00:00:00.000Z", + })), + errors: [], + truncated: true, + nextCursors: { "github.com": "second-page" }, + }; + const continuation = { + ...initial, + entries: [{ ...initial.entries[0], number: 31 }], + truncated: false, + nextCursors: {}, + }; + return { + inputs: [] as Array<{ readonly input: { readonly cursors?: unknown; readonly limit: number } }>, + refresh: vi.fn(), + initial, + continuation, + }; +}); + +const stats = vi.hoisted(() => ({ + targets: [] as ReadonlyArray[], + value: [] as ReadonlyArray, + refresh: vi.fn(), +})); + +const buttons = vi.hoisted(() => new Map void>()); +const observers = vi.hoisted( + () => [] as Array<(entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void>, +); +const detailActions = vi.hoisted(() => [] as Array<() => void>); +const rowEntries = vi.hoisted(() => [] as Array); + +vi.mock("~/state/pullRequests", () => ({ + pullRequestEnvironment: { + invalidate: {}, + list: (input: unknown) => input, + listStats: (target: unknown) => { + stats.targets.push([target]); + return target; + }, + }, +})); + +vi.mock("~/rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: () => ({ value: { stats: stats.value } }), + refresh: stats.refresh, + subscribe: () => () => undefined, + }, +})); + +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: () => async () => undefined, +})); + +vi.mock("~/state/queries", () => ({ + useDebouncedValue: (value: string) => value, +})); + +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (query: { + readonly input: { readonly cursors?: unknown; readonly limit: number }; + }) => { + queries.inputs.push(query); + const continuation = query.input.cursors !== undefined; + return { + data: continuation ? queries.continuation : queries.initial, + error: null, + isPending: false, + refresh: queries.refresh, + }; + }, +})); + +vi.mock("../ui/button", () => ({ + Button: ({ + children, + "aria-label": ariaLabel, + onClick, + }: { + children: ReactNode; + "aria-label"?: string; + onClick?: () => void; + }) => { + if (ariaLabel && onClick) buttons.set(ariaLabel, onClick); + return ; + }, +})); + +vi.mock("../ui/scroll-area", () => ({ + ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, +})); +vi.mock("./PullRequestListFilters", () => ({ PullRequestFiltersMenu: () => null })); +vi.mock("./PullRequestDetailPanel", () => ({ + PullRequestDetailPanel: ({ onActed }: { onActed: () => void }) => { + detailActions.push(onActed); + return null; + }, +})); +vi.mock("./PullRequestRow", () => ({ + PullRequestRow: ({ entry }: { readonly entry: unknown }) => { + rowEntries.push(entry); + return null; + }, +})); + +describe("PullRequestsPanel", () => { + it("refreshes the accumulated list from the first page after cursor pagination", async () => { + hooks.reset(); + queries.inputs.length = 0; + queries.refresh.mockReset(); + stats.targets.length = 0; + stats.value = []; + buttons.clear(); + observers.length = 0; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor( + callback: (entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void, + ) { + observers.push(callback); + } + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + observers.at(-1)?.([{ isIntersecting: true }]); + render(); + render(); + expect(queries.inputs.at(-1)?.input.cursors).toEqual({ "github.com": "second-page" }); + + buttons.get("Refresh pull requests")?.(); + await Promise.resolve(); + await Promise.resolve(); + render(); + + expect(queries.inputs.at(-1)?.input).toMatchObject({ limit: 60 }); + expect(queries.inputs.at(-1)?.input.cursors).toBeUndefined(); + }); + + it("refreshes line counts only for the current paged rows", async () => { + hooks.reset(); + queries.inputs.length = 0; + stats.targets.length = 0; + stats.value = []; + stats.refresh.mockReset(); + observers.length = 0; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor( + callback: (entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void, + ) { + observers.push(callback); + } + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + render(); + expect(stats.targets.at(-1)).toEqual([ + { + environmentId: "env-1", + input: { + refs: expect.arrayContaining([ + expect.objectContaining({ number: 1 }), + expect.objectContaining({ number: 30 }), + ]), + }, + }, + ]); + + observers.at(-1)?.([{ isIntersecting: true }]); + render(); + render(); + render(); + expect(stats.targets.at(-1)).toEqual([ + expect.objectContaining({ + input: { + refs: expect.arrayContaining([ + expect.objectContaining({ number: 1 }), + expect.objectContaining({ number: 31 }), + ]), + }, + }), + ]); + + buttons.get("Refresh pull requests")?.(); + await Promise.resolve(); + await Promise.resolve(); + + expect(stats.refresh).toHaveBeenCalledTimes(1); + expect( + ( + stats.targets.at(-1) as Array<{ + readonly input: { readonly refs: ReadonlyArray }; + }> + )[0]?.input.refs, + ).toHaveLength(31); + }); + + it("unmounts the browser list while a pull request detail is selected", () => { + hooks.reset(); + queries.inputs.length = 0; + stats.targets.length = 0; + stats.value = []; + + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + + expect(queries.inputs).toEqual([]); + expect(stats.targets).toEqual([]); + }); + + it("keeps unchanged decorated row entries stable between panel renders", () => { + hooks.reset(); + queries.inputs.length = 0; + rowEntries.length = 0; + stats.value = [ + { environmentId: "env-1", projectId: "project-1", number: 1, additions: 2, deletions: 1 }, + ]; + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + render(); + render(); + + expect(rowEntries.at(-30)).toBe(rowEntries.at(-60)); + }); + + it("replays a detail action after the list remounts", () => { + hooks.reset(); + queries.inputs.length = 0; + queries.refresh.mockReset(); + stats.refresh.mockReset(); + stats.targets.length = 0; + stats.value = []; + buttons.clear(); + detailActions.length = 0; + observers.length = 0; + vi.stubGlobal( + "IntersectionObserver", + class { + constructor( + callback: (entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void, + ) { + observers.push(callback); + } + disconnect() {} + observe() {} + }, + ); + + const render = ( + selected: { + readonly projectId: never; + readonly repository: string; + readonly number: number; + } | null, + ) => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(null); + observers.at(-1)?.([{ isIntersecting: true }]); + render(null); + render({ projectId: "project-1" as never, repository: "owner/repository", number: 1 }); + detailActions.at(-1)?.(); + hooks.unmountFrom(6); + render({ projectId: "project-1" as never, repository: "owner/repository", number: 1 }); + render({ projectId: "project-1" as never, repository: "owner/repository", number: 1 }); + render(null); + render(null); + + expect(queries.inputs.at(-1)?.input).toMatchObject({ limit: 60 }); + expect(queries.inputs.at(-1)?.input.cursors).toBeUndefined(); + expect(queries.refresh).not.toHaveBeenCalled(); + expect(stats.refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestsPanel.tsx b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx new file mode 100644 index 000000000000..ef87fe8d2480 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx @@ -0,0 +1,626 @@ +import type { + EnvironmentId, + IssueLink, + ProjectId, + PullRequestInvolvement, + PullRequestListCursors, + PullRequestListEntry, + PullRequestListFilters, + PullRequestListStatsResult, + PullRequestListState, + PullRequestState, + ScopedThreadRef, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { + ArrowLeftIcon, + EyeIcon, + GitPullRequestClosedIcon, + GitPullRequestIcon, + GitMergeIcon, + LayersIcon, + LoaderIcon, + PenLineIcon, + RefreshCwIcon, +} from "lucide-react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useDebouncedValue } from "~/state/queries"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import type { DraftId } from "~/composerDraftStore"; + +import { ListGhost } from "../sourceControl/ListGhosts"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { ScrollArea } from "../ui/scroll-area"; +import { + filterPullRequestsByInvolvement, + decoratePullRequestEntries, + groupPullRequestsByInvolvement, + matchesPullRequestFilters, + matchesPullRequestQuery, + mergePullRequestDiffStats, + parsePullRequestQuery, + pullRequestEntryKey, + pullRequestEntryViewer, + pullRequestStatsTargets, + prunePullRequestDiffStats, + rankPullRequestMatches, + type EnvironmentPullRequestEntry, + type EnvironmentPullRequestStat, + type PullRequestDiffStats, +} from "./pullRequestList.logic"; +import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; +import { PullRequestFiltersMenu, type PullRequestFilterOption } from "./PullRequestListFilters"; +import { PullRequestRow } from "./PullRequestRow"; + +const STATE_OPTIONS = [ + { value: "all", label: "All", Icon: LayersIcon }, + { value: "open", label: "Open", Icon: GitPullRequestIcon }, + { value: "closed", label: "Closed", Icon: GitPullRequestClosedIcon }, + { value: "merged", label: "Merged", Icon: GitMergeIcon }, +] as const satisfies ReadonlyArray>; + +const INVOLVEMENT_OPTIONS = [ + { value: "all", label: "All", Icon: LayersIcon }, + { value: "reviewing", label: "Reviewing", Icon: EyeIcon }, + { value: "authored", label: "Authored", Icon: PenLineIcon }, +] as const satisfies ReadonlyArray>; + +const SEARCH_DEBOUNCE_MS = 250; +const PAGE_SIZE = 30; +const MAX_LIMIT = 500; + +export interface PullRequestPanelSelection { + readonly projectId: ProjectId; + readonly repository: string; + readonly number: number; +} + +interface PullRequestsPanelProps { + readonly environmentId: EnvironmentId; + readonly projectId: ProjectId; + readonly selected: PullRequestPanelSelection | null; + readonly onSelect: (selection: PullRequestPanelSelection | null) => void; + readonly composerDraftTarget?: ScopedThreadRef | DraftId; + readonly onOpenLinkedIssue?: (link: IssueLink) => void; + readonly onStateChange?: (status: { + projectId: string; + repository: string; + number: number; + state: PullRequestState; + isDraft: boolean; + }) => void; +} + +interface PanelPage { + readonly key: string; + readonly size: number; + readonly cursors: PullRequestListCursors | null; +} + +function usePanelPullRequestListStats( + targets: ReadonlyArray[0]>, +) { + const [results, setResults] = useState< + ReadonlyMap + >(() => new Map()); + useEffect(() => { + setResults(new Map()); + const subscriptions = targets.map((target, index) => { + const atom = pullRequestEnvironment.listStats(target); + const update = (result: AsyncResult.AsyncResult) => { + const value = Option.getOrNull(AsyncResult.value(result)); + if (value === null) return; + setResults((previous) => { + const next = new Map(previous); + next.set(index, [target.environmentId, value]); + return next; + }); + }; + const unsubscribe = appAtomRegistry.subscribe(atom, update); + update(appAtomRegistry.get(atom)); + return unsubscribe; + }); + return () => { + for (const unsubscribe of subscriptions) unsubscribe(); + }; + }, [targets]); + const stats = useMemo | null>( + () => + results.size === 0 + ? null + : [...results.values()].flatMap(([environmentId, result]) => + result.stats.map((stat) => ({ ...stat, environmentId })), + ), + [results], + ); + const refresh = useCallback(() => { + for (const target of targets) { + appAtomRegistry.refresh(pullRequestEnvironment.listStats(target)); + } + }, [targets]); + return { stats, refresh }; +} + +export function PullRequestsPanel(props: PullRequestsPanelProps) { + return ; +} + +function ProjectPullRequests(props: PullRequestsPanelProps) { + const [query, setQuery] = useState(""); + const [filters, setFilters] = useState<{ + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly extra: PullRequestListFilters; + }>({ state: "open", involvement: "all", extra: {} }); + const [page, setPage] = useState({ key: "", size: PAGE_SIZE, cursors: null }); + const [refreshPending, setRefreshPending] = useState(false); + const [ordered, setOrdered] = useState<{ + readonly key: string; + readonly entries: ReadonlyArray; + readonly truncated: boolean; + } | null>(null); + const [statsByRow, setStatsByRow] = useState(() => new Map()); + + return ( +
+ {props.selected ? ( +
+
+ +
+
+ setRefreshPending(true)} + {...(props.onStateChange ? { onStateChange: props.onStateChange } : {})} + {...(props.onOpenLinkedIssue ? { onOpenLinkedIssue: props.onOpenLinkedIssue } : {})} + {...(props.composerDraftTarget + ? { composerDraftTarget: props.composerDraftTarget } + : {})} + /> +
+
+ ) : null} + {props.selected === null ? ( +
+ setRefreshPending(false)} + /> +
+ ) : null} +
+ ); +} + +function PullRequestBrowserList({ + environmentId, + projectId, + onSelect, + query, + onQuery, + filters, + onFilters, + page, + onPage, + ordered, + onOrdered, + statsByRow, + onStatsByRow, + refreshPending, + onRefreshConsumed, +}: { + environmentId: EnvironmentId; + projectId: ProjectId; + onSelect: (selection: PullRequestPanelSelection) => void; + query: string; + onQuery: (query: string) => void; + filters: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly extra: PullRequestListFilters; + }; + onFilters: (filters: { + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly extra: PullRequestListFilters; + }) => void; + page: PanelPage; + onPage: (page: PanelPage) => void; + ordered: { + readonly key: string; + readonly entries: ReadonlyArray; + readonly truncated: boolean; + } | null; + onOrdered: Dispatch< + SetStateAction<{ + readonly key: string; + readonly entries: ReadonlyArray; + readonly truncated: boolean; + } | null> + >; + statsByRow: PullRequestDiffStats; + onStatsByRow: Dispatch>; + refreshPending: boolean; + onRefreshConsumed: () => void; +}) { + const typed = query.trim().slice(0, 200); + const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); + const typedParsed = useMemo(() => parsePullRequestQuery(typed), [typed]); + const sentParsed = useMemo(() => parsePullRequestQuery(sent), [sent]); + const requestFilters = useMemo( + () => ({ ...filters.extra, ...sentParsed.filters }), + [filters.extra, sentParsed.filters], + ); + const localFilters = useMemo( + () => ({ ...filters.extra, ...typedParsed.filters }), + [filters.extra, typedParsed.filters], + ); + const filterKey = `${filters.state}:${filters.involvement}:${JSON.stringify(requestFilters)}:${sentParsed.text}`; + const pageSize = page.key === filterKey ? page.size : PAGE_SIZE; + const sentCursors = page.key === filterKey ? page.cursors : null; + + useEffect(() => { + onPage({ key: filterKey, size: PAGE_SIZE, cursors: null }); + }, [filterKey, onPage]); + + const listQuery = useEnvironmentQuery( + pullRequestEnvironment.list({ + environmentId, + input: { + state: filters.state, + involvement: filters.involvement, + projectId, + limit: pageSize, + ...(Object.keys(requestFilters).length > 0 ? { filters: requestFilters } : {}), + ...(sentParsed.text ? { query: sentParsed.text } : {}), + ...(sentCursors === null ? {} : { cursors: sentCursors }), + }, + }), + ); + const answered = listQuery.data; + const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [invalidating, setInvalidating] = useState(false); + useEffect(() => { + if (answered === null) return; + onOrdered((previous) => { + if (previous === null || previous.key !== filterKey || sentCursors === null) { + return { + key: filterKey, + entries: rankPullRequestMatches(answered.entries, sentParsed.text), + truncated: answered.truncated, + }; + } + const held = new Set(previous.entries.map(pullRequestEntryKey)); + return { + key: filterKey, + entries: [ + ...previous.entries, + ...answered.entries.filter((entry) => !held.has(pullRequestEntryKey(entry))), + ], + truncated: answered.truncated, + }; + }); + }, [answered, filterKey, sentCursors, sentParsed.text]); + + const entries = useMemo(() => { + const held = ordered?.key === filterKey ? ordered.entries : (answered?.entries ?? []); + const searchingHosts = new Set( + (answered?.providers ?? []) + .filter((provider) => provider.searchesOnHost) + .map((provider) => provider.host), + ); + const narrowed = filterPullRequestsByInvolvement( + held, + answered?.viewers ?? {}, + filters.involvement, + ).filter( + (entry) => + (typedParsed.text.length === 0 || + searchingHosts.has(entry.host) || + matchesPullRequestQuery(entry, typedParsed.text)) && + matchesPullRequestFilters( + entry, + localFilters, + pullRequestEntryViewer(entry, answered?.viewers ?? {}), + ), + ); + return narrowed.map((entry): EnvironmentPullRequestEntry => ({ ...entry, environmentId })); + }, [ + answered, + environmentId, + filterKey, + filters.involvement, + localFilters, + ordered, + typedParsed.text, + ]); + + const statsTargets = useMemo(() => pullRequestStatsTargets(entries), [entries]); + useEffect(() => { + onStatsByRow((previous) => prunePullRequestDiffStats(previous, entries)); + }, [entries, onStatsByRow]); + const statsQuery = usePanelPullRequestListStats(statsTargets); + useEffect(() => { + if (statsQuery.stats === null) return; + onStatsByRow((previous) => mergePullRequestDiffStats(previous, statsQuery.stats ?? [])); + }, [onStatsByRow, statsQuery.stats]); + const decorationCache = useRef< + ReadonlyMap + >(new Map()); + const decoration = useMemo( + () => decoratePullRequestEntries(entries, statsByRow, decorationCache.current), + [entries, statsByRow], + ); + useEffect(() => { + decorationCache.current = decoration.bySource; + }, [decoration]); + const decoratedEntries = decoration.entries; + const groups = useMemo( + () => + filters.involvement === "all" + ? groupPullRequestsByInvolvement(decoratedEntries, answered?.viewers ?? {}) + : [{ key: "others" as const, label: "", entries: decoratedEntries }], + [answered?.viewers, decoratedEntries, filters.involvement], + ); + + const truncated = ordered?.key === filterKey ? ordered.truncated : (answered?.truncated ?? false); + const nextCursors = answered?.nextCursors ?? {}; + const canContinue = Object.keys(nextCursors).length > 0; + const loadingMore = listQuery.isPending && entries.length > 0; + const loadMore = useCallback(() => { + onPage( + canContinue + ? { key: filterKey, size: pageSize, cursors: nextCursors } + : { key: filterKey, size: Math.min(pageSize + PAGE_SIZE, MAX_LIMIT), cursors: null }, + ); + }, [canContinue, filterKey, nextCursors, onPage, pageSize]); + + const refreshList = useCallback(() => { + if (sentCursors === null) { + listQuery.refresh(); + return; + } + const loadedCount = ordered?.key === filterKey ? ordered.entries.length : pageSize; + onPage({ + key: filterKey, + size: Math.min(Math.max(pageSize, Math.ceil(loadedCount / PAGE_SIZE) * PAGE_SIZE), MAX_LIMIT), + cursors: null, + }); + }, [filterKey, listQuery.refresh, onPage, ordered, pageSize, sentCursors]); + + const consumedRefresh = useRef(false); + useEffect(() => { + if (!refreshPending) { + consumedRefresh.current = false; + return; + } + if (consumedRefresh.current) return; + consumedRefresh.current = true; + refreshList(); + statsQuery.refresh(); + onRefreshConsumed(); + }, [onRefreshConsumed, refreshList, refreshPending, statsQuery.refresh]); + + const sentinelRef = useRef(null); + useEffect(() => { + const sentinel = sentinelRef.current; + if ( + !sentinel || + entries.length === 0 || + !truncated || + listQuery.isPending || + listQuery.error !== null || + (!canContinue && pageSize >= MAX_LIMIT) + ) { + return; + } + const observer = new IntersectionObserver( + (observed) => { + if (observed.some((entry) => entry.isIntersecting)) loadMore(); + }, + { rootMargin: "240px" }, + ); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [ + canContinue, + entries.length, + listQuery.error, + listQuery.isPending, + loadMore, + pageSize, + truncated, + ]); + + const select = useCallback( + (entry: EnvironmentPullRequestEntry) => + onSelect({ projectId, repository: entry.repository, number: entry.number }), + [onSelect, projectId], + ); + const narrowed = + filters.state !== "open" || + filters.involvement !== "all" || + Object.keys(localFilters).length > 0; + const refreshFromHost = useCallback(async () => { + setInvalidating(true); + try { + await invalidate({ environmentId, input: {} }); + } finally { + setInvalidating(false); + } + refreshList(); + statsQuery.refresh(); + }, [environmentId, invalidate, refreshList, statsQuery.refresh]); + const refreshing = invalidating || listQuery.isPending; + + return ( +
+
+ onQuery(event.target.value)} + /> + onFilters({ ...filters, state })} + involvement={filters.involvement} + involvementOptions={INVOLVEMENT_OPTIONS} + onInvolvement={(involvement) => onFilters({ ...filters, involvement })} + filters={filters.extra} + onFilters={(extra) => onFilters({ ...filters, extra })} + host={undefined} + hostOptions={[]} + onHost={() => undefined} + server={undefined} + serverOptions={[]} + onServer={() => undefined} + projects={[]} + projectId={undefined} + projectEnvironmentId={undefined} + unavailable={new Map()} + onProject={() => undefined} + showProjectScope={false} + /> + +
+ +
+ {entries.length === 0 && listQuery.isPending ? ( + + ) : listQuery.error !== null && listQuery.data === null ? ( +
+

{listQuery.error}

+ +
+ ) : entries.length === 0 && + (listQuery.error !== null || (answered?.errors.length ?? 0) > 0) ? ( +
+

Some pull requests could not be loaded.

+ +
+ ) : entries.length === 0 ? ( +
+

+ {typed.length > 0 + ? "No pull request here matches that." + : narrowed + ? "No pull request here matches these filters." + : "This repository has no pull requests to open."} +

+ {truncated && (canContinue || pageSize < MAX_LIMIT) ? ( + + ) : null} +
+ ) : ( + <> + {groups.map((group) => ( +
+ {group.label ? ( +

+ {group.label} +

+ ) : null} + {group.entries.map((entry) => ( + + ))} +
+ ))} + {answered?.errors.length ? ( +
+ Some pull requests could not be loaded. + +
+ ) : null} + {listQuery.error !== null ? ( +
+ The latest request failed. Showing the last pull requests loaded. + +
+ ) : null} + {truncated ? ( +
+ {loadingMore ? ( + + + Loading more + + ) : null} +
+ ) : null} + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index a90022ad72c9..3ea9408d4747 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, ProjectId, PullRequestListEntry } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, type PullRequestListEntry } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -7,6 +7,7 @@ import { mergePullRequestLists, pullRequestEntryKey, pullRequestEnvironmentSetKey, + decoratePullRequestEntries, groupPullRequestsByInvolvement, matchesPullRequestFilters, matchesPullRequestQuery, @@ -18,6 +19,8 @@ import { mergePullRequestDiffStats, narrowPullRequestsToFilters, partitionPullRequestsWithPriority, + pullRequestStatsTargets, + prunePullRequestDiffStats, readPullRequestListSnapshot, writePullRequestListSnapshot, rankPullRequestMatches, @@ -44,10 +47,10 @@ function entry( overrides: Partial & Pick, ) { return { - environmentId: "env-1", + environmentId: EnvironmentId.make("env-1"), provider: "github", host: "github.com", - projectId: "project-1", + projectId: ProjectId.make("project-1"), projectTitle: "t3code", repository: "pingdotgg/t3code", title: "Add the pull requests page", @@ -890,6 +893,59 @@ describe("line counts that arrive after the rows", () => { const row = entry({ number: 9, additions: 0, deletions: 0 }); expect(withDiffStat(row, stats)).toBe(row); }); + + it("keeps an unchanged decorated row stable when another row's count changes", () => { + const first = entry({ number: 1, additions: 0, deletions: 0 }); + const second = entry({ number: 2, additions: 0, deletions: 0 }); + const initial = decoratePullRequestEntries( + [first, second], + new Map([ + ["env-1 project-1 1", { additions: 1, deletions: 1 }], + ["env-1 project-1 2", { additions: 2, deletions: 2 }], + ]), + new Map(), + ); + const updated = decoratePullRequestEntries( + [first, second], + new Map([ + ["env-1 project-1 1", { additions: 1, deletions: 1 }], + ["env-1 project-1 2", { additions: 3, deletions: 2 }], + ]), + initial.bySource, + ); + + expect(updated.entries[0]).toBe(initial.entries[0]); + expect(updated.entries[1]).not.toBe(initial.entries[1]); + }); +}); + +describe("line-count query targets", () => { + it("drops prior filter and page batches before a refresh", () => { + const firstPage = pullRequestStatsTargets([entry({ number: 1 }), entry({ number: 2 })]); + const currentFilter = pullRequestStatsTargets([ + entry({ environmentId: EnvironmentId.make("env-1"), number: 31 }), + entry({ + environmentId: EnvironmentId.make("env-2"), + projectId: ProjectId.make("project-2"), + repository: "acme/api", + number: 7, + }), + ]); + + expect(firstPage.flatMap((target) => target.input.refs).map((ref) => ref.number)).toEqual([ + 1, 2, + ]); + expect(currentFilter).toEqual([ + { + environmentId: "env-1", + input: { refs: [{ projectId: "project-1", repository: "pingdotgg/t3code", number: 31 }] }, + }, + { + environmentId: "env-2", + input: { refs: [{ projectId: "project-2", repository: "acme/api", number: 7 }] }, + }, + ]); + }); }); describe("merging line counts across keyed stats queries", () => { @@ -925,6 +981,27 @@ describe("merging line counts across keyed stats queries", () => { }); }); +describe("retaining line counts for currently rendered rows", () => { + it("drops stale filters while preserving rows accumulated by pagination", () => { + const held = new Map([ + ["env-1 project-1 1", { additions: 1, deletions: 1 }], + ["env-1 project-1 2", { additions: 2, deletions: 2 }], + ["env-1 project-1 3", { additions: 3, deletions: 3 }], + ]); + + const paginated = prunePullRequestDiffStats(held, [entry({ number: 1 }), entry({ number: 2 })]); + expect(paginated).toEqual( + new Map([ + ["env-1 project-1 1", { additions: 1, deletions: 1 }], + ["env-1 project-1 2", { additions: 2, deletions: 2 }], + ]), + ); + expect(prunePullRequestDiffStats(paginated, [entry({ number: 2 })])).toEqual( + new Map([["env-1 project-1 2", { additions: 2, deletions: 2 }]]), + ); + }); +}); + describe("partitioning with the hosts' own priority reads", () => { const authoredRow = (number: number, updatedAt: string) => entry({ number, updatedAt, author: { login: "Bilal", name: null, avatarUrl: null } }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 1ab287f26f6d..7da02cf5e609 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -15,6 +15,7 @@ import type { PullRequestLabel, PullRequestListCursors, PullRequestListFilters, + PullRequestListStatsInput, PullRequestListState, } from "@t3tools/contracts"; @@ -669,6 +670,50 @@ export const pullRequestDiffStatKey = (row: { readonly projectId: string; readonly number: number; }) => `${row.environmentId} ${row.projectId} ${row.number}`; +const MAX_STATS_REFS_PER_BATCH = 500; + +/** Keeps the stats cache bounded to rows that remain visible after a filter or page change. */ +export function prunePullRequestDiffStats( + previous: PullRequestDiffStats, + entries: ReadonlyArray, +): PullRequestDiffStats { + const visible = new Set(entries.map(pullRequestDiffStatKey)); + if ([...previous.keys()].every((key) => visible.has(key))) return previous; + return new Map([...previous].filter(([key]) => visible.has(key))); +} + +/** The bounded line-count batches for the rows currently visible in the list. */ +export function pullRequestStatsTargets( + entries: ReadonlyArray, +): ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly input: PullRequestListStatsInput; +}> { + const refsByEnvironment = new Map< + EnvironmentId, + Array + >(); + const seen = new Set(); + for (const entry of entries) { + const key = pullRequestDiffStatKey(entry); + if (seen.has(key)) continue; + seen.add(key); + const refs = refsByEnvironment.get(entry.environmentId) ?? []; + refs.push({ projectId: entry.projectId, repository: entry.repository, number: entry.number }); + refsByEnvironment.set(entry.environmentId, refs); + } + return [...refsByEnvironment].flatMap(([environmentId, refs]) => + Array.from({ length: Math.ceil(refs.length / MAX_STATS_REFS_PER_BATCH) }, (_, batchIndex) => ({ + environmentId, + input: { + refs: refs.slice( + batchIndex * MAX_STATS_REFS_PER_BATCH, + (batchIndex + 1) * MAX_STATS_REFS_PER_BATCH, + ), + }, + })), + ); +} /** * Every connected environment's listing, read as one list. @@ -1064,3 +1109,25 @@ export function withDiffStat< const stat = statsByRow.get(pullRequestDiffStatKey(entry)); return stat === undefined ? entry : { ...entry, ...stat }; } + +/** Reuses a row's decoration when the same source row keeps the same line counts. */ +export function decoratePullRequestEntries< + Entry extends PullRequestListEntry & { readonly environmentId: string }, +>( + entries: ReadonlyArray, + statsByRow: ReadonlyMap, + previous: ReadonlyMap, +): { readonly entries: ReadonlyArray; readonly bySource: ReadonlyMap } { + const bySource = new Map(); + const decorated = entries.map((entry) => { + const next = withDiffStat(entry, statsByRow); + const held = previous.get(entry); + const value = + held !== undefined && held.additions === next.additions && held.deletions === next.deletions + ? held + : next; + bySource.set(entry, value); + return value; + }); + return { entries: decorated, bySource }; +} diff --git a/apps/web/src/gitHistoryCapability.test.ts b/apps/web/src/gitHistoryCapability.test.ts new file mode 100644 index 000000000000..bc49e1dd0229 --- /dev/null +++ b/apps/web/src/gitHistoryCapability.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasGitHistoryCapability } from "./gitHistoryCapability"; + +describe("Git History capability", () => { + it("treats only an explicit true flag as supported", () => { + expect(hasGitHistoryCapability(undefined)).toBe(false); + expect(hasGitHistoryCapability({})).toBe(false); + expect(hasGitHistoryCapability({ gitHistory: false })).toBe(false); + expect(hasGitHistoryCapability({ gitHistory: true })).toBe(true); + }); +}); diff --git a/apps/web/src/gitHistoryCapability.ts b/apps/web/src/gitHistoryCapability.ts new file mode 100644 index 000000000000..bc408079438d --- /dev/null +++ b/apps/web/src/gitHistoryCapability.ts @@ -0,0 +1,7 @@ +import type { ExecutionEnvironmentCapabilities } from "@t3tools/contracts"; + +export function hasGitHistoryCapability( + capabilities: Pick | null | undefined, +): boolean { + return capabilities?.gitHistory === true; +} diff --git a/apps/web/src/lib/gitHistoryGraph.test.ts b/apps/web/src/lib/gitHistoryGraph.test.ts new file mode 100644 index 000000000000..ea89dfb224ee --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW, + MAX_GIT_HISTORY_GRAPH_LANES, + layoutGitHistoryGraph, +} from "./gitHistoryGraph"; + +describe("layoutGitHistoryGraph", () => { + it("lays out a linear history in one lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "c", parentHashes: ["b"] }, + { hash: "b", parentHashes: ["a"] }, + { hash: "a", parentHashes: [] }, + ]); + + expect(layout).toEqual({ + laneCount: 1, + rows: [ + { + hash: "c", + lane: 0, + colorIndex: 0, + hasIncoming: false, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "b", + isMissingParent: false, + }, + ], + }, + { + hash: "b", + lane: 0, + colorIndex: 0, + hasIncoming: true, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "a", + isMissingParent: false, + }, + ], + }, + { hash: "a", lane: 0, colorIndex: 0, hasIncoming: true, edges: [] }, + ], + }); + }); + + it("keeps a lane color stable when a commit has a branch decoration", () => { + const layout = layoutGitHistoryGraph([ + { hash: "head", parentHashes: ["tip"], refs: ["HEAD -> merge-wt", "origin/merge-wt"] }, + { hash: "tip", parentHashes: ["base"] }, + { hash: "base", parentHashes: ["root"], refs: ["fix/602-pr-integration"] }, + { hash: "root", parentHashes: [] }, + ]); + + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["head", 0, 0], + ["tip", 0, 0], + ["base", 0, 0], + ["root", 0, 0], + ]); + expect(layout.rows[2]?.edges).toContainEqual( + expect.objectContaining({ kind: "parent", colorIndex: 0, fromLane: 0, toLane: 0 }), + ); + }); + + it("keeps branch lanes and colors stable through a merge", () => { + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect(layout.laneCount).toBe(2); + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["merge", 0, 0], + ["main", 0, 0], + ["side", 1, 1], + ["base", 0, 0], + ]); + expect(layout.rows[0]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "main", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "side", + isMissingParent: false, + }, + ]); + expect(layout.rows[1]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "base", + isMissingParent: false, + }, + { fromLane: 1, toLane: 1, colorIndex: 1, kind: "continuation" }, + ]); + expect(layout.rows[2]?.edges).toEqual([ + { + fromLane: 1, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "base", + isMissingParent: false, + }, + { fromLane: 0, toLane: 0, colorIndex: 0, kind: "continuation" }, + ]); + expect(layout.rows[3]?.edges).toEqual([ + { + fromLane: 1, + toLane: 0, + colorIndex: 1, + kind: "incoming", + }, + ]); + }); + + it("allocates one lane for each octopus parent", () => { + const layout = layoutGitHistoryGraph([ + { hash: "octopus", parentHashes: ["first", "second", "third"] }, + { hash: "first", parentHashes: [] }, + { hash: "second", parentHashes: [] }, + { hash: "third", parentHashes: [] }, + ]); + + expect(layout.laneCount).toBe(3); + expect(layout.rows[0]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "first", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "second", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 2, + colorIndex: 2, + kind: "parent", + parentHash: "third", + isMissingParent: false, + }, + ]); + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["octopus", 0, 0], + ["first", 0, 0], + ["second", 1, 1], + ["third", 2, 2], + ]); + }); + + it("keeps the first-parent history in one straight primary lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "head", parentHashes: ["merge"] }, + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect( + layout.rows + .filter((row) => ["head", "merge", "main", "base"].includes(row.hash)) + .map((row) => row.lane), + ).toEqual([0, 0, 0, 0]); + }); + + it("reserves the primary lane for the decorated current HEAD ancestry", () => { + const layout = layoutGitHistoryGraph([ + { hash: "other", parentHashes: [] }, + { hash: "head", parentHashes: ["main"], refs: ["HEAD -> feature/current"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect(layout.rows.map((row) => [row.hash, row.lane])).toEqual([ + ["other", 1], + ["head", 0], + ["main", 0], + ["base", 0], + ]); + expect(layout.rows[0]?.edges.some((edge) => edge.fromLane === 0)).toBe(false); + expect(layout.rows[1]?.hasIncoming).toBe(false); + }); + + it("keeps missing page-boundary parents visible and starts unrelated commits in a new lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "newest", parentHashes: ["older-than-page"] }, + { hash: "unrelated", parentHashes: [] }, + ]); + + expect(layout).toEqual({ + laneCount: 2, + rows: [ + { + hash: "newest", + lane: 0, + colorIndex: 0, + hasIncoming: false, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "older-than-page", + isMissingParent: true, + }, + ], + }, + { + hash: "unrelated", + lane: 1, + colorIndex: 1, + hasIncoming: false, + edges: [{ fromLane: 0, toLane: 0, colorIndex: 0, kind: "continuation" }], + }, + ], + }); + }); + + it("is deterministic and does not duplicate an edge for repeated parent hashes", () => { + const commits = [ + { hash: "tip", parentHashes: ["base", "base"] }, + { hash: "base", parentHashes: [] }, + ]; + + expect(layoutGitHistoryGraph(commits)).toEqual(layoutGitHistoryGraph(commits)); + expect(layoutGitHistoryGraph(commits).rows[0]?.edges).toHaveLength(1); + }); + + it("compacts a 1001-commit high-parent snapshot into bounded, explicit graph elisions", () => { + const parentHashes = Array.from({ length: 1_000 }, (_, index) => `parent-${index}`); + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes }, + ...parentHashes.map((hash) => ({ hash, parentHashes: [] })), + ]); + + expect(layout.rows).toHaveLength(1_001); + expect(layout.laneCount).toBeLessThanOrEqual(MAX_GIT_HISTORY_GRAPH_LANES); + expect(layout.rows.every((row) => row.lane < MAX_GIT_HISTORY_GRAPH_LANES)).toBe(true); + expect( + layout.rows.every((row) => row.edges.length <= MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW), + ).toBe(true); + expect(layout.rows.flatMap((row) => row.edges).length).toBeLessThanOrEqual( + layout.rows.length * MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW, + ); + expect( + layout.rows.flatMap((row) => row.edges).filter((edge) => edge.kind === "elided"), + ).toEqual([expect.objectContaining({ fromLane: 0, toLane: MAX_GIT_HISTORY_GRAPH_LANES - 1 })]); + expect( + layout.rows.every((row) => + row.edges + .filter((edge) => edge.kind === "parent" || edge.kind === "elided") + .every((edge) => edge.fromLane === row.lane), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/gitHistoryGraph.ts b/apps/web/src/lib/gitHistoryGraph.ts new file mode 100644 index 000000000000..11dbbddff252 --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.ts @@ -0,0 +1,231 @@ +export interface GitHistoryGraphCommit { + hash: string; + parentHashes: ReadonlyArray; + refs?: ReadonlyArray; +} + +export interface GitHistoryGraphEdge { + fromLane: number; + toLane: number; + colorIndex: number; + kind: "continuation" | "incoming" | "parent" | "elided"; + parentHash?: string; + isMissingParent?: boolean; +} + +export const MAX_GIT_HISTORY_GRAPH_LANES = 12; +export const MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW = MAX_GIT_HISTORY_GRAPH_LANES * 2; + +const MAX_TRACKED_LANES = MAX_GIT_HISTORY_GRAPH_LANES - 1; +const ELISION_LANE = MAX_GIT_HISTORY_GRAPH_LANES - 1; + +export interface GitHistoryGraphRow { + hash: string; + lane: number; + colorIndex: number; + hasIncoming: boolean; + edges: ReadonlyArray; +} + +export interface GitHistoryGraphLayout { + laneCount: number; + rows: ReadonlyArray; +} + +interface Lane { + hash: string; + colorIndex: number; + started: boolean; +} + +type LaneSlot = Lane | null; + +interface GitHistoryGraphOptions { + primaryHash?: string; + primaryHashes?: ReadonlySet; + includeMissingParents?: boolean; +} + +function findLaneIndices(lanes: ReadonlyArray, hash: string): number[] { + return lanes.flatMap((lane, index) => (lane?.hash === hash ? [index] : [])); +} + +function uniqueHashes(hashes: ReadonlyArray): string[] { + return Array.from(new Set(hashes)); +} + +function nextColorIndex(lanes: ReadonlyArray): number { + const usedColors = new Set(lanes.flatMap((lane) => (lane === null ? [] : [lane.colorIndex]))); + let colorIndex = 1; + + while (colorIndex < Number.MAX_SAFE_INTEGER && usedColors.has(colorIndex)) { + colorIndex += 1; + } + + return colorIndex; +} + +function continuationEdges( + beforeLanes: ReadonlyArray, + endingLanes: ReadonlySet, +): GitHistoryGraphEdge[] { + return beforeLanes.flatMap((lane, fromLane) => { + if (endingLanes.has(fromLane) || lane === null || !lane.started) { + return []; + } + + return [ + { + fromLane, + toLane: fromLane, + colorIndex: lane.colorIndex, + kind: "continuation" as const, + }, + ]; + }); +} + +function elidedEdge(fromLane: number, colorIndex: number): GitHistoryGraphEdge { + return { + fromLane, + toLane: ELISION_LANE, + colorIndex, + kind: "elided", + }; +} + +export function layoutGitHistoryGraph( + commits: ReadonlyArray, + options: GitHistoryGraphOptions = {}, +): GitHistoryGraphLayout { + const knownHashes = new Set(commits.map((commit) => commit.hash)); + const includeMissingParents = options.includeMissingParents ?? true; + const currentHeadHash = + options.primaryHash ?? + commits.find((commit) => + commit.refs?.some((ref) => ref === "HEAD" || ref.startsWith("HEAD -> ")), + )?.hash ?? + commits[0]?.hash; + const commitsByHash = new Map(commits.map((commit) => [commit.hash, commit])); + const primaryHashes = new Set(options.primaryHashes); + if (primaryHashes.size === 0) { + let primaryHash = currentHeadHash; + while (primaryHash && !primaryHashes.has(primaryHash)) { + primaryHashes.add(primaryHash); + primaryHash = commitsByHash.get(primaryHash)?.parentHashes[0]; + } + } + const rows: GitHistoryGraphRow[] = []; + let lanes: LaneSlot[] = []; + let laneCount = 0; + + for (const commit of commits) { + const isPrimary = primaryHashes.has(commit.hash); + const matchingLanes = findLaneIndices(lanes, commit.hash); + const activeMatchingLanes = matchingLanes.filter((lane) => lanes[lane]?.started === true); + const preferredNodeLane = isPrimary ? 0 : (activeMatchingLanes[0] ?? matchingLanes[0] ?? -1); + const emptyLane = lanes.findIndex( + (lane, index) => lane === null && index > 0 && index < MAX_TRACKED_LANES, + ); + const nodeLane = + preferredNodeLane !== -1 + ? preferredNodeLane + : emptyLane !== -1 + ? emptyLane + : Math.max(1, lanes.length) < MAX_TRACKED_LANES + ? Math.max(1, lanes.length) + : ELISION_LANE; + const isElidedNode = nodeLane === ELISION_LANE && matchingLanes.length === 0; + const existingNodeLane = lanes[nodeLane]; + const nodeColorIndex = isPrimary + ? (existingNodeLane?.colorIndex ?? 0) + : (existingNodeLane?.colorIndex ?? nextColorIndex(lanes)); + const beforeLanes = [...lanes]; + if (!isElidedNode) { + while (beforeLanes.length <= nodeLane) beforeLanes.push(null); + } + if (!isElidedNode && (beforeLanes[nodeLane] === null || beforeLanes[nodeLane] === undefined)) { + beforeLanes[nodeLane] = { + hash: commit.hash, + colorIndex: nodeColorIndex, + started: false, + }; + } + + const endingLanes = new Set(isElidedNode ? matchingLanes : [...matchingLanes, nodeLane]); + const incomingEdges: GitHistoryGraphEdge[] = activeMatchingLanes + .filter((lane) => lane !== nodeLane) + .map((lane) => ({ + fromLane: lane, + toLane: nodeLane, + colorIndex: beforeLanes[lane]?.colorIndex ?? nodeColorIndex, + kind: "incoming", + })); + const afterLanes = [...beforeLanes]; + for (const lane of endingLanes) afterLanes[lane] = null; + + const parentEdges: GitHistoryGraphEdge[] = []; + const parentHashes = uniqueHashes(commit.parentHashes).filter( + (parentHash) => includeMissingParents || knownHashes.has(parentHash), + ); + for (const [parentIndex, parentHash] of parentHashes.entries()) { + if (isElidedNode || parentEdges.length >= MAX_TRACKED_LANES) { + break; + } + const parentIsPrimary = primaryHashes.has(parentHash); + const preferredParentLane = parentIsPrimary ? 0 : parentIndex === 0 ? nodeLane : -1; + const availableParentLane = + preferredParentLane !== -1 && afterLanes[preferredParentLane] === null + ? preferredParentLane + : afterLanes.findIndex( + (lane, index) => lane === null && index > 0 && index < MAX_TRACKED_LANES, + ); + const parentLane = + availableParentLane === -1 && Math.max(1, afterLanes.length) < MAX_TRACKED_LANES + ? Math.max(1, afterLanes.length) + : availableParentLane; + if (parentLane === -1) { + break; + } + const colorIndex = parentIndex === 0 ? nodeColorIndex : nextColorIndex(afterLanes); + while (afterLanes.length <= parentLane) afterLanes.push(null); + afterLanes[parentLane] = { + hash: parentHash, + colorIndex, + started: true, + }; + parentEdges.push({ + fromLane: nodeLane, + toLane: parentLane, + colorIndex, + kind: "parent", + parentHash, + isMissingParent: !knownHashes.has(parentHash), + }); + } + + const hasElidedParents = parentEdges.length < parentHashes.length; + const edges = [ + ...incomingEdges, + ...parentEdges, + ...continuationEdges(beforeLanes, endingLanes), + ...(hasElidedParents ? [elidedEdge(nodeLane, nodeColorIndex)] : []), + ]; + rows.push({ + hash: commit.hash, + lane: nodeLane, + colorIndex: nodeColorIndex, + hasIncoming: activeMatchingLanes.includes(nodeLane), + edges: edges.slice(0, MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW), + }); + + laneCount = Math.max( + laneCount, + isElidedNode ? MAX_GIT_HISTORY_GRAPH_LANES : beforeLanes.length, + afterLanes.length, + ); + lanes = afterLanes; + } + + return { laneCount, rows }; +} diff --git a/apps/web/src/lib/gitRefTree.test.ts b/apps/web/src/lib/gitRefTree.test.ts new file mode 100644 index 000000000000..91767d29a149 --- /dev/null +++ b/apps/web/src/lib/gitRefTree.test.ts @@ -0,0 +1,57 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildGitRefTree, filterGitRefTree } from "./gitRefTree"; + +function ref(name: string, options?: { current?: boolean; isDefault?: boolean }): VcsHistoryRef { + return { + name, + current: options?.current ?? false, + isDefault: options?.isDefault ?? false, + worktreePath: null, + }; +} + +describe("buildGitRefTree", () => { + it("groups slash-delimited refs and pins current and default branches", () => { + const tree = buildGitRefTree([ + ref("development"), + ref("feat/two"), + ref("merge-wt", { current: true }), + ref("feat/one"), + ref("master", { isDefault: true }), + ]); + + expect(tree.map((node) => [node.kind, node.name])).toEqual([ + ["ref", "merge-wt"], + ["ref", "master"], + ["folder", "feat"], + ["ref", "development"], + ]); + expect(tree[2]).toMatchObject({ + kind: "folder", + path: "feat", + children: [ + { kind: "ref", name: "one" }, + { kind: "ref", name: "two" }, + ], + }); + }); + + it("preserves matching ancestors while filtering by full ref name", () => { + const tree = buildGitRefTree([ref("chore/review/hardening"), ref("feat/map")]); + expect(filterGitRefTree(tree, "hardening")).toMatchObject([ + { + kind: "folder", + name: "chore", + children: [ + { + kind: "folder", + name: "review", + children: [{ kind: "ref", name: "hardening" }], + }, + ], + }, + ]); + }); +}); diff --git a/apps/web/src/lib/gitRefTree.ts b/apps/web/src/lib/gitRefTree.ts new file mode 100644 index 000000000000..39e1d3bf3a94 --- /dev/null +++ b/apps/web/src/lib/gitRefTree.ts @@ -0,0 +1,88 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; + +export type GitRefTreeNode = + | { + readonly kind: "folder"; + readonly name: string; + readonly path: string; + readonly children: ReadonlyArray; + } + | { + readonly kind: "ref"; + readonly name: string; + readonly ref: VcsHistoryRef; + }; + +interface MutableFolder { + readonly folders: Map; + readonly refs: VcsHistoryRef[]; +} + +function createFolder(): MutableFolder { + return { folders: new Map(), refs: [] }; +} + +function compareRefs(left: VcsHistoryRef, right: VcsHistoryRef): number { + if (left.current !== right.current) return left.current ? -1 : 1; + if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1; + return left.name.localeCompare(right.name); +} + +function materialize(folder: MutableFolder, parentPath: string): GitRefTreeNode[] { + const folders = [...folder.folders.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, child]) => { + const path = parentPath.length === 0 ? name : `${parentPath}/${name}`; + return { + kind: "folder" as const, + name, + path, + children: materialize(child, path), + }; + }); + const refs = folder.refs.sort(compareRefs).map((ref) => ({ + kind: "ref" as const, + name: ref.name.split("/").at(-1) ?? ref.name, + ref, + })); + const pinned = refs.filter((node) => node.ref.current || node.ref.isDefault); + const ordinary = refs.filter((node) => !node.ref.current && !node.ref.isDefault); + return [...pinned, ...folders, ...ordinary]; +} + +export function buildGitRefTree(refs: ReadonlyArray): ReadonlyArray { + const root = createFolder(); + for (const ref of refs) { + const segments = ref.name.split("/").filter((segment) => segment.length > 0); + let folder = root; + for (const segment of segments.slice(0, -1)) { + const child = folder.folders.get(segment) ?? createFolder(); + folder.folders.set(segment, child); + folder = child; + } + folder.refs.push(ref); + } + return materialize(root, ""); +} + +export function filterGitRefTree( + nodes: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalized = query.trim().toLocaleLowerCase(); + if (normalized.length === 0) return nodes; + const filtered: GitRefTreeNode[] = []; + for (const node of nodes) { + if (node.kind === "ref") { + if (node.ref.name.toLocaleLowerCase().includes(normalized)) filtered.push(node); + continue; + } + if (node.path.toLocaleLowerCase().includes(normalized)) { + filtered.push(node); + continue; + } + const children = filterGitRefTree(node.children, normalized); + if (children.length > 0) filtered.push({ ...node, children }); + } + return filtered; +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 850afa2dcbca..05880afe8194 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -116,6 +116,22 @@ describe("rightPanelStore", () => { expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); }); + it("treats repository navigation and selection as manual choices", () => { + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + + store.openRepository(refA, "history"); + store.selectRepositoryView(refA, "issues"); + store.selectRepositoryIssue(refA, { + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(false); + expect(store.getUserActionRevision(refA)).toBeGreaterThan(revision); + }); + it("keeps manual choices scoped to their thread and environment", () => { const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId); const store = useRightPanelStore.getState(); @@ -361,6 +377,36 @@ describe("rightPanelStore", () => { }); }); + it("upgrades repository surfaces with normalized per-thread selections", () => { + expect( + migratePersistedRightPanelState({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "git-history", + surfaces: [{ id: "git-history", kind: "git-history", view: "issues" }], + }, + }, + }), + ).toEqual({ + byThreadKey: { + "env-1:thread-A": { + isOpen: true, + activeSurfaceId: "git-history", + surfaces: [ + { + id: "git-history", + kind: "git-history", + view: "issues", + selectedIssue: null, + selectedPullRequest: null, + }, + ], + }, + }, + }); + }); + it("open sets the active panel for a thread", () => { useRightPanelStore.getState().open(refA, "preview"); expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("preview"); @@ -391,6 +437,79 @@ describe("rightPanelStore", () => { }); }); + it("keeps Git History as a singleton surface", () => { + useRightPanelStore.getState().open(refA, "git-history"); + useRightPanelStore.getState().open(refA, "agents"); + useRightPanelStore.getState().open(refA, "git-history"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "git-history", + surfaces: [ + { + id: "git-history", + kind: "git-history", + view: "history", + selectedIssue: null, + selectedPullRequest: null, + }, + { id: "agents", kind: "agents" }, + ], + }); + }); + + it("opens the shared repository pane directly at pull requests", () => { + useRightPanelStore.getState().openRepository(refA, "pull-requests"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "git-history", + surfaces: [ + { + id: "git-history", + kind: "git-history", + view: "pull-requests", + selectedIssue: null, + selectedPullRequest: null, + }, + ], + }); + + useRightPanelStore.getState().selectRepositoryView(refA, "issues"); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "git-history", + kind: "git-history", + view: "issues", + selectedIssue: null, + selectedPullRequest: null, + }); + }); + + it("keeps repository selections isolated per thread and switches to the selected item", () => { + const issue = { projectId: "project-a", repository: "owner/a", number: 12 }; + const pullRequest = { projectId: "project-b", repository: "owner/b", number: 34 }; + + useRightPanelStore.getState().openRepository(refA, "history"); + useRightPanelStore.getState().selectRepositoryIssue(refA, issue); + useRightPanelStore.getState().openRepository(refB, "history"); + useRightPanelStore.getState().selectRepositoryPullRequest(refB, pullRequest); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "git-history", + kind: "git-history", + view: "issues", + selectedIssue: issue, + selectedPullRequest: null, + }); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refB)).toEqual({ + id: "git-history", + kind: "git-history", + view: "pull-requests", + selectedIssue: null, + selectedPullRequest: pullRequest, + }); + }); + it("keeps files as a singleton surface", () => { useRightPanelStore.getState().open(refA, "files"); useRightPanelStore.getState().open(refA, "files"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 40060a1163e9..0cffd89309cc 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,7 @@ import { resolveStorage } from "./lib/storage"; const RIGHT_PANEL_KINDS = [ "diff", + "git-history", "files", "file", "preview", @@ -32,6 +33,14 @@ const RIGHT_PANEL_KINDS = [ ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; +export type RepositoryView = "history" | "issues" | "pull-requests"; +export interface RepositoryItemSelection { + projectId: string; + provider?: string; + repository: string; + number: number; +} + export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } @@ -44,6 +53,13 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } + | { + id: "git-history"; + kind: "git-history"; + view: RepositoryView; + selectedIssue: RepositoryItemSelection | null; + selectedPullRequest: RepositoryItemSelection | null; + } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; @@ -95,7 +111,7 @@ export type RightPanelSurface = */ id: "issues"; kind: "issues"; - selected: { projectId: string; provider?: string; repository: string; number: number } | null; + selected: RepositoryItemSelection | null; } | { id: "agents"; kind: "agents" }; @@ -105,7 +121,9 @@ const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the "issue" surface kind and stops persisting the issues list's shared panel. // v13 adds the "issues" browser surface, which carries the issue it is showing. -const RIGHT_PANEL_STORAGE_VERSION = 13; +// v14 adds the combined repository view. +// v15 scopes issue and pull request selections to that repository surface. +const RIGHT_PANEL_STORAGE_VERSION = 15; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( @@ -170,6 +188,15 @@ interface RightPanelStoreState { }, ) => void; openIssues: (ref: ScopedThreadRef) => void; + /** Opens the shared repository pane at the requested view. */ + openRepository: (ref: ScopedThreadRef, view: RepositoryView) => void; + /** Changes the active view without creating another right-panel surface. */ + selectRepositoryView: (ref: ScopedThreadRef, view: RepositoryView) => void; + selectRepositoryIssue: (ref: ScopedThreadRef, target: RepositoryItemSelection | null) => void; + selectRepositoryPullRequest: ( + ref: ScopedThreadRef, + target: RepositoryItemSelection | null, + ) => void; /** What the issue browser is showing: an issue, or null for the list it was picked from. */ selectIssueInPanel: ( ref: ScopedThreadRef, @@ -216,6 +243,14 @@ const singletonSurface = ( switch (kind) { case "diff": return { id: "diff", kind }; + case "git-history": + return { + id: "git-history", + kind, + view: "history", + selectedIssue: null, + selectedPullRequest: null, + }; case "files": return { id: "files", kind }; case "agents": @@ -330,8 +365,8 @@ function issueSurface(target: { export type IssuesSurface = Extract; -/** A persisted selection is only usable if it still names an issue, so a broken one reads as none. */ -function normalizeIssueSelection(value: unknown): IssuesSurface["selected"] { +/** A persisted selection is only usable if it still names an item, so a broken one reads as none. */ +function normalizeRepositoryItemSelection(value: unknown): RepositoryItemSelection | null { if (!value || typeof value !== "object") return null; const { projectId, provider, repository, number } = value as Record; if ( @@ -498,7 +533,24 @@ export function migratePersistedRightPanelState(persistedState: unknown): { { id: "issues", kind: "issues", - selected: normalizeIssueSelection(surface.selected), + selected: normalizeRepositoryItemSelection(surface.selected), + }, + ]; + } + if (surface.kind === "git-history") { + const view = + surface.view === "issues" || surface.view === "pull-requests" + ? surface.view + : "history"; + return [ + { + id: "git-history", + kind: "git-history", + view, + selectedIssue: normalizeRepositoryItemSelection(surface.selectedIssue), + selectedPullRequest: normalizeRepositoryItemSelection( + surface.selectedPullRequest, + ), }, ]; } @@ -639,6 +691,62 @@ export const useRightPanelStore = create()( upsertSurface(current, { id: "issues", kind: "issues", selected: null }), ), ), + openRepository: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { + const existing = current.surfaces.find( + (surface): surface is Extract => + surface.kind === "git-history", + ); + const surface: RightPanelSurface = existing + ? { ...existing, view } + : { + id: "git-history", + kind: "git-history", + view, + selectedIssue: null, + selectedPullRequest: null, + }; + return { + isOpen: true, + activeSurfaceId: surface.id, + surfaces: existing + ? current.surfaces.map((entry) => (entry.id === surface.id ? surface : entry)) + : [...current.surfaces, surface], + }; + }), + ), + selectRepositoryView: (ref, view) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "git-history" ? { ...surface, view } : surface, + ), + })), + ), + selectRepositoryIssue: (ref, target) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "git-history" + ? { ...surface, view: "issues", selectedIssue: target } + : surface, + ), + })), + ), + selectRepositoryPullRequest: (ref, target) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "git-history" + ? { ...surface, view: "pull-requests", selectedPullRequest: target } + : surface, + ), + })), + ), selectIssueInPanel: (ref, target) => set((state) => userAction(state, scopedThreadKey(ref), (current) => ({ diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index f085075fdd7c..443e99b84cdc 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -11,6 +11,7 @@ import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -84,3 +85,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/web/src/state/paginatedBranches.ts b/apps/web/src/state/paginatedBranches.ts index 41809b2de85e..7a91b7be16fa 100644 --- a/apps/web/src/state/paginatedBranches.ts +++ b/apps/web/src/state/paginatedBranches.ts @@ -1,11 +1,10 @@ -import type { VcsListRefsResult } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/unstable/reactivity"; const DEFAULT_NEXT_PAGE_DISTANCE_PX = 96; -export function isPaginatedBranchesNextPagePending( - results: ReadonlyArray>, +export function isPaginatedBranchesNextPagePending( + results: ReadonlyArray>, ): boolean { const lastResult = results.at(-1); return ( diff --git a/apps/web/src/state/queries.test.tsx b/apps/web/src/state/queries.test.tsx new file mode 100644 index 000000000000..d7d4a7880f3f --- /dev/null +++ b/apps/web/src/state/queries.test.tsx @@ -0,0 +1,346 @@ +import { + VcsSnapshotExpiredError, + type EnvironmentId, + type VcsListRefsResult, + type VcsListHistoryRefsResult, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { usePaginatedBranches, usePaginatedHistoryRefs } from "./queries"; + +type PageResult = + | { + readonly _tag: "Success"; + readonly waiting: false; + readonly value: VcsListHistoryRefsResult; + } + | { + readonly _tag: "Failure"; + readonly cause: Error; + readonly waiting: false; + }; + +type PageAtom = { + readonly input: { + readonly cursor?: string; + readonly refresh?: true; + }; + readonly cacheKey?: number; + readonly result: PageResult; +}; + +const refsState = vi.hoisted(() => ({ + atoms: [] as PageAtom[], + branchAtoms: [] as Array<{ readonly input: { readonly cursor?: number } }>, + branchResults: new Map(), + connection: { phase: "connected", generation: 1 }, + refresh: vi.fn(), + results: new Map(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => effect(), + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, +})); + +vi.mock("effect/Cause", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, squash: (cause: Error) => cause }; +}); + +vi.mock("effect/unstable/reactivity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AsyncResult: { + ...actual.AsyncResult, + value: (result: PageResult) => + result._tag === "Success" ? { _tag: "Some", value: result.value } : { _tag: "None" }, + }, + Atom: { + ...actual.Atom, + make: (create: unknown) => { + if (typeof create !== "function") return actual.Atom.make(create); + const value = create((atom) => atom.result); + return { + pipe: () => ({ value }), + value, + }; + }, + withLabel: () => (atom: unknown) => atom, + }, + }; +}); + +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: refsState.refresh }, +})); + +vi.mock("@t3tools/client-runtime/state/thread-search", () => ({ + createThreadSearchResultsAtomFamily: () => () => ({}), + makeThreadSearchKey: () => "", +})); + +vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); +vi.mock("./projects", () => ({ projectContentSearch: {}, projectEnvironment: {} })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: { readonly state?: { readonly data: unknown } } | null) => + atom?.state ?? { data: null }, +})); +vi.mock("./threads", () => ({ useEnvironmentThread: () => ({}) })); + +vi.mock("./vcs", () => ({ + vcsEnvironment: { + listHistoryRefs: ({ + cacheKey, + input, + }: { + readonly cacheKey?: number; + readonly input: PageAtom["input"]; + }) => { + const result = refsState.results.get(`${cacheKey ?? 0}:${input.cursor ?? "first"}`); + if (result === undefined) + throw new Error(`Missing result for ${cacheKey ?? 0}:${input.cursor ?? "first"}`); + const atom = { cacheKey, input, result }; + refsState.atoms.push(atom); + return atom; + }, + listRefs: ({ input }: { readonly input: { readonly cursor?: number } }) => { + const result = refsState.branchResults.get(`${input.cursor ?? "first"}`); + if (result === undefined) throw new Error(`Missing result for ${input.cursor ?? "first"}`); + const atom = { input, result: { _tag: "Success" as const, waiting: false, value: result } }; + refsState.branchAtoms.push(atom); + return atom; + }, + }, +})); + +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { stateAtom: () => ({ state: { data: refsState.connection } }) }, +})); + +const target = { + environmentId: "environment" as EnvironmentId, + cwd: "C:/workspace", + query: "", +}; + +function page(nextCursor: string | null): PageResult { + return { + _tag: "Success", + waiting: false, + value: { + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor, + currentRef: null, + isComplete: true, + }, + }; +} + +function render(revision = 0, queryTarget = target) { + hooks.beginRender(); + return usePaginatedHistoryRefs(queryTarget, { revision }); +} + +function renderBranches() { + hooks.beginRender(); + return usePaginatedBranches(target); +} + +describe("usePaginatedHistoryRefs", () => { + beforeEach(() => { + hooks.reset(); + refsState.atoms = []; + refsState.branchAtoms = []; + refsState.branchResults.clear(); + refsState.connection = { phase: "connected", generation: 1 }; + refsState.refresh.mockReset(); + refsState.results.clear(); + }); + + it("uses refresh only for the first page of a refreshed ref snapshot", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("1:first", page("cursor-8")); + refsState.results.set("1:cursor-8", page(null)); + + const initial = render(); + initial.refresh(); + const refreshed = render(); + refreshed.loadNext(); + render(); + render(0, { ...target, query: "later" }); + render(); + + expect(refsState.atoms.map((atom) => ({ ...atom.input, cacheKey: atom.cacheKey }))).toEqual([ + { cacheKey: 0, cwd: "C:/workspace", limit: 100, namespace: "local" }, + { cacheKey: 1, cwd: "C:/workspace", limit: 100, namespace: "local", refresh: true }, + { cacheKey: 1, cwd: "C:/workspace", limit: 100, namespace: "local", refresh: true }, + { + cacheKey: 1, + cwd: "C:/workspace", + cursor: "cursor-8", + limit: 100, + namespace: "local", + }, + { + cacheKey: 1, + cwd: "C:/workspace", + limit: 100, + namespace: "local", + query: "later", + }, + { cacheKey: 1, cwd: "C:/workspace", limit: 100, namespace: "local" }, + { + cacheKey: 1, + cwd: "C:/workspace", + cursor: "cursor-8", + limit: 100, + namespace: "local", + }, + ]); + expect( + new Set( + refsState.atoms + .filter((atom) => atom.input.refresh === true) + .map( + (atom) => + `${atom.input.cwd}:${atom.input.query ?? ""}:${atom.input.cursor ?? ""}:${atom.cacheKey}`, + ), + ), + ).toEqual(new Set(["C:/workspace:::1"])); + }); + + it("discards history cursor pages when the root reconnects", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", page(null)); + + const initial = render(); + initial.loadNext(); + render(); + refsState.connection = { phase: "connected", generation: 2 }; + const reconnected = render(); + + expect(refsState.atoms.map((atom) => atom.input.cursor)).toEqual([ + undefined, + undefined, + "cursor-4", + undefined, + ]); + expect(reconnected.data?.nextCursor).toBe("cursor-4"); + }); + + it("restarts from the first history refs page once when its repository revision changes", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", page(null)); + refsState.results.set("1:first", page(null)); + + const initial = render(); + initial.loadNext(); + render(); + render(1); + + expect(refsState.atoms.map((atom) => ({ ...atom.input, cacheKey: atom.cacheKey }))).toEqual([ + { cacheKey: 0, cwd: "C:/workspace", limit: 100, namespace: "local" }, + { cacheKey: 0, cwd: "C:/workspace", limit: 100, namespace: "local" }, + { + cacheKey: 0, + cwd: "C:/workspace", + cursor: "cursor-4", + limit: 100, + namespace: "local", + }, + { cacheKey: 1, cwd: "C:/workspace", limit: 100, namespace: "local" }, + ]); + }); + + it("discards ref cursor pages when the root reconnects", () => { + refsState.branchResults.set("first", { + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: 4, + totalCount: 2, + }); + refsState.branchResults.set("4", { + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 2, + }); + + const initial = renderBranches(); + initial.loadNext(); + renderBranches(); + refsState.connection = { phase: "connected", generation: 2 }; + renderBranches(); + + expect(refsState.branchAtoms.map((atom) => atom.input.cursor)).toEqual([ + undefined, + undefined, + 4, + undefined, + ]); + }); + + it("retries the failed appended page without duplicating its cursor", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", { + _tag: "Failure", + cause: new Error("temporary failure"), + waiting: false, + }); + + const initial = render(); + initial.loadNext(); + const withFailedPage = render(); + withFailedPage.loadNext(); + withFailedPage.retry(); + + expect(refsState.atoms.map((atom) => atom.input.cursor)).toEqual([ + undefined, + undefined, + "cursor-4", + ]); + expect(refsState.refresh).toHaveBeenCalledTimes(1); + expect(refsState.refresh).toHaveBeenCalledWith(refsState.atoms.at(-1)); + }); + + it("recovers an expired snapshot once per generation, including after a later refresh", () => { + const expired = (cursor: string) => + new VcsSnapshotExpiredError({ operation: "GitVcsDriver.listHistoryRefs", cursor }); + refsState.results.set("0:first", { _tag: "Failure", cause: expired("first"), waiting: false }); + refsState.results.set("1:first", page(null)); + + render(); + render(); + render(); + + expect(refsState.atoms.map((atom) => atom.cacheKey)).toEqual([0, 1]); + + const recovered = render(); + recovered.refresh(); + refsState.results.set("2:first", { _tag: "Failure", cause: expired("second"), waiting: false }); + refsState.results.set("3:first", page(null)); + render(); + render(); + + expect(refsState.atoms.map((atom) => atom.cacheKey)).toEqual([0, 1, 2, 3]); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 1792c5e9e599..413e583dd224 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -9,20 +9,25 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; -import type { +import { + VcsSnapshotExpiredError, EnvironmentId, OrchestrationThread, ProjectContentMatch, ProjectEntryKind, + VcsHistoryRef, + VcsListHistoryRefsResult, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentCatalog } from "../connection/catalog"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; @@ -36,9 +41,11 @@ const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_HISTORY_REFS: ReadonlyArray = []; const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const isVcsSnapshotExpiredError = Schema.is(VcsSnapshotExpiredError); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, @@ -103,9 +110,13 @@ export function useThreadSearch( export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; + const connection = useEnvironmentQuery( + target.environmentId === null ? null : environmentCatalog.stateAtom(target.environmentId), + ).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; const targetKey = target.environmentId !== null && target.cwd !== null - ? JSON.stringify([target.environmentId, target.cwd, query]) + ? JSON.stringify([target.environmentId, target.cwd, query, connectionGeneration]) : null; const [pagination, setPagination] = useState<{ readonly targetKey: string | null; @@ -204,6 +215,186 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } +export function usePaginatedHistoryRefs( + target: VcsRefTarget, + options?: { + readonly limit?: number; + readonly namespace?: "local" | "remote" | "tag"; + readonly revision?: number; + }, +) { + const query = target.query?.trim() ?? ""; + const limit = options?.limit ?? VCS_REF_LIST_LIMIT; + const namespace = options?.namespace ?? "local"; + const revision = options?.revision ?? 0; + const connection = useEnvironmentQuery( + target.environmentId === null ? null : environmentCatalog.stateAtom(target.environmentId), + ).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; + const [queryGeneration, setQueryGeneration] = useState(0); + const [refreshGeneration, setRefreshGeneration] = useState(0); + const refSearchKey = JSON.stringify([ + target.environmentId, + target.cwd, + query, + limit, + namespace, + revision, + connectionGeneration, + ]); + const [refreshSearchKey, setRefreshSearchKey] = useState(null); + const isRefreshRequest = refreshSearchKey === refSearchKey; + const targetKey = + target.environmentId !== null && target.cwd !== null + ? JSON.stringify([ + target.environmentId, + target.cwd, + query, + limit, + namespace, + revision, + refreshGeneration, + connectionGeneration, + ]) + : null; + const [pagination, setPagination] = useState<{ + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + }>({ + targetKey, + cursors: INITIAL_BRANCH_CURSORS, + }); + const cursors = pagination.targetKey === targetKey ? pagination.cursors : INITIAL_BRANCH_CURSORS; + const pageAtoms = useMemo( + () => + target.environmentId !== null && target.cwd !== null + ? cursors.map((cursor) => + vcsEnvironment.listHistoryRefs({ + environmentId: target.environmentId!, + cacheKey: queryGeneration + revision, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit, + namespace, + ...(cursor === undefined && isRefreshRequest ? { refresh: true } : {}), + }, + }), + ) + : [], + [ + cursors, + limit, + query, + queryGeneration, + namespace, + refreshGeneration, + refreshSearchKey, + refSearchKey, + isRefreshRequest, + revision, + target.cwd, + target.environmentId, + ], + ); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`web:vcs-ref-pages:${targetKey ?? "empty"}`), + ), + [pageAtoms, targetKey], + ); + const results = useAtomValue(pagesAtom); + const values = useMemo( + () => + results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }), + [results], + ); + const data = useMemo(() => { + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + if (first === null || last === null) return null; + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) refs.set(ref.name, ref); + } + return { + refs: [...refs.values()], + currentRef: first.currentRef, + isRepo: first.isRepo, + hasPrimaryRemote: first.hasPrimaryRemote, + nextCursor: last.nextCursor, + isComplete: last.isComplete, + }; + }, [values]); + const failed = results.find((result) => result._tag === "Failure"); + const expiredPage = + failed?._tag === "Failure" && isVcsSnapshotExpiredError(Cause.squash(failed.cause)); + const effectiveQueryGeneration = queryGeneration + revision; + const recoveredSnapshotGeneration = useRef(null); + useEffect(() => { + if (!expiredPage || recoveredSnapshotGeneration.current === effectiveQueryGeneration) return; + recoveredSnapshotGeneration.current = effectiveQueryGeneration; + setQueryGeneration((generation) => generation + 1); + setRefreshGeneration((generation) => generation + 1); + setRefreshSearchKey(refSearchKey); + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + }, [effectiveQueryGeneration, expiredPage, refSearchKey, targetKey]); + useEffect(() => { + if (refreshSearchKey !== null && !isRefreshRequest) setRefreshSearchKey(null); + }, [isRefreshRequest, refreshSearchKey]); + const isFetchingNextPage = isPaginatedBranchesNextPagePending(results); + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + const refresh = useCallback(() => { + setQueryGeneration((generation) => generation + 1); + setRefreshGeneration((generation) => generation + 1); + setRefreshSearchKey(refSearchKey); + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + }, [refSearchKey, targetKey]); + const retry = useCallback(() => { + const failedIndex = results.findIndex((result) => result._tag === "Failure"); + const failedPageAtom = failedIndex === -1 ? undefined : pageAtoms[failedIndex]; + if (failedPageAtom !== undefined) { + appAtomRegistry.refresh(failedPageAtom); + } + }, [pageAtoms, results]); + const loadNext = useCallback(() => { + if (targetKey === null || data?.nextCursor === null || data?.nextCursor === undefined) { + return; + } + setPagination((current) => { + const currentCursors = + current.targetKey === targetKey ? current.cursors : INITIAL_BRANCH_CURSORS; + return currentCursors.includes(data.nextCursor!) + ? { targetKey, cursors: currentCursors } + : { targetKey, cursors: [...currentCursors, data.nextCursor!] }; + }); + }, [data?.nextCursor, targetKey]); + + return { + data, + refs: data?.refs ?? EMPTY_HISTORY_REFS, + error, + isPending: results.some((result) => result.waiting), + isFetchingNextPage, + refresh, + retry, + loadNext, + }; +} + type ProjectPathSearchTarget = ComposerPathSearchTarget & { readonly kind?: ProjectEntryKind | undefined; readonly imageOnly?: boolean | undefined; diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 9bc16b01fe32..514bc881274f 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -10,6 +10,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -30,6 +31,7 @@ export function useEnvironmentQuery( return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, refresh, }; diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index dc8c251149f6..29c952e2a76b 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -5,5 +5,6 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; -export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsEnvironment: ReturnType = + createVcsEnvironmentAtoms(connectionAtomRuntime); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts index 3a9bf9484ea1..aa7cc592e8f3 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -51,9 +51,24 @@ function createReactHookHarness() { nextIndex(); return callback; }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); + useMemo(factory: () => T, dependencies?: ReadonlyArray): T { + const index = nextIndex(); + const previous = slots[index] as + | { readonly dependencies: ReadonlyArray | undefined; readonly value: T } + | undefined; + if ( + previous !== undefined && + dependencies !== undefined && + previous.dependencies !== undefined && + previous.dependencies.length === dependencies.length && + previous.dependencies.every((value, dependencyIndex) => + Object.is(value, dependencies[dependencyIndex]), + ) + ) + return previous.value; + const value = factory(); + slots[index] = { dependencies, value }; + return value; }, useMemoCache(size: number): unknown[] { const index = nextIndex(); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5eb89bd02680..06e287481530 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -119,3 +119,16 @@ reopening a declined pull request. new one - Everything an issue carries is handed over as untrusted data, so a body written by a stranger on a public tracker cannot instruct the agent + +### Browse Repository History + +Open the right panel menu and choose **Repository**, then select **History** to explore the repository without leaving your thread. The history view shows a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. + +Repository History is currently available in web and desktop project views. Mobile keeps its existing Git status and actions, but does not yet include this dense history workspace. + +- Search by commit subject, author, ref, or hash. Use the clear control or `Escape` to reset a search. +- Select a commit to view its metadata and changed files, then open the same diff view used elsewhere in T3 Code. History diffs use Git's normal patch output, so they match the familiar command-line review view. +- Copy a full commit hash from a row. When a GitHub repository is connected, issue references in commit subjects open the matching issue. +- Branch rows show ahead and behind counts when an upstream is configured. + +For large repositories, history is loaded in pages and the commit list is virtualized. The initial browsing window is intentionally bounded so the right panel remains responsive; select a branch or search to narrow the result. diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index d04f313d26e5..b69a0fe42347 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -264,6 +264,12 @@ describe("environmentRpcKey", () => { expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); + expect(environmentRpcKey({ ...originalTarget, cacheKey: 1 })).not.toBe( + environmentRpcKey(originalTarget), + ); + expect(environmentRpcKey({ ...originalTarget, cacheKey: 1 })).not.toBe( + environmentRpcKey({ ...originalTarget, cacheKey: 2 }), + ); expect( environmentRpcKey({ environmentId: EnvironmentId.make("environment-2"), diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 46cb9fa0f115..9191c375feef 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -51,6 +51,7 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; + readonly revalidateOnReconnect?: (input: Input) => boolean; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; readonly input: Input; @@ -428,8 +429,9 @@ export async function settlePromise( export function environmentRpcKey(target: { readonly environmentId: EnvironmentIdType; readonly input: Input; + readonly cacheKey?: string | number; }): string { - return JSON.stringify([target.environmentId, target.input]); + return JSON.stringify([target.environmentId, target.input, target.cacheKey]); } function parseEnvironmentRpcKey(key: string): { @@ -491,6 +493,7 @@ export function createEnvironmentQueryAtomFamily( ): (target: { readonly environmentId: EnvironmentIdType; readonly input: Input; + readonly cacheKey?: string | number; }) => Atom.Atom> { const connectionAtom = Atom.family((environmentId: EnvironmentIdType) => runtime.atom( @@ -517,6 +520,29 @@ export function createEnvironmentQueryAtomFamily( A, E | ConnectionAttemptError | EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError >((get) => { + if (!(options.revalidateOnReconnect?.(target.input) ?? true)) { + return runInEnvironment( + target.environmentId, + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => + Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.filter((state) => state.phase === "connected"), + Stream.take(1), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.never, + onSome: () => options.execute(target.input), + }), + ), + ), + ), + ), + ); + } const connection = Option.getOrNull( AsyncResult.value(get(connectionAtom(target.environmentId))), ); @@ -610,6 +636,7 @@ export function createEnvironmentRpcQueryAtomFamily) => boolean; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; readonly input: EnvironmentRpcInput; @@ -623,6 +650,9 @@ export function createEnvironmentRpcQueryAtomFamily) => request(options.tag, input), }); diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index d7a4692fc317..7403f159f4a3 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -33,9 +33,11 @@ import { createVcsEnvironmentAtoms, makeCachedVcsRefsChanges, } from "./vcs.ts"; +import { executeAtomQuery } from "./runtime.ts"; import { invalidateCachedVcsRefs, invalidateVcsRefs, + vcsHistoryRevisionAtom, vcsRefsCacheStateAtom, } from "./vcsRefInvalidation.ts"; @@ -115,13 +117,90 @@ function cacheWithRefs( } describe("cached VCS refs", () => { - it("invalidates all ref streams in the mutated environment", () => { + it.effect("waits for the initial connection before requesting a cursor history page", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const connectionState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const sessionState = yield* SubscriptionRef.make(Option.none()); + const client = { + [WS_METHODS.vcsListHistoryRefs]: () => + Ref.update(calls, (count) => count + 1).pipe( + Effect.as({ + refs: [], + currentRef: null, + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: connectionState, + session: sessionState, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const result = yield* Effect.forkChild( + Effect.promise(() => + executeAtomQuery( + registry, + atoms.listHistoryRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/repo", cursor: "next", limit: 100 }, + }), + ), + ), + ); + + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(0); + + yield* SubscriptionRef.set(sessionState, Option.some(session(client))); + yield* SubscriptionRef.set(connectionState, CONNECTED_CONNECTION_STATE); + + expect(AsyncResult.isSuccess(yield* Fiber.join(result))).toBe(true); + expect(yield* Ref.get(calls)).toBe(1); + }), + ), + ); + + it("invalidates ref streams for an environment but history only for the mutated repository", () => { const registry = AtomRegistry.make(); const environment = { environmentId: TARGET.environmentId, + cwd: "/repo-a", + }; + const siblingRepository = { + environmentId: TARGET.environmentId, + cwd: "/repo-b", }; const otherEnvironment = { environmentId: EnvironmentId.make("environment-2"), + cwd: "/repo-a", }; expect(registry.get(vcsRefsCacheStateAtom(environment))).toEqual({ @@ -132,6 +211,9 @@ describe("cached VCS refs", () => { revision: 0, persistedCacheReadable: true, }); + expect(registry.get(vcsHistoryRevisionAtom(environment))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(siblingRepository))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(otherEnvironment))).toBe(0); invalidateVcsRefs(registry, environment); @@ -143,6 +225,9 @@ describe("cached VCS refs", () => { revision: 0, persistedCacheReadable: true, }); + expect(registry.get(vcsHistoryRevisionAtom(environment))).toBe(1); + expect(registry.get(vcsHistoryRevisionAtom(siblingRepository))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(otherEnvironment))).toBe(0); registry.dispose(); }); @@ -574,6 +659,66 @@ describe("cached VCS refs", () => { ), ); + it.effect("refreshes only the root ref page when reconnecting after cursor pages loaded", () => + Effect.scoped( + Effect.gen(function* () { + const requests = yield* Ref.make>([]); + const connectionState = yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE); + const client = { + [WS_METHODS.vcsListRefs]: (input: VcsListRefsInput) => + Ref.update(requests, (current) => [...current, input]).pipe(Effect.as(LIVE_REFS)), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: connectionState, + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const cache = cacheWithRefs(Option.none()); + const inputs: ReadonlyArray = [ + { cwd: "/repo", limit: 100 }, + { cwd: "/repo", limit: 100, cursor: 100 }, + { cwd: "/repo", limit: 100, cursor: 200 }, + ]; + const fibers = yield* Effect.forEach(inputs, (input) => + Stream.unwrap( + makeCachedVcsRefsChanges(input).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ), + ).pipe(Stream.runDrain, Effect.forkChild), + ); + + for ( + let attempt = 0; + attempt < 100 && (yield* Ref.get(requests)).length < 3; + attempt += 1 + ) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(requests)).toEqual(inputs); + + yield* SubscriptionRef.set(connectionState, { + ...CONNECTED_CONNECTION_STATE, + generation: 2, + }); + for ( + let attempt = 0; + attempt < 100 && (yield* Ref.get(requests)).length < 4; + attempt += 1 + ) { + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(requests)).toEqual([...inputs, inputs[0]!]); + yield* Effect.forEach(fibers, Fiber.interrupt); + }), + ), + ); + it.effect("does not poll refs while the connection remains stable", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 6c93e6204dc8..f04797f0010e 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -15,7 +15,11 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, + createEnvironmentSubscriptionAtomFamily, +} from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; @@ -25,6 +29,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { invalidateCachedVcsRefs, + vcsHistoryRevisionAtom, vcsRefsCacheStateAtom, withVcsRefsPersistenceLock, } from "./vcsRefInvalidation.ts"; @@ -178,12 +183,23 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange }), ), ); - const refreshedRefs = Stream.concat( + const connectionGenerations = Stream.concat( Stream.fromEffect(SubscriptionRef.get(supervisor.state)), SubscriptionRef.changes(supervisor.state), ).pipe( Stream.map((connection) => (connection.phase === "connected" ? connection.generation : null)), Stream.changes, + ); + const refreshedRefs = ( + input.cursor === undefined + ? connectionGenerations + : connectionGenerations.pipe( + Stream.filterMap((generation) => + generation === null ? Result.failVoid : Result.succeed(generation), + ), + Stream.take(1), + ) + ).pipe( Stream.switchMap((generation) => generation === null ? Stream.empty @@ -268,13 +284,41 @@ export function createVcsEnvironmentAtoms( const invalidateRefs = ( target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, registry: AtomRegistry.AtomRegistry, + invalidateHistory = true, ) => - invalidateCachedVcsRefs(registry, { - environmentId: target.environmentId, - cwd: target.input.cwd, - }); + invalidateCachedVcsRefs( + registry, + { + environmentId: target.environmentId, + cwd: target.input.cwd, + }, + invalidateHistory, + ); return { + historyRevisionAtom: vcsHistoryRevisionAtom, + listHistoryRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-history-refs", + tag: WS_METHODS.vcsListHistoryRefs, + revalidateOnReconnect: (input) => input.cursor === undefined, + }), + getHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-history", + tag: WS_METHODS.vcsGetHistory, + revalidateOnReconnect: (input) => input.cursor === undefined, + }), + getCommitDetails: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-commit-details", + tag: WS_METHODS.vcsGetCommitDetails, + }), + listCommitFiles: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-commit-files", + tag: WS_METHODS.vcsListCommitFiles, + }), + getCommitDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-commit-diff", + tag: WS_METHODS.vcsGetCommitDiff, + }), listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", @@ -302,7 +346,7 @@ export function createVcsEnvironmentAtoms( tag: WS_METHODS.vcsRefreshStatus, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, - onSettled: invalidateRefs, + onSettled: (target, registry) => invalidateRefs(target, registry, false), }), createWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-worktree", diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 905972975606..8bae4cc0acc0 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -41,7 +41,7 @@ import { VcsActionTargetKeyParseError, VcsActionUnavailableError, } from "./vcsAction.ts"; -import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; +import { vcsHistoryRevisionAtom, vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; const actionId = "action-123"; const action = "commit_push" as const; @@ -650,8 +650,10 @@ describe("vcsActionState", () => { Effect.sync(() => registry.dispose()), ); const state = vcsRefsCacheStateAtom({ environmentId }); + const historyRevision = vcsHistoryRevisionAtom({ environmentId, cwd }); expect(registry.get(state).revision).toBe(0); + expect(registry.get(historyRevision)).toBe(0); const successfulResult = yield* Effect.promise(() => manager.runStackedAction(targetKey).run(registry, { actionId: successfulActionId, @@ -661,6 +663,7 @@ describe("vcsActionState", () => { expect(AsyncResult.isSuccess(successfulResult)).toBe(true); expect(registry.get(state).revision).toBe(1); + expect(registry.get(historyRevision)).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); const failedResult = yield* Effect.promise(() => @@ -672,6 +675,7 @@ describe("vcsActionState", () => { expect(AsyncResult.isFailure(failedResult)).toBe(true); expect(registry.get(state).revision).toBe(2); + expect(registry.get(historyRevision)).toBe(2); expect(removed).toEqual([`${environmentId}:*`, `${environmentId}:*`]); }), ), diff --git a/packages/client-runtime/src/state/vcsRefInvalidation.ts b/packages/client-runtime/src/state/vcsRefInvalidation.ts index ff9de7bd1cc6..d29f27180547 100644 --- a/packages/client-runtime/src/state/vcsRefInvalidation.ts +++ b/packages/client-runtime/src/state/vcsRefInvalidation.ts @@ -25,21 +25,33 @@ const stateByEnvironment = Atom.family((environmentId: EnvironmentId) => persistedCacheReadable: true, }).pipe(Atom.keepAlive, Atom.withLabel(`environment-data:vcs:list-refs-state:${environmentId}`)), ); +const historyRevisionByRepository = Atom.family((key: string) => + Atom.make(0).pipe(Atom.withLabel(`environment-data:vcs:history-revision:${key}`)), +); const persistenceLock = PartitionedSemaphore.makeUnsafe({ permits: 1 }); export function vcsRefsCacheStateAtom(target: VcsRefsInvalidationTarget) { return stateByEnvironment(target.environmentId); } +export function vcsHistoryRevisionAtom( + target: CachedVcsRefsInvalidationTarget, +): Atom.Writable { + return historyRevisionByRepository(JSON.stringify([target.environmentId, target.cwd])); +} + export function invalidateVcsRefs( registry: AtomRegistry.AtomRegistry, - target: VcsRefsInvalidationTarget, + target: CachedVcsRefsInvalidationTarget, persistedCacheReadable?: boolean, + invalidateHistory = true, ): void { registry.update(vcsRefsCacheStateAtom(target), (state) => ({ revision: state.revision + 1, persistedCacheReadable: persistedCacheReadable ?? state.persistedCacheReadable, })); + if (invalidateHistory) + registry.update(vcsHistoryRevisionAtom(target), (revision) => revision + 1); } export function withVcsRefsPersistenceLock( @@ -59,6 +71,7 @@ export function withVcsRefsPersistenceLock( export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* ( registry: AtomRegistry.AtomRegistry, target: CachedVcsRefsInvalidationTarget, + invalidateHistory = true, ) { const cache = yield* EnvironmentCacheStore; yield* withVcsRefsPersistenceLock( @@ -77,7 +90,7 @@ export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached" ), ), ); - invalidateVcsRefs(registry, target, persistedCacheReadable); + invalidateVcsRefs(registry, target, persistedCacheReadable, invalidateHistory); }), ); }); diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 55633835bba1..4863f81ada65 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -27,6 +27,19 @@ describe("ExecutionEnvironmentDescriptor", () => { ).toBe(true); }); + it("treats a missing Git History capability as unsupported under version skew", () => { + expect(decodeDescriptor(descriptor).capabilities.gitHistory).toBeUndefined(); + }); + + it("preserves an advertised Git History capability", () => { + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, gitHistory: true }, + }).capabilities.gitHistory, + ).toBe(true); + }); + it("treats a missing attachment upload capability as unsupported", () => { expect(decodeDescriptor(descriptor).capabilities.attachmentUploads).toBeUndefined(); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 8a5829187a03..1be0343c26e1 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -94,6 +94,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server exposes the issue list, detail, activity, and mutation APIs. Absent on servers from before the issue workspace shipped, so clients must not probe them. */ issues: Schema.optionalKey(Schema.Boolean), + /** Server exposes Git History browsing RPCs. Absent on older servers, so clients must not + render the surface or probe the RPCs. */ + gitHistory: Schema.optionalKey(Schema.Boolean), /** Server understands thread.settle / thread.unsettle commands. Absent on pre-settlement servers, so clients treat missing as unsupported and never send the commands under version skew. */ diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 5dda491b009b..6c9740d0f6e9 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -8,6 +8,17 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + VcsGetHistoryResult, + VcsGetHistoryInput, + VcsGetCommitDiffInput, + VcsGetCommitDetailsInput, + VcsListCommitFilesInput, + VcsListCommitFilesResult, + VcsStatusResult, + VcsListHistoryRefsInput, + VcsListHistoryRefsResult, + VcsListRefsInput, + VcsListRefsResult, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -20,6 +31,188 @@ const decodePreparePullRequestThreadResult = Schema.decodeUnknownSync( const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); +const decodeListRefsInput = Schema.decodeUnknownSync(VcsListRefsInput); +const decodeListRefsResult = Schema.decodeUnknownSync(VcsListRefsResult); +const decodeListHistoryRefsInput = Schema.decodeUnknownSync(VcsListHistoryRefsInput); +const decodeCommitDetailsInput = Schema.decodeUnknownSync(VcsGetCommitDetailsInput); +const decodeCommitDiffInput = Schema.decodeUnknownSync(VcsGetCommitDiffInput); +const decodeListCommitFilesResult = Schema.decodeUnknownSync(VcsListCommitFilesResult); +const decodeListCommitFilesInput = Schema.decodeUnknownSync(VcsListCommitFilesInput); +const decodeStatusResult = Schema.decodeUnknownSync(VcsStatusResult); +const decodeGetHistoryResult = Schema.decodeUnknownSync(VcsGetHistoryResult); +const decodeGetHistoryInput = Schema.decodeUnknownSync(VcsGetHistoryInput); +const decodeListHistoryRefsResult = Schema.decodeUnknownSync(VcsListHistoryRefsResult); + +describe("VCS ref contracts", () => { + it("preserves the numeric cursor and result shape of vcs.listRefs", () => { + expect(decodeListRefsInput({ cwd: "/repo", cursor: 20, query: "release" })).toEqual({ + cwd: "/repo", + cursor: 20, + query: "release", + }); + expect( + decodeListRefsResult({ + refs: [], + isRepo: true, + hasPrimaryRemote: false, + nextCursor: 20, + totalCount: 25, + }).totalCount, + ).toBe(25); + }); + + it("uses an opaque cursor only for vcs.listHistoryRefs", () => { + expect( + decodeListHistoryRefsInput({ + cwd: "/repo", + cursor: "opaque-cursor", + query: "Release", + namespace: "tag", + }), + ).toEqual({ + cwd: "/repo", + cursor: "opaque-cursor", + query: "Release", + namespace: "tag", + }); + expect(() => decodeListRefsInput({ cwd: "/repo", cursor: "opaque-cursor" })).toThrow(); + }); +}); + +describe("Git commit hashes", () => { + it("accepts SHA-1 and SHA-256 object hashes", () => { + expect(decodeCommitDetailsInput({ cwd: "/repo", hash: "a".repeat(40) }).hash).toHaveLength(40); + expect(decodeCommitDetailsInput({ cwd: "/repo", hash: "b".repeat(64) }).hash).toHaveLength(64); + }); +}); + +describe("Git file path contracts", () => { + it("preserves leading and trailing whitespace in file-path requests and results", () => { + const filePath = " leading-and-trailing.txt "; + + expect( + decodeRunStackedActionInput({ + actionId: "action-1", + cwd: "/repo", + action: "commit", + filePaths: [filePath], + }).filePaths, + ).toEqual([filePath]); + expect( + decodeCommitDiffInput({ + cwd: "/repo", + hash: "a".repeat(40), + filePath, + }).filePath, + ).toBe(filePath); + expect( + decodeListCommitFilesResult({ + files: [{ status: "M", path: filePath }], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + }).files[0]?.path, + ).toBe(filePath); + expect( + decodeStatusResult({ + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: "main", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: filePath, insertions: 1, deletions: 0 }], + insertions: 1, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }).workingTree.files[0]?.path, + ).toBe(filePath); + }); +}); + +describe("Git history results", () => { + it("does not expose client cache generations in History RPC inputs", () => { + expect(decodeListHistoryRefsInput({ cwd: "/repo", queryGeneration: 1 })).not.toHaveProperty( + "queryGeneration", + ); + expect(decodeGetHistoryInput({ cwd: "/repo", queryGeneration: 1 })).not.toHaveProperty( + "queryGeneration", + ); + expect( + decodeCommitDetailsInput({ cwd: "/repo", hash: "a".repeat(40), queryGeneration: 1 }), + ).not.toHaveProperty("queryGeneration"); + expect( + decodeListCommitFilesInput({ cwd: "/repo", hash: "a".repeat(40), queryGeneration: 1 }), + ).not.toHaveProperty("queryGeneration"); + expect( + decodeCommitDiffInput({ cwd: "/repo", hash: "a".repeat(40), queryGeneration: 1 }), + ).not.toHaveProperty("queryGeneration"); + }); + + it("round-trips a listed ref name into a bounded history revision", () => { + const refName = `refs/heads/${"nested/".repeat(180)}feature`; + const listed = decodeListHistoryRefsResult({ + refs: [ + { + name: refName, + current: false, + isDefault: false, + worktreePath: null, + }, + ], + currentRef: null, + hasMore: false, + hasPrimaryRemote: false, + isRepo: true, + isComplete: true, + nextCursor: null, + }); + + expect(decodeGetHistoryInput({ cwd: "/repo", revision: listed.refs[0]!.name }).revision).toBe( + refName, + ); + expect(() => decodeGetHistoryInput({ cwd: "/repo", revision: "r".repeat(4097) })).toThrow(); + }); + + it("preserves an explicit capped signal for incomplete history snapshots", () => { + expect( + decodeGetHistoryResult({ + commits: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: true, + }).capped, + ).toBe(true); + }); + + it("preserves commits whose Git identity fields are empty", () => { + const result = decodeGetHistoryResult({ + commits: [ + { + hash: "a".repeat(40), + parentHashes: [], + subject: "identity-less commit", + authorName: "", + authorEmail: "", + authoredAt: "2026-08-19T00:00:00Z", + refs: [], + }, + ], + isRepo: true, + nextCursor: null, + hasMore: false, + }); + + expect(result.commits[0]?.authorName).toBe(""); + expect(result.commits[0]?.authorEmail).toBe(""); + }); +}); describe("VcsCreateWorktreeInput", () => { it("accepts omitted newRefName for existing-refName worktrees", () => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2ccf5c05abce..260e845d665c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -5,7 +5,11 @@ import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceC import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +const GitFilePath = Schema.String.check(Schema.isNonEmpty()); const GIT_LIST_BRANCHES_MAX_LIMIT = 200; +const GIT_HISTORY_MAX_LIMIT = 200; +const GIT_HISTORY_REVISION_MAX_LENGTH = 4096; +const GitCommitHash = Schema.String.check(Schema.isPattern(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i)); // Domain Types @@ -84,6 +88,20 @@ export const VcsRef = Schema.Struct({ }); export type VcsRef = typeof VcsRef.Type; +export const VcsHistoryRef = Schema.Struct({ + name: TrimmedNonEmptyStringSchema, + isRemote: Schema.optional(Schema.Boolean), + isTag: Schema.optional(Schema.Boolean), + remoteName: Schema.optional(TrimmedNonEmptyStringSchema), + upstreamName: Schema.optional(TrimmedNonEmptyStringSchema), + current: Schema.Boolean, + isDefault: Schema.Boolean, + worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + aheadCount: Schema.optional(NonNegativeInt), + behindCount: Schema.optional(NonNegativeInt), +}); +export type VcsHistoryRef = typeof VcsHistoryRef.Type; + const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, @@ -116,9 +134,7 @@ export const GitRunStackedActionInput = Schema.Struct({ action: GitStackedAction, commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), - filePaths: Schema.optional( - Schema.Array(TrimmedNonEmptyStringSchema).check(Schema.isMinLength(1)), - ), + filePaths: Schema.optional(Schema.Array(GitFilePath).check(Schema.isMinLength(1))), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; @@ -135,6 +151,50 @@ export const VcsListRefsInput = Schema.Struct({ }); export type VcsListRefsInput = typeof VcsListRefsInput.Type; +export const VcsListHistoryRefsInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), + cursor: Schema.optional(TrimmedNonEmptyStringSchema), + namespace: Schema.optional(Schema.Literals(["local", "remote", "tag"])), + refresh: Schema.optional(Schema.Boolean), + limit: Schema.optional( + PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), + ), +}); +export type VcsListHistoryRefsInput = typeof VcsListHistoryRefsInput.Type; + +export const VcsGetHistoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + revision: Schema.optional( + TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(GIT_HISTORY_REVISION_MAX_LENGTH)), + ), + cursor: Schema.optional(TrimmedNonEmptyStringSchema), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_HISTORY_MAX_LIMIT))), +}); +export type VcsGetHistoryInput = typeof VcsGetHistoryInput.Type; + +export const VcsGetCommitDetailsInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + hash: GitCommitHash, +}); +export type VcsGetCommitDetailsInput = typeof VcsGetCommitDetailsInput.Type; + +export const VcsListCommitFilesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + hash: GitCommitHash, + cursor: Schema.optional(TrimmedNonEmptyStringSchema), + limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(100))), +}); +export type VcsListCommitFilesInput = typeof VcsListCommitFilesInput.Type; + +export const VcsGetCommitDiffInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + hash: GitCommitHash, + filePath: Schema.optional(GitFilePath), + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), +}); +export type VcsGetCommitDiffInput = typeof VcsGetCommitDiffInput.Type; + export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, @@ -218,7 +278,7 @@ const VcsStatusLocalShape = { workingTree: Schema.Struct({ files: Schema.Array( Schema.Struct({ - path: TrimmedNonEmptyStringSchema, + path: GitFilePath, insertions: NonNegativeInt, deletions: NonNegativeInt, }), @@ -271,6 +331,76 @@ export const VcsListRefsResult = Schema.Struct({ }); export type VcsListRefsResult = typeof VcsListRefsResult.Type; +export const VcsListHistoryRefsResult = Schema.Struct({ + refs: Schema.Array(VcsHistoryRef), + currentRef: Schema.NullOr(VcsHistoryRef), + isRepo: Schema.Boolean, + hasPrimaryRemote: Schema.Boolean, + nextCursor: Schema.NullOr(TrimmedNonEmptyStringSchema), + isComplete: Schema.Boolean, +}); +export type VcsListHistoryRefsResult = typeof VcsListHistoryRefsResult.Type; + +export const GitHistoryCommit = Schema.Struct({ + hash: GitCommitHash, + parentHashes: Schema.Array(GitCommitHash), + subject: Schema.String, + authorName: Schema.String, + authorEmail: Schema.String, + authoredAt: Schema.String, + refs: Schema.Array(TrimmedNonEmptyStringSchema), +}); +export type GitHistoryCommit = typeof GitHistoryCommit.Type; + +export const GitCommitChangedFile = Schema.Struct({ + status: Schema.Literals(["A", "M", "D", "R", "C", "T", "U", "X", "B"]), + path: GitFilePath, +}); +export type GitCommitChangedFile = typeof GitCommitChangedFile.Type; + +export const GitCommitDetails = Schema.Struct({ + hash: GitCommitHash, + parentHashes: Schema.Array(GitCommitHash), + subject: Schema.String, + body: Schema.String, + authorName: Schema.String, + authorEmail: Schema.String, + authoredAt: Schema.String, + refs: Schema.Array(TrimmedNonEmptyStringSchema), +}); +export type GitCommitDetails = typeof GitCommitDetails.Type; + +export const VcsGetHistoryResult = Schema.Struct({ + commits: Schema.Array(GitHistoryCommit), + isRepo: Schema.Boolean, + nextCursor: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hasMore: Schema.Boolean, + capped: Schema.optional(Schema.Boolean), +}); +export type VcsGetHistoryResult = typeof VcsGetHistoryResult.Type; + +export const VcsGetCommitDetailsResult = Schema.Struct({ + commit: Schema.NullOr(GitCommitDetails), + isRepo: Schema.Boolean, +}); +export type VcsGetCommitDetailsResult = typeof VcsGetCommitDetailsResult.Type; + +export const VcsListCommitFilesResult = Schema.Struct({ + files: Schema.Array(GitCommitChangedFile), + isRepo: Schema.Boolean, + nextCursor: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hasMore: Schema.Boolean, + capped: Schema.Boolean, +}); +export type VcsListCommitFilesResult = typeof VcsListCommitFilesResult.Type; + +export const VcsGetCommitDiffResult = Schema.Struct({ + diff: Schema.String, + truncated: Schema.Boolean, + isRepo: Schema.Boolean, +}); +export type VcsGetCommitDiffResult = typeof VcsGetCommitDiffResult.Type; + export const VcsCreateWorktreeResult = Schema.Struct({ worktree: VcsWorktree, }); @@ -353,6 +483,15 @@ export class GitCommandError extends Schema.TaggedError()("GitC } } +export class VcsSnapshotExpiredError extends Schema.TaggedError()( + "VcsSnapshotExpiredError", + { operation: Schema.String, cursor: TrimmedNonEmptyStringSchema }, +) { + override get message(): string { + return `Git browsing snapshot expired for ${this.operation}.`; + } +} + export class TextGenerationError extends Schema.TaggedError()( "TextGenerationError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 54ec73c1a0a3..9025d5a9a60c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -51,13 +51,24 @@ import { VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, + VcsSnapshotExpiredError, VcsCreateRefInput, VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsGetHistoryInput, + VcsGetHistoryResult, + VcsGetCommitDetailsInput, + VcsGetCommitDetailsResult, + VcsListCommitFilesInput, + VcsListCommitFilesResult, + VcsGetCommitDiffInput, + VcsGetCommitDiffResult, VcsInitInput, VcsListRefsInput, VcsListRefsResult, + VcsListHistoryRefsInput, + VcsListHistoryRefsResult, GitManagerServiceError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -314,6 +325,11 @@ export const WS_METHODS = { vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", + vcsListHistoryRefs: "vcs.listHistoryRefs", + vcsGetHistory: "vcs.getHistory", + vcsGetCommitDetails: "vcs.getCommitDetails", + vcsListCommitFiles: "vcs.listCommitFiles", + vcsGetCommitDiff: "vcs.getCommitDiff", vcsCreateWorktree: "vcs.createWorktree", vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", @@ -1119,6 +1135,36 @@ const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +const WsVcsListHistoryRefsRpc = Rpc.make(WS_METHODS.vcsListHistoryRefs, { + payload: VcsListHistoryRefsInput, + success: VcsListHistoryRefsResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +const WsVcsGetHistoryRpc = Rpc.make(WS_METHODS.vcsGetHistory, { + payload: VcsGetHistoryInput, + success: VcsGetHistoryResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +const WsVcsGetCommitDetailsRpc = Rpc.make(WS_METHODS.vcsGetCommitDetails, { + payload: VcsGetCommitDetailsInput, + success: VcsGetCommitDetailsResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +const WsVcsListCommitFilesRpc = Rpc.make(WS_METHODS.vcsListCommitFiles, { + payload: VcsListCommitFilesInput, + success: VcsListCommitFilesResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +const WsVcsGetCommitDiffRpc = Rpc.make(WS_METHODS.vcsGetCommitDiff, { + payload: VcsGetCommitDiffInput, + success: VcsGetCommitDiffResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -1497,6 +1543,11 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsListHistoryRefsRpc, + WsVcsGetHistoryRpc, + WsVcsGetCommitDetailsRpc, + WsVcsListCommitFilesRpc, + WsVcsGetCommitDiffRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc,