t3-code-android-nightly/patches/@pierre%2Fdiffs@1.5.2.patch
Julius Marminge 14fe0158ed
feat(web): find in diffs with Cmd+F (#14623)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 17:11:05 -07:00

995 lines
42 KiB
Diff

diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts
index cc32d28..3a46bf4 100644
--- a/dist/components/CodeView.d.ts
+++ b/dist/components/CodeView.d.ts
@@ -158,6 +158,12 @@ interface CodeViewOptions<LAnnotation, Caret> extends CodeViewPassThroughOptions
* final pre-detach state.
*/
onItemEditComplete?: CodeViewItemEditCompleteHandler<LAnnotation, Caret>;
+ /**
+ * Called when find navigates to a match inside a collapsed item. Folding is
+ * host state, so the host unfolds the item (a controlled update with
+ * `collapsed: false` and a new `version`); the viewer then scrolls to it.
+ */
+ onRevealSearchMatch?(item: CodeViewItem<LAnnotation>): void;
/** Render a non-virtualized element at the very start of the scroll content,
* before the first item. It is always rendered and scrolls with the content.
* Return the same element across calls and mutate it in place to update;
@@ -268,6 +274,13 @@ declare class CodeView<LAnnotation = undefined, Caret = undefined> {
getSelectedLines(): CodeViewLineSelection | null;
clearSelectedLines(options?: SelectionWriteOptions): void;
getItem(itemId: string): CodeViewItem<LAnnotation> | undefined;
+ /**
+ * Open the find panel, or refocus it when open. It searches every diff
+ * item's lines (including folded files and virtualized rows), and is also
+ * opened by the primary-modifier+F shortcut while focus is in the viewer.
+ */
+ openSearch(): void;
+ closeSearch(): void;
/**
* Returns the editor while this item is being edited. Partial diffs get an
* editor after their files load; scrolling away or collapsing keeps it.
diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js
index 140f94f..96fc774 100644
--- a/dist/components/CodeView.js
+++ b/dist/components/CodeView.js
@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js";
import { roundToDevicePixel } from "../utils/roundToDevicePixel.js";
import { VirtualizedFile } from "./VirtualizedFile.js";
import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js";
+import { CodeViewSearch } from "./CodeViewSearch.js";
//#region src/components/CodeView.ts
const CODE_VIEW_DIFF_OPTION_KEYS = [
"theme",
@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) {
}
const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120;
const SUB_PIXEL_TOLERANCE = 1;
+/** Room the find panel covers at the top of the viewport; matches under it get scrolled clear. */
+const SEARCH_PANEL_RESERVED_HEIGHT = 48;
const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override";
function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) {
return {
@@ -211,6 +214,8 @@ var CodeView = class CodeView {
isReadySubscription;
pendingHighlighterTheme;
isContainerManaged;
+ search = new CodeViewSearch(this);
+ pendingSearchReveal;
constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) {
this.options = options;
this.computeMetricsCache(options.itemMetrics);
@@ -392,6 +397,7 @@ var CodeView = class CodeView {
this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true });
this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true });
this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true });
+ this.root.addEventListener("keydown", this.search.handleKeyDown);
this.resizeObserver.observe(this.root);
this.render(true);
window.__INSTANCE = this;
@@ -464,6 +470,7 @@ var CodeView = class CodeView {
this.heightDirty = true;
this.resetRenderState();
if (!this.isContainerManaged) this.flushSlotCoordinator();
+ this.search.refresh();
if (failed) throw failure;
}
cleanUp() {
@@ -485,6 +492,8 @@ var CodeView = class CodeView {
this.root?.removeEventListener("touchstart", this.clearPendingScroll);
this.root?.removeEventListener("pointerdown", this.clearPendingScroll);
this.root?.removeEventListener("keydown", this.clearPendingScroll);
+ this.root?.removeEventListener("keydown", this.search.handleKeyDown);
+ this.search.cleanUp();
this.root?.style.removeProperty("overflow-anchor");
this.container?.remove();
this.stickyOffset.remove();
@@ -626,6 +635,137 @@ var CodeView = class CodeView {
getItem(itemId) {
return this.idToItem.get(itemId)?.item;
}
+ /** Open the find panel, or refocus it when it is already open. */
+ openSearch() {
+ this.search.open();
+ }
+ closeSearch() {
+ this.search.close();
+ }
+ getSearchItems() {
+ return this.items.map((item) => item.item);
+ }
+ /** Index of the first match at or below the top of the viewport, or 0 when every match is above it. */
+ getFirstVisibleSearchMatchIndex(entries) {
+ if (this.items.length === 0) return 0;
+ const visibleTop = this.getScrollTop() - this.getItemTopOffset() + this.getStickyHeaderOffset();
+ const itemIndex = this.findFirstVisibleIndex(Math.max(this.getScrollTop() - this.getItemTopOffset(), 0));
+ const item = this.items[itemIndex];
+ for (let index = 0; index < entries.length; index++) {
+ const entry = entries[index];
+ if (entry.itemIndex < itemIndex) continue;
+ if (entry.itemIndex > itemIndex) return index;
+ const position = this.getLineScrollPosition(item, entry);
+ if (position == null || item.top + position.top >= visibleTop) return index;
+ }
+ return 0;
+ }
+ /**
+ * Bring a match on screen. A folded file is unfolded by the host through
+ * `onRevealSearchMatch` (folding is host state); the reveal resumes once the
+ * item arrives unfolded. Collapsed context around the line is expanded here.
+ */
+ revealSearchMatch(match, afterUnfold = false) {
+ const item = this.idToItem.get(match.id);
+ if (item == null || item.type !== "diff") return;
+ if (item.item.collapsed === true) {
+ if (this.options.onRevealSearchMatch == null) {
+ this.scrollTo({
+ type: "item",
+ id: match.id,
+ align: "start"
+ });
+ return;
+ }
+ this.pendingSearchReveal = match;
+ this.options.onRevealSearchMatch(item.item);
+ return;
+ }
+ const target = {
+ type: "line",
+ id: match.id,
+ lineNumber: match.lineNumber,
+ side: match.side,
+ align: "center",
+ // Center in the space below the sticky header and the find panel.
+ offset: -(this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT) / 2
+ };
+ // Before its first render a file reports every line renderable, so collapsed
+ // context can't be expanded yet: scroll there and finish once it has rendered.
+ const rendered = item.instance.getRenderedDiff() != null;
+ this.pendingSearchReveal = rendered ? void 0 : match;
+ if (rendered && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber);
+ else if (rendered && !afterUnfold && this.isScrollTargetInView(target)) return;
+ this.scrollTo(target);
+ }
+ cancelSearchReveal() {
+ this.pendingSearchReveal = void 0;
+ }
+ /** Finish a reveal that was waiting for its file to render for the first time. */
+ resumePendingSearchReveal(updatedItems) {
+ const reveal = this.pendingSearchReveal;
+ if (reveal == null) return;
+ for (const item of updatedItems) if (item.item.id === reveal.id && item.item.collapsed !== true && item.instance.getRenderedDiff() != null) {
+ queueMicrotask(() => {
+ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true);
+ });
+ return;
+ }
+ }
+ /** Scroll a wide code column so the current match is visible when lines don't wrap. */
+ revealSearchMatchHorizontally(match, element) {
+ const item = this.idToItem.get(match.id);
+ const code = element.closest("[data-code]");
+ if (item == null || code == null || code.scrollWidth <= code.clientWidth) return;
+ const codeRect = code.getBoundingClientRect();
+ const matchRect = getTextRangeRect(element, match.start, match.end);
+ if (matchRect == null) return;
+ // The line-number gutter is sticky at the left edge and covers the code under it.
+ const gutterRight = code.querySelector(":scope > [data-gutter]")?.getBoundingClientRect().right;
+ const visibleLeft = gutterRight != null && gutterRight > codeRect.left ? gutterRight : codeRect.left;
+ const padding = 24;
+ let next = code.scrollLeft;
+ if (matchRect.left < visibleLeft + padding) next -= visibleLeft + padding - matchRect.left;
+ else if (matchRect.right > codeRect.right - padding) next += matchRect.right - (codeRect.right - padding);
+ else return;
+ item.instance.setCodeScrollLeft(Math.max(0, next));
+ }
+ /** Whether a line already sits fully below the sticky header and find panel. */
+ isScrollTargetInView(target) {
+ const rect = this.getScrollTargetRect(target);
+ if (rect == null) return false;
+ const top = this.getItemTopOffset() + rect.top;
+ const scrollTop = this.getScrollTop();
+ return top >= scrollTop + this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT && top + rect.height <= scrollTop + this.getHeight();
+ }
+ /** Pair each match with its mounted line element; rows outside the window are skipped. */
+ getMountedSearchMatches(entriesByItem) {
+ const mounted = [];
+ const { firstIndex, lastIndex } = this.renderState;
+ if (firstIndex === -1) return mounted;
+ for (let index = firstIndex; index <= lastIndex; index++) {
+ const itemEntries = entriesByItem.get(index);
+ const shadowRoot = itemEntries != null ? this.items[index]?.element?.shadowRoot : void 0;
+ if (shadowRoot == null) continue;
+ const lines = /* @__PURE__ */ new Map();
+ for (const element of shadowRoot.querySelectorAll("[data-line][data-line-index]")) {
+ const unifiedIndex = element.getAttribute("data-line-index").split(",")[0];
+ const column = element.closest("[data-deletions]") != null ? "deletions" : "additions";
+ lines.set(`${column}:${unifiedIndex}`, element);
+ }
+ for (const entry of itemEntries) {
+ // Split view draws unchanged context in both columns, so paint each copy.
+ const additions = lines.get(`additions:${entry.unifiedLineIndex}`);
+ const deletions = lines.get(`deletions:${entry.unifiedLineIndex}`);
+ const elements = entry.side === "deletions" ? [deletions ?? additions] : entry.isContext ? [additions, deletions] : [additions];
+ for (const element of elements) if (element != null) mounted.push({
+ entry,
+ element
+ });
+ }
+ }
+ return mounted;
+ }
/**
* Returns the editor while this item is being edited. Partial diffs get an
* editor after their files load; scrolling away or collapsing keeps it.
@@ -645,6 +785,7 @@ var CodeView = class CodeView {
this.render();
this.syncItemEditors();
this.syncSelection();
+ this.search.refresh();
return true;
}
updateItemId(oldId, newId) {
@@ -678,6 +819,7 @@ var CodeView = class CodeView {
this.renamePendingScrollTarget(oldId, newId);
this.renamePendingLayoutAnchor(oldId, newId);
this.render();
+ this.search.refresh();
return true;
}
addItem(input) {
@@ -687,6 +829,7 @@ var CodeView = class CodeView {
this.appendItemsInternal(inputs);
this.syncItemEditors();
this.syncSelection();
+ this.search.refresh();
}
removeItem(itemId) {
const item = this.idToItem.get(itemId);
@@ -709,6 +852,7 @@ var CodeView = class CodeView {
for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp();
}
this.syncSelection();
+ this.search.refresh();
}
/**
* Append new records to the viewer while preserving existing layout state.
@@ -1330,6 +1474,10 @@ var CodeView = class CodeView {
item.item = nextItem;
item.version = nextItem.version;
item.renderedOptionsRevision = -1;
+ const reveal = this.pendingSearchReveal;
+ if (reveal != null && reveal.id === nextItem.id && nextItem.collapsed !== true) queueMicrotask(() => {
+ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true);
+ });
return true;
}
getMaxScrollTopForHeight(scrollHeight) {
@@ -1710,6 +1858,10 @@ var CodeView = class CodeView {
this.updateStickyPositioning();
this.validateStickyContainerHeight();
this.fixContainerFocus();
+ if (updatedItems.size > 0) {
+ this.resumePendingSearchReveal(updatedItems);
+ this.search.repaint();
+ }
if (fitPerfectly || this.scrollAnimation != null) this.render();
};
flushManagers(updatedItems) {
@@ -1804,7 +1956,8 @@ var CodeView = class CodeView {
this.notifyScroll();
this.render();
};
- clearPendingScroll = () => {
+ clearPendingScroll = (event) => {
+ if (event != null && this.search.isOpen && this.search.ownsEvent(event)) return;
this.pendingScrollTarget = void 0;
this.pendingLayoutAnchor = void 0;
this.scrollAnimation = void 0;
@@ -2066,7 +2219,12 @@ var CodeView = class CodeView {
const item = this.items[index];
if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index");
item.top = runningTop;
- if (item.type === "diff") item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []);
+ if (item.type === "diff") {
+ const wasPartial = item.item.fileDiff.isPartial;
+ item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []);
+ // Hydration fills the diff's lines in place, so the search has new text to cover.
+ if (wasPartial && !item.item.fileDiff.isPartial) this.search.refresh(true);
+ }
else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []);
runningTop += item.height;
if (index < this.items.length - 1) runningTop += layout.gap;
@@ -2086,6 +2244,27 @@ var CodeView = class CodeView {
return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight;
}
};
+function getTextRangeRect(lineElement, start, end) {
+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT);
+ let consumed = 0;
+ let startNode;
+ let startOffset = 0;
+ let node;
+ while ((node = walker.nextNode()) != null) {
+ const length = node.data.length;
+ if (startNode == null && start < consumed + length) {
+ startNode = node;
+ startOffset = start - consumed;
+ }
+ if (startNode != null && end <= consumed + length) {
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(node, end - consumed);
+ return range.getBoundingClientRect();
+ }
+ consumed += length;
+ }
+}
function prepareItemInstance(item) {
item.instance.cleanUp(true);
if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []);
diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js
new file mode 100644
index 0000000..29370ad
--- /dev/null
+++ b/dist/components/CodeViewSearch.js
@@ -0,0 +1,360 @@
+import { isPrimaryModifier } from "../editor/platform.js";
+import { resolveFindAgainShortcut } from "../editor/command.js";
+import { SearchPanelWidget } from "../editor/searchPanel.js";
+import { SVGSpriteSheet } from "../editor/sprite.js";
+import editor_default from "../editor/editor2.js";
+import { iterateOverDiff } from "../utils/iterateOverDiff.js";
+//#region src/components/CodeViewSearch.ts
+const MAX_SEARCH_MATCHES = 1e5;
+const WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?";
+const SEARCH_HOST_ATTRIBUTE = "data-diffs-search";
+const SEARCH_PANEL_HOST_CSS = `:host {
+ display: block;
+ position: sticky;
+ z-index: 100;
+ height: 0;
+}
+[data-icon-sprite] {
+ position: absolute;
+}
+[data-search-panel] {
+ position: relative;
+ top: 8px;
+}
+[data-editor-widget] {
+ --diffs-bg: var(--diffs-search-panel-bg, Canvas);
+ --diffs-fg: var(--diffs-search-panel-fg, CanvasText);
+ --diffs-modified-base: var(--diffs-search-panel-accent, light-dark(#009fff, #69b1ff));
+ --diffs-header-font-fallback: var(--diffs-search-panel-font, system-ui, sans-serif);
+}`;
+let sharedHighlights;
+/**
+* Find-in-diff for a CodeView. It searches every diff item's line data rather
+* than the rendered rows, so matches in virtualized rows, expanded context, and
+* folded files are all found, and navigating to one reveals it.
+*
+* Matches are painted with the CSS Custom Highlight API. Ranges are rebuilt for
+* whichever rows a render leaves mounted, so highlighting never touches the
+* rendered DOM or the renderer's caches.
+*/
+var CodeViewSearch = class {
+ #view;
+ #host;
+ #widget;
+ #entries = [];
+ #entriesByItem = /* @__PURE__ */ new Map();
+ #current;
+ #revealHorizontally;
+ #ranges = [];
+ #searchedDiffs = [];
+ constructor(view) {
+ this.#view = view;
+ }
+ get isOpen() {
+ return this.#host != null;
+ }
+ /** Open the panel, or refocus it when it is already open. */
+ open() {
+ if (this.#widget != null) {
+ this.#widget.setMode("find");
+ return;
+ }
+ const root = this.#view.getContainerElement();
+ if (root == null) return;
+ const host = document.createElement("div");
+ host.setAttribute(SEARCH_HOST_ATTRIBUTE, "");
+ host.style.top = `${this.#view.getStickyHeaderOffset()}px`;
+ const shadowRoot = host.attachShadow({ mode: "open" });
+ const style = document.createElement("style");
+ style.textContent = `${editor_default}\n${SEARCH_PANEL_HOST_CSS}`;
+ const sprite = document.createElement("div");
+ sprite.innerHTML = SVGSpriteSheet;
+ const anchor = document.createElement("div");
+ shadowRoot.append(style, ...sprite.children, anchor);
+ root.prepend(host);
+ this.#host = host;
+ const defaultQuery = getSelectionQuery();
+ // The widget runs its first search a frame after opening, so callbacks from a
+ // panel that has since closed must not touch the current search.
+ const isLive = () => this.#widget === widget;
+ // Without a selection that first frame reports no matches; ignore it if a
+ // query typed before then has already found some.
+ let initializing = defaultQuery === "";
+ const widget = new SearchPanelWidget({
+ textDocument: { search: (params) => isLive() ? this.#search(params) : [] },
+ containerElement: anchor,
+ defaultQuery,
+ mode: "find",
+ allowReplace: false,
+ initialMatch: defaultQuery !== "" ? [0, 0] : void 0,
+ scrollToMatch: ([offset]) => {
+ if (isLive()) this.#goTo(offset);
+ },
+ applyReplace: () => {},
+ onUpdate: (matches, options) => {
+ if (!isLive()) return;
+ if (matches.length === 0) {
+ if (!initializing || this.#entries.length === 0) this.#setEntries([]);
+ return;
+ }
+ if (options?.syncSelection === false) {
+ this.#paint();
+ return this.#current != null ? matches[this.#current] : void 0;
+ }
+ const next = matches[this.#current ?? this.#view.getFirstVisibleSearchMatchIndex(this.#entries)];
+ if (next != null) this.#goTo(next[0]);
+ return next;
+ },
+ onClose: () => this.#teardown(true)
+ });
+ this.#widget = widget;
+ // Runs after the widget's own first-frame callback, which was scheduled first.
+ if (initializing) requestAnimationFrame(() => {
+ initializing = false;
+ });
+ widget.focus();
+ }
+ close() {
+ this.#widget?.close();
+ }
+ /**
+ * Rerun the query after the items change, keeping the current match when it
+ * survives. Updates that keep every diff (folding, annotations) only repaint;
+ * `force` covers diffs whose lines were filled in place.
+ */
+ refresh(force = false) {
+ if (this.#widget == null) return;
+ if (!force && haveSameDiffs(this.#searchedDiffs, this.#view.getSearchItems())) {
+ this.#paint();
+ return;
+ }
+ this.#widget.updateMatches({ syncSelection: false });
+ }
+ /** Repaint highlights for the rows the last render pass left mounted. */
+ repaint() {
+ if (this.#entries.length > 0) this.#paint();
+ }
+ /** Whether an event came from the panel, which handles its own keys and pointer input. */
+ ownsEvent(event) {
+ return event.composedPath().some((node) => node instanceof Element && node.hasAttribute(SEARCH_HOST_ATTRIBUTE));
+ }
+ handleKeyDown = (event) => {
+ if (event.isComposing || event.keyCode === 229) return;
+ // The panel's inputs handle their own keys; its buttons get the shortcuts below.
+ if (this.ownsEvent(event) && isEditableTarget(event)) {
+ if (event.key === "Escape") event.stopPropagation();
+ return;
+ }
+ if (event.defaultPrevented) return;
+ if (isPrimaryModifier(event) && !event.altKey && !event.shiftKey && (event.key.toLowerCase() === "f" || event.code === "KeyF")) {
+ event.preventDefault();
+ this.open();
+ return;
+ }
+ if (this.#widget == null) return;
+ const findAgain = resolveFindAgainShortcut(event);
+ if (findAgain != null) {
+ event.preventDefault();
+ this.#widget.navigate(findAgain === "previous");
+ } else if (event.key === "Escape" && !isEditableTarget(event)) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.close();
+ }
+ };
+ cleanUp() {
+ this.#teardown(false);
+ }
+ #teardown(restoreFocus) {
+ if (this.#host == null) return;
+ this.#widget?.cleanup();
+ this.#widget = void 0;
+ this.#host.remove();
+ this.#host = void 0;
+ this.#searchedDiffs = [];
+ this.#setEntries([]);
+ if (restoreFocus) this.#view.getContainerElement()?.focus({ preventScroll: true });
+ }
+ #search(params) {
+ const previous = this.#current != null ? this.#entries[this.#current] : void 0;
+ const items = this.#view.getSearchItems();
+ this.#searchedDiffs = items.map((item) => [item.id, item.fileDiff]);
+ this.#setEntries(collectMatches(items, params), previous);
+ return this.#entries.map((entry) => entry.range);
+ }
+ #setEntries(entries, previous) {
+ this.#entries = entries;
+ this.#entriesByItem = groupByItem(entries);
+ this.#current = previous != null ? findSameMatch(entries, previous) : void 0;
+ if (entries.length === 0) {
+ this.#view.cancelSearchReveal();
+ this.#paint();
+ }
+ }
+ #goTo(offset) {
+ const index = this.#entries.findIndex((entry) => entry.range[0] === offset);
+ if (index === -1) return;
+ this.#current = index;
+ this.#revealHorizontally = this.#entries[index];
+ this.#view.revealSearchMatch(this.#entries[index]);
+ this.#paint();
+ }
+ #paint() {
+ const highlights = getSharedHighlights();
+ if (highlights == null) return;
+ for (const range of this.#ranges) {
+ highlights.match.delete(range);
+ highlights.focus.delete(range);
+ }
+ this.#ranges = [];
+ const current = this.#current != null ? this.#entries[this.#current] : void 0;
+ let currentElement;
+ for (const { entry, element } of this.#view.getMountedSearchMatches(this.#entriesByItem)) {
+ const range = createTextRange(element, entry.start, entry.end);
+ if (range == null) continue;
+ (entry === current ? highlights.focus : highlights.match).add(range);
+ this.#ranges.push(range);
+ if (entry === current) currentElement ??= element;
+ }
+ if (current != null && currentElement != null && this.#revealHorizontally === current) {
+ this.#revealHorizontally = void 0;
+ this.#view.revealSearchMatchHorizontally(current, currentElement);
+ }
+ }
+};
+/**
+* Collect matches across diff items in render order. Each match gets a
+* synthetic, increasing offset so the panel's offset-based navigation works
+* across files; nothing else reads those offsets.
+*/
+function collectMatches(items, params) {
+ if (params.text === "" || params.text.includes("\n")) return [];
+ let pattern;
+ try {
+ pattern = new RegExp(params.regex ? params.text : escapeRegExp(params.text), params.caseSensitive ? "g" : "gi");
+ } catch {
+ return [];
+ }
+ const entries = [];
+ let offset = 0;
+ for (let itemIndex = 0; itemIndex < items.length && entries.length < MAX_SEARCH_MATCHES; itemIndex++) {
+ const item = items[itemIndex];
+ if (item.type !== "diff") continue;
+ const { fileDiff } = item;
+ iterateOverDiff({
+ diff: fileDiff,
+ diffStyle: "unified",
+ expandedHunks: true,
+ callback: ({ type, deletionLine, additionLine }) => {
+ const isDeletion = type === "change" && deletionLine != null;
+ const line = isDeletion ? deletionLine : additionLine;
+ const text = trimLineBreak((isDeletion ? fileDiff.deletionLines : fileDiff.additionLines)[line.lineIndex] ?? "");
+ pattern.lastIndex = 0;
+ let match;
+ while ((match = pattern.exec(text)) !== null) {
+ const length = match[0].length;
+ if (length === 0) {
+ pattern.lastIndex++;
+ continue;
+ }
+ if (params.wholeWord && !isWholeWord(text, match.index, length)) continue;
+ entries.push({
+ id: item.id,
+ itemIndex,
+ side: isDeletion ? "deletions" : "additions",
+ isContext: type !== "change",
+ lineNumber: line.lineNumber,
+ unifiedLineIndex: line.unifiedLineIndex,
+ start: match.index,
+ end: match.index + length,
+ range: [offset + match.index, offset + match.index + length]
+ });
+ if (entries.length >= MAX_SEARCH_MATCHES) return true;
+ }
+ offset += text.length + 1;
+ }
+ });
+ }
+ return entries;
+}
+function haveSameDiffs(searched, items) {
+ return searched.length === items.length && items.every((item, index) => item.id === searched[index][0] && item.fileDiff === searched[index][1]);
+}
+function groupByItem(entries) {
+ const byItem = /* @__PURE__ */ new Map();
+ for (const entry of entries) {
+ let list = byItem.get(entry.itemIndex);
+ if (list == null) byItem.set(entry.itemIndex, list = []);
+ list.push(entry);
+ }
+ return byItem;
+}
+function findSameMatch(entries, previous) {
+ const index = entries.findIndex((entry) => entry.id === previous.id && entry.side === previous.side && entry.lineNumber === previous.lineNumber && entry.start === previous.start);
+ return index === -1 ? void 0 : index;
+}
+function createTextRange(lineElement, start, end) {
+ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT);
+ let consumed = 0;
+ let startNode;
+ let startOffset = 0;
+ let node;
+ while ((node = walker.nextNode()) != null) {
+ const length = node.data.length;
+ if (startNode == null && start < consumed + length) {
+ startNode = node;
+ startOffset = start - consumed;
+ }
+ if (startNode != null && end <= consumed + length) {
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(node, end - consumed);
+ return range;
+ }
+ consumed += length;
+ }
+}
+function trimLineBreak(text) {
+ if (text.endsWith("\r\n")) return text.slice(0, -2);
+ if (text.endsWith("\n") || text.endsWith("\r")) return text.slice(0, -1);
+ return text;
+}
+function isWholeWord(text, start, length) {
+ return isSeparator(text, start - 1) && isSeparator(text, start + length);
+}
+function isSeparator(text, index) {
+ if (index < 0 || index >= text.length) return true;
+ const code = text.charCodeAt(index);
+ return code <= 32 || code === 127 || WORD_SEPARATORS.includes(text[index]);
+}
+function escapeRegExp(text) {
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+function isEditableTarget(event) {
+ const target = event.composedPath()[0];
+ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select"));
+}
+function getSelectionQuery() {
+ const text = document.getSelection()?.toString() ?? "";
+ return text.includes("\n") ? "" : text;
+}
+/**
+* Two registry entries shared by every CodeView on the page; each search adds
+* and removes only its own ranges. The `::highlight()` colours live in the core
+* stylesheet, because a highlight rule only styles text in its own tree.
+*/
+function getSharedHighlights() {
+ if (sharedHighlights != null) return sharedHighlights;
+ if (typeof CSS === "undefined" || !("highlights" in CSS) || typeof Highlight === "undefined") return;
+ sharedHighlights = {
+ match: new Highlight(),
+ focus: new Highlight()
+ };
+ CSS.highlights.set("diffs-search-match", sharedHighlights.match);
+ CSS.highlights.set("diffs-search-focus", sharedHighlights.focus);
+ return sharedHighlights;
+}
+//#endregion
+export { CodeViewSearch, collectMatches };
+
+//# sourceMappingURL=CodeViewSearch.js.map
diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts
index 5e68e35..79dcd88 100644
--- a/dist/components/VirtualizedFile.d.ts
+++ b/dist/components/VirtualizedFile.d.ts
@@ -49,7 +49,7 @@ declare class VirtualizedFile<LAnnotation = undefined, Caret = undefined> extend
setVisibility(visible: boolean): void;
rerender(): void;
syncGhostTextRows(): void;
- applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean): void;
+ applyDocumentChange(textDocument: TextDocument<'file', LAnnotation>, newLineAnnotations?: LineAnnotation<LAnnotation>[], shouldUpdateBuffer?: boolean, startLine?: number): void;
render({ fileContainer, file, forceRender, lineAnnotations, ...props }: FileRenderProps<LAnnotation>): boolean;
protected finalizeRender(): void;
private updatePendingRender;
diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js
index d1f57cd..5908ad7 100644
--- a/dist/components/VirtualizedFile.js
+++ b/dist/components/VirtualizedFile.js
@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File {
heights: /* @__PURE__ */ new Map(),
checkpoints: [],
fileAnnotationHeight: 0,
+ codeWidth: void 0,
ghostTextRows: NO_GHOST_TEXT_ROWS
};
pendingRender;
@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File {
super(options, workerManager, isContainerManaged);
this.virtualizer = virtualizer;
this.metrics = metrics;
+ const simpleVirtualizer = this.getSimpleVirtualizer();
+ if (simpleVirtualizer != null) this.resizeManager.onResize = () => simpleVirtualizer.requestHeightReconcile(this);
}
setMetrics(metrics, force = false) {
const nextMetrics = computeVirtualFileMetrics(metrics);
@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File {
if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead.");
super.setThemeType(themeType);
}
- resetLayoutCache(recompute = false, resetRenderRange = true) {
+ resetLayoutCache(recompute = false, resetRenderRange = true, startLine = 0) {
this.layoutDirty = true;
- this.cache.fileAnnotationHeight = 0;
- if (this.cache.heights.size > 0) this.cache.heights.clear();
+ if (startLine === 0) this.cache.fileAnnotationHeight = 0;
+ // Dropping unchanged wrapped rows moves the viewport before they can be remeasured.
+ if (startLine === 0) this.cache.heights.clear();
+ else for (const lineIndex of this.cache.heights.keys()) if (lineIndex >= startLine) this.cache.heights.delete(lineIndex);
if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0;
this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS;
if (this.renderRange != null && resetRenderRange) this.renderRange = void 0;
@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File {
if (this.code == null) return hasHeightChange;
const content = this.code.children[1];
if (!(content instanceof HTMLElement)) return hasHeightChange;
+ const codeWidth = this.code.getBoundingClientRect().width;
+ if (!(codeWidth > 0)) return hasHeightChange;
+ if (this.cache.codeWidth != null && this.cache.codeWidth !== codeWidth) {
+ this.resetLayoutCache(false, false);
+ hasHeightChange = true;
+ }
+ this.cache.codeWidth = codeWidth;
if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) {
const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0;
if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) {
@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File {
codeView.instanceChanged(this, true);
} else this.getSimpleVirtualizer()?.requestHeightReconcile(this);
}
- applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false) {
+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer = false, startLine = 0) {
const { renderRange: previousRenderRange } = this;
this.getAdvancedVirtualizer()?.capturePendingLayoutAnchor();
super.applyDocumentChange(textDocument, newLineAnnotations);
this.getSimpleVirtualizer()?.markDOMDirty();
- this.resetLayoutCache(this.isSimpleMode(), false);
+ this.resetLayoutCache(this.isSimpleMode(), false, startLine);
const file = this.getRenderedFile();
if (!this.isSimpleMode()) this.computeApproximateSize(true);
else if (shouldUpdateBuffer && previousRenderRange != null && file != null) {
diff --git a/dist/components/web-components.js b/dist/components/web-components.js
index 6e7b486..5b7290f 100644
--- a/dist/components/web-components.js
+++ b/dist/components/web-components.js
@@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js";
import style_default from "../style.js";
import { getMeasuredScrollbarGutter } from "../utils/scrollbarGutter.js";
//#region src/components/web-components.ts
+// `::highlight()` rules only style text in their own tree, so find's match colours ride
+// along with every container's shadow root.
+const SEARCH_HIGHLIGHT_CSS = `::highlight(diffs-search-match) {
+ background-color: var(--diffs-search-match-bg, light-dark(#ff963288, #ff963266));
+}
+::highlight(diffs-search-focus) {
+ background-color: var(--diffs-search-focus-bg, color-mix(in lab, var(--diffs-modified-base) 45%, transparent));
+}`;
if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") == null) {
let sheet;
+ let searchSheet;
class FileDiffContainer extends HTMLElement {
constructor() {
super();
@@ -12,8 +21,10 @@ if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container")
if (sheet == null) {
sheet = new CSSStyleSheet();
sheet.replaceSync(style_default);
+ searchSheet = new CSSStyleSheet();
+ searchSheet.replaceSync(SEARCH_HIGHLIGHT_CSS);
}
- shadowRoot.adoptedStyleSheets = [sheet];
+ shadowRoot.adoptedStyleSheets = [sheet, searchSheet];
}
connectedCallback() {
const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" });
diff --git a/dist/editor/editor.js b/dist/editor/editor.js
index 8f04a6f..c203023 100644
--- a/dist/editor/editor.js
+++ b/dist/editor/editor.js
@@ -1899,7 +1899,11 @@ var Editor = class {
changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0,
documentLineCount: textDocument.lineCount
});
- if (didLineCountChange) applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer);
+ if (didLineCountChange) {
+ // A wider or narrower line-number gutter can rewrap unchanged rows.
+ const layoutStartLine = String(change.previousLineCount).length === String(change.lineCount).length ? change.startLine : 0;
+ applyDocumentChange(textDocument, newLineAnnotations, shouldUpdateBuffer, layoutStartLine);
+ }
if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache();
if (newLineAnnotations != null) {
this.#lineAnnotations = newLineAnnotations;
diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts
index 9542849..1c1a51f 100644
--- a/dist/editor/searchPanel.d.ts
+++ b/dist/editor/searchPanel.d.ts
@@ -15,6 +15,8 @@ interface SearchPanelOptions {
containerElement: HTMLElement;
defaultQuery: string;
mode?: SearchPanelMode;
+ /** False keeps the panel in find mode, for hosts with nothing to replace into. */
+ allowReplace?: boolean;
initialMatch?: MatchRange;
scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void;
applyReplace: (edits: ResolvedTextEdit[]) => void;
diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js
index df1ba63..676751b 100644
--- a/dist/editor/searchPanel.js
+++ b/dist/editor/searchPanel.js
@@ -12,7 +12,7 @@ var SearchPanelWidget = class {
#navigate;
#close;
constructor(options) {
- const { textDocument, containerElement, defaultQuery, mode = "find", initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options;
+ const { textDocument, containerElement, defaultQuery, mode = "find", allowReplace = true, initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options;
const searchParams = {
text: defaultQuery,
replaceText: "",
@@ -278,7 +278,7 @@ var SearchPanelWidget = class {
]
});
const applyMode = (next) => {
- gridElement.dataset.mode = next;
+ gridElement.dataset.mode = allowReplace ? next : "find";
this.#inputElement.focus();
this.#inputElement.select();
};
diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts
index 3efafc4..9e5b751 100644
--- a/dist/managers/ResizeManager.d.ts
+++ b/dist/managers/ResizeManager.d.ts
@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions {
columnVariables?: ResizeManagerColumnVariableMode;
}
declare class ResizeManager {
+ /** Schedule owner measurement after an observed code or gutter size change. */
+ onResize?: () => void;
private static resizeObserver;
private static managersByElement;
private static getResizeObserver;
diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js
index 9724cb1..14145d8 100644
--- a/dist/managers/ResizeManager.js
+++ b/dist/managers/ResizeManager.js
@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager {
for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries);
}
observedNodes = /* @__PURE__ */ new Map();
+ onResize;
setup(pre, { disableAnnotations, columnVariables = "apply" }) {
const annotationUpdates = /* @__PURE__ */ new Set();
const applyColumnVariables = columnVariables === "apply";
@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager {
this.applyAnnotationUpdates(annotationUpdates);
annotationUpdates.clear();
this.applyColumnUpdates(codeUpdates);
+ if (codeUpdates.size > 0) this.onResize?.();
codeUpdates.clear();
}
applyAnnotationUpdates(annotationUpdates) {
diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts
index e78e713..642b235 100644
--- a/dist/react/CodeView.d.ts
+++ b/dist/react/CodeView.d.ts
@@ -56,6 +56,8 @@ interface CodeViewBaseProps<LAnnotation, Caret> {
* is frozen; re-key the accepted value in place before accepting.
*/
onItemEditComplete?: CodeViewItemEditCompleteHandler<LAnnotation, Caret>;
+ /** Unfold a collapsed item that holds the find match being navigated to. */
+ onRevealSearchMatch?(item: CodeViewItem<LAnnotation>): void;
renderCustomHeader?(item: CodeViewItem<LAnnotation>): ReactNode;
renderHeaderPrefix?(item: CodeViewItem<LAnnotation>): ReactNode;
renderHeaderFilenameSuffix?(item: CodeViewItem<LAnnotation>): ReactNode;
diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js
index 84c7915..d1930bf 100644
--- a/dist/react/CodeView.js
+++ b/dist/react/CodeView.js
@@ -24,7 +24,7 @@ function createDefaultCache(controlled) {
};
}
function CodeViewInner(props, ref) {
- 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;
+ 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;
const controlled = controlledItems !== void 0;
const contextCreateEditor = useCreateEditor();
const poolManager = useContext(WorkerPoolContext);
@@ -52,6 +52,9 @@ function CodeViewInner(props, ref) {
onItemEditChange?.(event, item);
});
const emitItemEditComplete = useStableCallback((event, item, nextItem) => onItemEditComplete?.(event, item, nextItem) ?? "reject");
+ const emitRevealSearchMatch = useStableCallback((item) => {
+ onRevealSearchMatch?.(item);
+ });
const managedOptions = useMemo(() => createManagedCodeViewOptions({
options,
hasCustomHeader,
@@ -63,13 +66,15 @@ function CodeViewInner(props, ref) {
getEditStateKey,
createEditor: contextCreateEditor != null ? createEditor : void 0,
onItemEditChange: onItemEditChange != null ? emitItemEditChange : void 0,
- onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0
+ onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0,
+ onRevealSearchMatch: onRevealSearchMatch != null ? emitRevealSearchMatch : void 0
}), [
contextCreateEditor,
controlledSelection,
createEditor,
emitItemEditChange,
emitItemEditComplete,
+ emitRevealSearchMatch,
emitSelectedLinesChange,
hasCodeViewFooter,
hasCodeViewHeader,
@@ -78,6 +83,7 @@ function CodeViewInner(props, ref) {
getEditStateKey,
onItemEditChange,
onItemEditComplete,
+ onRevealSearchMatch,
onSelectedLinesChange,
options
]);
@@ -302,7 +308,7 @@ function createSlotContentStore() {
}
};
}
-function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete }) {
+function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, onRevealSearchMatch }) {
const managedOptions = {
...options,
controlledSelection,
@@ -312,6 +318,7 @@ function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRende
if (getEditStateKey != null) managedOptions.getEditStateKey = getEditStateKey;
if (onItemEditChange != null) managedOptions.onItemEditChange = onItemEditChange;
if (onItemEditComplete != null) managedOptions.onItemEditComplete = onItemEditComplete;
+ if (onRevealSearchMatch != null) managedOptions.onRevealSearchMatch = onRevealSearchMatch;
if (hasCustomHeader) managedOptions.renderCustomHeader = noopRender;
if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender;
if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender;
diff --git a/package.json b/package.json
index d9dc7b5..3c37bae 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,18 @@
"./worker/worker-portable.js": {
"types": "./dist/worker/worker-portable.d.ts",
"import": "./dist/worker/worker-portable.js"
+ },
+ "./types": {
+ "types": "./dist/types.d.ts",
+ "import": "./dist/types.js"
+ },
+ "./utils/getFiletypeFromFileName": {
+ "types": "./dist/utils/getFiletypeFromFileName.d.ts",
+ "import": "./dist/utils/getFiletypeFromFileName.js"
+ },
+ "./utils/parsePatchFiles": {
+ "types": "./dist/utils/parsePatchFiles.d.ts",
+ "import": "./dist/utils/parsePatchFiles.js"
}
},
"publishConfig": {