diff --git a/package-lock.json b/package-lock.json index 32ac9f8..c914ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4706,7 +4706,7 @@ }, "plugins/top-tabs": { "name": "bb-plugin-top-tabs", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@get-bb/plugin-sdk": "0.5.29", diff --git a/plugins/top-tabs/PLUGIN_OVERVIEW.md b/plugins/top-tabs/PLUGIN_OVERVIEW.md index 8391986..2f038d8 100644 --- a/plugins/top-tabs/PLUGIN_OVERVIEW.md +++ b/plugins/top-tabs/PLUGIN_OVERVIEW.md @@ -26,7 +26,8 @@ The sidebar keeps bb's own navigation, so you can reorder, hide and split from it as always. **Pinned tabs.** Pin the destinations you always want: they sit beside -Threads as icons and never close by accident. Labels can be icons-only, or +Threads as icons and never close by accident. Closing one with bb's Close or +the keyboard sends it back to its start and leaves it pinned. Labels can be icons-only, or only on the tab in view. **Splits.** Drag a tab down into the page to split it, as you would a @@ -34,7 +35,8 @@ thread, or open one in a split from the + menu. The strip marks every tab that's which pane it's in. Clicking one focuses its pane. **Keyboard.** Ctrl+Tab and Ctrl+Shift+Tab switch tabs, and every command is -in the palette. +in the palette. Closing a tab moves to its neighbour, as a browser does, or +back to the last tab you used, as VS Code does. ## How it works diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 72f2ed2..a9c79fc 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -42,11 +42,13 @@ header, the strip keeps working from the last list it saw. **+** menu, to keep it. See [Pinned tabs](#pinned-tabs). - **Close a tab** with the × that replaces its icon on hover, a middle-click, its context menu, or bb's own Close at the top right of the page. Pinned - tabs don't close. Closing the tab in view moves to its right-hand - neighbour, then its left, then Threads. + tabs don't close; bb's Close and **Top Tabs: Close tab** reset one instead. Closing the + tab in view moves to its right-hand neighbour, then its left, then + Threads, or, with **After closing a tab, go back to the last one you + used**, to the tab you were on before it. - **Reopen a closed tab** with Ctrl+Shift+T, the context menu, or the **+** menu. It comes back in the same position, at the same place inside the - panel. + panel. It also undoes a pinned tab's reset. - **Reorder** by dragging a tab sideways, within its group: pinned tabs among pinned, the rest among the rest. Drag it down into the page instead to open it in a split. @@ -116,6 +118,14 @@ A pinned tab is one you always want in the strip: for its name. - It has no ×, and middle-click, close-others and close-to-the-right all leave it alone. +- bb's Close at the top right of the page, or **Top Tabs: Close tab**, + resets it instead, as Arc does. It stays pinned, forgets where it was + left, so it next opens at the panel's start, and the strip moves on past + the other pins: to the first ordinary tab, or Threads if there is none. With **After closing a tab, go back to the last + one you used** on, it goes to the ordinary tab you used last instead. So + pressing the shortcut again closes that tab rather than stepping through + the pins. ⌃⇧T undoes a reset and takes the tab back to where it was, until + you go back to the tab yourself: then the reset is taken as it is. - Unpin it from its context menu (or with **Top Tabs: Pin or unpin tab**) to make it an ordinary tab again. It lands first among the ordinary tabs. @@ -253,11 +263,19 @@ rows, hidden ones behind **More**. | --- | --- | | Collapse the sidebar on other tabs | on | | Close the Settings tab when you leave Settings | on | +| After closing a tab, go back to the last one you used | off | | Tab labels | Always | **Collapse the sidebar** off keeps the sidebar wherever you leave it. The tabs work the same either way. +**After closing a tab, go back to the last one you used** chooses where +closing the tab in view goes. Off, it moves to the tab on its right, as a +browser does. On, it returns to the tab you were on before it, as VS Code +does, Threads included, and to the tab on its right once none of the tabs you +used recently is still open. Closing a pinned tab follows it too, skipping +the other pins. + **Tab labels** chooses how much of the strip is words: - *Always* names every tab. @@ -281,7 +299,7 @@ All are in bb's command palette, and every shortcut can be rebound under | Top Tabs: Go to Threads | — | | Top Tabs: Switch thread… | — | | Top Tabs: Open a tab… | — | -| Top Tabs: Close tab (on Threads: close the thread in view) | — | +| Top Tabs: Close tab (on Threads: close the thread in view; on a pinned tab: reset it) | — | | Top Tabs: Pin or unpin tab | — | In a web browser, the browser keeps Ctrl+Tab and Ctrl+Shift+T for itself. diff --git a/plugins/top-tabs/app.tsx b/plugins/top-tabs/app.tsx index 7197f05..b979e69 100644 --- a/plugins/top-tabs/app.tsx +++ b/plugins/top-tabs/app.tsx @@ -80,7 +80,8 @@ export default definePluginApp((app) => { }, }); // On Threads, which cannot close, this presses bb's Close on the thread page, - // which opens New Thread. + // which opens New Thread. On a pinned tab it resets the tab and leaves it, + // still pinned. const closeAction = () => { const controller = getController(); const active = controller?.active() ?? null; @@ -95,6 +96,7 @@ export default definePluginApp((app) => { const { controller, active, action } = closeAction(); if (action === "page") pageClose()?.click(); else if (action === "tab" && controller && active) controller.close(active); + else if (action === "pin" && controller && active) controller.closePinned(active); }, }); app.commands.register({ diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index a67e2e0..f670a91 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -43,6 +43,7 @@ import { activeTabFor, adopt, close, + closeOrderOf, closeOthers, closeToRight, cycle, @@ -56,11 +57,14 @@ import { recordRecent, recordRecentThread, reopen, + reopenable, + resetPinned, seed, sidebarStep, splitPartner, - stripTakesPageClose, + pageCloseAction, successorAfterClose, + successorAfterPinClose, threadIdFromPath, threadPaneFor, unpin, @@ -154,6 +158,7 @@ export function TopTabs() { const collapseSidebar = values?.collapseSidebar !== false; const closeSettingsOnExit = values?.closeSettingsOnExit !== false; const labelMode = labelModeOf(values?.tabLabels); + const closeOrder = closeOrderOf(values?.recentAfterClose); const compact = useMediaQuery(COMPACT_QUERY); const trafficLights = useReservesTrafficLights(); const path = useSyncExternalStore(subscribeLocation, currentPath); @@ -187,8 +192,8 @@ export function TopTabs() { // Callbacks below read the latest of these rather than closing over them, // so the controller and window listeners never act on a stale strip. - const live = useRef({ active, byId, shown, nav, navLive, bbNavigate, screen, threads, splits, threadActions, compact, collapseSidebar, inSplit }); - live.current = { active, byId, shown, nav, navLive, bbNavigate, screen, threads, splits, threadActions, compact, collapseSidebar, inSplit }; + const live = useRef({ active, byId, shown, nav, navLive, bbNavigate, screen, threads, splits, threadActions, compact, collapseSidebar, inSplit, closeOrder }); + live.current = { active, byId, shown, nav, navLive, bbNavigate, screen, threads, splits, threadActions, compact, collapseSidebar, inSplit, closeOrder }; // First run: the destinations the sidebar showed become the open tabs. // Wait for the list to settle, since plugin panels register as their @@ -207,10 +212,15 @@ export function TopTabs() { return () => window.clearTimeout(timer); }, [tabs.seeded, visibleKey]); + // A pinned tab the close-tab command reset, until the strip has left it: + // its location must not be recorded again on the way out. + const resetting = useRef(null); + // A destination reached any other way — the palette, a shortcut, a link — // gets a tab, and every tab remembers where it was left. useEffect(() => { if (active === null) return; + if (resetting.current !== active) resetting.current = null; // Read the location now rather than from the render: it is the one // source that cannot lag behind. const here = currentPath(); @@ -220,6 +230,7 @@ export function TopTabs() { update((s) => { let next = recordRecent(adopt(s, active), active); if (threadId !== null) next = recordRecentThread(next, threadId); + if (resetting.current === active) return next; return pathFits(active, target, pathnameOf(here)) ? recordPath(next, active, here) : next; }); }, [active, path, byId]); @@ -379,8 +390,8 @@ export function TopTabs() { (id: TabId) => { // Threads and pinned tabs stay; a pinned tab has to be unpinned first. if (id === THREADS || getState().pinned.includes(id)) return; - const { active, shown, screen } = live.current; - const next = successorAfterClose(shown, id, active); + const { active, shown, screen, closeOrder } = live.current; + const next = successorAfterClose(shown, id, active, closeOrder, getState().recent); update((s) => close(s, [id])); // A tab on screen in a split closes with its pane, and bb chooses which // pane takes focus; there is no neighbour to switch to. @@ -390,17 +401,39 @@ export function TopTabs() { [activateSoon], ); + /** + * Close a pinned tab with the close-tab command or bb's Close, as Arc does: + * it stays pinned, forgets where it was left, and the strip moves past the + * other pins. The tab's ×, a middle-click and the batch closes still leave + * pinned tabs alone. + */ + const closePinned = useCallback( + (id: TabId) => { + const { pinned, recent } = getState(); + if (!pinned.includes(id)) return; + const { active, shown, screen, closeOrder } = live.current; + update((s) => resetPinned(s, id)); + if (id !== active) return; + resetting.current = id; + if (closePanesOf(screen, [id])) return; + activateSoon(successorAfterPinClose(shown, pinned, id, closeOrder, recent)); + }, + [activateSoon], + ); + // bb's Close on a lone plugin page would open New Thread and leave the tab - // open behind Threads; it closes the tab instead, as the tab's own × does. + // open behind Threads; it closes the tab instead, as the tab's own × does, + // or resets a pinned one, as the close-tab command does. useEffect( () => interceptPageClose(() => { const { active } = live.current; - if (!stripTakesPageClose(active, getState().pinned)) return false; - closeTab(active!); - return true; + const action = pageCloseAction(active, getState().pinned); + if (action === "tab") closeTab(active!); + else if (action === "pin") closePinned(active!); + return action !== null; }), - [closeTab], + [closeTab, closePinned], ); /** @@ -528,6 +561,7 @@ export function TopTabs() { active: () => live.current.active, activate: activateSoon, close: closeTab, + closePinned, cycle: (direction) => { const { active, shown } = live.current; activateSoon(cycle([THREADS, ...shown], active, direction)); @@ -538,7 +572,7 @@ export function TopTabs() { togglePin, openSwitcher: () => setSwitcher({ keyboard: true }), }), - [activateSoon, closeTab, reopenTab, togglePin], + [activateSoon, closeTab, closePinned, reopenTab, togglePin], ); // Keep the tab in view visible when the strip scrolls. @@ -617,7 +651,7 @@ export function TopTabs() { id === THREADS ? closable.length > 0 : shown.slice(shown.indexOf(id) + 1).some((other) => closable.includes(other)), - canReopen: tabs.closed.some((c) => byId.has(c.id) && !tabs.open.includes(c.id)), + canReopen: reopenable(tabs, (id) => byId.has(id)) !== undefined, onClose: () => closeTab(id), onTogglePin: () => togglePin(id), onCloseOthers: () => closeOtherTabs(id), @@ -789,7 +823,7 @@ export function TopTabs() { openIds={shown} pinnedIds={tabs.pinned} active={active} - canReopen={tabs.closed.some((c) => byId.has(c.id) && !tabs.open.includes(c.id))} + canReopen={reopenable(tabs, (id) => byId.has(id)) !== undefined} splitFor={splitActionFor} onPick={activateSoon} onTogglePin={togglePin} diff --git a/plugins/top-tabs/lib/controller.ts b/plugins/top-tabs/lib/controller.ts index 0ccd54a..f82a400 100644 --- a/plugins/top-tabs/lib/controller.ts +++ b/plugins/top-tabs/lib/controller.ts @@ -10,6 +10,8 @@ export interface TabsController { active(): TabId | null; activate(id: TabId): void; close(id: TabId): void; + /** Reset a pinned tab to its start and leave it, still pinned. */ + closePinned(id: TabId): void; cycle(direction: 1 | -1): void; reopen(): void; openPicker(): void; diff --git a/plugins/top-tabs/lib/tabs-model.ts b/plugins/top-tabs/lib/tabs-model.ts index f7b88c8..e94bbfd 100644 --- a/plugins/top-tabs/lib/tabs-model.ts +++ b/plugins/top-tabs/lib/tabs-model.ts @@ -24,6 +24,11 @@ export interface ClosedTab { path: string | null; /** Its position in `open`, so reopening puts it back where it was. */ index: number; + /** + * A pinned tab that was reset rather than closed. It is still open, so + * reopening it gives back its location instead of a slot in the strip. + */ + reset?: true; } export interface TabsState { @@ -119,12 +124,13 @@ export function parseState(raw: unknown): TabsState { if (Array.isArray(record.closed)) { for (const entry of record.closed) { if (typeof entry !== "object" || entry === null) continue; - const { id, path, index } = entry as Record; + const { id, path, index, reset } = entry as Record; if (!isTabId(id) || id === THREADS) continue; closed.push({ id, path: isAppPath(path) ? path : null, index: typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : 0, + ...(reset === true ? { reset: true as const } : {}), }); if (closed.length === CLOSED_LIMIT) break; } @@ -319,9 +325,16 @@ export function unpin(state: TabsState, id: TabId): TabsState { return { ...state, pinned, open: [...pinned, id, ...others] }; } +/** + * Note where `id` is. A reset pin the user has gone back to has been taken + * as it is, so ⌃⇧T no longer gives back the location it forgot. + */ export function recordPath(state: TabsState, id: TabId, path: string): TabsState { if (!isAppPath(path) || state.paths[id] === path) return state; - return { ...state, paths: { ...state.paths, [id]: path } }; + const closed = state.closed.some((c) => c.id === id && c.reset) + ? state.closed.filter((c) => c.id !== id) + : state.closed; + return { ...state, paths: { ...state.paths, [id]: path }, closed }; } /** Fill an empty strip once, from the destinations the sidebar showed. */ @@ -345,49 +358,123 @@ function remember(closed: readonly ClosedTab[], entries: readonly ClosedTab[]): ); } +/** + * Where closing the tab in view goes: to its right-hand neighbour, as a + * browser does, or back to the tab in view before it, as VS Code does. + */ +export type CloseOrder = "position" | "recent"; + +/** The `recentAfterClose` setting. */ +export function closeOrderOf(value: unknown): CloseOrder { + return value === true ? "recent" : "position"; +} + +/** The tab most recently in view that is still open, other than `id`. */ +function lastInView( + open: readonly string[], + recent: readonly TabId[], + id: string, + skip: readonly string[] = [], +): TabId | undefined { + return recent.find( + (other) => other !== id && !skip.includes(other) && (other === THREADS || open.includes(other)), + ); +} + /** * The tab to show after closing `id` while `active` is in view. * * Closing a background tab changes nothing. Closing the one in view moves to * its right-hand neighbour, as a browser does, then its left, then Threads. + * In recent order it goes back to the tab in view before it instead, and to + * the neighbour once no tab it remembers is still open. */ export function successorAfterClose( open: readonly string[], id: string, active: TabId | null, + order: CloseOrder = "position", + recent: readonly TabId[] = [], ): TabId | null { if (id !== active) return active; const index = open.indexOf(id); if (index === -1) return active; + if (order === "recent") { + const last = lastInView(open, recent, id); + if (last !== undefined) return last; + } return open[index + 1] ?? open[index - 1] ?? THREADS; } /** - * Whether the strip answers bb's Close on a page shown on its own (bb 0.45+). + * The tab to show after the close-tab command leaves the pinned tab `id`. + * + * It moves past the other pins, as VS Code skips its pinned editors, so + * pressing the shortcut again closes an ordinary tab rather than stepping + * through every pin: the first ordinary tab, or in recent order the one most + * recently in view, and Threads when there is none. + */ +export function successorAfterPinClose( + open: readonly string[], + pinned: readonly string[], + id: string, + order: CloseOrder = "position", + recent: readonly TabId[] = [], +): TabId { + if (order === "recent") { + const last = lastInView(open, recent, id, pinned); + if (last !== undefined) return last; + } + return open.find((other) => !pinned.includes(other)) ?? THREADS; +} + +/** + * Close a pinned tab without unpinning it, as Arc does: it stays in the + * strip and forgets where it was left, so it next opens at its start. The + * location goes on the closed list, so ⌃⇧T can give it back. + */ +export function resetPinned(state: TabsState, id: TabId): TabsState { + const path = state.paths[id]; + if (!state.pinned.includes(id) || path === undefined) return state; + const entry: ClosedTab = { id, path, index: state.open.indexOf(id), reset: true }; + return { + ...state, + paths: withoutPath(state.paths, id), + closed: remember(state.closed, [entry]), + }; +} + +/** + * What the strip does with bb's Close on a page shown on its own (bb 0.45+). * * bb opens New Thread there. For a destination tab that would leave the tab - * open behind Threads, so the strip closes it instead, as the tab's own × - * does. Threads keeps bb's behaviour, and so does a pinned tab, which never - * closes: bb leaves the page and the tab stays. + * open behind Threads, so the strip answers it as the close-tab command does: + * it closes the tab, or resets a pinned one and leaves it, pinned. Null when + * bb keeps its own behaviour: on Threads, where New Thread is where it should + * go anyway, and on pages no tab holds. */ -export function stripTakesPageClose(active: TabId | null, pinned: readonly string[]): boolean { - return active !== null && active !== THREADS && !pinned.includes(active); +export function pageCloseAction( + active: TabId | null, + pinned: readonly string[], +): "tab" | "pin" | null { + if (active === null || active === THREADS) return null; + return pinned.includes(active) ? "pin" : "tab"; } /** - * What the close-tab command does: close the destination tab in view, or, on - * Threads, which cannot close, press bb's Close on the lone thread page so it - * opens New Thread. Null when there is nothing to close: a pinned tab, or - * Threads with no Close on screen (a split, or the compose screen itself). + * What the close-tab command does: what bb's Close does on a destination tab + * (see pageCloseAction), or, on Threads, which cannot close, press bb's Close + * on the lone thread page so it opens New Thread. Null when there is nothing + * to close: Threads with no Close on screen (a split, or the compose screen + * itself). */ export function closeCommandAction( active: TabId | null, pinned: readonly string[], hasPageClose: boolean, -): "tab" | "page" | null { - if (active === null) return null; +): "tab" | "pin" | "page" | null { if (active === THREADS) return hasPageClose ? "page" : null; - return pinned.includes(active) ? null : "tab"; + return pageCloseAction(active, pinned); } /** Close tabs. Pinned tabs are skipped: unpin a tab to close it. */ @@ -424,15 +511,39 @@ export function closeToRight(state: TabsState, of: TabId): TabsState { } /** - * Reopen the most recently closed tab that can still open. - * `isAvailable` filters out destinations whose plugin has since gone away. + * The most recently closed tab that can still open, or reset pin that is + * still open. `isAvailable` filters out destinations whose plugin has since + * gone away. A closed tab reopened another way since is passed over. + */ +export function reopenable( + state: TabsState, + isAvailable: (id: string) => boolean, +): ClosedTab | undefined { + return state.closed.find( + (c) => isAvailable(c.id) && state.open.includes(c.id) === (c.reset === true), + ); +} + +/** + * Reopen the most recently closed tab, or give a reset pin back its + * location. See `reopenable`. */ export function reopen( state: TabsState, isAvailable: (id: string) => boolean, ): { state: TabsState; tab: ClosedTab | null } { - const tab = state.closed.find((c) => isAvailable(c.id) && !state.open.includes(c.id)); + const tab = reopenable(state, isAvailable); if (tab === undefined) return { state, tab: null }; + if (tab.reset) { + return { + state: { + ...state, + paths: tab.path === null ? state.paths : { ...state.paths, [tab.id]: tab.path }, + closed: state.closed.filter((c) => c !== tab), + }, + tab, + }; + } const open = [...state.open]; // Back in its old slot, but never among the pinned tabs. const index = Math.max(state.pinned.length, Math.min(tab.index, open.length)); @@ -620,7 +731,7 @@ export function threadPaneFor( return threads.find((pane) => pane.threadId === savedThreadId) ?? threads[0] ?? null; } -/** Note that `tab` is in view, for choosing a split partner later. */ +/** Note that `tab` is in view, for choosing a split partner or where a close goes. */ export function recordRecent(state: TabsState, tab: TabId): TabsState { if (state.recent[0] === tab) return state; return { diff --git a/plugins/top-tabs/package-lock.json b/plugins/top-tabs/package-lock.json index 6eb418d..2473cb2 100644 --- a/plugins/top-tabs/package-lock.json +++ b/plugins/top-tabs/package-lock.json @@ -1,12 +1,12 @@ { "name": "bb-plugin-top-tabs", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bb-plugin-top-tabs", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@get-bb/plugin-sdk": "0.5.29", diff --git a/plugins/top-tabs/package.json b/plugins/top-tabs/package.json index fe61d0b..aabdc97 100644 --- a/plugins/top-tabs/package.json +++ b/plugins/top-tabs/package.json @@ -1,6 +1,6 @@ { "name": "bb-plugin-top-tabs", - "version": "0.1.1", + "version": "0.2.0", "private": true, "license": "MIT", "type": "module", diff --git a/plugins/top-tabs/server.ts b/plugins/top-tabs/server.ts index dfde607..9885163 100644 --- a/plugins/top-tabs/server.ts +++ b/plugins/top-tabs/server.ts @@ -29,6 +29,13 @@ export default async function plugin(bb: BbPluginApi) { "Escape, Back to app or going back closes the tab, as if Settings were a dialog. Switching tabs in the strip leaves it open, and a pinned Settings tab never closes.", default: true, }, + recentAfterClose: { + type: "boolean", + label: "After closing a tab, go back to the last one you used", + description: + "When off, closing the tab in view moves to its right-hand neighbour, as a browser does. When on, it returns to the tab you were on before it, as VS Code does.", + default: false, + }, tabLabels: { type: "select", label: "Tab labels", diff --git a/plugins/top-tabs/tests/tabs-model.test.ts b/plugins/top-tabs/tests/tabs-model.test.ts index 1236527..4d1d198 100644 --- a/plugins/top-tabs/tests/tabs-model.test.ts +++ b/plugins/top-tabs/tests/tabs-model.test.ts @@ -10,6 +10,7 @@ import { adopt, close, closeCommandAction, + closeOrderOf, closeOthers, closeToRight, cycle, @@ -30,11 +31,14 @@ import { recordRecent, recordRecentThread, reopen, + reopenable, + resetPinned, seed, sidebarStep, splitPartner, - stripTakesPageClose, + pageCloseAction, successorAfterClose, + successorAfterPinClose, threadGroup, threadIdFromPath, threadPaneFor, @@ -228,25 +232,128 @@ test("closing the tab in view moves right, then left, then to Threads", () => { test("closing a background tab keeps the tab in view", () => { assert.equal(successorAfterClose(["a", "b"], "a", "b"), "b"); assert.equal(successorAfterClose(["a", "b"], "a", THREADS), THREADS); + assert.equal(successorAfterClose(["a", "b"], "a", "b", "recent", ["b", "a"]), "b"); +}); + +test("in recent order, closing the tab in view goes back to the one before it", () => { + const open = ["a", "b", "c"]; + assert.equal(successorAfterClose(open, "b", "b", "recent", ["b", "a", "c"]), "a"); + assert.equal(successorAfterClose(open, "b", "b", "recent", ["b", THREADS, "c"]), THREADS); + // Tabs that have since closed, or whose plugin is not loaded, are passed over. + assert.equal(successorAfterClose(open, "b", "b", "recent", ["b", "gone", "c"]), "c"); + // With nothing remembered still open, it falls back to the neighbour. + assert.equal(successorAfterClose(open, "b", "b", "recent", ["b", "gone"]), "c"); + assert.equal(successorAfterClose(open, "c", "c", "recent", []), "b"); +}); + +test("the recentAfterClose setting picks the close order", () => { + assert.equal(closeOrderOf(true), "recent"); + assert.equal(closeOrderOf(false), "position"); + assert.equal(closeOrderOf(undefined), "position"); + assert.equal(closeOrderOf("yes"), "position"); +}); + +test("leaving a pinned tab moves past the other pins", () => { + const open = ["p", "q", "a", "b"]; + const pinned = ["p", "q"]; + assert.equal(successorAfterPinClose(open, pinned, "p"), "a"); + assert.equal(successorAfterPinClose(open, pinned, "q"), "a"); + assert.equal(successorAfterPinClose(["p", "q"], pinned, "p"), THREADS); +}); + +test("in recent order, leaving a pinned tab goes to the ordinary tab used last", () => { + const open = ["p", "q", "a", "b"]; + const pinned = ["p", "q"]; + assert.equal(successorAfterPinClose(open, pinned, "p", "recent", ["p", "q", "b", "a"]), "b"); + assert.equal(successorAfterPinClose(open, pinned, "p", "recent", ["p", "q", THREADS, "a"]), THREADS); + assert.equal(successorAfterPinClose(open, pinned, "p", "recent", ["p", "q", "gone"]), "a"); +}); + +test("resetting a pinned tab forgets its location and keeps it pinned", () => { + const s = state({ open: ["p", "a"], pinned: ["p"], paths: { p: "/plugins/p/p/deep", a: "/x" } }); + const reset = resetPinned(s, "p"); + assert.deepEqual(reset.open, ["p", "a"]); + assert.deepEqual(reset.pinned, ["p"]); + assert.deepEqual(reset.paths, { a: "/x" }); + assert.deepEqual(reset.closed, [{ id: "p", path: "/plugins/p/p/deep", index: 0, reset: true }]); + // Only pinned tabs reset, and a pin already at its start is left alone. + assert.equal(resetPinned(s, "a"), s); + assert.equal(resetPinned(reset, "p"), reset); +}); + +test("reopening after a reset gives the pin back its location", () => { + const s = state({ + open: ["p", "a"], + pinned: ["p"], + paths: { p: "/plugins/p/p/deep" }, + closed: [{ id: "b", path: null, index: 2 }], + }); + const { state: back, tab } = reopen(resetPinned(s, "p"), () => true); + assert.equal(tab?.id, "p"); + assert.equal(back.paths.p, "/plugins/p/p/deep"); + assert.deepEqual(back.open, ["p", "a"]); + assert.deepEqual(back.pinned, ["p"]); + // The tab closed before the reset is next. + assert.deepEqual(back.closed, [{ id: "b", path: null, index: 2 }]); + assert.equal(reopen(back, () => true).tab?.id, "b"); +}); + +test("going back to a reset pin takes the reset, so reopening skips it", () => { + const s = state({ + open: ["p", "a"], + pinned: ["p"], + paths: { p: "/plugins/p/p/deep" }, + closed: [{ id: "b", path: null, index: 2 }], + }); + const visited = recordPath(resetPinned(s, "p"), "p", "/plugins/p/p"); + assert.deepEqual(visited.closed, [{ id: "b", path: null, index: 2 }]); + assert.equal(reopen(visited, () => true).tab?.id, "b"); + // Recording another tab's location leaves the reset to undo. + const elsewhere = recordPath(resetPinned(s, "p"), "a", "/plugins/a/a"); + assert.equal(reopenable(elsewhere, () => true)?.id, "p"); +}); + +test("a reset is undoable only while its tab is open", () => { + const entry = { id: "p", path: "/plugins/p/p/deep", index: 0, reset: true as const }; + assert.equal(reopenable(state({ open: ["p"], pinned: ["p"], closed: [entry] }), () => true), entry); + assert.equal(reopenable(state({ open: [], closed: [entry] }), () => true), undefined); + assert.equal(reopenable(state({ open: ["p"], pinned: ["p"], closed: [entry] }), () => false), undefined); + // A closed tab opened again another way is still passed over. + assert.equal(reopenable(state({ open: ["a"], closed: [{ id: "a", path: null, index: 0 }] }), () => true), undefined); +}); + +test("a reset survives a reload", () => { + const entry = { id: "p", path: "/plugins/p/p/deep", index: 0, reset: true }; + const parsed = parseState({ open: ["p"], pinned: ["p"], closed: [entry, { id: "b", path: null, index: 1, reset: "yes" }] }); + assert.deepEqual(parsed.closed, [entry, { id: "b", path: null, index: 1 }]); }); test("bb's Close on a lone page closes the tab in view", () => { - assert.equal(stripTakesPageClose("gh/gh", []), true); - assert.equal(stripTakesPageClose(SETTINGS, []), true); + assert.equal(pageCloseAction("gh/gh", []), "tab"); + assert.equal(pageCloseAction(SETTINGS, []), "tab"); +}); + +test("bb's Close on a pinned tab resets it, as the close-tab command does", () => { + assert.equal(pageCloseAction("gh/gh", ["gh/gh"]), "pin"); + assert.equal(pageCloseAction(SETTINGS, [SETTINGS]), "pin"); + assert.equal(pageCloseAction("gh/gh", ["gh/gh"]), closeCommandAction("gh/gh", ["gh/gh"], true)); }); -test("bb keeps its own Close on Threads, pinned tabs and pages no tab holds", () => { +test("bb keeps its own Close on Threads and pages no tab holds", () => { // bb opens New Thread, which is where Threads should go anyway. - assert.equal(stripTakesPageClose(THREADS, []), false); - // A pinned tab never closes; bb leaves the page and the tab stays. - assert.equal(stripTakesPageClose("gh/gh", ["gh/gh"]), false); - assert.equal(stripTakesPageClose(null, []), false); + assert.equal(pageCloseAction(THREADS, []), null); + assert.equal(pageCloseAction(null, []), null); }); test("the close-tab command closes a destination tab", () => { assert.equal(closeCommandAction("gh/gh", [], false), "tab"); assert.equal(closeCommandAction("gh/gh", [], true), "tab"); - assert.equal(closeCommandAction("gh/gh", ["gh/gh"], true), null); +}); + +test("on a pinned tab, the close-tab command resets it instead", () => { + assert.equal(closeCommandAction("gh/gh", ["gh/gh"], true), "pin"); + assert.equal(closeCommandAction("gh/gh", ["gh/gh"], false), "pin"); + assert.equal(closeCommandAction(SETTINGS, [SETTINGS], false), "pin"); }); test("on Threads, the close-tab command closes the thread page instead", () => {