Restore incsearch

This commit is contained in:
Oliver Blanthorn 2026-07-26 23:47:53 +02:00
parent 0ab3f5bf62
commit 475ae04e79
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
6 changed files with 191 additions and 160 deletions

View file

@ -33,6 +33,7 @@ import { DialogCompletionSource } from "@src/completions/Dialog"
import { ExcmdCompletionSource } from "@src/completions/Excmd"
import { ExtensionsCompletionSource } from "@src/completions/Extensions"
import { FileSystemCompletionSource } from "@src/completions/FileSystem"
import { FindCompletionSource } from "@src/completions/Find"
import { GotoCompletionSource } from "@src/completions/Goto"
import { GuisetCompletionSource } from "@src/completions/Guiset"
import { GlossaryCompletionSource } from "@src/completions/Glossary"
@ -139,7 +140,7 @@ export function enableCompletions() {
if (!commandline_state.activeCompletions) {
commandline_state.activeCompletions = [
AutocmdCompletionSource,
// FindCompletionSource,
FindCompletionSource,
BindingsCompletionSource,
BmarkCompletionSource,
BookmarkFolderCompletionSource,
@ -342,7 +343,10 @@ commandline_state.clInput.addEventListener(
let refreshQueue: Promise<unknown> = Promise.resolve()
export function refresh_completions(exstr) {
const result = refreshQueue.then(() => refreshCompletions(exstr))
const session = commandSession
const result = refreshQueue.then(() =>
session === commandSession ? refreshCompletions(exstr) : undefined,
)
refreshQueue = result.catch(() => undefined)
return result
}
@ -366,32 +370,51 @@ function refreshCompletions(exstr) {
/** @hidden **/
let onInputPromise: Promise<void | void[]> = Promise.resolve()
const COMPLETION_THROTTLE_MS = 100
let completionTimer
let lastCompletionStarted = -Infinity
/** @hidden **/
commandline_state.clInput.addEventListener("input", () => {
logger.debug("commandline_frame clInput input event listener")
clInputValueChanged()
})
/** @hidden **/
async function updateCompletions(exstr: string, session = commandSession) {
lastCompletionStarted = performance.now()
await onInputPromise
if (session !== commandSession) return
if (exstr !== commandline_state.clInput.value) {
contentState.cmdline_filter = exstr
return
}
onInputPromise = refresh_completions(exstr)
onInputPromise.then(() => {
contentState.cmdline_filter = exstr
})
}
/** @hidden **/
function clInputValueChanged() {
const exstr = commandline_state.clInput.value
const session = commandSession
contentState.current_cmdline = exstr
contentState.cmdline_filter = ""
// Schedule completion computation. We do not start computing immediately because this would incur a slow down on quickly repeated input events (e.g. maintaining <Backspace> pressed)
setTimeout(async () => {
// Make sure the previous computation has ended
await onInputPromise
// If we're not the current completion computation anymore, stop
if (exstr !== commandline_state.clInput.value) {
contentState.cmdline_filter = exstr
return
}
onInputPromise = refresh_completions(exstr)
onInputPromise.then(() => {
contentState.cmdline_filter = exstr
})
}, 100)
// Run immediately when idle, otherwise retain one trailing refresh.
clearTimeout(completionTimer)
const delay =
COMPLETION_THROTTLE_MS - (performance.now() - lastCompletionStarted)
if (delay <= 0) void updateCompletions(exstr, session)
else
completionTimer = setTimeout(
() =>
void updateCompletions(
commandline_state.clInput.value,
session,
),
delay,
)
}
/** @hidden **/
@ -403,7 +426,11 @@ let cmdline_history_current = ""
* Otherwise, no need to pass an argument.
*/
export function clear(evlistener = false) {
if (evlistener) commandSession = { pending: 0, queue: [Promise.resolve()] }
if (evlistener) {
commandSession = { pending: 0, queue: [Promise.resolve()] }
clearTimeout(completionTimer)
lastCompletionStarted = -Infinity
}
if (evlistener) prev_cmd_called_history = false
if (evlistener)
commandline_state.clInput.removeEventListener("blur", noblur)

View file

@ -1,116 +1,46 @@
import { activeTabId } from "@src/lib/webext"
import * as Messaging from "@src/lib/messaging"
import * as Completions from "../completions"
import * as config from "@src/lib/config"
class FindCompletionOption
extends Completions.CompletionOptionHTML
implements Completions.CompletionOptionFuse {
public fuseKeys = []
constructor(m, reverse = false) {
super()
this.value =
(reverse ? "-? " : "") + ("-: " + m.index) + " " + m.rangeData.text
this.fuseKeys.push(m.rangeData.text)
// Create HTMLElement
this.html = html`<tr class="FindCompletionOption option">
<td class="content">
${m.precontext}<span class="match">${m.rangeData.text}</span
>${m.postcontext}
</td>
</tr>`
}
}
import * as Messaging from "@src/lib/messaging"
import { ownTabId } from "@src/lib/webext"
export class FindCompletionSource extends Completions.CompletionSourceFuse {
public options: FindCompletionOption[]
public prevCompletion = null
public completionCount = 0
public options = []
private session = Math.random()
private tabId = ownTabId()
private active = false
constructor(private _parent) {
super(["find "], "FindCompletionSource", "Matches")
this._parent.appendChild(this.node)
constructor(_parent?) {
super(["find"], "FindCompletionSource")
}
async onInput(exstr) {
const id = this.completionCount++
// If there's already a promise being executed, wait for it to finish
await this.prevCompletion
// Since we might have awaited for this.prevCompletion, we don't have a guarantee we're the last completion the user asked for anymore
if (id === this.completionCount - 1) {
// If we are the last completion
this.prevCompletion = this.updateOptions(exstr)
await this.prevCompletion
}
filter(exstr: string) {
const [, argstr] = this.splitOnPrefix(exstr)
if (argstr === undefined) return this.cancel()
this.active = true
void this.send(
{ session: this.session },
...argstr.trim().split(/\s+/),
).catch(() => undefined)
return Promise.resolve()
}
// Overriding this function is important, the default one has a tendency to hide options when you don't expect it
setStateFromScore() {
this.options.forEach(o => (o.state = "normal"))
destroy() {
return this.cancel()
}
private async updateOptions(exstr?: string) {
if (!exstr) return
// Flag parsing because -? should reverse completions
const tokens = exstr.split(" ")
const flagpos = tokens.indexOf("-?")
const reverse = flagpos >= 0
if (reverse) {
tokens.splice(flagpos, 1)
}
const query = tokens.slice(1).join(" ")
const minincsearchlen = await config.getAsync("minincsearchlen")
// No point if continuing if the user hasn't started searching yet
if (query.length < minincsearchlen) return
let findresults = await config.getAsync("findresults")
const incsearch = (await config.getAsync("incsearch")) === "true"
if (findresults === 0 && !incsearch) return
let incsearchonly = false
if (findresults === 0) {
findresults = 1
incsearchonly = true
}
// Note: the use of activeTabId here might break completions if the user starts searching for a pattern in a really big page and then switches to another tab.
// Getting the tabId should probably be done in the constructor but you can't have async constructors.
const tabId = await activeTabId()
const findings = await Messaging.messageTab(
tabId,
"finding_content",
"find",
[query, findresults, reverse],
private cancel() {
if (!this.active) return Promise.resolve()
this.active = false
return this.send({ session: this.session, cancel: true }).catch(
() => undefined,
)
}
// If the search was successful
if (findings.length > 0) {
// Get match context
const len = await config.getAsync("findcontextlen")
const matches = await Messaging.messageTab(
tabId,
"finding_content",
"getMatches",
[findings, len],
)
if (incsearch)
Messaging.messageTab(tabId, "finding_content", "jumpToMatch", [
query,
false,
0,
])
if (!incsearchonly) {
this.options = matches.map(
m => new FindCompletionOption(m, reverse),
)
this.updateChain(exstr, this.options)
}
}
private send(preview, ...args: string[]) {
return this.tabId.then(tabId =>
Messaging.messageTab(tabId, "excmd_content", "find", [
preview,
...args,
]),
)
}
}

View file

@ -1,6 +1,6 @@
import * as config from "@src/lib/config"
import * as DOM from "@src/lib/dom"
import { browserBg, activeTabId } from "@src/lib/webext"
import { browserBg, ownTabId } from "@src/lib/webext"
import state from "@src/state"
import * as State from "@src/state"
import { compute as scrollCompute } from "compute-scroll-into-view"
@ -126,16 +126,20 @@ class FindHighlight extends HTMLSpanElement {
const actions = scrollCompute(fakeNode as HTMLElement, option)
for (const { el: element, top, left } of actions) {
if (preview && !preview.scrolls.has(element))
preview.scrolls.set(element, [
element.scrollLeft,
element.scrollTop,
])
element.scrollTo({ top, left, behavior: "instant" })
}
}
focus() {
if (!isHighlightVisible(this)) {
focusMatch(focusElement = true, scroll = true) {
if (scroll && !isHighlightVisible(this)) {
this.scrollIntoView({ block: "center", inline: "center" })
}
const focusable = this.queryInRange("a,input,button,details")
if (focusable) focusable.focus()
if (focusElement && focusable) focusable.focus()
setNativeFocus(this.nativeRange, true)
this.background = `var(--tridactyl-search-highlight-active-color)`
for (const node of this.children) {
@ -222,11 +226,19 @@ function highlightsDrawn() {
let lastHighlights
// Which element of `lastSearch` was last selected
let selected = 0
let preview
let searchGeneration = 0
let HIGHLIGHT_TIMER
let REPOSITION_TIMER
const POSITION_OBSERVER = new MutationObserver(scheduleReposition)
function resetHighlightTimer() {
clearTimeout(HIGHLIGHT_TIMER)
const timeout = config.get("findhighlighttimeout")
if (timeout > 0) HIGHLIGHT_TIMER = setTimeout(removeHighlighting, timeout)
}
function scheduleReposition() {
if (!host?.firstChild) return
clearTimeout(REPOSITION_TIMER)
@ -239,11 +251,11 @@ window.addEventListener("resize", scheduleReposition)
window.addEventListener("scroll", scheduleReposition, true)
export async function jumpToMatch(searchQuery, option) {
const timeout = config.get("findhighlighttimeout")
if (timeout > 0) {
clearTimeout(HIGHLIGHT_TIMER)
HIGHLIGHT_TIMER = setTimeout(removeHighlighting, timeout)
}
const previewing = option["preview"] === true
const generation = ++searchGeneration
if (!previewing) preview = undefined
if (previewing) clearTimeout(HIGHLIGHT_TIMER)
else resetHighlightTimer()
// First, search for the query
const literal = option["regex"] && searchQuery.match(/^\/(.*)\/([^/]*)$/s)
let [source, flags] = literal ? literal.slice(1) : [searchQuery, ""]
@ -260,15 +272,18 @@ export async function jumpToMatch(searchQuery, option) {
let results: any = { count: 0 }
if (!regex)
results = await browserBg.find.find(searchQuery, {
tabId: await activeTabId(),
tabId: await ownTabId(),
caseSensitive: sensitive,
entireWord: false,
includeRangeData: true,
})
state.lastSearchQuery = searchQuery
state.lastSearchRegex = regex?.flags
if (generation !== searchGeneration) return
if (!previewing) {
state.lastSearchQuery = searchQuery
state.lastSearchRegex = regex?.flags
}
lastHighlights = []
removeHighlighting()
clearHighlighting()
const documents = [document]
if (!regex)
@ -321,14 +336,14 @@ export async function jumpToMatch(searchQuery, option) {
if ("jumpTo" in option) {
selected =
(option["jumpTo"] + lastHighlights.length) % lastHighlights.length
focusHighlight(selected)
focusHighlight(selected, !previewing)
return
}
// Just reuse the code to find the first match in the view
selected = 0
if (isHighlightVisible(lastHighlights[selected])) {
focusHighlight(selected)
focusHighlight(selected, !previewing)
} else {
const searchFromView = true
await jumpToNextMatch(1, searchFromView)
@ -354,15 +369,67 @@ function drawHighlights(highlights) {
highlights.forEach(elem => host.appendChild(elem))
}
export function removeHighlighting() {
function clearHighlighting() {
POSITION_OBSERVER.disconnect()
clearTimeout(REPOSITION_TIMER)
clearNativeHighlights()
while (host?.firstChild) host.removeChild(host.firstChild)
}
export function focusHighlight(index) {
lastHighlights[index].focus()
function restorePreviewScrolls(snapshot = preview) {
for (const [element, [left, top]] of snapshot?.scrolls || [])
element.scrollTo({ left, top, behavior: "instant" })
}
export async function previewMatch(session: number, searchQuery, option) {
if (preview?.session !== session) {
if (preview) cancelPreview(preview.session)
preview = {
session,
highlights: lastHighlights,
selected,
drawn: highlightsDrawn(),
scrolls: new Map(),
}
}
restorePreviewScrolls()
const generation = searchGeneration + 1
try {
await jumpToMatch(searchQuery, { ...option, preview: true })
} catch (_) {
if (preview?.session === session && generation === searchGeneration) {
clearHighlighting()
lastHighlights = []
restorePreviewScrolls()
}
}
}
export function cancelPreview(session: number) {
if (preview?.session !== session) return
++searchGeneration
clearHighlighting()
const snapshot = preview
preview = undefined
lastHighlights = snapshot.highlights
selected = snapshot.selected
if (snapshot.drawn && lastHighlights?.length) {
drawHighlights(lastHighlights)
focusHighlight(selected, false, false)
resetHighlightTimer()
}
restorePreviewScrolls(snapshot)
}
export function removeHighlighting() {
if (preview) cancelPreview(preview.session)
else ++searchGeneration
clearTimeout(HIGHLIGHT_TIMER)
clearHighlighting()
}
export function focusHighlight(index, focusElement = true, scroll = true) {
lastHighlights[index].focusMatch(focusElement, scroll)
if (nativeHighlights) return
repositionHighlight()
POSITION_OBSERVER.observe(document, {
@ -380,25 +447,25 @@ export function repositionHighlight() {
}
export async function jumpToNextMatch(n: number, searchFromView = false) {
const generation = searchGeneration
const lastSearchQuery = await State.getAsync("lastSearchQuery")
const lastRegex = await State.getAsync("lastSearchRegex")
if (generation !== searchGeneration) return
if (!lastSearchQuery) return
if (!lastHighlights) {
const rebuildGeneration = searchGeneration + 1
await jumpToMatch(lastSearchQuery, {
reverse: n < 0,
regex: !!lastRegex,
caseSensitive: lastRegex ? !lastRegex.includes("i") : undefined,
})
if (rebuildGeneration !== searchGeneration) return
if (Math.abs(n) === 1) return
n = n - n / Math.abs(n)
searchFromView = false
}
if (!highlightsDrawn()) {
const timeout = config.get("findhighlighttimeout")
if (timeout > 0) {
clearTimeout(HIGHLIGHT_TIMER)
HIGHLIGHT_TIMER = setTimeout(removeHighlighting, timeout)
}
resetHighlightTimer()
drawHighlights(lastHighlights)
}
if (lastHighlights[selected] === undefined) {
@ -431,7 +498,7 @@ export async function jumpToNextMatch(n: number, searchFromView = false) {
}
}
focusHighlight(selected)
focusHighlight(selected, !preview)
}
export function currentMatchRange(): Range {

View file

@ -1505,7 +1505,7 @@ export function scrollpage(n = 1, count = 1) {
}
/**
* Rudimentary find mode, left unbound by default as we don't currently support `incsearch`. Suggested binds:
* Find mode is left unbound by default. Suggested binds:
*
* ```text
* bind / fillcmdline find
@ -1529,6 +1529,9 @@ export function scrollpage(n = 1, count = 1) {
*/
//#content
export function find(...args: string[]) {
// Completion previews pass session metadata as a non-user argument.
const preview =
typeof (args[0] as any) === "object" ? (args.shift() as any) : undefined
const parsed = arg.lib(
{
"--jump-to": Number,
@ -1561,6 +1564,13 @@ export function find(...args: string[]) {
option["caseSensitive"] = argOpt["--case-sensitive"]
option["regex"] = argOpt["--regex"]
const searchQuery = argOpt._.join(" ")
if (preview) {
const { session } = preview
if (preview.cancel) return finding.cancelPreview(session)
if (config.get("incsearch") === "true" && searchQuery.length > 0)
return finding.previewMatch(session, searchQuery, option)
return finding.cancelPreview(session)
}
return finding.jumpToMatch(searchQuery, option)
}

View file

@ -153,7 +153,7 @@ export function getCommandlineFns(cmdline_state: {
},
/** Hide the command line and cmdline_state.clear its content without executing it. **/
hide_and_clear: () => {
hide_and_clear: async () => {
cmdline_state.clear(true)
cmdline_state.keyEvents = []
@ -162,14 +162,13 @@ export function getCommandlineFns(cmdline_state: {
messageOwnTab("commandline_content", "blur")
// Delete all completion sources - I don't think this is required, but this
// way if there is a transient bug in completions it shouldn't persist.
if (cmdline_state.activeCompletions)
cmdline_state.activeCompletions.forEach(comp => {
comp.destroy?.()
cmdline_state.completionsDiv.removeChild(comp.node)
})
const completions = cmdline_state.activeCompletions || []
cmdline_state.activeCompletions = undefined
const destroying = Promise.all(completions.map(comp => comp.destroy?.()))
completions.forEach(comp => comp.node.remove())
cmdline_state.isVisible = false
cmdline_state.resolveCloseWaiters?.()
await destroying
},
/**
@ -223,7 +222,7 @@ export function getCommandlineFns(cmdline_state: {
const command =
cmdline_state.getCompletion() || cmdline_state.clInput.value
cmdline_state.fns.hide_and_clear()
await cmdline_state.fns.hide_and_clear()
if (cmdline_state.fns.is_valid_commandline(command) === false)
return
@ -250,9 +249,9 @@ export function getCommandlineFns(cmdline_state: {
execute_ex_on_all_completions: (excmd: string) =>
execute_ex_on_all(cmdline_state, excmd),
copy_completion: () => {
copy_completion: async () => {
const command = cmdline_state.getCompletion()
cmdline_state.fns.hide_and_clear()
await cmdline_state.fns.hide_and_clear()
return messageOwnTab("controller_content", "acceptExCmd", [
"clipboard yank " + command,
])

View file

@ -1387,13 +1387,11 @@ export class default_config {
findhighlighttimeout = 0
/**
* Whether Tridactyl should jump to the first match when using `:find`
* Whether Tridactyl should preview matches while typing `:find`
*/
incsearch: "true" | "false" = "false"
incsearch: "true" | "false" = "true"
/**
* How many characters should be typed before triggering incsearch/completions
*/
/** @deprecated Retained for compatibility; this setting has no effect. */
minincsearchlen = 3
/**