diff --git a/plugins/top-tabs/PLUGIN_OVERVIEW.md b/plugins/top-tabs/PLUGIN_OVERVIEW.md index 2f038d8..de208c4 100644 --- a/plugins/top-tabs/PLUGIN_OVERVIEW.md +++ b/plugins/top-tabs/PLUGIN_OVERVIEW.md @@ -21,7 +21,8 @@ middle-click to close, Ctrl+Shift+T to reopen. it was on: the pull request inside GitHub, not just GitHub. **The sidebar gets out of the way.** It slides away on other tabs, so the -destination has the whole window, and returns with Threads, as you left it. +destination has the whole window, and returns with Threads. Each tab keeps it +as you left it there, so a panel you like beside the thread list keeps it. The sidebar keeps bb's own navigation, so you can reorder, hide and split from it as always. diff --git a/plugins/top-tabs/README.md b/plugins/top-tabs/README.md index 5917e4f..93329e9 100644 --- a/plugins/top-tabs/README.md +++ b/plugins/top-tabs/README.md @@ -8,9 +8,10 @@ comes back where you left it. **Threads** is the first tab, and it never closes. It is bb as you know it: the sidebar, the thread list and the thread in view. Every other tab is a -destination — a plugin panel, Plugins, Skills. While another tab is in view, -the sidebar slides away and the destination gets the whole window. It returns -when you go back to Threads. +destination — a plugin panel, Plugins, Skills. Each tab keeps the sidebar as +you left it there: it slides away on a destination, which gets the whole +window, and returns when you go back to Threads. Open it on a tab and it stays +open on that tab. ## Install @@ -101,9 +102,9 @@ settings page you left it on. Two things set it apart: - **Its navigation is the sidebar.** On Settings, bb fills the sidebar with - Settings' own sections, so arriving on the tab opens the sidebar. Leaving - undoes that: another tab collapses it, and Threads gets back the sidebar - you keep there. + Settings' own sections, so the tab opens with the sidebar open. Collapse it + there and it stays collapsed on Settings. Leaving gives every other tab back + its own. - **It can't go in a split**, because bb doesn't put Settings in a pane. - **Leaving Settings closes it.** Escape, Back to app or the browser's back closes the tab, as if Settings were a dialog. Switching tabs in the strip @@ -223,18 +224,18 @@ strip stays off them, with Ctrl+Tab, Ctrl+Shift+Tab and Ctrl+Shift+T. ### The sidebar -The sidebar belongs to Threads: - -- **Leaving Threads** collapses the sidebar if it was open, and remembers that - it was. -- **Returning to Threads** reopens it, but only if it was open when you left. - The same applies when the app loads straight onto a thread. -- **Collapsing it yourself on Threads** keeps it collapsed there. The strip - only ever restores your own choice, and Threads never collapses it. -- **Opening it by hand on another tab** keeps it open until you go back to - Threads. -- **Settings** opens the sidebar for its own sections, and leaving it - undoes that. See [The Settings tab](#the-settings-tab). +Each tab keeps the sidebar as you left it: + +- **Leaving a tab** remembers whether the sidebar was open there. +- **Arriving on a tab** opens or collapses the sidebar to match. The same + applies when the app loads straight onto a tab. +- **Opening or collapsing it yourself** is remembered for the tab you're on. + Open it on GitHub and it's open whenever you're on GitHub, and collapsed + again on the tabs where you left it collapsed. +- **A tab you haven't set** starts with the sidebar collapsed, so the + destination gets the whole window. Threads starts however it was the first + time the strip saw it, and Settings starts open for its own sections. See + [The Settings tab](#the-settings-tab). - **A split** pauses all of this until it closes. When the strip moves the sidebar as part of a switch, the space it takes @@ -250,9 +251,10 @@ title moves with them. Coming back from a page with a sidebar of its own bb has the thread list ready, and the thread list is what slides in. Leaving Threads, the sidebar stays over the new page until bb has drawn it, then slides away as the page fades in; opening Settings, its sections slide -in the same way. Only these changes of layout move: going between other -tabs keeps the layout, and switches instantly, as a browser's tabs do. -Opening or closing the sidebar yourself still slides as bb draws it. With reduced motion on, nothing animates. +in the same way. Only these changes of layout move. Any other the strip +makes, such as arriving on a tab where you keep the sidebar open, is instant, +as a browser's tabs switch. Opening or closing the sidebar yourself still +slides as bb draws it. With reduced motion on, nothing animates. Above the thread list, the sidebar keeps bb's own navigation, unchanged. Its rows, drag-to-reorder, options menu, More, customize editor and @@ -274,12 +276,12 @@ rows, hidden ones behind **More**. | | Default | | --- | --- | -| Collapse the sidebar on other tabs | on | +| Remember the sidebar on each tab | 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 +**Remember 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 diff --git a/plugins/top-tabs/components/TopTabs.tsx b/plugins/top-tabs/components/TopTabs.tsx index 0a7dacd..340b028 100644 --- a/plugins/top-tabs/components/TopTabs.tsx +++ b/plugins/top-tabs/components/TopTabs.tsx @@ -62,6 +62,7 @@ import { recordPath, recordRecent, recordRecentThread, + rememberSidebar, reopen, reopenable, resetPinned, @@ -241,9 +242,11 @@ export function TopTabs() { }); }, [active, path, byId]); - // The sidebar belongs to Threads; see sidebarStep for the rules. A split is - // the user's own arrangement, and the sidebar is where they drag threads - // into it from, so while one is up the strip leaves the sidebar alone. + // Each tab keeps the sidebar as the user left it; see sidebarStep for the + // rules. A split is the user's own arrangement, and the sidebar is where + // they drag threads into it from, so while one is up the strip leaves the + // sidebar alone. `previous` is the tab the strip has brought the sidebar + // in line with, which a tab click sets before the route catches up. const previous = useRef(undefined); const wasInSplit = useRef(false); @@ -267,17 +270,8 @@ export function TopTabs() { if (compact || !collapseSidebar || inSplit) return; const sidebarOpen = isSidebarOpen(); if (sidebarOpen === null) return; - const step = sidebarStep({ - previous: before, - next, - sidebarOpen, - threadsSidebarOpen: getState().threadsSidebarOpen, - }); - update((s) => - s.threadsSidebarOpen === step.threadsSidebarOpen - ? s - : { ...s, threadsSidebarOpen: step.threadsSidebarOpen }, - ); + const step = sidebarStep({ previous: before, next, sidebarOpen, sidebar: getState().sidebar }); + update((s) => (s.sidebar === step.sidebar ? s : { ...s, sidebar: step.sidebar })); if (step.action === null) return; // The sidebar slides in coming back to Threads or opening Settings, and // out leaving Threads, on the compositor; see playEntrance and @@ -292,20 +286,26 @@ export function TopTabs() { useEffect(() => { // When a split closes, look at what is left afresh, as on first load: the - // sidebar comes back on Threads and slides away on any other tab. + // tab in view gets back the sidebar it keeps. if (wasInSplit.current && !inSplit) previous.current = undefined; wasInSplit.current = inSplit; syncSidebar(active); }, [active, compact, collapseSidebar, inSplit, syncSidebar]); - // While Threads is in view, the user's own toggling is their preference. - // The strip's toggles happen once another tab is already active, or set - // the preference they restore, so they never record anything wrong. + // The user's own toggling is the preference of the tab they are on. It is + // filed under `previous`, not the route: a tab click toggles the sidebar + // before it navigates, and the route still names the tab being left. The + // strip's own toggles set the state the tab already wants, so recording + // them changes nothing. useEffect(() => { - if (active !== THREADS || compact || !collapseSidebar) return; + if (compact || !collapseSidebar) return; return observeSidebar((open) => { - if (live.current.active !== THREADS) return; - update((s) => (s.threadsSidebarOpen === open ? s : { ...s, threadsSidebarOpen: open })); + const tab = previous.current; + if (tab === undefined || tab === null || live.current.inSplit) return; + update((s) => { + const sidebar = rememberSidebar(s.sidebar, tab, open); + return sidebar === s.sidebar ? s : { ...s, sidebar }; + }); }); }, [active, compact, collapseSidebar]); diff --git a/plugins/top-tabs/lib/tabs-model.ts b/plugins/top-tabs/lib/tabs-model.ts index e94bbfd..023f0b7 100644 --- a/plugins/top-tabs/lib/tabs-model.ts +++ b/plugins/top-tabs/lib/tabs-model.ts @@ -18,6 +18,9 @@ export const SETTINGS = "__top-tabs__/settings"; export type TabId = string; +/** Whether the sidebar is open on each tab, Threads included. */ +export type SidebarMemory = Readonly>; + export interface ClosedTab { id: string; /** Where the tab was when it closed, so reopening lands in the same place. */ @@ -48,12 +51,12 @@ export interface TabsState { /** Recently closed tabs, most recent first. */ closed: readonly ClosedTab[]; /** - * Whether the user keeps the sidebar open on Threads: what it was the last - * time they left Threads, or last set it to while there. It is the state to - * restore on the way back, so collapsing the sidebar on Threads keeps it - * collapsed there. Null until the strip has seen Threads once. + * Whether the user keeps the sidebar open on each tab: what it was the last + * time they left the tab, or last set it to while there. It is the state to + * restore on the way back, so collapsing the sidebar on a tab keeps it + * collapsed there. A tab is missing until the strip has seen it. */ - threadsSidebarOpen: boolean | null; + sidebar: SidebarMemory; /** * Set once the strip has been filled from the sidebar's visible items, the * first time the plugin runs, so the destinations the user kept in the @@ -71,7 +74,7 @@ export const EMPTY_STATE: TabsState = { pinned: [], paths: {}, closed: [], - threadsSidebarOpen: null, + sidebar: {}, seeded: false, recent: [], recentThreads: [], @@ -145,8 +148,7 @@ export function parseState(raw: unknown): TabsState { pinned, paths, closed, - threadsSidebarOpen: - typeof record.threadsSidebarOpen === "boolean" ? record.threadsSidebarOpen : null, + sidebar: parseSidebar(record), seeded: record.seeded === true, recent: Array.isArray(record.recent) ? [...new Set(record.recent.filter(isTabId))].slice(0, RECENT_LIMIT) @@ -157,6 +159,20 @@ export function parseState(raw: unknown): TabsState { }; } +function parseSidebar(record: Record): SidebarMemory { + const sidebar: Record = {}; + if (typeof record.sidebar === "object" && record.sidebar !== null) { + for (const [id, open] of Object.entries(record.sidebar)) { + if (isTabId(id) && typeof open === "boolean") sidebar[id] = open; + } + } + // Before each tab had its own, only Threads remembered the sidebar. + if (!(THREADS in sidebar) && typeof record.threadsSidebarOpen === "boolean") { + sidebar[THREADS] = record.threadsSidebarOpen; + } + return sidebar; +} + /** * A destination as the router sees it: the nav item's id and action, which * is all bb tells a plugin about where an item leads. @@ -613,51 +629,52 @@ export interface SidebarStepInput { previous: TabId | null | undefined; next: TabId | null; sidebarOpen: boolean; - threadsSidebarOpen: boolean | null; + /** Whether the sidebar was open on each tab when the user left it. */ + sidebar: SidebarMemory; } export interface SidebarStep { action: "collapse" | "expand" | null; - threadsSidebarOpen: boolean | null; + sidebar: SidebarMemory; +} + +/** + * Whether the sidebar is open on a tab the strip has no memory of. Settings + * opens it, because bb fills the sidebar with Settings' own sections there; + * any other destination collapses it, so the page gets the whole window. + * Threads has no default: the strip learns it from what it finds there. + */ +function sidebarDefault(tab: TabId): boolean | undefined { + if (tab === THREADS) return undefined; + return tab === SETTINGS; +} + +/** `memory` with `tab` set to `open`, or `memory` itself if it already was. */ +export function rememberSidebar(memory: SidebarMemory, tab: TabId, open: boolean): SidebarMemory { + return memory[tab] === open ? memory : { ...memory, [tab]: open }; } /** * What the sidebar should do when the tab in view changes. * - * The sidebar belongs to Threads. Leaving Threads records whether it was open - * and collapses it; arriving on Threads — by switching back, or by loading - * the app there — reopens it if the user keeps it open there. Threads never - * collapses it: a sidebar the user opened is theirs. Between two other tabs - * nothing happens, so a sidebar opened by hand on a tab stays open until - * Threads. A route that belongs to no tab leaves it alone. - * - * Settings is the exception among tabs. bb fills the sidebar with Settings' - * own sections there, so arriving on Settings opens it. Since that was - * Settings' doing, not the user's, leaving Settings undoes it: another tab - * collapses it as usual, and Threads gets back the state the user keeps. + * Each tab keeps the sidebar as the user left it there. Leaving a tab records + * whether the sidebar was open; arriving on one — by switching, or by loading + * the app there — opens or collapses it to match. A tab never seen before + * starts from `sidebarDefault`. A route that belongs to no tab leaves the + * sidebar alone. */ export function sidebarStep(input: SidebarStepInput): SidebarStep { const { previous, next, sidebarOpen } = input; - let { threadsSidebarOpen } = input; - if (previous === next || next === null) return { action: null, threadsSidebarOpen }; - if (previous === THREADS) threadsSidebarOpen = sidebarOpen; - - if (next === THREADS) { - // Never seen Threads: learn the preference instead of imposing one. - if (threadsSidebarOpen === null) return { action: null, threadsSidebarOpen: sidebarOpen }; - if (threadsSidebarOpen && !sidebarOpen) return { action: "expand", threadsSidebarOpen }; - // Back from Settings, which opened it: the user keeps it collapsed here. - if (previous === SETTINGS && !threadsSidebarOpen && sidebarOpen) { - return { action: "collapse", threadsSidebarOpen }; - } - return { action: null, threadsSidebarOpen }; + let { sidebar } = input; + if (previous === next || next === null) return { action: null, sidebar }; + if (previous !== undefined && previous !== null) { + sidebar = rememberSidebar(sidebar, previous, sidebarOpen); } - - if (next === SETTINGS) return { action: sidebarOpen ? null : "expand", threadsSidebarOpen }; - - const arrivingFromAfar = - previous === THREADS || previous === SETTINGS || previous === undefined || previous === null; - return { action: arrivingFromAfar && sidebarOpen ? "collapse" : null, threadsSidebarOpen }; + const want = sidebar[next] ?? sidebarDefault(next); + // Never seen Threads: learn the preference instead of imposing one. + if (want === undefined) return { action: null, sidebar: rememberSidebar(sidebar, next, sidebarOpen) }; + if (want === sidebarOpen) return { action: null, sidebar }; + return { action: want ? "expand" : "collapse", sidebar }; } // ---------------------------------------------------------------- splits diff --git a/plugins/top-tabs/package.json b/plugins/top-tabs/package.json index aabdc97..edb2c74 100644 --- a/plugins/top-tabs/package.json +++ b/plugins/top-tabs/package.json @@ -28,7 +28,7 @@ "name": "Top Tabs", "description": "Opens bb destinations as tabs across the top of the window, beside a permanent Threads tab, so you can keep several open and switch without losing your place.", "branding": { - "icon": "Columns" + "icon": "AppWindow" }, "server": "./server.ts", "app": "./app.tsx" diff --git a/plugins/top-tabs/server.ts b/plugins/top-tabs/server.ts index 9885163..c50e76f 100644 --- a/plugins/top-tabs/server.ts +++ b/plugins/top-tabs/server.ts @@ -17,9 +17,9 @@ export default async function plugin(bb: BbPluginApi) { bb.settings.define({ collapseSidebar: { type: "boolean", - label: "Collapse the sidebar on other tabs", + label: "Remember the sidebar on each tab", description: - "The thread list belongs to the Threads tab. When on, it slides away while another tab is in view and comes back with Threads, as it was when you left.", + "Each tab keeps the sidebar open or collapsed as you left it there. A tab you haven't set starts collapsed, so it gets the whole window, and Settings starts open for its sections. When off, the sidebar stays wherever you leave it.", default: true, }, closeSettingsOnExit: { diff --git a/plugins/top-tabs/tests/tabs-model.test.ts b/plugins/top-tabs/tests/tabs-model.test.ts index 4d1d198..30ec393 100644 --- a/plugins/top-tabs/tests/tabs-model.test.ts +++ b/plugins/top-tabs/tests/tabs-model.test.ts @@ -30,6 +30,7 @@ import { recordPath, recordRecent, recordRecentThread, + rememberSidebar, reopen, reopenable, resetPinned, @@ -437,93 +438,69 @@ test("cycle wraps in both directions", () => { assert.equal(cycle(order, null, -1), "b"); }); -test("leaving Threads collapses an open sidebar and remembers it was open", () => { - assert.deepEqual( - sidebarStep({ previous: THREADS, next: "a", sidebarOpen: true, threadsSidebarOpen: false }), - { action: "collapse", threadsSidebarOpen: true }, - ); -}); +const step = ( + previous: string | null | undefined, + next: string | null, + sidebarOpen: boolean, + sidebar: Record = {}, +) => sidebarStep({ previous, next, sidebarOpen, sidebar }); -test("leaving Threads with the sidebar collapsed remembers that too", () => { - assert.deepEqual( - sidebarStep({ previous: THREADS, next: "a", sidebarOpen: false, threadsSidebarOpen: true }), - { action: null, threadsSidebarOpen: false }, - ); +test("leaving a tab remembers whether the sidebar was open there", () => { + assert.deepEqual(step(THREADS, "a", true).sidebar, { [THREADS]: true }); + assert.deepEqual(step(THREADS, "a", false, { [THREADS]: true }).sidebar, { [THREADS]: false }); + assert.deepEqual(step("a", "b", true).sidebar, { a: true }); }); -test("returning to Threads reopens the sidebar only if it was open", () => { - assert.equal( - sidebarStep({ previous: "a", next: THREADS, sidebarOpen: false, threadsSidebarOpen: true }) - .action, - "expand", - ); - assert.equal( - sidebarStep({ previous: "a", next: THREADS, sidebarOpen: false, threadsSidebarOpen: false }) - .action, - null, - ); - assert.equal( - sidebarStep({ previous: "a", next: THREADS, sidebarOpen: true, threadsSidebarOpen: true }) - .action, - null, - ); +test("arriving on a tab restores the sidebar it had there", () => { + assert.equal(step("a", THREADS, false, { [THREADS]: true }).action, "expand"); + assert.equal(step("a", THREADS, true, { [THREADS]: false }).action, "collapse"); + assert.equal(step(THREADS, "a", false, { a: true }).action, "expand"); + assert.equal(step(THREADS, "a", true, { a: false }).action, "collapse"); + assert.equal(step("a", THREADS, true, { [THREADS]: true }).action, null); }); -test("moving between two tabs leaves a hand-opened sidebar alone", () => { - assert.deepEqual( - sidebarStep({ previous: "a", next: "b", sidebarOpen: true, threadsSidebarOpen: true }), - { action: null, threadsSidebarOpen: true }, - ); +test("each tab keeps its own sidebar", () => { + // Opened by hand on a, then off to b, which keeps its own collapsed. + const memory = { [THREADS]: true, b: false }; + const toB = step("a", "b", true, memory); + assert.deepEqual(toB, { action: "collapse", sidebar: { [THREADS]: true, a: true, b: false } }); + // And back on a, it opens again. + assert.equal(step("b", "a", false, toB.sidebar).action, "expand"); }); -test("the first look collapses on a tab", () => { - assert.equal( - sidebarStep({ previous: undefined, next: "a", sidebarOpen: true, threadsSidebarOpen: true }) - .action, - "collapse", - ); +test("a tab the strip has not seen collapses the sidebar", () => { + assert.equal(step(THREADS, "a", true).action, "collapse"); + assert.equal(step(undefined, "a", true).action, "collapse"); + assert.equal(step("b", "a", false).action, null); }); -test("loading the app on Threads restores the sidebar the user keeps there", () => { +test("loading the app on a tab restores its sidebar, recording nothing", () => { // Reloaded on a thread after a tab had collapsed the sidebar. - assert.equal( - sidebarStep({ previous: undefined, next: THREADS, sidebarOpen: false, threadsSidebarOpen: true }) - .action, - "expand", - ); - assert.equal( - sidebarStep({ previous: undefined, next: THREADS, sidebarOpen: false, threadsSidebarOpen: false }) - .action, - null, - ); + assert.deepEqual(step(undefined, THREADS, false, { [THREADS]: true }), { + action: "expand", + sidebar: { [THREADS]: true }, + }); + assert.equal(step(undefined, THREADS, false, { [THREADS]: false }).action, null); + assert.equal(step(undefined, "a", false, { a: true }).action, "expand"); + assert.deepEqual(step(null, "a", true, {}).sidebar, {}); }); -test("Threads never collapses the sidebar", () => { - for (const previous of [undefined, null, "a"]) { - assert.equal( - sidebarStep({ previous, next: THREADS, sidebarOpen: true, threadsSidebarOpen: false }).action, - null, - ); - } +test("the first sight of Threads learns the preference instead of imposing one", () => { + assert.deepEqual(step(undefined, THREADS, false), { action: null, sidebar: { [THREADS]: false } }); + assert.deepEqual(step("a", THREADS, true), { action: null, sidebar: { a: true, [THREADS]: true } }); }); -test("the first sight of Threads learns the preference instead of imposing one", () => { - assert.deepEqual( - sidebarStep({ previous: undefined, next: THREADS, sidebarOpen: false, threadsSidebarOpen: null }), - { action: null, threadsSidebarOpen: false }, - ); - assert.deepEqual( - sidebarStep({ previous: "a", next: THREADS, sidebarOpen: true, threadsSidebarOpen: null }), - { action: null, threadsSidebarOpen: true }, - ); +test("a route no tab holds, or the same tab, leaves the sidebar alone", () => { + const memory = { [THREADS]: false }; + assert.deepEqual(step(THREADS, null, true, memory), { action: null, sidebar: memory }); + assert.deepEqual(step("a", "a", true, { a: false }), { action: null, sidebar: { a: false } }); }); -test("settings leaves the sidebar alone", () => { - assert.equal( - sidebarStep({ previous: THREADS, next: null, sidebarOpen: true, threadsSidebarOpen: true }) - .action, - null, - ); +test("rememberSidebar keeps the same object when nothing changes", () => { + const memory = { a: true }; + assert.equal(rememberSidebar(memory, "a", true), memory); + assert.deepEqual(rememberSidebar(memory, "a", false), { a: false }); + assert.deepEqual(rememberSidebar(memory, "b", false), { a: true, b: false }); }); test("parseState keeps the well-formed parts of hostile input", () => { @@ -533,7 +510,8 @@ test("parseState keeps the well-formed parts of hostile input", () => { open: ["a", "a", THREADS, 4, "", "b"], paths: { a: "/plugins/a/a", b: "//evil", c: 3 }, closed: [{ id: "c", path: "https://x", index: -1 }, { id: THREADS }, "junk"], - threadsSidebarOpen: "yes", + sidebar: { a: true, b: "yes", [""]: false }, + threadsSidebarOpen: false, seeded: true, recent: ["a", "a", 3, "b"], }); @@ -542,7 +520,7 @@ test("parseState keeps the well-formed parts of hostile input", () => { pinned: [], paths: { a: "/plugins/a/a" }, closed: [{ id: "c", path: null, index: 0 }], - threadsSidebarOpen: null, + sidebar: { a: true, [THREADS]: false }, seeded: true, recent: ["a", "b"], recentThreads: [], @@ -688,38 +666,17 @@ test("moveBefore places a tab in front of another in its group, or at the group' assert.deepEqual(moveBefore(two, "p", null).open, ["a", "p", "b", "c"]); }); -test("arriving on Settings opens the sidebar its sections live in", () => { - assert.deepEqual( - sidebarStep({ previous: "a", next: SETTINGS, sidebarOpen: false, threadsSidebarOpen: true }), - { action: "expand", threadsSidebarOpen: true }, - ); - assert.equal( - sidebarStep({ previous: undefined, next: SETTINGS, sidebarOpen: true, threadsSidebarOpen: true }) - .action, - null, - ); - // From Threads it records the preference on the way out, as any tab does. - assert.deepEqual( - sidebarStep({ previous: THREADS, next: SETTINGS, sidebarOpen: false, threadsSidebarOpen: true }), - { action: "expand", threadsSidebarOpen: false }, - ); +test("Settings opens the sidebar its sections live in, until told otherwise", () => { + assert.equal(step("a", SETTINGS, false).action, "expand"); + assert.equal(step(undefined, SETTINGS, true).action, null); + // Collapsed there by hand, it stays collapsed there. + assert.equal(step("a", SETTINGS, true, { [SETTINGS]: false }).action, "collapse"); }); -test("leaving Settings undoes what Settings did", () => { - assert.equal( - sidebarStep({ previous: SETTINGS, next: "a", sidebarOpen: true, threadsSidebarOpen: true }).action, - "collapse", - ); - assert.equal( - sidebarStep({ previous: SETTINGS, next: THREADS, sidebarOpen: true, threadsSidebarOpen: false }) - .action, - "collapse", - ); - assert.equal( - sidebarStep({ previous: SETTINGS, next: THREADS, sidebarOpen: true, threadsSidebarOpen: true }) - .action, - null, - ); +test("leaving Settings gives the next tab back its own sidebar", () => { + assert.deepEqual(step(SETTINGS, "a", true), { action: "collapse", sidebar: { [SETTINGS]: true } }); + assert.equal(step(SETTINGS, THREADS, true, { [THREADS]: false }).action, "collapse"); + assert.equal(step(SETTINGS, THREADS, true, { [THREADS]: true }).action, null); }); test("leaving Settings by bb's own way out closes its tab; a tab switch does not", () => { @@ -794,3 +751,13 @@ test("ago is brief", () => { assert.equal(ago(now - 2 * 24 * 3_600_000, now), "2d"); assert.equal(ago(now + 60_000, now), "now"); }); + +test("a Threads-only sidebar from before carries over", () => { + assert.deepEqual(parseState({ threadsSidebarOpen: true }).sidebar, { [THREADS]: true }); + assert.deepEqual(parseState({ threadsSidebarOpen: "yes" }).sidebar, {}); + // The per-tab record wins over the old field. + assert.deepEqual( + parseState({ threadsSidebarOpen: true, sidebar: { [THREADS]: false } }).sidebar, + { [THREADS]: false }, + ); +});