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
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
8. [写作模式(长文 / 书籍)](./design/07-writing-mode.md) — 提案;调研见 [research/06](./research/06-longform-writing.md),首个真实项目是 [`book/`](../book/README.md)
9. [宽内容出血](./design/08-wide-tables.md) — 表格与代码块出血到窗格宽度、按内容分配列宽;实现见 `src/lib/milkdown/table-view.ts`
10. [应用内渲染 Mermaid](./design/09-mermaid-in-app.md) — 补上一直缺失的 diagram nodeView;实现见 `src/lib/milkdown/diagram-view.ts`
11. [一次关掉一批标签页](./design/10-close-many-tabs.md) — 标签页多选 + 批量关闭,并修掉批量关闭不问脏状态的旧缺陷;实现见 `src/store.ts`、`src/components/TabBar.tsx`

## 目录结构

Expand All @@ -35,7 +36,8 @@ docs/
├── 02-mvp-features.md
├── 03-roadmap.md
├── 08-wide-tables.md
└── 09-mermaid-in-app.md
├── 09-mermaid-in-app.md
└── 10-close-many-tabs.md
```

## TL;DR 技术选型
Expand Down
294 changes: 294 additions & 0 deletions docs/design/10-close-many-tabs.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/components/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,10 @@ function ContextMenu({
>
<div
style={{ left: x, top: y }}
className="absolute min-w-[160px] py-1 rounded-md shadow-2xl bg-canvas-light dark:bg-canvas-dark border border-black/10 dark:border-white/15"
// 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).
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()}
>
{items.map((it) => (
Expand Down
8 changes: 8 additions & 0 deletions src/lib/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ describe("i18n", () => {
expect(t("status.error", "ENOENT")).toBe("Error: ENOENT");
});

it("t() doesn't expand $-patterns inside an argument", () => {
setLocale("en");
// `$&` / `$'` are special to String.replace with a string replacement;
// a filename is allowed to contain them.
expect(t("status.error", "x$&y")).toBe("Error: x$&y");
expect(t("status.error", "a$'b")).toBe("Error: a$'b");
});

