From 69d206895e0726e390892c703e63700fc50e29f0 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 16:45:57 -0500 Subject: [PATCH 1/2] Reset pinned tabs on close, and add a recent-order close setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Top Tabs: Close tab did nothing on a pinned tab, while bb's own Close on the same page opened New Thread and left the pin where it was. Both now reset the pin, as Arc does: it stays pinned, forgets where it was left so it next opens at the panel's start, and the strip moves past the other pins, as VS Code skips its pinned editors on a keyboard close. Pressing the shortcut again then closes an ordinary tab rather than stepping through every pin. The tab's ×, middle-click and the batch closes still leave pins alone. A new setting, "After closing a tab, go back to the last one you used", off by default, makes every close of the tab in view return to the tab in view before it, from the strip's existing `recent` list, as VS Code's focusRecentEditorAfterClose does. With nothing remembered still open it falls back to the neighbour. Leaving a pin follows it too. The rules are pure functions in tabs-model.ts (successorAfterClose's order, successorAfterPinClose, resetPinned, pageCloseAction, which the close command now shares), with tests. TopTabs holds back the reset pin's location while the strip leaves it, so it is not recorded again on the way out. Bumps Top Tabs to 0.2.0. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 2 +- plugins/top-tabs/PLUGIN_OVERVIEW.md | 6 +- plugins/top-tabs/README.md | 23 +++++- plugins/top-tabs/app.tsx | 4 +- plugins/top-tabs/components/TopTabs.tsx | 55 ++++++++++--- plugins/top-tabs/lib/controller.ts | 2 + plugins/top-tabs/lib/tabs-model.ts | 96 +++++++++++++++++++---- plugins/top-tabs/package-lock.json | 4 +- plugins/top-tabs/package.json | 2 +- plugins/top-tabs/server.ts | 7 ++ plugins/top-tabs/tests/tabs-model.test.ts | 77 +++++++++++++++--- 11 files changed, 234 insertions(+), 44 deletions(-) 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..87f6ae9 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -42,8 +42,10 @@ 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. @@ -116,6 +118,13 @@ 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 doesn't undo a reset. - 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 +262,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 +298,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..4447d56 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,13 @@ import { recordRecent, recordRecentThread, reopen, + resetPinned, seed, sidebarStep, splitPartner, - stripTakesPageClose, + pageCloseAction, successorAfterClose, + successorAfterPinClose, threadIdFromPath, threadPaneFor, unpin, @@ -154,6 +157,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 +191,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 +211,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 +229,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 +389,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 +400,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 +560,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 +571,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. 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..9ffd663 100644 --- a/plugins/top-tabs/lib/tabs-model.ts +++ b/plugins/top-tabs/lib/tabs-model.ts @@ -345,49 +345,117 @@ 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. + */ +export function resetPinned(state: TabsState, id: TabId): TabsState { + if (!state.pinned.includes(id)) return state; + const paths = withoutPath(state.paths, id); + return paths === state.paths ? state : { ...state, paths }; +} + +/** + * 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. */ @@ -620,7 +688,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..ad40982 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,13 @@ import { recordRecent, recordRecentThread, reopen, + resetPinned, seed, sidebarStep, splitPartner, - stripTakesPageClose, + pageCloseAction, successorAfterClose, + successorAfterPinClose, threadGroup, threadIdFromPath, threadPaneFor, @@ -228,25 +231,81 @@ 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, []); + // 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("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 keeps its own Close on Threads, pinned tabs and pages no tab holds", () => { +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 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", () => { From 58cd773439213bdd64a5aa0a570e44d0aba79b95 Mon Sep 17 00:00:00 2001 From: matthewdias Date: Sun, 4 Oct 2026 16:51:52 -0500 Subject: [PATCH 2/2] Let Ctrl+Shift+T undo a pinned-tab reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resetting a pin dropped its location for good. resetPinned now puts it on the closed list as an entry marked `reset`, and reopen gives the pin its location back instead of a new slot in the strip, so ⌃⇧T undoes the last close or reset, whichever came last. The marker is what tells a reset pin, still open, apart from a closed tab reopened another way, which reopen still passes over. Going back to the pin yourself takes the reset as it is: recordPath drops the entry, so a much later ⌃⇧T cannot pull the pin back to an old page. The menus' "Reopen closed tab" asks the same `reopenable` that reopen uses, rather than its own copy of the rule. Resets persist with the rest of the strip. Co-Authored-By: Claude Opus 5.5 --- plugins/top-tabs/README.md | 5 +- plugins/top-tabs/components/TopTabs.tsx | 5 +- plugins/top-tabs/lib/tabs-model.ts | 61 +++++++++++++++++++---- plugins/top-tabs/tests/tabs-model.test.ts | 50 ++++++++++++++++++- 4 files changed, 107 insertions(+), 14 deletions(-) diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 87f6ae9..a9c79fc 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -48,7 +48,7 @@ header, the strip keeps working from the last list it saw. 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. @@ -124,7 +124,8 @@ A pinned tab is one you always want in the strip: 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 doesn't undo a reset. + 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. diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index 4447d56..f670a91 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -57,6 +57,7 @@ import { recordRecent, recordRecentThread, reopen, + reopenable, resetPinned, seed, sidebarStep, @@ -650,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), @@ -822,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/tabs-model.ts b/plugins/top-tabs/lib/tabs-model.ts index 9ffd663..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. */ @@ -417,12 +430,18 @@ export function successorAfterPinClose( /** * 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. + * 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 { - if (!state.pinned.includes(id)) return state; - const paths = withoutPath(state.paths, id); - return paths === state.paths ? state : { ...state, paths }; + 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]), + }; } /** @@ -492,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)); diff --git a/plugins/top-tabs/tests/tabs-model.test.ts b/plugins/top-tabs/tests/tabs-model.test.ts index ad40982..4d1d198 100644 --- a/plugins/top-tabs/tests/tabs-model.test.ts +++ b/plugins/top-tabs/tests/tabs-model.test.ts @@ -31,6 +31,7 @@ import { recordRecent, recordRecentThread, reopen, + reopenable, resetPinned, seed, sidebarStep, @@ -274,12 +275,59 @@ test("resetting a pinned tab forgets its location and keeps it pinned", () => { assert.deepEqual(reset.open, ["p", "a"]); assert.deepEqual(reset.pinned, ["p"]); assert.deepEqual(reset.paths, { a: "/x" }); - assert.deepEqual(reset.closed, []); + 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(pageCloseAction("gh/gh", []), "tab"); assert.equal(pageCloseAction(SETTINGS, []), "tab");