Add :find completions back

Also improve perf:

- index headings for breadcrumbs

- don't clone ranges to make highlights

- fiddle with debounce
This commit is contained in:
Oliver Blanthorn 2026-07-27 13:43:15 +02:00
parent e8d85058f7
commit b5ba02e695
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
6 changed files with 357 additions and 43 deletions

View file

@ -114,6 +114,7 @@ function resizeArea() {
focus()
}
}
window.addEventListener("tridactyl-refresh-completions", resizeArea)
/** @hidden
* This is a bit loosely defined at the moment.

View file

@ -2,39 +2,160 @@ import * as Completions from "../completions"
import * as Messaging from "@src/lib/messaging"
import { ownTabId } from "@src/lib/webext"
class FindCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys
constructor(index: number, match, args: string) {
super()
this.value = [`--jump-to ${index}`, args].filter(Boolean).join(" ")
this.fuseKeys = [match.text, match.precontext, match.postcontext, match.breadcrumbs]
this.html = html`<tr class="FindCompletionOption option">
<td class="breadcrumbs">${match.breadcrumbs}</td>
<td class="content">
${match.precontext}<span class="match">${match.text}</span
>${match.postcontext}
</td>
<td class="position">${match.position}</td>
</tr>`
}
}
export class FindCompletionSource extends Completions.CompletionSourceFuse {
public options = []
public options: FindCompletionOption[] = []
private session = Math.random()
private tabId = ownTabId()
private active = false
private request = 0
private findArgs = ""
private pending: Promise<void> = Promise.resolve()
private selection: Promise<void> = Promise.resolve()
constructor(_parent?) {
super(["find"], "FindCompletionSource")
super(
["find"],
"FindCompletionSource",
html`<table><tr>
<td class="breadcrumbs">Breadcrumbs</td>
<td class="content">Context</td>
<td class="position">Position</td>
</tr></table>`,
)
}
filter(exstr: string) {
if (exstr === this.lastExstr && this.completion) return Promise.resolve()
this.lastExstr = exstr
const [, argstr] = this.splitOnPrefix(exstr)
if (argstr === undefined) return this.cancel()
const request = ++this.request
this.findArgs = argstr.trim()
this.active = true
void this.send(
{ session: this.session },
...argstr.trim().split(/\s+/),
).catch(() => undefined)
this.options = []
this.optionContainer.replaceChildren()
this.state = "hidden"
const optionArgs = this.findArgs.split(/(?:^|\s)--(?:\s|$)/, 1)[0]
if (/(^|\s)(--jump-to|-:)=?$/.test(optionArgs)) return this.cancel()
const hasJump = /(^|\s)(--jump-to(?:=|\s)|-:(?:\s|$))/.test(optionArgs)
const regex = /(^|\s)(-r|--regex)(?=\s|$)/.test(optionArgs)
const delay = regex
? new Promise<void>(resolve => window.setTimeout(resolve, 250))
: Promise.resolve()
this.pending = delay
.then(() => {
if (request !== this.request) return
return this.send(
{ session: this.session, completions: !hasJump },
...this.findArgs.split(/\s+/),
)
})
.then(matches => {
if (request !== this.request) return
this.options = hasJump
? []
: (matches || []).map(
match =>
new FindCompletionOption(
match.index,
match,
this.findArgs,
),
)
if (this.options.length) this.updateChain(exstr, this.options)
else this.state = "hidden"
this.resize()
})
.catch(() => {
if (request !== this.request) return
this.options = []
this.state = "hidden"
this.resize()
return this.send({ session: this.session, cancel: true })
})
return Promise.resolve()
}
setStateFromScore() {
this.options.forEach(option => (option.state = "normal"))
this.deselect()
}
scoredOptions() {
return []
}
updateDisplay() {
this.optionContainer.replaceChildren(
...this.options
.filter(option => option.state !== "hidden")
.map(option => option.html),
)
this.next(0)
}
select(option: FindCompletionOption) {
super.select(option)
this.selection = this.preview(option.value, true)
}
async next(inc = 1) {
if (!this.active) return false
const pending = this.pending
if (inc !== 0) await this.pending
if (!this.active || pending !== this.pending) return false
const moved = await super.next(inc)
if (inc !== 0 && moved) {
if (!this.completion) this.selection = this.preview(this.findArgs)
await this.selection
}
return moved
}
destroy() {
return this.cancel()
}
private cancel() {
if (!this.active) return Promise.resolve()
++this.request
this.active = false
this.options = []
this.state = "hidden"
return this.send({ session: this.session, cancel: true }).catch(
() => undefined,
)
}
private preview(args: string, selected = false) {
return this.send(
{ session: this.session, completions: false, selected },
...args.split(/\s+/),
)
.then(() => undefined)
.catch(() => undefined)
}
private send(preview, ...args: string[]) {
return this.tabId.then(tabId =>
Messaging.messageTab(tabId, "excmd_content", "find", [
@ -43,4 +164,8 @@ export class FindCompletionSource extends Completions.CompletionSourceFuse {
]),
)
}
private resize() {
window.dispatchEvent(new Event("tridactyl-refresh-completions"))
}
}

View file

@ -28,12 +28,10 @@ const NATIVE_HIGHLIGHTS = typeof Highlight === "function" && "highlights" in CSS
class FindHighlight extends HTMLSpanElement {
public top = Infinity
public nativeRange: Range
private background = `var(--tridactyl-search-highlight-color)`
constructor(public range: Range) {
super()
this.nativeRange = NATIVE_HIGHLIGHTS ? range.cloneRange() : range
{
// https://bugzilla.mozilla.org/show_bug.cgi?id=1716685
const proto = FindHighlight.prototype
@ -44,7 +42,9 @@ class FindHighlight extends HTMLSpanElement {
this.style.position = "absolute"
this.style.top = "0px"
this.style.left = "0px"
this.updateRectsPosition()
const rects = this.getClientRects()
if (!rects.length) throw new Error("Range has no rects")
this.updateRectsPosition(rects)
;(this as any).unfocus()
}
@ -52,17 +52,15 @@ class FindHighlight extends HTMLSpanElement {
const range = allTextNode[0].ownerDocument.createRange()
range.setStart(allTextNode[found.startTextNodePos], found.startOffset)
range.setEnd(allTextNode[found.endTextNodePos], found.endOffset)
if (range.getClientRects().length < 1)
throw new Error("Range has no rects")
return new this(range)
}
updateRectsPosition() {
updateRectsPosition(rects = this.getClientRects()) {
if (NATIVE_HIGHLIGHTS) {
this.top = this.getBoundingClientRect().top + window.pageYOffset
this.top = Array.from(rects).reduce((top, rect) =>
rect.width || rect.height ? Math.min(top, rect.top) : top, Infinity) + window.pageYOffset
return
}
const rects = this.getClientRects()
this.top = Infinity
const windowTop = window.pageYOffset
const windowLeft = window.pageXOffset
@ -99,7 +97,7 @@ class FindHighlight extends HTMLSpanElement {
return this.range.getClientRects()
}
unfocus() {
setNativeFocus(this.nativeRange, false)
setNativeFocus(this.range, false)
this.background = `var(--tridactyl-search-highlight-color)`
for (const node of this.children) {
;(node as HTMLElement).style.background = this.background
@ -140,7 +138,7 @@ class FindHighlight extends HTMLSpanElement {
}
const focusable = this.queryInRange("a,input,button,details")
if (focusElement && focusable) focusable.focus()
setNativeFocus(this.nativeRange, true)
setNativeFocus(this.range, true)
this.background = `var(--tridactyl-search-highlight-active-color)`
for (const node of this.children) {
const element = node as HTMLElement
@ -228,6 +226,8 @@ let lastHighlights
let selected = 0
let preview
let searchGeneration = 0
let regexSnapshots
let regexSnapshotObservers: MutationObserver[] = []
let HIGHLIGHT_TIMER
let REPOSITION_TIMER
@ -248,14 +248,71 @@ function scheduleReposition() {
}
window.addEventListener("resize", scheduleReposition)
window.addEventListener("resize", clearRegexSnapshots)
window.addEventListener("scroll", scheduleReposition, true)
function clearRegexSnapshots() {
regexSnapshots = undefined
regexSnapshotObservers.splice(0).forEach(observer => observer.disconnect())
}
async function yieldFind(generation) {
await new Promise(resolve => setTimeout(resolve))
return generation !== searchGeneration
}
function getRegexSnapshots(documents, cache: boolean) {
if (cache && regexSnapshots?.length === documents.length &&
regexSnapshots.every((snapshot, index) => snapshot.doc === documents[index])) return regexSnapshots
if (!NATIVE_HIGHLIGHTS) getFindHost()
const snapshots = documents.map(doc => {
if (!doc) return { doc, nodes: [], lengths: [], text: "" }
const painted = new WeakMap<HTMLElement, boolean>()
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
const nodes = []
const lengths = []
const parts = []
while (walker.nextNode()) {
const node = walker.currentNode as Text
const parent = node.parentElement
if (!painted.has(parent)) painted.set(parent, DOM.isPainted(parent))
if (painted.get(parent)) {
nodes.push(node)
lengths.push(node.length)
parts.push(node.data)
}
}
return { doc, nodes, lengths, text: parts.join("") }
})
if (cache) {
clearRegexSnapshots()
regexSnapshots = snapshots
regexSnapshotObservers = documents.filter(Boolean).map(doc => {
const observer = new MutationObserver(changes => {
if (changes.some(({ target }) =>
!(target as Element).closest?.("#cmdline_iframe,#TridactylFindHost")))
clearRegexSnapshots()
})
observer.observe(doc, {
attributes: true,
childList: true,
characterData: true,
subtree: true,
})
return observer
})
}
return snapshots
}
export async function jumpToMatch(searchQuery, option) {
const previewing = option["preview"] === true
const generation = ++searchGeneration
if (!previewing) preview = undefined
if (previewing) clearTimeout(HIGHLIGHT_TIMER)
else resetHighlightTimer()
if (!previewing) {
preview = undefined
clearRegexSnapshots()
}
clearTimeout(HIGHLIGHT_TIMER)
// First, search for the query
const literal = option["regex"] && searchQuery.match(/^\/(.*)\/([^/]*)$/s)
let [source, flags] = literal ? literal.slice(1) : [searchQuery, ""]
@ -285,35 +342,40 @@ export async function jumpToMatch(searchQuery, option) {
lastHighlights = []
clearHighlighting()
const documents = [document]
for (const frame of DOM.getAllDocumentFrames())
if (frame.contentDocument) documents.push(frame.contentDocument)
const nodeSets = documents.map(doc => {
const documents = [document, ...DOM.getAllDocumentFrames().map(frame => frame.contentDocument)]
const snapshots = regex && getRegexSnapshots(documents, previewing)
const nodeSets = regex ? [] : documents.map(doc => {
if (!doc) return []
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
const nodes = []
while (walker.nextNode()) nodes.push(walker.currentNode)
return regex ? nodes.filter(n => DOM.isPainted(n.parentElement)) : nodes
return nodes
})
if (regex) {
for (const nodes of nodeSets) {
const found = []
let converted = 0
for (const { nodes, lengths, text } of snapshots) {
let nodeIndex = 0
let nodeOffset = 0
const text = nodes.map(node => node.data).join("")
for (const match of text.matchAll(regex)) {
if (++converted % 100 === 0 && await yieldFind(generation)) return
if (!match[0]) continue
const end = match.index + match[0].length
while (match.index >= nodeOffset + nodes[nodeIndex].length)
nodeOffset += nodes[nodeIndex++].length
const range = nodes[0].ownerDocument.createRange()
range.setStart(nodes[nodeIndex], match.index - nodeOffset)
while (end > nodeOffset + nodes[nodeIndex].length)
nodeOffset += nodes[nodeIndex++].length
range.setEnd(nodes[nodeIndex], end - nodeOffset)
if (range.getClientRects().length)
lastHighlights.push(new FindHighlight(range))
try {
const end = match.index + match[0].length
while (match.index >= nodeOffset + lengths[nodeIndex])
nodeOffset += lengths[nodeIndex++]
const range = nodes[0].ownerDocument.createRange()
range.setStart(nodes[nodeIndex], match.index - nodeOffset)
while (end > nodeOffset + lengths[nodeIndex])
nodeOffset += lengths[nodeIndex++]
range.setEnd(nodes[nodeIndex], end - nodeOffset)
if (range.toString() !== match[0]) continue
found.push(new FindHighlight(range))
} catch (_) {}
}
}
lastHighlights = found
}
for (let i = 0; i < results.count; ++i) {
const range = results.rangeData[i]
@ -329,6 +391,7 @@ export async function jumpToMatch(searchQuery, option) {
throw new Error("Pattern not found: " + searchQuery)
}
drawHighlights(lastHighlights)
if (!previewing) resetHighlightTimer()
lastHighlights.sort(
option["reverse"] ? (a, b) => b.top - a.top : (a, b) => a.top - b.top,
)
@ -355,7 +418,7 @@ function drawHighlights(highlights) {
const doc = highlights[0].range.startContainer.ownerDocument
const win: any = doc.defaultView
const normal = new win.Highlight()
highlights.forEach(highlight => normal.add(highlight.nativeRange))
highlights.forEach(highlight => normal.add(highlight.range))
const active = new win.Highlight()
nativeRegistry = win.CSS.highlights
normal.priority = 2147483646
@ -381,7 +444,103 @@ function restorePreviewScrolls(snapshot = preview) {
element.scrollTo({ left, top, behavior: "instant" })
}
export async function previewMatch(session: number, searchQuery, option) {
function truncateFindContext(text: string, length: number, before: boolean) {
if (length < 1 || text.length <= length) return text.slice(0, length)
if (before) {
let start = text.length - length
while (start > 0 && !/[\s.]/.test(text[start - 1])) --start
return text.slice(start)
}
let end = length
while (end < text.length && !/[\s.]/.test(text[end])) ++end
if (text[end] === ".") ++end
return text.slice(0, end)
}
async function findCompletionMatches(generation) {
const limit = config.get("findresults")
const contextLength = Math.max(0, config.get("findcontextlen"))
const highlights =
limit < 0 ? lastHighlights : lastHighlights.slice(0, limit)
const matches = []
const headingIndexes = new Map()
for (let index = 0; index < highlights.length; ++index) {
const highlight = highlights[index]
const range = highlight.range
const doc: Document = range.startContainer.ownerDocument
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_TEXT)
walker.currentNode = range.startContainer
let precontext = (range.startContainer as Text).data.slice(
0,
range.startOffset,
)
while (precontext.length < contextLength && walker.previousNode())
precontext = (walker.currentNode as Text).data + precontext
const preTruncated =
contextLength > 0 &&
(precontext.length > contextLength || !!walker.previousNode())
walker.currentNode = range.endContainer
let postcontext = (range.endContainer as Text).data.slice(
range.endOffset,
)
while (postcontext.length < contextLength && walker.nextNode())
postcontext += (walker.currentNode as Text).data
const postTruncated =
contextLength > 0 &&
(postcontext.length > contextLength || !!walker.nextNode())
precontext = truncateFindContext(precontext, contextLength, true)
postcontext = truncateFindContext(postcontext, contextLength, false)
let headingIndex = headingIndexes.get(doc)
if (!headingIndex) {
const headings = Array.from(doc.querySelectorAll("h1,h2,h3,h4,h5,h6"))
const hierarchy = []
const breadcrumbs = new Map()
for (const heading of headings) {
hierarchy.length = Number(heading.tagName[1]) - 1
hierarchy.push(heading.textContent?.replace(/\s+/g, " ").trim())
breadcrumbs.set(heading, hierarchy.filter(Boolean).join(" > "))
}
headingIndex = { headings, breadcrumbs }
headingIndexes.set(doc, headingIndex)
}
const parent = (range.startContainer as Text).parentElement
let heading = parent.closest("h1,h2,h3,h4,h5,h6")
let start = 0
let end = headingIndex.headings.length
while (!heading && start < end) {
const middle = Math.floor((start + end) / 2)
if (
headingIndex.headings[middle].compareDocumentPosition(
range.startContainer,
) === Node.DOCUMENT_POSITION_FOLLOWING
)
start = middle + 1
else end = middle
}
heading ||= headingIndex.headings[start - 1]
const root = doc.scrollingElement || doc.documentElement
const top = range.getBoundingClientRect().top + doc.defaultView.scrollY
const percent = Math.round((top / Math.max(1, root.scrollHeight)) * 100)
matches.push({
index,
text: range.toString(),
precontext: (preTruncated ? "..." : "") + precontext,
postcontext: postcontext + (postTruncated ? "..." : ""),
breadcrumbs: headingIndex.breadcrumbs.get(heading) || "",
position: `${Math.min(100, Math.max(0, percent))}%`,
})
if (index % 100 === 99 && await yieldFind(generation)) return []
}
return matches
}
export async function previewMatch(
session: number,
searchQuery,
option,
completions = true,
keepScroll = false,
) {
if (preview?.session !== session) {
if (preview) cancelPreview(preview.session)
preview = {
@ -392,22 +551,32 @@ export async function previewMatch(session: number, searchQuery, option) {
scrolls: new Map(),
}
}
restorePreviewScrolls()
if (keepScroll && lastHighlights?.length && "jumpTo" in option) {
lastHighlights[selected].unfocus()
selected = (option["jumpTo"] + lastHighlights.length) % lastHighlights.length
return focusHighlight(selected, false)
}
if (!keepScroll) restorePreviewScrolls()
const generation = searchGeneration + 1
try {
await jumpToMatch(searchQuery, { ...option, preview: true })
if (preview?.session !== session || generation !== searchGeneration)
return []
return completions ? await findCompletionMatches(generation) : []
} catch (_) {
if (preview?.session === session && generation === searchGeneration) {
clearHighlighting()
lastHighlights = []
restorePreviewScrolls()
}
return []
}
}
export function cancelPreview(session: number) {
if (preview?.session !== session) return
++searchGeneration
clearRegexSnapshots()
clearHighlighting()
const snapshot = preview
preview = undefined
@ -502,5 +671,5 @@ export async function jumpToNextMatch(n: number, searchFromView = false) {
}
export function currentMatchRange(): Range {
return lastHighlights[selected].range
return lastHighlights[selected].range.cloneRange()
}

View file

@ -1524,6 +1524,8 @@ export function scrollpage(n = 1, count = 1) {
* The behavior of this function is affected by the following setting:
*
* `findcase`: either "smart", "sensitive" or "insensitive". If "smart", find will be case-sensitive if the pattern contains uppercase letters.
* `findresults`: maximum completion rows to show; `-1` is unlimited and `0` disables them.
* `findcontextlen`: number of context characters to show around each completion.
*
* Known bugs: find will currently happily jump to a non-visible element, and pressing n or N without having searched for anything will cause an error.
*/
@ -1567,8 +1569,9 @@ export function find(...args: string[]) {
if (preview) {
const { session } = preview
if (preview.cancel) return finding.cancelPreview(session)
const completions = preview.completions !== false && config.get("findresults") !== 0
if (config.get("incsearch") === "true" && searchQuery.length > 0)
return finding.previewMatch(session, searchQuery, option)
return finding.previewMatch(session, searchQuery, option, completions, preview.selected === true)
return finding.cancelPreview(session)
}
return finding.jumpToMatch(searchQuery, option)

View file

@ -46,7 +46,9 @@ export function getCommandlineFns(cmdline_state: {
"current_cmdline",
"cmdline_filter",
)
cmdline_state.activeCompletions?.forEach(comp => comp.next(count))
await Promise.all(
cmdline_state.activeCompletions?.map(comp => comp.next(count)) || [],
)
},
/** Selects the next completion, or history line if none is selected. */
@ -65,7 +67,9 @@ export function getCommandlineFns(cmdline_state: {
"current_cmdline",
"cmdline_filter",
)
cmdline_state.activeCompletions?.forEach(comp => comp.prev(count))
await Promise.all(
cmdline_state.activeCompletions?.map(comp => comp.prev(count)) || [],
)
},
/** Selects the previous completion, or history line if none is selected. */

View file

@ -164,6 +164,18 @@ a.url:hover {
font-weight: bold;
}
.FindCompletionSource .breadcrumbs {
width: 25%;
padding: 0 0.75em;
text-overflow: ellipsis;
}
.FindCompletionSource .position {
width: 6em;
padding-right: 0.75em;
text-align: right;
}
.hidden {
display: none;
}