| ... | ... | @@ -1,7 +1,704 @@ |
| 1 | diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts |
| 2 | index 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. |
| 32 | diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js |
| 33 | index 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 ?? []); |
| 331 | diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js |
| 332 | new file mode 100644 |
| 333 | index 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 |
| 1 | 697 | diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts |
| 698 | index 5e68e35..79dcd88 100644 |
| 2 | 699 | --- a/dist/components/VirtualizedFile.d.ts |
| 3 | 700 | +++ b/dist/components/VirtualizedFile.d.ts |
| 4 | | @@ -49,7 +49,7 @@ |
| 701 | @@ -49,7 +49,7 @@ declare class VirtualizedFile<LAnnotation = undefined, Caret = undefined> extend |
| 5 | 702 | setVisibility(visible: boolean): void; |
| 6 | 703 | rerender(): void; |
| 7 | 704 | syncGhostTextRows(): void; |
| ... | ... | @@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF |
| 11 | 708 | protected finalizeRender(): void; |
| 12 | 709 | private updatePendingRender; |
| 13 | 710 | diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js |
| 711 | index d1f57cd..5908ad7 100644 |
| 14 | 712 | --- a/dist/components/VirtualizedFile.js |
| 15 | 713 | +++ b/dist/components/VirtualizedFile.js |
| 16 | | @@ -23,6 +23,7 @@ |
| 714 | @@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { |
| 17 | 715 | 		heights: /* @__PURE__ */ new Map(), |
| 18 | 716 | 		checkpoints: [], |
| 19 | 717 | 		fileAnnotationHeight: 0, |
| ... | ... | @@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil |
| 21 | 719 | 		ghostTextRows: NO_GHOST_TEXT_ROWS |
| 22 | 720 | 	}; |
| 23 | 721 | 	pendingRender; |
| 24 | | @@ -35,6 +36,8 @@ |
| 722 | @@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { |
| 25 | 723 | 		super(options, workerManager, isContainerManaged); |
| 26 | 724 | 		this.virtualizer = virtualizer; |
| 27 | 725 | 		this.metrics = metrics; |
| ... | ... | @@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil |
| 30 | 728 | 	} |
| 31 | 729 | 	setMetrics(metrics, force = false) { |
| 32 | 730 | 		const nextMetrics = computeVirtualFileMetrics(metrics); |
| 33 | | @@ -86,10 +89,12 @@ |
| 731 | @@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { |
| 34 | 732 | 		if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); |
| 35 | 733 | 		super.setThemeType(themeType); |
| 36 | 734 | 	} |
| ... | ... | @@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil |
| 46 | 744 | 		if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; |
| 47 | 745 | 		this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; |
| 48 | 746 | 		if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; |
| 49 | | @@ -135,6 +140,13 @@ |
| 747 | @@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { |
| 50 | 748 | 		if (this.code == null) return hasHeightChange; |
| 51 | 749 | 		const content = this.code.children[1]; |
| 52 | 750 | 		if (!(content instanceof HTMLElement)) return hasHeightChange; |
| ... | ... | @@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil |
| 60 | 758 | 		if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { |
| 61 | 759 | 			const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; |
| 62 | 760 | 			if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { |
| 63 | | @@ -358,12 +370,12 @@ |
| 761 | @@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { |
| 64 | 762 | 			codeView.instanceChanged(this, true); |
| 65 | 763 | 		} else this.getSimpleVirtualizer()?.requestHeightReconcile(this); |
| 66 | 764 | 	} |
| ... | ... | @@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil |
| 75 | 773 | 		const file = this.getRenderedFile(); |
| 76 | 774 | 		if (!this.isSimpleMode()) this.computeApproximateSize(true); |
| 77 | 775 | 		else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { |
| 776 | diff --git a/dist/components/web-components.js b/dist/components/web-components.js |
| 777 | index 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" }); |
| 78 | 810 | diff --git a/dist/editor/editor.js b/dist/editor/editor.js |
| 811 | index 8f04a6f..c203023 100644 |
| 79 | 812 | --- a/dist/editor/editor.js |
| 80 | 813 | +++ b/dist/editor/editor.js |
| 81 | | @@ -1899,7 +1899,11 @@ |
| 814 | @@ -1899,7 +1899,11 @@ var Editor = class { |
| 82 | 815 | 			changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, |
| 83 | 816 | 			documentLineCount: textDocument.lineCount |
| 84 | 817 | 		}); |
| ... | ... | @@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js |
| 91 | 824 | 		if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); |
| 92 | 825 | 		if (newLineAnnotations != null) { |
| 93 | 826 | 			this.#lineAnnotations = newLineAnnotations; |
| 827 | diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts |
| 828 | index 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; |
| 840 | diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js |
| 841 | index 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 | 		}; |
| 94 | 862 | diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts |
| 863 | index 3efafc4..9e5b751 100644 |
| 95 | 864 | --- a/dist/managers/ResizeManager.d.ts |
| 96 | 865 | +++ b/dist/managers/ResizeManager.d.ts |
| 97 | | @@ -5,6 +5,8 @@ |
| 866 | @@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions { |
| 98 | 867 | columnVariables?: ResizeManagerColumnVariableMode; |
| 99 | 868 | } |
| 100 | 869 | declare class ResizeManager { |
| ... | ... | @@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts |
| 104 | 873 | private static managersByElement; |
| 105 | 874 | private static getResizeObserver; |
| 106 | 875 | diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js |
| 876 | index 9724cb1..14145d8 100644 |
| 107 | 877 | --- a/dist/managers/ResizeManager.js |
| 108 | 878 | +++ b/dist/managers/ResizeManager.js |
| 109 | | @@ -19,6 +19,7 @@ |
| 879 | @@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager { |
| 110 | 880 | 		for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); |
| 111 | 881 | 	} |
| 112 | 882 | 	observedNodes = /* @__PURE__ */ new Map(); |
| ... | ... | @@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js |
| 114 | 884 | 	setup(pre, { disableAnnotations, columnVariables = "apply" }) { |
| 115 | 885 | 		const annotationUpdates = /* @__PURE__ */ new Set(); |
| 116 | 886 | 		const applyColumnVariables = columnVariables === "apply"; |
| 117 | | @@ -212,6 +213,7 @@ |
| 887 | @@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager { |
| 118 | 888 | 		this.applyAnnotationUpdates(annotationUpdates); |
| 119 | 889 | 		annotationUpdates.clear(); |
| 120 | 890 | 		this.applyColumnUpdates(codeUpdates); |
| ... | ... | @@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js |
| 122 | 892 | 		codeUpdates.clear(); |
| 123 | 893 | 	} |
| 124 | 894 | 	applyAnnotationUpdates(annotationUpdates) { |
| 895 | diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts |
| 896 | index 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; |
| 908 | diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js |
| 909 | index 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; |
| 125 | 973 | diff --git a/package.json b/package.json |
| 974 | index d9dc7b5..3c37bae 100644 |
| 126 | 975 | --- a/package.json |
| 127 | 976 | +++ b/package.json |
| 128 | 977 | @@ -58,6 +58,18 @@ |