Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/top-tabs/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
48 changes: 25 additions & 23 deletions plugins/top-tabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
42 changes: 21 additions & 21 deletions plugins/top-tabs/components/TopTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
recordPath,
recordRecent,
recordRecentThread,
rememberSidebar,
reopen,
reopenable,
resetPinned,
Expand Down Expand Up @@ -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<TabId | null | undefined>(undefined);
const wasInSplit = useRef(false);

Expand All @@ -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
Expand All @@ -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]);

Expand Down
97 changes: 57 additions & 40 deletions plugins/top-tabs/lib/tabs-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>>;

export interface ClosedTab {
id: string;
/** Where the tab was when it closed, so reopening lands in the same place. */
Expand Down Expand Up @@ -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
Expand All @@ -71,7 +74,7 @@ export const EMPTY_STATE: TabsState = {
pinned: [],
paths: {},
closed: [],
threadsSidebarOpen: null,
sidebar: {},
seeded: false,
recent: [],
recentThreads: [],
Expand Down Expand Up @@ -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)
Expand All @@ -157,6 +159,20 @@ export function parseState(raw: unknown): TabsState {
};
}

function parseSidebar(record: Record<string, unknown>): SidebarMemory {
const sidebar: Record<string, boolean> = {};
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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/top-tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions plugins/top-tabs/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading
Loading