diff --git a/src/App.tsx b/src/App.tsx
index b18f8fe..3bed343 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1404,6 +1404,12 @@ export function App() {
else useAppStore.getState().activateNextTab();
return;
}
+ if (matchesShortcut(e, "closeOtherTabs")) {
+ e.preventDefault();
+ const cur = useAppStore.getState().activeTabId;
+ if (cur) useAppStore.getState().closeOtherTabs(cur);
+ return;
+ }
if (matchesShortcut(e, "reopenClosed")) {
e.preventDefault();
reopenLastClosed();
@@ -1586,6 +1592,31 @@ export function App() {
label: "Close All Tabs",
run: () => useAppStore.getState().closeAllTabs(),
},
+ {
+ id: "close_other_tabs",
+ label: "Close Other Tabs",
+ shortcut: "⌘⌥W",
+ run: () => {
+ const cur = useAppStore.getState().activeTabId;
+ if (cur) useAppStore.getState().closeOtherTabs(cur);
+ },
+ },
+ {
+ id: "close_tabs_left",
+ label: "Close Tabs to the Left",
+ run: () => {
+ const cur = useAppStore.getState().activeTabId;
+ if (cur) useAppStore.getState().closeTabsToLeft(cur);
+ },
+ },
+ {
+ id: "close_tabs_right",
+ label: "Close Tabs to the Right",
+ run: () => {
+ const cur = useAppStore.getState().activeTabId;
+ if (cur) useAppStore.getState().closeTabsToRight(cur);
+ },
+ },
{
id: "next_tab",
label: "Next Tab",
diff --git a/src/components/TabBar.test.tsx b/src/components/TabBar.test.tsx
index e6d8a3e..60fcada 100644
--- a/src/components/TabBar.test.tsx
+++ b/src/components/TabBar.test.tsx
@@ -114,4 +114,48 @@ describe("TabBar", () => {
const ids = useAppStore.getState().tabs.map((t) => t.id);
expect(ids).toEqual(["/b.md", "/c.md", "/a.md"]);
});
+
+ it("the context menu can close everything to the left", () => {
+ useAppStore.setState({
+ tabs: [
+ makeTab("/a.md", "a.md"),
+ makeTab("/b.md", "b.md"),
+ makeTab("/c.md", "c.md"),
+ ],
+ activeTabId: "/a.md",
+ });
+ render();
+ const cRow = screen.getByText("c.md").parentElement!;
+ fireEvent.contextMenu(cRow, { clientX: 10, clientY: 10 });
+ fireEvent.click(screen.getByText("Close to the Left"));
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/c.md"]);
+ });
+
+ it("Close to the Left is disabled on the first tab", () => {
+ useAppStore.setState({
+ tabs: [makeTab("/a.md", "a.md"), makeTab("/b.md", "b.md")],
+ activeTabId: "/a.md",
+ });
+ render();
+ const aRow = screen.getByText("a.md").parentElement!;
+ fireEvent.contextMenu(aRow, { clientX: 10, clientY: 10 });
+ expect(screen.getByText("Close to the Left")).toBeDisabled();
+ });
+
+ it("side / others / all closes are disabled when only pinned tabs would be hit", () => {
+ // Pinned tabs always sort to the front, so "everything to my left is
+ // pinned" is the common case, not a corner.
+ useAppStore.setState({
+ tabs: [{ ...makeTab("/a.md", "a.md"), pinned: true }, makeTab("/b.md", "b.md")],
+ activeTabId: "/b.md",
+ });
+ render();
+ const bRow = screen.getByText("b.md").parentElement!;
+ fireEvent.contextMenu(bRow, { clientX: 10, clientY: 10 });
+ expect(screen.getByText("Close to the Left")).toBeDisabled();
+ expect(screen.getByText("Close Others")).toBeDisabled();
+ expect(screen.getByText("Close to the Right")).toBeDisabled();
+ // "Close All" still has b.md to close.
+ expect(screen.getByText("Close All")).toBeEnabled();
+ });
});
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 5e3a233..554f62d 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -22,6 +22,7 @@ export function TabBar() {
const closeTab = useAppStore((s) => s.closeTab);
const closeOtherTabs = useAppStore((s) => s.closeOtherTabs);
const closeTabsToRight = useAppStore((s) => s.closeTabsToRight);
+ const closeTabsToLeft = useAppStore((s) => s.closeTabsToLeft);
const closeAllTabs = useAppStore((s) => s.closeAllTabs);
const toggleTabPinned = useAppStore((s) => s.toggleTabPinned);
const reorderTab = useAppStore((s) => s.reorderTab);
@@ -37,6 +38,8 @@ export function TabBar() {
if (tabs.length <= 1) return null;
+ const ctxIdx = ctx ? tabs.findIndex((t) => t.id === ctx.id) : -1;
+
return (
{tabs.map((tab) => {
@@ -175,13 +178,29 @@ export function TabBar() {
disabled: !tabs.find((t) => t.id === ctx.id)?.path,
},
{ label: "Close", run: () => closeTab(ctx.id) },
- { label: "Close Others", run: () => closeOtherTabs(ctx.id) },
+ {
+ label: "Close Others",
+ run: () => closeOtherTabs(ctx.id),
+ disabled: !tabs.some((t) => t.id !== ctx.id && !t.pinned),
+ },
+ // Pinned tabs sit out of every bulk close, so an item is greyed
+ // out when nothing on that side would actually go — not merely
+ // when there is nothing on that side.
+ {
+ label: "Close to the Left",
+ run: () => closeTabsToLeft(ctx.id),
+ disabled: !tabs.slice(0, ctxIdx).some((t) => !t.pinned),
+ },
{
label: "Close to the Right",
run: () => closeTabsToRight(ctx.id),
- disabled: tabs.findIndex((t) => t.id === ctx.id) === tabs.length - 1,
+ disabled: !tabs.slice(ctxIdx + 1).some((t) => !t.pinned),
+ },
+ {
+ label: "Close All",
+ run: () => closeAllTabs(),
+ disabled: !tabs.some((t) => !t.pinned),
},
- { label: "Close All", run: () => closeAllTabs() },
]}
/>
)}
diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts
index 5f1dd8b..3c02090 100644
--- a/src/lib/shortcuts.test.ts
+++ b/src/lib/shortcuts.test.ts
@@ -45,6 +45,55 @@ describe("eventToShortcut", () => {
expect(eventToShortcut(ke({ key: "/", metaKey: true }))).toBe("Mod+/");
expect(eventToShortcut(ke({ key: ",", metaKey: true }))).toBe("Mod+,");
});
+
+ it("names the physical key when macOS composes an Alt combo", () => {
+ // ⌥W arrives as "∑", ⌥] as "‘", ⌥T as "†" — the physical key still says
+ // which key it was.
+ expect(
+ eventToShortcut(ke({ key: "∑", code: "KeyW", metaKey: true, altKey: true })),
+ ).toBe("Mod+Alt+W");
+ expect(
+ eventToShortcut(
+ ke({ key: "‘", code: "BracketRight", metaKey: true, altKey: true }),
+ ),
+ ).toBe("Mod+Alt+]");
+ expect(
+ eventToShortcut(ke({ key: "†", code: "KeyT", metaKey: true, altKey: true })),
+ ).toBe("Mod+Alt+T");
+ expect(
+ eventToShortcut(
+ ke({ key: "Ω", code: "KeyZ", metaKey: true, shiftKey: true, altKey: true }),
+ ),
+ ).toBe("Mod+Shift+Alt+Z");
+ });
+
+ it("names the physical key when an Alt combo is a dead key", () => {
+ expect(
+ eventToShortcut(ke({ key: "Dead", code: "KeyE", metaKey: true, altKey: true })),
+ ).toBe("Mod+Alt+E");
+ });
+
+ it("leaves ASCII Alt combos alone so AltGr layouts keep typing", () => {
+ // Ctrl+Alt+Q is "@" on a German layout; the typed character must win.
+ expect(
+ eventToShortcut(ke({ key: "@", code: "KeyQ", ctrlKey: true, altKey: true })),
+ ).toBe("Mod+Alt+@");
+ });
+
+ it("matches a composed ⌘⌥W against the Close Other Tabs default", () => {
+ expect(
+ matches(
+ ke({ key: "∑", code: "KeyW", metaKey: true, altKey: true }),
+ "closeOtherTabs",
+ ),
+ ).toBe(true);
+ expect(
+ matches(
+ ke({ key: "w", code: "KeyW", metaKey: true, altKey: true }),
+ "closeOtherTabs",
+ ),
+ ).toBe(true);
+ });
});
describe("override + matches", () => {
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
index 223c1ed..fa31aae 100644
--- a/src/lib/shortcuts.ts
+++ b/src/lib/shortcuts.ts
@@ -28,6 +28,7 @@ export type ShortcutId =
| "settings"
| "nextTab"
| "prevTab"
+ | "closeOtherTabs"
| "reopenClosed"
| "fmtBold"
| "fmtItalic"
@@ -70,6 +71,7 @@ export const defaults: Record
= {
settings: "Mod+,",
nextTab: "Mod+Alt+]",
prevTab: "Mod+Alt+[",
+ closeOtherTabs: "Mod+Alt+W",
reopenClosed: "Mod+Shift+T",
fmtBold: "Mod+B",
fmtItalic: "Mod+I",
@@ -113,6 +115,7 @@ export const labels: Record = {
settings: "Settings",
nextTab: "Next Tab",
prevTab: "Previous Tab",
+ closeOtherTabs: "Close Other Tabs",
reopenClosed: "Reopen Last Closed Tab",
fmtBold: "Bold",
fmtItalic: "Italic",
@@ -208,10 +211,39 @@ export function subscribe(cb: () => void): () => void {
};
}
+const CODE_PUNCTUATION: Record = {
+ BracketLeft: "[",
+ BracketRight: "]",
+ Minus: "-",
+ Equal: "=",
+ Slash: "/",
+ Backslash: "\\",
+ Semicolon: ";",
+ Quote: "'",
+ Comma: ",",
+ Period: ".",
+ Backquote: "`",
+};
+
+/** The key name for a physical key (`KeyboardEvent.code`), or null if it
+ * isn't one a shortcut binds. */
+function keyNameFromCode(code: string): string | null {
+ if (/^Key[A-Z]$/.test(code)) return code.slice(3);
+ if (/^Digit[0-9]$/.test(code)) return code.slice(5);
+ return CODE_PUNCTUATION[code] ?? null;
+}
+
/**
* Convert a KeyboardEvent → "Mod+Shift+X"-style string.
* Returns null for events that aren't valid shortcuts (no modifiers, or
* just a modifier).
+ *
+ * With Alt held, macOS composes the key through the Option layer before the
+ * event reaches us: ⌥W arrives as `key: "∑"`, ⌥] as `"‘"`, ⌥E as a dead key.
+ * None of those can ever equal a binding like "Mod+Alt+W", so for composed
+ * (non-ASCII or dead) keys the name comes from the physical key instead.
+ * Plain ASCII keys are left alone on purpose — AltGr layouts type real
+ * characters through Ctrl+Alt and must keep doing so.
*/
export function eventToShortcut(e: KeyboardEvent): string | null {
const parts: string[] = [];
@@ -221,8 +253,10 @@ export function eventToShortcut(e: KeyboardEvent): string | null {
const k = e.key;
if (k === "Meta" || k === "Control" || k === "Shift" || k === "Alt") return null;
if (parts.length === 0) return null;
+ const composed = k === "Dead" || (k.length === 1 && k.charCodeAt(0) > 0x7f);
+ const physical = e.altKey && composed ? keyNameFromCode(e.code ?? "") : null;
// Normalise letters to uppercase, keep punctuation as-is.
- parts.push(k.length === 1 ? k.toUpperCase() : k);
+ parts.push(physical ?? (k.length === 1 ? k.toUpperCase() : k));
return parts.join("+");
}
diff --git a/src/store.test.ts b/src/store.test.ts
index 5116fff..73e7080 100644
--- a/src/store.test.ts
+++ b/src/store.test.ts
@@ -417,6 +417,42 @@ describe("app store", () => {
});
});
+ describe("closeTabsToLeft", () => {
+ // 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 everything before the pivot, pivot included", () => {
+ openN(4);
+ useAppStore.getState().closeTabsToLeft("/3.md");
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/3.md", "/4.md"]);
+ });
+
+ it("keeps pinned tabs on the left", () => {
+ openN(3);
+ useAppStore.getState().toggleTabPinned("/1.md"); // pinned tabs sort to the front
+ useAppStore.getState().closeTabsToLeft("/3.md");
+ expect(useAppStore.getState().tabs.map((t) => t.id)).toEqual(["/1.md", "/3.md"]);
+ });
+
+ it("on the first tab is a no-op", () => {
+ openN(2);
+ useAppStore.getState().closeTabsToLeft("/1.md");
+ expect(useAppStore.getState().tabs).toHaveLength(2);
+ });
+
+ it("activates the pivot when the active tab went with the batch", () => {
+ openN(3);
+ useAppStore.getState().setActiveTab("/1.md");
+ useAppStore.getState().closeTabsToLeft("/3.md");
+ expect(useAppStore.getState().activeTabId).toBe("/3.md");
+ });
+ });
+
describe("batch close asks before discarding unsaved work", () => {
// Opens every path in `paths` and leaves `dirtyPaths` with unsaved edits.
function openDirty(paths: string[], dirtyPaths: string[]) {
@@ -507,6 +543,15 @@ describe("app store", () => {
expect(s.activeTabId).toBe("/b.md");
});
+ it("closeTabsToLeft asks too, and cancelling keeps every tab", () => {
+ openDirty(["/a.md", "/b.md"], ["/a.md"]);
+ const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
+ useAppStore.getState().closeTabsToLeft("/b.md");
+ expect(spy).toHaveBeenCalledOnce();
+ expect(useAppStore.getState().tabs).toHaveLength(2);
+ spy.mockRestore();
+ });
+
it("closeTabsToRight lands the active tab on the pivot when it went with the batch", () => {
openDirty(["/a.md", "/b.md", "/c.md"], []);
useAppStore.getState().setActiveTab("/c.md");
diff --git a/src/store.ts b/src/store.ts
index f775575..4997691 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -114,6 +114,7 @@ interface AppState {
reorderTab: (fromId: string, toId: string) => void;
closeOtherTabs: (id: string) => void;
closeTabsToRight: (id: string) => void;
+ closeTabsToLeft: (id: string) => void;
closeAllTabs: () => void;
toggleTabPinned: (id: string) => void;
activateNextTab: () => void;
@@ -323,6 +324,23 @@ function removeTabs(state: AppState, victimIds: Set) {
return { tabs, activeTabId, recentlyClosed };
}
+/**
+ * Close everything on one side of `id`. Pinned tabs on that side survive —
+ * this gesture shouldn't take an anchored doc with it. The pivot always
+ * stays, and takes over as active if the previous active tab went along.
+ */
+function closeSide(state: AppState, id: string, side: "left" | "right") {
+ const idx = state.tabs.findIndex((x) => x.id === id);
+ if (idx < 0) return state;
+ const range = side === "left" ? state.tabs.slice(0, idx) : state.tabs.slice(idx + 1);
+ const victims = range.filter((x) => !x.pinned);
+ if (victims.length === 0) return state;
+ if (!confirmDiscard(victims)) return state;
+ const next = removeTabs(state, new Set(victims.map((x) => x.id)));
+ const activeSurvives = next.tabs.some((x) => x.id === state.activeTabId);
+ return { ...next, activeTabId: activeSurvives ? state.activeTabId : id };
+}
+
function welcomeTab(): Tab {
return {
id: `${SCRATCH_PREFIX}welcome`,
@@ -478,19 +496,9 @@ export const useAppStore = create((set) => ({
};
}),
- closeTabsToRight: (id) =>
- set((state) => {
- const idx = state.tabs.findIndex((t) => t.id === id);
- if (idx < 0) return state;
- // Keep pinned tabs that lived to the right of `id` so the user
- // doesn't lose their anchored ones via this gesture.
- const victims = state.tabs.slice(idx + 1).filter((t) => !t.pinned);
- if (victims.length === 0) return state;
- if (!confirmDiscard(victims)) return state;
- const next = removeTabs(state, new Set(victims.map((x) => x.id)));
- const activeSurvives = next.tabs.some((t) => t.id === state.activeTabId);
- return { ...next, activeTabId: activeSurvives ? state.activeTabId : id };
- }),
+ closeTabsToRight: (id) => set((state) => closeSide(state, id, "right")),
+
+ closeTabsToLeft: (id) => set((state) => closeSide(state, id, "left")),
closeAllTabs: () =>
set((state) => {