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
31 changes: 31 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/components/TabBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TabBar />);
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(<TabBar />);
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(<TabBar />);
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();
});
});
25 changes: 22 additions & 3 deletions src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 (
<div className="flex items-stretch border-b border-black/5 dark:border-white/10 overflow-x-auto no-scrollbar bg-canvas-light dark:bg-canvas-dark">
{tabs.map((tab) => {
Expand Down Expand Up @@ -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() },
]}
/>
)}
Expand Down
49 changes: 49 additions & 0 deletions src/lib/shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
36 changes: 35 additions & 1 deletion src/lib/shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type ShortcutId =
| "settings"
| "nextTab"
| "prevTab"
| "closeOtherTabs"
| "reopenClosed"
| "fmtBold"
| "fmtItalic"
Expand Down Expand Up @@ -70,6 +71,7 @@ export const defaults: Record<ShortcutId, string> = {
settings: "Mod+,",
nextTab: "Mod+Alt+]",
prevTab: "Mod+Alt+[",
closeOtherTabs: "Mod+Alt+W",
reopenClosed: "Mod+Shift+T",
fmtBold: "Mod+B",
fmtItalic: "Mod+I",
Expand Down Expand Up @@ -113,6 +115,7 @@ export const labels: Record<ShortcutId, string> = {
settings: "Settings",
nextTab: "Next Tab",
prevTab: "Previous Tab",
closeOtherTabs: "Close Other Tabs",
reopenClosed: "Reopen Last Closed Tab",
fmtBold: "Bold",
fmtItalic: "Italic",
Expand Down Expand Up @@ -208,10 +211,39 @@ export function subscribe(cb: () => void): () => void {
};
}

const CODE_PUNCTUATION: Record<string, string> = {
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[] = [];
Expand All @@ -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("+");
}

Expand Down
45 changes: 45 additions & 0 deletions src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand Down Expand Up @@ -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");
Expand Down
34 changes: 21 additions & 13 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -323,6 +324,23 @@ function removeTabs(state: AppState, victimIds: Set<string>) {
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`,
Expand Down Expand Up @@ -478,19 +496,9 @@ export const useAppStore = create<AppState>((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) => {
Expand Down
Loading