From 2a7c05fcaf1a246d106a2ec4225167c42daba563 Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:23:44 +0200 Subject: [PATCH 1/5] feat(#6315): unify repository history, issues, and pull requests --- apps/mobile/src/state/queries.ts | 7 +- apps/server/src/auth/RpcAuthorization.test.ts | 10 + apps/server/src/auth/RpcAuthorization.ts | 5 + .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../server/src/git/GitWorkflowService.test.ts | 39 + apps/server/src/git/GitWorkflowService.ts | 93 ++ apps/server/src/vcs/GitVcsDriver.ts | 26 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 1080 ++++++++++++- apps/server/src/vcs/GitVcsDriverCore.ts | 1149 ++++++++++++-- apps/server/src/ws.ts | 20 + .../web/src/components/ChatView.logic.test.ts | 33 + apps/web/src/components/ChatView.logic.ts | 21 + apps/web/src/components/ChatView.tsx | 141 ++ .../src/components/GitHistoryPanel.test.tsx | 1366 +++++++++++++++++ apps/web/src/components/GitHistoryPanel.tsx | 808 ++++++++++ .../src/components/RepositoryPanel.test.tsx | 276 ++++ apps/web/src/components/RepositoryPanel.tsx | 296 ++++ .../src/components/RightPanelTabs.test.tsx | 44 + apps/web/src/components/RightPanelTabs.tsx | 30 + .../git-history/GitHistoryCommitDetails.tsx | 274 ++++ .../git-history/GitHistoryCommitDiff.tsx | 118 ++ .../git-history/GitHistoryCommitList.tsx | 408 +++++ .../GitHistoryPaneResizeHandle.tsx | 51 + .../git-history/GitHistoryRefsPane.test.ts | 54 + .../git-history/GitHistoryRefsPane.tsx | 450 ++++++ .../git-history/GitHistoryVisualTypes.ts | 23 + .../git-history/gitHistoryClipboard.ts | 11 + .../git-history/useGitHistoryRefs.test.tsx | 224 +++ .../git-history/useGitHistoryRefs.ts | 149 ++ .../src/components/issue/IssuesPanel.test.tsx | 108 ++ apps/web/src/components/issue/IssuesPanel.tsx | 71 +- .../pullRequest/PullRequestChecksPopover.tsx | 55 +- .../pullRequest/PullRequestListFilters.tsx | 155 +- .../pullRequest/PullRequestsPanel.test.tsx | 447 ++++++ .../pullRequest/PullRequestsPanel.tsx | 578 +++++++ .../pullRequest/pullRequestChecks.test.tsx | 14 +- .../pullRequest/pullRequestList.logic.test.ts | 105 +- .../pullRequest/pullRequestList.logic.ts | 88 +- .../components/sidebar/SidebarChrome.test.tsx | 149 ++ .../src/components/sidebar/SidebarChrome.tsx | 83 +- apps/web/src/gitHistoryCapability.test.ts | 12 + apps/web/src/gitHistoryCapability.ts | 7 + apps/web/src/lib/gitHistoryGraph.test.ts | 325 ++++ apps/web/src/lib/gitHistoryGraph.ts | 234 +++ apps/web/src/lib/gitRefTree.test.ts | 57 + apps/web/src/lib/gitRefTree.ts | 88 ++ apps/web/src/rightPanelStore.test.ts | 103 ++ apps/web/src/rightPanelStore.ts | 118 +- apps/web/src/routes/_chat.issues.tsx | 2 + apps/web/src/routes/_chat.pull-requests.tsx | 75 +- apps/web/src/state/paginatedBranches.ts | 5 +- apps/web/src/state/queries.test.tsx | 301 ++++ apps/web/src/state/queries.ts | 192 ++- apps/web/src/state/query.ts | 2 + apps/web/src/state/vcs.ts | 3 +- apps/web/src/test/reactHookHarness.ts | 21 +- docs/user/source-control.md | 13 + packages/client-runtime/src/state/runtime.ts | 40 +- packages/client-runtime/src/state/vcs.test.ts | 147 +- packages/client-runtime/src/state/vcs.ts | 58 +- .../src/state/vcsAction.test.ts | 6 +- .../src/state/vcsRefInvalidation.ts | 17 +- packages/contracts/src/environment.test.ts | 13 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/git.test.ts | 144 ++ packages/contracts/src/git.ts | 149 +- packages/contracts/src/rpc.ts | 51 + 68 files changed, 10889 insertions(+), 358 deletions(-) create mode 100644 apps/web/src/components/GitHistoryPanel.test.tsx create mode 100644 apps/web/src/components/GitHistoryPanel.tsx create mode 100644 apps/web/src/components/RepositoryPanel.test.tsx create mode 100644 apps/web/src/components/RepositoryPanel.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitDetails.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitDiff.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitList.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryRefsPane.test.ts create mode 100644 apps/web/src/components/git-history/GitHistoryRefsPane.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryVisualTypes.ts create mode 100644 apps/web/src/components/git-history/gitHistoryClipboard.ts create mode 100644 apps/web/src/components/git-history/useGitHistoryRefs.test.tsx create mode 100644 apps/web/src/components/git-history/useGitHistoryRefs.ts create mode 100644 apps/web/src/components/issue/IssuesPanel.test.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestsPanel.test.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestsPanel.tsx create mode 100644 apps/web/src/components/sidebar/SidebarChrome.test.tsx create mode 100644 apps/web/src/gitHistoryCapability.test.ts create mode 100644 apps/web/src/gitHistoryCapability.ts create mode 100644 apps/web/src/lib/gitHistoryGraph.test.ts create mode 100644 apps/web/src/lib/gitHistoryGraph.ts create mode 100644 apps/web/src/lib/gitRefTree.test.ts create mode 100644 apps/web/src/lib/gitRefTree.ts create mode 100644 apps/web/src/state/queries.test.tsx 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 790be9386e6e..5d09ed6718dd 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -48,6 +48,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 025a3ad72362..fc7548d70e2c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -114,6 +114,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 ee30d987591d..72d216e40de0 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -91,6 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); + expect(second.capabilities.gitHistory).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 94dc525609f9..8e408ca36999 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -148,6 +148,7 @@ export const make = Effect.gen(function* () { connectionProbe: true, pullRequests: true, issues: true, + gitHistory: true, threadSettlement: true, threadSnooze: true, threadPinning: true, diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..555ea9e67ced 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -135,6 +135,45 @@ describe("GitWorkflowService", () => { ), ); + it.effect("returns an empty history when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const history = yield* workflow.getHistory({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(history, { + commits: [], + isRepo: false, + nextCursor: null, + hasMore: false, + capped: false, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("returns no commit details when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const details = yield* workflow.getCommitDetails({ + cwd: "/not-a-repo", + hash: "0123456789abcdef0123456789abcdef01234567", + }); + + assert.deepStrictEqual(details, { commit: null, isRepo: false }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + 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 da22794951fb..5ee3235472e2 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; @@ -134,6 +160,39 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +function nonRepositoryListHistoryRefs(): 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 }; +} + export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; @@ -299,6 +358,40 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + listHistoryRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listHistoryRefs", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? git.listHistoryRefs(input) + : Effect.succeed(nonRepositoryListHistoryRefs()), + ), + ), + getHistory: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getHistory", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.getHistory(input) : Effect.succeed(nonRepositoryHistory()), + ), + ), + getCommitDetails: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getCommitDetails", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? git.getCommitDetails(input) + : Effect.succeed(nonRepositoryCommitDetails()), + ), + ), + listCommitFiles: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listCommitFiles", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listCommitFiles(input) : Effect.succeed(nonRepositoryCommitFiles()), + ), + ), + getCommitDiff: (input) => + detectGitRepositoryForCommand("GitWorkflowService.getCommitDiff", input.cwd).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 b9ef992122ae..a4c604fc8d17 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, @@ -269,6 +280,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 66dc7b96a73e..a44b141a0c59 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; 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"; @@ -15,6 +16,7 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../config.ts"; import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; @@ -57,6 +59,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 => @@ -167,6 +194,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; @@ -451,6 +507,913 @@ 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("fails history output larger than its dedicated retained-byte ceiling", () => + 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("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 error = yield* driver.getHistory({ cwd }).pipe(Effect.flip); + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.getHistory.log", + outputLength: 512 * 1024 + 1, + }); + }), + ).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 once, rejects replay, and cleans up after the final page", () => + 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 }) + .pipe(Effect.flip); + assert.equal(replay._tag, "VcsSnapshotExpiredError"); + }).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("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("rejects synthetic commit-file output above the 512 KiB retained-byte cap", () => + 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("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 error = yield* driver.listCommitFiles({ cwd, hash: "a".repeat(40) }).pipe(Effect.flip); + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listCommitFiles.diffTree", + outputLength: 512 * 1024 + 1, + }); + }), + ).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("rejects streamed ref output above its two-megabyte ceiling after the ref cap", () => + 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 error = yield* driver.listHistoryRefs({ cwd, refresh: true }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listHistoryRefs.snapshot", + }); + if (error._tag !== "GitCommandError") return assert.fail("expected a Git command error"); + assert.ok((error.outputLength ?? 0) > 2 * 1024 * 1024); + }), + ).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* () { @@ -553,6 +1516,7 @@ it.effect("refreshes the current branch after an external checkout", () => it.effect("backs off failed upstream refreshes across linked worktrees", () => Effect.scoped( Effect.gen(function* () { + const hostPlatform = yield* HostProcessPlatform; const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const fetchAttempts = yield* Ref.make(0); const failingFetchSpawner = ChildProcessSpawner.make((command) => @@ -606,9 +1570,13 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => "rev-parse", "--git-common-dir", ])).stdout.trim(); + const rootCommonPath = yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)); + const linkedCommonPath = yield* fileSystem.realPath( + pathService.resolve(worktreePath, linkedCommonDir), + ); assert.equal( - yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)), - yield* fileSystem.realPath(pathService.resolve(worktreePath, linkedCommonDir)), + hostPlatform === "win32" ? rootCommonPath.toLowerCase() : rootCommonPath, + hostPlatform === "win32" ? linkedCommonPath.toLowerCase() : linkedCommonPath, ); yield* Ref.set(fetchAttempts, 0); @@ -632,7 +1600,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) => { @@ -1350,14 +2379,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { it.effect("preserves newline characters in worktree paths when listing refs", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); const fileSystem = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + const testFileSystem = { + ...fileSystem, + stat: (candidate) => + candidate === worktreePath + ? fileSystem.stat(worktreesRoot) + : fileSystem.stat(candidate), + } satisfies FileSystem.FileSystem; + let worktreeListOutput = ""; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.args.includes("worktree") && + command.args.includes("--porcelain") + ? Effect.succeed(makeSuccessfulHandle(worktreeListOutput)) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, testFileSystem), + ); + const { initialBranch } = yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + worktreeListOutput = [ + `worktree ${cwd}`, + `branch refs/heads/${initialBranch}`, + "", + `worktree ${worktreePath}`, + "branch refs/heads/feature/newline-path", + "", + ].join("\0"); + yield* git(cwd, ["branch", "feature/newline-path"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); const refs = yield* driver.listRefs({ cwd, refresh: true }); const listedPath = refs.refs.find( @@ -1367,11 +2426,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { if (typeof listedPath !== "string") { return assert.fail("expected the linked branch to include its worktree path"); } - assert.equal( - yield* fileSystem.realPath(listedPath), - yield* fileSystem.realPath(worktreePath), - ); - }), + assert.equal(listedPath, worktreePath); + }).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); it.effect("creates and removes a worktree for a new refName", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3d0f66c347f4..e1932a43a407 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"; @@ -74,6 +85,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, @@ -104,10 +164,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); @@ -147,6 +233,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 } { @@ -158,6 +247,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 }> { @@ -204,40 +336,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; @@ -626,22 +820,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) { @@ -650,6 +853,9 @@ const collectOutput = Effect.fnUntraced(function* ( if (trailing.length > 0 && onLine) { yield* onLine(trailing); } + if (!recordsComplete && onRecord) { + recordsComplete = !(yield* onRecord(trailing)); + } } }); @@ -674,7 +880,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); }); @@ -691,7 +897,7 @@ const collectOutput = Effect.fnUntraced(function* ( ); const remainder = truncated ? "" : decoder.decode(); - text += remainder; + if (captureText) text += remainder; lineBuffer += remainder; yield* emitCompleteLines(true); return { @@ -701,141 +907,277 @@ 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 deleteCommitFilesSnapshot = (snapshotId: string): void => { + commitFilesSnapshots.delete(snapshotId); + for (const [cursor, value] of commitFilesCursors) { + if (value.snapshotId === snapshotId) commitFilesCursors.delete(cursor); + } + }; + 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 cursor = NodeCrypto.randomBytes(18).toString("base64url"); + commitFilesCursors.set(cursor, { snapshotId, offset }); + 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( @@ -862,7 +1204,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* args: readonly string[], options: ExecuteGitOptions = {}, ): Effect.Effect => - execute({ + executeRaw({ operation, cwd, args, @@ -875,6 +1217,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) { @@ -1124,14 +1471,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; @@ -1156,8 +1511,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; @@ -1181,7 +1537,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); }); @@ -1203,7 +1561,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; @@ -1252,6 +1610,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* Cache.get( statusRemoteRefreshCache, new StatusRemoteRefreshCacheKey({ + cacheIdentity: normalizeGitCommonDirCacheKey(gitCommonDir), gitCommonDir, remoteName: upstream.remoteName, }), @@ -1548,7 +1907,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? aheadCount : yield* computeAheadCountAgainstBase(cwd, branch).pipe(Effect.orElseSucceed(() => 0)) : 0; - return { isRepo: true, defaultBranch, @@ -1601,7 +1959,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( @@ -2656,11 +3016,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 }), ) @@ -2756,6 +3112,476 @@ 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; + }); + 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, + fallbackErrorDetail: "Git ref snapshot enumeration failed.", + }, + ); + const isComplete = 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, + 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: 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, + }); + } + commitFilesCursors.delete(input.cursor); + snapshot = existing; + 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, + 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) 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) { @@ -3132,6 +3958,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); + const invalidateRefSnapshots = Effect.fn("invalidateRefSnapshots")(function* (cwd: string) { + const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( + Effect.catchTag("GitCommandError", () => Effect.succeed(null)), + ); + if (repositoryPaths === null) return; + const snapshotIds = refSnapshotIdsByGitCommonDir.get(repositoryPaths.gitCommonDir); + if (snapshotIds !== undefined) { + for (const snapshotId of snapshotIds) { + deleteRefSnapshot(snapshotId); + } + } + }); + const withListRefsInvalidation = ( cwd: string, effect: Effect.Effect, @@ -3140,6 +3979,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.ensuring( Effect.all([ invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), + invalidateRefSnapshots(cwd).pipe(Effect.ignore), invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), ]), ), @@ -3175,6 +4015,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 b4105c2c2066..c9c70c30c50d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2347,6 +2347,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 86b06ef750f0..69651fa32487 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -29,6 +29,7 @@ import { resolveSourceControlSurfaceCapability, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveGitHubIssueUrlPrefix, scheduleEnvironmentReconnectWarning, startNewThreadForProject, shouldShowBranchMismatchBanner, @@ -54,6 +55,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("environment reconnect warning grace", () => { afterEach(() => vi.useRealTimers()); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e7bdb9039504..a0bd3c258af7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, isProviderDriverKind, ProjectId, + type RepositoryIdentity, type ModelSelection, type ProviderDriverKind, type ServerProvider, @@ -10,6 +11,7 @@ import { type ThreadId, type TurnId, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadShell } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -35,6 +37,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 scheduleEnvironmentReconnectWarning(showWarning: () => void): () => void { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2027da375a36..79b67d8b6f09 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3,6 +3,8 @@ import { DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, + type IssueLink, + type IssueLinkedPullRequest, type MessageId, type ModelSelection, type ProjectScript, @@ -336,6 +338,7 @@ import { reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + resolveGitHubIssueUrlPrefix, resolveSourceControlSurfaceCapability, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -371,6 +374,7 @@ import { serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { hasGitHistoryCapability } from "../gitHistoryCapability"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -454,6 +458,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 = [ @@ -2074,6 +2079,7 @@ function ChatViewContent(props: ChatViewProps) { capabilityKnown: issuesCapabilityKnown, supported: supportsIssues, }); + const supportsGitHistory = hasGitHistoryCapability(serverConfig?.environment.capabilities); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2686,6 +2692,7 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const gitHistoryIssueUrlPrefix = resolveGitHubIssueUrlPrefix(activeProject?.repositoryIdentity); const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null @@ -3344,6 +3351,27 @@ function ChatViewContent(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"); @@ -3434,6 +3462,70 @@ function ChatViewContent(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 togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -6321,6 +6413,45 @@ function ChatViewContent(props: ChatViewProps) { initialGitScope={initialDiffPanelGitScope} /> + ) : activeRightPanelSurface?.kind === "git-history" && activeProject && activeProjectRef ? ( + + + useRightPanelStore.getState().selectRepositoryView(activeThreadRef, view) + } + selectedIssue={activeRightPanelSurface.selectedIssue} + onSelectIssue={selectRepositoryIssue} + selectedPullRequest={activeRightPanelSurface.selectedPullRequest} + onSelectPullRequest={selectRepositoryPullRequest} + handoffTarget={{ + kind: "existing-thread", + projectRef: activeProjectRef, + draftId: composerDraftTarget, + }} + onIssueStateChange={handleIssueTabStatusChange} + onPullRequestStateChange={handlePullRequestTabStatusChange} + onOpenLinkedIssue={openLinkedIssueInRepository} + onOpenLinkedPullRequest={openLinkedPullRequestInRepository} + {...(gitHistoryIssueUrlPrefix ? { issueUrlPrefix: gitHistoryIssueUrlPrefix } : {})} + /> + ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( @@ -6882,6 +7013,7 @@ function ChatViewContent(props: ChatViewProps) { onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddRepository={addRepositorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -6889,6 +7021,10 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + repositoryAvailable={ + (supportsGitHistory && isGitRepo && gitCwd !== null) || + ((supportsIssues || supportsPullRequests) && threadRepository !== null) + } filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} issueAvailable={issueSurfaceAvailable} @@ -6925,6 +7061,7 @@ function ChatViewContent(props: ChatViewProps) { onAddBrowser={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddRepository={addRepositorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -6932,6 +7069,10 @@ function ChatViewContent(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + repositoryAvailable={ + (supportsGitHistory && isGitRepo && gitCwd !== null) || + ((supportsIssues || supportsPullRequests) && threadRepository !== 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..516fc045f091 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.test.tsx @@ -0,0 +1,1366 @@ +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 { 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>, +})); + +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, + tags: [] as ReadonlyArray, + 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: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +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: null, + isPending: false, + isFetchingNextPage: false, + loadNext: vi.fn(), + retry: vi.fn(), + 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 === "refs") { + return { + ...base, + data: { + refs: historyState.refs, + tags: historyState.tags, + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: historyState.refs.length, + }, + }; + } + 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" }; + }, + listRefs: () => ({ kind: "refs" }), + status: () => ({ kind: "status" }), + }, +})); + +import { + appendCommitFilesPage, + nextCommitFilesCursor, + nextCommitFilesRecoveryGeneration, + isWideHistoryLayout, +} from "./GitHistoryPanel"; +import GitHistoryPanel from "./GitHistoryPanel"; + +const environmentId = EnvironmentId.make("environment-local"); + +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 hasMore?: boolean; readonly nextCursor?: string | null }, +): PageResult { + return { + _tag: "Success", + waiting: false, + value: { + commits, + isRepo: true, + hasMore: options?.hasMore ?? false, + nextCursor: options?.nextCursor ?? null, + }, + }; +} + +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: "C:/workspace", + ...(issueUrlPrefix ? { issueUrlPrefix } : {}), + }) as ReactElement>; +} + +function flushEffects(): void { + const effects = effectQueue.effects.splice(0); + for (const effect of effects) effect(); +} + +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", () => { + beforeEach(() => { + hooks.reset(); + fontState.interfaceSize = 16; + effectQueue.cursor = 0; + effectQueue.dependencies.length = 0; + effectQueue.effects.length = 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.tags = []; + historyState.status = { aheadCount: 0, behindCount: 0 }; + }); + + it("does not start an all-refs history request while the current ref is unresolved", () => { + historyState.refsResolved = false; + + renderPanel(); + + expect(historyState.getHistory).not.toHaveBeenCalled(); + }); + + it("restarts the first history page after a typed continuation expiry", () => { + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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.input); + expect(requests).toContainEqual({ + cwd: "C:/workspace", + cursor: "history-page-2", + limit: 100, + queryGeneration: 0, + }); + expect(requests.at(-1)).toEqual({ cwd: "C:/workspace", limit: 100, queryGeneration: 1 }); + }); + + it("recovers a second continuation expiry once after a successful recovery", () => { + const firstPage = page([commit("aaaaaaaa11111111111111111111111111111111", "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.input.queryGeneration, + ); + 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("aaaaaaaa11111111111111111111111111111111", "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({ + environmentId, + input: { cwd: "C:/workspace", limit: 100, queryGeneration: 1 }, + }); + expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + environmentId, + input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 1 }, + }); + expect(historyState.listCommitFiles).toHaveBeenLastCalledWith({ + environmentId, + input: { cwd: "C:/workspace", hash: historyCommit.hash, limit: 100, queryGeneration: 1 }, + }); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + environmentId, + input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 1 }, + }); + }); + + it("discards loaded history cursor pages when the environment reconnects", () => { + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "First")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set( + "history-page-2", + page([commit("bbbbbbbb22222222222222222222222222222222", "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("aaaaaaaa11111111111111111111111111111111", "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("aaaaaaaa11111111111111111111111111111111", "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("aaaaaaaa11111111111111111111111111111111", "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("bbbbbbbb22222222222222222222222222222222", "Parent"); + const child = { + ...commit("aaaaaaaa11111111111111111111111111111111", "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("aaaaaaaa11111111111111111111111111111111", "Child"), + parentHashes: ["bbbbbbbb22222222222222222222222222222222"], + }; + 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("aaaaaaaa11111111111111111111111111111111", "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("aaaaaaaa11111111111111111111111111111111", "Add Git history panel"), + commit("bbbbbbbb22222222222222222222222222222222", "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({ + environmentId, + input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0 }, + }); + }); + + it("keeps the desktop refs and details workflow available at ordinary desktop widths", () => { + expect(isWideHistoryLayout(1119)).toBe(false); + expect(isWideHistoryLayout(1120)).toBe(true); + }); + + it("filters history by commit message", () => { + historyState.pages.set( + undefined, + page([ + commit("aaaaaaaa11111111111111111111111111111111", "Prepare release"), + commit("bbbbbbbb22222222222222222222222222222222", "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("aaaaaaaa11111111111111111111111111111111", "Fix graph layout")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set( + "history-page-2", + page([commit("bbbbbbbb22222222222222222222222222222222", "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({ + environmentId, + input: { + cwd: "C:/workspace", + cursor: "history-page-2", + limit: 100, + queryGeneration: 0, + }, + }); + 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", () => { + historyState.pages.set( + undefined, + page([ + commit("0acf007c21111111111111111111111111111111", "Matching commit"), + commit("bbbbbbbb22222222222222222222222222222222", "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("aaaaaaaa11111111111111111111111111111111", "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("cccccccc33333333333333333333333333333333", "Match newest"), + parentHashes: ["b"], + }, + { + ...commit("bbbbbbbb22222222222222222222222222222222", "Hidden parent"), + parentHashes: ["a"], + }, + commit("aaaaaaaa11111111111111111111111111111111", "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([ + "cccccccc33333333333333333333333333333333", + "aaaaaaaa11111111111111111111111111111111", + ]); + 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("aaaaaaaa11111111111111111111111111111111", "Initial commit"); + historyState.pages.set( + undefined, + page([duplicate], { hasMore: true, nextCursor: "next-page" }), + ); + historyState.pages.set( + "next-page", + page([duplicate, commit("bbbbbbbb22222222222222222222222222222222", "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([ + "aaaaaaaa11111111111111111111111111111111", + "bbbbbbbb22222222222222222222222222222222", + ]); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + environmentId, + input: { cwd: "C:/workspace", cursor: "next-page", limit: 100, queryGeneration: 0 }, + }); + }); + + it("filters, expands, and selects nested branches while showing the branch commit count", () => { + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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({ + environmentId, + input: { + cwd: "C:/workspace", + limit: 100, + queryGeneration: 0, + 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({ + environmentId, + input: { + cwd: "C:/workspace", + limit: 100, + queryGeneration: 0, + 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("aaaaaaaa11111111111111111111111111111111", "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({ + environmentId, + input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0, revision: "refs/tags/v1.2.3" }, + }); + }); + + it("opens the full commit diff from selected commit details", () => { + const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "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({ + environmentId, + input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 0 }, + }); + }); + + it("shows the short commit hash in every history row", () => { + const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "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 === "aaaaaaaa"); + 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("aaaaaaaa11111111111111111111111111111111", "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("aaaaaaaa11111111111111111111111111111111", "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 local refs", () => { + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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).not.toHaveBeenCalled(); + expect(historyState.refreshTags).not.toHaveBeenCalled(); + }); + + it("links issue references to the active GitHub repository", () => { + const historyCommit = commit( + "aaaaaaaa11111111111111111111111111111111", + "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("aaaaaaaa11111111111111111111111111111111", "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({ + environmentId, + input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 0 }, + }); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + environmentId, + input: { + cwd: "C:/workspace", + hash: historyCommit.hash, + filePath: "src/panel.tsx", + queryGeneration: 0, + }, + }); + 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("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..e3e27d9fe1a6 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -0,0 +1,808 @@ +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, + RefTreeProps, +} 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; + +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; +} + +export function useWideHistoryLayout(panelRef: RefObject): boolean { + const [isWide, setIsWide] = useState(true); + + useEffect(() => { + const panel = panelRef.current; + if (!panel || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(([entry]) => { + setIsWide(isWideHistoryLayout(entry?.contentRect.width ?? 0)); + }); + observer.observe(panel); + return () => observer.disconnect(); + }, [panelRef]); + + return isWide; +} + +export default function GitHistoryPanel(props: GitHistoryPanelProps) { + const panelRef = useRef(null); + const isWideLayout = useWideHistoryLayout(panelRef); + 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 [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 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 + ? [] + : cursors.map((cursor) => + vcsEnvironment.getHistory({ + environmentId: props.environmentId, + input: { + cwd: props.cwd, + queryGeneration: historyQueryGeneration + vcsHistoryRevision, + ...(selectedRevision === null ? {} : { revision: selectedRevision.revision }), + ...(cursor === undefined ? {} : { cursor }), + limit: HISTORY_PAGE_SIZE, + }, + }), + ), + [ + cursors, + historyQueryGeneration, + props.cwd, + props.environmentId, + 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 = selectedRevision === undefined || results.some((result) => result.waiting); + const isInitialLoad = 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 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, + input: { cwd: props.cwd, hash: selectedHash, queryGeneration: vcsHistoryRevision }, + }), + ); + 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, + input: { + cwd: props.cwd, + hash: selectedHash, + limit: 100, + queryGeneration: commitFilesQueryGeneration + vcsHistoryRevision, + ...(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, + input: { + cwd: props.cwd, + hash: commitDiffRequest.hash, + queryGeneration: vcsHistoryRevision, + ...(commitDiffRequest.filePath ? { filePath: commitDiffRequest.filePath } : {}), + }, + }), + ); + const { + currentRef = null, + expandedRefKeys, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs, + onRetryRefs, + refreshRefs, + refPaginationError, + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs: selectAllHistoryRefs, + selectRef: selectHistoryRef, + setRefFilter, + tagRefTree, + tagRefs, + 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 sharedRefTreeProps = { + filterActive: normalizedRefFilter.length > 0, + expanded: expandedRefKeys, + selectedRevision: selectedRevision?.revision ?? null, + onToggle: toggleRefKey, + onSelect: selectRef, + } satisfies Omit; + const refPaneProps = { + refFilter, + onRefFilterChange: setRefFilter, + selectedRevision: selectedRevision ?? null, + onSelectAll: selectAllRefs, + currentRef, + onSelectRef: selectRef, + normalizedRefFilter, + localRefTree, + remoteRefTree, + tagRefTree, + expandedRefKeys, + onToggleRefKey: toggleRefKey, + sharedRefTreeProps, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + onLoadMoreRefs, + onRetryRefs, + refPaginationError, + } satisfies Omit, "className" | "id" | "onClose">; + + useEffect(() => { + setPagination({ targetKey, cursors: INITIAL_CURSORS }); + setFilter(""); + setSelectedHash(null); + setCommitDiffRequest(null); + setMobilePane(null); + }, [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 graphByHash = useMemo(() => new Map(graphRows.map((row) => [row.hash, row])), [graphRows]); + const filteredRows = useMemo(() => { + return filteredHistory.flatMap((commit) => { + const graph = graphByHash.get(commit.hash); + return graph ? [{ commit, graph }] : []; + }); + }, [filteredHistory, graphByHash]); + + 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} + /> + ) : isInitialLoad ? ( +
+ Loading history… +
+ ) : error && history.length === 0 ? ( +
+

{error}

+ +
+ ) : !isRepo ? ( +
+ This folder is not a Git repository. +
+ ) : history.length === 0 ? ( +
+ This repository has no commits yet. +
+ ) : ( +
+ {isWideLayout ? ( + + ) : null} + {isWideLayout ? ( + + setRefsPaneWidth((width) => + Math.min(REFS_PANE_MAX_WIDTH, Math.max(REFS_PANE_MIN_WIDTH, width + delta)), + ) + } + onReset={() => setRefsPaneWidth(256)} + /> + ) : 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 && historyLimitReached && hasMoreFromServer ? ( +
+ Showing the first {HISTORY_PAGE_SIZE * MAX_HISTORY_PAGES} commits. Choose a branch + or refine the search to keep history responsive. +
+ ) : null} +
+
+ {isWideLayout ? ( + + setDetailsPaneWidth((width) => + Math.min(DETAILS_PANE_MAX_WIDTH, Math.max(DETAILS_PANE_MIN_WIDTH, width - delta)), + ) + } + onReset={() => setDetailsPaneWidth(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 && mobilePane === "refs" ? ( + !open && setMobilePane(null)} + > + + Branches and tags + setMobilePane(null)} + /> + + + ) : null} + {!isWideLayout && mobilePane === "details" ? ( + !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..f75b144d2b2f --- /dev/null +++ b/apps/web/src/components/RepositoryPanel.test.tsx @@ -0,0 +1,276 @@ +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 { repositoryViewsAfterActivation, 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} + onPullRequestStateChange={() => 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} + onPullRequestStateChange={() => 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} + onPullRequestStateChange={() => undefined} + onOpenLinkedIssue={() => undefined} + onOpenLinkedPullRequest={() => undefined} + />, + ); + + expect(markup).toContain('data-activity-mode="visible">
{ + const historyViews = repositoryViewsAfterActivation(new Set(), "history"); + const issueViews = repositoryViewsAfterActivation(historyViews, "issues"); + const pullRequestViews = repositoryViewsAfterActivation(issueViews, "pull-requests"); + + expect([...historyViews]).toEqual(["history"]); + expect([...issueViews]).toEqual(["history", "issues"]); + expect([...pullRequestViews]).toEqual(["history", "issues", "pull-requests"]); + }); + + it("exposes Issues beside History in the repository pane", () => { + const markup = renderToStaticMarkup( + undefined} + selectedIssue={null} + onSelectIssue={() => undefined} + selectedPullRequest={null} + onSelectPullRequest={() => undefined} + handoffTarget={{ kind: "new-thread" }} + onIssueStateChange={() => undefined} + onPullRequestStateChange={() => 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} + onPullRequestStateChange={() => 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} + onPullRequestStateChange={() => 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 onPullRequestStateChange: (status: PullRequestTabStatus) => 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; +} + +export function repositoryViewsAfterActivation( + activatedViews: ReadonlySet, + view: RepositoryView, +): ReadonlySet { + if (activatedViews.has(view)) return activatedViews; + return new Set([...activatedViews, view]); +} + +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 [activatedViews, setActivatedViews] = useState>( + () => new Set([mode]), + ); + const mountedViews = repositoryViewsAfterActivation(activatedViews, mode); + + useEffect(() => { + setActivatedViews((views) => repositoryViewsAfterActivation(views, mode)); + }, [mode]); + + const activateView = (view: RepositoryView) => { + setActivatedViews((views) => repositoryViewsAfterActivation(views, view)); + 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 ( +
+
+ + + +
+ + {mountedViews.has("history") && ( + +
+ {props.gitHistoryCapabilityState === "ready" ? ( + + Loading Git History… + + } + > + + + ) : ( +
+ Update the environment server to browse Git History. +
+ )} +
+
+ )} + + {mountedViews.has("issues") && ( + +
+ {props.issuesCapabilityState === "loading" ? ( +
+ Loading issues… +
+ ) : props.issuesCapabilityState === "unavailable" ? ( +
+ Update the environment server to browse issues. +
+ ) : ( + Loading issues… + } + > + + + )} +
+
+ )} + + {mountedViews.has("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 4d2f487bb0fc..2a06c1d3341a 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -101,6 +101,7 @@ function renderTabs( onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} + onAddRepository={() => undefined} onAddIssue={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} @@ -111,6 +112,7 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} + repositoryAvailable={false} issueAvailable={false} agentsAvailable={false} > @@ -140,6 +142,48 @@ 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("offers the combined Repository surface independently of Git History", () => { + const html = renderToStaticMarkup( + undefined} + onCloseSurface={() => undefined} + onCloseOtherSurfaces={() => undefined} + onCloseSurfacesToRight={() => undefined} + onCloseAllSurfaces={() => undefined} + onCopyFilePath={() => undefined} + onAddBrowser={() => 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(html).toContain(">Repository"); + expect(html).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 69595391a061..a49991001317 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import { CircleDot, FileDiff, Files, + GitGraph, GitPullRequest, Globe2, Plus, @@ -79,6 +80,7 @@ interface RightPanelTabsProps { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddRepository: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; @@ -91,6 +93,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + repositoryAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; agentsAvailable: boolean; @@ -122,6 +125,7 @@ const SURFACE_DISABLED_REASONS = { terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", + repository: "Repository views are unavailable for this project.", pullRequest: "This thread's branch has no pull request yet.", issue: "Issues are only available from a project checked out from a host.", agents: "Agents are only available from a thread.", @@ -259,6 +263,7 @@ function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddRepository: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddIssue: () => void; @@ -266,6 +271,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + repositoryAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; issueAvailable: boolean; @@ -316,6 +322,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddDiff, badgeCount: 0, }, + { + label: "Repository", + description: "Browse history, issues, and pull requests.", + icon: GitGraph, + shortcut: "G", + available: props.repositoryAvailable, + disabledReason: SURFACE_DISABLED_REASONS.repository, + onClick: props.onAddRepository, + badgeCount: 0, + }, { label: "Pull request", description: "Open this branch's pull request.", @@ -523,6 +539,8 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; + case "git-history": + return "Repository"; case "files": return "Files"; case "file": @@ -598,6 +616,8 @@ function SurfaceIcon({ } case "diff": return ; + case "git-history": + return ; case "files": return ; case "file": @@ -689,6 +709,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, + { + label: "Repository", + icon: GitGraph, + shortcut: "G", + available: props.repositoryAvailable, + disabledReason: SURFACE_DISABLED_REASONS.repository, + onClick: props.onAddRepository, + }, { label: "Pull request", icon: GitPullRequest, @@ -1006,6 +1034,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onAddBrowser={props.onAddBrowser} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} + onAddRepository={props.onAddRepository} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddIssue={props.onAddIssue} @@ -1013,6 +1042,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..e9c7dbe5a45a --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx @@ -0,0 +1,118 @@ +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; + diff: string | null; + truncated: boolean; + isPending: boolean; + error: string | null; + onBack: () => void; + onSelectFile: (filePath?: string) => void; + onRetry: () => 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.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..bff305ad86ea --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitList.tsx @@ -0,0 +1,408 @@ +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 { + MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW, + 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.edges.slice(0, MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW); + 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..d75b3206804a --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx @@ -0,0 +1,51 @@ +import { GripVerticalIcon } from "lucide-react"; +import { useRef } from "react"; + +export function PaneResizeHandle(props: { + label: string; + value: number; + min: number; + max: number; + onMove: (delta: number) => void; + onReset: () => void; +}) { + const lastClientX = useRef(null); + return ( +
{ + lastClientX.current = event.clientX; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (lastClientX.current === null) return; + const delta = event.clientX - lastClientX.current; + lastClientX.current = event.clientX; + props.onMove(delta); + }} + onPointerUp={(event) => { + lastClientX.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onLostPointerCapture={() => { + lastClientX.current = null; + }} + 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..aea48094079b --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts @@ -0,0 +1,54 @@ +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: [], + 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: [], + 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"); + }); +}); 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..058c4b0097c3 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx @@ -0,0 +1,450 @@ +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"; +import type { RefTreeProps } from "./GitHistoryVisualTypes"; + +type RefPaneRow = + | { readonly kind: "all"; readonly key: "all" } + | { readonly kind: "current"; readonly key: "current" } + | { + 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: RefTreeProps["namespace"]; + readonly depth: number; + } + | { 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: RefTreeProps["namespace"], + 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 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" }, + ]; + 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; + remoteRefTree: ReadonlyArray; + tagRefTree: ReadonlyArray; + expandedRefKeys: ReadonlySet; + onToggleRefKey: (key: string) => void; + sharedRefTreeProps: Omit; + hasMoreRefs: boolean; + isFetchingMoreRefs: boolean; + isRefSnapshotComplete: boolean; + onLoadMoreRefs: () => void; + refPaginationError: string | null; + onRetryRefs: () => void; + onClose?: () => void; +}) { + const rows = useMemo( + () => + buildRefPaneRows({ + localRefTree: props.localRefTree, + remoteRefTree: props.remoteRefTree, + tagRefTree: props.tagRefTree, + expandedRefKeys: props.expandedRefKeys, + filterActive: props.normalizedRefFilter.length > 0, + hasMoreRefs: props.hasMoreRefs, + refPaginationError: props.refPaginationError, + }), + [ + props.expandedRefKeys, + 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..444555b01b34 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryVisualTypes.ts @@ -0,0 +1,23 @@ +import type { GitHistoryCommit } from "@t3tools/contracts"; + +import type { GitHistoryGraphRow } from "../../lib/gitHistoryGraph"; +import type { GitRefTreeNode } from "../../lib/gitRefTree"; + +export interface RefTreeProps { + nodes: ReadonlyArray; + namespace: "heads" | "remotes" | "tags"; + section: string; + depth?: number; + filterActive: boolean; + expanded: ReadonlySet; + selectedRevision: string | null; + onToggle: (key: string) => void; + onSelect: (label: string, revision: string) => void; +} + +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..b13a5747408d --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -0,0 +1,224 @@ +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, + debouncedRefFilter: "", + local: [] as ReadonlyArray, + remote: [] as ReadonlyArray, + tags: [] as ReadonlyArray, + isComplete: true, + refreshLocal: vi.fn(), + refreshRemote: vi.fn(), + refreshTags: vi.fn(), + 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, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +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; + return { + data: + options.namespace === "local" && !refState.currentRefResolved + ? null + : { currentRef: refState.currentRef, nextCursor: null, isComplete: refState.isComplete }, + refs, + error: null, + isFetchingNextPage: false, + loadNext: vi.fn(), + refresh: + options.namespace === "local" + ? refState.refreshLocal + : options.namespace === "remote" + ? refState.refreshRemote + : refState.refreshTags, + retry: vi.fn(), + }; + }, +})); + +import { GitRefsPane } from "./GitHistoryRefsPane"; +import { useGitHistoryRefs } from "./useGitHistoryRefs"; + +const environmentId = EnvironmentId.make("environment-local"); + +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, "C:/workspace", 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, + remoteRefTree: historyRefs.remoteRefTree, + tagRefTree: historyRefs.tagRefTree, + expandedRefKeys: historyRefs.expandedRefKeys, + onToggleRefKey: historyRefs.toggleRefKey, + sharedRefTreeProps: { + filterActive: false, + expanded: historyRefs.expandedRefKeys, + selectedRevision: historyRefs.selectedRevision?.revision ?? null, + onToggle: historyRefs.toggleRefKey, + onSelect: historyRefs.selectRef, + }, + 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.debouncedRefFilter = ""; + refState.local = Array.from({ length: 5_000 }, (_, index) => ref(`feature-${index}`)); + refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, true)); + refState.tags = []; + refState.isComplete = true; + refState.refreshLocal.mockReset(); + refState.refreshRemote.mockReset(); + refState.refreshTags.mockReset(); + refState.targets = []; + }); + + it("preserves ref trees and the 10k-row virtual-list model across unchanged rerenders", () => { + 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.isComplete = false; + + const rendered = renderRefs(); + + expect(rendered.capStatus?.props.children).toBe("Showing the first 10,000 matching refs."); + }); + + it("refreshes local refs without invalidating the other ref namespaces", () => { + const rendered = renderRefs(); + + rendered.historyRefs.refreshRefs(); + + expect(refState.refreshLocal).toHaveBeenCalledOnce(); + expect(refState.refreshRemote).not.toHaveBeenCalled(); + expect(refState.refreshTags).not.toHaveBeenCalled(); + }); + + 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("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("passes a repository revision to every history ref namespace", () => { + renderRefs(3); + + expect(refState.targets).toEqual([ + { + namespace: "local", + revision: 3, + target: { environmentId, cwd: "C:/workspace", 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..7794f21f2384 --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -0,0 +1,149 @@ +import type { EnvironmentId, VcsHistoryRef } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { buildGitRefTree, filterGitRefTree } from "../../lib/gitRefTree"; +import { useDebouncedValue, usePaginatedHistoryRefs } from "../../state/queries"; + +const REF_FILTER_DEBOUNCE_MS = 200; + +export interface GitHistoryRevision { + readonly label: string; + readonly revision: string; +} + +export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, revision: number) { + const [refFilter, setRefFilter] = useState(""); + const [selectedRevisionState, setSelectedRevision] = useState< + GitHistoryRevision | null | undefined + >(undefined); + const [expandedRefKeys, setExpandedRefKeys] = useState>( + () => new Set(["section:local"]), + ); + const deferredRefFilter = useDebouncedValue(refFilter.trim(), REF_FILTER_DEBOUNCE_MS); + const normalizedRefFilter = refFilter.trim().toLocaleLowerCase(); + const shouldLoadLocal = deferredRefFilter.length > 0 || expandedRefKeys.has("section:local"); + const shouldLoadRemote = deferredRefFilter.length > 0 || expandedRefKeys.has("section:remote"); + const shouldLoadTags = deferredRefFilter.length > 0 || expandedRefKeys.has("section:tags"); + const refs = usePaginatedHistoryRefs( + shouldLoadLocal + ? { environmentId, cwd, query: deferredRefFilter } + : { environmentId: null, cwd: null }, + { 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 remoteRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(remoteRefs), normalizedRefFilter), + [normalizedRefFilter, remoteRefs], + ); + const tagRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(tagRefs), normalizedRefFilter), + [normalizedRefFilter, tagRefs], + ); + const currentRef = refs.data?.currentRef; + const selectedRevision = useMemo( + () => + selectedRevisionState === undefined + ? currentRef === undefined + ? undefined + : currentRef === null + ? null + : { label: currentRef.name, revision: `refs/heads/${currentRef.name}` } + : selectedRevisionState, + [currentRef, selectedRevisionState], + ); + 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({ label, revision }); + }, []); + const selectAllRefs = useCallback(() => { + setSelectedRevision(null); + }, []); + + useEffect(() => { + setSelectedRevision(undefined); + setRefFilter(""); + setExpandedRefKeys(new Set(["section:local"])); + }, [cwd, environmentId]); + + return { + currentRef, + expandedRefKeys, + hasMoreRefs: + (shouldLoadLocal && 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: + (shouldLoadLocal && refs.isFetchingNextPage) || + (shouldLoadRemote && remote.isFetchingNextPage) || + (shouldLoadTags && tags.isFetchingNextPage), + isRefSnapshotComplete: + (!shouldLoadLocal || refs.data?.isComplete !== false) && + (!shouldLoadRemote || remote.data?.isComplete !== false) && + (!shouldLoadTags || tags.data?.isComplete !== false), + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs: () => { + if (shouldLoadLocal) refs.loadNext(); + if (shouldLoadRemote) remote.loadNext(); + if (shouldLoadTags) tags.loadNext(); + }, + refreshRefs: () => { + refs.refresh(); + }, + onRetryRefs: () => { + if (shouldLoadLocal && refs.error) refs.retry(); + if (shouldLoadRemote && remote.error) remote.retry(); + if (shouldLoadTags && tags.error) tags.retry(); + }, + refPaginationError: + (shouldLoadLocal ? refs.error : null) ?? + (shouldLoadRemote ? remote.error : null) ?? + (shouldLoadTags ? tags.error : null), + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs, + selectRef, + selectedRevision, + setRefFilter, + tagRefTree, + tagRefs, + toggleRefKey, + }; +} diff --git a/apps/web/src/components/issue/IssuesPanel.test.tsx b/apps/web/src/components/issue/IssuesPanel.test.tsx new file mode 100644 index 000000000000..bc160949b285 --- /dev/null +++ b/apps/web/src/components/issue/IssuesPanel.test.tsx @@ -0,0 +1,108 @@ +import { ProjectId, type IssueListEntry } from "@t3tools/contracts"; +import type { ReactElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + 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 }; +}); + +const matchingIssue = { + provider: "github", + host: "github.com", + projectId: ProjectId.make("project-1"), + projectTitle: "T3 Code", + repository: "pingdotgg/t3code", + number: 7606, + title: "[Bug]: Pull request screens are missing pointer cursors on clickable elements", + url: "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/pingdotgg/t3code/issues/7606", + author: { login: "octocat", name: null, avatarUrl: null }, + state: "open", + stateReason: null, + createdAt: "2026-08-20T00:00:00Z", + updatedAt: "2026-08-20T00:00:00Z", + closedAt: null, + assignees: [], + labels: [], + milestone: null, + commentCount: 0, +} as IssueListEntry; + +vi.mock("~/state/issues", () => ({ + issueEnvironment: { + list: ({ input }: { input: { readonly query?: string } }) => + input.query === undefined ? "baseline" : "searched", + }, +})); +vi.mock("~/state/queries", () => ({ + useDebouncedValue: (value: string) => value, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (query: "baseline" | "searched") => ({ + data: + query === "baseline" + ? { + viewers: {}, + providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], + entries: [matchingIssue], + errors: [], + truncated: false, + nextCursors: {}, + } + : { + viewers: {}, + providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + }, + error: null, + isPending: false, + refresh: () => undefined, + }), +})); + +import { IssuesPanel } from "./IssuesPanel"; + +function renderSearch(query: string): string { + hooks.reset(); + hooks.beginRender(); + const project = IssuesPanel({ + environmentId: "environment-1" as never, + projectId: "project-1" as never, + selected: null, + onSelect: () => undefined, + handoffTarget: { kind: "new-thread" }, + onStateChange: () => undefined, + }) as ReactElement; + const list = (project.type as (props: unknown) => ReactElement)(project.props) as ReactElement<{ + readonly query: string; + }>; + + hooks.reset(); + hooks.beginRender(); + const browser = (list.type as (props: unknown) => ReactElement)({ ...list.props, query }); + return renderToStaticMarkup(browser); +} + +describe("IssuesPanel", () => { + it("keeps a title match when a locally searched host returns no remote matches", () => { + expect(renderSearch("pointer cursors")).toContain(matchingIssue.title); + }); +}); diff --git a/apps/web/src/components/issue/IssuesPanel.tsx b/apps/web/src/components/issue/IssuesPanel.tsx index 62449a7daecd..265eced63468 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, @@ -33,6 +34,7 @@ import { ListGhost } from "../sourceControl/ListGhosts"; import { filterIssuesByInvolvement, issueEntryKey, + matchesIssueQuery, rankIssueMatches, type IssueViewers, } from "./issueList.logic"; @@ -94,6 +96,7 @@ interface IssuesPanelProps { onSelect: (target: NonNullable | null) => void; handoffTarget: IssueHandoffTarget; onStateChange: (status: IssueTabStatus) => void; + onOpenLinkedPullRequest?: (link: IssueLinkedPullRequest) => void; } /** @@ -116,11 +119,13 @@ 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. const [query, setQuery] = useState(""); const [page, setPage] = useState({ key: "", size: PAGE_SIZE, cursors: null }); + const [refreshPending, setRefreshPending] = useState(false); const [filters, setFilters] = useState<{ readonly state: IssueListState; readonly involvement: IssueInvolvement; @@ -156,7 +161,9 @@ function ProjectIssues({ number: selected.number, }} handoffTarget={handoffTarget} + onActed={() => setRefreshPending(true)} 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" @@ -176,6 +183,8 @@ function ProjectIssues({ onPage={setPage} filters={filters} onFilters={setFilters} + refreshPending={refreshPending} + onRefreshConsumed={() => setRefreshPending(false)} /> ); } @@ -190,6 +199,8 @@ function IssueBrowserList({ onPage, filters, onFilters, + refreshPending, + onRefreshConsumed, }: { environmentId: EnvironmentId; projectId: ProjectId; @@ -200,6 +211,8 @@ function IssueBrowserList({ onPage: (page: PanelPage) => void; filters: PanelFilters; onFilters: (filters: PanelFilters) => void; + refreshPending: boolean; + onRefreshConsumed: () => void; }) { const typed = query.trim().slice(0, MAX_QUERY_LENGTH); // Searching asks the host, which takes a round trip, so the text is held for a moment before it @@ -235,6 +248,20 @@ function IssueBrowserList({ }), ); const answered = listQuery.data; + const baselineQuery = useEnvironmentQuery( + issueEnvironment.list({ + environmentId, + input: { + state: filters.state, + involvement: filters.involvement, + projectId, + limit: PAGE_SIZE, + sort: filters.sort, + order: filters.order, + }, + }), + ); + const baseline = baselineQuery.data; const githubSortingAvailable = answered?.providers.some((provider) => provider.kind === "github") ?? false; @@ -287,15 +314,29 @@ function IssueBrowserList({ const entries = useMemo(() => { const shown = ordered?.key === filterKey ? ordered : null; const held = shown?.entries ?? answered?.entries ?? []; + const searchingHosts = new Set( + (baseline?.providers ?? answered?.providers ?? []) + .filter((provider) => provider.searchesOnHost) + .map((provider) => provider.host), + ); + const queried = + typed.length === 0 + ? held + : [ + ...held.filter((entry) => searchingHosts.has(entry.host)), + ...(baseline?.entries ?? held).filter( + (entry) => !searchingHosts.has(entry.host) && matchesIssueQuery(entry, typed), + ), + ]; const byInvolvement = filterIssuesByInvolvement( - held, - shown?.viewers ?? answered?.viewers ?? {}, + queried, + shown?.viewers ?? answered?.viewers ?? baseline?.viewers ?? {}, filters.involvement, ); return filters.label === undefined ? byInvolvement : byInvolvement.filter((entry) => entry.labels.some((label) => label.name === filters.label)); - }, [answered, filterKey, filters.involvement, filters.label, ordered]); + }, [answered, baseline, filterKey, filters.involvement, filters.label, ordered, typed]); /** From what is held rather than from the read in flight, which has not answered yet. */ const truncated = ordered?.key === filterKey ? ordered.truncated : (answered?.truncated ?? false); @@ -312,6 +353,30 @@ function IssueBrowserList({ ); }; + 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(); + onRefreshConsumed(); + }, [onRefreshConsumed, refreshList, refreshPending]); + const sentinelRef = useRef(null); useEffect(() => { const sentinel = sentinelRef.current; diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index 8070af60c040..bf57b261ceba 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -51,33 +51,37 @@ function ChecksBody({ checks }: { checks: ReadonlyArray }) { if (checks.length === 0) { return

No checks reported

; } + const keyOccurrences = new Map(); return (
    - {/* Keyed by position as well as by name: the host is the one that decides how many runs - share a name, and a repeated key is a rendering fault rather than a wrong list. */} - {checks.map((check, index) => ( -
  • - - - {check.name}} - /> - {check.description ?? check.name} - - - {pullRequestCheckStatusLabel(check.status)} - - {check.url === null ? null : ( - - )} -
  • - ))} + {checks.map((check) => { + const baseKey = check.url ?? `${check.name}:${check.status}:${check.description ?? ""}`; + const occurrence = keyOccurrences.get(baseKey) ?? 0; + keyOccurrences.set(baseKey, occurrence + 1); + return ( +
  • + + + {check.name}} + /> + {check.description ?? check.name} + + + {pullRequestCheckStatusLabel(check.status)} + + {check.url === null ? null : ( + + )} +
  • + ); + })}
); } @@ -112,6 +116,7 @@ export function PullRequestChecksPopover({ not valid inside one. The click is stopped here so opening the checks does not also select the row it sits on. */} >; @@ -126,6 +127,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 filtered = state !== "open" || @@ -199,80 +202,88 @@ export function PullRequestFiltersMenu({ /> ) : null} - - { - if (next === ALL_PROJECTS_VALUE) { - if (projectId !== undefined) onProject(undefined, undefined); - return; - } - // The value carries both halves, since the id alone cannot tell two servers' rows - // apart once they share one. - const project = projects.find((candidate) => pullRequestProjectKey(candidate) === next); - if ( - project !== undefined && - (project.id !== projectId || project.environmentId !== projectEnvironmentId) - ) { - onProject(project.id, project.environmentId); - } - }} - > - Project - - - - All projects - - - {/* The ones that can be chosen first: a list that opens with three disabled rows reads + {showProjectScope ? ( + <> + + { + if (next === ALL_PROJECTS_VALUE) { + if (projectId !== undefined) onProject(undefined, undefined); + return; + } + // The value carries both halves, since the id alone cannot tell two servers' rows + // apart once they share one. + const project = projects.find( + (candidate) => pullRequestProjectKey(candidate) === next, + ); + if ( + project !== undefined && + (project.id !== projectId || project.environmentId !== projectEnvironmentId) + ) { + onProject(project.id, project.environmentId); + } + }} + > + Project + + + + All projects + + + {/* The ones that can be chosen first: a list that opens with three disabled rows reads as a broken menu rather than as a workspace with three unreadable repositories. */} - {projects - .toSorted( - (left, right) => - Number(unavailable.has(pullRequestProjectKey(left))) - - Number(unavailable.has(pullRequestProjectKey(right))), - ) - .map((project) => { - const reason = unavailable.get(pullRequestProjectKey(project)); - const item = ( - - - - {project.title} - {reason === undefined ? null : ( - - Unavailable + {projects + .toSorted( + (left, right) => + Number(unavailable.has(pullRequestProjectKey(left))) - + Number(unavailable.has(pullRequestProjectKey(right))), + ) + .map((project) => { + const reason = unavailable.get(pullRequestProjectKey(project)); + const item = ( + + + + {project.title} + {reason === undefined ? null : ( + + Unavailable + + )} - )} - - - ); - if (reason === undefined) return item; - return ( - - - - {reason} - - - ); - })} - + + ); + if (reason === undefined) return item; + return ( + + + + {reason} + + + ); + })} + + + ) : 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..17bba9bc4b59 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.test.tsx @@ -0,0 +1,447 @@ +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, +})); + +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, + }, + usePullRequestListStats: (targets: ReadonlyArray) => { + stats.targets.push(targets); + return { stats: stats.value, refresh: stats.refresh }; + }, +})); + +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..8b3fbadbbc92 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx @@ -0,0 +1,578 @@ +import type { + EnvironmentId, + IssueLink, + ProjectId, + PullRequestInvolvement, + PullRequestListCursors, + PullRequestListEntry, + PullRequestListFilters, + PullRequestListState, + PullRequestState, + ScopedThreadRef, +} from "@t3tools/contracts"; +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 { pullRequestEnvironment, usePullRequestListStats } 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 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; +} + +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 = usePullRequestListStats(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/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index 6c2709d677bb..6e97edb4f14d 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts"; -import { Children, isValidElement, type ReactNode } from "react"; +import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; @@ -8,6 +8,14 @@ import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { PullRequestRow } from "./PullRequestRow"; import { pullRequestChecksState } from "./pullRequestPresentation"; +function checksPopoverTrigger(): ReactElement<{ nativeButton?: boolean }> | undefined { + const popover = PullRequestChecksPopover({ checksState: "failing" }); + return Children.toArray(popover.props.children).find( + (element): element is ReactElement<{ nativeButton?: boolean }> => + isValidElement<{ nativeButton?: boolean }>(element) && "nativeButton" in element.props, + ); +} + function check(status: PullRequestCheck["status"]): PullRequestCheck { return { name: `check-${status}`, status, description: null, url: null }; } @@ -88,4 +96,8 @@ describe("PullRequestRow checks indicator", () => { it("uses the shared source control row frame", () => { expect((row({}) as { type: unknown }).type).toBe(ListRow); }); + + it("disables native button semantics for its span trigger inside a row button", () => { + expect(checksPopoverTrigger()?.props.nativeButton).toBe(false); + }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 0145e6180331..37e2b239aa52 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, @@ -14,6 +15,9 @@ import { mergePullRequestDiffStats, narrowPullRequestsToFilters, partitionPullRequestsWithPriority, + pullRequestStatsTargets, + pullRequestVisibleEntries, + prunePullRequestDiffStats, readPullRequestListSnapshot, writePullRequestListSnapshot, rankPullRequestMatches, @@ -32,10 +36,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", @@ -477,6 +481,70 @@ 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 }] }, + }, + ]); + }); + + it("includes priority rows rendered outside the feed", () => { + const visible = pullRequestVisibleEntries([ + { key: "others", label: "Others", entries: [entry({ number: 1 })] }, + { key: "authored", label: "Authored", entries: [entry({ number: 2 })] }, + ]); + + expect(pullRequestStatsTargets(visible)[0]?.input.refs.map((ref) => ref.number)).toEqual([ + 1, 2, + ]); + }); }); describe("merging line counts across keyed stats queries", () => { @@ -510,6 +578,37 @@ describe("merging line counts across keyed stats queries", () => { ]); expect(held.get("env-1 project-1 1")).toEqual({ additions: 1, deletions: 1 }); }); + + it("keeps the same map when fresh results repeat every stored count", () => { + const held = new Map([["env-1 project-1 1", { additions: 1, deletions: 1 }]]); + + expect( + mergePullRequestDiffStats(held, [ + { environmentId: "env-1", projectId: "project-1", number: 1, additions: 1, deletions: 1 }, + ]), + ).toBe(held); + }); +}); + +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", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 9a249bd0bd99..8af1ffed6484 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -12,6 +12,7 @@ import type { PullRequestInvolvement, PullRequestListCursors, PullRequestListFilters, + PullRequestListStatsInput, PullRequestListState, } from "@t3tools/contracts"; @@ -311,8 +312,8 @@ export function matchesPullRequestFilters( filters: PullRequestListFilters, viewer?: string | null, ): boolean { - const labels = entry.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(entry.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) && (filters.review === undefined || @@ -431,11 +432,15 @@ export function mergePullRequestDiffStats( }>, ): PullRequestDiffStats { if (stats.length === 0) return previous; - const next = new Map(previous); + let next: Map | undefined; for (const stat of stats) { - next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); + const key = diffStatKey(stat); + const existing = (next ?? previous).get(key); + if (existing?.additions === stat.additions && existing.deletions === stat.deletions) continue; + next ??= new Map(previous); + next.set(key, { additions: stat.additions, deletions: stat.deletions }); } - return next; + return next ?? previous; } /** A project id only names a project within its own environment, so the key carries both. */ @@ -444,6 +449,57 @@ const diffStatKey = (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(diffStatKey)); + if ([...previous.keys()].every((key) => visible.has(key))) return previous; + return new Map([...previous].filter(([key]) => visible.has(key))); +} + +/** Returns the raw rows rendered across every priority group. */ +export function pullRequestVisibleEntries( + groups: ReadonlyArray>, +): ReadonlyArray { + return groups.flatMap((group) => group.entries); +} + +/** 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 = diffStatKey(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. @@ -771,3 +827,25 @@ export function withDiffStat< const stat = statsByRow.get(diffStatKey(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/components/sidebar/SidebarChrome.test.tsx b/apps/web/src/components/sidebar/SidebarChrome.test.tsx new file mode 100644 index 000000000000..a0d2a4281c35 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarChrome.test.tsx @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { visitElements } from "../../test/reactElementTree"; + +const navigation = vi.hoisted(() => vi.fn<(options: unknown) => void>()); +const rightPanel = vi.hoisted(() => ({ openRepository: vi.fn() })); +const routeState = vi.hoisted(() => ({ + params: { environmentId: "environment-1", threadId: "thread-1" } as Record, + draftSession: null as { + environmentId: string; + threadId: string; + projectId: string; + promotedTo: null; + } | null, + project: { repositoryIdentity: { canonicalKey: "github:acme/repository" } as object | null }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useMemo: reactHookHarness.useMemo, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("@tanstack/react-router", () => ({ + Link: "a", + useLocation: (options: { select: (location: { pathname: string }) => unknown }) => + options.select({ pathname: "/" }), + useNavigate: () => navigation, + useParams: (options: { select: (params: Record) => unknown }) => + options.select(routeState.params), +})); + +vi.mock("../../composerDraftStore", () => ({ + useComposerDraftStore: ( + select: (store: { getDraftSession: () => typeof routeState.draftSession }) => unknown, + ) => select({ getDraftSession: () => routeState.draftSession }), +})); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ + environments: [ + { + environmentId: "environment-1", + serverConfig: { environment: { capabilities: { issues: true, pullRequests: true } } }, + }, + ], + }), +})); +vi.mock("../../state/entities", () => ({ + useProjects: () => [{ environmentId: "environment-1", id: "project-1", ...routeState.project }], + useThreadShell: () => + routeState.params.threadId === "thread-1" + ? { environmentId: "environment-1", projectId: "project-1" } + : null, +})); +vi.mock("../../rightPanelStore", () => ({ + useRightPanelStore: { getState: () => rightPanel }, +})); +vi.mock("../ui/sidebar", () => ({ + SidebarFooter: "footer", + SidebarHeader: "header", + SidebarMenu: "menu", + SidebarMenuButton: "button", + SidebarMenuItem: "item", + SidebarTrigger: "button", + useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }), +})); +vi.mock("../ui/tooltip", () => ({ + Tooltip: "tooltip", + TooltipPopup: "tooltip-popup", + TooltipTrigger: "tooltip-trigger", +})); + +import { SidebarChromeFooter } from "./SidebarChrome"; + +function footerAction(label: "Issues" | "Pull Requests"): () => void { + hooks.beginRender(); + const footer = SidebarChromeFooter.type(); + const button = visitElements(footer, (element) => element.props["aria-label"] === label); + if (button === null || typeof button.props.onClick !== "function") { + throw new Error(`Could not find the ${label} footer action.`); + } + return button.props.onClick as () => void; +} + +describe("SidebarChromeFooter repository navigation", () => { + beforeEach(() => { + hooks.reset(); + navigation.mockReset(); + rightPanel.openRepository.mockReset(); + routeState.params = { environmentId: "environment-1", threadId: "thread-1" }; + routeState.draftSession = null; + routeState.project = { repositoryIdentity: { canonicalKey: "github:acme/repository" } }; + }); + + it("opens the active repository's pull requests in the repository pane", () => { + footerAction("Pull Requests")(); + + expect(rightPanel.openRepository).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-1", threadId: "thread-1" }), + "pull-requests", + ); + expect(navigation).not.toHaveBeenCalled(); + }); + + it("falls back to global issues when the active project has no repository identity", () => { + routeState.project = { repositoryIdentity: null }; + + footerAction("Issues")(); + + expect(rightPanel.openRepository).not.toHaveBeenCalled(); + expect(navigation).toHaveBeenCalledWith({ + to: "/issues", + search: { involvement: "all", state: "open" }, + }); + }); + + it("opens repository actions for an unpromoted draft in its project", () => { + routeState.params = { draftId: "draft-1" }; + routeState.draftSession = { + environmentId: "environment-1", + threadId: "draft-thread-1", + projectId: "project-1", + promotedTo: null, + }; + + footerAction("Issues")(); + footerAction("Pull Requests")(); + + expect(rightPanel.openRepository).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-1", threadId: "draft-thread-1" }), + "issues", + ); + expect(rightPanel.openRepository).toHaveBeenCalledWith( + expect.objectContaining({ environmentId: "environment-1", threadId: "draft-thread-1" }), + "pull-requests", + ); + expect(navigation).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 05a02b37f53d..ab4d19f9b212 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -6,12 +6,17 @@ import { SettingsIcon, } from "lucide-react"; import type { ReactNode } from "react"; -import { memo, useCallback } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; +import { memo, useCallback, useMemo } from "react"; +import { Link, useCanGoBack, useLocation, useNavigate, useParams } from "@tanstack/react-router"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; +import { useComposerDraftStore } from "../../composerDraftStore"; import { cn } from "../../lib/utils"; import { useEnvironments } from "../../state/environments"; +import { useProjects, useThreadShell } from "../../state/entities"; +import { useRightPanelStore } from "../../rightPanelStore"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../../threadRoutes"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, @@ -161,6 +166,19 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { : null, }); const { environments } = useEnvironments(); + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + const routeDraftThread = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, + ); + const routeThreadRef = useMemo( + () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), + [routeDraftThread, routeTarget], + ); + const routeThread = useThreadShell(routeThreadRef); + const projects = useProjects(); // The pages read every connected server, so one of them offering a surface is enough for // its link to lead somewhere. const pullRequestsSupported = environments.some( @@ -169,6 +187,53 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const issuesSupported = environments.some( (environment) => environment.serverConfig?.environment.capabilities.issues === true, ); + const routeRepositoryContext = useMemo(() => { + if (routeThreadRef !== null && routeThread !== null) { + return { + environmentId: routeThread.environmentId, + projectId: routeThread.projectId, + threadRef: routeThreadRef, + }; + } + if ( + routeTarget?.kind === "draft" && + routeDraftThread !== null && + !routeDraftThread.promotedTo + ) { + return { + environmentId: routeDraftThread.environmentId, + projectId: routeDraftThread.projectId, + threadRef: scopeThreadRef(routeDraftThread.environmentId, routeDraftThread.threadId), + }; + } + return null; + }, [routeDraftThread, routeTarget, routeThread, routeThreadRef]); + const routeEnvironment = useMemo( + () => + environments.find( + (environment) => environment.environmentId === routeRepositoryContext?.environmentId, + ), + [environments, routeRepositoryContext?.environmentId], + ); + const routeProject = useMemo( + () => + routeRepositoryContext === null + ? null + : (projects.find( + (project) => + project.environmentId === routeRepositoryContext.environmentId && + project.id === routeRepositoryContext.projectId, + ) ?? null), + [projects, routeRepositoryContext], + ); + const routeHasRepositoryIdentity = + routeProject !== null && routeProject.repositoryIdentity !== null; + const routePullRequestsSupported = + routeHasRepositoryIdentity && + routeEnvironment?.serverConfig?.environment.capabilities.pullRequests === true; + const routeIssuesSupported = + routeHasRepositoryIdentity && + routeEnvironment?.serverConfig?.environment.capabilities.issues === true; const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -176,12 +241,22 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { }, [isMobile, setOpenMobile]); const handlePullRequestsClick = useCallback(() => { closeMobileSidebar(); + if (routeRepositoryContext !== null && routePullRequestsSupported) { + useRightPanelStore + .getState() + .openRepository(routeRepositoryContext.threadRef, "pull-requests"); + return; + } void navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); - }, [closeMobileSidebar, navigate]); + }, [closeMobileSidebar, navigate, routePullRequestsSupported, routeRepositoryContext]); const handleIssuesClick = useCallback(() => { closeMobileSidebar(); + if (routeRepositoryContext !== null && routeIssuesSupported) { + useRightPanelStore.getState().openRepository(routeRepositoryContext.threadRef, "issues"); + return; + } void navigate({ to: "/issues", search: { involvement: "all", state: "open" } }); - }, [closeMobileSidebar, navigate]); + }, [closeMobileSidebar, navigate, routeIssuesSupported, routeRepositoryContext]); const handleSettingsClick = useCallback(() => { closeMobileSidebar(); void navigate({ to: "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/settings" }); 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..63b47020c469 --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.test.ts @@ -0,0 +1,325 @@ +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("never moves a continuation lane sideways without a commit", () => { + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + const continuations = layout.rows.flatMap((row) => + row.edges.filter((edge) => edge.kind === "continuation"), + ); + expect(continuations.every((edge) => edge.fromLane === edge.toLane)).toBe(true); + }); + + 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("does not create a branch edge from a continuation lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect( + layout.rows.flatMap((row) => + row.edges.filter((edge) => edge.kind === "parent").map((edge) => [row.lane, edge.fromLane]), + ), + ).toEqual([ + [0, 0], + [0, 0], + [0, 0], + [1, 1], + ]); + }); + + 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..ed6c49ebab73 --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.ts @@ -0,0 +1,234 @@ +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; + primary: boolean; + 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, + primary: isPrimary, + 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, + primary: parentIsPrimary, + 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 6f5efb621bfc..ebd81f350f81 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -243,6 +243,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"); @@ -273,6 +303,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 409bcc0857d4..0bcabab73f80 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -16,6 +16,7 @@ import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = [ "diff", + "git-history", "files", "file", "preview", @@ -27,6 +28,14 @@ export 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 } @@ -39,6 +48,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}`; @@ -85,7 +101,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" }; @@ -95,7 +111,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; /** * The pull-request list's shared panel (see PULL_REQUESTS_PANEL_ID in the route) is session @@ -135,6 +153,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, @@ -181,6 +208,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": @@ -284,8 +319,8 @@ export 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 ( @@ -446,7 +481,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, + ), }, ]; } @@ -555,6 +607,62 @@ export const useRightPanelStore = create()( upsertSurface(current, { id: "issues", kind: "issues", selected: null }), ), })), + openRepository: (ref, view) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, 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) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "git-history" ? { ...surface, view } : surface, + ), + })), + })), + selectRepositoryIssue: (ref, target) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + ...current, + surfaces: current.surfaces.map((surface) => + surface.kind === "git-history" + ? { ...surface, view: "issues", selectedIssue: target } + : surface, + ), + })), + })), + selectRepositoryPullRequest: (ref, target) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, 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) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index f01cff4b7cfe..c7217fba1bae 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -1440,6 +1440,7 @@ function IssuesRouteView() { onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} + onAddRepository={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} @@ -1447,6 +1448,7 @@ function IssuesRouteView() { browserAvailable={false} terminalAvailable={false} diffAvailable={false} + repositoryAvailable={false} filesAvailable={false} pullRequestAvailable={false} issueAvailable={false} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index ecb7cfc6fa59..ebd2681b5db4 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -28,6 +28,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { IssueDetailPanel } from "../components/issue/IssueDetailPanel"; import { filterPullRequestsByInvolvement, + decoratePullRequestEntries, findScopedProject, groupPullRequestsByInvolvement, matchesPullRequestFilters, @@ -38,13 +39,15 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, + pullRequestStatsTargets, + pullRequestVisibleEntries, + prunePullRequestDiffStats, rankPullRequestMatches, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, - withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, type EnvironmentPullRequestEntry, @@ -1122,10 +1125,12 @@ function PullRequestsRouteView() { * The line counts, asked for once the rows are on screen. On GitHub they are forty per cent of * the read that answers the whole page, for two small numbers at the end of a row — so the * listing leaves them out and they arrive a moment later, into rows that already draw without - * them. Keyed by the rows being shown, so scrolling further asks only about what is new. + * them. Keyed by the rows being shown, so a refresh cannot replay old filter or page batches. */ - const groups = useMemo(() => { - if (search.involvement !== "all") return [{ key: "others" as const, label: "", entries }]; + const visibleGroups = useMemo(() => { + if (search.involvement !== "all") { + return [{ key: "others" as const, label: "", entries }]; + } // Until both partitions have answered, the snapshot's stand in — they are yesterday's // groups, but whole ones, where grouping the feed's first page locally loses every // authored row older than it. Once the live reads land they take over; with neither, @@ -1166,41 +1171,36 @@ function PullRequestsRouteView() { search.involvement, viewers, ]); - - // Keyed by every row being shown — the partitions can hold rows the feed has not paged to — - // so scrolling further asks only about what is new. One read per environment, each asking only - // about its own rows: a reference names a project, and a project belongs to one machine. - const statsTargets = useMemo(() => { - const refsByEnvironment = new Map< - EnvironmentId, - Array<{ projectId: ProjectId; repository: string; number: number }> - >(); - for (const group of groups) { - for (const entry of group.entries) { - const refs = refsByEnvironment.get(entry.environmentId) ?? []; - refs.push({ - projectId: entry.projectId, - repository: entry.repository, - number: entry.number, - }); - refsByEnvironment.set(entry.environmentId, refs); - } - } - return [...refsByEnvironment].map(([environmentId, refs]) => ({ - environmentId, - input: { refs }, - })); - }, [groups]); - const statsQuery = usePullRequestListStats(statsTargets); - // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts - // are merged into what is already held rather than rebuilt: every count on screen stays until - // its replacement arrives. + const visibleEntries = useMemo(() => pullRequestVisibleEntries(visibleGroups), [visibleGroups]); const [statsByRow, setStatsByRow] = useState(() => new Map()); + const statsTargets = useMemo(() => pullRequestStatsTargets(visibleEntries), [visibleEntries]); + useEffect(() => { + setStatsByRow((previous) => prunePullRequestDiffStats(previous, visibleEntries)); + }, [visibleEntries]); + const statsQuery = usePullRequestListStats(statsTargets); useEffect(() => { const stats = statsQuery.stats; if (stats === null) return; setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); + const decorationCache = useRef< + ReadonlyMap + >(new Map()); + const decoration = useMemo( + () => decoratePullRequestEntries(visibleEntries, statsByRow, decorationCache.current), + [statsByRow, visibleEntries], + ); + useEffect(() => { + decorationCache.current = decoration.bySource; + }, [decoration]); + const groups = useMemo( + () => + visibleGroups.map((group) => ({ + ...group, + entries: group.entries.map((entry) => decoration.bySource.get(entry) ?? entry), + })), + [decoration.bySource, visibleGroups], + ); const linkedSelection = useMemo( () => @@ -1426,10 +1426,7 @@ function PullRequestsRouteView() { {group.entries.map((entry) => ( 1 && @@ -1643,6 +1640,7 @@ function PullRequestsRouteView() { onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} + onAddRepository={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} @@ -1650,6 +1648,7 @@ function PullRequestsRouteView() { browserAvailable={false} terminalAvailable={false} diffAvailable={false} + repositoryAvailable={false} filesAvailable={false} pullRequestAvailable={false} issueAvailable={false} @@ -1681,6 +1680,7 @@ function PullRequestsRouteView() { baselineQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); + statsQuery.refresh(); }} onStateChange={handleIssueTabStatusChange} onOpenLinkedPullRequest={(link) => { @@ -1724,6 +1724,7 @@ function PullRequestsRouteView() { baselineQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); + statsQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} // The issue a change request closes is read beside it, as a peer tab in this 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..13d450ded4cb --- /dev/null +++ b/apps/web/src/state/queries.test.tsx @@ -0,0 +1,301 @@ +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 queryGeneration: 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: ({ input }: { readonly input: PageAtom["input"] }) => { + const result = refsState.results.get(`${input.queryGeneration}:${input.cursor ?? "first"}`); + if (result === undefined) + throw new Error(`Missing result for ${input.queryGeneration}:${input.cursor ?? "first"}`); + const atom = { 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) { + hooks.beginRender(); + return usePaginatedHistoryRefs(target, { 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 a new page generation when refreshed", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("1:first", page(null)); + + const initial = render(); + initial.refresh(); + render(); + + expect(refsState.atoms.map((atom) => atom.input)).toEqual([ + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 0 }, + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 1, refresh: true }, + ]); + }); + + 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)).toEqual([ + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 0 }, + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 0 }, + { + cwd: "C:/workspace", + cursor: "cursor-4", + limit: 100, + namespace: "local", + queryGeneration: 0, + }, + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 1 }, + ]); + }); + + 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.input.queryGeneration)).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.input.queryGeneration)).toEqual([0, 1, 2, 3]); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..34326077e22d 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -9,21 +9,26 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; -import type { - EnvironmentId, - OrchestrationThread, - ProjectContentMatch, - ProjectEntryKind, - ThreadId, - VcsListRefsResult, - VcsRef, +import { + VcsSnapshotExpiredError, + type EnvironmentId, + type OrchestrationThread, + type ProjectContentMatch, + type ProjectEntryKind, + type ThreadId, + type VcsHistoryRef, + type VcsListHistoryRefsResult, + type VcsListRefsResult, + type 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"; @@ -38,9 +43,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, @@ -134,9 +141,13 @@ export function useBranches(target: VcsRefTarget) { 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; @@ -235,6 +246,167 @@ 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 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!, + input: { + cwd: target.cwd!, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit, + queryGeneration: queryGeneration + revision, + namespace, + ...(refreshGeneration > 0 ? { refresh: true } : {}), + }, + }), + ) + : [], + [ + cursors, + limit, + query, + queryGeneration, + namespace, + refreshGeneration, + 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); + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + }, [effectiveQueryGeneration, expiredPage, targetKey]); + 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); + setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); + }, [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 1b4b26fb6988..9ce65327054d 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -51,9 +51,24 @@ export 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 12a9df6e0e8d..13b4ea69c446 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -81,6 +81,19 @@ T3 Code works with the platforms your team already uses: - 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. + ### Know Your Setup at a Glance The **Source Control settings** page shows you exactly what's connected: diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 2404feb82b22..7140879a6b39 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -52,6 +52,7 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; + readonly revalidateOnReconnect?: (input: Input) => boolean; } interface EnvironmentSubscriptionAtomOptions { @@ -510,13 +511,36 @@ export function createEnvironmentQueryAtomFamily( const idleTtlMs = options.idleTtlMs ?? 5 * 60_000; const queryAtom = runtime .atom((get) => { - const generation = Option.getOrNull( - AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), - ); - if (generation === null) { - return Effect.never; + if (options.revalidateOnReconnect?.(target.input) ?? true) { + const generation = Option.getOrNull( + AsyncResult.value(get(rpcGenerationAtom(target.environmentId))), + ); + if (generation === null) { + return Effect.never; + } + return runInEnvironment(target.environmentId, options.execute(target.input)); } - return runInEnvironment(target.environmentId, options.execute(target.input)); + 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), + }), + ), + ), + ), + ), + ); }) .pipe( Atom.swr({ @@ -598,6 +622,7 @@ export function createEnvironmentRpcQueryAtomFamily) => boolean; }, ) { return createEnvironmentQueryAtomFamily(runtime, { @@ -607,6 +632,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 0a6264c62078..06c0603dbcc2 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"; @@ -114,13 +116,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({ @@ -131,6 +210,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); @@ -142,6 +224,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(); }); @@ -573,6 +658,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 042548336720..b019af90f421 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"; @@ -175,12 +180,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 @@ -265,13 +281,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", @@ -298,7 +342,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 b936246dc823..e20a0a989df0 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; @@ -649,8 +649,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, @@ -660,6 +662,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(() => @@ -671,6 +674,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 3a4324625a00..6e2b4d72dd9b 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -26,4 +26,17 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.pullRequests, ).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); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index d0a9fef30147..0d195cad1b61 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -54,6 +54,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 4ea86670ff8f..081408ef5000 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -7,6 +7,14 @@ import { GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, + VcsGetHistoryResult, + VcsGetCommitDiffInput, + VcsGetCommitDetailsInput, + VcsListCommitFilesResult, + VcsStatusResult, + VcsListHistoryRefsInput, + VcsListRefsInput, + VcsListRefsResult, } from "./git.ts"; const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); @@ -16,6 +24,142 @@ const decodePreparePullRequestThreadInput = 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 decodeStatusResult = Schema.decodeUnknownSync(VcsStatusResult); +const decodeGetHistoryResult = Schema.decodeUnknownSync(VcsGetHistoryResult); + +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("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 915c3627c9b9..9f4b2c71772f 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -4,7 +4,10 @@ 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 GitCommitHash = Schema.String.check(Schema.isPattern(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i)); // Domain Types @@ -83,6 +86,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, @@ -115,9 +132,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; @@ -134,6 +149,53 @@ 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), + queryGeneration: Schema.optional(NonNegativeInt), + 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(1024))), + cursor: Schema.optional(TrimmedNonEmptyStringSchema), + queryGeneration: Schema.optional(NonNegativeInt), + 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, + queryGeneration: Schema.optional(NonNegativeInt), +}); +export type VcsGetCommitDetailsInput = typeof VcsGetCommitDetailsInput.Type; + +export const VcsListCommitFilesInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + hash: GitCommitHash, + cursor: Schema.optional(TrimmedNonEmptyStringSchema), + queryGeneration: Schema.optional(NonNegativeInt), + 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), + queryGeneration: Schema.optional(NonNegativeInt), + ignoreWhitespace: Schema.optionalKey(Schema.Boolean), +}); +export type VcsGetCommitDiffInput = typeof VcsGetCommitDiffInput.Type; + export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, refName: TrimmedNonEmptyStringSchema, @@ -217,7 +279,7 @@ const VcsStatusLocalShape = { workingTree: Schema.Struct({ files: Schema.Array( Schema.Struct({ - path: TrimmedNonEmptyStringSchema, + path: GitFilePath, insertions: NonNegativeInt, deletions: NonNegativeInt, }), @@ -270,6 +332,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, }); @@ -352,6 +484,15 @@ export class GitCommandError extends Schema.TaggedErrorClass()( } } +export class VcsSnapshotExpiredError extends Schema.TaggedErrorClass()( + "VcsSnapshotExpiredError", + { operation: Schema.String, cursor: TrimmedNonEmptyStringSchema }, +) { + override get message(): string { + return `Git browsing snapshot expired for ${this.operation}.`; + } +} + export class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 61cc5e603711..a9b4d70daa23 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -24,13 +24,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, @@ -255,6 +266,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", @@ -922,6 +938,36 @@ export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsListHistoryRefsRpc = Rpc.make(WS_METHODS.vcsListHistoryRefs, { + payload: VcsListHistoryRefsInput, + success: VcsListHistoryRefsResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +export const WsVcsGetHistoryRpc = Rpc.make(WS_METHODS.vcsGetHistory, { + payload: VcsGetHistoryInput, + success: VcsGetHistoryResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +export const WsVcsGetCommitDetailsRpc = Rpc.make(WS_METHODS.vcsGetCommitDetails, { + payload: VcsGetCommitDetailsInput, + success: VcsGetCommitDetailsResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsListCommitFilesRpc = Rpc.make(WS_METHODS.vcsListCommitFiles, { + payload: VcsListCommitFilesInput, + success: VcsListCommitFilesResult, + error: Schema.Union([GitCommandError, VcsSnapshotExpiredError, EnvironmentAuthorizationError]), +}); + +export const WsVcsGetCommitDiffRpc = Rpc.make(WS_METHODS.vcsGetCommitDiff, { + payload: VcsGetCommitDiffInput, + success: VcsGetCommitDiffResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: VcsCreateWorktreeInput, success: VcsCreateWorktreeResult, @@ -1276,6 +1322,11 @@ export const WsRpcGroup = RpcGroup.make( WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, WsVcsListRefsRpc, + WsVcsListHistoryRefsRpc, + WsVcsGetHistoryRpc, + WsVcsGetCommitDetailsRpc, + WsVcsListCommitFilesRpc, + WsVcsGetCommitDiffRpc, WsVcsCreateWorktreeRpc, WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, From f3e9ffa8c752dd3dfeaa5db803b16f5a50989ef0 Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:41:28 +0200 Subject: [PATCH 2/5] fix(#6315): harden repository activity integration --- .../pullRequest/PullRequestService.test.ts | 25 + .../src/pullRequest/PullRequestService.ts | 10 +- apps/web/src/components/ChatView.tsx | 1 - .../src/components/GitHistoryPanel.test.tsx | 444 ++++++++++++- apps/web/src/components/GitHistoryPanel.tsx | 114 +++- .../git-history/GitHistoryCommitDiff.tsx | 24 + .../GitHistoryPaneResizeHandle.tsx | 2 +- .../git-history/useGitHistoryRefs.test.tsx | 95 ++- .../git-history/useGitHistoryRefs.ts | 75 ++- .../issue/IssueDetailPanel.label.test.tsx | 30 +- .../src/components/issue/IssueDetailPanel.tsx | 22 +- .../src/components/issue/IssuesPanel.test.tsx | 47 +- apps/web/src/components/issue/IssuesPanel.tsx | 21 +- .../pullRequest/PullRequestDetailPanel.tsx | 46 +- .../pullRequest/PullRequestsPanel.test.tsx | 607 +++++++++++++++++- .../pullRequest/PullRequestsPanel.tsx | 32 +- .../pullRequest/pullRequestList.logic.test.ts | 15 + .../pullRequest/pullRequestList.logic.ts | 8 + .../sourceControl/ListFilterMenu.test.tsx | 22 + .../sourceControl/ListFilterMenu.tsx | 4 +- apps/web/src/routes/_chat.issues.tsx | 43 +- apps/web/src/routes/_chat.pull-requests.tsx | 8 +- apps/web/src/state/pullRequests.test.tsx | 97 +++ apps/web/src/state/pullRequests.ts | 46 +- apps/web/src/state/queries.test.tsx | 47 +- apps/web/src/state/queries.ts | 25 +- packages/contracts/src/git.test.ts | 29 + packages/contracts/src/git.ts | 5 +- 28 files changed, 1839 insertions(+), 105 deletions(-) create mode 100644 apps/web/src/state/pullRequests.test.tsx diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 357de584c6dc..ad4a488679a9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2625,6 +2625,31 @@ it.effect("asks on its own for a repository a search answered nothing for", () = ); }), ); +it.effect("does not read repositories on their own when a free-text search answers nothing", () => + Effect.gen(function* () { + const separately: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequests: ({ repository }) => { + separately.push(repository); + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + listChangeRequestsAcross: () => Effect.succeed({ items: [], truncated: false }), + }), + ], + }); + + const result = yield* service.list({ state: "open", query: "sprocket" }); + + assert.deepStrictEqual(separately, []); + assert.deepStrictEqual(result.entries, []); + }), +); it.effect("reads the repositories one at a time when the search itself fails", () => Effect.gen(function* () { const separately: string[] = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9e683636ced0..040682548070 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -758,8 +758,8 @@ export const make = Effect.gen(function* () { viewer: string, ): boolean => { if (filters === undefined) return true; - const labels = item.labels.map((label) => label.name.trim().toLowerCase()); - const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + const labels = new Set(item.labels.map((label) => label.name.trim().toLowerCase())); + const holds = (label: string) => labels.has(label.trim().toLowerCase()); return ( (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && // Judged on the provider row rather than the entry, because the two absences mean @@ -1054,7 +1054,11 @@ export const make = Effect.gen(function* () { // keeps a search-invisible repository from disappearing on a busy host, at the // price of one request per repository with nothing in the first slice — which // run together, and only there. - if (fetched.length === 0 && cursorOf(project) === undefined) { + if ( + input.query === undefined && + fetched.length === 0 && + cursorOf(project) === undefined + ) { return readRepository(project); } const cursorHere = cursorOf(project); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 79b67d8b6f09..75a6ebf972df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -130,7 +130,6 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { - issueSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, diff --git a/apps/web/src/components/GitHistoryPanel.test.tsx b/apps/web/src/components/GitHistoryPanel.test.tsx index 516fc045f091..7c4d9c9b6cc5 100644 --- a/apps/web/src/components/GitHistoryPanel.test.tsx +++ b/apps/web/src/components/GitHistoryPanel.test.tsx @@ -10,7 +10,7 @@ import { import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import type { ReactElement } from "react"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { reactHookHarness as hooks } from "../test/reactHookHarness"; import { visitElements } from "../test/reactElementTree"; @@ -25,6 +25,7 @@ const effectQueue = vi.hoisted(() => ({ cursor: 0, dependencies: [] as Array | undefined>, effects: [] as Array<() => void>, + stateUpdates: 0, })); const historyState = vi.hoisted(() => ({ @@ -103,7 +104,16 @@ vi.mock("react", async (importOriginal) => { }, useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, - useState: reactHookHarness.useState, + useState: (initialValue: Value | (() => Value)) => { + const [value, setValue] = reactHookHarness.useState(initialValue); + return [ + value, + (nextValue: Value | ((previous: Value) => Value)) => { + effectQueue.stateUpdates += 1; + setValue(nextValue); + }, + ] as const; + }, }; }); @@ -254,6 +264,7 @@ import { isWideHistoryLayout, } from "./GitHistoryPanel"; import GitHistoryPanel from "./GitHistoryPanel"; +import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; const environmentId = EnvironmentId.make("environment-local"); @@ -414,12 +425,17 @@ function componentElement( } 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(); @@ -457,6 +473,117 @@ describe("GitHistoryPanel", () => { expect(historyState.getHistory).not.toHaveBeenCalled(); }); + it("restarts history after the environment connection generation changes", () => { + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), + ); + + renderPanel(); + historyState.getHistory.mockClear(); + historyState.connection = { phase: "connected", generation: 2 }; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + environmentId, + input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0 }, + }); + }); + + it("keeps the narrow branches sheet open when the history target rekeys", () => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) {} + + disconnect() {} + + observe() { + this.callback([{ contentRect: { width: 539 } }]); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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.id === "git-history-refs-panel"), + ).not.toBeNull(); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements(renderPanel(), (element) => element.props.id === "git-history-refs-panel"), + ).not.toBeNull(); + }); + + it("closes the narrow details sheet when the history target rekeys", () => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) {} + + disconnect() {} + + observe() { + this.callback([{ contentRect: { width: 539 } }]); + } + }, + ); + const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "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.id === "git-history-details-panel"), + ).not.toBeNull(); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements(renderPanel(), (element) => element.props.id === "git-history-details-panel"), + ).toBeNull(); + }); + it("restarts the first history page after a typed continuation expiry", () => { historyState.pages.set( undefined, @@ -789,6 +916,256 @@ describe("GitHistoryPanel", () => { expect(isWideHistoryLayout(1120)).toBe(true); }); + it("does not rerender history children for repeated wide widths without pane clamping", () => { + let notify: ((width: number) => void) | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) { + notify = (width) => this.callback([{ contentRect: { width } }]); + } + + disconnect() {} + + observe() { + notify?.(1400); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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", () => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) {} + + disconnect() {} + + observe() { + this.callback([{ contentRect: { width: 1120 } }]); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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", () => { + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) {} + + disconnect() {} + + observe() { + this.callback([{ contentRect: { width: 1120 } }]); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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", () => { + let notify: ((width: number) => void) | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) { + notify = (width) => this.callback([{ contentRect: { width } }]); + } + + disconnect() {} + + observe() { + notify?.(1400); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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", () => { + let notify: ((width: number) => void) | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor( + private readonly callback: ( + entries: ReadonlyArray<{ contentRect: { width: number } }>, + ) => void, + ) { + notify = (width) => this.callback([{ contentRect: { width } }]); + } + + disconnect() {} + + observe() { + notify?.(1600); + } + }, + ); + historyState.pages.set( + undefined, + page([commit("aaaaaaaa11111111111111111111111111111111", "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, @@ -1352,6 +1729,69 @@ describe("GitHistoryPanel", () => { 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("aaaaaaaa11111111111111111111111111111111", "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: "aaaaaaaa11111111111111111111111111111111", + 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" }]; diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx index e3e27d9fe1a6..8209ed00a020 100644 --- a/apps/web/src/components/GitHistoryPanel.tsx +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -53,6 +53,8 @@ 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)); @@ -103,25 +105,59 @@ export function nextCommitFilesRecoveryGeneration(input: { : null; } -export function useWideHistoryLayout(panelRef: RefObject): boolean { +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]) => { - setIsWide(isWideHistoryLayout(entry?.contentRect.width ?? 0)); + 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; + return { isWide, widthRef }; } export default function GitHistoryPanel(props: GitHistoryPanelProps) { const panelRef = useRef(null); - const isWideLayout = useWideHistoryLayout(panelRef); + const refsPaneWidthRef = useRef(256); + const detailsPaneWidthRef = useRef(384); const interfaceFontSize = useClientSettings((settings) => settings.fontSizeInterface); const rowHeight = gitHistoryRowHeight(interfaceFontSize); const baseTargetKey = `${props.environmentId}:${props.cwd}`; @@ -129,6 +165,32 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { 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 }), @@ -158,6 +220,7 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { }), ), [ + connectionGeneration, cursors, historyQueryGeneration, props.cwd, @@ -402,7 +465,7 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { setFilter(""); setSelectedHash(null); setCommitDiffRequest(null); - setMobilePane(null); + setMobilePane((pane) => (pane === "details" ? null : pane)); }, [targetKey]); useEffect(() => { @@ -540,6 +603,9 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { hash={commitDiffRequest.hash} {...(commitDiffRequest.filePath ? { filePath: commitDiffRequest.filePath } : {})} files={selectedCommitFiles} + filesError={commitFilesQuery.error !== null} + filesHasMore={commitFilesHasMore} + filesLoading={commitFilesQuery.isPending} diff={commitDiffQuery.data?.diff ?? null} truncated={commitDiffQuery.data?.truncated ?? false} isPending={commitDiffQuery.isPending} @@ -553,6 +619,8 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { ) } onRetry={commitDiffQuery.refresh} + onLoadMoreFiles={loadMoreCommitFiles} + onRetryFiles={() => void commitFilesQuery.refresh()} /> ) : isInitialLoad ? (
@@ -594,11 +662,23 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { min={REFS_PANE_MIN_WIDTH} max={REFS_PANE_MAX_WIDTH} onMove={(delta) => - setRefsPaneWidth((width) => - Math.min(REFS_PANE_MAX_WIDTH, Math.max(REFS_PANE_MIN_WIDTH, width + delta)), + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current + delta, + detailsPaneWidth: detailsPaneWidthRef.current, + }), + ) + } + onReset={() => + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: 256, + detailsPaneWidth: detailsPaneWidthRef.current, + }), ) } - onReset={() => setRefsPaneWidth(256)} /> ) : null}
- setDetailsPaneWidth((width) => - Math.min(DETAILS_PANE_MAX_WIDTH, Math.max(DETAILS_PANE_MIN_WIDTH, width - delta)), + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current, + detailsPaneWidth: detailsPaneWidthRef.current - delta, + }), + ) + } + onReset={() => + applyPaneWidths( + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: refsPaneWidthRef.current, + detailsPaneWidth: 384, + }), ) } - onReset={() => setDetailsPaneWidth(384)} /> ) : null} {isWideLayout ? ( diff --git a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx index e9c7dbe5a45a..db7628cecea2 100644 --- a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx +++ b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx @@ -17,6 +17,9 @@ export function CommitDiffView(props: { hash: string; filePath?: string; files: ReadonlyArray; + filesError: boolean; + filesHasMore: boolean; + filesLoading: boolean; diff: string | null; truncated: boolean; isPending: boolean; @@ -24,6 +27,8 @@ export function CommitDiffView(props: { onBack: () => void; onSelectFile: (filePath?: string) => void; onRetry: () => void; + onLoadMoreFiles: () => void; + onRetryFiles: () => void; }) { const { resolvedTheme } = useTheme(); const renderable = useMemo( @@ -68,6 +73,25 @@ export function CommitDiffView(props: { {props.truncated ? truncated : null} + {!props.filesError && (props.filesHasMore || props.filesLoading) ? ( + + ) : null} + {props.filesError ? ( + + ) : null}
{props.isPending ? ( diff --git a/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx index d75b3206804a..faaaa848c26f 100644 --- a/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx +++ b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx @@ -19,7 +19,7 @@ export function PaneResizeHandle(props: { aria-valuemax={props.max} aria-valuenow={props.value} tabIndex={0} - className="group relative z-10 hidden w-2 shrink-0 cursor-col-resize touch-none bg-background/70 outline-none @min-[1380px]/history-list:block" + className="group relative z-10 block w-2 shrink-0 cursor-col-resize touch-none bg-background/70 outline-none" onPointerDown={(event) => { lastClientX.current = event.clientX; event.currentTarget.setPointerCapture(event.pointerId); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx index b13a5747408d..30ed33af3e69 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -13,6 +13,7 @@ const refState = vi.hoisted(() => ({ remote: [] as ReadonlyArray, tags: [] as ReadonlyArray, isComplete: true, + nextCursor: null as string | null, refreshLocal: vi.fn(), refreshRemote: vi.fn(), refreshTags: vi.fn(), @@ -58,11 +59,17 @@ vi.mock("../../state/queries", () => ({ : options.namespace === "remote" ? refState.remote : refState.tags; + const target = _target as { readonly environmentId: EnvironmentId | null }; return { data: - options.namespace === "local" && !refState.currentRefResolved + target.environmentId === null || + (options.namespace === "local" && !refState.currentRefResolved) ? null - : { currentRef: refState.currentRef, nextCursor: null, isComplete: refState.isComplete }, + : { + currentRef: refState.currentRef, + nextCursor: refState.nextCursor, + isComplete: refState.isComplete, + }, refs, error: null, isFetchingNextPage: false, @@ -133,6 +140,7 @@ describe("useGitHistoryRefs", () => { refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, true)); refState.tags = []; refState.isComplete = true; + refState.nextCursor = null; refState.refreshLocal.mockReset(); refState.refreshRemote.mockReset(); refState.refreshTags.mockReset(); @@ -180,6 +188,27 @@ describe("useGitHistoryRefs", () => { expect(rendered.historyRefs.selectedRevision).toBeUndefined(); }); + 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: "C:/workspace", 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"); @@ -189,6 +218,68 @@ describe("useGitHistoryRefs", () => { 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: "C:/workspace", 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: "C:/workspace", query: "" }, + }); + }); + it("passes a repository revision to every history ref namespace", () => { renderRefs(3); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.ts b/apps/web/src/components/git-history/useGitHistoryRefs.ts index 7794f21f2384..463964139f92 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.ts +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -21,9 +21,15 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev ); const deferredRefFilter = useDebouncedValue(refFilter.trim(), REF_FILTER_DEBOUNCE_MS); const normalizedRefFilter = refFilter.trim().toLocaleLowerCase(); - const shouldLoadLocal = deferredRefFilter.length > 0 || expandedRefKeys.has("section:local"); - const shouldLoadRemote = deferredRefFilter.length > 0 || expandedRefKeys.has("section:remote"); - const shouldLoadTags = deferredRefFilter.length > 0 || expandedRefKeys.has("section:tags"); + const shouldLoadLocal = true; + const shouldLoadRemote = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:remote") || + selectedRevisionState?.revision.startsWith("refs/remotes/") === true; + const shouldLoadTags = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:tags") || + selectedRevisionState?.revision.startsWith("refs/tags/") === true; const refs = usePaginatedHistoryRefs( shouldLoadLocal ? { environmentId, cwd, query: deferredRefFilter } @@ -66,17 +72,58 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev [normalizedRefFilter, tagRefs], ); const currentRef = refs.data?.currentRef; - const selectedRevision = useMemo( + const defaultSelectedRevision = useMemo( () => - selectedRevisionState === undefined - ? currentRef === undefined - ? undefined - : currentRef === null - ? null - : { label: currentRef.name, revision: `refs/heads/${currentRef.name}` } - : selectedRevisionState, - [currentRef, selectedRevisionState], + currentRef === undefined + ? undefined + : currentRef === null + ? null + : { label: currentRef.name, revision: `refs/heads/${currentRef.name}` }, + [currentRef], ); + const selectedRefWasRemoved = useMemo(() => { + if (selectedRevisionState === undefined || selectedRevisionState === null) return false; + if (deferredRefFilter.length > 0) return false; + const selectedRef = selectedRevisionState.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, + selectedRevisionState, + tagRefs, + tags.data?.isComplete, + tags.data?.nextCursor, + ]); + const selectedRevision = + selectedRevisionState === undefined || selectedRefWasRemoved + ? defaultSelectedRevision + : selectedRevisionState; const toggleRefKey = useCallback((key: string) => { setExpandedRefKeys((current) => { const next = new Set(current); @@ -98,6 +145,10 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev setExpandedRefKeys(new Set(["section:local"])); }, [cwd, environmentId]); + useEffect(() => { + if (selectedRefWasRemoved) setSelectedRevision(undefined); + }, [selectedRefWasRemoved]); + return { currentRef, expandedRefKeys, diff --git a/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx b/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx index cc7d6a041524..b2508d0aa0f6 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx @@ -6,13 +6,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { reactHookHarness as hooks } from "../../test/reactHookHarness"; import { visitElements } from "../../test/reactElementTree"; +const effects: Array<() => void> = []; + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, - useEffect: () => undefined, + useEffect: (effect: () => void) => effects.push(effect), useLayoutEffect: () => undefined, useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, @@ -198,6 +200,7 @@ describe("IssueDetailPanel provider labels", () => { }); it("renders Linear labels in the header, tooltips, menu, and aria attributes", () => { + effects.length = 0; hooks.reset(); const panel = renderPanel(); const menu = visitElements(panel, (element) => element.type === Menu); @@ -218,4 +221,29 @@ describe("IssueDetailPanel provider labels", () => { // here, while the MenuItem assertion above checks the child mounted in that root. expect(markup).toContain('aria-haspopup="menu"'); }); + + it("reports refreshed tab status", () => { + effects.length = 0; + const onStateChange = vi.fn(); + + IssueDetailPanel({ + environmentId: "environment-1" as EnvironmentId, + reference: { + projectId: "project-1" as IssueDetail["projectId"], + repository: "acme/project", + number: 42, + }, + handoffTarget: { kind: "new-thread" }, + onStateChange, + }); + effects.forEach((effect) => effect()); + + expect(onStateChange).toHaveBeenCalledWith({ + projectId: "project-1", + repository: "acme/project", + number: 42, + state: "open", + stateReason: null, + }); + }); }); diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx index 710c59504a16..7c97d09746ee 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -157,6 +157,7 @@ export function IssueDetailPanel({ refreshToken: forcedRefreshToken = 0, onActed, onStateChange, + onRefreshPendingChange, onOpenLinkedPullRequest, chromeVariant = "full", }: { @@ -183,6 +184,7 @@ export function IssueDetailPanel({ state: IssueState; stateReason: IssueCloseReason | null; }) => void; + onRefreshPendingChange?: (pending: boolean) => void; /** * Opens one of the change requests that reference this issue, as a peer tab beside it. Supplied * by whoever mounted the panel, because only they know which panel the tab belongs in; without @@ -292,6 +294,17 @@ export function IssueDetailPanel({ ); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; + const [invalidating, setInvalidating] = useState(false); + const refreshPending = invalidating || detailQuery.isPending || activityQuery.isPending; + useEffect(() => { + onRefreshPendingChange?.(refreshPending); + }, [onRefreshPendingChange, refreshPending]); + useEffect( + () => () => { + onRefreshPendingChange?.(false); + }, + [onRefreshPendingChange], + ); const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); @@ -358,8 +371,13 @@ export function IssueDetailPanel({ // and at worst answer from it. const invalidate = useAtomCommand(issueEnvironment.invalidate, { reportFailure: false }); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); + setInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + } finally { + setInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page, rather than by the menu item below. const appliedForcedToken = useRef(forcedRefreshToken); diff --git a/apps/web/src/components/issue/IssuesPanel.test.tsx b/apps/web/src/components/issue/IssuesPanel.test.tsx index bc160949b285..3ffbd70b7ed5 100644 --- a/apps/web/src/components/issue/IssuesPanel.test.tsx +++ b/apps/web/src/components/issue/IssuesPanel.test.tsx @@ -5,6 +5,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +let searchedError: string | null = null; +let searchedDataAvailable = true; + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); @@ -64,15 +67,17 @@ vi.mock("~/state/query", () => ({ truncated: false, nextCursors: {}, } - : { - viewers: {}, - providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], - entries: [], - errors: [], - truncated: false, - nextCursors: {}, - }, - error: null, + : searchedDataAvailable + ? { + viewers: {}, + providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], + entries: [], + errors: [], + truncated: false, + nextCursors: {}, + } + : null, + error: query === "searched" ? searchedError : null, isPending: false, refresh: () => undefined, }), @@ -105,4 +110,28 @@ describe("IssuesPanel", () => { it("keeps a title match when a locally searched host returns no remote matches", () => { expect(renderSearch("pointer cursors")).toContain(matchingIssue.title); }); + + it("limits the visible search input to the query sent to the host", () => { + expect(renderSearch("")).toContain('maxLength="200"'); + }); + + it("keeps retained rows actionable when loading the next slice fails", () => { + searchedError = "The host stopped responding"; + const markup = renderSearch("pointer cursors"); + searchedError = null; + + expect(markup).toContain("The latest request failed. Showing the last issues loaded."); + expect(markup).toContain("Retry"); + }); + + it("keeps Retry visible when a null failed continuation is filtered to zero rows", () => { + searchedError = "The host stopped responding"; + searchedDataAvailable = false; + const markup = renderSearch("unmatched query"); + searchedError = null; + searchedDataAvailable = true; + + expect(markup).toContain("The host stopped responding"); + expect(markup).toContain("Retry"); + }); }); diff --git a/apps/web/src/components/issue/IssuesPanel.tsx b/apps/web/src/components/issue/IssuesPanel.tsx index 265eced63468..a07166de8ec4 100644 --- a/apps/web/src/components/issue/IssuesPanel.tsx +++ b/apps/web/src/components/issue/IssuesPanel.tsx @@ -214,7 +214,7 @@ function IssueBrowserList({ refreshPending: boolean; onRefreshConsumed: () => void; }) { - const typed = query.trim().slice(0, MAX_QUERY_LENGTH); + const typed = query.trim(); // Searching asks the host, which takes a round trip, so the text is held for a moment before it // is sent — the same bargain the issues page makes. const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); @@ -443,15 +443,17 @@ function IssueBrowserList({ }), [onSelect, projectId], ); + const retainedDataError = listQuery.error !== null && listQuery.data !== null; return (
onQuery(event.target.value)} + onChange={(event) => onQuery(event.target.value.slice(0, MAX_QUERY_LENGTH))} />
) : listQuery.error !== null && listQuery.data === null ? ( -

{listQuery.error}

+
+ {listQuery.error} + +
) : entries.length === 0 ? (

@@ -529,6 +536,14 @@ function IssueBrowserList({ ) : null} )} + {retainedDataError ? ( +

+ The latest request failed. Showing the last issues loaded. + +
+ ) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 2846fb05ab4d..db7f58a3d220 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -76,6 +76,7 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -370,8 +371,10 @@ export function PullRequestDetailPanel({ reference, refreshToken: forcedRefreshToken = 0, onActed, + onTitleSaved, onClose, onStateChange, + onRefreshPendingChange, onOpenLinkedIssue, context = "page", composerDraftTarget, @@ -389,6 +392,7 @@ export function PullRequestDetailPanel({ * Told rather than assumed: only the page knows whether it is showing one. */ onActed?: () => void; + onTitleSaved?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */ @@ -399,6 +403,7 @@ export function PullRequestDetailPanel({ state: PullRequestState; isDraft: boolean; }) => void; + onRefreshPendingChange?: (pending: boolean) => void; /** * Opens one of the issues this pull request references, as a peer tab beside it. Supplied by * whoever mounted the panel, because only they know which panel the tab belongs in. @@ -484,6 +489,7 @@ export function PullRequestDetailPanel({ const _diffWarmUpQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), ); + const [invalidating, setInvalidating] = useState(false); const coreDetail = detailQuery.data; const activity = activityQuery.data; const detail = useMemo( @@ -522,6 +528,18 @@ export function PullRequestDetailPanel({ isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; + const retainedActivityError = activity === null ? null : activityQuery.error; + const refreshPending = + invalidating || detailQuery.isPending || activityQuery.isPending || _diffWarmUpQuery.isPending; + useEffect(() => { + onRefreshPendingChange?.(refreshPending); + }, [onRefreshPendingChange, refreshPending]); + useEffect( + () => () => { + onRefreshPendingChange?.(false); + }, + [onRefreshPendingChange], + ); const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); @@ -561,9 +579,14 @@ export function PullRequestDetailPanel({ const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); + setInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + setInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -695,6 +718,7 @@ export function PullRequestDetailPanel({ } setTitleScope(null); refreshDetail(); + onTitleSaved?.(); }; type ThreadTask = { @@ -1954,6 +1978,22 @@ export function PullRequestDetailPanel({ ) : detail ? ( <> + {(coreDetail !== null && detailQuery.error !== null) || + (activity !== null && retainedActivityError !== null) ? ( +
+ + + Showing stale pull request data + {detailQuery.error ?? retainedActivityError} + + + + +
+ ) : null} {mountedTabs.has("summary") ? (
{ const state = new Map(); const setters = new Map void>(); const effects = new Map | undefined>(); + const cleanups = new Map void>(); + const deferredEffects = new Map void | (() => void)>(); + let deferEffects = false; const memos = new Map< number, { readonly dependencies: ReadonlyArray; readonly value: unknown } @@ -23,6 +26,9 @@ const hooks = vi.hoisted(() => { state.clear(); setters.clear(); effects.clear(); + cleanups.clear(); + deferredEffects.clear(); + deferEffects = false; memos.clear(); }, unmountFrom: (firstIndex: number) => { @@ -33,7 +39,11 @@ const hooks = vi.hoisted(() => { if (index >= firstIndex) setters.delete(index); } for (const index of effects.keys()) { - if (index >= firstIndex) effects.delete(index); + if (index >= firstIndex) { + cleanups.get(index)?.(); + cleanups.delete(index); + effects.delete(index); + } } for (const index of memos.keys()) { if (index >= firstIndex) memos.delete(index); @@ -43,6 +53,17 @@ const hooks = vi.hoisted(() => { hookIndex += 1; return callback; }, + deferEffects: () => { + deferEffects = true; + }, + flushEffects: () => { + for (const [index, effect] of deferredEffects) { + const cleanup = effect(); + if (cleanup) cleanups.set(index, cleanup); + } + deferredEffects.clear(); + deferEffects = false; + }, useEffect: (effect: () => void | (() => void), dependencies: ReadonlyArray) => { const index = hookIndex++; const previous = effects.get(index); @@ -52,7 +73,13 @@ const hooks = vi.hoisted(() => { previous.length !== dependencies.length || previous.some((value, dependencyIndex) => value !== dependencies[dependencyIndex]) ) { - effect(); + cleanups.get(index)?.(); + cleanups.delete(index); + if (deferEffects) deferredEffects.set(index, effect); + else { + const cleanup = effect(); + if (cleanup) cleanups.set(index, cleanup); + } } }, useMemo: (factory: () => T, dependencies: ReadonlyArray) => { @@ -111,6 +138,8 @@ const queries = vi.hoisted(() => { viewers: {}, providers: [], entries: Array.from({ length: 30 }, (_, index) => ({ + additions: 0, + deletions: 0, host: "github.com", labels: [], number: index + 1, @@ -137,36 +166,129 @@ const queries = vi.hoisted(() => { }; }); -const stats = vi.hoisted(() => ({ - targets: [] as ReadonlyArray[], - value: [] as ReadonlyArray, - refresh: vi.fn(), -})); +const stats = vi.hoisted(() => { + const subscriptions: Array<{ + readonly atom: unknown; + readonly callback: (result: unknown) => void; + active: boolean; + }> = []; + return { + targets: [] as ReadonlyArray[], + events: [] as string[], + subscriptions, + cleanups: [] as unknown[], + refreshes: [] as unknown[], + value: [] as ReadonlyArray, + emit: (atom: unknown, result: unknown) => { + let emitted = 0; + for (const subscription of subscriptions) { + if (subscription.atom === atom && subscription.active) { + subscription.callback(result); + emitted += 1; + } + } + return emitted; + }, + }; +}); + +const debounce = vi.hoisted(() => ({ value: null as string | null })); const buttons = vi.hoisted(() => new Map void>()); +const inputs = vi.hoisted(() => new Map void>()); +const inputMaxLengths = vi.hoisted(() => new Map()); const observers = vi.hoisted( () => [] as Array<(entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void>, ); const detailActions = vi.hoisted(() => [] as Array<() => void>); +const detailTitleSaves = vi.hoisted(() => [] as Array<(() => void) | undefined>); const rowEntries = vi.hoisted(() => [] as Array); vi.mock("~/state/pullRequests", () => ({ + usePullRequestListStats: ( + targets: ReadonlyArray<{ readonly environmentId: string; readonly input: unknown }>, + ) => { + const [results, setResults] = hooks.useState>(new Map()); + hooks.useEffect(() => { + const subscriptions = targets.map((target, index) => { + stats.targets.push([target]); + const atom = target; + const update = (result: unknown) => { + const value = + typeof result === "object" && result !== null && "stats" in result + ? (result.stats as ReadonlyArray | undefined) + : undefined; + setResults((previous) => { + const next = new Map(previous); + next.set(index, value ?? []); + return next; + }); + }; + stats.events.push("subscribe"); + const subscription = { atom, callback: update, active: true }; + stats.subscriptions.push(subscription); + stats.events.push("read"); + update({ stats: stats.value }); + return () => { + subscription.active = false; + stats.cleanups.push(atom); + }; + }); + return () => { + for (const unsubscribe of subscriptions) unsubscribe(); + }; + }, [targets]); + const merged = [...results.entries()].flatMap(([index, value]) => + (value as ReadonlyArray>).map((stat) => ({ + ...stat, + environmentId: targets[index]?.environmentId, + })), + ); + return { + stats: merged.length === 0 ? null : merged, + refresh: () => { + for (const target of targets) stats.refreshes.push(target); + }, + }; + }, pullRequestEnvironment: { invalidate: {}, list: (input: unknown) => input, + listStats: (input: unknown) => { + stats.targets.push([input]); + return input; + }, }, - usePullRequestListStats: (targets: ReadonlyArray) => { - stats.targets.push(targets); - return { stats: stats.value, refresh: stats.refresh }; +})); + +vi.mock("~/rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: (_atom: unknown) => { + stats.events.push("read"); + return { stats: stats.value }; + }, + subscribe: (atom: unknown, callback: (result: unknown) => void) => { + stats.events.push("subscribe"); + const subscription = { atom, callback, active: true }; + stats.subscriptions.push(subscription); + return () => { + subscription.active = false; + stats.cleanups.push(atom); + }; + }, + refresh: (atom: unknown) => stats.refreshes.push(atom), }, })); +vi.mock("effect/Option", () => ({ getOrNull: (value: T) => value })); +vi.mock("effect/unstable/reactivity", () => ({ AsyncResult: { value: (value: T) => value } })); + vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => undefined, })); vi.mock("~/state/queries", () => ({ - useDebouncedValue: (value: string) => value, + useDebouncedValue: (value: string) => debounce.value ?? value, })); vi.mock("~/state/query", () => ({ @@ -199,13 +321,38 @@ vi.mock("../ui/button", () => ({ }, })); +vi.mock("../ui/input", () => ({ + Input: ({ + "aria-label": ariaLabel, + maxLength, + onChange, + }: { + "aria-label"?: string; + maxLength?: number; + onChange?: (event: { readonly target: { readonly value: string } }) => void; + }) => { + if (ariaLabel && onChange) { + inputs.set(ariaLabel, (value) => onChange({ target: { value } })); + inputMaxLengths.set(ariaLabel, maxLength); + } + return ; + }, +})); + vi.mock("../ui/scroll-area", () => ({ ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("./PullRequestListFilters", () => ({ PullRequestFiltersMenu: () => null })); vi.mock("./PullRequestDetailPanel", () => ({ - PullRequestDetailPanel: ({ onActed }: { onActed: () => void }) => { + PullRequestDetailPanel: ({ + onActed, + onTitleSaved, + }: { + onActed: () => void; + onTitleSaved?: () => void; + }) => { detailActions.push(onActed); + detailTitleSaves.push(onTitleSaved); return null; }, })); @@ -217,6 +364,375 @@ vi.mock("./PullRequestRow", () => ({ })); describe("PullRequestsPanel", () => { + it("subscribes to line-count atoms only after the panel render", () => { + hooks.reset(); + stats.events.length = 0; + stats.subscriptions.length = 0; + stats.cleanups.length = 0; + stats.refreshes.length = 0; + stats.targets.length = 0; + stats.value = []; + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + hooks.deferEffects(); + render(); + expect(stats.events).toEqual([]); + hooks.flushEffects(); + + expect(stats.events).toEqual(["subscribe", "read"]); + expect(stats.subscriptions.map((subscription) => subscription.atom)).toEqual( + stats.targets.at(-1), + ); + }); + + it("merges later stats emissions and ignores emissions after target cleanup", () => { + hooks.reset(); + stats.events.length = 0; + stats.subscriptions.length = 0; + stats.cleanups.length = 0; + stats.refreshes.length = 0; + stats.targets.length = 0; + stats.value = []; + rowEntries.length = 0; + 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(); + const initialAtom = stats.subscriptions[0]?.atom; + if (initialAtom === undefined) throw new Error("Expected initial stats subscription."); + expect( + stats.emit(initialAtom, { + stats: [ + { + projectId: "project-1", + repository: "owner/repository", + number: 1, + additions: 5, + deletions: 2, + }, + ], + }), + ).toBe(1); + render(); + render(); + + const updatedEntry = rowEntries + .filter( + (entry): entry is { readonly number: number; readonly additions?: number } => + typeof entry === "object" && entry !== null && "number" in entry, + ) + .findLast((entry) => entry.number === 1); + expect(updatedEntry).toMatchObject({ additions: 5, deletions: 2, environmentId: "env-1" }); + + observers.at(-1)?.([{ isIntersecting: true }]); + render(); + render(); + stats.emit(initialAtom, { + stats: [ + { + projectId: "project-1", + repository: "owner/repository", + number: 1, + additions: 99, + deletions: 99, + }, + ], + }); + render(); + + const entryAfterCleanup = rowEntries + .filter( + (entry): entry is { readonly number: number; readonly additions?: number } => + typeof entry === "object" && entry !== null && "number" in entry, + ) + .findLast((entry) => entry.number === 1); + expect(stats.cleanups).toContain(initialAtom); + expect(entryAfterCleanup).toMatchObject({ additions: 5, deletions: 2 }); + }); + + it("replaces line-count subscriptions when paged targets change", () => { + hooks.reset(); + stats.events.length = 0; + stats.subscriptions.length = 0; + stats.cleanups.length = 0; + stats.refreshes.length = 0; + stats.value = []; + 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(); + const initialAtom = stats.subscriptions[0]?.atom; + if (initialAtom === undefined) throw new Error("Expected initial stats subscription."); + observers.at(-1)?.([{ isIntersecting: true }]); + render(); + render(); + + expect(stats.cleanups).toContain(initialAtom); + expect( + stats.subscriptions.find((subscription) => subscription.atom === initialAtom)?.active, + ).toBe(false); + }); + + it("refreshes each current line-count atom once per refresh generation", async () => { + hooks.reset(); + stats.events.length = 0; + stats.subscriptions.length = 0; + stats.cleanups.length = 0; + stats.refreshes.length = 0; + stats.value = []; + buttons.clear(); + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + buttons.get("Refresh pull requests")?.(); + await Promise.resolve(); + await Promise.resolve(); + render(); + render(); + + expect(stats.refreshes).toEqual( + stats.subscriptions.slice(-1).map((subscription) => subscription.atom), + ); + }); + + it("caps compact pull request searches at the host query limit", () => { + hooks.reset(); + inputMaxLengths.clear(); + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + + expect(inputMaxLengths.get("Search pull requests")).toBe(200); + }); + + it("unmounts line-count stats while a compact search is typed then cleared", () => { + hooks.reset(); + stats.targets.length = 0; + stats.value = []; + debounce.value = null; + inputs.clear(); + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + const settledTargetCount = stats.targets.length; + debounce.value = ""; + inputs.get("Search pull requests")?.("x".repeat(200)); + render(); + expect(stats.targets).toHaveLength(settledTargetCount); + + inputs.get("Search pull requests")?.(""); + render(); + + expect(stats.targets).toHaveLength(settledTargetCount + 1); + debounce.value = null; + }); + + it("does not refresh line-count stats after a refresh occurs while compact search targets are empty", async () => { + hooks.reset(); + stats.targets.length = 0; + stats.value = []; + stats.refreshes.length = 0; + debounce.value = null; + inputs.clear(); + buttons.clear(); + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + debounce.value = ""; + inputs.get("Search pull requests")?.("x".repeat(200)); + render(); + hooks.unmountFrom(28); + + buttons.get("Refresh pull requests")?.(); + await Promise.resolve(); + await Promise.resolve(); + render(); + + inputs.get("Search pull requests")?.(""); + render(); + + expect(stats.refreshes).toHaveLength(0); + debounce.value = null; + }); + + it("does not refresh line-count stats again after targets remount without another refresh", async () => { + hooks.reset(); + stats.targets.length = 0; + stats.value = []; + stats.refreshes.length = 0; + debounce.value = null; + inputs.clear(); + buttons.clear(); + vi.stubGlobal( + "IntersectionObserver", + class { + disconnect() {} + observe() {} + }, + ); + + const render = () => { + hooks.beginRender(); + renderToStaticMarkup( + undefined} + />, + ); + }; + + render(); + buttons.get("Refresh pull requests")?.(); + await Promise.resolve(); + await Promise.resolve(); + render(); + expect(stats.refreshes).toHaveLength(1); + + debounce.value = ""; + inputs.get("Search pull requests")?.("x".repeat(200)); + render(); + hooks.unmountFrom(28); + inputs.get("Search pull requests")?.(""); + render(); + + expect(stats.refreshes).toHaveLength(1); + debounce.value = null; + }); + it("refreshes the accumulated list from the first page after cursor pagination", async () => { hooks.reset(); queries.inputs.length = 0; @@ -270,7 +786,7 @@ describe("PullRequestsPanel", () => { queries.inputs.length = 0; stats.targets.length = 0; stats.value = []; - stats.refresh.mockReset(); + stats.refreshes.length = 0; observers.length = 0; vi.stubGlobal( "IntersectionObserver", @@ -329,8 +845,9 @@ describe("PullRequestsPanel", () => { buttons.get("Refresh pull requests")?.(); await Promise.resolve(); await Promise.resolve(); + render(); - expect(stats.refresh).toHaveBeenCalledTimes(1); + expect(stats.refreshes).toHaveLength(1); expect( ( stats.targets.at(-1) as Array<{ @@ -383,6 +900,7 @@ describe("PullRequestsPanel", () => { render(); render(); render(); + render(); expect(rowEntries.at(-30)).toBe(rowEntries.at(-60)); }); @@ -391,7 +909,7 @@ describe("PullRequestsPanel", () => { hooks.reset(); queries.inputs.length = 0; queries.refresh.mockReset(); - stats.refresh.mockReset(); + stats.refreshes.length = 0; stats.targets.length = 0; stats.value = []; buttons.clear(); @@ -442,6 +960,63 @@ describe("PullRequestsPanel", () => { 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); + expect(stats.refreshes).toHaveLength(1); + }); + + it("replays a saved title after the list remounts", () => { + hooks.reset(); + queries.inputs.length = 0; + queries.refresh.mockReset(); + stats.refreshes.length = 0; + stats.targets.length = 0; + stats.value = []; + detailTitleSaves.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 }); + detailTitleSaves.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.refreshes).toHaveLength(1); }); }); diff --git a/apps/web/src/components/pullRequest/PullRequestsPanel.tsx b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx index 8b3fbadbbc92..a57901a53aec 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx @@ -51,7 +51,7 @@ import { parsePullRequestQuery, pullRequestEntryKey, pullRequestEntryViewer, - pullRequestStatsTargets, + pullRequestStatsTargetsForSettledResults, prunePullRequestDiffStats, rankPullRequestMatches, type EnvironmentPullRequestEntry, @@ -149,6 +149,7 @@ function ProjectPullRequests(props: PullRequestsPanelProps) { context="page" chromeVariant="collapse" onActed={() => setRefreshPending(true)} + onTitleSaved={() => setRefreshPending(true)} {...(props.onStateChange ? { onStateChange: props.onStateChange } : {})} {...(props.onOpenLinkedIssue ? { onOpenLinkedIssue: props.onOpenLinkedIssue } : {})} {...(props.composerDraftTarget @@ -234,8 +235,11 @@ function PullRequestBrowserList({ refreshPending: boolean; onRefreshConsumed: () => void; }) { + const [statsRefreshGeneration, setStatsRefreshGeneration] = useState(0); + const handledStatsRefreshGeneration = useRef(0); const typed = query.trim().slice(0, 200); const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); + const querySettled = typed === sent; const typedParsed = useMemo(() => parsePullRequestQuery(typed), [typed]); const sentParsed = useMemo(() => parsePullRequestQuery(sent), [sent]); const requestFilters = useMemo( @@ -326,15 +330,24 @@ function PullRequestBrowserList({ typedParsed.text, ]); - const statsTargets = useMemo(() => pullRequestStatsTargets(entries), [entries]); + const statsTargets = useMemo( + () => pullRequestStatsTargetsForSettledResults(entries, querySettled, false), + [entries, querySettled], + ); + const statsQuery = usePullRequestListStats(statsTargets); useEffect(() => { onStatsByRow((previous) => prunePullRequestDiffStats(previous, entries)); }, [entries, onStatsByRow]); - const statsQuery = usePullRequestListStats(statsTargets); useEffect(() => { - if (statsQuery.stats === null) return; - onStatsByRow((previous) => mergePullRequestDiffStats(previous, statsQuery.stats ?? [])); + const stats = statsQuery.stats; + if (stats === null) return; + onStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [onStatsByRow, statsQuery.stats]); + useEffect(() => { + if (handledStatsRefreshGeneration.current === statsRefreshGeneration) return; + handledStatsRefreshGeneration.current = statsRefreshGeneration; + statsQuery.refresh(); + }, [handledStatsRefreshGeneration, statsQuery, statsRefreshGeneration]); const decorationCache = useRef< ReadonlyMap >(new Map()); @@ -388,9 +401,9 @@ function PullRequestBrowserList({ if (consumedRefresh.current) return; consumedRefresh.current = true; refreshList(); - statsQuery.refresh(); + setStatsRefreshGeneration((generation) => generation + 1); onRefreshConsumed(); - }, [onRefreshConsumed, refreshList, refreshPending, statsQuery.refresh]); + }, [onRefreshConsumed, refreshList, refreshPending]); const sentinelRef = useRef(null); useEffect(() => { @@ -440,8 +453,8 @@ function PullRequestBrowserList({ setInvalidating(false); } refreshList(); - statsQuery.refresh(); - }, [environmentId, invalidate, refreshList, statsQuery.refresh]); + setStatsRefreshGeneration((generation) => generation + 1); + }, [environmentId, invalidate, refreshList]); const refreshing = invalidating || listQuery.isPending; return ( @@ -449,6 +462,7 @@ function PullRequestBrowserList({
onQuery(event.target.value)} diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 37e2b239aa52..da1cf0e64c25 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -16,6 +16,7 @@ import { narrowPullRequestsToFilters, partitionPullRequestsWithPriority, pullRequestStatsTargets, + pullRequestStatsTargetsForSettledResults, pullRequestVisibleEntries, prunePullRequestDiffStats, readPullRequestListSnapshot, @@ -508,6 +509,20 @@ describe("line counts that arrive after the rows", () => { }); describe("line-count query targets", () => { + it("does not retarget line counts while the visible search is ahead of the sent query", () => { + const previousTargets = pullRequestStatsTargetsForSettledResults( + [entry({ number: 1 })], + true, + false, + ); + const rapidTypingTargets = ["a", "ad", "add"].map((query) => + pullRequestStatsTargetsForSettledResults([entry({ number: query.length + 1 })], false, false), + ); + + expect(previousTargets[0]?.input.refs.map((ref) => ref.number)).toEqual([1]); + expect(rapidTypingTargets).toEqual([[], [], []]); + }); + it("drops prior filter and page batches before a refresh", () => { const firstPage = pullRequestStatsTargets([entry({ number: 1 }), entry({ number: 2 })]); const currentFilter = pullRequestStatsTargets([ diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 8af1ffed6484..3d53b28698b9 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -501,6 +501,14 @@ export function pullRequestStatsTargets( ); } +export function pullRequestStatsTargetsForSettledResults( + entries: ReadonlyArray, + querySettled: boolean, + showingCarried: boolean, +) { + return querySettled && !showingCarried ? pullRequestStatsTargets(entries) : []; +} + /** * Every connected environment's listing, read as one list. * diff --git a/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx b/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx index 98cc6bd82634..af0c69b5f2cf 100644 --- a/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx +++ b/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx @@ -96,6 +96,28 @@ describe("list filter menu", () => { expect(field.props.type).toBe("search"); }); + it("caps full-page search input and reports the capped value", () => { + const onChange = vi.fn(); + const input = ListSearchInput({ + label: "Search issues", + value: "", + onChange, + }); + const field = Children.toArray(input.props.children).find( + (child) => + isValidElement(child) && + (child.props as { readonly "aria-label"?: string })["aria-label"] === "Search issues", + ) as ReactElement<{ + readonly maxLength: number; + readonly onChange: (event: { readonly currentTarget: { readonly value: string } }) => void; + }>; + + field.props.onChange({ currentTarget: { value: "x".repeat(201) } }); + + expect(field.props.maxLength).toBe(200); + expect(onChange).toHaveBeenCalledWith("x".repeat(200)); + }); + it("uses the compact input primitive in entity pickers", () => { const picker = EntityPicker({ icon: null, diff --git a/apps/web/src/components/sourceControl/ListFilterMenu.tsx b/apps/web/src/components/sourceControl/ListFilterMenu.tsx index dcb325944fe9..36d6075e687b 100644 --- a/apps/web/src/components/sourceControl/ListFilterMenu.tsx +++ b/apps/web/src/components/sourceControl/ListFilterMenu.tsx @@ -49,6 +49,7 @@ export interface ListFilterHost export const ALL_HOSTS_VALUE = ""; const ALL_PROJECTS_VALUE = "all"; +const MAX_SEARCH_QUERY_LENGTH = 200; /** * What to call a host in the row. The provider's own name reads best — "GitHub" over * "github.com" — but it stops naming anything once a workspace has two hosts of one kind, so @@ -89,7 +90,8 @@ export function ListSearchInput({ onChange(event.currentTarget.value)} + maxLength={MAX_SEARCH_QUERY_LENGTH} + onChange={(event) => onChange(event.currentTarget.value.slice(0, MAX_SEARCH_QUERY_LENGTH))} placeholder={placeholder} aria-label={label} /> diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index c7217fba1bae..525992bffd68 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -597,10 +597,13 @@ function IssuesRouteView() { // happens to be theirs: the list and whatever the panel is showing. The panel owns its own // reads, so it is told to redo them rather than reached into. const [detailRefreshToken, setDetailRefreshToken] = useState(0); + const [detailRefreshPending, setDetailRefreshPending] = useState(false); + const [refreshAwaiting, setRefreshAwaiting] = useState(false); // The queries only go pending once the invalidation has come back, so refreshing is tracked // from the first moment rather than the second: a button that stays live through the slow half // of its own work is a button that gets pressed again, and buys the whole cascade twice. const [invalidating, setInvalidating] = useState(false); + const refreshAwaitingRef = useRef(false); const invalidateHost = async () => { setInvalidating(true); try { @@ -611,15 +614,37 @@ function IssuesRouteView() { setInvalidating(false); } }; - const refreshFromHost = async () => { - await invalidateHost(); - refreshList(); - baselineQuery.refresh(); - authoredQuery.refresh(); - assignedQuery.refresh(); - setDetailRefreshToken((token) => token + 1); + const refreshFromHost = () => { + if (refreshAwaitingRef.current) return Promise.resolve(); + refreshAwaitingRef.current = true; + setRefreshAwaiting(true); + return invalidateHost().then(() => { + if (sentCursors === null) { + (baselineVisible ? baselineQuery : listQuery).refresh(); + if (!baselineVisible) baselineQuery.refresh(); + } else { + refreshList(); + baselineQuery.refresh(); + } + authoredQuery.refresh(); + assignedQuery.refresh(); + setDetailRefreshToken((token) => token + 1); + }); }; - const refreshing = invalidating || listQuery.isPending; + const refreshReadsPending = + invalidating || + listQuery.isPending || + baselineQuery.isPending || + authoredQuery.isPending || + assignedQuery.isPending || + (activeSurface?.kind === "issue" && detailRefreshPending) || + (activeSurface?.kind === "pull-request" && detailRefreshPending); + useEffect(() => { + if (!refreshAwaiting || refreshReadsPending) return; + refreshAwaitingRef.current = false; + setRefreshAwaiting(false); + }, [refreshAwaiting, refreshReadsPending]); + const refreshing = refreshAwaiting || refreshReadsPending; // Every page size and every search is its own query, and a new one starts empty. The last // answer for these filters is held so the page grows and narrows in place rather than blanking @@ -1467,6 +1492,7 @@ function IssuesRouteView() { number: activeSurface.number, }} refreshToken={detailRefreshToken} + onRefreshPendingChange={setDetailRefreshPending} // Merging or closing one of these can close the issue it was opened from, so the // list behind it is out of date the moment the host takes the action. onActed={() => { @@ -1510,6 +1536,7 @@ function IssuesRouteView() { number: activeSurface.number, }} refreshToken={detailRefreshToken} + onRefreshPendingChange={setDetailRefreshPending} // There is no thread behind this panel, so handing an issue to an agent starts // one rather than continuing whatever the reader last had open. handoffTarget={{ kind: "new-thread" }} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index ebd2681b5db4..8f639acb8016 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -39,7 +39,7 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, - pullRequestStatsTargets, + pullRequestStatsTargetsForSettledResults, pullRequestVisibleEntries, prunePullRequestDiffStats, rankPullRequestMatches, @@ -1173,7 +1173,10 @@ function PullRequestsRouteView() { ]); const visibleEntries = useMemo(() => pullRequestVisibleEntries(visibleGroups), [visibleGroups]); const [statsByRow, setStatsByRow] = useState(() => new Map()); - const statsTargets = useMemo(() => pullRequestStatsTargets(visibleEntries), [visibleEntries]); + const statsTargets = useMemo( + () => pullRequestStatsTargetsForSettledResults(visibleEntries, querySettled, showingCarried), + [querySettled, showingCarried, visibleEntries], + ); useEffect(() => { setStatsByRow((previous) => prunePullRequestDiffStats(previous, visibleEntries)); }, [visibleEntries]); @@ -1726,6 +1729,7 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); statsQuery.refresh(); }} + onTitleSaved={refreshList} onStateChange={handlePullRequestTabStatusChange} // The issue a change request closes is read beside it, as a peer tab in this // page's own panel: leaving for the issues page would take the change request diff --git a/apps/web/src/state/pullRequests.test.tsx b/apps/web/src/state/pullRequests.test.tsx new file mode 100644 index 000000000000..63482cce7aa9 --- /dev/null +++ b/apps/web/src/state/pullRequests.test.tsx @@ -0,0 +1,97 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { usePullRequestListStats } from "./pullRequests"; + +const stats = vi.hoisted(() => { + const subscriptions: Array<{ + readonly atom: unknown; + readonly callback: (result: unknown) => void; + }> = []; + return { + events: [] as string[], + refreshes: [] as unknown[], + subscriptions, + }; +}); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void | (() => void)) => effect(), + useMemo: reactHookHarness.useMemo, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({ + createPullRequestEnvironmentAtoms: () => ({ + list: () => ({}), + listStats: (target: unknown) => target, + }), +})); + +vi.mock("effect/Option", () => ({ getOrNull: (value: T) => value })); +vi.mock("effect/unstable/reactivity", () => ({ + AsyncResult: { value: (result: { readonly value?: unknown }) => result.value ?? null }, + Atom: { + family: () => () => ({}), + make: () => ({ pipe: () => ({}) }), + withLabel: () => (atom: unknown) => atom, + }, +})); + +vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); +vi.mock("./query", () => ({ formatEnvironmentQueryError: () => "Query failed" })); +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { + get: (atom: unknown) => { + stats.events.push("read"); + return { value: { stats: [] }, atom }; + }, + subscribe: (atom: unknown, callback: (result: unknown) => void) => { + stats.events.push("subscribe"); + stats.subscriptions.push({ atom, callback }); + return () => undefined; + }, + refresh: (atom: unknown) => stats.refreshes.push(atom), + }, +})); + +function render( + targets: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly input: unknown }>, +) { + hooks.beginRender(); + return usePullRequestListStats(targets as never); +} + +describe("usePullRequestListStats", () => { + beforeEach(() => { + hooks.reset(); + stats.events.length = 0; + stats.refreshes.length = 0; + stats.subscriptions.length = 0; + }); + + it("subscribes before reading every stats atom and refreshes only those atoms", () => { + const targets = [ + { + environmentId: "environment-1" as EnvironmentId, + input: { refs: [{ projectId: "project-1", repository: "owner/repository", number: 1 }] }, + }, + ] as const; + + const initial = render(targets); + + expect(initial.stats).toBeNull(); + expect(stats.events).toEqual(["subscribe", "read"]); + + initial.refresh(); + + expect(stats.refreshes).toEqual(stats.subscriptions.map((subscription) => subscription.atom)); + }); +}); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 1445ceca0328..5bae93889d5c 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -4,10 +4,11 @@ import type { EnvironmentId, PullRequestListInput, PullRequestListStatsInput, + PullRequestListStatsResult, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -87,11 +88,6 @@ const usePullRequestListsQuery = createMergedEnvironmentQuery( pullRequestEnvironment.list, ); -const usePullRequestStatsQuery = createMergedEnvironmentQuery( - "web-pull-requests:list-stats", - pullRequestEnvironment.listStats, -); - export interface MergedPullRequestListView { readonly data: MergedPullRequestList | null; readonly error: string | null; @@ -115,15 +111,43 @@ export function usePullRequestListStats( readonly stats: ReadonlyArray | null; readonly refresh: () => void; } { - const query = usePullRequestStatsQuery(targets); + 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( () => - query.values.length === 0 + results.size === 0 ? null - : query.values.flatMap(([environmentId, result]) => + : [...results.values()].flatMap(([environmentId, result]) => result.stats.map((stat) => ({ ...stat, environmentId })), ), - [query.values], + [results], ); - return { stats, refresh: query.refresh }; + const refresh = useCallback(() => { + for (const target of targets) { + appAtomRegistry.refresh(pullRequestEnvironment.listStats(target)); + } + }, [targets]); + return { stats, refresh }; } diff --git a/apps/web/src/state/queries.test.tsx b/apps/web/src/state/queries.test.tsx index 13d450ded4cb..bafef2a392aa 100644 --- a/apps/web/src/state/queries.test.tsx +++ b/apps/web/src/state/queries.test.tsx @@ -25,6 +25,7 @@ type PageAtom = { readonly input: { readonly cursor?: string; readonly queryGeneration: number; + readonly refresh?: true; }; readonly result: PageResult; }; @@ -146,9 +147,9 @@ function page(nextCursor: string | null): PageResult { }; } -function render(revision = 0) { +function render(revision = 0, queryTarget = target) { hooks.beginRender(); - return usePaginatedHistoryRefs(target, { revision }); + return usePaginatedHistoryRefs(queryTarget, { revision }); } function renderBranches() { @@ -167,18 +168,56 @@ describe("usePaginatedHistoryRefs", () => { refsState.results.clear(); }); - it("uses a new page generation when refreshed", () => { + 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(null)); + 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)).toEqual([ { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 0 }, { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 1, refresh: true }, + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 1, refresh: true }, + { + cwd: "C:/workspace", + cursor: "cursor-8", + limit: 100, + namespace: "local", + queryGeneration: 1, + }, + { + cwd: "C:/workspace", + limit: 100, + namespace: "local", + query: "later", + queryGeneration: 1, + }, + { cwd: "C:/workspace", limit: 100, namespace: "local", queryGeneration: 1 }, + { + cwd: "C:/workspace", + cursor: "cursor-8", + limit: 100, + namespace: "local", + queryGeneration: 1, + }, ]); + expect( + new Set( + refsState.atoms + .filter((atom) => atom.input.refresh === true) + .map( + (atom) => + `${atom.input.cwd}:${atom.input.query ?? ""}:${atom.input.cursor ?? ""}:${atom.input.queryGeneration}`, + ), + ), + ).toEqual(new Set(["C:/workspace:::1"])); }); it("discards history cursor pages when the root reconnects", () => { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 34326077e22d..13c570f7bc64 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -264,6 +264,17 @@ export function usePaginatedHistoryRefs( 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([ @@ -298,7 +309,7 @@ export function usePaginatedHistoryRefs( limit, queryGeneration: queryGeneration + revision, namespace, - ...(refreshGeneration > 0 ? { refresh: true } : {}), + ...(cursor === undefined && isRefreshRequest ? { refresh: true } : {}), }, }), ) @@ -310,6 +321,9 @@ export function usePaginatedHistoryRefs( queryGeneration, namespace, refreshGeneration, + refreshSearchKey, + refSearchKey, + isRefreshRequest, revision, target.cwd, target.environmentId, @@ -358,8 +372,12 @@ export function usePaginatedHistoryRefs( recoveredSnapshotGeneration.current = effectiveQueryGeneration; setQueryGeneration((generation) => generation + 1); setRefreshGeneration((generation) => generation + 1); + setRefreshSearchKey(refSearchKey); setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); - }, [effectiveQueryGeneration, expiredPage, targetKey]); + }, [effectiveQueryGeneration, expiredPage, refSearchKey, targetKey]); + useEffect(() => { + if (refreshSearchKey !== null && !isRefreshRequest) setRefreshSearchKey(null); + }, [isRefreshRequest, refreshSearchKey]); const isFetchingNextPage = isPaginatedBranchesNextPagePending(results); const error = failed?._tag === "Failure" @@ -373,8 +391,9 @@ export function usePaginatedHistoryRefs( const refresh = useCallback(() => { setQueryGeneration((generation) => generation + 1); setRefreshGeneration((generation) => generation + 1); + setRefreshSearchKey(refSearchKey); setPagination({ targetKey, cursors: INITIAL_BRANCH_CURSORS }); - }, [targetKey]); + }, [refSearchKey, targetKey]); const retry = useCallback(() => { const failedIndex = results.findIndex((result) => result._tag === "Failure"); const failedPageAtom = failedIndex === -1 ? undefined : pageAtoms[failedIndex]; diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 081408ef5000..2c3e875681a5 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -8,11 +8,13 @@ import { GitRunStackedActionInput, GitResolvePullRequestResult, VcsGetHistoryResult, + VcsGetHistoryInput, VcsGetCommitDiffInput, VcsGetCommitDetailsInput, VcsListCommitFilesResult, VcsStatusResult, VcsListHistoryRefsInput, + VcsListHistoryRefsResult, VcsListRefsInput, VcsListRefsResult, } from "./git.ts"; @@ -32,6 +34,8 @@ const decodeCommitDiffInput = Schema.decodeUnknownSync(VcsGetCommitDiffInput); const decodeListCommitFilesResult = Schema.decodeUnknownSync(VcsListCommitFilesResult); 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", () => { @@ -126,6 +130,31 @@ describe("Git file path contracts", () => { }); describe("Git history results", () => { + 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({ diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 9f4b2c71772f..249217213476 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -7,6 +7,7 @@ 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 @@ -164,7 +165,9 @@ export type VcsListHistoryRefsInput = typeof VcsListHistoryRefsInput.Type; export const VcsGetHistoryInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - revision: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(1024))), + revision: Schema.optional( + TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(GIT_HISTORY_REVISION_MAX_LENGTH)), + ), cursor: Schema.optional(TrimmedNonEmptyStringSchema), queryGeneration: Schema.optional(NonNegativeInt), limit: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_HISTORY_MAX_LIMIT))), From 84078f905e1c8cec5c80e12c7234b400675b1580 Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:59:59 +0200 Subject: [PATCH 3/5] fix(#6315): harden Git history integration --- .../server/src/git/GitWorkflowService.test.ts | 214 +++++++- apps/server/src/git/GitWorkflowService.ts | 21 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 59 +- apps/server/src/vcs/GitVcsDriverCore.ts | 98 +++- .../src/components/GitHistoryPanel.test.tsx | 518 +++++++++--------- apps/web/src/components/GitHistoryPanel.tsx | 31 +- .../src/components/RightPanelTabs.test.tsx | 44 +- .../git-history/GitHistoryCommitList.tsx | 7 +- .../GitHistoryPaneResizeHandle.tsx | 61 ++- .../git-history/GitHistoryRefsPane.tsx | 14 +- .../git-history/GitHistoryVisualTypes.ts | 14 - .../git-history/useGitHistoryRefs.test.tsx | 75 ++- .../git-history/useGitHistoryRefs.ts | 19 +- .../components/sidebar/SidebarChrome.test.tsx | 14 +- apps/web/src/lib/gitHistoryGraph.test.ts | 34 -- apps/web/src/lib/gitHistoryGraph.ts | 3 - apps/web/src/state/queries.test.tsx | 48 +- apps/web/src/state/queries.ts | 2 +- .../client-runtime/src/state/runtime.test.ts | 6 + packages/client-runtime/src/state/runtime.ts | 4 +- packages/contracts/src/git.test.ts | 20 + packages/contracts/src/git.ts | 5 - 22 files changed, 798 insertions(+), 513 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 555ea9e67ced..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,11 +140,60 @@ describe("GitWorkflowService", () => { ), ); - it.effect("returns an empty history when no VCS repository is detected", () => - Effect.gen(function* () { + 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, @@ -147,31 +201,161 @@ describe("GitWorkflowService", () => { 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: () => Effect.succeed(null), + detect, + git: { listHistoryRefs, getHistory, getCommitDetails, listCommitFiles, getCommitDiff }, }), ), - ), - ); + ); + }); - it.effect("returns no commit details when no VCS repository is detected", () => - Effect.gen(function* () { + 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 details = yield* workflow.getCommitDetails({ - cwd: "/not-a-repo", - hash: "0123456789abcdef0123456789abcdef01234567", - }); + const historyInput = { cwd: "/repo" }; + const commitInput = { + cwd: "/repo", + hash: testCommitHash, + }; - assert.deepStrictEqual(details, { commit: null, isRepo: false }); + 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: () => Effect.succeed(null), + 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", () => { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 5ee3235472e2..760513b063a0 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -160,7 +160,7 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } -function nonRepositoryListHistoryRefs(): VcsListHistoryRefsResult { +function nonRepositoryHistoryRefs(): VcsListHistoryRefsResult { return { refs: [], currentRef: null, @@ -277,7 +277,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) => @@ -294,6 +294,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", @@ -359,21 +362,19 @@ export const make = Effect.gen(function* () { ), ), listHistoryRefs: (input) => - detectGitRepositoryForCommand("GitWorkflowService.listHistoryRefs", input.cwd).pipe( + detectGitRepositoryForCommand("GitWorkflowService.listHistoryRefs", input.cwd, true).pipe( Effect.flatMap((isGitRepository) => - isGitRepository - ? git.listHistoryRefs(input) - : Effect.succeed(nonRepositoryListHistoryRefs()), + isGitRepository ? git.listHistoryRefs(input) : Effect.succeed(nonRepositoryHistoryRefs()), ), ), getHistory: (input) => - detectGitRepositoryForCommand("GitWorkflowService.getHistory", input.cwd).pipe( + detectGitRepositoryForCommand("GitWorkflowService.getHistory", input.cwd, true).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? git.getHistory(input) : Effect.succeed(nonRepositoryHistory()), ), ), getCommitDetails: (input) => - detectGitRepositoryForCommand("GitWorkflowService.getCommitDetails", input.cwd).pipe( + detectGitRepositoryForCommand("GitWorkflowService.getCommitDetails", input.cwd, true).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? git.getCommitDetails(input) @@ -381,13 +382,13 @@ export const make = Effect.gen(function* () { ), ), listCommitFiles: (input) => - detectGitRepositoryForCommand("GitWorkflowService.listCommitFiles", input.cwd).pipe( + detectGitRepositoryForCommand("GitWorkflowService.listCommitFiles", input.cwd, true).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? git.listCommitFiles(input) : Effect.succeed(nonRepositoryCommitFiles()), ), ), getCommitDiff: (input) => - detectGitRepositoryForCommand("GitWorkflowService.getCommitDiff", input.cwd).pipe( + detectGitRepositoryForCommand("GitWorkflowService.getCommitDiff", input.cwd, true).pipe( Effect.flatMap((isGitRepository) => isGitRepository ? git.getCommitDiff(input) : Effect.succeed(nonRepositoryCommitDiff()), ), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index a44b141a0c59..e758cec8c607 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -745,13 +745,15 @@ it.effect("rejects a history cursor from another linked worktree", () => }).pipe(Effect.provide(TestLayer)), ); -it.effect("fails history output larger than its dedicated retained-byte ceiling", () => +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("x".repeat(512 * 1024 + 1))) + ? Effect.succeed( + makeSuccessfulHandle(`${makeGitHistoryOutput(1)}${"x".repeat(512 * 1024 + 1)}`), + ) : delegate.spawn(command), ); const driver = yield* makeGitVcsDriverCore().pipe( @@ -760,12 +762,12 @@ it.effect("fails history output larger than its dedicated retained-byte ceiling" const cwd = yield* makeTmpDir(); yield* driver.initRepo({ cwd }); - const error = yield* driver.getHistory({ cwd }).pipe(Effect.flip); - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.getHistory.log", - outputLength: 512 * 1024 + 1, - }); + 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)))), ); @@ -971,7 +973,7 @@ it.effect("treats commit diff file paths containing pathspec magic as literals", }).pipe(Effect.provide(TestLayer)), ); -it.effect("pages commit files once, rejects replay, and cleans up after the final page", () => +it.effect("pages commit files idempotently and retains the snapshot through its TTL", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -988,10 +990,8 @@ it.effect("pages commit files once, rejects replay, and cleans up after the fina 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 }) - .pipe(Effect.flip); - assert.equal(replay._tag, "VcsSnapshotExpiredError"); + const replay = yield* driver.listCommitFiles({ cwd, hash, cursor: first.nextCursor, limit: 1 }); + assert.deepEqual(replay, second); }).pipe(Effect.provide(TestLayer)), ); @@ -1029,6 +1029,12 @@ it.effect( 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 }) @@ -1075,13 +1081,15 @@ it.effect( ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect("rejects synthetic commit-file output above the 512 KiB retained-byte cap", () => +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("x".repeat(512 * 1024 + 1))) + ? Effect.succeed( + makeSuccessfulHandle(`A\x00complete.ts\x00${"x".repeat(512 * 1024 + 1)}`), + ) : delegate.spawn(command), ); const driver = yield* makeGitVcsDriverCore().pipe( @@ -1089,12 +1097,9 @@ it.effect("rejects synthetic commit-file output above the 512 KiB retained-byte ); const cwd = yield* makeTmpDir(); yield* driver.initRepo({ cwd }); - const error = yield* driver.listCommitFiles({ cwd, hash: "a".repeat(40) }).pipe(Effect.flip); - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.listCommitFiles.diffTree", - outputLength: 512 * 1024 + 1, - }); + 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)))), ); @@ -1380,7 +1385,7 @@ it.effect( ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); -it.effect("rejects streamed ref output above its two-megabyte ceiling after the ref cap", () => +it.effect("caps ref output at complete NUL records", () => Effect.scoped( Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -1402,14 +1407,10 @@ it.effect("rejects streamed ref output above its two-megabyte ceiling after the const cwd = yield* makeTmpDir(); yield* driver.initRepo({ cwd }); - const error = yield* driver.listHistoryRefs({ cwd, refresh: true }).pipe(Effect.flip); + const refs = yield* driver.listHistoryRefs({ cwd, refresh: true }); - assert.deepInclude(error, { - _tag: "GitCommandError", - operation: "GitVcsDriver.listHistoryRefs.snapshot", - }); - if (error._tag !== "GitCommandError") return assert.fail("expected a Git command error"); - assert.ok((error.outputLength ?? 0) > 2 * 1024 * 1024); + assert.equal(refs.refs.length, 100); + assert.equal(refs.isComplete, false); }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index e1932a43a407..0c095c540312 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -896,10 +896,17 @@ const collectOutput = Effect.fnUntraced(function* ( }), ); - const remainder = truncated ? "" : decoder.decode(); - if (captureText) 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, @@ -919,10 +926,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* string, { readonly snapshotId: string; readonly offset: number } >(); + const commitFilesCursorIdsBySnapshotOffset = new Map>(); const deleteCommitFilesSnapshot = (snapshotId: string): void => { commitFilesSnapshots.delete(snapshotId); - for (const [cursor, value] of commitFilesCursors) { - if (value.snapshotId === snapshotId) commitFilesCursors.delete(cursor); + 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 => { @@ -931,8 +943,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } }; 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 => { @@ -3211,7 +3229,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* parsedRefs.push({ fullName, ref }); return parsedRefs.length <= GIT_REF_SNAPSHOT_MAX_REFS; }); - yield* executeGitWithStableDiagnostics( + const refOutput = yield* executeGitWithStableDiagnostics( "GitVcsDriver.listHistoryRefs.snapshot", input.cwd, [ @@ -3225,10 +3243,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stdoutRecordSeparator: "\0", onStdoutRecord: processRecord, maxOutputBytes: GIT_REF_SNAPSHOT_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, fallbackErrorDetail: "Git ref snapshot enumeration failed.", }, ); - const isComplete = parsedRefs.length <= GIT_REF_SNAPSHOT_MAX_REFS; + 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), @@ -3345,6 +3365,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, maxOutputBytes: GIT_HISTORY_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + stdoutRecordSeparator: "\0", fallbackErrorDetail: "git log failed", }, ); @@ -3385,7 +3407,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* worktreePath, revision, commits: commits.slice(0, GIT_HISTORY_SNAPSHOT_MAX_COMMITS), - capped: commits.length > 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); @@ -3488,8 +3510,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cursor: input.cursor, }); } - commitFilesCursors.delete(input.cursor); - snapshot = existing; + 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 { @@ -3512,6 +3538,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { maxOutputBytes: GIT_COMMIT_FILES_MAX_OUTPUT_BYTES, + appendTruncationMarker: true, + stdoutRecordSeparator: "\0", fallbackErrorDetail: "git diff-tree failed", }, ); @@ -3530,7 +3558,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const files = snapshot.files.slice(offset, offset + limit); const hasMore = offset + files.length < snapshot.files.length; - if (!hasMore) deleteCommitFilesSnapshot(snapshotId); + if (!hasMore && input.cursor === undefined) deleteCommitFilesSnapshot(snapshotId); return { files, isRepo: true, @@ -3958,32 +3986,42 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); - const invalidateRefSnapshots = Effect.fn("invalidateRefSnapshots")(function* (cwd: string) { - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTag("GitCommandError", () => Effect.succeed(null)), - ); - if (repositoryPaths === null) return; - const snapshotIds = refSnapshotIdsByGitCommonDir.get(repositoryPaths.gitCommonDir); - if (snapshotIds !== undefined) { + 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), - invalidateRefSnapshots(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, ) => diff --git a/apps/web/src/components/GitHistoryPanel.test.tsx b/apps/web/src/components/GitHistoryPanel.test.tsx index 7c4d9c9b6cc5..e951ae891f2e 100644 --- a/apps/web/src/components/GitHistoryPanel.test.tsx +++ b/apps/web/src/components/GitHistoryPanel.test.tsx @@ -197,19 +197,6 @@ vi.mock("../state/queries", () => ({ vi.mock("../state/query", () => ({ useEnvironmentQuery: (target: { readonly kind?: string } | null) => { const base = { error: null, errorCause: null, isPending: false, refresh: vi.fn() }; - if (target?.kind === "refs") { - return { - ...base, - data: { - refs: historyState.refs, - tags: historyState.tags, - isRepo: true, - hasPrimaryRemote: false, - nextCursor: null, - totalCount: historyState.refs.length, - }, - }; - } if (target?.kind === "status") return { ...base, data: historyState.status }; if (target?.kind === "commit-details") return { ...base, data: { commit: historyState.commitDetails } }; @@ -252,7 +239,6 @@ vi.mock("../state/vcs", () => ({ historyState.getCommitDiff(target); return { kind: "commit-diff" }; }, - listRefs: () => ({ kind: "refs" }), status: () => ({ kind: "status" }), }, })); @@ -265,8 +251,14 @@ import { } 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 { @@ -341,7 +333,7 @@ function renderPanel(issueUrlPrefix?: string): ReactElement>; } @@ -351,6 +343,25 @@ function flushEffects(): void { 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, @@ -474,10 +485,7 @@ describe("GitHistoryPanel", () => { }); it("restarts history after the environment connection generation changes", () => { - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); renderPanel(); historyState.getHistory.mockClear(); @@ -486,32 +494,15 @@ describe("GitHistoryPanel", () => { renderPanel(); expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0 }, + input: { cwd: workspacePath, limit: historyPageSize }, }); }); it("keeps the narrow branches sheet open when the history target rekeys", () => { - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) {} - - disconnect() {} - - observe() { - this.callback([{ contentRect: { width: 539 } }]); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + stubResizeObserver(539); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const initial = renderPanel(); (initial.props.ref as { current: object | null }).current = {}; @@ -536,23 +527,8 @@ describe("GitHistoryPanel", () => { }); it("closes the narrow details sheet when the history target rekeys", () => { - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) {} - - disconnect() {} - - observe() { - this.callback([{ contentRect: { width: 539 } }]); - } - }, - ); - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Initial"); + stubResizeObserver(539); + const historyCommit = commit(primaryCommitHash, "Initial"); historyState.pages.set(undefined, page([historyCommit])); const initial = renderPanel(); @@ -587,7 +563,7 @@ describe("GitHistoryPanel", () => { it("restarts the first history page after a typed continuation expiry", () => { historyState.pages.set( undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "First")], { + page([commit(primaryCommitHash, "First")], { hasMore: true, nextCursor: "history-page-2", }), @@ -600,18 +576,21 @@ describe("GitHistoryPanel", () => { flushEffects(); renderPanel(); - const requests = historyState.getHistory.mock.calls.map(([target]) => target.input); + const requests = historyState.getHistory.mock.calls.map(([target]) => target); expect(requests).toContainEqual({ - cwd: "C:/workspace", - cursor: "history-page-2", - limit: 100, - queryGeneration: 0, + 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 }, }); - expect(requests.at(-1)).toEqual({ cwd: "C:/workspace", limit: 100, queryGeneration: 1 }); }); it("recovers a second continuation expiry once after a successful recovery", () => { - const firstPage = page([commit("aaaaaaaa11111111111111111111111111111111", "First")], { + const firstPage = page([commit(primaryCommitHash, "First")], { hasMore: true, nextCursor: "history-page-2", }); @@ -634,16 +613,14 @@ describe("GitHistoryPanel", () => { renderPanel(); flushEffects(); - const generations = historyState.getHistory.mock.calls.map( - ([target]) => target.input.queryGeneration, - ); + 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("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, body: "" }; @@ -669,35 +646,36 @@ describe("GitHistoryPanel", () => { renderPanel(); expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 1, environmentId, - input: { cwd: "C:/workspace", limit: 100, queryGeneration: 1 }, + input: { cwd: workspacePath, limit: historyPageSize }, }); expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + cacheKey: 1, environmentId, - input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 1 }, + input: { cwd: workspacePath, hash: historyCommit.hash }, }); expect(historyState.listCommitFiles).toHaveBeenLastCalledWith({ + cacheKey: 1, environmentId, - input: { cwd: "C:/workspace", hash: historyCommit.hash, limit: 100, queryGeneration: 1 }, + input: { cwd: workspacePath, hash: historyCommit.hash, limit: 100 }, }); expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 1, environmentId, - input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 1 }, + input: { cwd: workspacePath, hash: historyCommit.hash }, }); }); it("discards loaded history cursor pages when the environment reconnects", () => { historyState.pages.set( undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "First")], { + page([commit(primaryCommitHash, "First")], { hasMore: true, nextCursor: "history-page-2", }), ); - historyState.pages.set( - "history-page-2", - page([commit("bbbbbbbb22222222222222222222222222222222", "Second")]), - ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Second")])); const initial = renderPanel(); loadMoreHistory(initial); @@ -711,10 +689,7 @@ describe("GitHistoryPanel", () => { }); it("keeps graph rows stable when history query results have not changed", () => { - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "First")]), - ); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "First")])); const first = historyList(renderPanel()); const second = historyList(renderPanel()); @@ -723,7 +698,7 @@ describe("GitHistoryPanel", () => { }); it("keeps row separators out of the graph column", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); const list = historyList(renderPanel()); @@ -755,7 +730,7 @@ describe("GitHistoryPanel", () => { it("scales the list and graph geometry with the interface font size", () => { fontState.interfaceSize = 20; - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); const list = historyList(renderPanel()); @@ -776,9 +751,9 @@ describe("GitHistoryPanel", () => { it("keeps graph paths within each paint-contained row while joining adjacent lanes", () => { fontState.interfaceSize = 20; - const parent = commit("bbbbbbbb22222222222222222222222222222222", "Parent"); + const parent = commit(secondaryCommitHash, "Parent"); const child = { - ...commit("aaaaaaaa11111111111111111111111111111111", "Child"), + ...commit(primaryCommitHash, "Child"), parentHashes: [parent.hash], }; historyState.pages.set(undefined, page([child, parent])); @@ -817,8 +792,8 @@ describe("GitHistoryPanel", () => { it("keeps missing-parent graph paths dashed without boundary overlays", () => { const child = { - ...commit("aaaaaaaa11111111111111111111111111111111", "Child"), - parentHashes: ["bbbbbbbb22222222222222222222222222222222"], + ...commit(primaryCommitHash, "Child"), + parentHashes: [secondaryCommitHash], }; historyState.pages.set(undefined, page([child])); @@ -842,7 +817,7 @@ describe("GitHistoryPanel", () => { it("creates a fresh changed-file first-page generation after each recovered snapshot expiry", () => { const errorCause = expiredSnapshotCause(); - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, body: "" }; historyState.commitFiles = { @@ -892,8 +867,8 @@ describe("GitHistoryPanel", () => { historyState.pages.set( undefined, page([ - commit("aaaaaaaa11111111111111111111111111111111", "Add Git history panel"), - commit("bbbbbbbb22222222222222222222222222222222", "Expose commit graph", "Grace Hopper"), + commit(primaryCommitHash, "Add Git history panel"), + commit(secondaryCommitHash, "Expose commit graph", "Grace Hopper"), ]), ); @@ -906,8 +881,9 @@ describe("GitHistoryPanel", () => { ]); expect(list.props.recycleItems).toBe(false); expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0 }, + input: { cwd: workspacePath, limit: historyPageSize }, }); }); @@ -916,64 +892,126 @@ describe("GitHistoryPanel", () => { 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", () => { - let notify: ((width: number) => void) | undefined; - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) { - notify = (width) => this.callback([{ contentRect: { width } }]); - } - - disconnect() {} - - observe() { - notify?.(1400); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + 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); + notify(1399); + notify(1398); + notify(1397); expect(effectQueue.stateUpdates).toBe(0); }); it("constrains both side panes when widening branches at the minimum wide layout", () => { - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) {} - - disconnect() {} - - observe() { - this.callback([{ contentRect: { width: 1120 } }]); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const initial = renderPanel(); (initial.props.ref as { current: object | null }).current = {}; @@ -996,26 +1034,8 @@ describe("GitHistoryPanel", () => { }); it("constrains commit details when resetting at the minimum wide layout", () => { - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) {} - - disconnect() {} - - observe() { - this.callback([{ contentRect: { width: 1120 } }]); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const initial = renderPanel(); (initial.props.ref as { current: object | null }).current = {}; @@ -1046,29 +1066,8 @@ describe("GitHistoryPanel", () => { }); it("clamps expanded side panes when a wide history panel shrinks", () => { - let notify: ((width: number) => void) | undefined; - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) { - notify = (width) => this.callback([{ contentRect: { width } }]); - } - - disconnect() {} - - observe() { - notify?.(1400); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + const notify = stubResizeObserver(1400); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const initial = renderPanel(); (initial.props.ref as { current: object | null }).current = {}; @@ -1094,7 +1093,7 @@ describe("GitHistoryPanel", () => { (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); - notify?.(1120); + notify(1120); renderPanel(); flushEffects(); const shrunken = renderPanel(); @@ -1107,29 +1106,8 @@ describe("GitHistoryPanel", () => { }); it("preserves wide pane widths through a narrow layout transition", () => { - let notify: ((width: number) => void) | undefined; - vi.stubGlobal( - "ResizeObserver", - class { - constructor( - private readonly callback: ( - entries: ReadonlyArray<{ contentRect: { width: number } }>, - ) => void, - ) { - notify = (width) => this.callback([{ contentRect: { width } }]); - } - - disconnect() {} - - observe() { - notify?.(1600); - } - }, - ); - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + const notify = stubResizeObserver(1600); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const initial = renderPanel(); (initial.props.ref as { current: object | null }).current = {}; @@ -1152,10 +1130,10 @@ describe("GitHistoryPanel", () => { (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); - notify?.(1119); + notify(1119); renderPanel(); flushEffects(); - notify?.(1600); + notify(1600); renderPanel(); flushEffects(); const restored = renderPanel(); @@ -1170,8 +1148,8 @@ describe("GitHistoryPanel", () => { historyState.pages.set( undefined, page([ - commit("aaaaaaaa11111111111111111111111111111111", "Prepare release"), - commit("bbbbbbbb22222222222222222222222222222222", "Fix graph layout"), + commit(primaryCommitHash, "Prepare release"), + commit(secondaryCommitHash, "Fix graph layout"), ]), ); @@ -1196,15 +1174,12 @@ describe("GitHistoryPanel", () => { it("keeps a history search to the loaded page until the user requests older commits", () => { historyState.pages.set( undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Fix graph layout")], { + page([commit(primaryCommitHash, "Fix graph layout")], { hasMore: true, nextCursor: "history-page-2", }), ); - historyState.pages.set( - "history-page-2", - page([commit("bbbbbbbb22222222222222222222222222222222", "Release notes")]), - ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Release notes")])); const initialPanel = renderPanel(); flushEffects(); @@ -1223,12 +1198,12 @@ describe("GitHistoryPanel", () => { expect(historyState.getHistory).toHaveBeenCalledTimes(1); expect(historyState.getHistory).not.toHaveBeenCalledWith({ + cacheKey: 0, environmentId, input: { - cwd: "C:/workspace", + cwd: workspacePath, cursor: "history-page-2", - limit: 100, - queryGeneration: 0, + limit: historyPageSize, }, }); const searchOlder = visitElements( @@ -1239,11 +1214,12 @@ describe("GitHistoryPanel", () => { }); it("clears a hash search from the clear button or Escape key", () => { + const matchingCommitHash = "0acf007c21111111111111111111111111111111"; historyState.pages.set( undefined, page([ - commit("0acf007c21111111111111111111111111111111", "Matching commit"), - commit("bbbbbbbb22222222222222222222222222222222", "Other commit"), + commit(matchingCommitHash, "Matching commit"), + commit(secondaryCommitHash, "Other commit"), ]), ); @@ -1280,10 +1256,7 @@ describe("GitHistoryPanel", () => { }); it("keeps a trailing-space search visible, literal, and clearable", () => { - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Add Git history panel")]), - ); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Add Git history panel")])); const search = visitElements( renderPanel(), @@ -1317,14 +1290,14 @@ describe("GitHistoryPanel", () => { undefined, page([ { - ...commit("cccccccc33333333333333333333333333333333", "Match newest"), + ...commit(newestMatchingCommitHash, "Match newest"), parentHashes: ["b"], }, { - ...commit("bbbbbbbb22222222222222222222222222222222", "Hidden parent"), + ...commit(secondaryCommitHash, "Hidden parent"), parentHashes: ["a"], }, - commit("aaaaaaaa11111111111111111111111111111111", "Match oldest"), + commit(primaryCommitHash, "Match oldest"), ]), ); @@ -1340,8 +1313,8 @@ describe("GitHistoryPanel", () => { const filtered = historyList(renderPanel()); expect(filtered.props.data.map((row) => row.commit.hash)).toEqual([ - "cccccccc33333333333333333333333333333333", - "aaaaaaaa11111111111111111111111111111111", + newestMatchingCommitHash, + primaryCommitHash, ]); expect(filtered.props.data.flatMap((row) => row.graph.edges)).not.toContainEqual( expect.objectContaining({ kind: "parent" }), @@ -1349,14 +1322,14 @@ describe("GitHistoryPanel", () => { }); it("deduplicates overlapping pages and keeps Load more in the scrolling column footer", () => { - const duplicate = commit("aaaaaaaa11111111111111111111111111111111", "Initial commit"); + 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("bbbbbbbb22222222222222222222222222222222", "Second page commit")]), + page([duplicate, commit(secondaryCommitHash, "Second page commit")]), ); const panel = renderPanel(); @@ -1377,20 +1350,22 @@ describe("GitHistoryPanel", () => { const expanded = historyList(renderPanel()); expect(expanded.props.data.map((row) => row.commit.hash)).toEqual([ - "aaaaaaaa11111111111111111111111111111111", - "bbbbbbbb22222222222222222222222222222222", + primaryCommitHash, + secondaryCommitHash, ]); expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", cursor: "next-page", limit: 100, queryGeneration: 0 }, + 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("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); historyState.refs = [ gitRef("feature/api"), gitRef("feature/ui", { @@ -1409,11 +1384,11 @@ describe("GitHistoryPanel", () => { const initial = renderPanel(); expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, input: { - cwd: "C:/workspace", - limit: 100, - queryGeneration: 0, + cwd: workspacePath, + limit: historyPageSize, revision: "refs/heads/feature/ui", }, }); @@ -1487,11 +1462,11 @@ describe("GitHistoryPanel", () => { renderPanel(); expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, input: { - cwd: "C:/workspace", - limit: 100, - queryGeneration: 0, + cwd: workspacePath, + limit: historyPageSize, revision: "refs/heads/feature/ui", }, }); @@ -1516,10 +1491,7 @@ describe("GitHistoryPanel", () => { }); it("lists and selects tags from the refs snapshot even when history has no tag decorations", () => { - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); historyState.tags = [gitRef("v1.2.3", { isTag: true })]; const initial = renderPanel(); @@ -1554,13 +1526,18 @@ describe("GitHistoryPanel", () => { renderPanel(); expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", limit: 100, queryGeneration: 0, revision: "refs/tags/v1.2.3" }, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/tags/v1.2.3", + }, }); }); it("opens the full commit diff from selected commit details", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, body: "" }; @@ -1584,18 +1561,22 @@ describe("GitHistoryPanel", () => { renderPanel(); expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 0 }, + input: { cwd: workspacePath, hash: historyCommit.hash }, }); }); it("shows the short commit hash in every history row", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + 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 === "aaaaaaaa"); + 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}`, @@ -1607,7 +1588,7 @@ describe("GitHistoryPanel", () => { }); it("shows an error toast when copying a history hash is rejected", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); const list = historyList(renderPanel()); @@ -1627,7 +1608,7 @@ describe("GitHistoryPanel", () => { it("gives every selectable commit its author, date, and parent topology", () => { const historyCommit = { - ...commit("aaaaaaaa11111111111111111111111111111111", "Merge release", "Grace Hopper"), + ...commit(primaryCommitHash, "Merge release", "Grace Hopper"), parentHashes: ["parent-one", "parent-two"], }; historyState.pages.set(undefined, page([historyCommit])); @@ -1643,11 +1624,8 @@ describe("GitHistoryPanel", () => { expect(selectableRow?.props["aria-label"]).toContain("2-parent merge commit"); }); - it("refreshes history and local refs", () => { - historyState.pages.set( - undefined, - page([commit("aaaaaaaa11111111111111111111111111111111", "Initial")]), - ); + it("refreshes history and every ref namespace", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const refresh = visitElements( renderPanel(), @@ -1656,15 +1634,12 @@ describe("GitHistoryPanel", () => { (refresh?.props.onClick as (() => void) | undefined)?.(); expect(historyState.refreshRefs).toHaveBeenCalledOnce(); - expect(historyState.refreshRemoteRefs).not.toHaveBeenCalled(); - expect(historyState.refreshTags).not.toHaveBeenCalled(); + expect(historyState.refreshRemoteRefs).toHaveBeenCalledOnce(); + expect(historyState.refreshTags).toHaveBeenCalledOnce(); }); it("links issue references to the active GitHub repository", () => { - const historyCommit = commit( - "aaaaaaaa11111111111111111111111111111111", - "fix(repository): view (#602)", - ); + 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/")); @@ -1677,7 +1652,7 @@ describe("GitHistoryPanel", () => { }); it("opens a changed file diff from selected commit details", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, @@ -1710,16 +1685,17 @@ describe("GitHistoryPanel", () => { const diff = renderPanel(); expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, - input: { cwd: "C:/workspace", hash: historyCommit.hash, queryGeneration: 0 }, + input: { cwd: workspacePath, hash: historyCommit.hash }, }); expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + cacheKey: 0, environmentId, input: { - cwd: "C:/workspace", + cwd: workspacePath, hash: historyCommit.hash, filePath: "src/panel.tsx", - queryGeneration: 0, }, }); const diffView = visitElements( @@ -1730,7 +1706,7 @@ describe("GitHistoryPanel", () => { }); it("lets the diff load changed files beyond the first page", () => { - const historyCommit = commit("aaaaaaaa11111111111111111111111111111111", "Add panel"); + const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, body: "" }; historyState.commitFiles = { @@ -1768,7 +1744,7 @@ describe("GitHistoryPanel", () => { const retryFiles = vi.fn(); hooks.beginRender(); const diff = CommitDiffView({ - hash: "aaaaaaaa11111111111111111111111111111111", + hash: primaryCommitHash, files: [{ status: "A", path: "first.ts" }], filesError: true, filesHasMore: true, diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx index 8209ed00a020..06b71d7ec824 100644 --- a/apps/web/src/components/GitHistoryPanel.tsx +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -35,11 +35,7 @@ import { } from "./git-history/GitHistoryCommitList"; import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; import { GitRefsPane } from "./git-history/GitHistoryRefsPane"; -import type { - CommitRefKind, - GitHistoryRow, - RefTreeProps, -} from "./git-history/GitHistoryVisualTypes"; +import type { CommitRefKind, GitHistoryRow } from "./git-history/GitHistoryVisualTypes"; import { useGitHistoryRefs } from "./git-history/useGitHistoryRefs"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; @@ -210,9 +206,9 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { : cursors.map((cursor) => vcsEnvironment.getHistory({ environmentId: props.environmentId, + cacheKey: historyQueryGeneration + vcsHistoryRevision, input: { cwd: props.cwd, - queryGeneration: historyQueryGeneration + vcsHistoryRevision, ...(selectedRevision === null ? {} : { revision: selectedRevision.revision }), ...(cursor === undefined ? {} : { cursor }), limit: HISTORY_PAGE_SIZE, @@ -308,7 +304,8 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { ? null : vcsEnvironment.getCommitDetails({ environmentId: props.environmentId, - input: { cwd: props.cwd, hash: selectedHash, queryGeneration: vcsHistoryRevision }, + cacheKey: vcsHistoryRevision, + input: { cwd: props.cwd, hash: selectedHash }, }), ); const selectedCommitDetails = commitDetailsQuery.data?.commit ?? null; @@ -334,11 +331,11 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { ? null : vcsEnvironment.listCommitFiles({ environmentId: props.environmentId, + cacheKey: commitFilesQueryGeneration + vcsHistoryRevision, input: { cwd: props.cwd, hash: selectedHash, limit: 100, - queryGeneration: commitFilesQueryGeneration + vcsHistoryRevision, ...(commitFilesCursor ? { cursor: commitFilesCursor } : {}), }, }), @@ -382,10 +379,10 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { ? null : vcsEnvironment.getCommitDiff({ environmentId: props.environmentId, + cacheKey: vcsHistoryRevision, input: { cwd: props.cwd, hash: commitDiffRequest.hash, - queryGeneration: vcsHistoryRevision, ...(commitDiffRequest.filePath ? { filePath: commitDiffRequest.filePath } : {}), }, }), @@ -431,13 +428,6 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { selectAllHistoryRefs(); setMobilePane(null); }, [selectAllHistoryRefs]); - const sharedRefTreeProps = { - filterActive: normalizedRefFilter.length > 0, - expanded: expandedRefKeys, - selectedRevision: selectedRevision?.revision ?? null, - onToggle: toggleRefKey, - onSelect: selectRef, - } satisfies Omit; const refPaneProps = { refFilter, onRefFilterChange: setRefFilter, @@ -451,7 +441,6 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { tagRefTree, expandedRefKeys, onToggleRefKey: toggleRefKey, - sharedRefTreeProps, hasMoreRefs, isFetchingMoreRefs, isRefSnapshotComplete, @@ -493,13 +482,9 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { }), [activeFilter, filteredHistory, headHash, primaryHashes], ); - const graphByHash = useMemo(() => new Map(graphRows.map((row) => [row.hash, row])), [graphRows]); const filteredRows = useMemo(() => { - return filteredHistory.flatMap((commit) => { - const graph = graphByHash.get(commit.hash); - return graph ? [{ commit, graph }] : []; - }); - }, [filteredHistory, graphByHash]); + return filteredHistory.map((commit, index) => ({ commit, graph: graphRows[index]! })); + }, [filteredHistory, graphRows]); useEffect(() => { if (selectedHash !== null && !history.some((commit) => commit.hash === selectedHash)) { diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 2a06c1d3341a..6b0305ba02e5 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -2,6 +2,7 @@ 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, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs"; function shortcutEvent( @@ -29,6 +30,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", @@ -78,12 +89,19 @@ 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} @@ -143,6 +173,16 @@ describe("RightPanelTabs preview favicon", () => { 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 independently of Git History", () => { const html = renderToStaticMarkup( lane * laneWidth + GRAPH_HORIZONTAL_PADDING + laneWidth / 2; - const edges = props.graph.edges.slice(0, MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW); + const { edges } = props.graph; return ( @@ -833,7 +848,7 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { } /> ) : null} - {!isWideLayout && mobilePane === "refs" ? ( + {!isWideLayout ? ( !open && setMobilePane(null)} @@ -849,7 +864,7 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { ) : null} - {!isWideLayout && mobilePane === "details" ? ( + {!isWideLayout ? ( !open && setMobilePane(null)} diff --git a/apps/web/src/components/RepositoryPanel.test.tsx b/apps/web/src/components/RepositoryPanel.test.tsx index f75b144d2b2f..4aee68e98108 100644 --- a/apps/web/src/components/RepositoryPanel.test.tsx +++ b/apps/web/src/components/RepositoryPanel.test.tsx @@ -27,7 +27,7 @@ vi.mock("react", async (importOriginal) => { }; }); -import { repositoryViewsAfterActivation, repositoryViewFromTabKey } from "./RepositoryPanel"; +import { repositoryViewFromTabKey } from "./RepositoryPanel"; import RepositoryPanel from "./RepositoryPanel"; vi.mock("./GitHistoryPanel", () => ({ @@ -150,16 +150,6 @@ describe("RepositoryPanel", () => { expect(markup).not.toContain('data-activity-mode="hidden"'); }); - it("retains each repository view after it is activated", () => { - const historyViews = repositoryViewsAfterActivation(new Set(), "history"); - const issueViews = repositoryViewsAfterActivation(historyViews, "issues"); - const pullRequestViews = repositoryViewsAfterActivation(issueViews, "pull-requests"); - - expect([...historyViews]).toEqual(["history"]); - expect([...issueViews]).toEqual(["history", "issues"]); - expect([...pullRequestViews]).toEqual(["history", "issues", "pull-requests"]); - }); - it("exposes Issues beside History in the repository pane", () => { const markup = renderToStaticMarkup( , - view: RepositoryView, -): ReadonlySet { - if (activatedViews.has(view)) return activatedViews; - return new Set([...activatedViews, view]); -} - function repositoryTabId(view: RepositoryView): string { return `repository-tab-${view}`; } @@ -94,17 +86,14 @@ function RepositoryPanelLoadingFallback(props: { readonly children: string }) { export default function RepositoryPanel(props: RepositoryPanelProps) { const mode = props.view; const tabRefs = useRef>([]); - const [activatedViews, setActivatedViews] = useState>( - () => new Set([mode]), - ); - const mountedViews = repositoryViewsAfterActivation(activatedViews, mode); + const [historyActivated, setHistoryActivated] = useState(mode === "history"); useEffect(() => { - setActivatedViews((views) => repositoryViewsAfterActivation(views, mode)); + if (mode === "history") setHistoryActivated(true); }, [mode]); const activateView = (view: RepositoryView) => { - setActivatedViews((views) => repositoryViewsAfterActivation(views, view)); + if (view === "history") setHistoryActivated(true); props.onViewChange(view); }; @@ -177,7 +166,7 @@ export default function RepositoryPanel(props: RepositoryPanelProps) {
- {mountedViews.has("history") && ( + {historyActivated && (
)} - {mountedViews.has("issues") && ( - -
- {props.issuesCapabilityState === "loading" ? ( -
- Loading issues… -
- ) : props.issuesCapabilityState === "unavailable" ? ( -
- Update the environment server to browse issues. -
- ) : ( - Loading issues… - } - > - - - )} -
-
+ {mode === "issues" && ( +
+ {props.issuesCapabilityState === "loading" ? ( +
+ Loading issues… +
+ ) : props.issuesCapabilityState === "unavailable" ? ( +
+ Update the environment server to browse issues. +
+ ) : ( + Loading issues… + } + > + + + )} +
)} - {mountedViews.has("pull-requests") && ( - -
- {props.pullRequestsCapabilityState === "loading" ? ( -
- Loading pull requests… -
- ) : props.pullRequestsCapabilityState === "unavailable" ? ( -
- Update the environment server to browse pull requests. -
- ) : ( - - Loading pull requests… - + {mode === "pull-requests" && ( +
+ {props.pullRequestsCapabilityState === "loading" ? ( +
+ Loading pull requests… +
+ ) : props.pullRequestsCapabilityState === "unavailable" ? ( +
+ Update the environment server to browse pull requests. +
+ ) : ( + + Loading pull requests… + + } + > + - - - )} -
- + onSelect={props.onSelectPullRequest} + composerDraftTarget={props.composerDraftTarget} + onStateChange={props.onPullRequestStateChange} + onOpenLinkedIssue={props.onOpenLinkedIssue} + /> +
+ )} +
)} ); diff --git a/apps/web/src/components/RightPanelTabs.test.tsx b/apps/web/src/components/RightPanelTabs.test.tsx index 6b0305ba02e5..88b3632adb84 100644 --- a/apps/web/src/components/RightPanelTabs.test.tsx +++ b/apps/web/src/components/RightPanelTabs.test.tsx @@ -131,7 +131,6 @@ function renderTabs( onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddPullRequest={() => undefined} - onAddRepository={() => undefined} onAddIssue={() => undefined} onAddDiff={() => undefined} onAddFiles={() => undefined} @@ -142,7 +141,6 @@ function renderTabs( diffAvailable={false} filesAvailable={false} pullRequestAvailable={false} - repositoryAvailable={false} issueAvailable={false} agentsAvailable={false} > @@ -183,8 +181,45 @@ describe("RightPanelTabs preview favicon", () => { expect(html).toContain("text-violet-600"); }); - it("offers the combined Repository surface independently of Git History", () => { + 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} + 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( { , ); - expect(html).toContain(">Repository"); - expect(html).toContain("Browse history, issues, and pull requests."); + expect(enabledHtml).toContain(">Repository"); + expect(enabledHtml).toContain("Browse history, issues, and pull requests."); }); }); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a49991001317..71469a70bf17 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -80,7 +80,7 @@ interface RightPanelTabsProps { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; - onAddRepository: () => void; + onAddRepository?: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddAgents: () => void; @@ -93,7 +93,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; - repositoryAvailable: boolean; + repositoryAvailable?: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; agentsAvailable: boolean; @@ -125,7 +125,6 @@ const SURFACE_DISABLED_REASONS = { terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", - repository: "Repository views are unavailable for this project.", pullRequest: "This thread's branch has no pull request yet.", issue: "Issues are only available from a project checked out from a host.", agents: "Agents are only available from a thread.", @@ -232,7 +231,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) function SurfaceMenuItem(props: { available: boolean; - disabledReason?: string; + disabledReason: string | undefined; shortcut: string; onClick: () => void; children: ReactNode; @@ -263,7 +262,7 @@ function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; onAddDiff: () => void; - onAddRepository: () => void; + onAddRepository: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; onAddIssue: () => void; @@ -271,7 +270,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; - repositoryAvailable: boolean; + repositoryAvailable: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; issueAvailable: boolean; @@ -322,16 +321,20 @@ function RightPanelEmptyState(props: { onClick: props.onAddDiff, badgeCount: 0, }, - { - label: "Repository", - description: "Browse history, issues, and pull requests.", - icon: GitGraph, - shortcut: "G", - available: props.repositoryAvailable, - disabledReason: SURFACE_DISABLED_REASONS.repository, - onClick: props.onAddRepository, - 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.", @@ -709,14 +712,18 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, - { - label: "Repository", - icon: GitGraph, - shortcut: "G", - available: props.repositoryAvailable, - disabledReason: SURFACE_DISABLED_REASONS.repository, - onClick: props.onAddRepository, - }, + ...(props.repositoryAvailable && props.onAddRepository + ? [ + { + label: "Repository", + icon: GitGraph, + shortcut: "G", + available: true, + disabledReason: undefined, + onClick: props.onAddRepository, + }, + ] + : []), { label: "Pull request", icon: GitPullRequest, diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx index 5e33d716a6ab..6016e2e50ebe 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -8,6 +8,7 @@ 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, @@ -35,6 +36,7 @@ vi.mock("react", async (importOriginal) => { useDeferredValue: (value: Value) => value, useEffect: () => undefined, useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, useState: reactHookHarness.useState, }; }); @@ -76,7 +78,7 @@ vi.mock("../../state/queries", () => ({ isComplete: refState.isComplete, }, refs, - error: null, + error: options.namespace === "local" ? refState.localError : null, isFetchingNextPage: false, loadNext: vi.fn(), refresh: @@ -137,6 +139,7 @@ describe("useGitHistoryRefs", () => { hooks.reset(); refState.currentRefResolved = true; refState.currentRef = null; + refState.localError = null; refState.debouncedRefFilter = ""; refState.local = []; refState.remote = []; @@ -227,6 +230,38 @@ describe("useGitHistoryRefs", () => { 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("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"); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.ts b/apps/web/src/components/git-history/useGitHistoryRefs.ts index 20aed7968cf7..968602b132bd 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.ts +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, VcsHistoryRef } from "@t3tools/contracts"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { buildGitRefTree, filterGitRefTree } from "../../lib/gitRefTree"; import { useDebouncedValue, usePaginatedHistoryRefs } from "../../state/queries"; @@ -68,15 +68,31 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev () => filterGitRefTree(buildGitRefTree(tagRefs), normalizedRefFilter), [normalizedRefFilter, tagRefs], ); - const currentRef = refs.data?.currentRef; + 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( () => currentRef === undefined - ? undefined + ? refs.error + ? null + : undefined : currentRef === null ? null : { label: currentRef.name, revision: `refs/heads/${currentRef.name}` }, - [currentRef], + [currentRef, refs.error], ); const selectedRefWasRemoved = useMemo(() => { if (selectedRevisionState === undefined || selectedRevisionState === null) return false; diff --git a/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx b/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx index b2508d0aa0f6..cc7d6a041524 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.label.test.tsx @@ -6,15 +6,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { reactHookHarness as hooks } from "../../test/reactHookHarness"; import { visitElements } from "../../test/reactElementTree"; -const effects: Array<() => void> = []; - vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, - useEffect: (effect: () => void) => effects.push(effect), + useEffect: () => undefined, useLayoutEffect: () => undefined, useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, @@ -200,7 +198,6 @@ describe("IssueDetailPanel provider labels", () => { }); it("renders Linear labels in the header, tooltips, menu, and aria attributes", () => { - effects.length = 0; hooks.reset(); const panel = renderPanel(); const menu = visitElements(panel, (element) => element.type === Menu); @@ -221,29 +218,4 @@ describe("IssueDetailPanel provider labels", () => { // here, while the MenuItem assertion above checks the child mounted in that root. expect(markup).toContain('aria-haspopup="menu"'); }); - - it("reports refreshed tab status", () => { - effects.length = 0; - const onStateChange = vi.fn(); - - IssueDetailPanel({ - environmentId: "environment-1" as EnvironmentId, - reference: { - projectId: "project-1" as IssueDetail["projectId"], - repository: "acme/project", - number: 42, - }, - handoffTarget: { kind: "new-thread" }, - onStateChange, - }); - effects.forEach((effect) => effect()); - - expect(onStateChange).toHaveBeenCalledWith({ - projectId: "project-1", - repository: "acme/project", - number: 42, - state: "open", - stateReason: null, - }); - }); }); diff --git a/apps/web/src/components/issue/IssueDetailPanel.tsx b/apps/web/src/components/issue/IssueDetailPanel.tsx index 7c97d09746ee..710c59504a16 100644 --- a/apps/web/src/components/issue/IssueDetailPanel.tsx +++ b/apps/web/src/components/issue/IssueDetailPanel.tsx @@ -157,7 +157,6 @@ export function IssueDetailPanel({ refreshToken: forcedRefreshToken = 0, onActed, onStateChange, - onRefreshPendingChange, onOpenLinkedPullRequest, chromeVariant = "full", }: { @@ -184,7 +183,6 @@ export function IssueDetailPanel({ state: IssueState; stateReason: IssueCloseReason | null; }) => void; - onRefreshPendingChange?: (pending: boolean) => void; /** * Opens one of the change requests that reference this issue, as a peer tab beside it. Supplied * by whoever mounted the panel, because only they know which panel the tab belongs in; without @@ -294,17 +292,6 @@ export function IssueDetailPanel({ ); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; - const [invalidating, setInvalidating] = useState(false); - const refreshPending = invalidating || detailQuery.isPending || activityQuery.isPending; - useEffect(() => { - onRefreshPendingChange?.(refreshPending); - }, [onRefreshPendingChange, refreshPending]); - useEffect( - () => () => { - onRefreshPendingChange?.(false); - }, - [onRefreshPendingChange], - ); const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); @@ -371,13 +358,8 @@ export function IssueDetailPanel({ // and at worst answer from it. const invalidate = useAtomCommand(issueEnvironment.invalidate, { reportFailure: false }); const refreshFromHost = useCallback(async () => { - setInvalidating(true); - try { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - } finally { - setInvalidating(false); - } + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page, rather than by the menu item below. const appliedForcedToken = useRef(forcedRefreshToken); diff --git a/apps/web/src/components/issue/IssuesPanel.test.tsx b/apps/web/src/components/issue/IssuesPanel.test.tsx deleted file mode 100644 index 3ffbd70b7ed5..000000000000 --- a/apps/web/src/components/issue/IssuesPanel.test.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { ProjectId, type IssueListEntry } from "@t3tools/contracts"; -import type { ReactElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { reactHookHarness as hooks } from "../../test/reactHookHarness"; - -let searchedError: string | null = null; -let searchedDataAvailable = true; - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - const { reactHookHarness } = await import("../../test/reactHookHarness"); - return { - ...actual, - useCallback: reactHookHarness.useCallback, - 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 }; -}); - -const matchingIssue = { - provider: "github", - host: "github.com", - projectId: ProjectId.make("project-1"), - projectTitle: "T3 Code", - repository: "pingdotgg/t3code", - number: 7606, - title: "[Bug]: Pull request screens are missing pointer cursors on clickable elements", - url: "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/pingdotgg/t3code/issues/7606", - author: { login: "octocat", name: null, avatarUrl: null }, - state: "open", - stateReason: null, - createdAt: "2026-08-20T00:00:00Z", - updatedAt: "2026-08-20T00:00:00Z", - closedAt: null, - assignees: [], - labels: [], - milestone: null, - commentCount: 0, -} as IssueListEntry; - -vi.mock("~/state/issues", () => ({ - issueEnvironment: { - list: ({ input }: { input: { readonly query?: string } }) => - input.query === undefined ? "baseline" : "searched", - }, -})); -vi.mock("~/state/queries", () => ({ - useDebouncedValue: (value: string) => value, -})); -vi.mock("~/state/query", () => ({ - useEnvironmentQuery: (query: "baseline" | "searched") => ({ - data: - query === "baseline" - ? { - viewers: {}, - providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], - entries: [matchingIssue], - errors: [], - truncated: false, - nextCursors: {}, - } - : searchedDataAvailable - ? { - viewers: {}, - providers: [{ kind: "github", host: "github.com", searchesOnHost: false }], - entries: [], - errors: [], - truncated: false, - nextCursors: {}, - } - : null, - error: query === "searched" ? searchedError : null, - isPending: false, - refresh: () => undefined, - }), -})); - -import { IssuesPanel } from "./IssuesPanel"; - -function renderSearch(query: string): string { - hooks.reset(); - hooks.beginRender(); - const project = IssuesPanel({ - environmentId: "environment-1" as never, - projectId: "project-1" as never, - selected: null, - onSelect: () => undefined, - handoffTarget: { kind: "new-thread" }, - onStateChange: () => undefined, - }) as ReactElement; - const list = (project.type as (props: unknown) => ReactElement)(project.props) as ReactElement<{ - readonly query: string; - }>; - - hooks.reset(); - hooks.beginRender(); - const browser = (list.type as (props: unknown) => ReactElement)({ ...list.props, query }); - return renderToStaticMarkup(browser); -} - -describe("IssuesPanel", () => { - it("keeps a title match when a locally searched host returns no remote matches", () => { - expect(renderSearch("pointer cursors")).toContain(matchingIssue.title); - }); - - it("limits the visible search input to the query sent to the host", () => { - expect(renderSearch("")).toContain('maxLength="200"'); - }); - - it("keeps retained rows actionable when loading the next slice fails", () => { - searchedError = "The host stopped responding"; - const markup = renderSearch("pointer cursors"); - searchedError = null; - - expect(markup).toContain("The latest request failed. Showing the last issues loaded."); - expect(markup).toContain("Retry"); - }); - - it("keeps Retry visible when a null failed continuation is filtered to zero rows", () => { - searchedError = "The host stopped responding"; - searchedDataAvailable = false; - const markup = renderSearch("unmatched query"); - searchedError = null; - searchedDataAvailable = true; - - expect(markup).toContain("The host stopped responding"); - expect(markup).toContain("Retry"); - }); -}); diff --git a/apps/web/src/components/issue/IssuesPanel.tsx b/apps/web/src/components/issue/IssuesPanel.tsx index a07166de8ec4..9641d2f066e0 100644 --- a/apps/web/src/components/issue/IssuesPanel.tsx +++ b/apps/web/src/components/issue/IssuesPanel.tsx @@ -34,7 +34,6 @@ import { ListGhost } from "../sourceControl/ListGhosts"; import { filterIssuesByInvolvement, issueEntryKey, - matchesIssueQuery, rankIssueMatches, type IssueViewers, } from "./issueList.logic"; @@ -125,7 +124,6 @@ function ProjectIssues({ // the search that found it — the list is unmounted while the issue is open. const [query, setQuery] = useState(""); const [page, setPage] = useState({ key: "", size: PAGE_SIZE, cursors: null }); - const [refreshPending, setRefreshPending] = useState(false); const [filters, setFilters] = useState<{ readonly state: IssueListState; readonly involvement: IssueInvolvement; @@ -161,7 +159,6 @@ function ProjectIssues({ number: selected.number, }} handoffTarget={handoffTarget} - onActed={() => setRefreshPending(true)} onStateChange={onStateChange} {...(onOpenLinkedPullRequest ? { onOpenLinkedPullRequest } : {})} // The panel is the narrowest place this reads, so the metadata folds into the top row @@ -183,8 +180,6 @@ function ProjectIssues({ onPage={setPage} filters={filters} onFilters={setFilters} - refreshPending={refreshPending} - onRefreshConsumed={() => setRefreshPending(false)} /> ); } @@ -199,8 +194,6 @@ function IssueBrowserList({ onPage, filters, onFilters, - refreshPending, - onRefreshConsumed, }: { environmentId: EnvironmentId; projectId: ProjectId; @@ -211,10 +204,8 @@ function IssueBrowserList({ onPage: (page: PanelPage) => void; filters: PanelFilters; onFilters: (filters: PanelFilters) => void; - refreshPending: boolean; - onRefreshConsumed: () => void; }) { - const typed = query.trim(); + const typed = query.trim().slice(0, MAX_QUERY_LENGTH); // Searching asks the host, which takes a round trip, so the text is held for a moment before it // is sent — the same bargain the issues page makes. const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); @@ -248,20 +239,6 @@ function IssueBrowserList({ }), ); const answered = listQuery.data; - const baselineQuery = useEnvironmentQuery( - issueEnvironment.list({ - environmentId, - input: { - state: filters.state, - involvement: filters.involvement, - projectId, - limit: PAGE_SIZE, - sort: filters.sort, - order: filters.order, - }, - }), - ); - const baseline = baselineQuery.data; const githubSortingAvailable = answered?.providers.some((provider) => provider.kind === "github") ?? false; @@ -314,29 +291,15 @@ function IssueBrowserList({ const entries = useMemo(() => { const shown = ordered?.key === filterKey ? ordered : null; const held = shown?.entries ?? answered?.entries ?? []; - const searchingHosts = new Set( - (baseline?.providers ?? answered?.providers ?? []) - .filter((provider) => provider.searchesOnHost) - .map((provider) => provider.host), - ); - const queried = - typed.length === 0 - ? held - : [ - ...held.filter((entry) => searchingHosts.has(entry.host)), - ...(baseline?.entries ?? held).filter( - (entry) => !searchingHosts.has(entry.host) && matchesIssueQuery(entry, typed), - ), - ]; const byInvolvement = filterIssuesByInvolvement( - queried, - shown?.viewers ?? answered?.viewers ?? baseline?.viewers ?? {}, + held, + shown?.viewers ?? answered?.viewers ?? {}, filters.involvement, ); return filters.label === undefined ? byInvolvement : byInvolvement.filter((entry) => entry.labels.some((label) => label.name === filters.label)); - }, [answered, baseline, filterKey, filters.involvement, filters.label, ordered, typed]); + }, [answered, filterKey, filters.involvement, filters.label, ordered]); /** From what is held rather than from the read in flight, which has not answered yet. */ const truncated = ordered?.key === filterKey ? ordered.truncated : (answered?.truncated ?? false); @@ -353,30 +316,6 @@ function IssueBrowserList({ ); }; - 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(); - onRefreshConsumed(); - }, [onRefreshConsumed, refreshList, refreshPending]); - const sentinelRef = useRef(null); useEffect(() => { const sentinel = sentinelRef.current; @@ -443,17 +382,15 @@ function IssueBrowserList({ }), [onSelect, projectId], ); - const retainedDataError = listQuery.error !== null && listQuery.data !== null; return (
onQuery(event.target.value.slice(0, MAX_QUERY_LENGTH))} + onChange={(event) => onQuery(event.target.value)} />
) : listQuery.error !== null && listQuery.data === null ? ( -
- {listQuery.error} - -
+

{listQuery.error}

) : entries.length === 0 ? (

@@ -536,14 +468,6 @@ function IssueBrowserList({ ) : null} )} - {retainedDataError ? ( -

- The latest request failed. Showing the last issues loaded. - -
- ) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx index bf57b261ceba..73f8d91bbacb 100644 --- a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -51,37 +51,33 @@ function ChecksBody({ checks }: { checks: ReadonlyArray }) { if (checks.length === 0) { return

No checks reported

; } - const keyOccurrences = new Map(); return (
    - {checks.map((check) => { - const baseKey = check.url ?? `${check.name}:${check.status}:${check.description ?? ""}`; - const occurrence = keyOccurrences.get(baseKey) ?? 0; - keyOccurrences.set(baseKey, occurrence + 1); - return ( -
  • - - - {check.name}} - /> - {check.description ?? check.name} - - - {pullRequestCheckStatusLabel(check.status)} - - {check.url === null ? null : ( - - )} -
  • - ); - })} + {/* Keyed by position as well as by name: the host is the one that decides how many runs + share a name, and a repeated key is a rendering fault rather than a wrong list. */} + {checks.map((check, index) => ( +
  • + + + {check.name}} + /> + {check.description ?? check.name} + + + {pullRequestCheckStatusLabel(check.status)} + + {check.url === null ? null : ( + + )} +
  • + ))}
); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index db7f58a3d220..2846fb05ab4d 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -76,7 +76,6 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -371,10 +370,8 @@ export function PullRequestDetailPanel({ reference, refreshToken: forcedRefreshToken = 0, onActed, - onTitleSaved, onClose, onStateChange, - onRefreshPendingChange, onOpenLinkedIssue, context = "page", composerDraftTarget, @@ -392,7 +389,6 @@ export function PullRequestDetailPanel({ * Told rather than assumed: only the page knows whether it is showing one. */ onActed?: () => void; - onTitleSaved?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; /** Keeps compact chrome, such as the right-panel tab, in step with refreshed host state. */ @@ -403,7 +399,6 @@ export function PullRequestDetailPanel({ state: PullRequestState; isDraft: boolean; }) => void; - onRefreshPendingChange?: (pending: boolean) => void; /** * Opens one of the issues this pull request references, as a peer tab beside it. Supplied by * whoever mounted the panel, because only they know which panel the tab belongs in. @@ -489,7 +484,6 @@ export function PullRequestDetailPanel({ const _diffWarmUpQuery = useEnvironmentQuery( pullRequestEnvironment.diff({ environmentId, input: { ...reference } }), ); - const [invalidating, setInvalidating] = useState(false); const coreDetail = detailQuery.data; const activity = activityQuery.data; const detail = useMemo( @@ -528,18 +522,6 @@ export function PullRequestDetailPanel({ isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; - const retainedActivityError = activity === null ? null : activityQuery.error; - const refreshPending = - invalidating || detailQuery.isPending || activityQuery.isPending || _diffWarmUpQuery.isPending; - useEffect(() => { - onRefreshPendingChange?.(refreshPending); - }, [onRefreshPendingChange, refreshPending]); - useEffect( - () => () => { - onRefreshPendingChange?.(false); - }, - [onRefreshPendingChange], - ); const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); @@ -579,14 +561,9 @@ export function PullRequestDetailPanel({ const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [refreshToken, setRefreshToken] = useState(0); const refreshFromHost = useCallback(async () => { - setInvalidating(true); - try { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); - } finally { - setInvalidating(false); - } + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -718,7 +695,6 @@ export function PullRequestDetailPanel({ } setTitleScope(null); refreshDetail(); - onTitleSaved?.(); }; type ThreadTask = { @@ -1978,22 +1954,6 @@ export function PullRequestDetailPanel({ ) : detail ? ( <> - {(coreDetail !== null && detailQuery.error !== null) || - (activity !== null && retainedActivityError !== null) ? ( -
- - - Showing stale pull request data - {detailQuery.error ?? retainedActivityError} - - - - -
- ) : null} {mountedTabs.has("summary") ? (
{ const state = new Map(); const setters = new Map void>(); const effects = new Map | undefined>(); - const cleanups = new Map void>(); - const deferredEffects = new Map void | (() => void)>(); - let deferEffects = false; const memos = new Map< number, { readonly dependencies: ReadonlyArray; readonly value: unknown } @@ -26,9 +23,6 @@ const hooks = vi.hoisted(() => { state.clear(); setters.clear(); effects.clear(); - cleanups.clear(); - deferredEffects.clear(); - deferEffects = false; memos.clear(); }, unmountFrom: (firstIndex: number) => { @@ -39,11 +33,7 @@ const hooks = vi.hoisted(() => { if (index >= firstIndex) setters.delete(index); } for (const index of effects.keys()) { - if (index >= firstIndex) { - cleanups.get(index)?.(); - cleanups.delete(index); - effects.delete(index); - } + if (index >= firstIndex) effects.delete(index); } for (const index of memos.keys()) { if (index >= firstIndex) memos.delete(index); @@ -53,17 +43,6 @@ const hooks = vi.hoisted(() => { hookIndex += 1; return callback; }, - deferEffects: () => { - deferEffects = true; - }, - flushEffects: () => { - for (const [index, effect] of deferredEffects) { - const cleanup = effect(); - if (cleanup) cleanups.set(index, cleanup); - } - deferredEffects.clear(); - deferEffects = false; - }, useEffect: (effect: () => void | (() => void), dependencies: ReadonlyArray) => { const index = hookIndex++; const previous = effects.get(index); @@ -73,13 +52,7 @@ const hooks = vi.hoisted(() => { previous.length !== dependencies.length || previous.some((value, dependencyIndex) => value !== dependencies[dependencyIndex]) ) { - cleanups.get(index)?.(); - cleanups.delete(index); - if (deferEffects) deferredEffects.set(index, effect); - else { - const cleanup = effect(); - if (cleanup) cleanups.set(index, cleanup); - } + effect(); } }, useMemo: (factory: () => T, dependencies: ReadonlyArray) => { @@ -133,13 +106,16 @@ vi.mock("react", async (importOriginal) => ({ useState: hooks.useState, })); +vi.mock("effect/Option", () => ({ 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) => ({ - additions: 0, - deletions: 0, host: "github.com", labels: [], number: index + 1, @@ -166,129 +142,44 @@ const queries = vi.hoisted(() => { }; }); -const stats = vi.hoisted(() => { - const subscriptions: Array<{ - readonly atom: unknown; - readonly callback: (result: unknown) => void; - active: boolean; - }> = []; - return { - targets: [] as ReadonlyArray[], - events: [] as string[], - subscriptions, - cleanups: [] as unknown[], - refreshes: [] as unknown[], - value: [] as ReadonlyArray, - emit: (atom: unknown, result: unknown) => { - let emitted = 0; - for (const subscription of subscriptions) { - if (subscription.atom === atom && subscription.active) { - subscription.callback(result); - emitted += 1; - } - } - return emitted; - }, - }; -}); - -const debounce = vi.hoisted(() => ({ value: null as string | null })); +const stats = vi.hoisted(() => ({ + targets: [] as ReadonlyArray[], + value: [] as ReadonlyArray, + refresh: vi.fn(), +})); const buttons = vi.hoisted(() => new Map void>()); -const inputs = vi.hoisted(() => new Map void>()); -const inputMaxLengths = vi.hoisted(() => new Map()); const observers = vi.hoisted( () => [] as Array<(entries: ReadonlyArray<{ readonly isIntersecting: boolean }>) => void>, ); const detailActions = vi.hoisted(() => [] as Array<() => void>); -const detailTitleSaves = vi.hoisted(() => [] as Array<(() => void) | undefined>); const rowEntries = vi.hoisted(() => [] as Array); vi.mock("~/state/pullRequests", () => ({ - usePullRequestListStats: ( - targets: ReadonlyArray<{ readonly environmentId: string; readonly input: unknown }>, - ) => { - const [results, setResults] = hooks.useState>(new Map()); - hooks.useEffect(() => { - const subscriptions = targets.map((target, index) => { - stats.targets.push([target]); - const atom = target; - const update = (result: unknown) => { - const value = - typeof result === "object" && result !== null && "stats" in result - ? (result.stats as ReadonlyArray | undefined) - : undefined; - setResults((previous) => { - const next = new Map(previous); - next.set(index, value ?? []); - return next; - }); - }; - stats.events.push("subscribe"); - const subscription = { atom, callback: update, active: true }; - stats.subscriptions.push(subscription); - stats.events.push("read"); - update({ stats: stats.value }); - return () => { - subscription.active = false; - stats.cleanups.push(atom); - }; - }); - return () => { - for (const unsubscribe of subscriptions) unsubscribe(); - }; - }, [targets]); - const merged = [...results.entries()].flatMap(([index, value]) => - (value as ReadonlyArray>).map((stat) => ({ - ...stat, - environmentId: targets[index]?.environmentId, - })), - ); - return { - stats: merged.length === 0 ? null : merged, - refresh: () => { - for (const target of targets) stats.refreshes.push(target); - }, - }; - }, pullRequestEnvironment: { invalidate: {}, list: (input: unknown) => input, - listStats: (input: unknown) => { - stats.targets.push([input]); - return input; + listStats: (target: unknown) => { + stats.targets.push([target]); + return target; }, }, })); vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { - get: (_atom: unknown) => { - stats.events.push("read"); - return { stats: stats.value }; - }, - subscribe: (atom: unknown, callback: (result: unknown) => void) => { - stats.events.push("subscribe"); - const subscription = { atom, callback, active: true }; - stats.subscriptions.push(subscription); - return () => { - subscription.active = false; - stats.cleanups.push(atom); - }; - }, - refresh: (atom: unknown) => stats.refreshes.push(atom), + get: () => ({ value: { stats: stats.value } }), + refresh: stats.refresh, + subscribe: () => () => undefined, }, })); -vi.mock("effect/Option", () => ({ getOrNull: (value: T) => value })); -vi.mock("effect/unstable/reactivity", () => ({ AsyncResult: { value: (value: T) => value } })); - vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => undefined, })); vi.mock("~/state/queries", () => ({ - useDebouncedValue: (value: string) => debounce.value ?? value, + useDebouncedValue: (value: string) => value, })); vi.mock("~/state/query", () => ({ @@ -321,38 +212,13 @@ vi.mock("../ui/button", () => ({ }, })); -vi.mock("../ui/input", () => ({ - Input: ({ - "aria-label": ariaLabel, - maxLength, - onChange, - }: { - "aria-label"?: string; - maxLength?: number; - onChange?: (event: { readonly target: { readonly value: string } }) => void; - }) => { - if (ariaLabel && onChange) { - inputs.set(ariaLabel, (value) => onChange({ target: { value } })); - inputMaxLengths.set(ariaLabel, maxLength); - } - return ; - }, -})); - vi.mock("../ui/scroll-area", () => ({ ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
, })); vi.mock("./PullRequestListFilters", () => ({ PullRequestFiltersMenu: () => null })); vi.mock("./PullRequestDetailPanel", () => ({ - PullRequestDetailPanel: ({ - onActed, - onTitleSaved, - }: { - onActed: () => void; - onTitleSaved?: () => void; - }) => { + PullRequestDetailPanel: ({ onActed }: { onActed: () => void }) => { detailActions.push(onActed); - detailTitleSaves.push(onTitleSaved); return null; }, })); @@ -364,375 +230,6 @@ vi.mock("./PullRequestRow", () => ({ })); describe("PullRequestsPanel", () => { - it("subscribes to line-count atoms only after the panel render", () => { - hooks.reset(); - stats.events.length = 0; - stats.subscriptions.length = 0; - stats.cleanups.length = 0; - stats.refreshes.length = 0; - stats.targets.length = 0; - stats.value = []; - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - const render = () => { - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - }; - - hooks.deferEffects(); - render(); - expect(stats.events).toEqual([]); - hooks.flushEffects(); - - expect(stats.events).toEqual(["subscribe", "read"]); - expect(stats.subscriptions.map((subscription) => subscription.atom)).toEqual( - stats.targets.at(-1), - ); - }); - - it("merges later stats emissions and ignores emissions after target cleanup", () => { - hooks.reset(); - stats.events.length = 0; - stats.subscriptions.length = 0; - stats.cleanups.length = 0; - stats.refreshes.length = 0; - stats.targets.length = 0; - stats.value = []; - rowEntries.length = 0; - 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(); - const initialAtom = stats.subscriptions[0]?.atom; - if (initialAtom === undefined) throw new Error("Expected initial stats subscription."); - expect( - stats.emit(initialAtom, { - stats: [ - { - projectId: "project-1", - repository: "owner/repository", - number: 1, - additions: 5, - deletions: 2, - }, - ], - }), - ).toBe(1); - render(); - render(); - - const updatedEntry = rowEntries - .filter( - (entry): entry is { readonly number: number; readonly additions?: number } => - typeof entry === "object" && entry !== null && "number" in entry, - ) - .findLast((entry) => entry.number === 1); - expect(updatedEntry).toMatchObject({ additions: 5, deletions: 2, environmentId: "env-1" }); - - observers.at(-1)?.([{ isIntersecting: true }]); - render(); - render(); - stats.emit(initialAtom, { - stats: [ - { - projectId: "project-1", - repository: "owner/repository", - number: 1, - additions: 99, - deletions: 99, - }, - ], - }); - render(); - - const entryAfterCleanup = rowEntries - .filter( - (entry): entry is { readonly number: number; readonly additions?: number } => - typeof entry === "object" && entry !== null && "number" in entry, - ) - .findLast((entry) => entry.number === 1); - expect(stats.cleanups).toContain(initialAtom); - expect(entryAfterCleanup).toMatchObject({ additions: 5, deletions: 2 }); - }); - - it("replaces line-count subscriptions when paged targets change", () => { - hooks.reset(); - stats.events.length = 0; - stats.subscriptions.length = 0; - stats.cleanups.length = 0; - stats.refreshes.length = 0; - stats.value = []; - 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(); - const initialAtom = stats.subscriptions[0]?.atom; - if (initialAtom === undefined) throw new Error("Expected initial stats subscription."); - observers.at(-1)?.([{ isIntersecting: true }]); - render(); - render(); - - expect(stats.cleanups).toContain(initialAtom); - expect( - stats.subscriptions.find((subscription) => subscription.atom === initialAtom)?.active, - ).toBe(false); - }); - - it("refreshes each current line-count atom once per refresh generation", async () => { - hooks.reset(); - stats.events.length = 0; - stats.subscriptions.length = 0; - stats.cleanups.length = 0; - stats.refreshes.length = 0; - stats.value = []; - buttons.clear(); - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - const render = () => { - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - }; - - render(); - buttons.get("Refresh pull requests")?.(); - await Promise.resolve(); - await Promise.resolve(); - render(); - render(); - - expect(stats.refreshes).toEqual( - stats.subscriptions.slice(-1).map((subscription) => subscription.atom), - ); - }); - - it("caps compact pull request searches at the host query limit", () => { - hooks.reset(); - inputMaxLengths.clear(); - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - - expect(inputMaxLengths.get("Search pull requests")).toBe(200); - }); - - it("unmounts line-count stats while a compact search is typed then cleared", () => { - hooks.reset(); - stats.targets.length = 0; - stats.value = []; - debounce.value = null; - inputs.clear(); - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - const render = () => { - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - }; - - render(); - const settledTargetCount = stats.targets.length; - debounce.value = ""; - inputs.get("Search pull requests")?.("x".repeat(200)); - render(); - expect(stats.targets).toHaveLength(settledTargetCount); - - inputs.get("Search pull requests")?.(""); - render(); - - expect(stats.targets).toHaveLength(settledTargetCount + 1); - debounce.value = null; - }); - - it("does not refresh line-count stats after a refresh occurs while compact search targets are empty", async () => { - hooks.reset(); - stats.targets.length = 0; - stats.value = []; - stats.refreshes.length = 0; - debounce.value = null; - inputs.clear(); - buttons.clear(); - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - const render = () => { - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - }; - - render(); - debounce.value = ""; - inputs.get("Search pull requests")?.("x".repeat(200)); - render(); - hooks.unmountFrom(28); - - buttons.get("Refresh pull requests")?.(); - await Promise.resolve(); - await Promise.resolve(); - render(); - - inputs.get("Search pull requests")?.(""); - render(); - - expect(stats.refreshes).toHaveLength(0); - debounce.value = null; - }); - - it("does not refresh line-count stats again after targets remount without another refresh", async () => { - hooks.reset(); - stats.targets.length = 0; - stats.value = []; - stats.refreshes.length = 0; - debounce.value = null; - inputs.clear(); - buttons.clear(); - vi.stubGlobal( - "IntersectionObserver", - class { - disconnect() {} - observe() {} - }, - ); - - const render = () => { - hooks.beginRender(); - renderToStaticMarkup( - undefined} - />, - ); - }; - - render(); - buttons.get("Refresh pull requests")?.(); - await Promise.resolve(); - await Promise.resolve(); - render(); - expect(stats.refreshes).toHaveLength(1); - - debounce.value = ""; - inputs.get("Search pull requests")?.("x".repeat(200)); - render(); - hooks.unmountFrom(28); - inputs.get("Search pull requests")?.(""); - render(); - - expect(stats.refreshes).toHaveLength(1); - debounce.value = null; - }); - it("refreshes the accumulated list from the first page after cursor pagination", async () => { hooks.reset(); queries.inputs.length = 0; @@ -786,7 +283,7 @@ describe("PullRequestsPanel", () => { queries.inputs.length = 0; stats.targets.length = 0; stats.value = []; - stats.refreshes.length = 0; + stats.refresh.mockReset(); observers.length = 0; vi.stubGlobal( "IntersectionObserver", @@ -845,9 +342,8 @@ describe("PullRequestsPanel", () => { buttons.get("Refresh pull requests")?.(); await Promise.resolve(); await Promise.resolve(); - render(); - expect(stats.refreshes).toHaveLength(1); + expect(stats.refresh).toHaveBeenCalledTimes(1); expect( ( stats.targets.at(-1) as Array<{ @@ -900,7 +396,6 @@ describe("PullRequestsPanel", () => { render(); render(); render(); - render(); expect(rowEntries.at(-30)).toBe(rowEntries.at(-60)); }); @@ -909,7 +404,7 @@ describe("PullRequestsPanel", () => { hooks.reset(); queries.inputs.length = 0; queries.refresh.mockReset(); - stats.refreshes.length = 0; + stats.refresh.mockReset(); stats.targets.length = 0; stats.value = []; buttons.clear(); @@ -960,63 +455,6 @@ describe("PullRequestsPanel", () => { expect(queries.inputs.at(-1)?.input).toMatchObject({ limit: 60 }); expect(queries.inputs.at(-1)?.input.cursors).toBeUndefined(); expect(queries.refresh).not.toHaveBeenCalled(); - expect(stats.refreshes).toHaveLength(1); - }); - - it("replays a saved title after the list remounts", () => { - hooks.reset(); - queries.inputs.length = 0; - queries.refresh.mockReset(); - stats.refreshes.length = 0; - stats.targets.length = 0; - stats.value = []; - detailTitleSaves.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 }); - detailTitleSaves.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.refreshes).toHaveLength(1); + expect(stats.refresh).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/web/src/components/pullRequest/PullRequestsPanel.tsx b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx index a57901a53aec..ef87fe8d2480 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPanel.tsx @@ -6,10 +6,13 @@ import type { 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, @@ -31,7 +34,8 @@ import { type SetStateAction, } from "react"; -import { pullRequestEnvironment, usePullRequestListStats } from "~/state/pullRequests"; +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"; @@ -51,10 +55,11 @@ import { parsePullRequestQuery, pullRequestEntryKey, pullRequestEntryViewer, - pullRequestStatsTargetsForSettledResults, + pullRequestStatsTargets, prunePullRequestDiffStats, rankPullRequestMatches, type EnvironmentPullRequestEntry, + type EnvironmentPullRequestStat, type PullRequestDiffStats, } from "./pullRequestList.logic"; import { PullRequestDetailPanel } from "./PullRequestDetailPanel"; @@ -106,6 +111,50 @@ interface PanelPage { 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 ; } @@ -147,9 +196,7 @@ function ProjectPullRequests(props: PullRequestsPanelProps) { environmentId={props.environmentId} reference={props.selected} context="page" - chromeVariant="collapse" onActed={() => setRefreshPending(true)} - onTitleSaved={() => setRefreshPending(true)} {...(props.onStateChange ? { onStateChange: props.onStateChange } : {})} {...(props.onOpenLinkedIssue ? { onOpenLinkedIssue: props.onOpenLinkedIssue } : {})} {...(props.composerDraftTarget @@ -235,11 +282,8 @@ function PullRequestBrowserList({ refreshPending: boolean; onRefreshConsumed: () => void; }) { - const [statsRefreshGeneration, setStatsRefreshGeneration] = useState(0); - const handledStatsRefreshGeneration = useRef(0); const typed = query.trim().slice(0, 200); const sent = useDebouncedValue(typed, SEARCH_DEBOUNCE_MS); - const querySettled = typed === sent; const typedParsed = useMemo(() => parsePullRequestQuery(typed), [typed]); const sentParsed = useMemo(() => parsePullRequestQuery(sent), [sent]); const requestFilters = useMemo( @@ -330,24 +374,15 @@ function PullRequestBrowserList({ typedParsed.text, ]); - const statsTargets = useMemo( - () => pullRequestStatsTargetsForSettledResults(entries, querySettled, false), - [entries, querySettled], - ); - const statsQuery = usePullRequestListStats(statsTargets); + const statsTargets = useMemo(() => pullRequestStatsTargets(entries), [entries]); useEffect(() => { onStatsByRow((previous) => prunePullRequestDiffStats(previous, entries)); }, [entries, onStatsByRow]); + const statsQuery = usePanelPullRequestListStats(statsTargets); useEffect(() => { - const stats = statsQuery.stats; - if (stats === null) return; - onStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); + if (statsQuery.stats === null) return; + onStatsByRow((previous) => mergePullRequestDiffStats(previous, statsQuery.stats ?? [])); }, [onStatsByRow, statsQuery.stats]); - useEffect(() => { - if (handledStatsRefreshGeneration.current === statsRefreshGeneration) return; - handledStatsRefreshGeneration.current = statsRefreshGeneration; - statsQuery.refresh(); - }, [handledStatsRefreshGeneration, statsQuery, statsRefreshGeneration]); const decorationCache = useRef< ReadonlyMap >(new Map()); @@ -401,9 +436,9 @@ function PullRequestBrowserList({ if (consumedRefresh.current) return; consumedRefresh.current = true; refreshList(); - setStatsRefreshGeneration((generation) => generation + 1); + statsQuery.refresh(); onRefreshConsumed(); - }, [onRefreshConsumed, refreshList, refreshPending]); + }, [onRefreshConsumed, refreshList, refreshPending, statsQuery.refresh]); const sentinelRef = useRef(null); useEffect(() => { @@ -453,8 +488,8 @@ function PullRequestBrowserList({ setInvalidating(false); } refreshList(); - setStatsRefreshGeneration((generation) => generation + 1); - }, [environmentId, invalidate, refreshList]); + statsQuery.refresh(); + }, [environmentId, invalidate, refreshList, statsQuery.refresh]); const refreshing = invalidating || listQuery.isPending; return ( @@ -462,7 +497,6 @@ function PullRequestBrowserList({
onQuery(event.target.value)} diff --git a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx index 6e97edb4f14d..6c2709d677bb 100644 --- a/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx +++ b/apps/web/src/components/pullRequest/pullRequestChecks.test.tsx @@ -1,5 +1,5 @@ import type { EnvironmentId, ProjectId, PullRequestCheck } from "@t3tools/contracts"; -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { Children, isValidElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; @@ -8,14 +8,6 @@ import type { EnvironmentPullRequestEntry } from "./pullRequestList.logic"; import { PullRequestRow } from "./PullRequestRow"; import { pullRequestChecksState } from "./pullRequestPresentation"; -function checksPopoverTrigger(): ReactElement<{ nativeButton?: boolean }> | undefined { - const popover = PullRequestChecksPopover({ checksState: "failing" }); - return Children.toArray(popover.props.children).find( - (element): element is ReactElement<{ nativeButton?: boolean }> => - isValidElement<{ nativeButton?: boolean }>(element) && "nativeButton" in element.props, - ); -} - function check(status: PullRequestCheck["status"]): PullRequestCheck { return { name: `check-${status}`, status, description: null, url: null }; } @@ -96,8 +88,4 @@ describe("PullRequestRow checks indicator", () => { it("uses the shared source control row frame", () => { expect((row({}) as { type: unknown }).type).toBe(ListRow); }); - - it("disables native button semantics for its span trigger inside a row button", () => { - expect(checksPopoverTrigger()?.props.nativeButton).toBe(false); - }); }); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index da1cf0e64c25..2a48906661d3 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -16,8 +16,6 @@ import { narrowPullRequestsToFilters, partitionPullRequestsWithPriority, pullRequestStatsTargets, - pullRequestStatsTargetsForSettledResults, - pullRequestVisibleEntries, prunePullRequestDiffStats, readPullRequestListSnapshot, writePullRequestListSnapshot, @@ -509,20 +507,6 @@ describe("line counts that arrive after the rows", () => { }); describe("line-count query targets", () => { - it("does not retarget line counts while the visible search is ahead of the sent query", () => { - const previousTargets = pullRequestStatsTargetsForSettledResults( - [entry({ number: 1 })], - true, - false, - ); - const rapidTypingTargets = ["a", "ad", "add"].map((query) => - pullRequestStatsTargetsForSettledResults([entry({ number: query.length + 1 })], false, false), - ); - - expect(previousTargets[0]?.input.refs.map((ref) => ref.number)).toEqual([1]); - expect(rapidTypingTargets).toEqual([[], [], []]); - }); - it("drops prior filter and page batches before a refresh", () => { const firstPage = pullRequestStatsTargets([entry({ number: 1 }), entry({ number: 2 })]); const currentFilter = pullRequestStatsTargets([ @@ -549,17 +533,6 @@ describe("line-count query targets", () => { }, ]); }); - - it("includes priority rows rendered outside the feed", () => { - const visible = pullRequestVisibleEntries([ - { key: "others", label: "Others", entries: [entry({ number: 1 })] }, - { key: "authored", label: "Authored", entries: [entry({ number: 2 })] }, - ]); - - expect(pullRequestStatsTargets(visible)[0]?.input.refs.map((ref) => ref.number)).toEqual([ - 1, 2, - ]); - }); }); describe("merging line counts across keyed stats queries", () => { @@ -593,16 +566,6 @@ describe("merging line counts across keyed stats queries", () => { ]); expect(held.get("env-1 project-1 1")).toEqual({ additions: 1, deletions: 1 }); }); - - it("keeps the same map when fresh results repeat every stored count", () => { - const held = new Map([["env-1 project-1 1", { additions: 1, deletions: 1 }]]); - - expect( - mergePullRequestDiffStats(held, [ - { environmentId: "env-1", projectId: "project-1", number: 1, additions: 1, deletions: 1 }, - ]), - ).toBe(held); - }); }); describe("retaining line counts for currently rendered rows", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 3d53b28698b9..c6306185996d 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -312,8 +312,8 @@ export function matchesPullRequestFilters( filters: PullRequestListFilters, viewer?: string | null, ): boolean { - const labels = new Set(entry.labels.map((label) => label.name.trim().toLowerCase())); - const holds = (label: string) => labels.has(label.trim().toLowerCase()); + const labels = entry.labels.map((label) => label.name.trim().toLowerCase()); + const holds = (label: string) => labels.includes(label.trim().toLowerCase()); return ( (filters.draft === undefined || entry.isDraft === (filters.draft === "only")) && (filters.review === undefined || @@ -432,15 +432,11 @@ export function mergePullRequestDiffStats( }>, ): PullRequestDiffStats { if (stats.length === 0) return previous; - let next: Map | undefined; + const next = new Map(previous); for (const stat of stats) { - const key = diffStatKey(stat); - const existing = (next ?? previous).get(key); - if (existing?.additions === stat.additions && existing.deletions === stat.deletions) continue; - next ??= new Map(previous); - next.set(key, { additions: stat.additions, deletions: stat.deletions }); + next.set(diffStatKey(stat), { additions: stat.additions, deletions: stat.deletions }); } - return next ?? previous; + return next; } /** A project id only names a project within its own environment, so the key carries both. */ @@ -461,13 +457,6 @@ export function prunePullRequestDiffStats( return new Map([...previous].filter(([key]) => visible.has(key))); } -/** Returns the raw rows rendered across every priority group. */ -export function pullRequestVisibleEntries( - groups: ReadonlyArray>, -): ReadonlyArray { - return groups.flatMap((group) => group.entries); -} - /** The bounded line-count batches for the rows currently visible in the list. */ export function pullRequestStatsTargets( entries: ReadonlyArray, @@ -501,14 +490,6 @@ export function pullRequestStatsTargets( ); } -export function pullRequestStatsTargetsForSettledResults( - entries: ReadonlyArray, - querySettled: boolean, - showingCarried: boolean, -) { - return querySettled && !showingCarried ? pullRequestStatsTargets(entries) : []; -} - /** * Every connected environment's listing, read as one list. * diff --git a/apps/web/src/components/sidebar/SidebarChrome.test.tsx b/apps/web/src/components/sidebar/SidebarChrome.test.tsx deleted file mode 100644 index 8850f4691c6d..000000000000 --- a/apps/web/src/components/sidebar/SidebarChrome.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { reactHookHarness as hooks } from "../../test/reactHookHarness"; -import { visitElements } from "../../test/reactElementTree"; - -const navigation = vi.hoisted(() => vi.fn<(options: unknown) => void>()); -const rightPanel = vi.hoisted(() => ({ openRepository: vi.fn() })); -const routeState = vi.hoisted(() => ({ - params: { environmentId: "environment-1", threadId: "thread-1" } as Record, - draftSession: null as { - environmentId: string; - threadId: string; - projectId: string; - promotedTo: null; - } | null, - project: { repositoryIdentity: { canonicalKey: "github:acme/repository" } as object | null }, -})); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - const { reactHookHarness } = await import("../../test/reactHookHarness"); - return { - ...actual, - useCallback: reactHookHarness.useCallback, - useMemo: reactHookHarness.useMemo, - }; -}); - -vi.mock("react/compiler-runtime", async () => { - const { reactHookHarness } = await import("../../test/reactHookHarness"); - return { c: reactHookHarness.useMemoCache }; -}); - -vi.mock("@tanstack/react-router", () => ({ - Link: "a", - useCanGoBack: () => false, - useLocation: (options: { select: (location: { pathname: string }) => unknown }) => - options.select({ pathname: "/" }), - useNavigate: () => navigation, - useParams: (options: { select: (params: Record) => unknown }) => - options.select(routeState.params), -})); - -vi.mock("../../composerDraftStore", () => ({ - useComposerDraftStore: ( - select: (store: { getDraftSession: () => typeof routeState.draftSession }) => unknown, - ) => select({ getDraftSession: () => routeState.draftSession }), -})); -vi.mock("../../state/environments", () => ({ - useEnvironments: () => ({ - environments: [ - { - environmentId: "environment-1", - serverConfig: { environment: { capabilities: { issues: true, pullRequests: true } } }, - }, - ], - }), -})); -vi.mock("../../state/entities", () => ({ - useProjects: () => [{ environmentId: "environment-1", id: "project-1", ...routeState.project }], - useThreadShell: () => - routeState.params.threadId === "thread-1" - ? { environmentId: "environment-1", projectId: "project-1" } - : null, -})); -vi.mock("../../rightPanelStore", () => ({ - useRightPanelStore: { getState: () => rightPanel }, -})); -vi.mock("../ui/sidebar", () => ({ - SidebarFooter: "footer", - SidebarHeader: "header", - SidebarMenu: "menu", - SidebarMenuButton: "button", - SidebarMenuItem: "item", - SidebarTrigger: "button", - useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }), -})); -vi.mock("../ui/tooltip", () => ({ - Tooltip: "tooltip", - TooltipPopup: "tooltip-popup", - TooltipTrigger: "tooltip-trigger", -})); - -import { SidebarUtilityMenu } from "./SidebarChrome"; - -function footerAction(label: "Issues" | "Pull Requests"): () => void { - hooks.beginRender(); - const utilityMenu = SidebarUtilityMenu.type(); - const utilityItem = visitElements(utilityMenu, (element) => element.props.label === label); - if (utilityItem === null || typeof utilityItem.type !== "function") { - throw new Error(`Could not find the ${label} footer action.`); - } - const button = visitElements( - utilityItem.type(utilityItem.props), - (element) => element.props["aria-label"] === label, - ); - if (button === null || typeof button.props.onClick !== "function") { - throw new Error(`Could not find the ${label} footer action.`); - } - return button.props.onClick as () => void; -} - -describe("SidebarChromeFooter repository navigation", () => { - beforeEach(() => { - hooks.reset(); - navigation.mockReset(); - rightPanel.openRepository.mockReset(); - routeState.params = { environmentId: "environment-1", threadId: "thread-1" }; - routeState.draftSession = null; - routeState.project = { repositoryIdentity: { canonicalKey: "github:acme/repository" } }; - }); - - it("opens the active repository's pull requests in the repository pane", () => { - footerAction("Pull Requests")(); - - expect(rightPanel.openRepository).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: "environment-1", threadId: "thread-1" }), - "pull-requests", - ); - expect(navigation).not.toHaveBeenCalled(); - }); - - it("falls back to global issues when the active project has no repository identity", () => { - routeState.project = { repositoryIdentity: null }; - - footerAction("Issues")(); - - expect(rightPanel.openRepository).not.toHaveBeenCalled(); - expect(navigation).toHaveBeenCalledWith({ - to: "/issues", - search: { involvement: "all", state: "open" }, - }); - }); - - it("opens repository actions for an unpromoted draft in its project", () => { - routeState.params = { draftId: "draft-1" }; - routeState.draftSession = { - environmentId: "environment-1", - threadId: "draft-thread-1", - projectId: "project-1", - promotedTo: null, - }; - - footerAction("Issues")(); - footerAction("Pull Requests")(); - - expect(rightPanel.openRepository).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: "environment-1", threadId: "draft-thread-1" }), - "issues", - ); - expect(rightPanel.openRepository).toHaveBeenCalledWith( - expect.objectContaining({ environmentId: "environment-1", threadId: "draft-thread-1" }), - "pull-requests", - ); - expect(navigation).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ab4d19f9b212..05a02b37f53d 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -6,17 +6,12 @@ import { SettingsIcon, } from "lucide-react"; import type { ReactNode } from "react"; -import { memo, useCallback, useMemo } from "react"; -import { Link, useCanGoBack, useLocation, useNavigate, useParams } from "@tanstack/react-router"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { memo, useCallback } from "react"; +import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; -import { useComposerDraftStore } from "../../composerDraftStore"; import { cn } from "../../lib/utils"; import { useEnvironments } from "../../state/environments"; -import { useProjects, useThreadShell } from "../../state/entities"; -import { useRightPanelStore } from "../../rightPanelStore"; -import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../../threadRoutes"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, @@ -166,19 +161,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { : null, }); const { environments } = useEnvironments(); - const routeTarget = useParams({ - strict: false, - select: (params) => resolveThreadRouteTarget(params), - }); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = useMemo( - () => resolveActiveThreadRouteRef(routeTarget, routeDraftThread), - [routeDraftThread, routeTarget], - ); - const routeThread = useThreadShell(routeThreadRef); - const projects = useProjects(); // The pages read every connected server, so one of them offering a surface is enough for // its link to lead somewhere. const pullRequestsSupported = environments.some( @@ -187,53 +169,6 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { const issuesSupported = environments.some( (environment) => environment.serverConfig?.environment.capabilities.issues === true, ); - const routeRepositoryContext = useMemo(() => { - if (routeThreadRef !== null && routeThread !== null) { - return { - environmentId: routeThread.environmentId, - projectId: routeThread.projectId, - threadRef: routeThreadRef, - }; - } - if ( - routeTarget?.kind === "draft" && - routeDraftThread !== null && - !routeDraftThread.promotedTo - ) { - return { - environmentId: routeDraftThread.environmentId, - projectId: routeDraftThread.projectId, - threadRef: scopeThreadRef(routeDraftThread.environmentId, routeDraftThread.threadId), - }; - } - return null; - }, [routeDraftThread, routeTarget, routeThread, routeThreadRef]); - const routeEnvironment = useMemo( - () => - environments.find( - (environment) => environment.environmentId === routeRepositoryContext?.environmentId, - ), - [environments, routeRepositoryContext?.environmentId], - ); - const routeProject = useMemo( - () => - routeRepositoryContext === null - ? null - : (projects.find( - (project) => - project.environmentId === routeRepositoryContext.environmentId && - project.id === routeRepositoryContext.projectId, - ) ?? null), - [projects, routeRepositoryContext], - ); - const routeHasRepositoryIdentity = - routeProject !== null && routeProject.repositoryIdentity !== null; - const routePullRequestsSupported = - routeHasRepositoryIdentity && - routeEnvironment?.serverConfig?.environment.capabilities.pullRequests === true; - const routeIssuesSupported = - routeHasRepositoryIdentity && - routeEnvironment?.serverConfig?.environment.capabilities.issues === true; const closeMobileSidebar = useCallback(() => { if (isMobile) { setOpenMobile(false); @@ -241,22 +176,12 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { }, [isMobile, setOpenMobile]); const handlePullRequestsClick = useCallback(() => { closeMobileSidebar(); - if (routeRepositoryContext !== null && routePullRequestsSupported) { - useRightPanelStore - .getState() - .openRepository(routeRepositoryContext.threadRef, "pull-requests"); - return; - } void navigate({ to: "/pull-requests", search: { involvement: "all", state: "open" } }); - }, [closeMobileSidebar, navigate, routePullRequestsSupported, routeRepositoryContext]); + }, [closeMobileSidebar, navigate]); const handleIssuesClick = useCallback(() => { closeMobileSidebar(); - if (routeRepositoryContext !== null && routeIssuesSupported) { - useRightPanelStore.getState().openRepository(routeRepositoryContext.threadRef, "issues"); - return; - } void navigate({ to: "/issues", search: { involvement: "all", state: "open" } }); - }, [closeMobileSidebar, navigate, routeIssuesSupported, routeRepositoryContext]); + }, [closeMobileSidebar, navigate]); const handleSettingsClick = useCallback(() => { closeMobileSidebar(); void navigate({ to: "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/settings" }); diff --git a/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx b/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx index af0c69b5f2cf..98cc6bd82634 100644 --- a/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx +++ b/apps/web/src/components/sourceControl/ListFilterMenu.test.tsx @@ -96,28 +96,6 @@ describe("list filter menu", () => { expect(field.props.type).toBe("search"); }); - it("caps full-page search input and reports the capped value", () => { - const onChange = vi.fn(); - const input = ListSearchInput({ - label: "Search issues", - value: "", - onChange, - }); - const field = Children.toArray(input.props.children).find( - (child) => - isValidElement(child) && - (child.props as { readonly "aria-label"?: string })["aria-label"] === "Search issues", - ) as ReactElement<{ - readonly maxLength: number; - readonly onChange: (event: { readonly currentTarget: { readonly value: string } }) => void; - }>; - - field.props.onChange({ currentTarget: { value: "x".repeat(201) } }); - - expect(field.props.maxLength).toBe(200); - expect(onChange).toHaveBeenCalledWith("x".repeat(200)); - }); - it("uses the compact input primitive in entity pickers", () => { const picker = EntityPicker({ icon: null, diff --git a/apps/web/src/components/sourceControl/ListFilterMenu.tsx b/apps/web/src/components/sourceControl/ListFilterMenu.tsx index 36d6075e687b..dcb325944fe9 100644 --- a/apps/web/src/components/sourceControl/ListFilterMenu.tsx +++ b/apps/web/src/components/sourceControl/ListFilterMenu.tsx @@ -49,7 +49,6 @@ export interface ListFilterHost export const ALL_HOSTS_VALUE = ""; const ALL_PROJECTS_VALUE = "all"; -const MAX_SEARCH_QUERY_LENGTH = 200; /** * What to call a host in the row. The provider's own name reads best — "GitHub" over * "github.com" — but it stops naming anything once a workspace has two hosts of one kind, so @@ -90,8 +89,7 @@ export function ListSearchInput({ onChange(event.currentTarget.value.slice(0, MAX_SEARCH_QUERY_LENGTH))} + onChange={(event) => onChange(event.currentTarget.value)} placeholder={placeholder} aria-label={label} /> diff --git a/apps/web/src/routes/_chat.issues.tsx b/apps/web/src/routes/_chat.issues.tsx index 525992bffd68..f01cff4b7cfe 100644 --- a/apps/web/src/routes/_chat.issues.tsx +++ b/apps/web/src/routes/_chat.issues.tsx @@ -597,13 +597,10 @@ function IssuesRouteView() { // happens to be theirs: the list and whatever the panel is showing. The panel owns its own // reads, so it is told to redo them rather than reached into. const [detailRefreshToken, setDetailRefreshToken] = useState(0); - const [detailRefreshPending, setDetailRefreshPending] = useState(false); - const [refreshAwaiting, setRefreshAwaiting] = useState(false); // The queries only go pending once the invalidation has come back, so refreshing is tracked // from the first moment rather than the second: a button that stays live through the slow half // of its own work is a button that gets pressed again, and buys the whole cascade twice. const [invalidating, setInvalidating] = useState(false); - const refreshAwaitingRef = useRef(false); const invalidateHost = async () => { setInvalidating(true); try { @@ -614,37 +611,15 @@ function IssuesRouteView() { setInvalidating(false); } }; - const refreshFromHost = () => { - if (refreshAwaitingRef.current) return Promise.resolve(); - refreshAwaitingRef.current = true; - setRefreshAwaiting(true); - return invalidateHost().then(() => { - if (sentCursors === null) { - (baselineVisible ? baselineQuery : listQuery).refresh(); - if (!baselineVisible) baselineQuery.refresh(); - } else { - refreshList(); - baselineQuery.refresh(); - } - authoredQuery.refresh(); - assignedQuery.refresh(); - setDetailRefreshToken((token) => token + 1); - }); + const refreshFromHost = async () => { + await invalidateHost(); + refreshList(); + baselineQuery.refresh(); + authoredQuery.refresh(); + assignedQuery.refresh(); + setDetailRefreshToken((token) => token + 1); }; - const refreshReadsPending = - invalidating || - listQuery.isPending || - baselineQuery.isPending || - authoredQuery.isPending || - assignedQuery.isPending || - (activeSurface?.kind === "issue" && detailRefreshPending) || - (activeSurface?.kind === "pull-request" && detailRefreshPending); - useEffect(() => { - if (!refreshAwaiting || refreshReadsPending) return; - refreshAwaitingRef.current = false; - setRefreshAwaiting(false); - }, [refreshAwaiting, refreshReadsPending]); - const refreshing = refreshAwaiting || refreshReadsPending; + const refreshing = invalidating || listQuery.isPending; // Every page size and every search is its own query, and a new one starts empty. The last // answer for these filters is held so the page grows and narrows in place rather than blanking @@ -1465,7 +1440,6 @@ function IssuesRouteView() { onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} - onAddRepository={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} @@ -1473,7 +1447,6 @@ function IssuesRouteView() { browserAvailable={false} terminalAvailable={false} diffAvailable={false} - repositoryAvailable={false} filesAvailable={false} pullRequestAvailable={false} issueAvailable={false} @@ -1492,7 +1465,6 @@ function IssuesRouteView() { number: activeSurface.number, }} refreshToken={detailRefreshToken} - onRefreshPendingChange={setDetailRefreshPending} // Merging or closing one of these can close the issue it was opened from, so the // list behind it is out of date the moment the host takes the action. onActed={() => { @@ -1536,7 +1508,6 @@ function IssuesRouteView() { number: activeSurface.number, }} refreshToken={detailRefreshToken} - onRefreshPendingChange={setDetailRefreshPending} // There is no thread behind this panel, so handing an issue to an agent starts // one rather than continuing whatever the reader last had open. handoffTarget={{ kind: "new-thread" }} diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 8f639acb8016..ecb7cfc6fa59 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -28,7 +28,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { IssueDetailPanel } from "../components/issue/IssueDetailPanel"; import { filterPullRequestsByInvolvement, - decoratePullRequestEntries, findScopedProject, groupPullRequestsByInvolvement, matchesPullRequestFilters, @@ -39,15 +38,13 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, - pullRequestStatsTargetsForSettledResults, - pullRequestVisibleEntries, - prunePullRequestDiffStats, rankPullRequestMatches, pullRequestEnvironmentSetKey, readPullRequestListSnapshot, resolveProjectScope, resolveQueryEnvironmentIds, resolveSelectedEnvironmentId, + withDiffStat, writePullRequestListSnapshot, scorePullRequestMatch, type EnvironmentPullRequestEntry, @@ -1125,12 +1122,10 @@ function PullRequestsRouteView() { * The line counts, asked for once the rows are on screen. On GitHub they are forty per cent of * the read that answers the whole page, for two small numbers at the end of a row — so the * listing leaves them out and they arrive a moment later, into rows that already draw without - * them. Keyed by the rows being shown, so a refresh cannot replay old filter or page batches. + * them. Keyed by the rows being shown, so scrolling further asks only about what is new. */ - const visibleGroups = useMemo(() => { - if (search.involvement !== "all") { - return [{ key: "others" as const, label: "", entries }]; - } + const groups = useMemo(() => { + if (search.involvement !== "all") return [{ key: "others" as const, label: "", entries }]; // Until both partitions have answered, the snapshot's stand in — they are yesterday's // groups, but whole ones, where grouping the feed's first page locally loses every // authored row older than it. Once the live reads land they take over; with neither, @@ -1171,39 +1166,41 @@ function PullRequestsRouteView() { search.involvement, viewers, ]); - const visibleEntries = useMemo(() => pullRequestVisibleEntries(visibleGroups), [visibleGroups]); - const [statsByRow, setStatsByRow] = useState(() => new Map()); - const statsTargets = useMemo( - () => pullRequestStatsTargetsForSettledResults(visibleEntries, querySettled, showingCarried), - [querySettled, showingCarried, visibleEntries], - ); - useEffect(() => { - setStatsByRow((previous) => prunePullRequestDiffStats(previous, visibleEntries)); - }, [visibleEntries]); + + // Keyed by every row being shown — the partitions can hold rows the feed has not paged to — + // so scrolling further asks only about what is new. One read per environment, each asking only + // about its own rows: a reference names a project, and a project belongs to one machine. + const statsTargets = useMemo(() => { + const refsByEnvironment = new Map< + EnvironmentId, + Array<{ projectId: ProjectId; repository: string; number: number }> + >(); + for (const group of groups) { + for (const entry of group.entries) { + const refs = refsByEnvironment.get(entry.environmentId) ?? []; + refs.push({ + projectId: entry.projectId, + repository: entry.repository, + number: entry.number, + }); + refsByEnvironment.set(entry.environmentId, refs); + } + } + return [...refsByEnvironment].map(([environmentId, refs]) => ({ + environmentId, + input: { refs }, + })); + }, [groups]); const statsQuery = usePullRequestListStats(statsTargets); + // Adding or removing one row keys a fresh stats query with nothing in it yet, so the counts + // are merged into what is already held rather than rebuilt: every count on screen stays until + // its replacement arrives. + const [statsByRow, setStatsByRow] = useState(() => new Map()); useEffect(() => { const stats = statsQuery.stats; if (stats === null) return; setStatsByRow((previous) => mergePullRequestDiffStats(previous, stats)); }, [statsQuery.stats]); - const decorationCache = useRef< - ReadonlyMap - >(new Map()); - const decoration = useMemo( - () => decoratePullRequestEntries(visibleEntries, statsByRow, decorationCache.current), - [statsByRow, visibleEntries], - ); - useEffect(() => { - decorationCache.current = decoration.bySource; - }, [decoration]); - const groups = useMemo( - () => - visibleGroups.map((group) => ({ - ...group, - entries: group.entries.map((entry) => decoration.bySource.get(entry) ?? entry), - })), - [decoration.bySource, visibleGroups], - ); const linkedSelection = useMemo( () => @@ -1429,7 +1426,10 @@ function PullRequestsRouteView() { {group.entries.map((entry) => ( 1 && @@ -1643,7 +1643,6 @@ function PullRequestsRouteView() { onAddBrowser={() => undefined} onAddTerminal={() => undefined} onAddDiff={() => undefined} - onAddRepository={() => undefined} onAddFiles={() => undefined} onAddPullRequest={() => undefined} onAddIssue={() => undefined} @@ -1651,7 +1650,6 @@ function PullRequestsRouteView() { browserAvailable={false} terminalAvailable={false} diffAvailable={false} - repositoryAvailable={false} filesAvailable={false} pullRequestAvailable={false} issueAvailable={false} @@ -1683,7 +1681,6 @@ function PullRequestsRouteView() { baselineQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); - statsQuery.refresh(); }} onStateChange={handleIssueTabStatusChange} onOpenLinkedPullRequest={(link) => { @@ -1727,9 +1724,7 @@ function PullRequestsRouteView() { baselineQuery.refresh(); authoredQuery.refresh(); reviewingQuery.refresh(); - statsQuery.refresh(); }} - onTitleSaved={refreshList} onStateChange={handlePullRequestTabStatusChange} // The issue a change request closes is read beside it, as a peer tab in this // page's own panel: leaving for the issues page would take the change request diff --git a/apps/web/src/state/pullRequests.test.tsx b/apps/web/src/state/pullRequests.test.tsx deleted file mode 100644 index 63482cce7aa9..000000000000 --- a/apps/web/src/state/pullRequests.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { EnvironmentId } from "@t3tools/contracts"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -import { reactHookHarness as hooks } from "../test/reactHookHarness"; -import { usePullRequestListStats } from "./pullRequests"; - -const stats = vi.hoisted(() => { - const subscriptions: Array<{ - readonly atom: unknown; - readonly callback: (result: unknown) => void; - }> = []; - return { - events: [] as string[], - refreshes: [] as unknown[], - subscriptions, - }; -}); - -vi.mock("react", async (importOriginal) => { - const actual = await importOriginal(); - const { reactHookHarness } = await import("../test/reactHookHarness"); - return { - ...actual, - useCallback: reactHookHarness.useCallback, - useEffect: (effect: () => void | (() => void)) => effect(), - useMemo: reactHookHarness.useMemo, - useState: reactHookHarness.useState, - }; -}); - -vi.mock("@t3tools/client-runtime/state/pull-requests", () => ({ - createPullRequestEnvironmentAtoms: () => ({ - list: () => ({}), - listStats: (target: unknown) => target, - }), -})); - -vi.mock("effect/Option", () => ({ getOrNull: (value: T) => value })); -vi.mock("effect/unstable/reactivity", () => ({ - AsyncResult: { value: (result: { readonly value?: unknown }) => result.value ?? null }, - Atom: { - family: () => () => ({}), - make: () => ({ pipe: () => ({}) }), - withLabel: () => (atom: unknown) => atom, - }, -})); - -vi.mock("../connection/runtime", () => ({ connectionAtomRuntime: {} })); -vi.mock("./query", () => ({ formatEnvironmentQueryError: () => "Query failed" })); -vi.mock("../rpc/atomRegistry", () => ({ - appAtomRegistry: { - get: (atom: unknown) => { - stats.events.push("read"); - return { value: { stats: [] }, atom }; - }, - subscribe: (atom: unknown, callback: (result: unknown) => void) => { - stats.events.push("subscribe"); - stats.subscriptions.push({ atom, callback }); - return () => undefined; - }, - refresh: (atom: unknown) => stats.refreshes.push(atom), - }, -})); - -function render( - targets: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly input: unknown }>, -) { - hooks.beginRender(); - return usePullRequestListStats(targets as never); -} - -describe("usePullRequestListStats", () => { - beforeEach(() => { - hooks.reset(); - stats.events.length = 0; - stats.refreshes.length = 0; - stats.subscriptions.length = 0; - }); - - it("subscribes before reading every stats atom and refreshes only those atoms", () => { - const targets = [ - { - environmentId: "environment-1" as EnvironmentId, - input: { refs: [{ projectId: "project-1", repository: "owner/repository", number: 1 }] }, - }, - ] as const; - - const initial = render(targets); - - expect(initial.stats).toBeNull(); - expect(stats.events).toEqual(["subscribe", "read"]); - - initial.refresh(); - - expect(stats.refreshes).toEqual(stats.subscriptions.map((subscription) => subscription.atom)); - }); -}); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 5bae93889d5c..1445ceca0328 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -4,11 +4,10 @@ import type { EnvironmentId, PullRequestListInput, PullRequestListStatsInput, - PullRequestListStatsResult, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -88,6 +87,11 @@ const usePullRequestListsQuery = createMergedEnvironmentQuery( pullRequestEnvironment.list, ); +const usePullRequestStatsQuery = createMergedEnvironmentQuery( + "web-pull-requests:list-stats", + pullRequestEnvironment.listStats, +); + export interface MergedPullRequestListView { readonly data: MergedPullRequestList | null; readonly error: string | null; @@ -111,43 +115,15 @@ export function usePullRequestListStats( readonly stats: ReadonlyArray | null; readonly refresh: () => void; } { - 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 query = usePullRequestStatsQuery(targets); const stats = useMemo( () => - results.size === 0 + query.values.length === 0 ? null - : [...results.values()].flatMap(([environmentId, result]) => + : query.values.flatMap(([environmentId, result]) => result.stats.map((stat) => ({ ...stat, environmentId })), ), - [results], + [query.values], ); - const refresh = useCallback(() => { - for (const target of targets) { - appAtomRegistry.refresh(pullRequestEnvironment.listStats(target)); - } - }, [targets]); - return { stats, refresh }; + return { stats, refresh: query.refresh }; } From 9d7c9fe614a2595f2318d7f2a841f400b74cbb69 Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:02:00 +0200 Subject: [PATCH 5/5] fix(#6315): restore Git history navigation and performance (cherry picked from commit 40b2b76051287e6be9b6458a960e1a123c604259) --- .../src/components/GitHistoryPanel.test.tsx | 25 ++- apps/web/src/components/GitHistoryPanel.tsx | 18 +- .../git-history/GitHistoryRefsPane.test.ts | 28 +++ .../git-history/GitHistoryRefsPane.tsx | 178 +++++++++++------- .../git-history/useGitHistoryRefs.test.tsx | 29 ++- .../git-history/useGitHistoryRefs.ts | 95 +++++++--- 6 files changed, 274 insertions(+), 99 deletions(-) diff --git a/apps/web/src/components/GitHistoryPanel.test.tsx b/apps/web/src/components/GitHistoryPanel.test.tsx index ac5d396e4c1a..e40eecb43a85 100644 --- a/apps/web/src/components/GitHistoryPanel.test.tsx +++ b/apps/web/src/components/GitHistoryPanel.test.tsx @@ -124,6 +124,10 @@ vi.mock("react/compiler-runtime", async () => { return { c: reactHookHarness.useMemoCache }; }); +vi.mock("../hooks/useLocalStorage", () => ({ + useLocalStorage: () => [[], vi.fn()], +})); + vi.mock("@effect/atom-react", () => ({ useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, })); @@ -488,26 +492,29 @@ describe("GitHistoryPanel", () => { it("does not start an all-refs history request while the current ref is unresolved", () => { historyState.refsResolved = false; - renderPanel(); + 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 while loading all history", () => { + it("shows an initial ref failure with a reachable retry", () => { historyState.refsResolved = false; historyState.refsError = "Could not load refs."; - historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); const panel = renderPanel(); const refsPane = componentElement(panel, "GitRefsPane"); - expect(historyState.getHistory).toHaveBeenCalledWith({ - cacheKey: 0, - environmentId, - input: { cwd: workspacePath, limit: historyPageSize }, - }); + expect(historyState.getHistory).not.toHaveBeenCalled(); expect(refsPane.props.refPaginationError).toBe("Could not load refs."); - (refsPane.props.onRetryRefs as () => void)(); + 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(); }); diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx index d6b40a140177..bda77b2a3303 100644 --- a/apps/web/src/components/GitHistoryPanel.tsx +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -268,7 +268,10 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { } }, [failed, historyQueryGeneration, targetKey, values.length]); const isPending = selectedRevision === undefined || results.some((result) => result.waiting); - const isInitialLoad = selectedRevision === undefined || (values.length === 0 && isPending); + const refSelectionError = selectedRevision === undefined ? historyRefs.refPaginationError : null; + const isInitialLoad = + refSelectionError === null && + (selectedRevision === undefined || (values.length === 0 && isPending)); const history = useMemo(() => { const commitsByHash = new Map(); for (const value of values) { @@ -391,6 +394,8 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { const { currentRef = null, expandedRefKeys, + favoriteBranches, + favoriteRefs, hasMoreRefs, isFetchingMoreRefs, isRefSnapshotComplete, @@ -409,6 +414,7 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { setRefFilter, tagRefTree, tagRefs, + toggleFavorite, toggleRefKey, } = historyRefs; const commitRefKinds = useMemo(() => { @@ -438,6 +444,9 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { onSelectRef: selectRef, normalizedRefFilter, localRefTree, + favoriteRefs, + favoriteBranches, + onToggleFavorite: toggleFavorite, remoteRefTree, tagRefTree, expandedRefKeys, @@ -608,6 +617,13 @@ export default function GitHistoryPanel(props: GitHistoryPanelProps) { onLoadMoreFiles={loadMoreCommitFiles} onRetryFiles={() => void commitFilesQuery.refresh()} /> + ) : refSelectionError ? ( +
+

{refSelectionError}

+ +
) : isInitialLoad ? (
Loading history… diff --git a/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts index aea48094079b..7ac06365515a 100644 --- a/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts @@ -19,6 +19,7 @@ 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)), @@ -40,6 +41,7 @@ describe("buildRefPaneRows", () => { 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(), @@ -51,4 +53,30 @@ describe("buildRefPaneRows", () => { 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 index ff58982d888c..51a30d8640af 100644 --- a/apps/web/src/components/git-history/GitHistoryRefsPane.tsx +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx @@ -26,6 +26,14 @@ 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; @@ -47,6 +55,7 @@ type RefPaneRow = 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 } @@ -95,6 +104,7 @@ function countRefTreeRefs(nodes: ReadonlyArray): number { export function buildRefPaneRows(props: { readonly localRefTree: ReadonlyArray; + readonly favoriteRefs: ReadonlyArray; readonly remoteRefTree: ReadonlyArray; readonly tagRefTree: ReadonlyArray; readonly expandedRefKeys: ReadonlySet; @@ -106,6 +116,16 @@ export function buildRefPaneRows(props: { { 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 }, { @@ -160,6 +180,9 @@ export function GitRefsPane(props: { onSelectRef: (label: string, revision: string) => void; normalizedRefFilter: string; localRefTree: ReadonlyArray; + favoriteRefs: ReadonlyArray; + favoriteBranches: ReadonlySet; + onToggleFavorite: (branch: string) => void; remoteRefTree: ReadonlyArray; tagRefTree: ReadonlyArray; expandedRefKeys: ReadonlySet; @@ -176,6 +199,7 @@ export function GitRefsPane(props: { () => buildRefPaneRows({ localRefTree: props.localRefTree, + favoriteRefs: props.favoriteRefs, remoteRefTree: props.remoteRefTree, tagRefTree: props.tagRefTree, expandedRefKeys: props.expandedRefKeys, @@ -185,6 +209,7 @@ export function GitRefsPane(props: { }), [ props.expandedRefKeys, + props.favoriteRefs, props.hasMoreRefs, props.localRefTree, props.normalizedRefFilter, @@ -336,72 +361,97 @@ export function GitRefsPane(props: { .filter((description): description is string => description !== null) .join(". "); return ( - - + + props.onSelectRef(row.node.ref.name, revision)} + aria-pressed={selected} + aria-label={ + syncDescription + ? `${row.node.ref.name}. ${syncDescription}.` + : row.node.ref.name + } + > + {row.node.ref.isDefault ? ( + + ) : row.namespace === "tags" ? ( + + ) : ( + + )} + {row.node.name} + {aheadCount > 0 || behindCount > 0 ? ( + + {aheadCount > 0 ? ( + + + + {aheadCount > 99 ? "99+" : aheadCount} + + } + /> + {`${aheadCount} commits ahead of ${upstreamName}`} + + ) : null} + {behindCount > 0 ? ( + + + + {behindCount > 99 ? "99+" : behindCount} + + } + /> + {`${behindCount} commits behind ${upstreamName}`} + + ) : null} + + ) : null} + + } + /> + {row.node.ref.name} + + {row.namespace === "heads" ? ( + - } - /> - {row.node.ref.name} - + /> + + ) : null} +
); } if (row.kind === "empty") diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx index 6016e2e50ebe..fe9874534959 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -13,6 +13,7 @@ const refState = vi.hoisted(() => ({ local: [] as ReadonlyArray, remote: [] as ReadonlyArray, tags: [] as ReadonlyArray, + favoriteBranches: [] as ReadonlyArray, isComplete: true, nextCursor: null as string | null, refreshLocal: vi.fn(), @@ -46,6 +47,16 @@ vi.mock("react/compiler-runtime", async () => { 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: ( @@ -96,7 +107,11 @@ vi.mock("../../state/queries", () => ({ })); import { GitRefsPane } from "./GitHistoryRefsPane"; -import { useGitHistoryRefs } from "./useGitHistoryRefs"; +import { + gitHistoryFavoriteStorageKey, + toggleGitHistoryFavorite, + useGitHistoryRefs, +} from "./useGitHistoryRefs"; const environmentId = EnvironmentId.make("environment-local"); const repositoryCwd = "C:/workspace"; @@ -117,6 +132,9 @@ function renderRefs(revision = 0) { 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, @@ -144,6 +162,7 @@ describe("useGitHistoryRefs", () => { refState.local = []; refState.remote = []; refState.tags = []; + refState.favoriteBranches = []; refState.isComplete = true; refState.nextCursor = null; refState.refreshLocal.mockReset(); @@ -154,6 +173,14 @@ describe("useGitHistoryRefs", () => { 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)); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.ts b/apps/web/src/components/git-history/useGitHistoryRefs.ts index 968602b132bd..2b85adc3ce34 100644 --- a/apps/web/src/components/git-history/useGitHistoryRefs.ts +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -1,21 +1,47 @@ 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 REF_FILTER_DEBOUNCE_MS = 200; +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< - GitHistoryRevision | null | undefined - >(undefined); + 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"]), ); @@ -24,11 +50,11 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev const shouldLoadRemote = deferredRefFilter.length > 0 || expandedRefKeys.has("section:remote") || - selectedRevisionState?.revision.startsWith("refs/remotes/") === true; + scopedSelectedRevision?.revision.startsWith("refs/remotes/") === true; const shouldLoadTags = deferredRefFilter.length > 0 || expandedRefKeys.has("section:tags") || - selectedRevisionState?.revision.startsWith("refs/tags/") === true; + scopedSelectedRevision?.revision.startsWith("refs/tags/") === true; const refs = usePaginatedHistoryRefs( { environmentId, cwd, query: deferredRefFilter }, { limit: 200, namespace: "local", revision }, @@ -60,6 +86,17 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev () => 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], @@ -86,18 +123,16 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev const defaultSelectedRevision = useMemo( () => currentRef === undefined - ? refs.error - ? null - : undefined + ? undefined : currentRef === null ? null : { label: currentRef.name, revision: `refs/heads/${currentRef.name}` }, - [currentRef, refs.error], + [currentRef], ); const selectedRefWasRemoved = useMemo(() => { - if (selectedRevisionState === undefined || selectedRevisionState === null) return false; + if (scopedSelectedRevision === undefined || scopedSelectedRevision === null) return false; if (deferredRefFilter.length > 0) return false; - const selectedRef = selectedRevisionState.revision; + const selectedRef = scopedSelectedRevision.revision; if (selectedRef.startsWith("refs/heads/")) { return ( refs.data?.isComplete === true && @@ -128,15 +163,15 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev remote.data?.isComplete, remote.data?.nextCursor, remoteRefs, - selectedRevisionState, + scopedSelectedRevision, tagRefs, tags.data?.isComplete, tags.data?.nextCursor, ]); const selectedRevision = - selectedRevisionState === undefined || selectedRefWasRemoved + scopedSelectedRevision === undefined || selectedRefWasRemoved ? defaultSelectedRevision - : selectedRevisionState; + : scopedSelectedRevision; const toggleRefKey = useCallback((key: string) => { setExpandedRefKeys((current) => { const next = new Set(current); @@ -145,26 +180,37 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev return next; }); }, []); - const selectRef = useCallback((label: string, revision: string) => { - setSelectedRevision({ label, revision }); - }, []); + const selectRef = useCallback( + (label: string, revision: string) => { + setSelectedRevision({ scopeKey, value: { label, revision } }); + }, + [scopeKey], + ); const selectAllRefs = useCallback(() => { - setSelectedRevision(null); - }, []); + setSelectedRevision({ scopeKey, value: null }); + }, [scopeKey]); + const toggleFavorite = useCallback( + (branch: string) => { + setFavoriteBranches((current) => toggleGitHistoryFavorite(current, branch)); + }, + [setFavoriteBranches], + ); useEffect(() => { - setSelectedRevision(undefined); + setSelectedRevision({ scopeKey, value: undefined }); setRefFilter(""); setExpandedRefKeys(new Set(["section:local"])); - }, [cwd, environmentId]); + }, [cwd, environmentId, scopeKey]); useEffect(() => { - if (selectedRefWasRemoved) setSelectedRevision(undefined); - }, [selectedRefWasRemoved]); + if (selectedRefWasRemoved) setSelectedRevision({ scopeKey, value: undefined }); + }, [scopeKey, selectedRefWasRemoved]); return { currentRef, expandedRefKeys, + favoriteBranches: favoriteBranchSet, + favoriteRefs, hasMoreRefs: (refs.data?.nextCursor !== null && refs.data?.nextCursor !== undefined) || (shouldLoadRemote && @@ -211,5 +257,6 @@ export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, rev tagRefTree, tagRefs, toggleRefKey, + toggleFavorite, }; }