authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-04 18:00:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-05 00:00:24+02:00
log4fb03aff0bcddf3379e9f9277a87e8eae994a2ae
tree88d707a1e12964b6ad349a9b98031e9eb51c836e
parentd7950ac153c6fdd788ef63699a5d061243bb4997
signature Signed by PGP key B5690EEEBB952194

fix(web): better right panel (new diffs styling) (#5260)


23 files changed, 1087 insertions(+), 155 deletions(-)

apps/server/src/auth/RpcAuthorization.ts+1
...@@ -75,6 +75,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -75,6 +75,7 @@ export const RPC_REQUIRED_SCOPES = {
75 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,75 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
76 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,76 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
77 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,77 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
78 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
78 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,79 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
79 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,80 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
80 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,81 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
apps/server/src/review/ReviewService.test.ts+33
...@@ -57,6 +57,39 @@ describe("ReviewService", () => {...@@ -57,6 +57,39 @@ describe("ReviewService", () => {
57 }).pipe(Effect.provide(NodeServices.layer)),57 }).pipe(Effect.provide(NodeServices.layer)),
58 );58 );
5959
60 it.effect("attributes file-content workspace violations to the file-content operation", () =>
61 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;
63 const workspaceRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-workspace-" });
64 const outsideRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-outside-" });
65 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-review-base-" });
66 const detectCalls: Array<{ readonly cwd: string }> = [];
67
68 const error = yield* Effect.gen(function* () {
69 const review = yield* ReviewService.ReviewService;
70 return yield* review
71 .getDiffFileContents({
72 cwd: outsideRoot,
73 sourceKind: "working-tree",
74 changeType: "change",
75 baseRef: "HEAD",
76 headRef: null,
77 oldPath: "file.ts",
78 newPath: "file.ts",
79 })
80 .pipe(Effect.flip);
81 }).pipe(Effect.provide(makeLayer({ workspaceRoot, baseDir, detectCalls })));
82
83 assert.strictEqual(error._tag, "VcsRepositoryDetectionError");
84 assert.strictEqual(error.operation, "ReviewService.getDiffFileContents");
85 assert.match(
86 "detail" in error ? error.detail : "",
87 /must stay within the configured workspace root/,
88 );
89 assert.deepStrictEqual(detectCalls, []);
90 }).pipe(Effect.provide(NodeServices.layer)),
91 );
92
60 it.effect("allows diff preview cwd inside the configured workspace root", () =>93 it.effect("allows diff preview cwd inside the configured workspace root", () =>
61 Effect.gen(function* () {94 Effect.gen(function* () {
62 const fs = yield* FileSystem.FileSystem;95 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+30-3
...@@ -8,6 +8,8 @@ import * as Path from "effect/Path";...@@ -8,6 +8,8 @@ import * as Path from "effect/Path";
8import {8import {
9 VcsRepositoryDetectionError,9 VcsRepositoryDetectionError,
10 VcsUnsupportedOperationError,10 VcsUnsupportedOperationError,
11 type ReviewDiffFileContentsInput,
12 type ReviewDiffFileContentsResult,
11 type ReviewDiffPreviewError,13 type ReviewDiffPreviewError,
12 type ReviewDiffPreviewInput,14 type ReviewDiffPreviewInput,
13 type ReviewDiffPreviewResult,15 type ReviewDiffPreviewResult,
...@@ -23,6 +25,9 @@ export class ReviewService extends Context.Service<...@@ -23,6 +25,9 @@ export class ReviewService extends Context.Service<
23 readonly getDiffPreview: (25 readonly getDiffPreview: (
24 input: ReviewDiffPreviewInput,26 input: ReviewDiffPreviewInput,
25 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;27 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
28 readonly getDiffFileContents: (
29 input: ReviewDiffFileContentsInput,
30 ) => Effect.Effect<ReviewDiffFileContentsResult, ReviewDiffPreviewError>;
26 }31 }
27>()("t3/review/ReviewService") {}32>()("t3/review/ReviewService") {}
2833
...@@ -58,6 +63,7 @@ export const make = Effect.gen(function* () {...@@ -58,6 +63,7 @@ export const make = Effect.gen(function* () {
58 };63 };
5964
60 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (65 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (
66 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
61 cwd: string,67 cwd: string,
62 ) {68 ) {
63 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([69 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
...@@ -71,16 +77,19 @@ export const make = Effect.gen(function* () {...@@ -71,16 +77,19 @@ export const make = Effect.gen(function* () {
71 }77 }
7278
73 return yield* new VcsRepositoryDetectionError({79 return yield* new VcsRepositoryDetectionError({
74 operation: "ReviewService.getDiffPreview",80 operation,
75 cwd,81 cwd,
76 detail: "Review diff preview cwd must stay within the configured workspace root.",82 detail:
83 operation === "ReviewService.getDiffPreview"
84 ? "Review diff preview cwd must stay within the configured workspace root."
85 : "Review diff file contents cwd must stay within the configured workspace root.",
77 });86 });
78 });87 });
7988
80 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(89 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(
81 "ReviewService.getDiffPreview",90 "ReviewService.getDiffPreview",
82 )(function* (input) {91 )(function* (input) {
83 yield* assertWorkspaceBoundCwd(input.cwd);92 yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd);
8493
85 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });94 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
86 if (!handle) {95 if (!handle) {
...@@ -106,8 +115,26 @@ export const make = Effect.gen(function* () {...@@ -106,8 +115,26 @@ export const make = Effect.gen(function* () {
106 return yield* getDriverDiffPreview(input);115 return yield* getDriverDiffPreview(input);
107 });116 });
108117
118 const getDiffFileContents: ReviewService["Service"]["getDiffFileContents"] = Effect.fn(
119 "ReviewService.getDiffFileContents",
120 )(function* (input) {
121 yield* assertWorkspaceBoundCwd("ReviewService.getDiffFileContents", input.cwd);
122
123 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
124 if (handle?.kind !== "git") {
125 return yield* new VcsUnsupportedOperationError({
126 operation: "ReviewService.getDiffFileContents",
127 kind: handle?.kind ?? "unknown",
128 detail: "Unchanged diff expansion currently requires a Git repository.",
129 });
130 }
131
132 return yield* git.getReviewDiffFileContents(input);
133 });
134
109 return ReviewService.of({135 return ReviewService.of({
110 getDiffPreview,136 getDiffPreview,
137 getDiffFileContents,
111 });138 });
112});139});
113140
apps/server/src/server.test.ts+21
...@@ -5249,6 +5249,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5249,6 +5249,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5249 },5249 },
5250 ],5250 ],
5251 }),5251 }),
5252 getDiffFileContents: () =>
5253 Effect.succeed({
5254 oldContents: "before\n",
5255 newContents: "after\n",
5256 }),
5252 },5257 },
5253 },5258 },
5254 });5259 });
...@@ -5363,6 +5368,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -5363,6 +5368,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
5363 ),5368 ),
5364 );5369 );
5365 assert.equal(diffPreview.sources[0]?.diff, "dirty-diff");5370 assert.equal(diffPreview.sources[0]?.diff, "dirty-diff");
5371
5372 const diffFileContents = yield* Effect.scoped(
5373 withWsRpcClient(wsUrl, (client) =>
5374 client[WS_METHODS.reviewGetDiffFileContents]({
5375 cwd: "/tmp/repo",
5376 sourceKind: "working-tree",
5377 changeType: "change",
5378 baseRef: "HEAD",
5379 headRef: null,
5380 oldPath: "README.md",
5381 newPath: "README.md",
5382 }),
5383 ),
5384 );
5385 assert.equal(diffFileContents.oldContents, "before\n");
5386 assert.equal(diffFileContents.newContents, "after\n");
5366 }).pipe(Effect.provide(NodeHttpServer.layerTest)),5387 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5367 );5388 );
53685389
apps/server/src/vcs/GitVcsDriver.ts+5
...@@ -20,6 +20,8 @@ import {...@@ -20,6 +20,8 @@ import {
20 type VcsCreateWorktreeResult,20 type VcsCreateWorktreeResult,
21 type ReviewDiffPreviewInput,21 type ReviewDiffPreviewInput,
22 type ReviewDiffPreviewResult,22 type ReviewDiffPreviewResult,
23 type ReviewDiffFileContentsInput,
24 type ReviewDiffFileContentsResult,
23 type VcsInitInput,25 type VcsInitInput,
24 type VcsListRefsInput,26 type VcsListRefsInput,
25 type VcsListRefsResult,27 type VcsListRefsResult,
...@@ -221,6 +223,9 @@ export class GitVcsDriver extends Context.Service<...@@ -221,6 +223,9 @@ export class GitVcsDriver extends Context.Service<
221 readonly getReviewDiffPreview: (223 readonly getReviewDiffPreview: (
222 input: ReviewDiffPreviewInput,224 input: ReviewDiffPreviewInput,
223 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;225 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
226 readonly getReviewDiffFileContents: (
227 input: ReviewDiffFileContentsInput,
228 ) => Effect.Effect<ReviewDiffFileContentsResult, GitCommandError>;
224 readonly readConfigValue: (229 readonly readConfigValue: (
225 cwd: string,230 cwd: string,
226 key: string,231 key: string,
apps/server/src/vcs/GitVcsDriverCore.test.ts+116-1
...@@ -14,7 +14,7 @@ import * as Stream from "effect/Stream";...@@ -14,7 +14,7 @@ import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1616
17import { GitCommandError } from "@t3tools/contracts";17import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
18import { ServerConfig } from "../config.ts";18import { ServerConfig } from "../config.ts";
19import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";19import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
20import * as GitVcsDriver from "./GitVcsDriver.ts";20import * as GitVcsDriver from "./GitVcsDriver.ts";
...@@ -78,6 +78,20 @@ const writeTextFile = (...@@ -78,6 +78,20 @@ const writeTextFile = (
78 yield* fileSystem.writeFileString(filePath, contents);78 yield* fileSystem.writeFileString(filePath, contents);
79 });79 });
8080
81const makeReviewDiffFileContentsInput = (
82 cwd: string,
83 overrides: Partial<Omit<ReviewDiffFileContentsInput, "cwd">> = {},
84): ReviewDiffFileContentsInput => ({
85 cwd,
86 sourceKind: "working-tree",
87 changeType: "change",
88 baseRef: "HEAD",
89 headRef: null,
90 oldPath: "README.md",
91 newPath: "README.md",
92 ...overrides,
93});
94
81const git = (95const git = (
82 cwd: string,96 cwd: string,
83 args: ReadonlyArray<string>,97 args: ReadonlyArray<string>,
...@@ -803,6 +817,107 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -803,6 +817,107 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
803 );817 );
804 }),818 }),
805 );819 );
820
821 it.effect("loads full file contents for working-tree diff expansion", () =>
822 Effect.gen(function* () {
823 const cwd = yield* makeTmpDir();
824 yield* initRepoWithCommit(cwd);
825 const driver = yield* GitVcsDriver.GitVcsDriver;
826 const pathService = yield* Path.Path;
827 yield* writeTextFile(cwd, "nested/.keep", "");
828 yield* writeTextFile(cwd, "README.md", "# changed\nunchanged context\n");
829
830 const contents = yield* driver.getReviewDiffFileContents(
831 makeReviewDiffFileContentsInput(pathService.join(cwd, "nested")),
832 );
833
834 assert.strictEqual(contents.oldContents, "# test\n");
835 assert.strictEqual(contents.newContents, "# changed\nunchanged context\n");
836 }),
837 );
838
839 it.effect("attributes working-tree filesystem failures to the failing operation", () =>
840 Effect.gen(function* () {
841 const cwd = yield* makeTmpDir();
842 yield* initRepoWithCommit(cwd);
843 const driver = yield* GitVcsDriver.GitVcsDriver;
844
845 const error = yield* driver
846 .getReviewDiffFileContents(
847 makeReviewDiffFileContentsInput(cwd, {
848 changeType: "new",
849 oldPath: "missing.ts",
850 newPath: "missing.ts",
851 }),
852 )
853 .pipe(Effect.flip);
854
855 assert.deepInclude(error, {
856 _tag: "GitCommandError",
857 operation: "GitVcsDriver.getReviewDiffFileContents.workingTree.fs.realPath",
858 command: "fs.realPath",
859 cwd,
860 detail: "Could not resolve diff file 'missing.ts'.",
861 });
862 }),
863 );
864
865 it.effect("loads new and deleted files without reading their missing side", () =>
866 Effect.gen(function* () {
867 const cwd = yield* makeTmpDir();
868 yield* initRepoWithCommit(cwd);
869 const driver = yield* GitVcsDriver.GitVcsDriver;
870 const fileSystem = yield* FileSystem.FileSystem;
871 const pathService = yield* Path.Path;
872 yield* writeTextFile(cwd, "added.ts", "export const added = true;\n");
873 yield* fileSystem.remove(pathService.join(cwd, "README.md"));
874
875 const [added, deleted] = yield* Effect.all([
876 driver.getReviewDiffFileContents(
877 makeReviewDiffFileContentsInput(cwd, {
878 changeType: "new",
879 oldPath: "added.ts",
880 newPath: "added.ts",
881 }),
882 ),
883 driver.getReviewDiffFileContents(
884 makeReviewDiffFileContentsInput(cwd, { changeType: "deleted" }),
885 ),
886 ]);
887
888 assert.deepStrictEqual(added, {
889 oldContents: "",
890 newContents: "export const added = true;\n",
891 });
892 assert.deepStrictEqual(deleted, {
893 oldContents: "# test\n",
894 newContents: "",
895 });
896 }),
897 );
898
899 it.effect("loads merge-base and head contents for branch diff expansion", () =>
900 Effect.gen(function* () {
901 const cwd = yield* makeTmpDir();
902 const { initialBranch } = yield* initRepoWithCommit(cwd);
903 const driver = yield* GitVcsDriver.GitVcsDriver;
904 yield* git(cwd, ["checkout", "-b", "feature/context"]);
905 yield* writeTextFile(cwd, "README.md", "# branch change\nunchanged context\n");
906 yield* git(cwd, ["add", "README.md"]);
907 yield* git(cwd, ["commit", "-m", "change readme"]);
908
909 const contents = yield* driver.getReviewDiffFileContents(
910 makeReviewDiffFileContentsInput(cwd, {
911 sourceKind: "branch-range",
912 baseRef: initialBranch,
913 headRef: "feature/context",
914 }),
915 );
916
917 assert.strictEqual(contents.oldContents, "# test\n");
918 assert.strictEqual(contents.newContents, "# branch change\nunchanged context\n");
919 }),
920 );
806 });921 });
807922
808 describe("repository status", () => {923 describe("repository status", () => {
apps/server/src/vcs/GitVcsDriverCore.ts+160
...@@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";...@@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2121
22import {22import {
23 GitCommandError,23 GitCommandError,
24 type ReviewDiffFileContentsInput,
24 type ReviewDiffPreviewInput,25 type ReviewDiffPreviewInput,
25 type ReviewDiffPreviewSource,26 type ReviewDiffPreviewSource,
26 type VcsRef,27 type VcsRef,
...@@ -46,6 +47,7 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;...@@ -46,6 +47,7 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
46const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;47const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
47const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;48const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
48const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;49const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
50const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
49const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;51const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
50const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);52const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
51const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);53const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
...@@ -2270,6 +2272,163 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2270,6 +2272,163 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2270 };2272 };
2271 });2273 });
22722274
2275 const reviewDiffFileError = (
2276 input: ReviewDiffFileContentsInput,
2277 detail: string,
2278 cause?: unknown,
2279 ) =>
2280 new GitCommandError({
2281 operation: "GitVcsDriver.getReviewDiffFileContents",
2282 command: "git",
2283 cwd: input.cwd,
2284 detail,
2285 ...(cause === undefined ? {} : { cause }),
2286 });
2287
2288 const isPathWithinRoot = (root: string, candidate: string) => {
2289 const relative = path.relative(root, candidate);
2290 return (
2291 relative === "" ||
2292 (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
2293 );
2294 };
2295
2296 const readReviewFileAtRevision = Effect.fn("readReviewFileAtRevision")(function* (
2297 input: ReviewDiffFileContentsInput,
2298 revision: string,
2299 relativePath: string,
2300 ) {
2301 const result = yield* executeGit(
2302 "GitVcsDriver.getReviewDiffFileContents.revision",
2303 input.cwd,
2304 ["show", `${revision}:${relativePath}`],
2305 { maxOutputBytes: REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES },
2306 );
2307 if (result.stdout.includes("\0")) {
2308 return yield* reviewDiffFileError(input, `Cannot expand binary file '${relativePath}'.`);
2309 }
2310 return result.stdout;
2311 });
2312
2313 const readWorkingTreeReviewFile = Effect.fn("readWorkingTreeReviewFile")(function* (
2314 input: ReviewDiffFileContentsInput,
2315 repositoryRoot: string,
2316 ) {
2317 const fileError = (stage: string, detail: string, cause?: unknown) =>
2318 new GitCommandError({
2319 operation: `GitVcsDriver.getReviewDiffFileContents.workingTree.${stage}`,
2320 command: stage,
2321 cwd: input.cwd,
2322 detail,
2323 ...(cause === undefined ? {} : { cause }),
2324 });
2325 const requestedPath = path.resolve(repositoryRoot, input.newPath);
2326 if (!isPathWithinRoot(repositoryRoot, requestedPath)) {
2327 return yield* fileError(
2328 "path.resolve",
2329 `Diff file '${input.newPath}' resolves outside the review workspace.`,
2330 );
2331 }
2332
2333 const [realRepositoryRoot, realTarget] = yield* Effect.all([
2334 fileSystem.realPath(repositoryRoot),
2335 fileSystem.realPath(requestedPath),
2336 ]).pipe(
2337 Effect.mapError((cause) =>
2338 fileError("fs.realPath", `Could not resolve diff file '${input.newPath}'.`, cause),
2339 ),
2340 );
2341 if (!isPathWithinRoot(realRepositoryRoot, realTarget)) {
2342 return yield* fileError(
2343 "fs.realPath",
2344 `Diff file '${input.newPath}' resolves outside the review workspace.`,
2345 );
2346 }
2347
2348 const info = yield* fileSystem
2349 .stat(realTarget)
2350 .pipe(
2351 Effect.mapError((cause) =>
2352 fileError("fs.stat", `Could not inspect diff file '${input.newPath}'.`, cause),
2353 ),
2354 );
2355 if (info.type !== "File") {
2356 return yield* fileError("fs.stat", `Diff path '${input.newPath}' is not a file.`);
2357 }
2358 if (info.size > BigInt(REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES)) {
2359 return yield* fileError(
2360 "fs.stat",
2361 `Diff file '${input.newPath}' exceeds the 1 MB expansion limit.`,
2362 );
2363 }
2364
2365 const bytes = yield* fileSystem
2366 .readFile(realTarget)
2367 .pipe(
2368 Effect.mapError((cause) =>
2369 fileError("fs.readFile", `Could not read diff file '${input.newPath}'.`, cause),
2370 ),
2371 );
2372 if (bytes.includes(0)) {
2373 return yield* fileError("fs.readFile", `Cannot expand binary file '${input.newPath}'.`);
2374 }
2375 return new TextDecoder("utf-8").decode(bytes);
2376 });
2377
2378 const getReviewDiffFileContents = Effect.fn("getReviewDiffFileContents")(function* (
2379 input: ReviewDiffFileContentsInput,
2380 ) {
2381 if (input.sourceKind === "working-tree") {
2382 const repositoryRoot = yield* runGitStdout(
2383 "GitVcsDriver.getReviewDiffFileContents.repositoryRoot",
2384 input.cwd,
2385 ["rev-parse", "--show-toplevel"],
2386 ).pipe(Effect.map((value) => value.trim()));
2387 if (repositoryRoot.length === 0) {
2388 return yield* reviewDiffFileError(input, "Could not resolve the Git repository root.");
2389 }
2390 const [oldContents, newContents] = yield* Effect.all(
2391 [
2392 input.changeType === "new"
2393 ? Effect.succeed("")
2394 : readReviewFileAtRevision(input, input.baseRef ?? "HEAD", input.oldPath),
2395 input.changeType === "deleted"
2396 ? Effect.succeed("")
2397 : readWorkingTreeReviewFile(input, repositoryRoot),
2398 ],
2399 { concurrency: 2 },
2400 );
2401 return { oldContents, newContents };
2402 }
2403
2404 if (!input.baseRef || !input.headRef) {
2405 return yield* reviewDiffFileError(
2406 input,
2407 "Branch diff file expansion requires both base and head refs.",
2408 );
2409 }
2410 const mergeBase = yield* runGitStdout(
2411 "GitVcsDriver.getReviewDiffFileContents.mergeBase",
2412 input.cwd,
2413 ["merge-base", input.baseRef, input.headRef],
2414 ).pipe(Effect.map((value) => value.trim()));
2415 if (mergeBase.length === 0) {
2416 return yield* reviewDiffFileError(input, "Could not resolve the branch comparison base.");
2417 }
2418 const [oldContents, newContents] = yield* Effect.all(
2419 [
2420 input.changeType === "new"
2421 ? Effect.succeed("")
2422 : readReviewFileAtRevision(input, mergeBase, input.oldPath),
2423 input.changeType === "deleted"
2424 ? Effect.succeed("")
2425 : readReviewFileAtRevision(input, input.headRef, input.newPath),
2426 ],
2427 { concurrency: 2 },
2428 );
2429 return { oldContents, newContents };
2430 });
2431
2273 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>2432 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
2274 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(2433 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
2275 Effect.map((stdout) => stdout.trim()),2434 Effect.map((stdout) => stdout.trim()),
...@@ -2903,6 +3062,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -2903,6 +3062,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
2903 pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)),3062 pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)),
2904 readRangeContext,3063 readRangeContext,
2905 getReviewDiffPreview,3064 getReviewDiffPreview,
3065 getReviewDiffFileContents,
2906 readConfigValue,3066 readConfigValue,
2907 listRefs,3067 listRefs,
2908 createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)),3068 createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)),
apps/server/src/ws.ts+6
...@@ -1854,6 +1854,12 @@ const makeWsRpcLayer = (...@@ -1854,6 +1854,12 @@ const makeWsRpcLayer = (
1854 observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), {1854 observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), {
1855 "rpc.aggregate": "review",1855 "rpc.aggregate": "review",
1856 }),1856 }),
1857 [WS_METHODS.reviewGetDiffFileContents]: (input) =>
1858 observeRpcEffect(
1859 WS_METHODS.reviewGetDiffFileContents,
1860 review.getDiffFileContents(input),
1861 { "rpc.aggregate": "review" },
1862 ),
1857 [WS_METHODS.terminalOpen]: (input) =>1863 [WS_METHODS.terminalOpen]: (input) =>
1858 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {1864 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
1859 "rpc.aggregate": "terminal",1865 "rpc.aggregate": "terminal",
apps/web/src/components/ChatView.tsx+8-1
...@@ -5648,7 +5648,14 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5648,7 +5648,14 @@ function ChatViewContent(props: ChatViewProps) {
5648 />5648 />
5649 );5649 );
5650 const panelLayoutControls = (5650 const panelLayoutControls = (
5651 <div className="workspace-titlebar-controls z-50 mr-px gap-1 [-webkit-app-region:no-drag]">5651 <div
5652 className={cn(
5653 "workspace-titlebar-controls z-50 gap-1 [-webkit-app-region:no-drag]",
5654 rightPanelOpen && !shouldUsePlanSidebarSheet
5655 ? "right-2 wco:right-[var(--workspace-controls-right)]"
5656 : "mr-px",
5657 )}
5658 >
5652 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (5659 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
5653 <RightPanelMaximizeControl5660 <RightPanelMaximizeControl
5654 maximized={rightPanelMaximized}5661 maximized={rightPanelMaximized}
apps/web/src/components/DiffPanel.tsx+329-40
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import type { FileDiffContentsLoader } from "@pierre/diffs";
2import { useParams } from "@tanstack/react-router";3import { useParams } from "@tanstack/react-router";
3import {4import {
4 isAtomCommandInterrupted,5 isAtomCommandInterrupted,
...@@ -15,6 +16,7 @@ import {...@@ -15,6 +16,7 @@ import {
15 ChevronsUpDownIcon,16 ChevronsUpDownIcon,
16 Columns2Icon,17 Columns2Icon,
17 PilcrowIcon,18 PilcrowIcon,
19 RefreshCwIcon,
18 Rows3Icon,20 Rows3Icon,
19 SearchIcon,21 SearchIcon,
20 TextWrapIcon,22 TextWrapIcon,
...@@ -67,6 +69,7 @@ import {...@@ -67,6 +69,7 @@ import {
67} from "./ui/menu";69} from "./ui/menu";
68import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";70import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
69import { useEnvironmentQuery } from "../state/query";71import { useEnvironmentQuery } from "../state/query";
72import { useAtomCommand } from "../state/use-atom-command";
70import { serverEnvironment } from "../state/server";73import { serverEnvironment } from "../state/server";
71import { reviewEnvironment } from "../state/review";74import { reviewEnvironment } from "../state/review";
72import { vcsEnvironment } from "../state/vcs";75import { vcsEnvironment } from "../state/vcs";
...@@ -91,9 +94,9 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -91,9 +94,9 @@ const DIFF_PANEL_UNSAFE_CSS = `
91[data-virtualizer-buffer] {94[data-virtualizer-buffer] {
92 --diffs-header-font-family: var(--font-sans) !important;95 --diffs-header-font-family: var(--font-sans) !important;
93 --diffs-font-family: var(--font-mono) !important;96 --diffs-font-family: var(--font-mono) !important;
94 --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;97 --diffs-bg: var(--background) !important;
95 --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;98 --diffs-light-bg: var(--background) !important;
96 --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;99 --diffs-dark-bg: var(--background) !important;
97 --diffs-token-light-bg: transparent;100 --diffs-token-light-bg: transparent;
98 --diffs-token-dark-bg: transparent;101 --diffs-token-dark-bg: transparent;
99102
...@@ -102,13 +105,25 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -102,13 +105,25 @@ const DIFF_PANEL_UNSAFE_CSS = `
102 --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));105 --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));
103 --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground));106 --diffs-bg-buffer-override: color-mix(in srgb, var(--background) 90%, var(--foreground));
104107
105 --diffs-bg-addition-override: color-mix(in srgb, var(--background) 92%, var(--success));108 --diffs-bg-addition-override: light-dark(
106 --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success));109 color-mix(in srgb, var(--background) 50%, var(--success)),
110 color-mix(in srgb, var(--background) 70%, var(--success))
111 );
112 --diffs-bg-addition-number-override: light-dark(
113 color-mix(in srgb, var(--background) 35%, var(--success)),
114 color-mix(in srgb, var(--background) 60%, var(--success))
115 );
107 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));116 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));
108 --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success));117 --diffs-bg-addition-emphasis-override: color-mix(in srgb, var(--background) 80%, var(--success));
109118
110 --diffs-bg-deletion-override: color-mix(in srgb, var(--background) 92%, var(--destructive));119 --diffs-bg-deletion-override: light-dark(
111 --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive));120 color-mix(in srgb, var(--background) 50%, var(--destructive)),
121 color-mix(in srgb, var(--background) 70%, var(--destructive))
122 );
123 --diffs-bg-deletion-number-override: light-dark(
124 color-mix(in srgb, var(--background) 35%, var(--destructive)),
125 color-mix(in srgb, var(--background) 60%, var(--destructive))
126 );
112 --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));127 --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));
113 --diffs-bg-deletion-emphasis-override: color-mix(128 --diffs-bg-deletion-emphasis-override: color-mix(
114 in srgb,129 in srgb,
...@@ -119,9 +134,66 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -119,9 +134,66 @@ const DIFF_PANEL_UNSAFE_CSS = `
119 background-color: var(--diffs-bg) !important;134 background-color: var(--diffs-bg) !important;
120}135}
121136
137:is(
138 [data-line],
139 [data-line-annotation],
140 [data-merge-conflict],
141 [data-merge-conflict-actions],
142 [data-no-newline]
143)[data-selected-line] {
144 --diffs-line-bg: light-dark(
145 color-mix(
146 in lab,
147 var(--background) 88%,
148 color-mix(in srgb, var(--background) 50%, var(--diffs-modified-base))
149 ),
150 color-mix(
151 in lab,
152 var(--background) 80%,
153 color-mix(in srgb, var(--background) 70%, var(--diffs-modified-base))
154 )
155 ) !important;
156}
157
158:is([data-gutter-buffer], [data-column-number])[data-selected-line] {
159 --diffs-line-bg: light-dark(
160 color-mix(
161 in lab,
162 var(--background) 91%,
163 color-mix(in srgb, var(--background) 35%, var(--diffs-modified-base))
164 ),
165 color-mix(
166 in lab,
167 var(--background) 85%,
168 color-mix(in srgb, var(--background) 60%, var(--diffs-modified-base))
169 )
170 ) !important;
171}
172
173[data-indicators="bars"]
174 :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line] {
175 position: relative;
176}
177
178[data-indicators="bars"]
179 :is([data-column-number], [data-gutter-buffer="annotation"])[data-selected-line]::before {
180 position: absolute !important;
181 inset-block: 0 !important;
182 inset-inline-start: 0 !important;
183 display: block !important;
184 width: 4px !important;
185 min-width: 4px !important;
186 max-width: 4px !important;
187 height: auto !important;
188 padding: 0 !important;
189 content: "" !important;
190 background-color: var(--diffs-modified-base) !important;
191 background-image: none !important;
192}
193
122[data-file-info] {194[data-file-info] {
123 background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;195 background-color: var(--background) !important;
124 border-block-color: var(--border) !important;196 border-block-color: transparent !important;
125 color: var(--foreground) !important;197 color: var(--foreground) !important;
126}198}
127199
...@@ -129,14 +201,103 @@ const DIFF_PANEL_UNSAFE_CSS = `...@@ -129,14 +201,103 @@ const DIFF_PANEL_UNSAFE_CSS = `
129 position: sticky !important;201 position: sticky !important;
130 top: 0;202 top: 0;
131 z-index: 4;203 z-index: 4;
132 background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;204 background-color: var(--background) !important;
133 border-bottom: 1px solid var(--border) !important;205 border-bottom-color: transparent !important;
134 align-items: center !important;206 align-items: center !important;
135 font-family: var(--font-sans) !important;207 font-family: var(--font-sans) !important;
136 font-size: 12px !important;208 font-size: 12px !important;
137 line-height: 1 !important;209 line-height: 1 !important;
138 min-height: 32px !important;210 min-height: 32px !important;
139 padding-block: 6px !important;211 padding-block: 6px !important;
212 padding-inline: 8px 12px !important;
213}
214
215[data-diffs-header]:hover {
216 background-color: color-mix(in srgb, var(--background) 97%, var(--foreground)) !important;
217}
218
219:is([data-separator="line-info"], [data-separator="line-info-basic"]) {
220 height: 24px !important;
221 margin-block: 0 !important;
222 background-color: var(--background) !important;
223}
224
225:is([data-separator="line-info"], [data-separator="line-info-basic"])
226 [data-separator-wrapper] {
227 padding-inline: 8px 12px !important;
228 background-color: transparent !important;
229}
230
231:is([data-separator="line-info"], [data-separator="line-info-basic"])
232 [data-separator-content] {
233 gap: 8px;
234 padding-inline: 0 !important;
235 background-color: transparent !important;
236 color: color-mix(in srgb, var(--foreground) 52%, var(--background)) !important;
237 font-family: var(--font-sans) !important;
238 font-size: 11px !important;
239 text-decoration: none !important;
240}
241
242:is([data-separator="line-info"], [data-separator="line-info-basic"])
243 [data-unmodified-lines] {
244 display: flex !important;
245 min-width: 0;
246 flex: 1 1 auto;
247 align-items: center;
248 gap: 8px;
249 cursor: pointer;
250}
251
252:is([data-separator="line-info"], [data-separator="line-info-basic"])
253 [data-unmodified-lines]::before,
254:is([data-separator="line-info"], [data-separator="line-info-basic"])
255 [data-unmodified-lines]::after {
256 width: auto;
257 height: 1px;
258 flex: 1 1 auto;
259 content: "";
260 background-color: color-mix(in srgb, var(--background) 92%, var(--foreground));
261}
262
263:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
264 [data-separator-wrapper] {
265 grid-template-columns: 0 minmax(0, 1fr) !important;
266}
267
268:is([data-separator="line-info"], [data-separator="line-info-basic"])[data-expand-index]
269 [data-separator-content] {
270 grid-column: 2 !important;
271}
272
273:is([data-separator="line-info"], [data-separator="line-info-basic"])
274 [data-expand-button] {
275 display: none !important;
276}
277
278:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
279 [data-expand-button]
280 )
281 [data-separator-content] {
282 cursor: pointer;
283}
284
285:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
286 [data-expand-button]
287 ):hover
288 [data-separator-content] {
289 color: color-mix(in srgb, var(--foreground) 76%, var(--background)) !important;
290}
291
292:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
293 [data-expand-button]
294 ):hover
295 [data-unmodified-lines]::before,
296:is([data-separator="line-info"], [data-separator="line-info-basic"]):has(
297 [data-expand-button]
298 ):hover
299 [data-unmodified-lines]::after {
300 background-color: color-mix(in srgb, var(--background) 84%, var(--foreground));
140}301}
141302
142[data-diffs-header] [data-header-content] {303[data-diffs-header] [data-header-content] {
...@@ -205,7 +366,12 @@ export default function DiffPanel({...@@ -205,7 +366,12 @@ export default function DiffPanel({
205 scopeKey: null,366 scopeKey: null,
206 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,367 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
207 }));368 }));
369 const [codeViewRevision, setCodeViewRevision] = useState(0);
208 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);370 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
371 const lastCompletedTurnRefreshRef = useRef<{
372 readonly threadKey: string | null;
373 readonly turnId: TurnId | null;
374 } | null>(null);
209375
210 const routeThreadRef = useParams({376 const routeThreadRef = useParams({
211 strict: false,377 strict: false,
...@@ -230,6 +396,7 @@ export default function DiffPanel({...@@ -230,6 +396,7 @@ export default function DiffPanel({
230 activeThread?.environmentId ?? null,396 activeThread?.environmentId ?? null,
231 serverConfig?.availableEditors ?? [],397 serverConfig?.availableEditors ?? [],
232 );398 );
399 const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents);
233 const gitStatusQuery = useEnvironmentQuery(400 const gitStatusQuery = useEnvironmentQuery(
234 activeThread !== null && activeThread !== undefined && activeCwd != null401 activeThread !== null && activeThread !== undefined && activeCwd != null
235 ? vcsEnvironment.status({402 ? vcsEnvironment.status({
...@@ -298,6 +465,7 @@ export default function DiffPanel({...@@ -298,6 +465,7 @@ export default function DiffPanel({
298 const collapseScopeKey = routeThreadRef465 const collapseScopeKey = routeThreadRef
299 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`466 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
300 : null;467 : null;
468 const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;
301 const collapsedDiffFileKeys =469 const collapsedDiffFileKeys =
302 collapsedDiffFiles.scopeKey === collapseScopeKey470 collapsedDiffFiles.scopeKey === collapseScopeKey
303 ? collapsedDiffFiles.fileKeys471 ? collapsedDiffFiles.fileKeys
...@@ -360,9 +528,93 @@ export default function DiffPanel({...@@ -360,9 +528,93 @@ export default function DiffPanel({
360 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd528 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
361 ? fallbackBranchDiffPreview529 ? fallbackBranchDiffPreview
362 : primaryBranchDiffPreview;530 : primaryBranchDiffPreview;
531 const refreshBranchDiffPreview = branchDiffPreview.refresh;
532 const canRefreshGitDiff =
533 isGitRepo && selectedTurnId === null && activeThread != null && activeCwd != null;
534 const activeThreadRefreshKey = routeThreadRef
535 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}`
536 : null;
537
538 useEffect(() => {
539 if (!canRefreshGitDiff) return;
540 const refreshOnFocus = () => refreshBranchDiffPreview();
541 window.addEventListener("focus", refreshOnFocus);
542 return () => window.removeEventListener("focus", refreshOnFocus);
543 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
544
545 useEffect(() => {
546 const current = {
547 threadKey: activeThreadRefreshKey,
548 turnId: latestTurn?.turnId ?? null,
549 };
550 const previous = lastCompletedTurnRefreshRef.current;
551 if (!canRefreshGitDiff) {
552 return;
553 }
554 if (previous === null || previous.threadKey !== current.threadKey) {
555 lastCompletedTurnRefreshRef.current = current;
556 return;
557 }
558 if (previous.turnId === current.turnId) return;
559 refreshBranchDiffPreview();
560 lastCompletedTurnRefreshRef.current = current;
561 }, [activeThreadRefreshKey, canRefreshGitDiff, latestTurn?.turnId, refreshBranchDiffPreview]);
562
363 const selectedGitSource = branchDiffPreview.data?.sources.find(563 const selectedGitSource = branchDiffPreview.data?.sources.find(
364 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),564 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
365 );565 );
566 const loadDiffFiles = useMemo<FileDiffContentsLoader | undefined>(() => {
567 const preview = branchDiffPreview.data;
568 if (selectedTurnId !== null || !activeThread || !preview || !selectedGitSource) {
569 return undefined;
570 }
571
572 const source = selectedGitSource;
573 return async (fileDiff) => {
574 const newPath = resolveFileDiffPath(fileDiff);
575 const oldPath = fileDiff.prevName
576 ? resolveFileDiffPath({ ...fileDiff, name: fileDiff.prevName })
577 : newPath;
578 const result = await getDiffFileContents({
579 environmentId: activeThread.environmentId,
580 input: {
581 cwd: preview.cwd,
582 sourceKind: source.kind,
583 changeType: fileDiff.type,
584 baseRef: source.baseRef,
585 headRef: source.headRef,
586 oldPath,
587 newPath,
588 },
589 });
590 if (result._tag !== "Success") {
591 throw squashAtomCommandFailure(result);
592 }
593
594 const newFile = {
595 name: newPath,
596 contents: result.value.newContents,
597 cacheKey: `${source.diffHash}:new:${newPath}`,
598 };
599 if (fileDiff.type === "rename-pure") {
600 return { oldFile: null, newFile };
601 }
602 return {
603 oldFile: {
604 name: oldPath,
605 contents: result.value.oldContents,
606 cacheKey: `${source.diffHash}:old:${oldPath}`,
607 },
608 newFile,
609 };
610 };
611 }, [
612 activeThread,
613 branchDiffPreview.data,
614 getDiffFileContents,
615 selectedGitSource,
616 selectedTurnId,
617 ]);
366 const localBranchRefs = useEnvironmentQuery(618 const localBranchRefs = useEnvironmentQuery(
367 selectedTurnId === null &&619 selectedTurnId === null &&
368 selectedGitScope === "branch" &&620 selectedGitScope === "branch" &&
...@@ -439,10 +691,17 @@ export default function DiffPanel({...@@ -439,10 +691,17 @@ export default function DiffPanel({
439 }),691 }),
440 );692 );
441 }, [renderablePatch]);693 }, [renderablePatch]);
694 const renderableFileEntries = useMemo(
695 () =>
696 renderableFiles.map((fileDiff) => ({
697 fileDiff,
698 fileKey: buildFileDiffRenderKey(fileDiff),
699 })),
700 [renderableFiles],
701 );
442 const codeViewFiles = useMemo(702 const codeViewFiles = useMemo(
443 () =>703 () =>
444 renderableFiles.map((fileDiff) => {704 renderableFileEntries.map(({ fileDiff, fileKey }) => {
445 const fileKey = buildFileDiffRenderKey(fileDiff);
446 return {705 return {
447 fileDiff,706 fileDiff,
448 filePath: resolveFileDiffPath(fileDiff),707 filePath: resolveFileDiffPath(fileDiff),
...@@ -450,18 +709,19 @@ export default function DiffPanel({...@@ -450,18 +709,19 @@ export default function DiffPanel({
450 collapsed: collapsedDiffFileKeys.has(fileKey),709 collapsed: collapsedDiffFileKeys.has(fileKey),
451 };710 };
452 }),711 }),
453 [collapsedDiffFileKeys, renderableFiles],712 [collapsedDiffFileKeys, renderableFileEntries],
454 );713 );
455 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);714 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
456 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);715 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
457 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);716 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
717 const selectedDiffFileKey = selectedFilePath
718 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
719 : null;
458720
459 useEffect(() => {721 useEffect(() => {
460 if (!selectedFilePath) return;722 if (!selectedDiffFileKey) return;
461 const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath);723 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
462 if (!file) return;724 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
463 codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" });
464 }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]);
465725
466 const openDiffFile = useCallback(726 const openDiffFile = useCallback(
467 (filePath: string) => {727 (filePath: string) => {
...@@ -506,6 +766,7 @@ export default function DiffPanel({...@@ -506,6 +766,7 @@ export default function DiffPanel({
506 );766 );
507767
508 const toggleDiffFileCollapse = useCallback(() => {768 const toggleDiffFileCollapse = useCallback(() => {
769 setCodeViewRevision((current) => current + 1);
509 setCollapsedDiffFiles((current) => {770 setCollapsedDiffFiles((current) => {
510 const currentKeys =771 const currentKeys =
511 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;772 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
...@@ -724,23 +985,45 @@ export default function DiffPanel({...@@ -724,23 +985,45 @@ export default function DiffPanel({
724 layout="inline"985 layout="inline"
725 />986 />
726 )}987 )}
988 {canRefreshGitDiff && (
989 <Tooltip>
990 <TooltipTrigger
991 render={
992 <Button
993 type="button"
994 size="icon-sm"
995 variant="ghost"
996 aria-label={branchDiffPreview.isPending ? "Refreshing diff" : "Refresh diff"}
997 onClick={refreshBranchDiffPreview}
998 />
999 }
1000 >
1001 <RefreshCwIcon
1002 className={cn("size-3.5", branchDiffPreview.isPending && "animate-spin")}
1003 />
1004 </TooltipTrigger>
1005 <TooltipPopup side="top">
1006 {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"}
1007 </TooltipPopup>
1008 </Tooltip>
1009 )}
727 {codeViewFiles.length > 0 && (1010 {codeViewFiles.length > 0 && (
728 <Tooltip>1011 <Tooltip>
729 <TooltipTrigger1012 <TooltipTrigger
730 render={1013 render={
731 <Button1014 <Button
732 type="button"1015 type="button"
733 size="icon-xs"1016 size="icon-sm"
734 variant="outline"1017 variant="ghost"
735 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}1018 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
736 onClick={toggleDiffFileCollapse}1019 onClick={toggleDiffFileCollapse}
737 />1020 />
738 }1021 }
739 >1022 >
740 {allDiffFilesCollapsed ? (1023 {allDiffFilesCollapsed ? (
741 <ChevronsUpDownIcon className="size-3" />1024 <ChevronsUpDownIcon className="size-3.5" />
742 ) : (1025 ) : (
743 <ChevronsDownUpIcon className="size-3" />1026 <ChevronsDownUpIcon className="size-3.5" />
744 )}1027 )}
745 </TooltipTrigger>1028 </TooltipTrigger>
746 <TooltipPopup side="top">1029 <TooltipPopup side="top">
...@@ -749,9 +1032,8 @@ export default function DiffPanel({...@@ -749,9 +1032,8 @@ export default function DiffPanel({
749 </Tooltip>1032 </Tooltip>
750 )}1033 )}
751 <ToggleGroup1034 <ToggleGroup
752 className="shrink-0"1035 className="shrink-0 gap-1"
753 variant="outline"1036 size="sm"
754 size="xs"
755 value={[diffRenderMode]}1037 value={[diffRenderMode]}
756 onValueChange={(value) => {1038 onValueChange={(value) => {
757 const next = value[0];1039 const next = value[0];
...@@ -760,11 +1042,11 @@ export default function DiffPanel({...@@ -760,11 +1042,11 @@ export default function DiffPanel({
760 }1042 }
761 }}1043 }}
762 >1044 >
763 <Toggle aria-label="Stacked diff view" value="stacked">1045 <Toggle aria-label="Stacked diff view" value="stacked" variant="ghost">
764 <Rows3Icon className="size-3" />1046 <Rows3Icon className="size-3.5" />
765 </Toggle>1047 </Toggle>
766 <Toggle aria-label="Split diff view" value="split">1048 <Toggle aria-label="Split diff view" value="split" variant="ghost">
767 <Columns2Icon className="size-3" />1049 <Columns2Icon className="size-3.5" />
768 </Toggle>1050 </Toggle>
769 </ToggleGroup>1051 </ToggleGroup>
770 <Tooltip>1052 <Tooltip>
...@@ -772,8 +1054,8 @@ export default function DiffPanel({...@@ -772,8 +1054,8 @@ export default function DiffPanel({
772 render={1054 render={
773 <Toggle1055 <Toggle
774 aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}1056 aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
775 variant="outline"1057 variant="ghost"
776 size="xs"1058 size="sm"
777 pressed={wordWrap}1059 pressed={wordWrap}
778 onPressedChange={(pressed) => {1060 onPressedChange={(pressed) => {
779 setWordWrap(Boolean(pressed));1061 setWordWrap(Boolean(pressed));
...@@ -781,7 +1063,7 @@ export default function DiffPanel({...@@ -781,7 +1063,7 @@ export default function DiffPanel({
781 />1063 />
782 }1064 }
783 >1065 >
784 <TextWrapIcon className="size-3" />1066 <TextWrapIcon className="size-3.5" />
785 </TooltipTrigger>1067 </TooltipTrigger>
786 <TooltipPopup side="top">1068 <TooltipPopup side="top">
787 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}1069 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
...@@ -794,8 +1076,8 @@ export default function DiffPanel({...@@ -794,8 +1076,8 @@ export default function DiffPanel({
794 aria-label={1076 aria-label={
795 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"1077 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
796 }1078 }
797 variant="outline"1079 variant="ghost"
798 size="xs"1080 size="sm"
799 pressed={diffIgnoreWhitespace}1081 pressed={diffIgnoreWhitespace}
800 onPressedChange={(pressed) => {1082 onPressedChange={(pressed) => {
801 setDiffIgnoreWhitespace(Boolean(pressed));1083 setDiffIgnoreWhitespace(Boolean(pressed));
...@@ -803,7 +1085,7 @@ export default function DiffPanel({...@@ -803,7 +1085,7 @@ export default function DiffPanel({
803 />1085 />
804 }1086 }
805 >1087 >
806 <PilcrowIcon className="size-3" />1088 <PilcrowIcon className="size-3.5" />
807 </TooltipTrigger>1089 </TooltipTrigger>
808 <TooltipPopup side="top">1090 <TooltipPopup side="top">
809 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}1091 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
...@@ -875,8 +1157,9 @@ export default function DiffPanel({...@@ -875,8 +1157,9 @@ export default function DiffPanel({
875 }}1157 }}
876 >1158 >
877 <AnnotatableCodeView1159 <AnnotatableCodeView
878 viewerRef={codeViewRef}
879 key={collapseScopeKey ?? reviewSectionId}1160 key={collapseScopeKey ?? reviewSectionId}
1161 viewerRef={codeViewRef}
1162 codeViewKey={codeViewMountKey}
880 className="diff-render-surface h-full min-h-0 overflow-auto"1163 className="diff-render-surface h-full min-h-0 overflow-auto"
881 files={codeViewFiles}1164 files={codeViewFiles}
882 sectionId={reviewSectionId}1165 sectionId={reviewSectionId}
...@@ -891,7 +1174,7 @@ export default function DiffPanel({...@@ -891,7 +1174,7 @@ export default function DiffPanel({
891 <button1174 <button
892 type="button"1175 type="button"
893 className={cn(1176 className={cn(
894 "inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",1177 "-ms-0.5 inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 transition-colors hover:bg-foreground/10 focus-visible:outline-hidden",
895 getDiffCollapseIconClassName(fileDiff),1178 getDiffCollapseIconClassName(fileDiff),
896 )}1179 )}
897 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}1180 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
...@@ -923,8 +1206,14 @@ export default function DiffPanel({...@@ -923,8 +1206,14 @@ export default function DiffPanel({
923 themeType: resolvedTheme as DiffThemeType,1206 themeType: resolvedTheme as DiffThemeType,
924 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,1207 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
925 stickyHeaders: true,1208 stickyHeaders: true,
926 itemMetrics: { diffHeaderHeight: 33 },1209 ...(loadDiffFiles ? { loadDiffFiles } : {}),
927 layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },1210 itemMetrics: {
1211 diffHeaderHeight: 32,
1212 hunkSeparatorHeight: 24,
1213 paddingTop: 0,
1214 paddingBottom: 0,
1215 },
1216 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
928 }}1217 }}
929 />1218 />
930 </div>1219 </div>
apps/web/src/components/DiffPanelShell.tsx+46-22
...@@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded";...@@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
11 const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded";11 const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded";
12 return cn(12 return cn(
13 "flex items-center justify-between gap-2 px-4",13 "flex items-center justify-between gap-2",
14 mode === "embedded" ? "px-2" : "px-4",
14 shouldUseDragRegion15 shouldUseDragRegion
15 ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"16 ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]"
16 : "surface-subheader",17 : "surface-subheader",
...@@ -59,30 +60,53 @@ export function DiffPanelHeaderSkeleton() {...@@ -59,30 +60,53 @@ export function DiffPanelHeaderSkeleton() {
59 );60 );
60}61}
6162
63function DiffFileHeaderSkeleton({ titleClassName }: { titleClassName: string }) {
64 return (
65 <div className="flex h-8 items-center gap-2 px-2 pr-3">
66 <div className="flex size-5 shrink-0 items-center justify-center">
67 <Skeleton className="size-2.5 rounded-[2px]" />
68 </div>
69 <Skeleton className="size-5 shrink-0 rounded-md" />
70 <Skeleton className={cn("h-3 rounded-full", titleClassName)} />
71 <div className="ml-auto flex shrink-0 items-center gap-2">
72 <Skeleton className="h-3 w-5 rounded-full" />
73 <Skeleton className="h-3 w-5 rounded-full" />
74 </div>
75 </div>
76 );
77}
78
79function DiffCodeLineSkeleton({ contentClassName }: { contentClassName: string }) {
80 return (
81 <div className="flex items-center gap-3">
82 <Skeleton className="h-2.5 w-5 shrink-0 rounded-full" />
83 <Skeleton className={cn("h-2.5 rounded-full", contentClassName)} />
84 </div>
85 );
86}
87
62export function DiffPanelLoadingState(props: { label: string }) {88export function DiffPanelLoadingState(props: { label: string }) {
63 return (89 return (
64 <div className="flex min-h-0 flex-1 flex-col p-2">90 <div
65 <div91 className="min-h-0 flex-1 overflow-hidden bg-background"
66 className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/60 bg-card/25"92 role="status"
67 role="status"93 aria-live="polite"
68 aria-live="polite"94 aria-label={props.label}
69 aria-label={props.label}95 >
70 >96 <DiffFileHeaderSkeleton titleClassName="w-1/2 max-w-64" />
71 <div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">97 <div className="flex h-6 items-center gap-2 px-2 pr-3">
72 <Skeleton className="h-4 w-32 rounded-full" />98 <div className="h-px flex-1 bg-border/40" />
73 <Skeleton className="ml-auto h-4 w-20 rounded-full" />99 <Skeleton className="h-2.5 w-24 rounded-full" />
74 </div>100 <div className="h-px flex-1 bg-border/40" />
75 <div className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-4">101 </div>
76 <div className="space-y-2">102 <div className="space-y-2 px-3 py-2">
77 <Skeleton className="h-3 w-full rounded-full" />103 <DiffCodeLineSkeleton contentClassName="w-2/3" />
78 <Skeleton className="h-3 w-full rounded-full" />104 <DiffCodeLineSkeleton contentClassName="w-4/5" />
79 <Skeleton className="h-3 w-10/12 rounded-full" />105 <DiffCodeLineSkeleton contentClassName="w-3/5" />
80 <Skeleton className="h-3 w-11/12 rounded-full" />
81 <Skeleton className="h-3 w-9/12 rounded-full" />
82 </div>
83 <span className="sr-only">{props.label}</span>
84 </div>
85 </div>106 </div>
107 <DiffFileHeaderSkeleton titleClassName="w-2/5 max-w-52" />
108 <DiffFileHeaderSkeleton titleClassName="w-3/5 max-w-72" />
109 <span className="sr-only">{props.label}</span>
86 </div>110 </div>
87 );111 );
88}112}
apps/web/src/components/RightPanelTabs.tsx+34-39
...@@ -221,14 +221,14 @@ function surfaceTitle(...@@ -221,14 +221,14 @@ function surfaceTitle(
221function PreviewFavicon({ url }: { url: string | null }) {221function PreviewFavicon({ url }: { url: string | null }) {
222 const faviconUrl = faviconUrlForOrigin(url, 32);222 const faviconUrl = faviconUrlForOrigin(url, 32);
223 const [failedUrl, setFailedUrl] = useState<string | null>(null);223 const [failedUrl, setFailedUrl] = useState<string | null>(null);
224 if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3.5 shrink-0" />;224 if (!faviconUrl || failedUrl === faviconUrl) return <Globe2 className="size-3 shrink-0" />;
225 return (225 return (
226 <img226 <img
227 src={faviconUrl}227 src={faviconUrl}
228 alt=""228 alt=""
229 aria-hidden229 aria-hidden
230 draggable={false}230 draggable={false}
231 className="size-3.5 shrink-0 rounded-sm"231 className="size-3 shrink-0 rounded-sm"
232 onError={() => setFailedUrl(faviconUrl)}232 onError={() => setFailedUrl(faviconUrl)}
233 />233 />
234 );234 );
...@@ -250,22 +250,22 @@ function SurfaceIcon({...@@ -250,22 +250,22 @@ function SurfaceIcon({
250 return <PreviewFavicon url={url} />;250 return <PreviewFavicon url={url} />;
251 }251 }
252 case "diff":252 case "diff":
253 return <FileDiff className="size-3.5 shrink-0" />;253 return <FileDiff className="size-3 shrink-0" />;
254 case "files":254 case "files":
255 return <Files className="size-3.5 shrink-0" />;255 return <Files className="size-3 shrink-0" />;
256 case "file":256 case "file":
257 return (257 return (
258 <PierreEntryIcon258 <PierreEntryIcon
259 pathValue={surface.relativePath}259 pathValue={surface.relativePath}
260 kind="file"260 kind="file"
261 theme={theme}261 theme={theme}
262 className="size-3.5"262 className="size-3"
263 />263 />
264 );264 );
265 case "terminal":265 case "terminal":
266 return <TerminalSquare className="size-3.5 shrink-0" />;266 return <TerminalSquare className="size-3 shrink-0" />;
267 case "plan":267 case "plan":
268 return <ClipboardList className="size-3.5 shrink-0" />;268 return <ClipboardList className="size-3 shrink-0" />;
269 }269 }
270}270}
271271
...@@ -358,7 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -358,7 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
358 <div358 <div
359 className={cn(359 className={cn(
360 "workspace-topbar gap-1 pl-2",360 "workspace-topbar gap-1 pl-2",
361 !ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",361 props.mode !== "inline" && "[--workspace-topbar-height:--spacing(11)]",
362 props.mode === "inline" ? "pr-28" : "pr-3",362 props.mode === "inline" ? "pr-28" : "pr-3",
363 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",363 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
364 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,364 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
...@@ -385,62 +385,57 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -385,62 +385,57 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
385 onAuxClick={(event) => handleTabAuxClick(event, surface)}385 onAuxClick={(event) => handleTabAuxClick(event, surface)}
386 onContextMenu={(event) => void handleTabContextMenu(event, surface)}386 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
387 className={cn(387 className={cn(
388 "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm",388 "group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs",
389 active389 active
390 ? "bg-accent text-foreground"390 ? "bg-accent text-foreground"
391 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",391 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
392 )}392 )}
393 >393 >
394 <button
395 type="button"
396 className="group/close relative flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted"
397 aria-label={`Close ${title}`}
398 onClick={() => props.onCloseSurface(surface)}
399 >
400 <span className="relative flex size-3 items-center justify-center group-hover/tab:hidden group-focus-visible/close:hidden">
401 <SurfaceIcon
402 surface={surface}
403 sessions={props.previewSessions}
404 theme={resolvedTheme}
405 />
406 {pending ? (
407 <span
408 className="absolute -right-0.5 -bottom-0.5 size-1.5 rounded-full bg-current"
409 aria-hidden
410 />
411 ) : null}
412 </span>
413 <X className="hidden size-3 group-hover/tab:block group-focus-visible/close:block" />
414 </button>
394 <Tooltip>415 <Tooltip>
395 <TooltipTrigger416 <TooltipTrigger
396 render={417 render={
397 <button418 <button
398 type="button"419 type="button"
399 className="flex min-w-0 flex-1 items-center gap-1.5"420 className="flex min-w-0 items-center"
400 onClick={() => props.onActivate(surface)}421 onClick={() => props.onActivate(surface)}
401 >422 >
402 <SurfaceIcon
403 surface={surface}
404 sessions={props.previewSessions}
405 theme={resolvedTheme}
406 />
407 <span className="truncate">{title}</span>423 <span className="truncate">{title}</span>
408 </button>424 </button>
409 }425 }
410 />426 />
411 <TooltipPopup>{title}</TooltipPopup>427 <TooltipPopup>{title}</TooltipPopup>
412 </Tooltip>428 </Tooltip>
413 <button
414 type="button"
415 className={cn(
416 "relative flex size-4 shrink-0 items-center justify-center rounded hover:bg-muted focus:opacity-100",
417 pending ? "opacity-100" : "opacity-0 group-hover:opacity-100",
418 )}
419 aria-label={`Close ${title}`}
420 onClick={() => props.onCloseSurface(surface)}
421 >
422 {pending ? (
423 <>
424 <span
425 className="size-2 rounded-full bg-current group-hover:hidden"
426 aria-hidden
427 />
428 <X className="hidden size-3 group-hover:block" />
429 </>
430 ) : (
431 <X className="size-3" />
432 )}
433 </button>
434 </div>429 </div>
435 );430 );
436 })}431 })}
437 {props.surfaces.length > 0 ? (432 {props.surfaces.length > 0 ? (
438 <Menu>433 <Menu>
439 <MenuTrigger434 <MenuTrigger
440 className="relative inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"435 className="relative inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
441 aria-label="Add panel surface"436 aria-label="Add panel surface"
442 >437 >
443 <Plus className="size-4" />438 <Plus className="size-3.5" />
444 </MenuTrigger>439 </MenuTrigger>
445 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">440 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
446 <SurfaceMenuItem441 <SurfaceMenuItem
...@@ -478,7 +473,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -478,7 +473,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
478 </ScrollArea>473 </ScrollArea>
479 {props.layoutControls}474 {props.layoutControls}
480 </div>475 </div>
481 <div className="flex min-h-0 flex-1 flex-col">476 <div className="flex min-h-0 flex-1 flex-col" data-right-panel-surface-content>
482 {props.activeSurfaceId === null ? (477 {props.activeSurfaceId === null ? (
483 <RightPanelEmptyState478 <RightPanelEmptyState
484 onAddBrowser={props.onAddBrowser}479 onAddBrowser={props.onAddBrowser}
apps/web/src/components/diffs/AnnotatableCodeView.test.tsx created+59
...@@ -0,0 +1,59 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const testState = vi.hoisted(() => ({
5 codeViewOptions: null as Record<string, unknown> | null,
6}));
7
8vi.mock("@pierre/diffs/react", () => ({
9 CodeView: (props: { options: Record<string, unknown> }) => {
10 testState.codeViewOptions = props.options;
11 return null;
12 },
13}));
14
15vi.mock("~/composerDraftStore", () => ({
16 useComposerDraftStore: (selector: (store: Record<string, unknown>) => unknown) =>
17 selector({
18 addReviewComment: vi.fn(),
19 removeReviewComment: vi.fn(),
20 getComposerDraft: () => undefined,
21 }),
22}));
23
24vi.mock("../files/LocalCommentAnnotation", () => ({
25 LocalCommentAnnotation: () => null,
26}));
27
28vi.mock("../files/fileCommentAnnotations", () => ({
29 nextFileCommentId: () => "comment-test",
30}));
31
32import { AnnotatableCodeView } from "./AnnotatableCodeView";
33
34describe("AnnotatableCodeView", () => {
35 beforeEach(() => {
36 testState.codeViewOptions = null;
37 });
38
39 it("opens comments from Pierre's gutter action without ending line selection", () => {
40 renderToStaticMarkup(
41 <AnnotatableCodeView
42 codeViewKey="test-view"
43 files={[]}
44 sectionId="working-tree"
45 sectionTitle="Working tree"
46 composerDraftTarget={"draft-test" as never}
47 options={{}}
48 renderHeaderPrefix={() => null}
49 />,
50 );
51
52 expect(testState.codeViewOptions).toMatchObject({
53 enableGutterUtility: true,
54 enableLineSelection: true,
55 onGutterUtilityClick: expect.any(Function),
56 });
57 expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd");
58 });
59});
apps/web/src/components/diffs/AnnotatableCodeView.tsx+29-16
...@@ -71,6 +71,7 @@ function appendAnnotationEntry(...@@ -71,6 +71,7 @@ function appendAnnotationEntry(
71}71}
7272
73interface AnnotatableCodeViewProps {73interface AnnotatableCodeViewProps {
74 codeViewKey: string;
74 files: ReadonlyArray<{75 files: ReadonlyArray<{
75 fileDiff: FileDiffMetadata;76 fileDiff: FileDiffMetadata;
76 filePath: string;77 filePath: string;
...@@ -95,6 +96,7 @@ interface DiffSelectionContext {...@@ -95,6 +96,7 @@ interface DiffSelectionContext {
95}96}
9697
97export function AnnotatableCodeView({98export function AnnotatableCodeView({
99 codeViewKey,
98 files,100 files,
99 sectionId,101 sectionId,
100 sectionTitle,102 sectionTitle,
...@@ -117,6 +119,7 @@ export function AnnotatableCodeView({...@@ -117,6 +119,7 @@ export function AnnotatableCodeView({
117 fileKey: string;119 fileKey: string;
118 annotation: DiffCommentLineAnnotation;120 annotation: DiffCommentLineAnnotation;
119 } | null>(null);121 } | null>(null);
122 const [draftText, setDraftText] = useState("");
120123
121 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);124 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);
122 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(125 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(
...@@ -167,6 +170,7 @@ export function AnnotatableCodeView({...@@ -167,6 +170,7 @@ export function AnnotatableCodeView({
167 setSelectedLines(null);170 setSelectedLines(null);
168 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {171 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
169 setDraft(null);172 setDraft(null);
173 setDraftText("");
170 } else {174 } else {
171 removeReviewComment(composerDraftTarget, entryId);175 removeReviewComment(composerDraftTarget, entryId);
172 }176 }
...@@ -193,6 +197,7 @@ export function AnnotatableCodeView({...@@ -193,6 +197,7 @@ export function AnnotatableCodeView({
193 if (comment) addReviewComment(composerDraftTarget, comment);197 if (comment) addReviewComment(composerDraftTarget, comment);
194 setSelectedLines(null);198 setSelectedLines(null);
195 setDraft(null);199 setDraft(null);
200 setDraftText("");
196 },201 },
197 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],202 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],
198 );203 );
...@@ -215,6 +220,7 @@ export function AnnotatableCodeView({...@@ -215,6 +220,7 @@ export function AnnotatableCodeView({
215 text: "",220 text: "",
216 });221 });
217 if (!comment) return;222 if (!comment) return;
223 setDraftText("");
218 setDraft({224 setDraft({
219 fileKey: item.id,225 fileKey: item.id,
220 annotation: {226 annotation: {
...@@ -232,6 +238,7 @@ export function AnnotatableCodeView({...@@ -232,6 +238,7 @@ export function AnnotatableCodeView({
232 const hasOpenComment = draft !== null;238 const hasOpenComment = draft !== null;
233 return (239 return (
234 <CodeView<DiffCommentAnnotationGroup>240 <CodeView<DiffCommentAnnotationGroup>
241 key={codeViewKey}
235 {...(viewerRef ? { ref: viewerRef } : {})}242 {...(viewerRef ? { ref: viewerRef } : {})}
236 {...(className ? { className } : {})}243 {...(className ? { className } : {})}
237 items={items}244 items={items}
...@@ -241,28 +248,34 @@ export function AnnotatableCodeView({...@@ -241,28 +248,34 @@ export function AnnotatableCodeView({
241 ...options,248 ...options,
242 enableGutterUtility: !hasOpenComment,249 enableGutterUtility: !hasOpenComment,
243 enableLineSelection: !hasOpenComment,250 enableLineSelection: !hasOpenComment,
244 onLineSelectionEnd: beginComment,251 onGutterUtilityClick: beginComment,
245 }}252 }}
246 renderHeaderPrefix={(item) =>253 renderHeaderPrefix={(item) =>
247 item.type === "diff"254 item.type === "diff"
248 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)255 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
249 : null256 : null
250 }257 }
251 renderAnnotation={(annotation) => (258 renderAnnotation={(annotation) => {
252 <div className="py-1">259 const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft");
253 {annotation.metadata.entries.map((entry) => (260 return (
254 <LocalCommentAnnotation261 <div
255 key={entry.id}262 className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"}
256 kind={entry.kind}263 >
257 rangeLabel={entry.rangeLabel}264 {annotation.metadata.entries.map((entry) => (
258 text={entry.text}265 <LocalCommentAnnotation
259 onCancel={() => removeEntry(entry.id)}266 key={entry.id}
260 onComment={(text) => submitEntry(entry.id, text)}267 kind={entry.kind}
261 onDelete={() => removeEntry(entry.id)}268 rangeLabel={entry.rangeLabel}
262 />269 text={entry.kind === "draft" ? draftText : entry.text}
263 ))}270 onTextChange={setDraftText}
264 </div>271 onCancel={() => removeEntry(entry.id)}
265 )}272 onComment={(text) => submitEntry(entry.id, text)}
273 onDelete={() => removeEntry(entry.id)}
274 />
275 ))}
276 </div>
277 );
278 }}
266 />279 />
267 );280 );
268}281}
apps/web/src/components/files/LocalCommentAnnotation.test.tsx created+66
...@@ -0,0 +1,66 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it, vi } from "vite-plus/test";
3
4import { LocalCommentAnnotation } from "./LocalCommentAnnotation";
5
6const callbacks = {
7 onTextChange: vi.fn(),
8 onCancel: vi.fn(),
9 onComment: vi.fn(),
10 onDelete: vi.fn(),
11};
12
13describe("LocalCommentAnnotation", () => {
14 it("renders the draft composer directly in the selected diff", () => {
15 const markup = renderToStaticMarkup(
16 <LocalCommentAnnotation kind="draft" rangeLabel="+78" text="" {...callbacks} />,
17 );
18
19 expect(markup).toContain("font-sans");
20 expect(markup).not.toContain("chat-composer-glass");
21 expect(markup).not.toContain("font-mono");
22 expect(markup).not.toContain("Local comment");
23 expect(markup).not.toContain("on +78");
24 expect(markup).toContain("⌘/Ctrl Enter to send");
25 expect(markup).toContain("Add a comment…");
26 expect(markup).toContain(">Comment</button>");
27 expect(markup).toContain("autofocus");
28 const textareaControl = markup.match(/<span[^>]*data-slot="textarea-control"[^>]*>/)?.[0];
29 expect(textareaControl).toBeDefined();
30 expect(textareaControl).not.toContain("ring-ring");
31 expect(markup).toContain("cursor-text");
32 });
33
34 it("renders a saved comment without a nested card or redundant range label", () => {
35 const markup = renderToStaticMarkup(
36 <LocalCommentAnnotation
37 kind="comment"
38 rangeLabel="+78"
39 text="Please keep this branch explicit."
40 {...callbacks}
41 />,
42 );
43
44 expect(markup).toContain("font-sans");
45 expect(markup).not.toContain("chat-composer-glass");
46 expect(markup).not.toContain("on +78");
47 expect(markup).toContain("Please keep this branch explicit.");
48 expect(markup).toContain('aria-label="Delete comment"');
49 expect(markup).toContain("border-s-2");
50 expect(markup).toContain("bg-primary/[0.045]");
51 expect(markup).toContain("lucide-message-circle");
52 });
53
54 it("renders draft text owned by the annotation wrapper", () => {
55 const markup = renderToStaticMarkup(
56 <LocalCommentAnnotation
57 kind="draft"
58 rangeLabel="+78"
59 text="Keep this unsaved draft"
60 {...callbacks}
61 />,
62 );
63
64 expect(markup).toContain("Keep this unsaved draft");
65 });
66});
apps/web/src/components/files/LocalCommentAnnotation.tsx+42-29
...@@ -8,6 +8,7 @@ interface LocalCommentAnnotationProps {...@@ -8,6 +8,7 @@ interface LocalCommentAnnotationProps {
8 kind: "draft" | "comment";8 kind: "draft" | "comment";
9 rangeLabel: string;9 rangeLabel: string;
10 text: string;10 text: string;
11 onTextChange?: (text: string) => void;
11 onCancel: () => void;12 onCancel: () => void;
12 onComment: (text: string) => void;13 onComment: (text: string) => void;
13 onDelete: () => void;14 onDelete: () => void;
...@@ -16,32 +17,34 @@ interface LocalCommentAnnotationProps {...@@ -16,32 +17,34 @@ interface LocalCommentAnnotationProps {
16export function LocalCommentAnnotation({17export function LocalCommentAnnotation({
17 kind,18 kind,
18 rangeLabel,19 rangeLabel,
19 text: savedText,20 text,
21 onTextChange,
20 onCancel,22 onCancel,
21 onComment,23 onComment,
22 onDelete,24 onDelete,
23}: LocalCommentAnnotationProps) {25}: LocalCommentAnnotationProps) {
24 const [text, setText] = useState("");26 const [localDraftText, setLocalDraftText] = useState("");
27 const displayedText = kind === "draft" && !onTextChange ? localDraftText : text;
2528
26 if (kind === "comment") {29 if (kind === "comment") {
27 return (30 return (
28 <div31 <div
29 data-file-comment-annotation32 data-file-comment-annotation
30 className="mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 shadow-sm"33 className="group/comment flex min-w-0 items-start gap-2.5 border-s-2 border-primary/55 bg-primary/[0.045] px-3 py-2.5 font-sans text-foreground"
31 contentEditable={false}34 contentEditable={false}
32 onPointerDown={(event) => event.stopPropagation()}35 onPointerDown={(event) => event.stopPropagation()}
33 >36 >
34 <div className="flex items-center gap-2">37 <MessageCircle className="mt-0.5 size-3.5 shrink-0 text-primary/70" aria-hidden="true" />
35 <MessageCircle className="size-4 text-muted-foreground" />38 <p className="min-w-0 flex-1 whitespace-pre-wrap text-[13px] leading-5">{displayedText}</p>
36 <span className="text-xs font-medium">Local comment</span>39 <Button
37 <span className="ml-auto text-[11px] text-muted-foreground">{rangeLabel}</span>40 className="-my-1 -mr-1 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/comment:opacity-100 focus-visible:opacity-100 max-sm:opacity-100"
38 <Button variant="ghost" size="icon-xs" aria-label="Delete comment" onClick={onDelete}>41 variant="ghost"
39 <Trash2 className="size-3.5" />42 size="icon-xs"
40 </Button>43 aria-label="Delete comment"
41 </div>44 onClick={onDelete}
42 <p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-foreground">45 >
43 {savedText}46 <Trash2 className="size-3" />
44 </p>47 </Button>
45 </div>48 </div>
46 );49 );
47 }50 }
...@@ -49,39 +52,49 @@ export function LocalCommentAnnotation({...@@ -49,39 +52,49 @@ export function LocalCommentAnnotation({
49 return (52 return (
50 <div53 <div
51 data-file-comment-annotation54 data-file-comment-annotation
52 className="mx-3 my-2 rounded-xl border border-border/70 bg-background p-3 shadow-lg"55 className="px-3 py-2 font-sans text-foreground"
53 contentEditable={false}56 contentEditable={false}
54 onPointerDown={(event) => event.stopPropagation()}57 onPointerDown={(event) => event.stopPropagation()}
55 >58 >
56 <div className="flex items-center gap-2">
57 <MessageCircle className="size-4 text-muted-foreground" />
58 <span className="text-sm font-medium">Local comment</span>
59 </div>
60 <div className="mt-1 text-xs text-muted-foreground">Comment on lines {rangeLabel}</div>
61 <Textarea59 <Textarea
62 autoFocus60 autoFocus
63 className="mt-3"61 unstyled
62 className="relative inline-flex w-full rounded-md border border-border/50 bg-background/20 font-sans text-foreground transition-colors focus-within:border-border/70 [&_[data-slot=textarea]]:min-h-12 [&_[data-slot=textarea]]:cursor-text [&_[data-slot=textarea]]:px-2.5 [&_[data-slot=textarea]]:py-1.5 [&_[data-slot=textarea]]:font-sans [&_[data-slot=textarea]]:text-xs [&_[data-slot=textarea]]:leading-5 max-sm:[&_[data-slot=textarea]]:min-h-12"
64 size="sm"63 size="sm"
65 value={text}64 value={displayedText}
66 placeholder="Request change"65 placeholder="Add a comment…"
67 aria-label={`Comment on lines ${rangeLabel}`}66 aria-label={`Comment on lines ${rangeLabel}`}
68 onChange={(event) => setText(event.target.value)}67 onChange={(event) => (onTextChange ?? setLocalDraftText)(event.target.value)}
68 onFocus={(event) => {
69 const end = event.currentTarget.value.length;
70 event.currentTarget.setSelectionRange(end, end);
71 }}
69 onKeyDown={(event) => {72 onKeyDown={(event) => {
70 if (event.key === "Escape") {73 if (event.key === "Escape") {
71 event.preventDefault();74 event.preventDefault();
72 onCancel();75 onCancel();
73 }76 }
74 if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && text.trim()) {77 if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && displayedText.trim()) {
75 event.preventDefault();78 event.preventDefault();
76 onComment(text.trim());79 onComment(displayedText.trim());
77 }80 }
78 }}81 }}
79 />82 />
80 <div className="mt-3 flex justify-end gap-2">83 <div className="mt-1.5 flex items-center gap-1">
81 <Button variant="ghost" size="sm" onClick={onCancel}>84 <span className="mr-auto text-[10px] text-muted-foreground/70">⌘/Ctrl Enter to send</span>
85 <Button
86 className="text-muted-foreground hover:text-foreground"
87 variant="ghost"
88 size="xs"
89 onClick={onCancel}
90 >
82 Cancel91 Cancel
83 </Button>92 </Button>
84 <Button size="sm" disabled={!text.trim()} onClick={() => onComment(text.trim())}>93 <Button
94 size="xs"
95 disabled={!displayedText.trim()}
96 onClick={() => onComment(displayedText.trim())}
97 >
85 Comment98 Comment
86 </Button>99 </Button>
87 </div>100 </div>
apps/web/src/index.css+7
...@@ -406,6 +406,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -406,6 +406,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
406 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;406 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;
407 }407 }
408408
409 [data-preview-panel-mode="inline"] [data-right-panel-surface-content] [data-surface-subheader] {
410 height: calc(var(--spacing) * 7);
411 min-height: calc(var(--spacing) * 7);
412 margin-bottom: calc(var(--spacing) * 3);
413 border-bottom-color: transparent;
414 }
415
409 .chat-composer-horizontal-inset {416 .chat-composer-horizontal-inset {
410 padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem);417 padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem);
411 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);418 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);
apps/web/src/lib/diffRendering.test.ts+30-1
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import { buildPatchCacheKey, getDiffLineStat, getRenderablePatch } from "./diffRendering";2import {
3 buildFileDiffRenderKey,
4 buildPatchCacheKey,
5 getDiffLineStat,
6 getRenderablePatch,
7} from "./diffRendering";
38
4describe("buildPatchCacheKey", () => {9describe("buildPatchCacheKey", () => {
5 it("returns a stable cache key for identical content", () => {10 it("returns a stable cache key for identical content", () => {
...@@ -83,6 +88,30 @@ describe("getRenderablePatch", () => {...@@ -83,6 +88,30 @@ describe("getRenderablePatch", () => {
83 });88 });
84});89});
8590
91describe("buildFileDiffRenderKey", () => {
92 it("keeps file identity stable when Pierre hydrates a partial diff", () => {
93 const patch = [
94 "diff --git a/example.ts b/example.ts",
95 "--- a/example.ts",
96 "+++ b/example.ts",
97 "@@ -1 +1 @@",
98 "-before",
99 "+after",
100 ].join("\n");
101 const parsed = getRenderablePatch(patch, "hydrated-key");
102 expect(parsed?.kind).toBe("files");
103 if (parsed?.kind !== "files") return;
104
105 const file = parsed.files[0];
106 expect(file).toBeDefined();
107 if (!file) return;
108 const key = buildFileDiffRenderKey(file);
109 file.cacheKey = `${file.cacheKey}:hydrated`;
110
111 expect(buildFileDiffRenderKey(file)).toBe(key);
112 });
113});
114
86describe("getDiffLineStat", () => {115describe("getDiffLineStat", () => {
87 it("totals additions and deletions across every file and hunk", () => {116 it("totals additions and deletions across every file and hunk", () => {
88 const patch = [117 const patch = [
apps/web/src/lib/diffRendering.ts+4-1
...@@ -152,7 +152,10 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {...@@ -152,7 +152,10 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
152}152}
153153
154export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {154export function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
155 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;155 const cacheKey = fileDiff.cacheKey;
156 if (!cacheKey) return `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
157
158 return cacheKey.endsWith(":hydrated") ? cacheKey.slice(0, -":hydrated".length) : cacheKey;
156}159}
157160
158export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {161export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
packages/client-runtime/src/state/review.ts+25-1
...@@ -1,17 +1,41 @@...@@ -1,17 +1,41 @@
1import { WS_METHODS } from "@t3tools/contracts";1import { WS_METHODS } from "@t3tools/contracts";
2import { Atom } from "effect/unstable/reactivity";2import { Atom } from "effect/unstable/reactivity";
33
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";4import {
5 createAtomCommandScheduler,
6 createEnvironmentRpcCommand,
7 createEnvironmentRpcQueryAtomFamily,
8} from "./runtime.ts";
5import type { EnvironmentRegistry } from "../connection/registry.ts";9import type { EnvironmentRegistry } from "../connection/registry.ts";
610
7export function createReviewEnvironmentAtoms<R, E>(11export function createReviewEnvironmentAtoms<R, E>(
8 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,12 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
9) {13) {
14 const diffFileScheduler = createAtomCommandScheduler();
10 return {15 return {
11 diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, {16 diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, {
12 label: "environment-data:review:diff-preview",17 label: "environment-data:review:diff-preview",
13 tag: WS_METHODS.reviewGetDiffPreview,18 tag: WS_METHODS.reviewGetDiffPreview,
14 staleTimeMs: 5_000,19 staleTimeMs: 5_000,
15 }),20 }),
21 diffFileContents: createEnvironmentRpcCommand(runtime, {
22 label: "environment-data:review:diff-file-contents",
23 tag: WS_METHODS.reviewGetDiffFileContents,
24 scheduler: diffFileScheduler,
25 concurrency: {
26 mode: "singleFlight",
27 key: ({ environmentId, input }) =>
28 JSON.stringify([
29 environmentId,
30 input.cwd,
31 input.sourceKind,
32 input.baseRef,
33 input.headRef,
34 input.oldPath,
35 input.newPath,
36 input.changeType,
37 ]),
38 },
39 }),
16 };40 };
17}41}
packages/contracts/src/ipc.ts+9-1
...@@ -18,7 +18,12 @@ import type {...@@ -18,7 +18,12 @@ import type {
18 VcsStatusInput,18 VcsStatusInput,
19 VcsStatusResult,19 VcsStatusResult,
20} from "./git.ts";20} from "./git.ts";
21import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";21import type {
22 ReviewDiffFileContentsInput,
23 ReviewDiffFileContentsResult,
24 ReviewDiffPreviewInput,
25 ReviewDiffPreviewResult,
26} from "./review.ts";
22import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";27import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
23import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";28import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
24import type {29import type {
...@@ -1203,6 +1208,9 @@ export interface EnvironmentApi {...@@ -1203,6 +1208,9 @@ export interface EnvironmentApi {
1203 };1208 };
1204 review: {1209 review: {
1205 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;1210 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;
1211 getDiffFileContents: (
1212 input: ReviewDiffFileContentsInput,
1213 ) => Promise<ReviewDiffFileContentsResult>;
1206 };1214 };
1207 orchestration: {1215 orchestration: {
1208 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;1216 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
packages/contracts/src/review.ts+17
...@@ -25,6 +25,23 @@ export const ReviewDiffPreviewSource = Schema.Struct({...@@ -25,6 +25,23 @@ export const ReviewDiffPreviewSource = Schema.Struct({
25});25});
26export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;26export type ReviewDiffPreviewSource = typeof ReviewDiffPreviewSource.Type;
2727
28export const ReviewDiffFileContentsInput = Schema.Struct({
29 cwd: TrimmedNonEmptyString,
30 sourceKind: ReviewDiffPreviewSourceKind,
31 changeType: Schema.Literals(["change", "rename-pure", "rename-changed", "new", "deleted"]),
32 baseRef: Schema.NullOr(TrimmedNonEmptyString),
33 headRef: Schema.NullOr(TrimmedNonEmptyString),
34 oldPath: TrimmedNonEmptyString,
35 newPath: TrimmedNonEmptyString,
36});
37export type ReviewDiffFileContentsInput = typeof ReviewDiffFileContentsInput.Type;
38
39export const ReviewDiffFileContentsResult = Schema.Struct({
40 oldContents: Schema.String,
41 newContents: Schema.String,
42});
43export type ReviewDiffFileContentsResult = typeof ReviewDiffFileContentsResult.Type;
44
28export const ReviewDiffPreviewResult = Schema.Struct({45export const ReviewDiffPreviewResult = Schema.Struct({
29 cwd: TrimmedNonEmptyString,46 cwd: TrimmedNonEmptyString,
30 generatedAt: Schema.DateTimeUtc,47 generatedAt: Schema.DateTimeUtc,
packages/contracts/src/rpc.ts+10
...@@ -45,6 +45,8 @@ import {...@@ -45,6 +45,8 @@ import {
45 VcsStatusStreamEvent,45 VcsStatusStreamEvent,
46} from "./git.ts";46} from "./git.ts";
47import {47import {
48 ReviewDiffFileContentsInput,
49 ReviewDiffFileContentsResult,
48 ReviewDiffPreviewError,50 ReviewDiffPreviewError,
49 ReviewDiffPreviewInput,51 ReviewDiffPreviewInput,
50 ReviewDiffPreviewResult,52 ReviewDiffPreviewResult,
...@@ -197,6 +199,7 @@ export const WS_METHODS = {...@@ -197,6 +199,7 @@ export const WS_METHODS = {
197199
198 // Review methods200 // Review methods
199 reviewGetDiffPreview: "review.getDiffPreview",201 reviewGetDiffPreview: "review.getDiffPreview",
202 reviewGetDiffFileContents: "review.getDiffFileContents",
200203
201 // Terminal methods204 // Terminal methods
202 terminalOpen: "terminal.open",205 terminalOpen: "terminal.open",
...@@ -566,6 +569,12 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie...@@ -566,6 +569,12 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie
566 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),569 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
567});570});
568571
572export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, {
573 payload: ReviewDiffFileContentsInput,
574 success: ReviewDiffFileContentsResult,
575 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
576});
577
569export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {578export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
570 payload: TerminalOpenInput,579 payload: TerminalOpenInput,
571 success: TerminalSessionSnapshot,580 success: TerminalSessionSnapshot,
...@@ -830,6 +839,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -830,6 +839,7 @@ export const WsRpcGroup = RpcGroup.make(
830 WsVcsSwitchRefRpc,839 WsVcsSwitchRefRpc,
831 WsVcsInitRpc,840 WsVcsInitRpc,
832 WsReviewGetDiffPreviewRpc,841 WsReviewGetDiffPreviewRpc,
842 WsReviewGetDiffFileContentsRpc,
833 WsTerminalOpenRpc,843 WsTerminalOpenRpc,
834 WsTerminalAttachRpc,844 WsTerminalAttachRpc,
835 WsTerminalWriteRpc,845 WsTerminalWriteRpc,