From d19039aeef6942e6eb204856c43b5354c0333e2d Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:48:57 +0200 Subject: [PATCH 1/5] fix(server): detect repositories after initialization (#4848) --- apps/server/src/vcs/VcsDriverRegistry.test.ts | 45 +++++++++++++++++++ apps/server/src/vcs/VcsDriverRegistry.ts | 5 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 7a531a5adcc0..0ad2450eb192 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -92,4 +92,49 @@ describe("VcsDriverRegistry", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("detects a repository created after a negative lookup", () => { + let insideWorkTreeChecks = 0; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + const command = normalizeGitArgs(input.args).join(" "); + if (command === "rev-parse --is-inside-work-tree") { + insideWorkTreeChecks += 1; + return insideWorkTreeChecks === 1 + ? { + ...processOutput(""), + exitCode: ChildProcessSpawner.ExitCode(128), + stderr: "fatal: not a git repository", + } + : processOutput("true\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + + assert.equal(yield* registry.detect({ cwd: "/repo" }), null); + assert.equal((yield* registry.detect({ cwd: "/repo" }))?.repository.rootPath, "/repo"); + assert.equal(insideWorkTreeChecks, 2); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 0bf95c2ffba9..f40e1dadea3e 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -115,7 +115,10 @@ export const make = Effect.gen(function* () { (key) => detectResolvedKind(parseDetectionCacheKey(key)), { capacity: DETECTION_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero), + timeToLive: Exit.match({ + onSuccess: (detected) => (detected === null ? Duration.zero : DETECTION_CACHE_TTL), + onFailure: () => Duration.zero, + }), }, ); From faea70aebae41f1257140c6722f1d78f12f5eb74 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:54:11 +0530 Subject: [PATCH 2/5] perf(server): merge separate staged/unstaged numstat calls into single diff HEAD --numstat (#4843) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 46 ++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 104 ++++++++++++++----- 2 files changed, 122 insertions(+), 28 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 941d92cdbffb..aab69de6a128 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -997,6 +997,52 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(status.aheadOfDefaultCount, 1); }), ); + + it.effect("reports combined staged and unstaged edits to the same file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "// line one\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* git(cwd, ["commit", "-m", "add feature"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n"); + yield* git(cwd, ["add", "feature.ts"]); + yield* writeTextFile(cwd, "feature.ts", "// line one\n// line two\n// line three\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.hasWorkingTreeChanges, true); + const file = status.workingTree.files.find((f) => f.path === "feature.ts"); + assert.ok(file); + // HEAD has 1 line. Staged has 2 lines (+1). Unstaged has 3 lines (+2 from HEAD). + // Combined net from HEAD: +2 insertions. + assert.equal(file.insertions, 2); + assert.equal(file.deletions, 0); + }), + ); + + it.effect("reports staged file counts on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "initial.ts", "// first file\n"); + yield* git(cwd, ["add", "initial.ts"]); + + const status = yield* driver.statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.workingTree.files.length, 1); + const file = status.workingTree.files[0]; + if (file) { + assert.equal(file.path, "initial.ts"); + assert.equal(file.insertions, 1); + } + }), + ); }); describe("refName operations", () => { diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f739c98da29e..73d9b6b0147a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -415,6 +415,12 @@ function isMissingGitCwdError(error: GitCommandError): boolean { function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } +function isUnbornHeadStderr(stderr: string): boolean { + return ( + stderr.toLowerCase().includes("unknown revision") && + stderr.toLowerCase().includes("path not in the working tree") + ); +} interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; @@ -1511,27 +1517,73 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = - yield* Effect.all( - [ - runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ - "diff", - "--cached", - "--numstat", - ]), - executeGit( - "GitVcsDriver.statusDetails.defaultRef", - cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - allowNonZeroExit: true, - }, - ), - originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), - ], - { concurrency: "unbounded" }, - ); + const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( + [ + executeGitWithStableDiagnostics( + "GitVcsDriver.statusDetails.numstat", + cwd, + ["diff", "HEAD", "--numstat"], + { allowNonZeroExit: true }, + ).pipe( + Effect.flatMap((result) => { + if (result.exitCode === 0) return Effect.succeed(result.stdout); + if (isUnbornHeadStderr(result.stderr)) { + return Effect.map( + Effect.all([ + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn", cwd, [ + "diff", + "--numstat", + ]), + runGitStdout("GitVcsDriver.statusDetails.numstat.unborn.staged", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + ]), + ([unstagedStdout, stagedStdout]) => { + const staged = parseNumstatEntries(stagedStdout); + const unstaged = parseNumstatEntries(unstagedStdout); + const map = new Map(); + for (const entry of [...staged, ...unstaged]) { + const existing = map.get(entry.path) ?? { + insertions: 0, + deletions: 0, + }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + map.set(entry.path, existing); + } + return Array.from(map.entries()) + .map(([p, s]) => `${s.insertions}\t${s.deletions}\t${p}`) + .join("\n"); + }, + ); + } + return Effect.fail( + new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetails.numstat", + cwd, + args: ["diff", "HEAD", "--numstat"], + }), + detail: "git diff HEAD --numstat failed.", + exitCode: result.exitCode, + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), + ); + }), + ), + executeGit( + "GitVcsDriver.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ), + originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + ], + { concurrency: "unbounded" }, + ); const statusStdout = statusResult.stdout; const defaultBranch = defaultRefResult.exitCode === 0 @@ -1592,14 +1644,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : yield* computeAheadCountAgainstBase(cwd, refName).pipe(Effect.orElseSucceed(() => 0)); } - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const numstatEntries = parseNumstatEntries(numstatStdout); const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); + for (const entry of numstatEntries) { + fileStatMap.set(entry.path, { insertions: entry.insertions, deletions: entry.deletions }); } let insertions = 0; From 85a89868703530e03c5e79797c7b952c684bd222 Mon Sep 17 00:00:00 2001 From: ohbentos <72638975+ohbentos@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:27:00 -0300 Subject: [PATCH 3/5] fix(git): disable external diff for review diff previews (#4854) --- apps/server/src/vcs/GitVcsDriverCore.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 73d9b6b0147a..3aa7575d5909 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2049,7 +2049,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* executeGit( "GitVcsDriver.readUntrackedReviewDiffs.diff", cwd, - ["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath], + [ + "diff", + "--no-index", + "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--minimal", + "--", + "/dev/null", + relativePath, + ], { allowNonZeroExit: true, maxOutputBytes: REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES, @@ -2094,6 +2105,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ "diff", "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", "--minimal", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", @@ -2127,6 +2141,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* [ "diff", "--patch", + "--no-color", + "--no-ext-diff", + "--no-textconv", "--minimal", ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${baseRef}...HEAD`, From fc28abf24b034502402cba1fedf5fa38658ee2e8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 29 Jul 2026 13:06:39 -0400 Subject: [PATCH 4/5] Fix editable file focus and live syntax highlighting (#3979) --- .../src/components/files/FilePreviewPanel.tsx | 18 ++++-- .../files/fileContentRevision.test.ts | 29 ++++++++- .../components/files/fileContentRevision.ts | 18 ++++++ ...ch => @pierre%2Fdiffs@1.3.0-beta.10.patch} | 60 ++++++++++++------- pnpm-lock.yaml | 58 ++++++++++-------- pnpm-workspace.yaml | 4 +- 6 files changed, 131 insertions(+), 56 deletions(-) rename patches/{@pierre%2Fdiffs@1.3.0-beta.5.patch => @pierre%2Fdiffs@1.3.0-beta.10.patch} (50%) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 6ddd38e9d253..e3d921901d40 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -7,7 +7,7 @@ import type { import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { VirtualizedFile, type SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; -import { EditorProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; +import { EditProvider, File, type FileOptions, Virtualizer } from "@pierre/diffs/react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -52,7 +52,7 @@ import { } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; -import { projectFileCacheKey } from "./fileContentRevision"; +import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; @@ -364,6 +364,8 @@ function EditableFileSurface({ const editor = useMemo( () => new Editor({ + persistState: true, + persistStateStorage: "inMemory", onChange: (file, nextLineAnnotations) => { setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); saveCoordinator.change(file.contents); @@ -536,7 +538,7 @@ function EditableFileSurface({ ); return ( - +
-
+ ); } diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index db3ba4f5e269..e2ec7f9f1cad 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { fileContentRevision, projectFileCacheKey } from "./fileContentRevision"; +import { + fileContentRevision, + projectFileCacheKey, + projectFileEditorCacheKey, +} from "./fileContentRevision"; describe("fileContentRevision", () => { it("changes for same-length edits", () => { @@ -12,4 +16,27 @@ describe("fileContentRevision", () => { projectFileCacheKey("/repo", "file.json", "contents"), ); }); + + it("keeps editor identity stable for locally edited contents", () => { + const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "after", undefined); + + expect( + projectFileEditorCacheKey("local", "/repo", "file.json", "after edit", { + cacheKey, + contents: "after edit", + }), + ).toBe(cacheKey); + }); + + it("rotates editor identity for external contents and environments", () => { + const cacheKey = projectFileEditorCacheKey("local", "/repo", "file.json", "before", undefined); + const editorFile = { cacheKey, contents: "before" }; + + expect( + projectFileEditorCacheKey("local", "/repo", "file.json", "external edit", editorFile), + ).not.toBe(cacheKey); + expect(projectFileEditorCacheKey("remote", "/repo", "file.json", "before", undefined)).not.toBe( + cacheKey, + ); + }); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index 3fa8d07bccb2..e51d464925bd 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -10,3 +10,21 @@ export function fileContentRevision(contents: string): string { export function projectFileCacheKey(cwd: string, relativePath: string, contents: string): string { return `${cwd}:${relativePath}:${fileContentRevision(contents)}`; } + +interface EditorFileIdentity { + readonly cacheKey?: string; + readonly contents: string; +} + +export function projectFileEditorCacheKey( + environmentId: string, + cwd: string, + relativePath: string, + contents: string, + editorFile: EditorFileIdentity | undefined, +): string { + if (editorFile?.contents === contents && editorFile.cacheKey) { + return editorFile.cacheKey; + } + return `editor:${environmentId}:${projectFileCacheKey(cwd, relativePath, contents)}`; +} diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch similarity index 50% rename from patches/@pierre%2Fdiffs@1.3.0-beta.5.patch rename to patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 59aa02f6d1cd..b342d4b5dd13 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.5.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -1,38 +1,54 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js -index e8013fc6eb6f243a6c912facf3fc0319ac66a8d0..80c82df4cdeb828bd331f5ec2f443d216bedc304 100644 +index ff78e2a..f9df318 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -77,15 +77,12 @@ var Editor = class { - this.#options = options; - } - edit(component) { -- const { useTokenTransformer, enableGutterUtility, enableLineSelection, expandUnchanged, diffStyle, lineHoverHighlight,...rest } = component.options; -+ const { useTokenTransformer, expandUnchanged, diffStyle,...rest } = component.options; - const isDiff = component.type === "file-diff"; -- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled" || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { -+ if (useTokenTransformer !== true || expandUnchanged !== true && isDiff || diffStyle === "unified" && isDiff) { - component.setOptions({ +@@ -146,14 +146,11 @@ var Editor = class { + const file = fileInstance.__getCurrentFile?.(); + if (file !== void 0) requirePersistedCacheKey(file); + } +- const { useTokenTransformer, enableGutterUtility, enableLineSelection, lineHoverHighlight = "disabled", ...rest } = fileInstance.options; +- if (useTokenTransformer !== true || enableGutterUtility === true || enableLineSelection === true || lineHoverHighlight !== "disabled") { ++ const { useTokenTransformer, ...rest } = fileInstance.options; ++ if (useTokenTransformer !== true) { + fileInstance.setOptions({ ...rest, - useTokenTransformer: true, +- useTokenTransformer: true, - enableGutterUtility: false, - enableLineSelection: false, -- lineHoverHighlight: "disabled", - expandUnchanged: true, - diffStyle: "split" +- lineHoverHighlight: "disabled" ++ useTokenTransformer: true }); -@@ -511,6 +508,7 @@ var Editor = class { + fileInstance.rerender(); + } +@@ -908,6 +905,7 @@ var Editor = class { return lineNumber - 1; }; this.#editorEventDisposes.push(addEventListener(gutterEl, "pointerdown", (e) => { + if (this.#fileInstance?.options.enableLineSelection === true) return; - const textDocument = this.#textDocument; - const lineIndex = resolveEditableLine(resolveGutterTarget(e.composedPath()[0])); - if (lineIndex === void 0 || textDocument === void 0) return; + const gutterRow = resolveGutterTarget(e.composedPath()[0]); + if (gutterRow?.dataset.lineType === "change-deletion") { + const code = gutterRow.closest("[data-code]"); +@@ -1522,6 +1520,7 @@ var Editor = class { + if (gutterEl !== void 0) gutterEl.style.gridRow = "span " + gridRow; + } + fileInstance.updateRenderCache(dirtyLines, tokenizer.themeType, !didLineCountChange, didLineCountChange); ++ if (fileInstance.file !== void 0) fileInstance.file.contents = textDocument.getText(); + if (didLineCountChange) fileInstance.applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer); + if (this.#isDiff && (this.#diffSyle === "unified" || didLineCountChange)) this.#resetCache(); + if (newLineAnnotations !== void 0) { +@@ -1788,6 +1787,7 @@ var Editor = class { + } + } + #setSelectedLinesSafe(range, lineNumberOnly = false) { ++ if (this.#fileInstance?.options.controlledSelection === true) return; + try { + this.#fileInstance?.setSelectedLines(range, { + notify: false, diff --git a/dist/react/utils/useFileInstance.js b/dist/react/utils/useFileInstance.js -index cb8e2026fb5d7a19f489c0a2402efbcb7dff3322..510fad6364d4a2214c7dd65fe2b114f1cce0c815 100644 +index e9f62f5..af82a46 100644 --- a/dist/react/utils/useFileInstance.js +++ b/dist/react/utils/useFileInstance.js -@@ -92,10 +92,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu +@@ -91,10 +91,7 @@ function mergeFileOptions({ options, controlledSelection, contentEditable, hasCu }; if (needsEditorOptions) merged = { ...merged, @@ -45,7 +61,7 @@ index cb8e2026fb5d7a19f489c0a2402efbcb7dff3322..510fad6364d4a2214c7dd65fe2b114f1 return merged; } diff --git a/package.json b/package.json -index d1558633de87044b7aa96cff09443db11f163cec..c0b16f0a0bec6fba2026f24f38b2c0a8fa06af7c 100644 +index ff61c90..1e170e5 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4981a25d3d00..c46d2c6a4dc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ catalogs: specifier: 1.8.0 version: 1.8.0 '@pierre/diffs': - specifier: 1.3.0-beta.5 - version: 1.3.0-beta.5 + specifier: 1.3.0-beta.10 + version: 1.3.0-beta.10 '@typescript/native-preview': specifier: 7.0.0-dev.20260604.1 version: 7.0.0-dev.20260604.1 @@ -75,7 +75,7 @@ patchedDependencies: '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 '@legendapp/list@3.2.0': 9203ec3db86574a31e157a265cefd8997b259cc420a0c4c61c580c80ed8cf78d - '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a + '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.102: 71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488 @@ -222,7 +222,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -466,7 +466,7 @@ importers: version: 1.15.13 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.102 version: 4.0.0-beta.102(patch_hash=71215759e1ac0a7f65d7b75d816986687ae6c3a6cba02d928d184ca71790d488) @@ -551,7 +551,7 @@ importers: version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -3584,20 +3584,20 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} - '@pierre/diffs@1.3.0-beta.5': - resolution: {integrity: sha512-d7449IY6Phcg9LCRLbPxhsxn6Bv4KoaP/vPyZtGu2uR1SFsSJPQcRoPf8lzyobNGKD0GZGuhgHW5LrOlilFo7w==} + '@pierre/diffs@1.3.0-beta.10': + resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 - '@pierre/theme@1.0.3': - resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==} + '@pierre/theme@1.1.0': + resolution: {integrity: sha512-GC2OWTAfTIIWWYhPCygwG8t2EtePQkRfON4MI2rwIkJylmiyqIttJID2dCL8sUD8cNdEvYkEyfEHHKMeCiDLoQ==} engines: {vscode: ^1.0.0} - '@pierre/theming@0.0.1': - resolution: {integrity: sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A==} + '@pierre/theming@0.0.2': + resolution: {integrity: sha512-QM1M4stXfnzfaE8I8YbjXSApV8c+2dBsXJj8eYg9WTpBR/cTmCZIcfGnN4p13iRrYu2Br/R/OJfEL7uR8Qjctw==} peerDependencies: - '@pierre/theme': ^1.0.0 + '@pierre/theme': ^1.1.0 '@shikijs/themes': ^3.0.0 || ^4.0.0 react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -6104,6 +6104,10 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -13569,12 +13573,12 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 - diff: 8.0.3 + diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.3 @@ -13583,12 +13587,12 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@pierre/theme': 1.0.3 - '@pierre/theming': 0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) + '@pierre/theme': 1.1.0 + '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) '@shikijs/transformers': 4.2.0 - diff: 8.0.3 + diff: 9.0.0 hast-util-to-html: 9.0.5 lru_map: 0.4.1 react: 19.2.6 @@ -13597,19 +13601,19 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/theme@1.0.3': {} + '@pierre/theme@1.1.0': {} - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)': + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.0.3 + '@pierre/theme': 1.1.0 '@shikijs/themes': 4.2.0 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) shiki: 4.2.0 - '@pierre/theming@0.0.1(@pierre/theme@1.0.3)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': + '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)': optionalDependencies: - '@pierre/theme': 1.0.3 + '@pierre/theme': 1.1.0 '@shikijs/themes': 4.3.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) @@ -16379,6 +16383,8 @@ snapshots: diff@8.0.3: {} + diff@9.0.0: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 42529f56ee3c..70f61242053d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -41,7 +41,7 @@ catalog: "@effect/vitest": 4.0.0-beta.102 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 - "@pierre/diffs": 1.3.0-beta.5 + "@pierre/diffs": 1.3.0-beta.10 "@types/node": 24.12.4 "@typescript/native-preview": 7.0.0-dev.20260604.1 effect: 4.0.0-beta.102 @@ -128,7 +128,7 @@ patchedDependencies: "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch - "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch + "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch effect@4.0.0-beta.102: patches/effect@4.0.0-beta.102.patch From e6987965f65914861f0dabd0db03729fe5cd2508 Mon Sep 17 00:00:00 2001 From: Simon Doba Date: Wed, 29 Jul 2026 20:01:03 +0200 Subject: [PATCH 5/5] fix(web): remember the rendered-markdown choice across threads (#4853) Co-authored-by: Simon Doba Co-authored-by: Claude Opus 5 --- .../src/components/files/FilePreviewPanel.tsx | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index e3d921901d40..24e63a6d8eaf 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -22,7 +22,7 @@ import ChatMarkdown from "~/components/ChatMarkdown"; import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; -import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; +import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; @@ -79,6 +79,7 @@ interface FilePreviewPanelProps { } const FILE_EXPLORER_STORAGE_KEY = "t3code.fileExplorerOpen"; +const RENDER_MARKDOWN_STORAGE_KEY = "t3code.renderMarkdown"; const FILE_SAVE_DEBOUNCE_MS = 500; const FILE_LINK_REVEAL_ATTRIBUTE = "data-file-link-reveal"; const FILE_LINK_REVEAL_UNSAFE_CSS = ` @@ -680,16 +681,27 @@ export default function FilePreviewPanel({ const isImage = relativePath !== null && isWorkspaceImagePreviewPath(relativePath); const file = useProjectFileQuery(environmentId, cwd, relativePath, !isImage); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); - const [markdownView, setMarkdownView] = useState<{ - path: string | null; - revealRequestId: number | null; - }>({ path: null, revealRequestId: null }); + // Reading markdown rendered is a preference, not a property of one file. Keeping + // it on the panel meant a thread switch dropped it and forced source back. + const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( + RENDER_MARKDOWN_STORAGE_KEY, + false, + Schema.Boolean, + ); + // Paired with the path on purpose: each file surface counts its reveals from + // one, so a bare id would let a dismissed reveal on one file swallow the first + // reveal on the next. + const [handledReveal, setHandledReveal] = useState<{ path: string; requestId: number } | null>( + null, + ); const breadcrumbRef = useRef(null); const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; + // A reveal still wins over the preference: the line only exists in the source. const renderMarkdown = isMarkdown && - markdownView.path === relativePath && - (revealLine === null || markdownView.revealRequestId === revealRequestId); + renderMarkdownPreferred && + (revealLine === null || + (handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId)); const canOpenInBrowser = relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; @@ -796,10 +808,12 @@ export default function FilePreviewPanel({ className="shrink-0" pressed={renderMarkdown} onPressedChange={(pressed) => { - setMarkdownView({ - path: pressed ? relativePath : null, - revealRequestId: pressed ? revealRequestId : null, - }); + setRenderMarkdownPreferred(pressed); + setHandledReveal( + pressed && relativePath !== null + ? { path: relativePath, requestId: revealRequestId } + : null, + ); }} aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} variant="ghost"