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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions plugins/top-tabs/PLUGIN_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,17 @@ 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
thread, or open one in a split from the + menu. The strip marks every tab that's on screen, with a small map of
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

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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion plugins/top-tabs/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand Down
60 changes: 47 additions & 13 deletions plugins/top-tabs/components/TopTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
activeTabFor,
adopt,
close,
closeOrderOf,
closeOthers,
closeToRight,
cycle,
Expand All @@ -56,11 +57,14 @@ import {
recordRecent,
recordRecentThread,
reopen,
reopenable,
resetPinned,
seed,
sidebarStep,
splitPartner,
stripTakesPageClose,
pageCloseAction,
successorAfterClose,
successorAfterPinClose,
threadIdFromPath,
threadPaneFor,
unpin,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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<TabId | null>(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();
Expand All @@ -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]);
Expand Down Expand Up @@ -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.
Expand All @@ -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],
);

/**
Expand Down Expand Up @@ -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));
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions plugins/top-tabs/lib/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading