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