diff --git a/src/completions/Tab.ts b/src/completions/Tab.ts index 50efd15f..ca21deb1 100644 --- a/src/completions/Tab.ts +++ b/src/completions/Tab.ts @@ -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 { - 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) } /** diff --git a/src/completions/TabAll.ts b/src/completions/TabAll.ts index 63573f2e..e9e681cf 100644 --- a/src/completions/TabAll.ts +++ b/src/completions/TabAll.ts @@ -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`${preplain} - + + + ${valueStr}: ${tab.title} ${Completions.decodeUrlForDisplay(tab.url)} @@ -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 { - 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) } } diff --git a/src/completions/TabBase.ts b/src/completions/TabBase.ts new file mode 100644 index 00000000..a0a94fb5 --- /dev/null +++ b/src/completions/TabBase.ts @@ -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 + + private async reactToTabChanges(): Promise { + 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") + } +}