{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
+ const isSelected = selected.has(tab.id);
const indicator = tab.status === "dirty" ? "●" : "";
const isDragging = draggingId === tab.id;
const isOver = overId === tab.id && draggingId && draggingId !== tab.id;
+ // A selected tab stays at full opacity — the marking has to be
+ // obvious enough that nobody forgets a selection is live.
+ const tone = isActive
+ ? "bg-canvas-light dark:bg-canvas-dark text-ink-light dark:text-ink-dark"
+ : isSelected
+ ? ""
+ : "opacity-60 hover:opacity-90";
return (
{
e.preventDefault();
+ // Right-clicking outside the selection moves focus to that tab,
+ // so the menu never describes tabs the user isn't pointing at.
+ if (!isSelected) clearTabSelection();
setCtx({ id: tab.id, x: e.clientX, y: e.clientY });
}}
onMouseDown={(e) => {
@@ -90,14 +137,32 @@ export function TabBar() {
closeTab(tab.id);
}
}}
- className={`group titlebar-no-drag relative flex items-center gap-2 pl-3 pr-1 py-1.5 text-[12px] cursor-pointer border-r border-black/5 dark:border-white/10 select-none ${
- isActive
- ? "bg-canvas-light dark:bg-canvas-dark text-ink-light dark:text-ink-dark"
- : "opacity-60 hover:opacity-90"
+ data-selected={isSelected ? "true" : undefined}
+ className={`group titlebar-no-drag relative flex items-center gap-2 pl-3 pr-1 py-1.5 text-[12px] cursor-pointer border-r border-black/5 dark:border-white/10 select-none ${tone} ${
+ isSelected ? "bg-blue-500/10 ring-1 ring-inset ring-blue-500/60" : ""
} ${isDragging ? "opacity-30" : ""} ${
isOver ? "ring-2 ring-blue-500/50 ring-inset" : ""
}`}
- onClick={() => setActiveTab(tab.id)}
+ onClick={(e) => {
+ // ⇧ extends a range from the anchor, ⌘/Ctrl toggles one tab
+ // (without moving the active doc), a plain click does what it
+ // always did — activate, and drop any selection.
+ if (e.shiftKey) {
+ // The anchor may have been closed since it was set; fall
+ // back to the active tab rather than selecting one tab.
+ const anchorLive = anchorId && tabs.some((t) => t.id === anchorId);
+ selectTabRange(anchorLive ? anchorId : activeTabId, tab.id);
+ return;
+ }
+ if (e.metaKey || e.ctrlKey) {
+ toggleTabSelection(tab.id);
+ setAnchorId(tab.id);
+ return;
+ }
+ clearTabSelection();
+ setAnchorId(tab.id);
+ setActiveTab(tab.id);
+ }}
>
{tab.pinned && (
@@ -139,6 +204,16 @@ export function TabBar() {
y={ctx.y}
onClose={() => setCtx(null)}
items={[
+ ...(selected.has(ctx.id) && live.length > 0
+ ? [
+ {
+ label:
+ live.length === 1 ? "Close 1 Tab" : `Close ${live.length} Tabs`,
+ run: () => closeTabs(live),
+ },
+ { label: "Clear Selection", run: () => clearTabSelection() },
+ ]
+ : []),
{
label: tabs.find((t) => t.id === ctx.id)?.pinned ? "Unpin" : "Pin",
run: () => toggleTabPinned(ctx.id),
@@ -238,7 +313,7 @@ function ContextMenu({
style={{ left: x, top: y }}
// flex-col so the panel's max-content width is the widest item, not
// the sum of them — inline-block buttons made it grow with the item
- // count (8 items measured 684px wide).
+ // count (~700px before this feature added two more entries).
className="absolute flex flex-col min-w-[160px] py-1 rounded-md shadow-2xl bg-canvas-light dark:bg-canvas-dark border border-black/10 dark:border-white/15"
onClick={(e) => e.stopPropagation()}
>
diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts
index ca28af8..baca3ac 100644
--- a/src/lib/locales/en.ts
+++ b/src/lib/locales/en.ts
@@ -112,6 +112,7 @@ export const en = {
"toast.copiedPlainText": "Copied as plain text",
"toast.copiedHtml": "Copied as HTML",
"toast.savedAll": "Saved {0} files",
+ "toast.noTabSelection": "No tabs are selected",
"toast.saveAllFailed": "Saved {0}, {1} failed",
"toast.tableSizeBad": "Format: rows x cols (e.g. 3x4)",
"toast.reloaded": "Reloaded from disk",
diff --git a/src/lib/locales/zh.ts b/src/lib/locales/zh.ts
index 7168417..d794a53 100644
--- a/src/lib/locales/zh.ts
+++ b/src/lib/locales/zh.ts
@@ -113,6 +113,7 @@ export const zh: Strings = {
"toast.copiedPlainText": "已作为纯文本复制",
"toast.copiedHtml": "已作为 HTML 复制",
"toast.savedAll": "已保存 {0} 个文件",
+ "toast.noTabSelection": "没有选中的标签页",
"toast.saveAllFailed": "已保存 {0},{1} 个失败",
"toast.tableSizeBad": "格式:行 x 列(例如 3x4)",
"toast.reloaded": "已从磁盘重新加载",
diff --git a/src/store.test.ts b/src/store.test.ts
index 73e7080..300f6f8 100644
--- a/src/store.test.ts
+++ b/src/store.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { useAppStore } from "./store";
+import { liveSelection, useAppStore } from "./store";
function reset() {
// Re-create the store's initial state by closing all tabs except welcome
@@ -18,6 +18,7 @@ function reset() {
recentFiles: [],
recentlyClosed: [],
recentVaults: [],
+ selectedTabIds: [],
});
}
@@ -559,4 +560,191 @@ describe("app store", () => {
expect(useAppStore.getState().activeTabId).toBe("/a.md");
});
});
+
+ describe("closeTabs (an explicit set)", () => {
+ // Opens n tabs named /1.md … /n.md, left to right.
+ function openN(n: number) {
+ const { openLoadedFile } = useAppStore.getState();
+ for (let i = 1; i <= n; i++) {
+ openLoadedFile({ path: `/${i}.md`, content: "", mtime_ms: 1 });
+ }
+ }
+
+ it("removes exactly the listed tabs", () => {
+ openN(4);
+ useAppStore.getState().closeTabs(["/1.md", "/3.md"]);
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md", "/4.md"]);
+ });
+
+ it("skips pinned tabs in the batch", () => {
+ openN(3);
+ useAppStore.getState().toggleTabPinned("/2.md");
+ useAppStore.getState().closeTabs(["/1.md", "/2.md", "/3.md"]);
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md"]);
+ });
+
+ it("lands the active tab left of the leftmost closed slot", () => {
+ openN(4);
+ useAppStore.getState().setActiveTab("/3.md");
+ useAppStore.getState().closeTabs(["/2.md", "/3.md"]);
+ expect(useAppStore.getState().activeTabId).toBe("/1.md");
+ });
+
+ it("keeps the active tab when it isn't in the batch", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/1.md");
+ useAppStore.getState().closeTabs(["/2.md", "/3.md"]);
+ expect(useAppStore.getState().activeTabId).toBe("/1.md");
+ });
+
+ it("falls back to a fresh welcome scratch when it empties the strip", () => {
+ openN(2);
+ useAppStore.getState().closeTabs(["/1.md", "/2.md"]);
+ const s = useAppStore.getState();
+ expect(s.tabs).toHaveLength(1);
+ expect(s.tabs[0].path).toBeNull();
+ expect(s.activeTabId).toBe(s.tabs[0].id);
+ });
+
+ it("on an empty / unknown set is a no-op", () => {
+ openN(2);
+ useAppStore.getState().closeTabs([]);
+ useAppStore.getState().closeTabs(["/nope.md"]);
+ expect(useAppStore.getState().tabs).toHaveLength(2);
+ });
+
+ it("asks before discarding unsaved work in the set", () => {
+ openN(2);
+ useAppStore.getState().setActiveTab("/1.md");
+ useAppStore.getState().updateActiveContent("edited");
+ const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
+ useAppStore.getState().closeTabs(["/1.md", "/2.md"]);
+ expect(spy).toHaveBeenCalledOnce();
+ expect(useAppStore.getState().tabs).toHaveLength(2);
+ spy.mockRestore();
+ });
+ });
+
+ describe("tab selection", () => {
+ function openN(n: number) {
+ const { openLoadedFile } = useAppStore.getState();
+ for (let i = 1; i <= n; i++) {
+ openLoadedFile({ path: `/${i}.md`, content: "", mtime_ms: 1 });
+ }
+ }
+
+ it("toggleTabSelection adds then removes", () => {
+ openN(2);
+ useAppStore.getState().toggleTabSelection("/1.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/1.md"]);
+ useAppStore.getState().toggleTabSelection("/1.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual([]);
+ });
+
+ it("refuses pinned tabs — they're exempt from every bulk close", () => {
+ openN(2);
+ useAppStore.getState().toggleTabPinned("/1.md");
+ useAppStore.getState().toggleTabSelection("/1.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual([]);
+ });
+
+ it("selectTabRange covers the span between anchor and target", () => {
+ openN(4);
+ useAppStore.getState().selectTabRange("/2.md", "/4.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/2.md", "/3.md", "/4.md"]);
+ });
+
+ it("selectTabRange works backwards and skips pinned tabs", () => {
+ openN(4);
+ useAppStore.getState().toggleTabPinned("/3.md"); // pinned sorts to the front
+ // Order is now /3, /1, /2, /4 — this range walks all four backwards,
+ // and the pinned one must not join the selection.
+ useAppStore.getState().selectTabRange("/4.md", "/3.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/1.md", "/2.md", "/4.md"]);
+ });
+
+ it("selectTabRange with no anchor selects just the target", () => {
+ openN(3);
+ useAppStore.getState().selectTabRange(null, "/2.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/2.md"]);
+ });
+
+ it("any close clears the selection", () => {
+ openN(3);
+ const s = useAppStore.getState();
+ s.toggleTabSelection("/1.md");
+ s.toggleTabSelection("/2.md");
+ expect(useAppStore.getState().selectedTabIds).toHaveLength(2);
+ useAppStore.getState().closeTab("/3.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual([]);
+ });
+
+ it("Save As carries the selection across the tab's id change", () => {
+ const { newScratchTab, toggleTabSelection, setActivePathAndName } =
+ useAppStore.getState();
+ newScratchTab();
+ const scratchId = useAppStore.getState().activeTabId as string;
+ toggleTabSelection(scratchId);
+ setActivePathAndName("/saved.md", "saved.md", 1);
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/saved.md"]);
+ });
+
+ it("closeSelectedOrActive closes the selection when there is one", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/3.md");
+ useAppStore.getState().toggleTabSelection("/1.md");
+ useAppStore.getState().closeSelectedOrActive();
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/2.md", "/3.md"]);
+ });
+
+ it("closeSelectedOrActive ignores a selection the strip isn't showing", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/2.md");
+ useAppStore.getState().toggleTabSelection("/1.md");
+ useAppStore.setState({ showTabBar: false });
+ useAppStore.getState().closeSelectedOrActive();
+ // Closed the active tab, not the invisible selection.
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]);
+ useAppStore.setState({ showTabBar: true });
+ });
+
+ it("closeSelectedOrActive falls back to the active tab", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/2.md");
+ useAppStore.getState().closeSelectedOrActive();
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]);
+ });
+
+ it("pinning a selected tab drops it from the selection", () => {
+ openN(3);
+ useAppStore.getState().toggleTabSelection("/2.md");
+ useAppStore.getState().toggleTabSelection("/3.md");
+ useAppStore.getState().toggleTabPinned("/2.md");
+ expect(useAppStore.getState().selectedTabIds).toEqual(["/3.md"]);
+ });
+
+ it("⌘W never goes dead: a selection of only pinned tabs falls back to the active tab", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/3.md");
+ // Force the bad state directly — the store itself no longer produces it.
+ useAppStore.getState().toggleTabPinned("/1.md");
+ useAppStore.setState({ selectedTabIds: ["/1.md"] });
+ useAppStore.getState().closeSelectedOrActive();
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/2.md"]);
+ });
+
+ it("liveSelection counts only tabs a bulk close would remove", () => {
+ openN(3);
+ useAppStore.getState().toggleTabPinned("/1.md");
+ useAppStore.setState({ selectedTabIds: ["/1.md", "/2.md", "/gone.md"] });
+ expect(liveSelection(useAppStore.getState())).toEqual(["/2.md"]);
+ });
+
+ it("clearTabSelection empties it", () => {
+ openN(2);
+ useAppStore.getState().toggleTabSelection("/1.md");
+ useAppStore.getState().clearTabSelection();
+ expect(useAppStore.getState().selectedTabIds).toEqual([]);
+ });
+ });
});
diff --git a/src/store.ts b/src/store.ts
index 4997691..5574c9d 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -47,6 +47,12 @@ export interface VaultFile {
interface AppState {
tabs: Tab[];
activeTabId: string | null;
+ /** Ids of tabs marked for a multi-tab close (⌘-click / ⇧-click on the
+ * strip). Never holds pinned tabs — they're exempt from every bulk
+ * gesture, so letting them in would make the "Close N Tabs" count lie.
+ * Cleared by any close and by a plain click. See
+ * docs/design/10-close-many-tabs.md §4.2. */
+ selectedTabIds: string[];
vaultRoot: string | null;
vaultFiles: VaultFile[];
sourceMode: boolean;
@@ -110,12 +116,25 @@ interface AppState {
/** Open fetched text (e.g. a GitHub file) as a new unsaved buffer. */
openScratchWithContent: (name: string, content: string) => void;
closeTab: (id: string) => void;
+ /** Close an explicit set of tabs in one transaction. Pinned tabs in the
+ * set are skipped, like every other bulk gesture. */
+ closeTabs: (ids: string[]) => void;
setActiveTab: (id: string) => void;
reorderTab: (fromId: string, toId: string) => void;
closeOtherTabs: (id: string) => void;
closeTabsToRight: (id: string) => void;
closeTabsToLeft: (id: string) => void;
closeAllTabs: () => void;
+ /** What ⌘W / File ▸ Close Tab does: closes the selection when the user
+ * marked one, otherwise just the active tab. Lives here (not in App) so
+ * the dispatch is testable — it's the riskiest path in this feature. */
+ closeSelectedOrActive: () => void;
+ /** Add/remove one tab from the selection. Pinned tabs are ignored. */
+ toggleTabSelection: (id: string) => void;
+ /** Add every tab between `anchorId` and `toId` (inclusive) to the
+ * selection, skipping pinned. A null/unknown anchor selects `toId` alone. */
+ selectTabRange: (anchorId: string | null, toId: string) => void;
+ clearTabSelection: () => void;
toggleTabPinned: (id: string) => void;
activateNextTab: () => void;
activatePrevTab: () => void;
@@ -274,6 +293,17 @@ function pushClosed(stack: string[], paths: Array): s
return next.slice(0, RECENTLY_CLOSED_MAX);
}
+/** The selected tab ids that a bulk close would actually remove: present on
+ * the strip and not pinned. Exported for the strip, which labels its
+ * "Close N Tabs" item from it so the count can't lie. */
+export function liveSelection(
+ state: Pick,
+): string[] {
+ return state.selectedTabIds.filter((id) =>
+ state.tabs.some((x) => x.id === id && !x.pinned),
+ );
+}
+
/** How many filenames a batch confirmation spells out before summarising. */
const CONFIRM_NAME_MAX = 5;
@@ -297,7 +327,8 @@ function confirmDiscard(victims: Tab[]): boolean {
/**
* Remove `victimIds` from the strip in one transaction: record the closed
- * paths for ⌘⇧T and land the active tab somewhere sane.
+ * paths for ⌘⇧T, land the active tab somewhere sane, and drop the selection
+ * (it could only have referred to tabs that are now gone).
*
* Callers own the confirm gate and the pinned filter — this is the shared
* mechanics, not the policy.
@@ -312,6 +343,7 @@ function removeTabs(state: AppState, victimIds: Set) {
tabs: [welcomeTab()],
activeTabId: `${SCRATCH_PREFIX}welcome` as string | null,
recentlyClosed,
+ selectedTabIds: [] as string[],
};
}
// If the active tab went with the batch, land on the survivor just left of
@@ -321,7 +353,7 @@ function removeTabs(state: AppState, victimIds: Set) {
state.activeTabId && victimIds.has(state.activeTabId)
? tabs[Math.min(Math.max(0, firstIdx - 1), tabs.length - 1)].id
: state.activeTabId;
- return { tabs, activeTabId, recentlyClosed };
+ return { tabs, activeTabId, recentlyClosed, selectedTabIds: [] as string[] };
}
/**
@@ -353,9 +385,10 @@ function welcomeTab(): Tab {
};
}
-export const useAppStore = create((set) => ({
+export const useAppStore = create((set, get) => ({
tabs: [welcomeTab()],
activeTabId: `${SCRATCH_PREFIX}welcome`,
+ selectedTabIds: [],
vaultRoot: null,
vaultFiles: [],
sourceMode: false,
@@ -459,6 +492,15 @@ export const useAppStore = create((set) => ({
return removeTabs(state, new Set([id]));
}),
+ closeTabs: (ids) =>
+ set((state) => {
+ const wanted = new Set(ids);
+ const victims = state.tabs.filter((x) => wanted.has(x.id) && !x.pinned);
+ if (victims.length === 0) return state;
+ if (!confirmDiscard(victims)) return state;
+ return removeTabs(state, new Set(victims.map((x) => x.id)));
+ }),
+
setActiveTab: (id) => set({ activeTabId: id }),
reorderTab: (fromId, toId) =>
@@ -510,6 +552,51 @@ export const useAppStore = create((set) => ({
return removeTabs(state, new Set(victims.map((x) => x.id)));
}),
+ closeSelectedOrActive: () => {
+ const s = get();
+ // A selection the user can't see must not steer ⌘W — the strip can be
+ // switched off entirely. Only ids that would actually close count:
+ // pinned tabs never do, and a stale id (shouldn't happen — every close
+ // clears the selection) must fall through to the active tab rather
+ // than turn ⌘W into a dead key.
+ const live = s.showTabBar ? liveSelection(s) : [];
+ if (live.length > 0) {
+ s.closeTabs(live);
+ return;
+ }
+ if (s.activeTabId) s.closeTab(s.activeTabId);
+ },
+
+ toggleTabSelection: (id) =>
+ set((state) => {
+ const target = state.tabs.find((x) => x.id === id);
+ // Pinned tabs are exempt from bulk closes, so they never join a
+ // selection — see docs/design/10-close-many-tabs.md §3 (辩题五).
+ if (!target || target.pinned) return state;
+ return {
+ selectedTabIds: state.selectedTabIds.includes(id)
+ ? state.selectedTabIds.filter((x) => x !== id)
+ : [...state.selectedTabIds, id],
+ };
+ }),
+
+ selectTabRange: (anchorId, toId) =>
+ set((state) => {
+ const to = state.tabs.findIndex((x) => x.id === toId);
+ if (to < 0) return state;
+ const from = anchorId ? state.tabs.findIndex((x) => x.id === anchorId) : -1;
+ const start = from < 0 ? to : Math.min(from, to);
+ const end = from < 0 ? to : Math.max(from, to);
+ const merged = [...state.selectedTabIds];
+ for (const x of state.tabs.slice(start, end + 1)) {
+ if (!x.pinned && !merged.includes(x.id)) merged.push(x.id);
+ }
+ return { selectedTabIds: merged };
+ }),
+
+ clearTabSelection: () =>
+ set((state) => (state.selectedTabIds.length === 0 ? state : { selectedTabIds: [] })),
+
toggleTabPinned: (id) =>
set((state) => {
const idx = state.tabs.findIndex((t) => t.id === id);
@@ -521,7 +608,13 @@ export const useAppStore = create((set) => ({
const tabs = next.pinned
? [...others.filter((t) => t.pinned), next, ...others.filter((t) => !t.pinned)]
: [...others.filter((t) => t.pinned), ...others.filter((t) => !t.pinned), next];
- return { tabs };
+ // A pinned tab can't be selected (it's exempt from bulk closes), so
+ // pinning a selected one drops it from the selection — otherwise
+ // "Close N Tabs" would overcount and ⌘W would close nothing.
+ const selectedTabIds = next.pinned
+ ? state.selectedTabIds.filter((x) => x !== id)
+ : state.selectedTabIds;
+ return { tabs, selectedTabIds };
}),
activateNextTab: () =>
@@ -642,6 +735,9 @@ export const useAppStore = create((set) => ({
t.id === id ? { ...t, id: path, path, name, mtimeMs, status: "saved" } : t,
),
activeTabId: path,
+ // The tab's id just changed (scratch:* → its path); move any
+ // selection entry with it so it can't strand.
+ selectedTabIds: state.selectedTabIds.map((x) => (x === id ? path : x)),
};
}),