authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 17:11:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 17:11:05-07:00
log14fe0158ede3d964f183e281f9fec225d30a6b32
tree11328ca4bd2299c3624b2b065feb66745e92d3e5
parentff3a04a8ebaaf6bb74b8b5b996013b82e044c700
signature Signed by PGP key B5690EEEBB952194

feat(web): find in diffs with Cmd+F (#14623)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

11 files changed, 1042 insertions(+), 17 deletions(-)

apps/web/src/components/DiffPanel.tsx+13
...@@ -695,6 +695,18 @@ export default function DiffPanel({...@@ -695,6 +695,18 @@ export default function DiffPanel({
695 return { scopeKey: collapseScopeKey, fileKeys: next };695 return { scopeKey: collapseScopeKey, fileKeys: next };
696 });696 });
697 }, []);697 }, []);
698 // Find can ask again before the unfolded file reaches the viewer, so this must never fold.
699 const unfoldDiffFile = useCallback((fileKey: string) => {
700 const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current;
701 setCollapsedDiffFiles((current) => {
702 const fileKeys =
703 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys;
704 if (!fileKeys.has(fileKey)) return current;
705 const next = new Set(fileKeys);
706 next.delete(fileKey);
707 return { scopeKey: collapseScopeKey, fileKeys: next };
708 });
709 }, []);
698710
699 const toggleDiffFileCollapse = useCallback(() => {711 const toggleDiffFileCollapse = useCallback(() => {
700 setCodeViewRevision((current) => current + 1);712 setCodeViewRevision((current) => current + 1);
...@@ -1197,6 +1209,7 @@ export default function DiffPanel({...@@ -1197,6 +1209,7 @@ export default function DiffPanel({
1197 },1209 },
1198 }1210 }
1199 : {})}1211 : {})}
1212 onRevealSearchMatch={unfoldDiffFile}
1200 renderHeaderPrefix={(fileDiff, fileKey) => {1213 renderHeaderPrefix={(fileDiff, fileKey) => {
1201 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;1214 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
1202 return (1215 return (
apps/web/src/components/RightPanelTabs.tsx+2-1
...@@ -415,7 +415,8 @@ function RightPanelEmptyState(props: {...@@ -415,7 +415,8 @@ function RightPanelEmptyState(props: {
415 const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);415 const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
416 if (!action) return;416 if (!action) return;
417 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;417 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
418 const target = event.target;418 // The composed path starts at the real target, which may sit inside a shadow root.
419 const target = event.composedPath()[0] ?? event.target;
419 if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;420 if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;
420 event.preventDefault();421 event.preventDefault();
421 event.stopPropagation();422 event.stopPropagation();
apps/web/src/components/diffs/AnnotatableCodeView.tsx+4
...@@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps {...@@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps {
95 fileKey: string,95 fileKey: string,
96 collapsed: boolean,96 collapsed: boolean,
97 ) => ReactNode;97 ) => ReactNode;
98 /** Unfold a collapsed file that holds the find match being navigated to. */
99 onRevealSearchMatch: (fileKey: string) => void;
98}100}
99101
100interface DiffSelectionContext {102interface DiffSelectionContext {
...@@ -115,6 +117,7 @@ export function AnnotatableCodeView({...@@ -115,6 +117,7 @@ export function AnnotatableCodeView({
115 renderHeaderMetadata,117 renderHeaderMetadata,
116 renderHeaderFilenameSuffix,118 renderHeaderFilenameSuffix,
117 renderHeaderPrefix,119 renderHeaderPrefix,
120 onRevealSearchMatch,
118}: AnnotatableCodeViewProps) {121}: AnnotatableCodeViewProps) {
119 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);122 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
120 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);123 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
...@@ -262,6 +265,7 @@ export function AnnotatableCodeView({...@@ -262,6 +265,7 @@ export function AnnotatableCodeView({
262 items={items}265 items={items}
263 selectedLines={selectedLines}266 selectedLines={selectedLines}
264 onSelectedLinesChange={setSelectedLines}267 onSelectedLinesChange={setSelectedLines}
268 onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)}
265 options={{269 options={{
266 ...options,270 ...options,
267 enableGutterUtility: !hasOpenComment,271 enableGutterUtility: !hasOpenComment,
apps/web/src/components/diffs/codeViewSearch.test.ts created+106
...@@ -0,0 +1,106 @@
1import type { CodeViewItem } from "@pierre/diffs";
2import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
3import { describe, expect, it } from "vite-plus/test";
4
5// Find-in-diff lives in our @pierre/diffs patch and is not a package export.
6const searchUrl = new URL("./components/CodeViewSearch.js", import.meta.resolve("@pierre/diffs"));
7const { collectMatches } = (await import(/* @vite-ignore */ searchUrl.href)) as {
8 collectMatches(
9 items: ReadonlyArray<CodeViewItem<undefined>>,
10 params: { text: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean },
11 ): Array<{
12 id: string;
13 side: string;
14 isContext: boolean;
15 lineNumber: number;
16 start: number;
17 end: number;
18 }>;
19};
20
21const PATCH = `diff --git a/src/a.ts b/src/a.ts
22index 1111111..2222222 100644
23--- a/src/a.ts
24+++ b/src/a.ts
25@@ -1,4 +1,5 @@
26 const keep = 1;
27-const oldName = 2;
28+const newName = 2;
29+const added = oldName;
30 const tail = 4;
31@@ -20,3 +21,3 @@ function x() {
32 ctx20 name
33-del21 Name
34+add22 names
35 ctx22
36diff --git a/src/b.ts b/src/b.ts
37index 3333333..4444444 100644
38--- a/src/b.ts
39+++ b/src/b.ts
40@@ -1,1 +1,1 @@
41-nil
42+: nil
43`;
44
45const items: CodeViewItem<undefined>[] = parsePatchFiles(PATCH)[0]!.files.map(
46 (fileDiff, index) => ({ id: `file-${index}`, type: "diff", fileDiff, collapsed: index === 1 }),
47);
48
49function find(
50 text: string,
51 flags: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean } = {},
52) {
53 return collectMatches(items, {
54 text,
55 caseSensitive: flags.caseSensitive ?? false,
56 wholeWord: flags.wholeWord ?? false,
57 regex: flags.regex ?? false,
58 }).map(({ id, side, lineNumber, start, end }) => `${id}:${side}:${lineNumber}:${start}-${end}`);
59}
60
61describe("collectMatches", () => {
62 it("finds removed, added, and context lines in render order, folded files included", () => {
63 expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]);
64 expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]);
65 });
66
67 it("honours case, whole-word, and regex toggles", () => {
68 expect(find("Name", { caseSensitive: true })).toEqual([
69 "file-0:deletions:2:9-13",
70 "file-0:additions:2:9-13",
71 "file-0:additions:3:17-21",
72 "file-0:deletions:21:6-10",
73 ]);
74 expect(find("name", { wholeWord: true })).toEqual([
75 "file-0:additions:21:6-10",
76 "file-0:deletions:21:6-10",
77 ]);
78 expect(find("n[a-z]+e", { regex: true })).toHaveLength(6);
79 });
80
81 it("marks unchanged lines, which split view draws in both columns", () => {
82 const flags = collectMatches(items, {
83 text: "ctx",
84 caseSensitive: false,
85 wholeWord: false,
86 regex: false,
87 }).map(({ lineNumber, isContext }) => [lineNumber, isContext]);
88 expect(flags).toEqual([
89 [21, true],
90 [23, true],
91 ]);
92 expect(
93 collectMatches(items, {
94 text: "oldName",
95 caseSensitive: false,
96 wholeWord: false,
97 regex: false,
98 }).map(({ isContext }) => isContext),
99 ).toEqual([false, false]);
100 });
101
102 it("returns nothing for an empty query or an invalid pattern", () => {
103 expect(find("")).toEqual([]);
104 expect(find("(", { regex: true })).toEqual([]);
105 });
106});
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+6
...@@ -1516,6 +1516,12 @@ function PullRequestCodeTab({...@@ -1516,6 +1516,12 @@ function PullRequestCodeTab({
1516 renderHeaderPrefix={renderHeaderPrefix}1516 renderHeaderPrefix={renderHeaderPrefix}
1517 renderHeaderMetadata={renderHeaderMetadata}1517 renderHeaderMetadata={renderHeaderMetadata}
1518 renderAnnotation={renderAnnotation}1518 renderAnnotation={renderAnnotation}
1519 // Find can ask again before the unfolded file arrives, so this unfolds and never folds.
1520 onRevealSearchMatch={(item) =>
1521 setToggledFiles((current) =>
1522 toggleFileDiffFoldForViewed(item.id, false, effectiveFoldOverride, current),
1523 )
1524 }
1519 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}1525 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1520 />1526 />
1521 </div>1527 </div>
apps/web/src/index.css+8
...@@ -2087,6 +2087,14 @@ code {...@@ -2087,6 +2087,14 @@ code {
2087 color: var(--code-foreground);2087 color: var(--code-foreground);
2088}2088}
20892089
2090/* Pierre's find panel and match highlights read these. */
2091.diff-render-surface {
2092 --diffs-search-panel-bg: var(--popover);
2093 --diffs-search-panel-fg: var(--popover-foreground);
2094 --diffs-search-panel-accent: var(--primary);
2095 --diffs-search-panel-font: var(--font-sans);
2096}
2097
2090/* Pierre's file renderer owns its own surface tokens. Keep the root of a file2098/* Pierre's file renderer owns its own surface tokens. Keep the root of a file
2091 preview on the same themeable code surface as the diff panel. */2099 preview on the same themeable code surface as the diff panel. */
2092.diff-render-surface diffs-container,2100.diff-render-surface diffs-container,
apps/web/src/lib/editableFocus.test.ts created+25
...@@ -0,0 +1,25 @@
1// @vitest-environment jsdom
2
3import { afterEach, describe, expect, it } from "vite-plus/test";
4
5import { isEditableFocused } from "./editableFocus";
6
7afterEach(() => {
8 document.body.replaceChildren();
9});
10
11describe("isEditableFocused", () => {
12 it("sees a text field focused inside an open shadow root", () => {
13 // A key event from a shadow input is retargeted to its host, so the host is
14 // what page-level shortcut handlers receive as the event target.
15 const host = document.createElement("div");
16 const input = document.createElement("input");
17 host.attachShadow({ mode: "open" }).append(input);
18 document.body.append(host);
19
20 expect(isEditableFocused(host)).toBe(false);
21 input.focus();
22 expect(isEditableFocused(host)).toBe(true);
23 expect(isEditableFocused()).toBe(true);
24 });
25});
apps/web/src/lib/editableFocus.ts+5-1
...@@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [...@@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [
11/**11/**
12 * Whether a text-editing element owns the keyboard. Shortcuts that share12 * Whether a text-editing element owns the keyboard. Shortcuts that share
13 * their chord with native editing (mod+z) must yield when this is true.13 * their chord with native editing (mod+z) must yield when this is true.
14 * Focus inside an open shadow root counts: a key event's target is retargeted
15 * to the shadow host, so the host's focused descendant is what is typed into.
14 */16 */
15export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean {17export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean {
16 return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null;18 let element = target instanceof Element ? target : null;
19 while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement;
20 return element !== null && element.closest(EDITABLE_SELECTOR) !== null;
17}21}
docs/user/keybindings.md+9
...@@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix....@@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
46Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”46Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”
47or “Copy Number”. They copy the selected PR and leave terminal input alone.47or “Copy Number”. They copy the selected PR and leave terminal input alone.
4848
49## Find in a diff
50
51Click into a diff in the Diff panel or a pull request's Code tab, then press
52`mod+f` to search every file in it, including folded files and unchanged lines
53hidden between changes. Enter and `Shift+Enter` move between matches, and a
54match in a folded file opens it. Escape closes the search. This shortcut is not
55configurable. A very large uncommitted diff loads its files as you scroll, and
56find only searches the files loaded so far.
57
49## iPad58## iPad
5059
51With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine60With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine
patches/@pierre%2Fdiffs@1.5.2.patch+859-10
...@@ -1,7 +1,704 @@...@@ -1,7 +1,704 @@
1diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts
2index cc32d28..3a46bf4 100644
3--- a/dist/components/CodeView.d.ts
4+++ b/dist/components/CodeView.d.ts
5@@ -158,6 +158,12 @@ interface CodeViewOptions<LAnnotation, Caret> extends CodeViewPassThroughOptions
6 * final pre-detach state.
7 */
8 onItemEditComplete?: CodeViewItemEditCompleteHandler<LAnnotation, Caret>;
9+ /**
10+ * Called when find navigates to a match inside a collapsed item. Folding is
11+ * host state, so the host unfolds the item (a controlled update with
12+ * `collapsed: false` and a new `version`); the viewer then scrolls to it.
13+ */
14+ onRevealSearchMatch?(item: CodeViewItem<LAnnotation>): void;
15 /** Render a non-virtualized element at the very start of the scroll content,
16 * before the first item. It is always rendered and scrolls with the content.
17 * Return the same element across calls and mutate it in place to update;
18@@ -268,6 +274,13 @@ declare class CodeView<LAnnotation = undefined, Caret = undefined> {
19 getSelectedLines(): CodeViewLineSelection | null;
20 clearSelectedLines(options?: SelectionWriteOptions): void;
21 getItem(itemId: string): CodeViewItem<LAnnotation> | undefined;
22+ /**
23+ * Open the find panel, or refocus it when open. It searches every diff
24+ * item's lines (including folded files and virtualized rows), and is also
25+ * opened by the primary-modifier+F shortcut while focus is in the viewer.
26+ */
27+ openSearch(): void;
28+ closeSearch(): void;
29 /**
30 * Returns the editor while this item is being edited. Partial diffs get an
31 * editor after their files load; scrolling away or collapsing keeps it.
32diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js
33index 140f94f..96fc774 100644
34--- a/dist/components/CodeView.js
35+++ b/dist/components/CodeView.js
36@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js";
37 import { roundToDevicePixel } from "../utils/roundToDevicePixel.js";
38 import { VirtualizedFile } from "./VirtualizedFile.js";
39 import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js";
40+import { CodeViewSearch } from "./CodeViewSearch.js";
41 //#region src/components/CodeView.ts
42 const CODE_VIEW_DIFF_OPTION_KEYS = [
43 "theme",
44@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) {
45 }
46 const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120;
47 const SUB_PIXEL_TOLERANCE = 1;
48+/** Room the find panel covers at the top of the viewport; matches under it get scrolled clear. */
49+const SEARCH_PANEL_RESERVED_HEIGHT = 48;
50 const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override";
51 function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) {
52 return {
53@@ -211,6 +214,8 @@ var CodeView = class CodeView {
54 isReadySubscription;
55 pendingHighlighterTheme;
56 isContainerManaged;
57+ search = new CodeViewSearch(this);
58+ pendingSearchReveal;
59 constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) {
60 this.options = options;
61 this.computeMetricsCache(options.itemMetrics);
62@@ -392,6 +397,7 @@ var CodeView = class CodeView {
63 this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true });
64 this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true });
65 this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true });
66+ this.root.addEventListener("keydown", this.search.handleKeyDown);
67 this.resizeObserver.observe(this.root);
68 this.render(true);
69 window.__INSTANCE = this;
70@@ -464,6 +470,7 @@ var CodeView = class CodeView {
71 this.heightDirty = true;
72 this.resetRenderState();
73 if (!this.isContainerManaged) this.flushSlotCoordinator();
74+ this.search.refresh();
75 if (failed) throw failure;
76 }
77 cleanUp() {
78@@ -485,6 +492,8 @@ var CodeView = class CodeView {
79 this.root?.removeEventListener("touchstart", this.clearPendingScroll);
80 this.root?.removeEventListener("pointerdown", this.clearPendingScroll);
81 this.root?.removeEventListener("keydown", this.clearPendingScroll);
82+ this.root?.removeEventListener("keydown", this.search.handleKeyDown);
83+ this.search.cleanUp();
84 this.root?.style.removeProperty("overflow-anchor");
85 this.container?.remove();
86 this.stickyOffset.remove();
87@@ -626,6 +635,137 @@ var CodeView = class CodeView {
88 getItem(itemId) {
89 return this.idToItem.get(itemId)?.item;
90 }
91+ /** Open the find panel, or refocus it when it is already open. */
92+ openSearch() {
93+ this.search.open();
94+ }
95+ closeSearch() {
96+ this.search.close();
97+ }
98+ getSearchItems() {
99+ return this.items.map((item) => item.item);
100+ }
101+ /** Index of the first match at or below the top of the viewport, or 0 when every match is above it. */
102+ getFirstVisibleSearchMatchIndex(entries) {
103+ if (this.items.length === 0) return 0;
104+ const visibleTop = this.getScrollTop() - this.getItemTopOffset() + this.getStickyHeaderOffset();
105+ const itemIndex = this.findFirstVisibleIndex(Math.max(this.getScrollTop() - this.getItemTopOffset(), 0));
106+ const item = this.items[itemIndex];
107+ for (let index = 0; index < entries.length; index++) {
108+ const entry = entries[index];
109+ if (entry.itemIndex < itemIndex) continue;
110+ if (entry.itemIndex > itemIndex) return index;
111+ const position = this.getLineScrollPosition(item, entry);
112+ if (position == null || item.top + position.top >= visibleTop) return index;
113+ }
114+ return 0;
115+ }
116+ /**
117+ * Bring a match on screen. A folded file is unfolded by the host through
118+ * `onRevealSearchMatch` (folding is host state); the reveal resumes once the
119+ * item arrives unfolded. Collapsed context around the line is expanded here.
120+ */
121+ revealSearchMatch(match, afterUnfold = false) {
122+ const item = this.idToItem.get(match.id);
123+ if (item == null || item.type !== "diff") return;
124+ if (item.item.collapsed === true) {
125+ if (this.options.onRevealSearchMatch == null) {
126+ this.scrollTo({
127+ type: "item",
128+ id: match.id,
129+ align: "start"
130+ });
131+ return;
132+ }
133+ this.pendingSearchReveal = match;
134+ this.options.onRevealSearchMatch(item.item);
135+ return;
136+ }
137+ const target = {
138+ type: "line",
139+ id: match.id,
140+ lineNumber: match.lineNumber,
141+ side: match.side,
142+ align: "center",
143+ // Center in the space below the sticky header and the find panel.
144+ offset: -(this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT) / 2
145+ };
146+ // Before its first render a file reports every line renderable, so collapsed
147+ // context can't be expanded yet: scroll there and finish once it has rendered.
148+ const rendered = item.instance.getRenderedDiff() != null;
149+ this.pendingSearchReveal = rendered ? void 0 : match;
150+ if (rendered && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber);
151+ else if (rendered && !afterUnfold && this.isScrollTargetInView(target)) return;
152+ this.scrollTo(target);
153+ }
154+ cancelSearchReveal() {
155+ this.pendingSearchReveal = void 0;
156+ }
157+ /** Finish a reveal that was waiting for its file to render for the first time. */
158+ resumePendingSearchReveal(updatedItems) {
159+ const reveal = this.pendingSearchReveal;
160+ if (reveal == null) return;
161+ for (const item of updatedItems) if (item.item.id === reveal.id && item.item.collapsed !== true && item.instance.getRenderedDiff() != null) {
162+ queueMicrotask(() => {
163+ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true);
164+ });
165+ return;
166+ }
167+ }
168+ /** Scroll a wide code column so the current match is visible when lines don't wrap. */
169+ revealSearchMatchHorizontally(match, element) {
170+ const item = this.idToItem.get(match.id);
171+ const code = element.closest("[data-code]");
172+ if (item == null || code == null || code.scrollWidth <= code.clientWidth) return;
173+ const codeRect = code.getBoundingClientRect();
174+ const matchRect = getTextRangeRect(element, match.start, match.end);
175+ if (matchRect == null) return;
176+ // The line-number gutter is sticky at the left edge and covers the code under it.
177+ const gutterRight = code.querySelector(":scope > [data-gutter]")?.getBoundingClientRect().right;
178+ const visibleLeft = gutterRight != null && gutterRight > codeRect.left ? gutterRight : codeRect.left;
179+ const padding = 24;
180+ let next = code.scrollLeft;
181+ if (matchRect.left < visibleLeft + padding) next -= visibleLeft + padding - matchRect.left;
182+ else if (matchRect.right > codeRect.right - padding) next += matchRect.right - (codeRect.right - padding);
183+ else return;
184+ item.instance.setCodeScrollLeft(Math.max(0, next));
185+ }
186+ /** Whether a line already sits fully below the sticky header and find panel. */
187+ isScrollTargetInView(target) {
188+ const rect = this.getScrollTargetRect(target);
189+ if (rect == null) return false;
190+ const top = this.getItemTopOffset() + rect.top;
191+ const scrollTop = this.getScrollTop();
192+ return top >= scrollTop + this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT && top + rect.height <= scrollTop + this.getHeight();
193+ }
194+ /** Pair each match with its mounted line element; rows outside the window are skipped. */
195+ getMountedSearchMatches(entriesByItem) {
196+ const mounted = [];
197+ const { firstIndex, lastIndex } = this.renderState;
198+ if (firstIndex === -1) return mounted;
199+ for (let index = firstIndex; index <= lastIndex; index++) {
200+ const itemEntries = entriesByItem.get(index);
201+ const shadowRoot = itemEntries != null ? this.items[index]?.element?.shadowRoot : void 0;
202+ if (shadowRoot == null) continue;
203+ const lines = /* @__PURE__ */ new Map();
204+ for (const element of shadowRoot.querySelectorAll("[data-line][data-line-index]")) {
205+ const unifiedIndex = element.getAttribute("data-line-index").split(",")[0];
206+ const column = element.closest("[data-deletions]") != null ? "deletions" : "additions";
207+ lines.set(`${column}:${unifiedIndex}`, element);
208+ }
209+ for (const entry of itemEntries) {
210+ // Split view draws unchanged context in both columns, so paint each copy.
211+ const additions = lines.get(`additions:${entry.unifiedLineIndex}`);
212+ const deletions = lines.get(`deletions:${entry.unifiedLineIndex}`);
213+ const elements = entry.side === "deletions" ? [deletions ?? additions] : entry.isContext ? [additions, deletions] : [additions];
214+ for (const element of elements) if (element != null) mounted.push({
215+ entry,
216+ element
217+ });
218+ }
219+ }
220+ return mounted;
221+ }
222 /**
223 * Returns the editor while this item is being edited. Partial diffs get an
224 * editor after their files load; scrolling away or collapsing keeps it.
225@@ -645,6 +785,7 @@ var CodeView = class CodeView {
226 this.render();
227 this.syncItemEditors();
228 this.syncSelection();
229+ this.search.refresh();
230 return true;
231 }
232 updateItemId(oldId, newId) {
233@@ -678,6 +819,7 @@ var CodeView = class CodeView {
234 this.renamePendingScrollTarget(oldId, newId);
235 this.renamePendingLayoutAnchor(oldId, newId);
236 this.render();
237+ this.search.refresh();
238 return true;
239 }
240 addItem(input) {
241@@ -687,6 +829,7 @@ var CodeView = class CodeView {
242 this.appendItemsInternal(inputs);
243 this.syncItemEditors();
244 this.syncSelection();
245+ this.search.refresh();
246 }
247 removeItem(itemId) {
248 const item = this.idToItem.get(itemId);
249@@ -709,6 +852,7 @@ var CodeView = class CodeView {
250 for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp();
251 }
252 this.syncSelection();
253+ this.search.refresh();
254 }
255 /**
256 * Append new records to the viewer while preserving existing layout state.
257@@ -1330,6 +1474,10 @@ var CodeView = class CodeView {
258 item.item = nextItem;
259 item.version = nextItem.version;
260 item.renderedOptionsRevision = -1;
261+ const reveal = this.pendingSearchReveal;
262+ if (reveal != null && reveal.id === nextItem.id && nextItem.collapsed !== true) queueMicrotask(() => {
263+ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true);
264+ });
265 return true;
266 }
267 getMaxScrollTopForHeight(scrollHeight) {
268@@ -1710,6 +1858,10 @@ var CodeView = class CodeView {
269 this.updateStickyPositioning();
270 this.validateStickyContainerHeight();
271 this.fixContainerFocus();
272+ if (updatedItems.size > 0) {
273+ this.resumePendingSearchReveal(updatedItems);
274+ this.search.repaint();
275+ }
276 if (fitPerfectly || this.scrollAnimation != null) this.render();
277 };
278 flushManagers(updatedItems) {
279@@ -1804,7 +1956,8 @@ var CodeView = class CodeView {
280 this.notifyScroll();
281 this.render();
282 };
283- clearPendingScroll = () => {
284+ clearPendingScroll = (event) => {
285+ if (event != null && this.search.isOpen && this.search.ownsEvent(event)) return;
286 this.pendingScrollTarget = void 0;
287 this.pendingLayoutAnchor = void 0;
288 this.scrollAnimation = void 0;
289@@ -2066,7 +2219,12 @@ var CodeView = class CodeView {
290 const item = this.items[index];
291 if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index");
292 item.top = runningTop;
293- if (item.type === "diff") item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []);
294+ if (item.type === "diff") {
295+ const wasPartial = item.item.fileDiff.isPartial;
296+ item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []);
297+ // Hydration fills the diff's lines in place, so the search has new text to cover.
298+ if (wasPartial && !item.item.fileDiff.isPartial) this.search.refresh(true);
299+ }
300 else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []);
301 runningTop += item.height;
302 if (index < this.items.length - 1) runningTop += layout.gap;
303@@ -2086,6 +2244,27 @@ var CodeView = class CodeView {
304 return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight;
305 }
306 };
307+function getTextRangeRect(lineElement, start, end) {
308+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT);
309+ let consumed = 0;
310+ let startNode;
311+ let startOffset = 0;
312+ let node;
313+ while ((node = walker.nextNode()) != null) {
314+ const length = node.data.length;
315+ if (startNode == null && start < consumed + length) {
316+ startNode = node;
317+ startOffset = start - consumed;
318+ }
319+ if (startNode != null && end <= consumed + length) {
320+ const range = document.createRange();
321+ range.setStart(startNode, startOffset);
322+ range.setEnd(node, end - consumed);
323+ return range.getBoundingClientRect();
324+ }
325+ consumed += length;
326+ }
327+}
328 function prepareItemInstance(item) {
329 item.instance.cleanUp(true);
330 if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []);
331diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js
332new file mode 100644
333index 0000000..29370ad
334--- /dev/null
335+++ b/dist/components/CodeViewSearch.js
336@@ -0,0 +1,360 @@
337+import { isPrimaryModifier } from "../editor/platform.js";
338+import { resolveFindAgainShortcut } from "../editor/command.js";
339+import { SearchPanelWidget } from "../editor/searchPanel.js";
340+import { SVGSpriteSheet } from "../editor/sprite.js";
341+import editor_default from "../editor/editor2.js";
342+import { iterateOverDiff } from "../utils/iterateOverDiff.js";
343+//#region src/components/CodeViewSearch.ts
344+const MAX_SEARCH_MATCHES = 1e5;
345+const WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
346+const SEARCH_HOST_ATTRIBUTE = "data-diffs-search";
347+const SEARCH_PANEL_HOST_CSS = `:host {
348+ display: block;
349+ position: sticky;
350+ z-index: 100;
351+ height: 0;
352+}
353+[data-icon-sprite] {
354+ position: absolute;
355+}
356+[data-search-panel] {
357+ position: relative;
358+ top: 8px;
359+}
360+[data-editor-widget] {
361+ --diffs-bg: var(--diffs-search-panel-bg, Canvas);
362+ --diffs-fg: var(--diffs-search-panel-fg, CanvasText);
363+ --diffs-modified-base: var(--diffs-search-panel-accent, light-dark(#009fff, #69b1ff));
364+ --diffs-header-font-fallback: var(--diffs-search-panel-font, system-ui, sans-serif);
365+}`;
366+let sharedHighlights;
367+/**
368+* Find-in-diff for a CodeView. It searches every diff item's line data rather
369+* than the rendered rows, so matches in virtualized rows, expanded context, and
370+* folded files are all found, and navigating to one reveals it.
371+*
372+* Matches are painted with the CSS Custom Highlight API. Ranges are rebuilt for
373+* whichever rows a render leaves mounted, so highlighting never touches the
374+* rendered DOM or the renderer's caches.
375+*/
376+var CodeViewSearch = class {
377+ #view;
378+ #host;
379+ #widget;
380+ #entries = [];
381+ #entriesByItem = /* @__PURE__ */ new Map();
382+ #current;
383+ #revealHorizontally;
384+ #ranges = [];
385+ #searchedDiffs = [];
386+ constructor(view) {
387+ this.#view = view;
388+ }
389+ get isOpen() {
390+ return this.#host != null;
391+ }
392+ /** Open the panel, or refocus it when it is already open. */
393+ open() {
394+ if (this.#widget != null) {
395+ this.#widget.setMode("find");
396+ return;
397+ }
398+ const root = this.#view.getContainerElement();
399+ if (root == null) return;
400+ const host = document.createElement("div");
401+ host.setAttribute(SEARCH_HOST_ATTRIBUTE, "");
402+ host.style.top = `${this.#view.getStickyHeaderOffset()}px`;
403+ const shadowRoot = host.attachShadow({ mode: "open" });
404+ const style = document.createElement("style");
405+ style.textContent = `${editor_default}\n${SEARCH_PANEL_HOST_CSS}`;
406+ const sprite = document.createElement("div");
407+ sprite.innerHTML = SVGSpriteSheet;
408+ const anchor = document.createElement("div");
409+ shadowRoot.append(style, ...sprite.children, anchor);
410+ root.prepend(host);
411+ this.#host = host;
412+ const defaultQuery = getSelectionQuery();
413+ // The widget runs its first search a frame after opening, so callbacks from a
414+ // panel that has since closed must not touch the current search.
415+ const isLive = () => this.#widget === widget;
416+ // Without a selection that first frame reports no matches; ignore it if a
417+ // query typed before then has already found some.
418+ let initializing = defaultQuery === "";
419+ const widget = new SearchPanelWidget({
420+ textDocument: { search: (params) => isLive() ? this.#search(params) : [] },
421+ containerElement: anchor,
422+ defaultQuery,
423+ mode: "find",
424+ allowReplace: false,
425+ initialMatch: defaultQuery !== "" ? [0, 0] : void 0,
426+ scrollToMatch: ([offset]) => {
427+ if (isLive()) this.#goTo(offset);
428+ },
429+ applyReplace: () => {},
430+ onUpdate: (matches, options) => {
431+ if (!isLive()) return;
432+ if (matches.length === 0) {
433+ if (!initializing || this.#entries.length === 0) this.#setEntries([]);
434+ return;
435+ }
436+ if (options?.syncSelection === false) {
437+ this.#paint();
438+ return this.#current != null ? matches[this.#current] : void 0;
439+ }
440+ const next = matches[this.#current ?? this.#view.getFirstVisibleSearchMatchIndex(this.#entries)];
441+ if (next != null) this.#goTo(next[0]);
442+ return next;
443+ },
444+ onClose: () => this.#teardown(true)
445+ });
446+ this.#widget = widget;
447+ // Runs after the widget's own first-frame callback, which was scheduled first.
448+ if (initializing) requestAnimationFrame(() => {
449+ initializing = false;
450+ });
451+ widget.focus();
452+ }
453+ close() {
454+ this.#widget?.close();
455+ }
456+ /**
457+ * Rerun the query after the items change, keeping the current match when it
458+ * survives. Updates that keep every diff (folding, annotations) only repaint;
459+ * `force` covers diffs whose lines were filled in place.
460+ */
461+ refresh(force = false) {
462+ if (this.#widget == null) return;
463+ if (!force && haveSameDiffs(this.#searchedDiffs, this.#view.getSearchItems())) {
464+ this.#paint();
465+ return;
466+ }
467+ this.#widget.updateMatches({ syncSelection: false });
468+ }
469+ /** Repaint highlights for the rows the last render pass left mounted. */
470+ repaint() {
471+ if (this.#entries.length > 0) this.#paint();
472+ }
473+ /** Whether an event came from the panel, which handles its own keys and pointer input. */
474+ ownsEvent(event) {
475+ return event.composedPath().some((node) => node instanceof Element && node.hasAttribute(SEARCH_HOST_ATTRIBUTE));
476+ }
477+ handleKeyDown = (event) => {
478+ if (event.isComposing || event.keyCode === 229) return;
479+ // The panel's inputs handle their own keys; its buttons get the shortcuts below.
480+ if (this.ownsEvent(event) && isEditableTarget(event)) {
481+ if (event.key === "Escape") event.stopPropagation();
482+ return;
483+ }
484+ if (event.defaultPrevented) return;
485+ if (isPrimaryModifier(event) && !event.altKey && !event.shiftKey && (event.key.toLowerCase() === "f" || event.code === "KeyF")) {
486+ event.preventDefault();
487+ this.open();
488+ return;
489+ }
490+ if (this.#widget == null) return;
491+ const findAgain = resolveFindAgainShortcut(event);
492+ if (findAgain != null) {
493+ event.preventDefault();
494+ this.#widget.navigate(findAgain === "previous");
495+ } else if (event.key === "Escape" && !isEditableTarget(event)) {
496+ event.preventDefault();
497+ event.stopPropagation();
498+ this.close();
499+ }
500+ };
501+ cleanUp() {
502+ this.#teardown(false);
503+ }
504+ #teardown(restoreFocus) {
505+ if (this.#host == null) return;
506+ this.#widget?.cleanup();
507+ this.#widget = void 0;
508+ this.#host.remove();
509+ this.#host = void 0;
510+ this.#searchedDiffs = [];
511+ this.#setEntries([]);
512+ if (restoreFocus) this.#view.getContainerElement()?.focus({ preventScroll: true });
513+ }
514+ #search(params) {
515+ const previous = this.#current != null ? this.#entries[this.#current] : void 0;
516+ const items = this.#view.getSearchItems();
517+ this.#searchedDiffs = items.map((item) => [item.id, item.fileDiff]);
518+ this.#setEntries(collectMatches(items, params), previous);
519+ return this.#entries.map((entry) => entry.range);
520+ }
521+ #setEntries(entries, previous) {
522+ this.#entries = entries;
523+ this.#entriesByItem = groupByItem(entries);
524+ this.#current = previous != null ? findSameMatch(entries, previous) : void 0;
525+ if (entries.length === 0) {
526+ this.#view.cancelSearchReveal();
527+ this.#paint();
528+ }
529+ }
530+ #goTo(offset) {
531+ const index = this.#entries.findIndex((entry) => entry.range[0] === offset);
532+ if (index === -1) return;
533+ this.#current = index;
534+ this.#revealHorizontally = this.#entries[index];
535+ this.#view.revealSearchMatch(this.#entries[index]);
536+ this.#paint();
537+ }
538+ #paint() {
539+ const highlights = getSharedHighlights();
540+ if (highlights == null) return;
541+ for (const range of this.#ranges) {
542+ highlights.match.delete(range);
543+ highlights.focus.delete(range);
544+ }
545+ this.#ranges = [];
546+ const current = this.#current != null ? this.#entries[this.#current] : void 0;
547+ let currentElement;
548+ for (const { entry, element } of this.#view.getMountedSearchMatches(this.#entriesByItem)) {
549+ const range = createTextRange(element, entry.start, entry.end);
550+ if (range == null) continue;
551+ (entry === current ? highlights.focus : highlights.match).add(range);
552+ this.#ranges.push(range);
553+ if (entry === current) currentElement ??= element;
554+ }
555+ if (current != null && currentElement != null && this.#revealHorizontally === current) {
556+ this.#revealHorizontally = void 0;
557+ this.#view.revealSearchMatchHorizontally(current, currentElement);
558+ }
559+ }
560+};
561+/**
562+* Collect matches across diff items in render order. Each match gets a
563+* synthetic, increasing offset so the panel's offset-based navigation works
564+* across files; nothing else reads those offsets.
565+*/
566+function collectMatches(items, params) {
567+ if (params.text === "" || params.text.includes("\n")) return [];
568+ let pattern;
569+ try {
570+ pattern = new RegExp(params.regex ? params.text : escapeRegExp(params.text), params.caseSensitive ? "g" : "gi");
571+ } catch {
572+ return [];
573+ }
574+ const entries = [];
575+ let offset = 0;
576+ for (let itemIndex = 0; itemIndex < items.length && entries.length < MAX_SEARCH_MATCHES; itemIndex++) {
577+ const item = items[itemIndex];
578+ if (item.type !== "diff") continue;
579+ const { fileDiff } = item;
580+ iterateOverDiff({
581+ diff: fileDiff,
582+ diffStyle: "unified",
583+ expandedHunks: true,
584+ callback: ({ type, deletionLine, additionLine }) => {
585+ const isDeletion = type === "change" && deletionLine != null;
586+ const line = isDeletion ? deletionLine : additionLine;
587+ const text = trimLineBreak((isDeletion ? fileDiff.deletionLines : fileDiff.additionLines)[line.lineIndex] ?? "");
588+ pattern.lastIndex = 0;
589+ let match;
590+ while ((match = pattern.exec(text)) !== null) {
591+ const length = match[0].length;
592+ if (length === 0) {
593+ pattern.lastIndex++;
594+ continue;
595+ }
596+ if (params.wholeWord && !isWholeWord(text, match.index, length)) continue;
597+ entries.push({
598+ id: item.id,
599+ itemIndex,
600+ side: isDeletion ? "deletions" : "additions",
601+ isContext: type !== "change",
602+ lineNumber: line.lineNumber,
603+ unifiedLineIndex: line.unifiedLineIndex,
604+ start: match.index,
605+ end: match.index + length,
606+ range: [offset + match.index, offset + match.index + length]
607+ });
608+ if (entries.length >= MAX_SEARCH_MATCHES) return true;
609+ }
610+ offset += text.length + 1;
611+ }
612+ });
613+ }
614+ return entries;
615+}
616+function haveSameDiffs(searched, items) {
617+ return searched.length === items.length && items.every((item, index) => item.id === searched[index][0] && item.fileDiff === searched[index][1]);
618+}
619+function groupByItem(entries) {
620+ const byItem = /* @__PURE__ */ new Map();
621+ for (const entry of entries) {
622+ let list = byItem.get(entry.itemIndex);
623+ if (list == null) byItem.set(entry.itemIndex, list = []);
624+ list.push(entry);
625+ }
626+ return byItem;
627+}
628+function findSameMatch(entries, previous) {
629+ const index = entries.findIndex((entry) => entry.id === previous.id && entry.side === previous.side && entry.lineNumber === previous.lineNumber && entry.start === previous.start);
630+ return index === -1 ? void 0 : index;
631+}
632+function createTextRange(lineElement, start, end) {
633+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT);
634+ let consumed = 0;
635+ let startNode;
636+ let startOffset = 0;
637+ let node;
638+ while ((node = walker.nextNode()) != null) {
639+ const length = node.data.length;
640+ if (startNode == null && start < consumed + length) {
641+ startNode = node;
642+ startOffset = start - consumed;
643+ }
644+ if (startNode != null && end <= consumed + length) {
645+ const range = document.createRange();
646+ range.setStart(startNode, startOffset);
647+ range.setEnd(node, end - consumed);
648+ return range;
649+ }
650+ consumed += length;
651+ }
652+}
653+function trimLineBreak(text) {
654+ if (text.endsWith("\r\n")) return text.slice(0, -2);
655+ if (text.endsWith("\n") || text.endsWith("\r")) return text.slice(0, -1);
656+ return text;
657+}
658+function isWholeWord(text, start, length) {
659+ return isSeparator(text, start - 1) && isSeparator(text, start + length);
660+}
661+function isSeparator(text, index) {
662+ if (index < 0 || index >= text.length) return true;
663+ const code = text.charCodeAt(index);
664+ return code <= 32 || code === 127 || WORD_SEPARATORS.includes(text[index]);
665+}
666+function escapeRegExp(text) {
667+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
668+}
669+function isEditableTarget(event) {
670+ const target = event.composedPath()[0];
671+ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select"));
672+}
673+function getSelectionQuery() {
674+ const text = document.getSelection()?.toString() ?? "";
675+ return text.includes("\n") ? "" : text;
676+}
677+/**
678+* Two registry entries shared by every CodeView on the page; each search adds
679+* and removes only its own ranges. The `::highlight()` colours live in the core
680+* stylesheet, because a highlight rule only styles text in its own tree.
681+*/
682+function getSharedHighlights() {
683+ if (sharedHighlights != null) return sharedHighlights;
684+ if (typeof CSS === "undefined" || !("highlights" in CSS) || typeof Highlight === "undefined") return;
685+ sharedHighlights = {
686+ match: new Highlight(),
687+ focus: new Highlight()
688+ };
689+ CSS.highlights.set("diffs-search-match", sharedHighlights.match);
690+ CSS.highlights.set("diffs-search-focus", sharedHighlights.focus);
691+ return sharedHighlights;
692+}
693+//#endregion
694+export { CodeViewSearch, collectMatches };
695+
696+//# sourceMappingURL=CodeViewSearch.js.map
1diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts697diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts
698index 5e68e35..79dcd88 100644
2--- a/dist/components/VirtualizedFile.d.ts699--- a/dist/components/VirtualizedFile.d.ts
3+++ b/dist/components/VirtualizedFile.d.ts700+++ b/dist/components/VirtualizedFile.d.ts
4@@ -49,7 +49,7 @@701@@ -49,7 +49,7 @@ declare class VirtualizedFile<LAnnotation = undefined, Caret = undefined> extend
5 setVisibility(visible: boolean): void;702 setVisibility(visible: boolean): void;
6 rerender(): void;703 rerender(): void;
7 syncGhostTextRows(): void;704 syncGhostTextRows(): void;
...@@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF...@@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF
11 protected finalizeRender(): void;708 protected finalizeRender(): void;
12 private updatePendingRender;709 private updatePendingRender;
13diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js710diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js
711index d1f57cd..5908ad7 100644
14--- a/dist/components/VirtualizedFile.js712--- a/dist/components/VirtualizedFile.js
15+++ b/dist/components/VirtualizedFile.js713+++ b/dist/components/VirtualizedFile.js
16@@ -23,6 +23,7 @@714@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File {
17 heights: /* @__PURE__ */ new Map(),715 heights: /* @__PURE__ */ new Map(),
18 checkpoints: [],716 checkpoints: [],
19 fileAnnotationHeight: 0,717 fileAnnotationHeight: 0,
...@@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil...@@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
21 ghostTextRows: NO_GHOST_TEXT_ROWS719 ghostTextRows: NO_GHOST_TEXT_ROWS
22 };720 };
23 pendingRender;721 pendingRender;
24@@ -35,6 +36,8 @@722@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File {
25 super(options, workerManager, isContainerManaged);723 super(options, workerManager, isContainerManaged);
26 this.virtualizer = virtualizer;724 this.virtualizer = virtualizer;
27 this.metrics = metrics;725 this.metrics = metrics;
...@@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil...@@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
30 }728 }
31 setMetrics(metrics, force = false) {729 setMetrics(metrics, force = false) {
32 const nextMetrics = computeVirtualFileMetrics(metrics);730 const nextMetrics = computeVirtualFileMetrics(metrics);
33@@ -86,10 +89,12 @@731@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File {
34 if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.");732 if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.");
35 super.setThemeType(themeType);733 super.setThemeType(themeType);
36 }734 }
...@@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil...@@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
46 if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0;744 if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0;
47 this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS;745 this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS;
48 if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;746 if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;
49@@ -135,6 +140,13 @@747@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File {
50 if (this.code == null) return hasHeightChange;748 if (this.code == null) return hasHeightChange;
51 const content = this.code.children[1];749 const content = this.code.children[1];
52 if (!(content instanceof HTMLElement)) return hasHeightChange;750 if (!(content instanceof HTMLElement)) return hasHeightChange;
...@@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil...@@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
60 if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) {758 if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) {
61 const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;759 const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;
62 if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) {760 if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) {
63@@ -358,12 +370,12 @@761@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File {
64 codeView.instanceChanged(this, true);762 codeView.instanceChanged(this, true);
65 } else this.getSimpleVirtualizer()?.requestHeightReconcile(this);763 } else this.getSimpleVirtualizer()?.requestHeightReconcile(this);
66 }764 }
...@@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil...@@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
75 const file = this.getRenderedFile();773 const file = this.getRenderedFile();
76 if (!this.isSimpleMode()) this.computeApproximateSize(true);774 if (!this.isSimpleMode()) this.computeApproximateSize(true);
77 else if (shouldUpdateBuffer && previousRenderRange != null && file != null) {775 else if (shouldUpdateBuffer && previousRenderRange != null && file != null) {
776diff --git a/dist/components/web-components.js b/dist/components/web-components.js
777index 6e7b486..5b7290f 100644
778--- a/dist/components/web-components.js
779+++ b/dist/components/web-components.js
780@@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js";
781 import style_default from "../style.js";
782 import { getMeasuredScrollbarGutter } from "../utils/scrollbarGutter.js";
783 //#region src/components/web-components.ts
784+// `::highlight()` rules only style text in their own tree, so find's match colours ride
785+// along with every container's shadow root.
786+const SEARCH_HIGHLIGHT_CSS = `::highlight(diffs-search-match) {
787+ background-color: var(--diffs-search-match-bg, light-dark(#ff963288, #ff963266));
788+}
789+::highlight(diffs-search-focus) {
790+ background-color: var(--diffs-search-focus-bg, color-mix(in lab, var(--diffs-modified-base) 45%, transparent));
791+}`;
792 if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") == null) {
793 let sheet;
794+ let searchSheet;
795 class FileDiffContainer extends HTMLElement {
796 constructor() {
797 super();
798@@ -12,8 +21,10 @@ if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container")
799 if (sheet == null) {
800 sheet = new CSSStyleSheet();
801 sheet.replaceSync(style_default);
802+ searchSheet = new CSSStyleSheet();
803+ searchSheet.replaceSync(SEARCH_HIGHLIGHT_CSS);
804 }
805- shadowRoot.adoptedStyleSheets = [sheet];
806+ shadowRoot.adoptedStyleSheets = [sheet, searchSheet];
807 }
808 connectedCallback() {
809 const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" });
78diff --git a/dist/editor/editor.js b/dist/editor/editor.js810diff --git a/dist/editor/editor.js b/dist/editor/editor.js
811index 8f04a6f..c203023 100644
79--- a/dist/editor/editor.js812--- a/dist/editor/editor.js
80+++ b/dist/editor/editor.js813+++ b/dist/editor/editor.js
81@@ -1899,7 +1899,11 @@814@@ -1899,7 +1899,11 @@ var Editor = class {
82 changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,815 changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,
83 documentLineCount: textDocument.lineCount816 documentLineCount: textDocument.lineCount
84 });817 });
...@@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js...@@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js
91 if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache();824 if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache();
92 if (newLineAnnotations != null) {825 if (newLineAnnotations != null) {
93 this.#lineAnnotations = newLineAnnotations;826 this.#lineAnnotations = newLineAnnotations;
827diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts
828index 9542849..1c1a51f 100644
829--- a/dist/editor/searchPanel.d.ts
830+++ b/dist/editor/searchPanel.d.ts
831@@ -15,6 +15,8 @@ interface SearchPanelOptions {
832 containerElement: HTMLElement;
833 defaultQuery: string;
834 mode?: SearchPanelMode;
835+ /** False keeps the panel in find mode, for hosts with nothing to replace into. */
836+ allowReplace?: boolean;
837 initialMatch?: MatchRange;
838 scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void;
839 applyReplace: (edits: ResolvedTextEdit[]) => void;
840diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js
841index df1ba63..676751b 100644
842--- a/dist/editor/searchPanel.js
843+++ b/dist/editor/searchPanel.js
844@@ -12,7 +12,7 @@ var SearchPanelWidget = class {
845 #navigate;
846 #close;
847 constructor(options) {
848- const { textDocument, containerElement, defaultQuery, mode = "find", initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options;
849+ const { textDocument, containerElement, defaultQuery, mode = "find", allowReplace = true, initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options;
850 const searchParams = {
851 text: defaultQuery,
852 replaceText: "",
853@@ -278,7 +278,7 @@ var SearchPanelWidget = class {
854 ]
855 });
856 const applyMode = (next) => {
857- gridElement.dataset.mode = next;
858+ gridElement.dataset.mode = allowReplace ? next : "find";
859 this.#inputElement.focus();
860 this.#inputElement.select();
861 };
94diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts862diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
863index 3efafc4..9e5b751 100644
95--- a/dist/managers/ResizeManager.d.ts864--- a/dist/managers/ResizeManager.d.ts
96+++ b/dist/managers/ResizeManager.d.ts865+++ b/dist/managers/ResizeManager.d.ts
97@@ -5,6 +5,8 @@866@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions {
98 columnVariables?: ResizeManagerColumnVariableMode;867 columnVariables?: ResizeManagerColumnVariableMode;
99 }868 }
100 declare class ResizeManager {869 declare class ResizeManager {
...@@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts...@@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
104 private static managersByElement;873 private static managersByElement;
105 private static getResizeObserver;874 private static getResizeObserver;
106diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js875diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
876index 9724cb1..14145d8 100644
107--- a/dist/managers/ResizeManager.js877--- a/dist/managers/ResizeManager.js
108+++ b/dist/managers/ResizeManager.js878+++ b/dist/managers/ResizeManager.js
109@@ -19,6 +19,7 @@879@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager {
110 for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);880 for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);
111 }881 }
112 observedNodes = /* @__PURE__ */ new Map();882 observedNodes = /* @__PURE__ */ new Map();
...@@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js...@@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
114 setup(pre, { disableAnnotations, columnVariables = "apply" }) {884 setup(pre, { disableAnnotations, columnVariables = "apply" }) {
115 const annotationUpdates = /* @__PURE__ */ new Set();885 const annotationUpdates = /* @__PURE__ */ new Set();
116 const applyColumnVariables = columnVariables === "apply";886 const applyColumnVariables = columnVariables === "apply";
117@@ -212,6 +213,7 @@887@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager {
118 this.applyAnnotationUpdates(annotationUpdates);888 this.applyAnnotationUpdates(annotationUpdates);
119 annotationUpdates.clear();889 annotationUpdates.clear();
120 this.applyColumnUpdates(codeUpdates);890 this.applyColumnUpdates(codeUpdates);
...@@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js...@@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
122 codeUpdates.clear();892 codeUpdates.clear();
123 }893 }
124 applyAnnotationUpdates(annotationUpdates) {894 applyAnnotationUpdates(annotationUpdates) {
895diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts
896index e78e713..642b235 100644
897--- a/dist/react/CodeView.d.ts
898+++ b/dist/react/CodeView.d.ts
899@@ -56,6 +56,8 @@ interface CodeViewBaseProps<LAnnotation, Caret> {
900 * is frozen; re-key the accepted value in place before accepting.
901 */
902 onItemEditComplete?: CodeViewItemEditCompleteHandler<LAnnotation, Caret>;
903+ /** Unfold a collapsed item that holds the find match being navigated to. */
904+ onRevealSearchMatch?(item: CodeViewItem<LAnnotation>): void;
905 renderCustomHeader?(item: CodeViewItem<LAnnotation>): ReactNode;
906 renderHeaderPrefix?(item: CodeViewItem<LAnnotation>): ReactNode;
907 renderHeaderFilenameSuffix?(item: CodeViewItem<LAnnotation>): ReactNode;
908diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js
909index 84c7915..d1930bf 100644
910--- a/dist/react/CodeView.js
911+++ b/dist/react/CodeView.js
912@@ -24,7 +24,7 @@ function createDefaultCache(controlled) {
913 };
914 }
915 function CodeViewInner(props, ref) {
916- const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props;
917+ const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onRevealSearchMatch, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props;
918 const controlled = controlledItems !== void 0;
919 const contextCreateEditor = useCreateEditor();
920 const poolManager = useContext(WorkerPoolContext);
921@@ -52,6 +52,9 @@ function CodeViewInner(props, ref) {
922 onItemEditChange?.(event, item);
923 });
924 const emitItemEditComplete = useStableCallback((event, item, nextItem) => onItemEditComplete?.(event, item, nextItem) ?? "reject");
925+ const emitRevealSearchMatch = useStableCallback((item) => {
926+ onRevealSearchMatch?.(item);
927+ });
928 const managedOptions = useMemo(() => createManagedCodeViewOptions({
929 options,
930 hasCustomHeader,
931@@ -63,13 +66,15 @@ function CodeViewInner(props, ref) {
932 getEditStateKey,
933 createEditor: contextCreateEditor != null ? createEditor : void 0,
934 onItemEditChange: onItemEditChange != null ? emitItemEditChange : void 0,
935- onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0
936+ onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0,
937+ onRevealSearchMatch: onRevealSearchMatch != null ? emitRevealSearchMatch : void 0
938 }), [
939 contextCreateEditor,
940 controlledSelection,
941 createEditor,
942 emitItemEditChange,
943 emitItemEditComplete,
944+ emitRevealSearchMatch,
945 emitSelectedLinesChange,
946 hasCodeViewFooter,
947 hasCodeViewHeader,
948@@ -78,6 +83,7 @@ function CodeViewInner(props, ref) {
949 getEditStateKey,
950 onItemEditChange,
951 onItemEditComplete,
952+ onRevealSearchMatch,
953 onSelectedLinesChange,
954 options
955 ]);
956@@ -302,7 +308,7 @@ function createSlotContentStore() {
957 }
958 };
959 }
960-function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete }) {
961+function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, onRevealSearchMatch }) {
962 const managedOptions = {
963 ...options,
964 controlledSelection,
965@@ -312,6 +318,7 @@ function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRende
966 if (getEditStateKey != null) managedOptions.getEditStateKey = getEditStateKey;
967 if (onItemEditChange != null) managedOptions.onItemEditChange = onItemEditChange;
968 if (onItemEditComplete != null) managedOptions.onItemEditComplete = onItemEditComplete;
969+ if (onRevealSearchMatch != null) managedOptions.onRevealSearchMatch = onRevealSearchMatch;
970 if (hasCustomHeader) managedOptions.renderCustomHeader = noopRender;
971 if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender;
972 if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender;
125diff --git a/package.json b/package.json973diff --git a/package.json b/package.json
974index d9dc7b5..3c37bae 100644
126--- a/package.json975--- a/package.json
127+++ b/package.json976+++ b/package.json
128@@ -58,6 +58,18 @@977@@ -58,6 +58,18 @@
pnpm-lock.yaml+5-5
...@@ -103,7 +103,7 @@ patchedDependencies:...@@ -103,7 +103,7 @@ patchedDependencies:
103 '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd103 '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd
104 '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685104 '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685
105 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59105 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59
106 '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289106 '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0
107 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d107 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
108 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba108 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
109 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552109 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
...@@ -276,7 +276,7 @@ importers:...@@ -276,7 +276,7 @@ importers:
276 version: 1.9.1276 version: 1.9.1
277 '@pierre/diffs':277 '@pierre/diffs':
278 specifier: 'catalog:'278 specifier: 'catalog:'
279 version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)279 version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
280 '@react-native-ai/apple':280 '@react-native-ai/apple':
281 specifier: 0.12.0281 specifier: 0.12.0
282 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))282 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))
...@@ -665,7 +665,7 @@ importers:...@@ -665,7 +665,7 @@ importers:
665 version: 1.8.0665 version: 1.8.0
666 '@pierre/diffs':666 '@pierre/diffs':
667 specifier: 'catalog:'667 specifier: 'catalog:'
668 version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)668 version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
669 '@pierre/trees':669 '@pierre/trees':
670 specifier: 1.0.0-beta.6670 specifier: 1.0.0-beta.6
671 version: 1.0.0-beta.6(@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)671 version: 1.0.0-beta.6(@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)
...@@ -14913,7 +14913,7 @@ snapshots:...@@ -14913,7 +14913,7 @@ snapshots:
14913 tslib: 2.8.114913 tslib: 2.8.1
14914 webcrypto-core: 1.9.214914 webcrypto-core: 1.9.2
1491514915
14916 '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':14916 '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
14917 dependencies:14917 dependencies:
14918 '@pierre/theme': 2.0.014918 '@pierre/theme': 2.0.0
14919 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)14919 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0)
...@@ -14928,7 +14928,7 @@ snapshots:...@@ -14928,7 +14928,7 @@ snapshots:
14928 transitivePeerDependencies:14928 transitivePeerDependencies:
14929 - '@shikijs/themes'14929 - '@shikijs/themes'
1493014930
14931 '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':14931 '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
14932 dependencies:14932 dependencies:
14933 '@pierre/theme': 2.0.014933 '@pierre/theme': 2.0.0
14934 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)14934 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0)