it("getLocale reflects the currently active value", () => {
setLocale("zh");
expect(getLocale()).toBe("zh");
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ export function t(key: keyof Strings, ...args: (string | number)[]): string {
const dict = dicts[effective()];
let s = dict[key] ?? en[key] ?? key;
for (let i = 0; i < args.length; i++) {
s = s.replace(`{${i}}`, String(args[i]));
// Function replacer: a string replacement would expand `$&`, `$'` etc.
// inside the argument, and filenames flow through here.
const arg = String(args[i]);
s = s.replace(`{${i}}`, () => arg);
}
return s;
}
Expand Down
3 changes: 3 additions & 0 deletions src/lib/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ export const en = {

// misc
"tab.confirmClose": '"{0}" has unsaved changes that will be lost. Close anyway?',
"tab.confirmCloseMany":
"{0} tabs have unsaved changes that will be lost:\n\n{1}\n\nClose them all anyway?",
"tab.confirmCloseMore": "…and {0} more",

// onboarding
"onboard.title": "Markup",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export const zh: Strings = {

// misc
"tab.confirmClose": "「{0}」有未保存的修改,关闭后会丢失。确认关闭吗?",
"tab.confirmCloseMany":
"有 {0} 个标签页存在未保存的修改,关闭后会丢失:\n\n{1}\n\n确认全部关闭吗?",
"tab.confirmCloseMore": "……还有 {0} 个",

// onboarding
"onboard.title": "Markup",
Expand Down
98 changes: 98 additions & 0 deletions src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,4 +416,102 @@ describe("app store", () => {
expect(tab?.kind).toBe("canvas");
});
});

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[]) {
const s = useAppStore.getState();
for (const p of paths) s.openLoadedFile({ path: p, content: "v1", mtime_ms: 1 });
for (const p of dirtyPaths) {
useAppStore.getState().setActiveTab(p);
useAppStore.getState().updateActiveContent("edited");
}
}

it("closeOtherTabs asks, and cancelling keeps every tab", () => {
openDirty(["/a.md", "/b.md", "/c.md"], ["/b.md"]);
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
useAppStore.getState().closeOtherTabs("/a.md");
expect(spy).toHaveBeenCalledOnce();
expect(useAppStore.getState().tabs).toHaveLength(3);
spy.mockRestore();
});

it("closeTabsToRight asks, and cancelling keeps every tab", () => {
openDirty(["/a.md", "/b.md"], ["/b.md"]);
const spy = vi.spyOn(window, "confirm").mockReturnValue(false);
useAppStore.getState().closeTabsToRight("/a.md");
expect(spy).toHaveBeenCalledOnce();
expect(useAppStore.getState().tabs).toHaveLength(2);
spy.mockRestore();
});

it("names every unsaved doc in the batch, not just the first", () => {
openDirty(["/a.md", "/b.md", "/c.md"], ["/a.md", "/b.md", "/c.md"]);
const spy = vi.spyOn(window, "confirm").mockReturnValue(true);
useAppStore.getState().closeAllTabs();
const message = String(spy.mock.calls[0][0]);
expect(message).toContain("a.md");
expect(message).toContain("b.md");
expect(message).toContain("c.md");
spy.mockRestore();
});

it("asks once per batch, not once per dirty tab", () => {
openDirty(["/a.md", "/b.md", "/c.md"], ["/a.md", "/b.md", "/c.md"]);
const spy = vi.spyOn(window, "confirm").mockReturnValue(true);
useAppStore.getState().closeAllTabs();
expect(spy).toHaveBeenCalledOnce();
spy.mockRestore();
});

it("stays silent when nothing in the batch is dirty", () => {
openDirty(["/a.md", "/b.md", "/c.md"], []);
const spy = vi.spyOn(window, "confirm").mockReturnValue(true);
useAppStore.getState().closeOtherTabs("/a.md");
expect(spy).not.toHaveBeenCalled();
expect(useAppStore.getState().tabs).toHaveLength(1);
spy.mockRestore();
});

it("a batch close stacks recentlyClosed left to right, so ⌘⇧T walks back in order", () => {
openDirty(["/a.md", "/b.md", "/c.md"], []);
useAppStore.getState().closeAllTabs();
const { popRecentlyClosed } = useAppStore.getState();
expect(popRecentlyClosed()).toBe("/a.md");
expect(popRecentlyClosed()).toBe("/b.md");
expect(popRecentlyClosed()).toBe("/c.md");
});

it("closeOtherTabs still activates the kept tab when everything else is pinned", () => {
openDirty(["/a.md", "/b.md", "/c.md"], []);
useAppStore.getState().toggleTabPinned("/a.md");
useAppStore.getState().toggleTabPinned("/b.md");
useAppStore.getState().setActiveTab("/a.md");
useAppStore.getState().closeOtherTabs("/c.md");
const s = useAppStore.getState();
expect(s.tabs).toHaveLength(3);
expect(s.activeTabId).toBe("/c.md");
});

it("closeAllTabs lands on the pinned tab nearest the closed ones", () => {
openDirty(["/a.md", "/b.md", "/c.md", "/d.md"], []);
useAppStore.getState().toggleTabPinned("/a.md");
useAppStore.getState().toggleTabPinned("/b.md"); // strip: a* b* c d
useAppStore.getState().setActiveTab("/c.md");
useAppStore.getState().closeAllTabs();
const s = useAppStore.getState();
expect(s.tabs.map((t) => t.id)).toEqual(["/a.md", "/b.md"]);
// The left neighbour of the first closed slot — same rule as every
// other close, so the eye doesn't jump to the far end of the strip.
expect(s.activeTabId).toBe("/b.md");
});

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");
useAppStore.getState().closeTabsToRight("/a.md");
expect(useAppStore.getState().activeTabId).toBe("/a.md");
});
});
});
134 changes: 76 additions & 58 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,56 @@ function pushClosed(stack: string[], paths: Array<string | null | undefined>): s
return next.slice(0, RECENTLY_CLOSED_MAX);
}

/** How many filenames a batch confirmation spells out before summarising. */
const CONFIRM_NAME_MAX = 5;

/**
* One prompt for a whole batch — the batch closes only if it returns true.
*
* Only path-backed dirty tabs can actually lose work; a scratch buffer has
* no on-disk version to diverge from (and never flips to "dirty" — see
* `updateActiveContent`). Single-victim batches keep the exact wording the
* one-tab close has always used.
*/
function confirmDiscard(victims: Tab[]): boolean {
const dirty = victims.filter((v) => v.status === "dirty" && v.path);
if (dirty.length === 0) return true;
if (dirty.length === 1) return window.confirm(t("tab.confirmClose", dirty[0].name));
const shown = dirty.slice(0, CONFIRM_NAME_MAX).map((d) => d.name);
const rest = dirty.length - shown.length;
const lines = rest > 0 ? [...shown, t("tab.confirmCloseMore", rest)] : shown;
return window.confirm(t("tab.confirmCloseMany", dirty.length, lines.join("\n")));
}

/**
* Remove `victimIds` from the strip in one transaction: record the closed
* paths for ⌘⇧T and land the active tab somewhere sane.
*
* Callers own the confirm gate and the pinned filter — this is the shared
* mechanics, not the policy.
*/
function removeTabs(state: AppState, victimIds: Set<string>) {
const closed = state.tabs.filter((x) => victimIds.has(x.id)).map((x) => x.path);
const firstIdx = state.tabs.findIndex((x) => victimIds.has(x.id));
const tabs = state.tabs.filter((x) => !victimIds.has(x.id));
const recentlyClosed = pushClosed(state.recentlyClosed, closed);
if (tabs.length === 0) {
return {
tabs: [welcomeTab()],
activeTabId: `${SCRATCH_PREFIX}welcome` as string | null,
recentlyClosed,
};
}
// If the active tab went with the batch, land on the survivor just left of
// the leftmost removed slot — the rule single-tab close has always used, so
// the eye doesn't jump across the strip.
const activeTabId =
state.activeTabId && victimIds.has(state.activeTabId)
? tabs[Math.min(Math.max(0, firstIdx - 1), tabs.length - 1)].id
: state.activeTabId;
return { tabs, activeTabId, recentlyClosed };
}

