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 = {
7575 [WS_METHODS.vcsSwitchRef]: AuthOrchestrationOperateScope,
7676 [WS_METHODS.vcsInit]: AuthOrchestrationOperateScope,
7777 [WS_METHODS.reviewGetDiffPreview]: AuthReviewWriteScope,
78 [WS_METHODS.reviewGetDiffFileContents]: AuthReviewWriteScope,
7879 [WS_METHODS.terminalOpen]: AuthTerminalOperateScope,
7980 [WS_METHODS.terminalAttach]: AuthTerminalOperateScope,
8081 [WS_METHODS.terminalWrite]: AuthTerminalOperateScope,
apps/server/src/review/ReviewService.test.ts+33
......@@ -57,6 +57,39 @@ describe("ReviewService", () => {
5757 }).pipe(Effect.provide(NodeServices.layer)),
5858 );
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
6093 it.effect("allows diff preview cwd inside the configured workspace root", () =>
6194 Effect.gen(function* () {
6295 const fs = yield* FileSystem.FileSystem;
apps/server/src/review/ReviewService.ts+30-3
......@@ -8,6 +8,8 @@ import * as Path from "effect/Path";
88import {
99 VcsRepositoryDetectionError,
1010 VcsUnsupportedOperationError,
11 type ReviewDiffFileContentsInput,
12 type ReviewDiffFileContentsResult,
1113 type ReviewDiffPreviewError,
1214 type ReviewDiffPreviewInput,
1315 type ReviewDiffPreviewResult,
......@@ -23,6 +25,9 @@ export class ReviewService extends Context.Service<
2325 readonly getDiffPreview: (
2426 input: ReviewDiffPreviewInput,
2527 ) => Effect.Effect<ReviewDiffPreviewResult, ReviewDiffPreviewError>;
28 readonly getDiffFileContents: (
29 input: ReviewDiffFileContentsInput,
30 ) => Effect.Effect<ReviewDiffFileContentsResult, ReviewDiffPreviewError>;
2631 }
2732>()("t3/review/ReviewService") {}
2833
......@@ -58,6 +63,7 @@ export const make = Effect.gen(function* () {
5863 };
5964
6065 const assertWorkspaceBoundCwd = Effect.fn("ReviewService.assertWorkspaceBoundCwd")(function* (
66 operation: "ReviewService.getDiffPreview" | "ReviewService.getDiffFileContents",
6167 cwd: string,
6268 ) {
6369 const [candidate, workspaceRoot, worktreesRoot] = yield* Effect.all([
......@@ -71,16 +77,19 @@ export const make = Effect.gen(function* () {
7177 }
7278
7379 return yield* new VcsRepositoryDetectionError({
74 operation: "ReviewService.getDiffPreview",
80 operation,
7581 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.",
7786 });
7887 });
7988
8089 const getDiffPreview: ReviewService["Service"]["getDiffPreview"] = Effect.fn(
8190 "ReviewService.getDiffPreview",
8291 )(function* (input) {
83 yield* assertWorkspaceBoundCwd(input.cwd);
92 yield* assertWorkspaceBoundCwd("ReviewService.getDiffPreview", input.cwd);
8493
8594 const handle = yield* vcsRegistry.detect({ cwd: input.cwd, requestedKind: "auto" });
8695 if (!handle) {
......@@ -106,8 +115,26 @@ export const make = Effect.gen(function* () {
106115 return yield* getDriverDiffPreview(input);
107116 });
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
109135 return ReviewService.of({
110136 getDiffPreview,
137 getDiffFileContents,
111138 });
112139});
113140
apps/server/src/server.test.ts+21
......@@ -5249,6 +5249,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
52495249 },
52505250 ],
52515251 }),
5252 getDiffFileContents: () =>
5253 Effect.succeed({
5254 oldContents: "before\n",
5255 newContents: "after\n",
5256 }),
52525257 },
52535258 },
52545259 });
......@@ -5363,6 +5368,22 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
53635368 ),
53645369 );
53655370 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");
53665387 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
53675388 );
53685389
apps/server/src/vcs/GitVcsDriver.ts+5
......@@ -20,6 +20,8 @@ import {
2020 type VcsCreateWorktreeResult,
2121 type ReviewDiffPreviewInput,
2222 type ReviewDiffPreviewResult,
23 type ReviewDiffFileContentsInput,
24 type ReviewDiffFileContentsResult,
2325 type VcsInitInput,
2426 type VcsListRefsInput,
2527 type VcsListRefsResult,
......@@ -221,6 +223,9 @@ export class GitVcsDriver extends Context.Service<
221223 readonly getReviewDiffPreview: (
222224 input: ReviewDiffPreviewInput,
223225 ) => Effect.Effect<ReviewDiffPreviewResult, GitCommandError>;
226 readonly getReviewDiffFileContents: (
227 input: ReviewDiffFileContentsInput,
228 ) => Effect.Effect<ReviewDiffFileContentsResult, GitCommandError>;
224229 readonly readConfigValue: (
225230 cwd: string,
226231 key: string,
apps/server/src/vcs/GitVcsDriverCore.test.ts+116-1
......@@ -14,7 +14,7 @@ import * as Stream from "effect/Stream";
1414import * as TestClock from "effect/testing/TestClock";
1515import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1616
17import { GitCommandError } from "@t3tools/contracts";
17import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts";
1818import { ServerConfig } from "../config.ts";
1919import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts";
2020import * as GitVcsDriver from "./GitVcsDriver.ts";
......@@ -78,6 +78,20 @@ const writeTextFile = (
7878 yield* fileSystem.writeFileString(filePath, contents);
7979 });
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
8195const git = (
8296 cwd: string,
8397 args: ReadonlyArray<string>,
......@@ -803,6 +817,107 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
803817 );
804818 }),
805819 );
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 );
806921 });
807922
808923 describe("repository status", () => {
apps/server/src/vcs/GitVcsDriverCore.ts+160
......@@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
2121
2222import {
2323 GitCommandError,
24 type ReviewDiffFileContentsInput,
2425 type ReviewDiffPreviewInput,
2526 type ReviewDiffPreviewSource,
2627 type VcsRef,
......@@ -46,6 +47,7 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
4647const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
4748const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
4849const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000;
50const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024;
4951const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000;
5052const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
5153const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
......@@ -2270,6 +2272,163 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
22702272 };
22712273 });
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
22732432 const readConfigValue: GitVcsDriver.GitVcsDriver["Service"]["readConfigValue"] = (cwd, key) =>
22742433 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
22752434 Effect.map((stdout) => stdout.trim()),
......@@ -2903,6 +3062,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
29033062 pullCurrentBranch: (cwd) => withListRefsInvalidation(cwd, pullCurrentBranch(cwd)),
29043063 readRangeContext,
29053064 getReviewDiffPreview,
3065 getReviewDiffFileContents,
29063066 readConfigValue,
29073067 listRefs,
29083068 createWorktree: (input) => withListRefsInvalidation(input.cwd, createWorktree(input)),
apps/server/src/ws.ts+6
......@@ -1854,6 +1854,12 @@ const makeWsRpcLayer = (
18541854 observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), {
18551855 "rpc.aggregate": "review",
18561856 }),
1857 [WS_METHODS.reviewGetDiffFileContents]: (input) =>
1858 observeRpcEffect(
1859 WS_METHODS.reviewGetDiffFileContents,
1860 review.getDiffFileContents(input),
1861 { "rpc.aggregate": "review" },
1862 ),
18571863 [WS_METHODS.terminalOpen]: (input) =>
18581864 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
18591865 "rpc.aggregate": "terminal",
apps/web/src/components/ChatView.tsx+8-1
......@@ -5648,7 +5648,14 @@ function ChatViewContent(props: ChatViewProps) {
56485648 />
56495649 );
56505650 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 >
56525659 {rightPanelOpen && !shouldUsePlanSidebarSheet ? (
56535660 <RightPanelMaximizeControl
56545661 maximized={rightPanelMaximized}
apps/web/src/components/DiffPanel.tsx+329-40
......@@ -1,4 +1,5 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { FileDiffContentsLoader } from "@pierre/diffs";
23import { useParams } from "@tanstack/react-router";
34import {
45 isAtomCommandInterrupted,
......@@ -15,6 +16,7 @@ import {
1516 ChevronsUpDownIcon,
1617 Columns2Icon,
1718 PilcrowIcon,
19 RefreshCwIcon,
1820 Rows3Icon,
1921 SearchIcon,
2022 TextWrapIcon,
......@@ -67,6 +69,7 @@ import {
6769} from "./ui/menu";
6870import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
6971import { useEnvironmentQuery } from "../state/query";
72import { useAtomCommand } from "../state/use-atom-command";
7073import { serverEnvironment } from "../state/server";
7174import { reviewEnvironment } from "../state/review";
7275import { vcsEnvironment } from "../state/vcs";
......@@ -91,9 +94,9 @@ const DIFF_PANEL_UNSAFE_CSS = `
9194[data-virtualizer-buffer] {
9295 --diffs-header-font-family: var(--font-sans) !important;
9396 --diffs-font-family: var(--font-mono) !important;
94 --diffs-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
95 --diffs-light-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
96 --diffs-dark-bg: color-mix(in srgb, var(--card) 90%, var(--background)) !important;
97 --diffs-bg: var(--background) !important;
98 --diffs-light-bg: var(--background) !important;
99 --diffs-dark-bg: var(--background) !important;
97100 --diffs-token-light-bg: transparent;
98101 --diffs-token-dark-bg: transparent;
99102
......@@ -102,13 +105,25 @@ const DIFF_PANEL_UNSAFE_CSS = `
102105 --diffs-bg-separator-override: color-mix(in srgb, var(--background) 95%, var(--foreground));
103106 --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));
106 --diffs-bg-addition-number-override: color-mix(in srgb, var(--background) 88%, var(--success));
108 --diffs-bg-addition-override: light-dark(
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 );
107116 --diffs-bg-addition-hover-override: color-mix(in srgb, var(--background) 85%, var(--success));
108117 --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));
111 --diffs-bg-deletion-number-override: color-mix(in srgb, var(--background) 88%, var(--destructive));
119 --diffs-bg-deletion-override: light-dark(
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 );
112127 --diffs-bg-deletion-hover-override: color-mix(in srgb, var(--background) 85%, var(--destructive));
113128 --diffs-bg-deletion-emphasis-override: color-mix(
114129 in srgb,
......@@ -119,9 +134,66 @@ const DIFF_PANEL_UNSAFE_CSS = `
119134 background-color: var(--diffs-bg) !important;
120135}
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
122194[data-file-info] {
123 background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;
124 border-block-color: var(--border) !important;
195 background-color: var(--background) !important;
196 border-block-color: transparent !important;
125197 color: var(--foreground) !important;
126198}
127199
......@@ -129,14 +201,103 @@ const DIFF_PANEL_UNSAFE_CSS = `
129201 position: sticky !important;
130202 top: 0;
131203 z-index: 4;
132 background-color: color-mix(in srgb, var(--card) 94%, var(--foreground)) !important;
133 border-bottom: 1px solid var(--border) !important;
204 background-color: var(--background) !important;
205 border-bottom-color: transparent !important;
134206 align-items: center !important;
135207 font-family: var(--font-sans) !important;
136208 font-size: 12px !important;
137209 line-height: 1 !important;
138210 min-height: 32px !important;
139211 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));
140301}
141302
142303[data-diffs-header] [data-header-content] {
......@@ -205,7 +366,12 @@ export default function DiffPanel({
205366 scopeKey: null,
206367 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
207368 }));
369 const [codeViewRevision, setCodeViewRevision] = useState(0);
208370 const codeViewRef = useRef<AnnotatableCodeViewHandle>(null);
371 const lastCompletedTurnRefreshRef = useRef<{
372 readonly threadKey: string | null;
373 readonly turnId: TurnId | null;
374 } | null>(null);
209375
210376 const routeThreadRef = useParams({
211377 strict: false,
......@@ -230,6 +396,7 @@ export default function DiffPanel({
230396 activeThread?.environmentId ?? null,
231397 serverConfig?.availableEditors ?? [],
232398 );
399 const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents);
233400 const gitStatusQuery = useEnvironmentQuery(
234401 activeThread !== null && activeThread !== undefined && activeCwd != null
235402 ? vcsEnvironment.status({
......@@ -298,6 +465,7 @@ export default function DiffPanel({
298465 const collapseScopeKey = routeThreadRef
299466 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
300467 : null;
468 const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;
301469 const collapsedDiffFileKeys =
302470 collapsedDiffFiles.scopeKey === collapseScopeKey
303471 ? collapsedDiffFiles.fileKeys
......@@ -360,9 +528,93 @@ export default function DiffPanel({
360528 const branchDiffPreview = shouldRetryBranchDiffAtEnvironmentCwd
361529 ? fallbackBranchDiffPreview
362530 : 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
363563 const selectedGitSource = branchDiffPreview.data?.sources.find(
364564 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
365565 );
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 ]);
366618 const localBranchRefs = useEnvironmentQuery(
367619 selectedTurnId === null &&
368620 selectedGitScope === "branch" &&
......@@ -439,10 +691,17 @@ export default function DiffPanel({
439691 }),
440692 );
441693 }, [renderablePatch]);
694 const renderableFileEntries = useMemo(
695 () =>
696 renderableFiles.map((fileDiff) => ({
697 fileDiff,
698 fileKey: buildFileDiffRenderKey(fileDiff),
699 })),
700 [renderableFiles],
701 );
442702 const codeViewFiles = useMemo(
443703 () =>
444 renderableFiles.map((fileDiff) => {
445 const fileKey = buildFileDiffRenderKey(fileDiff);
704 renderableFileEntries.map(({ fileDiff, fileKey }) => {
446705 return {
447706 fileDiff,
448707 filePath: resolveFileDiffPath(fileDiff),
......@@ -450,18 +709,19 @@ export default function DiffPanel({
450709 collapsed: collapsedDiffFileKeys.has(fileKey),
451710 };
452711 }),
453 [collapsedDiffFileKeys, renderableFiles],
712 [collapsedDiffFileKeys, renderableFileEntries],
454713 );
455714 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
456715 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
457716 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
717 const selectedDiffFileKey = selectedFilePath
718 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
719 : null;
458720
459721 useEffect(() => {
460 if (!selectedFilePath) return;
461 const file = codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath);
462 if (!file) return;
463 codeViewRef.current?.scrollTo({ type: "item", id: file.fileKey, align: "start" });
464 }, [codeViewFiles, selectedFilePath, selectedFileRevealRequestId]);
722 if (!selectedDiffFileKey) return;
723 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
724 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
465725
466726 const openDiffFile = useCallback(
467727 (filePath: string) => {
......@@ -506,6 +766,7 @@ export default function DiffPanel({
506766 );
507767
508768 const toggleDiffFileCollapse = useCallback(() => {
769 setCodeViewRevision((current) => current + 1);
509770 setCollapsedDiffFiles((current) => {
510771 const currentKeys =
511772 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
......@@ -724,23 +985,45 @@ export default function DiffPanel({
724985 layout="inline"
725986 />
726987 )}
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 )}
7271010 {codeViewFiles.length > 0 && (
7281011 <Tooltip>
7291012 <TooltipTrigger
7301013 render={
7311014 <Button
7321015 type="button"
733 size="icon-xs"
734 variant="outline"
1016 size="icon-sm"
1017 variant="ghost"
7351018 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
7361019 onClick={toggleDiffFileCollapse}
7371020 />
7381021 }
7391022 >
7401023 {allDiffFilesCollapsed ? (
741 <ChevronsUpDownIcon className="size-3" />
1024 <ChevronsUpDownIcon className="size-3.5" />
7421025 ) : (
743 <ChevronsDownUpIcon className="size-3" />
1026 <ChevronsDownUpIcon className="size-3.5" />
7441027 )}
7451028 </TooltipTrigger>
7461029 <TooltipPopup side="top">
......@@ -749,9 +1032,8 @@ export default function DiffPanel({
7491032 </Tooltip>
7501033 )}
7511034 <ToggleGroup
752 className="shrink-0"
753 variant="outline"
754 size="xs"
1035 className="shrink-0 gap-1"
1036 size="sm"
7551037 value={[diffRenderMode]}
7561038 onValueChange={(value) => {
7571039 const next = value[0];
......@@ -760,11 +1042,11 @@ export default function DiffPanel({
7601042 }
7611043 }}
7621044 >
763 <Toggle aria-label="Stacked diff view" value="stacked">
764 <Rows3Icon className="size-3" />
1045 <Toggle aria-label="Stacked diff view" value="stacked" variant="ghost">
1046 <Rows3Icon className="size-3.5" />
7651047 </Toggle>
766 <Toggle aria-label="Split diff view" value="split">
767 <Columns2Icon className="size-3" />
1048 <Toggle aria-label="Split diff view" value="split" variant="ghost">
1049 <Columns2Icon className="size-3.5" />
7681050 </Toggle>
7691051 </ToggleGroup>
7701052 <Tooltip>
......@@ -772,8 +1054,8 @@ export default function DiffPanel({
7721054 render={
7731055 <Toggle
7741056 aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
775 variant="outline"
776 size="xs"
1057 variant="ghost"
1058 size="sm"
7771059 pressed={wordWrap}
7781060 onPressedChange={(pressed) => {
7791061 setWordWrap(Boolean(pressed));
......@@ -781,7 +1063,7 @@ export default function DiffPanel({
7811063 />
7821064 }
7831065 >
784 <TextWrapIcon className="size-3" />
1066 <TextWrapIcon className="size-3.5" />
7851067 </TooltipTrigger>
7861068 <TooltipPopup side="top">
7871069 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
......@@ -794,8 +1076,8 @@ export default function DiffPanel({
7941076 aria-label={
7951077 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
7961078 }
797 variant="outline"
798 size="xs"
1079 variant="ghost"
1080 size="sm"
7991081 pressed={diffIgnoreWhitespace}
8001082 onPressedChange={(pressed) => {
8011083 setDiffIgnoreWhitespace(Boolean(pressed));
......@@ -803,7 +1085,7 @@ export default function DiffPanel({
8031085 />
8041086 }
8051087 >
806 <PilcrowIcon className="size-3" />
1088 <PilcrowIcon className="size-3.5" />
8071089 </TooltipTrigger>
8081090 <TooltipPopup side="top">
8091091 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
......@@ -875,8 +1157,9 @@ export default function DiffPanel({
8751157 }}
8761158 >
8771159 <AnnotatableCodeView
878 viewerRef={codeViewRef}
8791160 key={collapseScopeKey ?? reviewSectionId}
1161 viewerRef={codeViewRef}
1162 codeViewKey={codeViewMountKey}
8801163 className="diff-render-surface h-full min-h-0 overflow-auto"
8811164 files={codeViewFiles}
8821165 sectionId={reviewSectionId}
......@@ -891,7 +1174,7 @@ export default function DiffPanel({
8911174 <button
8921175 type="button"
8931176 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",
8951178 getDiffCollapseIconClassName(fileDiff),
8961179 )}
8971180 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
......@@ -923,8 +1206,14 @@ export default function DiffPanel({
9231206 themeType: resolvedTheme as DiffThemeType,
9241207 unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
9251208 stickyHeaders: true,
926 itemMetrics: { diffHeaderHeight: 33 },
927 layout: { paddingTop: 0, paddingBottom: 8, gap: 8 },
1209 ...(loadDiffFiles ? { loadDiffFiles } : {}),
1210 itemMetrics: {
1211 diffHeaderHeight: 32,
1212 hunkSeparatorHeight: 24,
1213 paddingTop: 0,
1214 paddingBottom: 0,
1215 },
1216 layout: { paddingTop: 0, paddingBottom: 0, gap: 0 },
9281217 }}
9291218 />
9301219 </div>
apps/web/src/components/DiffPanelShell.tsx+46-22
......@@ -10,7 +10,8 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar" | "embedded";
1010function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
1111 const shouldUseDragRegion = isElectron && mode !== "sheet" && mode !== "embedded";
1212 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",
1415 shouldUseDragRegion
1516 ? "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)]"
1617 : "surface-subheader",
......@@ -59,30 +60,53 @@ export function DiffPanelHeaderSkeleton() {
5960 );
6061}
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
6288export function DiffPanelLoadingState(props: { label: string }) {
6389 return (
64 <div className="flex min-h-0 flex-1 flex-col p-2">
65 <div
66 className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border/60 bg-card/25"
67 role="status"
68 aria-live="polite"
69 aria-label={props.label}
70 >
71 <div className="flex items-center gap-2 border-b border-border/50 px-3 py-2">
72 <Skeleton className="h-4 w-32 rounded-full" />
73 <Skeleton className="ml-auto h-4 w-20 rounded-full" />
74 </div>
75 <div className="flex min-h-0 flex-1 flex-col gap-4 px-3 py-4">
76 <div className="space-y-2">
77 <Skeleton className="h-3 w-full rounded-full" />
78 <Skeleton className="h-3 w-full rounded-full" />
79 <Skeleton className="h-3 w-10/12 rounded-full" />
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>
90 <div
91 className="min-h-0 flex-1 overflow-hidden bg-background"
92 role="status"
93 aria-live="polite"
94 aria-label={props.label}
95 >
96 <DiffFileHeaderSkeleton titleClassName="w-1/2 max-w-64" />
97 <div className="flex h-6 items-center gap-2 px-2 pr-3">
98 <div className="h-px flex-1 bg-border/40" />
99 <Skeleton className="h-2.5 w-24 rounded-full" />
100 <div className="h-px flex-1 bg-border/40" />
101 </div>
102 <div className="space-y-2 px-3 py-2">
103 <DiffCodeLineSkeleton contentClassName="w-2/3" />
104 <DiffCodeLineSkeleton contentClassName="w-4/5" />
105 <DiffCodeLineSkeleton contentClassName="w-3/5" />
85106 </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>
86110 </div>
87111 );
88112}
apps/web/src/components/RightPanelTabs.tsx+34-39
......@@ -221,14 +221,14 @@ function surfaceTitle(
221221function PreviewFavicon({ url }: { url: string | null }) {
222222 const faviconUrl = faviconUrlForOrigin(url, 32);
223223 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" />;
225225 return (
226226 <img
227227 src={faviconUrl}
228228 alt=""
229229 aria-hidden
230230 draggable={false}
231 className="size-3.5 shrink-0 rounded-sm"
231 className="size-3 shrink-0 rounded-sm"
232232 onError={() => setFailedUrl(faviconUrl)}
233233 />
234234 );
......@@ -250,22 +250,22 @@ function SurfaceIcon({
250250 return <PreviewFavicon url={url} />;
251251 }
252252 case "diff":
253 return <FileDiff className="size-3.5 shrink-0" />;
253 return <FileDiff className="size-3 shrink-0" />;
254254 case "files":
255 return <Files className="size-3.5 shrink-0" />;
255 return <Files className="size-3 shrink-0" />;
256256 case "file":
257257 return (
258258 <PierreEntryIcon
259259 pathValue={surface.relativePath}
260260 kind="file"
261261 theme={theme}
262 className="size-3.5"
262 className="size-3"
263263 />
264264 );
265265 case "terminal":
266 return <TerminalSquare className="size-3.5 shrink-0" />;
266 return <TerminalSquare className="size-3 shrink-0" />;
267267 case "plan":
268 return <ClipboardList className="size-3.5 shrink-0" />;
268 return <ClipboardList className="size-3 shrink-0" />;
269269 }
270270}
271271
......@@ -358,7 +358,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
358358 <div
359359 className={cn(
360360 "workspace-topbar gap-1 pl-2",
361 !ownsDesktopTitleBar && "[--workspace-topbar-height:--spacing(11)]",
361 props.mode !== "inline" && "[--workspace-topbar-height:--spacing(11)]",
362362 props.mode === "inline" ? "pr-28" : "pr-3",
363363 ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]",
364364 props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS,
......@@ -385,62 +385,57 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
385385 onAuxClick={(event) => handleTabAuxClick(event, surface)}
386386 onContextMenu={(event) => void handleTabContextMenu(event, surface)}
387387 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",
389389 active
390390 ? "bg-accent text-foreground"
391391 : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
392392 )}
393393 >
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>
394415 <Tooltip>
395416 <TooltipTrigger
396417 render={
397418 <button
398419 type="button"
399 className="flex min-w-0 flex-1 items-center gap-1.5"
420 className="flex min-w-0 items-center"
400421 onClick={() => props.onActivate(surface)}
401422 >
402 <SurfaceIcon
403 surface={surface}
404 sessions={props.previewSessions}
405 theme={resolvedTheme}
406 />
407423 <span className="truncate">{title}</span>
408424 </button>
409425 }
410426 />
411427 <TooltipPopup>{title}</TooltipPopup>
412428 </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>
434429 </div>
435430 );
436431 })}
437432 {props.surfaces.length > 0 ? (
438433 <Menu>
439434 <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"
441436 aria-label="Add panel surface"
442437 >
443 <Plus className="size-4" />
438 <Plus className="size-3.5" />
444439 </MenuTrigger>
445440 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">
446441 <SurfaceMenuItem
......@@ -478,7 +473,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
478473 </ScrollArea>
479474 {props.layoutControls}
480475 </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>
482477 {props.activeSurfaceId === null ? (
483478 <RightPanelEmptyState
484479 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(
7171}
7272
7373interface AnnotatableCodeViewProps {
74 codeViewKey: string;
7475 files: ReadonlyArray<{
7576 fileDiff: FileDiffMetadata;
7677 filePath: string;
......@@ -95,6 +96,7 @@ interface DiffSelectionContext {
9596}
9697
9798export function AnnotatableCodeView({
99 codeViewKey,
98100 files,
99101 sectionId,
100102 sectionTitle,
......@@ -117,6 +119,7 @@ export function AnnotatableCodeView({
117119 fileKey: string;
118120 annotation: DiffCommentLineAnnotation;
119121 } | null>(null);
122 const [draftText, setDraftText] = useState("");
120123
121124 const filesByKey = useMemo(() => new Map(files.map((file) => [file.fileKey, file])), [files]);
122125 const items = useMemo<CodeViewDiffItem<DiffCommentAnnotationGroup>[]>(
......@@ -167,6 +170,7 @@ export function AnnotatableCodeView({
167170 setSelectedLines(null);
168171 if (draft?.annotation.metadata.entries.some((entry) => entry.id === entryId)) {
169172 setDraft(null);
173 setDraftText("");
170174 } else {
171175 removeReviewComment(composerDraftTarget, entryId);
172176 }
......@@ -193,6 +197,7 @@ export function AnnotatableCodeView({
193197 if (comment) addReviewComment(composerDraftTarget, comment);
194198 setSelectedLines(null);
195199 setDraft(null);
200 setDraftText("");
196201 },
197202 [addReviewComment, composerDraftTarget, draft, filesByKey, sectionId, sectionTitle],
198203 );
......@@ -215,6 +220,7 @@ export function AnnotatableCodeView({
215220 text: "",
216221 });
217222 if (!comment) return;
223 setDraftText("");
218224 setDraft({
219225 fileKey: item.id,
220226 annotation: {
......@@ -232,6 +238,7 @@ export function AnnotatableCodeView({
232238 const hasOpenComment = draft !== null;
233239 return (
234240 <CodeView<DiffCommentAnnotationGroup>
241 key={codeViewKey}
235242 {...(viewerRef ? { ref: viewerRef } : {})}
236243 {...(className ? { className } : {})}
237244 items={items}
......@@ -241,28 +248,34 @@ export function AnnotatableCodeView({
241248 ...options,
242249 enableGutterUtility: !hasOpenComment,
243250 enableLineSelection: !hasOpenComment,
244 onLineSelectionEnd: beginComment,
251 onGutterUtilityClick: beginComment,
245252 }}
246253 renderHeaderPrefix={(item) =>
247254 item.type === "diff"
248255 ? renderHeaderPrefix(item.fileDiff, item.id, item.collapsed === true)
249256 : null
250257 }
251 renderAnnotation={(annotation) => (
252 <div className="py-1">
253 {annotation.metadata.entries.map((entry) => (
254 <LocalCommentAnnotation
255 key={entry.id}
256 kind={entry.kind}
257 rangeLabel={entry.rangeLabel}
258 text={entry.text}
259 onCancel={() => removeEntry(entry.id)}
260 onComment={(text) => submitEntry(entry.id, text)}
261 onDelete={() => removeEntry(entry.id)}
262 />
263 ))}
264 </div>
265 )}
258 renderAnnotation={(annotation) => {
259 const hasDraft = annotation.metadata.entries.some((entry) => entry.kind === "draft");
260 return (
261 <div
262 className={hasDraft ? "py-1" : "divide-y divide-border/30 border-y border-border/30"}
263 >
264 {annotation.metadata.entries.map((entry) => (
265 <LocalCommentAnnotation
266 key={entry.id}
267 kind={entry.kind}
268 rangeLabel={entry.rangeLabel}
269 text={entry.kind === "draft" ? draftText : entry.text}
270 onTextChange={setDraftText}
271 onCancel={() => removeEntry(entry.id)}
272 onComment={(text) => submitEntry(entry.id, text)}
273 onDelete={() => removeEntry(entry.id)}
274 />
275 ))}
276 </div>
277 );
278 }}
266279 />
267280 );
268281}
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 {
88 kind: "draft" | "comment";
99 rangeLabel: string;
1010 text: string;
11 onTextChange?: (text: string) => void;
1112 onCancel: () => void;
1213 onComment: (text: string) => void;
1314 onDelete: () => void;
......@@ -16,32 +17,34 @@ interface LocalCommentAnnotationProps {
1617export function LocalCommentAnnotation({
1718 kind,
1819 rangeLabel,
19 text: savedText,
20 text,
21 onTextChange,
2022 onCancel,
2123 onComment,
2224 onDelete,
2325}: LocalCommentAnnotationProps) {
24 const [text, setText] = useState("");
26 const [localDraftText, setLocalDraftText] = useState("");
27 const displayedText = kind === "draft" && !onTextChange ? localDraftText : text;
2528
2629 if (kind === "comment") {
2730 return (
2831 <div
2932 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"
3134 contentEditable={false}
3235 onPointerDown={(event) => event.stopPropagation()}
3336 >
34 <div className="flex items-center gap-2">
35 <MessageCircle className="size-4 text-muted-foreground" />
36 <span className="text-xs font-medium">Local comment</span>
37 <span className="ml-auto text-[11px] text-muted-foreground">{rangeLabel}</span>
38 <Button variant="ghost" size="icon-xs" aria-label="Delete comment" onClick={onDelete}>
39 <Trash2 className="size-3.5" />
40 </Button>
41 </div>
42 <p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
43 {savedText}
44 </p>
37 <MessageCircle className="mt-0.5 size-3.5 shrink-0 text-primary/70" aria-hidden="true" />
38 <p className="min-w-0 flex-1 whitespace-pre-wrap text-[13px] leading-5">{displayedText}</p>
39 <Button
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"
41 variant="ghost"
42 size="icon-xs"
43 aria-label="Delete comment"
44 onClick={onDelete}
45 >
46 <Trash2 className="size-3" />
47 </Button>
4548 </div>
4649 );
4750 }
......@@ -49,39 +52,49 @@ export function LocalCommentAnnotation({
4952 return (
5053 <div
5154 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"
5356 contentEditable={false}
5457 onPointerDown={(event) => event.stopPropagation()}
5558 >
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>
6159 <Textarea
6260 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"
6463 size="sm"
65 value={text}
66 placeholder="Request change"
64 value={displayedText}
65 placeholder="Add a comment…"
6766 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 }}
6972 onKeyDown={(event) => {
7073 if (event.key === "Escape") {
7174 event.preventDefault();
7275 onCancel();
7376 }
74 if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && text.trim()) {
77 if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && displayedText.trim()) {
7578 event.preventDefault();
76 onComment(text.trim());
79 onComment(displayedText.trim());
7780 }
7881 }}
7982 />
80 <div className="mt-3 flex justify-end gap-2">
81 <Button variant="ghost" size="sm" onClick={onCancel}>
83 <div className="mt-1.5 flex items-center gap-1">
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 >
8291 Cancel
8392 </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 >
8598 Comment
8699 </Button>
87100 </div>
apps/web/src/index.css+7
......@@ -406,6 +406,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
406406 @apply flex h-10 min-h-10 shrink-0 items-center border-b border-border/60 bg-background;
407407 }
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
409416 .chat-composer-horizontal-inset {
410417 padding-inline-start: calc(env(safe-area-inset-left) + 0.75rem);
411418 padding-inline-end: calc(env(safe-area-inset-right) + 0.75rem);
apps/web/src/lib/diffRendering.test.ts+30-1
......@@ -1,5 +1,10 @@
11import { 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
49describe("buildPatchCacheKey", () => {
510 it("returns a stable cache key for identical content", () => {
......@@ -83,6 +88,30 @@ describe("getRenderablePatch", () => {
8388 });
8489});
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
86115describe("getDiffLineStat", () => {
87116 it("totals additions and deletions across every file and hunk", () => {
88117 const patch = [
apps/web/src/lib/diffRendering.ts+4-1
......@@ -152,7 +152,10 @@ export function resolveFileDiffPath(fileDiff: FileDiffMetadata): string {
152152}
153153
154154export 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;
156159}
157160
158161export function getDiffCollapseIconClassName(fileDiff: FileDiffMetadata): string {
packages/client-runtime/src/state/review.ts+25-1
......@@ -1,17 +1,41 @@
11import { WS_METHODS } from "@t3tools/contracts";
22import { Atom } from "effect/unstable/reactivity";
33
4import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts";
4import {
5 createAtomCommandScheduler,
6 createEnvironmentRpcCommand,
7 createEnvironmentRpcQueryAtomFamily,
8} from "./runtime.ts";
59import type { EnvironmentRegistry } from "../connection/registry.ts";
610
711export function createReviewEnvironmentAtoms<R, E>(
812 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
913) {
14 const diffFileScheduler = createAtomCommandScheduler();
1015 return {
1116 diffPreview: createEnvironmentRpcQueryAtomFamily(runtime, {
1217 label: "environment-data:review:diff-preview",
1318 tag: WS_METHODS.reviewGetDiffPreview,
1419 staleTimeMs: 5_000,
1520 }),
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 }),
1640 };
1741}
packages/contracts/src/ipc.ts+9-1
......@@ -18,7 +18,12 @@ import type {
1818 VcsStatusInput,
1919 VcsStatusResult,
2020} from "./git.ts";
21import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts";
21import type {
22 ReviewDiffFileContentsInput,
23 ReviewDiffFileContentsResult,
24 ReviewDiffPreviewInput,
25 ReviewDiffPreviewResult,
26} from "./review.ts";
2227import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
2328import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts";
2429import type {
......@@ -1203,6 +1208,9 @@ export interface EnvironmentApi {
12031208 };
12041209 review: {
12051210 getDiffPreview: (input: ReviewDiffPreviewInput) => Promise<ReviewDiffPreviewResult>;
1211 getDiffFileContents: (
1212 input: ReviewDiffFileContentsInput,
1213 ) => Promise<ReviewDiffFileContentsResult>;
12061214 };
12071215 orchestration: {
12081216 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
packages/contracts/src/review.ts+17
......@@ -25,6 +25,23 @@ export const ReviewDiffPreviewSource = Schema.Struct({
2525});
2626export 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
2845export const ReviewDiffPreviewResult = Schema.Struct({
2946 cwd: TrimmedNonEmptyString,
3047 generatedAt: Schema.DateTimeUtc,
packages/contracts/src/rpc.ts+10
......@@ -45,6 +45,8 @@ import {
4545 VcsStatusStreamEvent,
4646} from "./git.ts";
4747import {
48 ReviewDiffFileContentsInput,
49 ReviewDiffFileContentsResult,
4850 ReviewDiffPreviewError,
4951 ReviewDiffPreviewInput,
5052 ReviewDiffPreviewResult,
......@@ -197,6 +199,7 @@ export const WS_METHODS = {
197199
198200 // Review methods
199201 reviewGetDiffPreview: "review.getDiffPreview",
202 reviewGetDiffFileContents: "review.getDiffFileContents",
200203
201204 // Terminal methods
202205 terminalOpen: "terminal.open",
......@@ -566,6 +569,12 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie
566569 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
567570});
568571
572export const WsReviewGetDiffFileContentsRpc = Rpc.make(WS_METHODS.reviewGetDiffFileContents, {
573 payload: ReviewDiffFileContentsInput,
574 success: ReviewDiffFileContentsResult,
575 error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]),
576});
577
569578export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
570579 payload: TerminalOpenInput,
571580 success: TerminalSessionSnapshot,
......@@ -830,6 +839,7 @@ export const WsRpcGroup = RpcGroup.make(
830839 WsVcsSwitchRefRpc,
831840 WsVcsInitRpc,
832841 WsReviewGetDiffPreviewRpc,
842 WsReviewGetDiffFileContentsRpc,
833843 WsTerminalOpenRpc,
834844 WsTerminalAttachRpc,
835845 WsTerminalWriteRpc,