diff --git a/src/index.ts b/src/index.ts index 11ca93e5..0a9b22eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,13 +13,6 @@ import {logErrorsFrom} from "./util/oops"; logErrorsFrom(async () => { // BEGIN FILE-WIDE ASYNC BLOCK - // - // Migrations -- these are old DBs which are in the wrong format - // - - indexedDB.deleteDatabase("cache:favicons"); - indexedDB.deleteDatabase("cache:bookmarks"); - // // Start our various services and set global variables used throughout the rest // of this file. @@ -28,6 +21,34 @@ logErrorsFrom(async () => { const model = await service_model(); (globalThis).model = model; + // + // Migrations + // + + // Delete old DBs that are in the wrong format + indexedDB.deleteDatabase("cache:favicons"); + indexedDB.deleteDatabase("cache:bookmarks"); + + // Tag hidden tabs which were hidden before upgrading to a version of Tab + // Stash that keeps track of which tabs it was responsible for hiding. + if (!model.options.local.state.migrated_tab_markers_applied) { + logErrorsFrom(async () => { + // Don't do anything if the browser doesn't support hiding tabs. + if (!browser.tabs.hide) return; + + const tabs = await browser.tabs.query({hidden: true}); + + for (const t of tabs) { + if (model.bookmarks.isURLStashed(t.url!)) { + // This applies the tag as a side effect + await model.tabs.hide([t.id! as TabID]); + } + } + + await model.options.local.set({migrated_tab_markers_applied: true}); + }); + } + // // User-triggered commands thru menu items, etc. IDs in the menu items // correspond to field names in the commands object. diff --git a/src/mock/browser/index.ts b/src/mock/browser/index.ts index e6ebdc56..e54fe05c 100644 --- a/src/mock/browser/index.ts +++ b/src/mock/browser/index.ts @@ -1,7 +1,8 @@ import bookmarks from "./bookmarks"; import containers from "./containers"; import runtime from "./runtime"; +import sessions from "./sessions"; import storage from "./storage"; import tabs_and_windows from "./tabs-and-windows"; -export {bookmarks, containers, runtime, storage, tabs_and_windows}; +export {bookmarks, containers, runtime, sessions, storage, tabs_and_windows}; diff --git a/src/mock/browser/sessions.ts b/src/mock/browser/sessions.ts new file mode 100644 index 00000000..61b748c1 --- /dev/null +++ b/src/mock/browser/sessions.ts @@ -0,0 +1,106 @@ +import type {Sessions, Tabs as T, Windows as W} from "webextension-polyfill"; + +import * as events from "../events"; +import type {State} from "./tabs-and-windows"; +import tabs_and_windows from "./tabs-and-windows"; + +type Metadata = Map; + +class MockSessions implements Sessions.Static { + readonly MAX_SESSION_RESULTS = 25; + + readonly onChanged: events.MockEvent<() => void> = new events.MockEvent( + "browser.sessions.onChanged", + ); + + private readonly _state: State; + private readonly _windows = new WeakMap(); + private readonly _tabs = new WeakMap(); + + constructor(state: State) { + this._state = state; + } + + forgetClosedTab(windowId: number, sessionId: string): Promise { + throw new Error("Method not implemented."); + } + forgetClosedWindow(sessionId: string): Promise { + throw new Error("Method not implemented."); + } + getRecentlyClosed( + filter?: Sessions.Filter | undefined, + ): Promise { + throw new Error("Method not implemented."); + } + restore(sessionId?: string | undefined): Promise { + throw new Error("Method not implemented."); + } + + async setTabValue(tabId: number, key: string, value: any): Promise { + const md = this._tab(tabId); + md.set(key, JSON.stringify(value)); + } + async getTabValue(tabId: number, key: string): Promise { + const md = this._tab(tabId); + const val = md.get(key); + if (val === undefined) return undefined; + return JSON.parse(val); + } + async removeTabValue(tabId: number, key: string): Promise { + const md = this._tab(tabId); + md.delete(key); + } + + async setWindowValue( + windowId: number, + key: string, + value: any, + ): Promise { + const md = this._window(windowId); + md.set(key, JSON.stringify(value)); + } + async getWindowValue(windowId: number, key: string): Promise { + const md = this._window(windowId); + const val = md.get(key); + if (val === undefined) return undefined; + return JSON.parse(val); + } + async removeWindowValue(windowId: number, key: string): Promise { + const md = this._window(windowId); + md.delete(key); + } + + private _window(id: number): Metadata { + const win = this._state.win(id); + let md = this._windows.get(win); + if (!md) { + md = new Map(); + this._windows.set(win, md); + } + return md; + } + + private _tab(id: number): Metadata { + const tab = this._state.tab(id); + let md = this._tabs.get(tab); + if (!md) { + md = new Map(); + this._tabs.set(tab, md); + } + return md; + } +} + +export default (() => { + const exports = { + sessions: new MockSessions(tabs_and_windows.state), + reset() { + exports.sessions = new MockSessions(tabs_and_windows.state); + (globalThis).browser.sessions = exports.sessions; + }, + }; + + exports.reset(); + + return exports; +})(); diff --git a/src/mock/browser/tabs-and-windows.ts b/src/mock/browser/tabs-and-windows.ts index e48f6d3f..3034cc80 100644 --- a/src/mock/browser/tabs-and-windows.ts +++ b/src/mock/browser/tabs-and-windows.ts @@ -11,7 +11,8 @@ import * as events from "../events"; type Window = Omit & {id: number; tabs: Tab[]}; type Tab = Omit & {id: number; windowId: number}; -class State { +// Exported because it's also used by the sessions mock +export class State { readonly onWindowCreated: events.MockEvent<(window: W.Window) => void>; readonly onWindowRemoved: events.MockEvent<(windowId: number) => void>; readonly onWindowFocusChanged: events.MockEvent<(windowId: number) => void>; @@ -910,11 +911,12 @@ export default (() => { let state = new State(); const exports = { + state, windows: new MockWindows(state), tabs: new MockTabs(state), reset() { - state = new State(); + exports.state = state = new State(); exports.windows = new MockWindows(state); exports.tabs = new MockTabs(state); (globalThis).browser.windows = exports.windows; diff --git a/src/mock/index.ts b/src/mock/index.ts index 73e633bd..8bcddd66 100644 --- a/src/mock/index.ts +++ b/src/mock/index.ts @@ -44,6 +44,7 @@ export const mochaHooks: RootHookObject = { mock_browser.storage.reset(); mock_browser.bookmarks.reset(); mock_browser.tabs_and_windows.reset(); + mock_browser.sessions.reset(); mock_browser.containers.reset(); }, async afterEach() { diff --git a/src/model/bookmarks.ts b/src/model/bookmarks.ts index d53afa3c..10f7d6ad 100644 --- a/src/model/bookmarks.ts +++ b/src/model/bookmarks.ts @@ -223,6 +223,14 @@ export class Model { return index; } + /** Returns a (non-reactive) array of bookmarks in the stash with the + * specified URL. */ + bookmarksWithURLInStash(url: string): Bookmark[] { + return Array.from(this.bookmarksWithURL(url)).filter(bm => + this.isNodeInStashRoot(bm), + ); + } + isParent(node: Node): node is Folder { return isFolder(node); } diff --git a/src/model/index.test.ts b/src/model/index.test.ts index ebacf48d..59c59a4f 100644 --- a/src/model/index.test.ts +++ b/src/model/index.test.ts @@ -226,6 +226,54 @@ describe("model", () => { ); } }); + + describe("hidden tabs", () => { + it("leaves stashed tabs alone", async () => { + await browser.sessions.setTabValue( + tabs.real_doug_2.id, + M.Tabs.SK_HIDDEN_BY_TAB_STASH, + true, + ); + + await model.closeOrphanedHiddenTabs(); + + const hidden = await browser.tabs.query({hidden: true}); + expect(hidden.map(t => t.id!)).to.deep.equal([ + tabs.real_doug_2.id, + tabs.real_harry.id, + tabs.real_helen.id, + ]); + }); + + it("closes orphaned stashed tabs", async () => { + await browser.sessions.setTabValue( + tabs.real_doug_2.id, + M.Tabs.SK_HIDDEN_BY_TAB_STASH, + true, + ); + + await model.bookmarks.remove(bookmarks.doug_2.id); + + await model.closeOrphanedHiddenTabs(); + + const hidden = await browser.tabs.query({hidden: true}); + expect(hidden.map(t => t.id!)).to.deep.equal([ + tabs.real_harry.id, + tabs.real_helen.id, + ]); + }); + + it("leaves tabs hidden by other extensions alone", async () => { + await model.closeOrphanedHiddenTabs(); + + const hidden = await browser.tabs.query({hidden: true}); + expect(hidden.map(t => t.id!)).to.deep.equal([ + tabs.real_doug_2.id, + tabs.real_harry.id, + tabs.real_helen.id, + ]); + }); + }); }); describe("choosing stashable tabs in a window", () => { @@ -291,6 +339,12 @@ describe("model", () => { hidden: true, }); expect(model.tabs.tab(tabs.right_doug.id)!.discarded).not.to.be.ok; + + const bm_id = await browser.sessions.getTabValue( + tabs.right_doug.id, + M.Tabs.SK_HIDDEN_BY_TAB_STASH, + ); + expect(bm_id).to.equal(true); }); it("hides and unloads tabs", async () => { @@ -318,6 +372,12 @@ describe("model", () => { hidden: true, discarded: true, }); + + const bm_id = await browser.sessions.getTabValue( + tabs.right_doug.id, + M.Tabs.SK_HIDDEN_BY_TAB_STASH, + ); + expect(bm_id).to.equal(true); }); it("closes tabs", async () => { @@ -354,6 +414,7 @@ describe("model", () => { id => model.tabs.tab(id)!, ), ); + await events.next(browser.tabs.onHighlighted); // un-highlight current tab await events.next(browser.tabs.onCreated); await events.next(browser.tabs.onActivated); await events.next(browser.tabs.onHighlighted); @@ -402,6 +463,7 @@ describe("model", () => { it("refocuses away from an active tab that is to be closed", async () => { await model.hideOrCloseStashedTabs([model.tabs.tab(tabs.left_alice.id)!]); + await events.next(browser.tabs.onHighlighted); // un-highlight current tab await events.next(browser.tabs.onActivated); await events.next(browser.tabs.onHighlighted); await events.next(browser.tabs.onUpdated); // hidden diff --git a/src/model/index.ts b/src/model/index.ts index 69d040a4..c0320fa5 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -373,6 +373,8 @@ export class Model { id === BookmarkMetadata.CUR_WINDOW_MD_ID || !!this.bookmarks.node(id as Bookmarks.NodeID), ); + + await this.closeOrphanedHiddenTabs(); } /** Stashes all eligible tabs in the specified window, leaving the existing @@ -447,35 +449,23 @@ export class Model { async hideOrCloseStashedTabs(tabs: Tabs.Tab[]): Promise { const tids = tabs.map(t => t.id); - await this.tabs.refocusAwayFromTabs(tids); - // Clear any highlights/selections on tabs we are stashing await Promise.all( tids.map(id => browser.tabs.update(id, {highlighted: false})), ); for (const t of tabs) this.selection.info(t).isSelected = false; - // istanbul ignore else -- hide() is always available in tests - if (browser.tabs.hide) { - // If the browser supports hiding tabs, then hide or close them - // according to the user's preference. - switch (this.options.local.state.after_stashing_tab) { - case "hide_discard": - await browser.tabs.hide(tids); - await browser.tabs.discard(tids); - break; - case "close": - await this.tabs.remove(tids); - break; - case "hide": - default: - await browser.tabs.hide(tids); - break; - } - } else { - // The browser does not support hiding tabs, so our only option is - // to close them. - await this.tabs.remove(tids); + switch (this.options.local.state.after_stashing_tab) { + case "hide_discard": + await this.tabs.hide(tids, "discard"); + break; + case "close": + await this.tabs.remove(tids); + break; + case "hide": + default: + await this.tabs.hide(tids); + break; } } @@ -872,7 +862,9 @@ export class Model { // location before moving to the desired location, so doing the // move first reduces flickering in the UI. await this.tabs.move(t.id, to_win_id, to_index); - if (t.hidden && !!browser.tabs.show) await browser.tabs.show(t.id); + if (t.hidden && !!browser.tabs.show) { + await this.tabs.show(t.id); + } // console.log('new layout:', this.tabs.window(t.windowId)?.tabs); @@ -1102,6 +1094,46 @@ export class Model { // Remove the item we just restored. await di.drop(deletion.key, path); } + + /** Closes any hidden tabs that were originally hidden by Tab Stash, but are + * no longer present as bookmarks in the stash. */ + async closeOrphanedHiddenTabs() { + const now = Date.now(); + const tabs = await browser.tabs.query({hidden: true}); + + const our_hidden_tabs = await Promise.allSettled( + tabs.map(tab => + this.tabs + .wasTabHiddenByUs(tab.id! as Tabs.TabID) + .then(hidden_by_us => ({tab, hidden_by_us})), + ), + ); + + const tab_ids_to_close = filterMap(our_hidden_tabs, res => { + // If we couldn't figure out whether the tab was hidden by us or not, OR + // if we can tell the tab was NOT hidden by us, leave it alone. + if (res.status !== "fulfilled") return undefined; + if (res.value.tab.id === undefined) return undefined; + if (!res.value.hidden_by_us) return undefined; + + // If the tab was very recently accessed, we should ignore it; we might be + // in the midst of stashing it right now (and it's possible the bookmark + // hasn't been created yet). + if ( + res.value.tab.lastAccessed && + res.value.tab.lastAccessed > now - 2000 + ) { + return undefined; + } + + // If there is a URL in the stash matching the tab's URL, we know this + // tab is still in the stash and cannot be closed. + if (this.bookmarks.isURLStashed(res.value.tab.url!)) return undefined; + return res.value.tab.id; + }); + + await browser.tabs.remove(tab_ids_to_close); + } } export type BookmarkTabsResult = { diff --git a/src/model/options.ts b/src/model/options.ts index 78d8847e..6b9cbef6 100644 --- a/src/model/options.ts +++ b/src/model/options.ts @@ -134,6 +134,12 @@ export const LOCAL_DEF = { /** Container color indicators. Related issue: #125 */ // ff_container_indicators: {default: false, is: aBoolean}, + + // Migration flags + + /** Tracks whether we have marked hidden tabs in the session store as + * belonging to Tab Stash. */ + migrated_tab_markers_applied: {default: false, is: aBoolean}, } as const; export type Source = { diff --git a/src/model/tabs.ts b/src/model/tabs.ts index f9e3c2ee..61ed3475 100644 --- a/src/model/tabs.ts +++ b/src/model/tabs.ts @@ -43,6 +43,9 @@ export type TabID = number & {readonly __tab_id: unique symbol}; const trace = trace_fn("tabs"); +/** The key of the stashed tab's bookmark ID in the browser's session store. */ +export const SK_HIDDEN_BY_TAB_STASH = "hidden_by_tab_stash"; + /** How many tabs do we want to allow to be loaded at once? (NOTE: This is * approximate, since the model may not be fully up to date, and the user can * always trigger tab loading on their own.) */ @@ -218,6 +221,15 @@ export class Model { return index; } + /** Checks if the tab was hidden by us or by some other extension. */ + async wasTabHiddenByUs(tabId: TabID): Promise { + const res = await browser.sessions.getTabValue( + tabId, + SK_HIDDEN_BY_TAB_STASH, + ); + return res ?? false; + } + // // User-level operations on tabs // @@ -301,6 +313,32 @@ export class Model { }); } + /** Shows a tab that was previously hidden. */ + async show(tid: TabID): Promise { + await browser.tabs.show(tid); + // We expect SK_HIDDEN_BY_TAB_STASH to be cleared automatically by + // whenTabUpdated(). We do it there, instead of here, because some other + // extension or the user could have un-hid the tab without going thru us. + } + + /** Hides the specified tabs, optionally discarding them (to free up memory). + * If the browser does not support hiding tabs, closes them instead. */ + async hide(tabIds: TabID[], discard?: "discard"): Promise { + if (!!browser.tabs.hide) { + trace("hiding tabs", tabIds); + await this.refocusAwayFromTabs(tabIds); + + await browser.tabs.hide(tabIds); + if (discard) await browser.tabs.discard(tabIds); + + for (const t of tabIds) { + await browser.sessions.setTabValue(t, SK_HIDDEN_BY_TAB_STASH, true); + } + } else { + await this.remove(tabIds); + } + } + /** Close the specified tabs, but leave the browser window open (and create * a new tab if necessary to keep it open). */ async remove(tabIds: TabID[]): Promise { @@ -543,7 +581,18 @@ export class Model { } if (info.favIconUrl !== undefined) t.favIconUrl = info.favIconUrl; if (info.pinned !== undefined) t.pinned = info.pinned; - if (info.hidden !== undefined) t.hidden = info.hidden; + if (info.hidden !== undefined) { + if (t.hidden !== info.hidden && !info.hidden) { + // We must clear the "hidden by Tab Stash" flag because somebody (could + // have been us or someone else) un-hid this tab, and if the tab is + // hidden again later by some other extension, we don't want to believe + // it was hidden by Tab Stash. + logErrorsFrom(() => + browser.sessions.removeTabValue(t.id, SK_HIDDEN_BY_TAB_STASH), + ); + } + t.hidden = info.hidden; + } if (info.discarded !== undefined) t.discarded = info.discarded; }