function welcomeTab(): Tab {
return {
id: `${SCRATCH_PREFIX}welcome`,
Expand Down Expand Up @@ -383,26 +433,12 @@ export const useAppStore = create<AppState>((set) => ({

closeTab: (id) =>
set((state) => {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return state;
const target = state.tabs[idx];
// Confirm if dirty (and we're closing a real file, not a scratch buffer)
if (target.status === "dirty" && target.path) {
const ok = window.confirm(t("tab.confirmClose", target.name));
if (!ok) return state;
}
const tabs = state.tabs.filter((t) => t.id !== id);
const recentlyClosed = pushClosed(state.recentlyClosed, [target.path]);
if (tabs.length === 0) {
return {
tabs: [welcomeTab()],
activeTabId: `${SCRATCH_PREFIX}welcome`,
recentlyClosed,
};
}
const activeTabId =
state.activeTabId === id ? tabs[Math.max(0, idx - 1)].id : state.activeTabId;
return { tabs, activeTabId, recentlyClosed };
const target = state.tabs.find((t) => t.id === id);
if (!target) return state;
// An explicit single close names its target, so it closes pinned tabs
// too. The bulk gestures below are the ones that sweep past them.
if (!confirmDiscard([target])) return state;
return removeTabs(state, new Set([id]));
}),

setActiveTab: (id) => set({ activeTabId: id }),
Expand All @@ -429,59 +465,41 @@ export const useAppStore = create<AppState>((set) => ({
const keep = state.tabs.find((t) => t.id === id);
if (!keep) return state;
// Pinned tabs survive "close others" — they're explicitly anchored.
const closed = state.tabs
.filter((t) => t.id !== id && !t.pinned)
.map((t) => t.path);
const tabs = state.tabs.filter((t) => t.id === id || t.pinned);
const victims = state.tabs.filter((t) => t.id !== id && !t.pinned);
// Nothing to close (everything else is pinned) still lands you on the
// tab you pointed at — the gesture has always done that.
if (victims.length === 0) {
return state.activeTabId === keep.id ? state : { activeTabId: keep.id };
}
if (!confirmDiscard(victims)) return state;
return {
tabs,
...removeTabs(state, new Set(victims.map((x) => x.id))),
activeTabId: keep.id,
recentlyClosed: pushClosed(state.recentlyClosed, closed),
};
}),

closeTabsToRight: (id) =>
set((state) => {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return state;
const head = state.tabs.slice(0, idx + 1);
// Keep pinned tabs that lived to the right of `id` so the user
// doesn't lose their anchored ones via this gesture.
const right = state.tabs.slice(idx + 1);
const pinnedRight = right.filter((t) => t.pinned);
const closed = right.filter((t) => !t.pinned).map((t) => t.path);
const tabs = [...head, ...pinnedRight];
const activeStillVisible = tabs.some((t) => t.id === state.activeTabId);
return {
tabs,
activeTabId: activeStillVisible ? state.activeTabId : id,
recentlyClosed: pushClosed(state.recentlyClosed, closed),
};
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 };
}),

closeAllTabs: () =>
set((state) => {
const dirty = state.tabs.find((x) => x.status === "dirty" && x.path && !x.pinned);
if (dirty) {
const ok = window.confirm(t("tab.confirmClose", dirty.name));
if (!ok) return state;
}
const pinned = state.tabs.filter((t) => t.pinned);
const closed = state.tabs.filter((t) => !t.pinned).map((t) => t.path);
const recentlyClosed = pushClosed(state.recentlyClosed, closed);
if (pinned.length === 0) {
return {
tabs: [welcomeTab()],
activeTabId: `${SCRATCH_PREFIX}welcome`,
recentlyClosed,
};
}
const stillActive = pinned.some((t) => t.id === state.activeTabId);
return {
tabs: pinned,
activeTabId: stillActive ? state.activeTabId : pinned[0].id,
recentlyClosed,
};
const victims = state.tabs.filter((t) => !t.pinned);
if (victims.length === 0) return state;
// One prompt for the whole batch, naming every doc that would lose
// changes — this used to name only the first and drop the rest.
if (!confirmDiscard(victims)) return state;
return removeTabs(state, new Set(victims.map((x) => x.id)));
}),

toggleTabPinned: (id) =>
Expand Down
Loading