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({
695695 return { scopeKey: collapseScopeKey, fileKeys: next };
696696 });
697697 }, []);
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
699711 const toggleDiffFileCollapse = useCallback(() => {
700712 setCodeViewRevision((current) => current + 1);
......@@ -1197,6 +1209,7 @@ export default function DiffPanel({
11971209 },
11981210 }
11991211 : {})}
1212 onRevealSearchMatch={unfoldDiffFile}
12001213 renderHeaderPrefix={(fileDiff, fileKey) => {
12011214 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
12021215 return (
apps/web/src/components/RightPanelTabs.tsx+2-1
......@@ -415,7 +415,8 @@ function RightPanelEmptyState(props: {
415415 const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
416416 if (!action) return;
417417 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;
419420 if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return;
420421 event.preventDefault();
421422 event.stopPropagation();
apps/web/src/components/diffs/AnnotatableCodeView.tsx+4
......@@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps {
9595 fileKey: string,
9696 collapsed: boolean,
9797 ) => ReactNode;
98 /** Unfold a collapsed file that holds the find match being navigated to. */
99 onRevealSearchMatch: (fileKey: string) => void;
98100}
99101
100102interface DiffSelectionContext {
......@@ -115,6 +117,7 @@ export function AnnotatableCodeView({
115117 renderHeaderMetadata,
116118 renderHeaderFilenameSuffix,
117119 renderHeaderPrefix,
120 onRevealSearchMatch,
118121}: AnnotatableCodeViewProps) {
119122 const addReviewComment = useComposerDraftStore((store) => store.addReviewComment);
120123 const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment);
......@@ -262,6 +265,7 @@ export function AnnotatableCodeView({
262265 items={items}
263266 selectedLines={selectedLines}
264267 onSelectedLinesChange={setSelectedLines}
268 onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)}
265269 options={{
266270 ...options,
267271 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({
15161516 renderHeaderPrefix={renderHeaderPrefix}
15171517 renderHeaderMetadata={renderHeaderMetadata}
15181518 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 }
15191525 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
15201526 />
15211527 </div>
apps/web/src/index.css+8
......@@ -2087,6 +2087,14 @@ code {
20872087 color: var(--code-foreground);
20882088}
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
20902098/* Pierre's file renderer owns its own surface tokens. Keep the root of a file
20912099 preview on the same themeable code surface as the diff panel. */
20922100.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 = [
1111/**
1212 * Whether a text-editing element owns the keyboard. Shortcuts that share
1313 * 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.
1416 */
1517export 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;
1721}
docs/user/keybindings.md+9
......@@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix.
4646Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID”
4747or “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
4958## iPad
5059
5160With 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 @@
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
1697diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts
698index 5e68e35..79dcd88 100644
2699--- a/dist/components/VirtualizedFile.d.ts
3700+++ b/dist/components/VirtualizedFile.d.ts
4@@ -49,7 +49,7 @@
701@@ -49,7 +49,7 @@ declare class VirtualizedFile<LAnnotation = undefined, Caret = undefined> extend
5702 setVisibility(visible: boolean): void;
6703 rerender(): void;
7704 syncGhostTextRows(): void;
......@@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF
11708 protected finalizeRender(): void;
12709 private updatePendingRender;
13710diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js
711index d1f57cd..5908ad7 100644
14712--- a/dist/components/VirtualizedFile.js
15713+++ b/dist/components/VirtualizedFile.js
16@@ -23,6 +23,7 @@
714@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File {
17715 heights: /* @__PURE__ */ new Map(),
18716 checkpoints: [],
19717 fileAnnotationHeight: 0,
......@@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
21719 ghostTextRows: NO_GHOST_TEXT_ROWS
22720 };
23721 pendingRender;
24@@ -35,6 +36,8 @@
722@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File {
25723 super(options, workerManager, isContainerManaged);
26724 this.virtualizer = virtualizer;
27725 this.metrics = metrics;
......@@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
30728 }
31729 setMetrics(metrics, force = false) {
32730 const nextMetrics = computeVirtualFileMetrics(metrics);
33@@ -86,10 +89,12 @@
731@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File {
34732 if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.");
35733 super.setThemeType(themeType);
36734 }
......@@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
46744 if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0;
47745 this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS;
48746 if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;
49@@ -135,6 +140,13 @@
747@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File {
50748 if (this.code == null) return hasHeightChange;
51749 const content = this.code.children[1];
52750 if (!(content instanceof HTMLElement)) return hasHeightChange;
......@@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
60758 if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) {
61759 const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;
62760 if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) {
63@@ -358,12 +370,12 @@
761@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File {
64762 codeView.instanceChanged(this, true);
65763 } else this.getSimpleVirtualizer()?.requestHeightReconcile(this);
66764 }
......@@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil
75773 const file = this.getRenderedFile();
76774 if (!this.isSimpleMode()) this.computeApproximateSize(true);
77775 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" });
78810diff --git a/dist/editor/editor.js b/dist/editor/editor.js
811index 8f04a6f..c203023 100644
79812--- a/dist/editor/editor.js
80813+++ b/dist/editor/editor.js
81@@ -1899,7 +1899,11 @@
814@@ -1899,7 +1899,11 @@ var Editor = class {
82815 changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,
83816 documentLineCount: textDocument.lineCount
84817 });
......@@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js
91824 if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache();
92825 if (newLineAnnotations != null) {
93826 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 };
94862diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
863index 3efafc4..9e5b751 100644
95864--- a/dist/managers/ResizeManager.d.ts
96865+++ b/dist/managers/ResizeManager.d.ts
97@@ -5,6 +5,8 @@
866@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions {
98867 columnVariables?: ResizeManagerColumnVariableMode;
99868 }
100869 declare class ResizeManager {
......@@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
104873 private static managersByElement;
105874 private static getResizeObserver;
106875diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
876index 9724cb1..14145d8 100644
107877--- a/dist/managers/ResizeManager.js
108878+++ b/dist/managers/ResizeManager.js
109@@ -19,6 +19,7 @@
879@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager {
110880 for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);
111881 }
112882 observedNodes = /* @__PURE__ */ new Map();
......@@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
114884 setup(pre, { disableAnnotations, columnVariables = "apply" }) {
115885 const annotationUpdates = /* @__PURE__ */ new Set();
116886 const applyColumnVariables = columnVariables === "apply";
117@@ -212,6 +213,7 @@
887@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager {
118888 this.applyAnnotationUpdates(annotationUpdates);
119889 annotationUpdates.clear();
120890 this.applyColumnUpdates(codeUpdates);
......@@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
122892 codeUpdates.clear();
123893 }
124894 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;
125973diff --git a/package.json b/package.json
974index d9dc7b5..3c37bae 100644
126975--- a/package.json
127976+++ b/package.json
128977@@ -58,6 +58,18 @@
pnpm-lock.yaml+5-5
......@@ -103,7 +103,7 @@ patchedDependencies:
103103 '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd
104104 '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685
105105 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59
106 '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289
106 '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0
107107 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d
108108 '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba
109109 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
......@@ -276,7 +276,7 @@ importers:
276276 version: 1.9.1
277277 '@pierre/diffs':
278278 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)
280280 '@react-native-ai/apple':
281281 specifier: 0.12.0
282282 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:
665665 version: 1.8.0
666666 '@pierre/diffs':
667667 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)
669669 '@pierre/trees':
670670 specifier: 1.0.0-beta.6
671671 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:
1491314913 tslib: 2.8.1
1491414914 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)':
1491714917 dependencies:
1491814918 '@pierre/theme': 2.0.0
1491914919 '@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:
1492814928 transitivePeerDependencies:
1492914929 - '@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)':
1493214932 dependencies:
1493314933 '@pierre/theme': 2.0.0
1493414934 '@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)