Fix selection fighting on startup with many tabs

Also, modestly unify Tab + TabAll completions
This commit is contained in:
Oliver Blanthorn 2026-07-27 21:45:12 +02:00
parent af727c7b8d
commit 9422bd8e5b
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
3 changed files with 150 additions and 118 deletions

View file

@ -4,7 +4,7 @@ import { enumerate } from "@src/lib/itertools"
import * as Containers from "@src/lib/containers"
import * as Completions from "@src/completions"
import * as config from "@src/lib/config"
import * as Messaging from "@src/lib/messaging"
import { TabCompletionSource } from "@src/completions/TabBase"
class BufferCompletionOption
extends Completions.CompletionOptionHTML
@ -76,10 +76,9 @@ class BufferCompletionOption
}
}
export class BufferCompletionSource extends Completions.CompletionSourceFuse {
export class BufferCompletionSource extends TabCompletionSource {
public options: BufferCompletionOption[]
private shouldSetStateFromScore = true
private removeTabChangesListener: () => void
private navigationAnchorTabId: number
// TODO:
@ -108,15 +107,7 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
this.sortScoredOptions = true
this.updateOptions()
this._parent.appendChild(this.node)
this.removeTabChangesListener = Messaging.addListener(
"tab_changes",
() => this.reactToTabChanges(),
)
}
public destroy() {
this.removeTabChangesListener()
this.listenForTabChanges()
}
async onInput(exstr) {
@ -200,21 +191,25 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
return res
}
private async fillOptions(prefix: string) {
private async fillOptions(prefix: string, generation: number) {
// Get alternative tab, defined as last accessed tab in any group in
// this window.
const altTab = await prevActiveTab()
// tabmove uses physical order within the pinned or unpinned group.
const forceSort = prefix === "tabmove" ? "default" : undefined
let tabs = await getSortedTabs(forceSort)
const [altTab, sortedTabs, container_all] = await Promise.all([
prevActiveTab(),
getSortedTabs(forceSort),
browserBg.contextualIdentities.query({}).catch(() => []),
])
if (!this.isCurrentUpdate(generation)) return
let tabs = sortedTabs
if (prefix === "tabmove") {
const activeTab = tabs.find(tab => tab.active)
tabs = tabs.filter(tab => tab.pinned === activeTab.pinned)
}
const options = []
const container_all = await browserBg.contextualIdentities.query({})
const container_map = new Map()
container_all.forEach(elem =>
container_map.set(elem.cookieStoreId, elem),
@ -230,20 +225,21 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
new BufferCompletionOption(
(index + 1).toString(),
tab,
tab.index === altTab.index,
tab.id === altTab?.id,
tab_container,
index,
),
)
}
this.options = options
return options
}
// Eslint doesn't like this decorator but there's nothing we can do about it
// eslint-disable-next-line @typescript-eslint/member-ordering
@Perf.measuredAsync
private async updateOptions(exstr = "", setInitialPosition = true) {
const generation = this.beginUpdate()
this.lastExstr = exstr
if (setInitialPosition) this.navigationAnchorTabId = undefined
let [prefix, query] = this.splitOnPrefix(exstr)
@ -271,7 +267,13 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
return
}
await this.fillOptions(prefix)
const options = await this.fillOptions(prefix, generation)
if (!options || !this.isCurrentUpdate(generation)) return
const lastFocused =
!setInitialPosition && this.lastFocused?.state === "focused"
? (this.lastFocused as BufferCompletionOption)
: undefined
this.options = options
this.completion = undefined
/* console.log('updateOptions', this.optionContainer) */
@ -292,7 +294,22 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
this.options.forEach(option => (option.state = "normal"))
}
this.updateDisplay()
if (!setInitialPosition) return
if (!setInitialPosition) {
const option =
this.options.find(
option =>
option.tabId === lastFocused?.tabId &&
option.state !== "hidden",
) ||
(lastFocused && this.shouldSetStateFromScore
? this.getTheNextTabOption(lastFocused)
: undefined)
if (option) {
this.deselect()
this.select(option)
}
return
}
if (match || query.trim()) return
const initialPosition = config.get(
"completions",
@ -310,31 +327,8 @@ export class BufferCompletionSource extends Completions.CompletionSourceFuse {
}
}
/**
* Update the list of possible tab options and select (focus on)
* the appropriate option.
*/
private async reactToTabChanges(): Promise<void> {
const lastFocused = this.lastFocused as BufferCompletionOption
const lastFocusedTabId = lastFocused?.tabId
const oldIndex = lastFocused?.tabIndex
const wasFocused = lastFocused?.state === "focused"
await this.updateOptions(this.lastExstr, false)
if (!this.options || this.options.length === 0) return
const stillExists = this.options.find(
o => o.tabId === lastFocusedTabId && o.state !== "hidden",
)
if (wasFocused) {
this.deselect()
const option =
stillExists ||
(this.shouldSetStateFromScore
? this.getTheNextTabOption({ tabIndex: oldIndex } as any)
: undefined)
if (option) this.select(option)
}
if (!this.node.isConnected || this.state === "hidden") return
await Messaging.messageOwnTab("commandline_content", "show")
protected refreshForTabChanges() {
return this.updateOptions(this.lastExstr, false)
}
/**

View file

@ -2,9 +2,9 @@ import * as Perf from "@src/perf"
import { browserBg, getSortedTabs, prevActiveTab } from "@src/lib/webext"
import * as Containers from "@src/lib/containers"
import * as Completions from "@src/completions"
import * as Messaging from "@src/lib/messaging"
import * as config from "@src/lib/config"
import { tabTgroup } from "@src/lib/tab_groups"
import { TabCompletionSource } from "@src/completions/TabBase"
class TabAllCompletionOption
extends Completions.CompletionOptionHTML
@ -66,6 +66,7 @@ class TabAllCompletionOption
const favIconUrl = tab.favIconUrl
? tab.favIconUrl
: Completions.DEFAULT_FAVICON
const faviconLoading = tab.favIconUrl ? "lazy" : "eager"
this.html = html`<tr
class="BufferAllCompletionOption option container_${container.color} container_${container.icon} container_${container.name} ${incognito
? "incognito"
@ -75,7 +76,9 @@ class TabAllCompletionOption
<td class="prefixplain" hidden>${preplain}</td>
<td class="privatewindow"></td>
<td class="container"></td>
<td class="icon"><img src="${favIconUrl}" /></td>
<td class="icon">
<img loading="${faviconLoading}" src="${favIconUrl}" />
</td>
<td class="title">${valueStr}: ${tab.title}</td>
<td class="content">
<a class="url" target="_blank" href=${tab.url}>${Completions.decodeUrlForDisplay(tab.url)}</a>
@ -85,10 +88,9 @@ class TabAllCompletionOption
}
}
export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
export class TabAllCompletionSource extends TabCompletionSource {
public options: TabAllCompletionOption[]
private shouldSetStateFromScore = true
private removeTabChangesListener: () => void
constructor(private _parent) {
super(["taball", "tabgrab"], "TabAllCompletionSource", "All Tabs")
@ -97,15 +99,7 @@ export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
this._parent.appendChild(this.node)
this.shouldSetStateFromScore =
config.get("completions", "TabAll", "autoselect") === "true"
this.removeTabChangesListener = Messaging.addListener(
"tab_changes",
() => this.reactToTabChanges(),
)
}
public destroy() {
this.removeTabChangesListener()
this.listenForTabChanges()
}
async onInput(exstr) {
@ -116,43 +110,6 @@ export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
super.setStateFromScore(scoredOpts, this.shouldSetStateFromScore)
}
/**
* Map all windows into a {[windowId]: window} object
*/
private async getWindows() {
const windows = await browserBg.windows.getAll()
const response: { [windowId: number]: browser.windows.Window } = {}
windows.forEach(win => (response[win.id] = win))
return response
}
/**
* Update the list of possible tab options and select (focus on)
* the appropriate option.
*/
private async reactToTabChanges(): Promise<void> {
if (this.state === "hidden") return
const lastFocused = this.lastFocused as TabAllCompletionOption
const lastFocusedTabId =
lastFocused?.state === "focused" ? lastFocused.tabId : undefined
const oldIndex = (this.options || [])
.filter(o => o.state !== "hidden")
.indexOf(lastFocused)
await this.updateOptions(this.lastExstr)
if (lastFocusedTabId !== undefined) {
const visibleOptions = this.options.filter(o => o.state !== "hidden")
const option =
visibleOptions.find(o => o.tabId === lastFocusedTabId) ||
visibleOptions[Math.min(oldIndex, visibleOptions.length - 1)]
if (option) {
this.deselect()
this.select(option)
}
}
if (!this.node.isConnected) return
await Messaging.messageOwnTab("commandline_content", "show")
}
/**
* Gets the next option in this BufferCompletionSource assuming
* that this BufferCompletionSource length has been reduced by 1
@ -169,7 +126,8 @@ export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
// Eslint doesn't like this decorator but there's nothing we can do about it
// eslint-disable-next-line @typescript-eslint/member-ordering
@Perf.measuredAsync
private async updateOptions(exstr = "") {
private async updateOptions(exstr = "", preserveSelection = false) {
const generation = this.beginUpdate()
this.lastExstr = exstr
const [prefix] = this.splitOnPrefix(exstr)
@ -185,11 +143,14 @@ export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
}
const mru = config.get("tabsort") == "mru"
const tabsPromise = getSortedTabs(mru ? "mru" : "default", true)
const windowsPromise = this.getWindows()
const [tabs, windows] = await Promise.all([tabsPromise, windowsPromise])
const options = []
const [tabs, altTab, currentWindow, containerList] =
await Promise.all([
getSortedTabs(mru ? "mru" : "default", true),
prevActiveTab(),
browserBg.windows.getCurrent(),
browserBg.contextualIdentities.query({}).catch(() => []),
])
if (!this.isCurrentUpdate(generation)) return
if (!mru) {
tabs.sort((a, b) => {
@ -198,40 +159,66 @@ export class TabAllCompletionSource extends Completions.CompletionSourceFuse {
})
}
const altTab = await prevActiveTab()
// Check to see if this is a command that needs to exclude the current
// window
const excludeCurrentWindow = this.canonicalisePrefix(prefix) === "tabgrab"
const currentWindow = await browserBg.windows.getCurrent()
const windowIndices = new Map(
[...new Set(tabs.map(tab => tab.windowId))]
.sort((a, b) => a - b)
.map((windowId, index) => [windowId, index + 1]),
)
for (const tab of tabs) {
// if we are excluding the current window and this tab is in the current window
// then skip it
if (excludeCurrentWindow && tab.windowId === currentWindow.id)
continue
options.push(
const includedTabs = tabs.filter(
tab => !excludeCurrentWindow || tab.windowId !== currentWindow.id,
)
const tabGroups = await Promise.all(
includedTabs.map(tab =>
tabTgroup(tab.id).catch(() => undefined),
),
)
const containerMap = new Map()
containerList.forEach(container =>
containerMap.set(container.cookieStoreId, container),
)
if (!this.isCurrentUpdate(generation)) return
const options = includedTabs.map(
(tab, index) =>
new TabAllCompletionOption(
tab.id.toString(),
tab,
tab.index === altTab.index &&
tab.windowId === altTab.windowId,
tab.id === altTab?.id,
tab.active &&
tab.windowId === currentWindow.id,
windowIndices.get(tab.windowId),
await Containers.getFromId(tab.cookieStoreId),
windows[tab.windowId].incognito,
await tabTgroup(tab.id),
containerMap.get(tab.cookieStoreId) ||
Containers.DefaultContainer,
tab.incognito,
tabGroups[index],
),
)
}
)
const lastFocused = this.lastFocused as TabAllCompletionOption
const wasFocused = preserveSelection && lastFocused?.state === "focused"
const oldIndex = wasFocused
? (this.options || [])
.filter(o => o.state !== "hidden")
.indexOf(lastFocused)
: -1
this.completion = undefined
this.options = options
return this.updateChain()
this.updateChain()
if (wasFocused) {
const visibleOptions = this.options.filter(o => o.state !== "hidden")
const option =
visibleOptions.find(o => o.tabId === lastFocused.tabId) ||
visibleOptions[Math.min(oldIndex, visibleOptions.length - 1)]
if (option) {
this.deselect()
this.select(option)
}
}
}
protected refreshForTabChanges() {
return this.updateOptions(this.lastExstr, true)
}
}

View file

@ -0,0 +1,51 @@
import * as Completions from "@src/completions"
import * as Messaging from "@src/lib/messaging"
export abstract class TabCompletionSource extends Completions.CompletionSourceFuse {
private removeTabChangesListener: () => void
private handlingTabChanges = false
private tabChangesQueued = false
private updateGeneration = 0
public destroy() {
this.updateGeneration++
this.tabChangesQueued = false
this.removeTabChangesListener()
}
protected listenForTabChanges() {
this.removeTabChangesListener = Messaging.addListener(
"tab_changes",
() => this.reactToTabChanges(),
)
}
protected beginUpdate() {
return ++this.updateGeneration
}
protected isCurrentUpdate(generation: number) {
return generation === this.updateGeneration
}
protected abstract refreshForTabChanges(): Promise<void>
private async reactToTabChanges(): Promise<void> {
if (this.state === "hidden") return
this.tabChangesQueued = true
if (this.handlingTabChanges) return
this.handlingTabChanges = true
try {
do {
this.tabChangesQueued = false
await this.refreshForTabChanges()
} while (this.tabChangesQueued)
} finally {
this.handlingTabChanges = false
if (this.tabChangesQueued) void this.reactToTabChanges()
}
if (!this.node.isConnected || this.node.classList.contains("hidden"))
return
await Messaging.messageOwnTab("commandline_content", "show")
}
}