diff --git a/common/i18n/locales/en.json b/common/i18n/locales/en.json index 30b685a..8af40b1 100644 --- a/common/i18n/locales/en.json +++ b/common/i18n/locales/en.json @@ -11,6 +11,7 @@ "menu": { "about": "About DOMD", "checkUpdates": "Check for Updates...", + "newTab": "New Tab", "newWindow": "New Window", "openUrl": "Open URL...", "closeWindow": "Close Window", @@ -404,5 +405,17 @@ "locate": "Could not locate the text to edit — try rephrasing" }, "localSelf": "Me" + }, + "tabs": { + "newTab": "New tab", + "closeTab": "Close tab", + "shrinkToFit": "Shrink to fit", + "scrollHorizontally": "Scroll horizontally", + "untitled": "Untitled.md", + "unsavedTitle": "Unsaved changes", + "unsavedBody": "Save changes to “{{name}}” before closing?", + "unsavedSave": "Save", + "unsavedDontSave": "Don't Save", + "unsavedCancel": "Cancel" } } diff --git a/common/i18n/locales/ja.json b/common/i18n/locales/ja.json index df3b7d2..7d42410 100644 --- a/common/i18n/locales/ja.json +++ b/common/i18n/locales/ja.json @@ -11,6 +11,7 @@ "menu": { "about": "DOMD について", "checkUpdates": "アップデートを確認...", + "newTab": "新しいタブ", "newWindow": "新規ウインドウ", "openUrl": "URL を開く...", "closeWindow": "ウインドウを閉じる", @@ -404,5 +405,17 @@ "locate": "編集対象のテキストを特定できませんでした——言い換えてみてください" }, "localSelf": "自分" + }, + "tabs": { + "newTab": "新しいタブ", + "closeTab": "タブを閉じる", + "shrinkToFit": "幅に合わせる", + "scrollHorizontally": "横スクロール", + "untitled": "Untitled.md", + "unsavedTitle": "未保存の変更", + "unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?", + "unsavedSave": "保存", + "unsavedDontSave": "保存しない", + "unsavedCancel": "キャンセル" } } diff --git a/common/i18n/locales/zh.json b/common/i18n/locales/zh.json index 9811fcd..45662cc 100644 --- a/common/i18n/locales/zh.json +++ b/common/i18n/locales/zh.json @@ -11,6 +11,7 @@ "menu": { "about": "关于 DOMD", "checkUpdates": "检查更新...", + "newTab": "新建标签页", "newWindow": "新建窗口", "openUrl": "打开 URL...", "closeWindow": "关闭窗口", @@ -404,5 +405,17 @@ "locate": "未能定位要修改的文本——换个说法试试" }, "localSelf": "我" + }, + "tabs": { + "newTab": "新建标签页", + "closeTab": "关闭标签页", + "shrinkToFit": "自适应宽度", + "scrollHorizontally": "横向滚动", + "untitled": "Untitled.md", + "unsavedTitle": "未保存的更改", + "unsavedBody": "关闭前保存对“{{name}}”的更改吗?", + "unsavedSave": "保存", + "unsavedDontSave": "不保存", + "unsavedCancel": "取消" } } diff --git a/features/collaboration/lib/collab-db-tauri.ts b/features/collaboration/lib/collab-db-tauri.ts index dc21ae3..15521a0 100644 --- a/features/collaboration/lib/collab-db-tauri.ts +++ b/features/collaboration/lib/collab-db-tauri.ts @@ -19,8 +19,11 @@ import { } from "@/plugins/collaboration/crdt-sync"; import type { RoomRecord } from "./types"; -/** Frontmatter domd-id of the document open in this window. One window - * hosts one document, so module-level state is safe. */ +/** Frontmatter domd-id of the document collaboration currently applies to. + * A window can hold several documents in tabs, but only the ACTIVE one has + * a mounted editor for a session to attach to — so there is still exactly + * one at a time and module-level state stays safe. The tabbed desktop shell + * re-points this on every tab switch (see tabbed-editor-app). */ let currentDocId: string | null = null; export const setCollabDocId = (docId: string): void => { diff --git a/features/editor/components/editor-app.tsx b/features/editor/components/editor-app.tsx index b1434dc..d03705f 100644 --- a/features/editor/components/editor-app.tsx +++ b/features/editor/components/editor-app.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; import { DOMDProvider, + EditorStore, useEditorStore, useEditorStoreApi, useFormatState, @@ -80,6 +81,11 @@ import { ModeController } from "./mode-controller"; import { NewDocModal } from "./new-doc-modal"; import { ImageDropHandler } from "../hooks/use-image-drop"; import { useDocumentLoaders } from "../hooks/use-document-loaders"; +import { useTabs } from "../hooks/use-tabs"; +import { TabStoreProvider } from "../stores/tab-store"; +import { TabBar } from "./tab-bar"; +import { TabCloseModal } from "./tab-close-modal"; +import { TabFocusOnSwitch } from "./tab-focus"; import { useTauriDragDrop } from "../hooks/use-tauri-drag-drop"; import { useTauriEvent } from "../hooks/use-tauri-event"; import { useWebDragDrop } from "../hooks/use-web-drag-drop"; @@ -256,10 +262,38 @@ export function EditorApp() { // trigger surface — web top-bar buttons, the native-titlebar event // bridge, the drawer overlay — reaches the same store without threading // callbacks through props (claude-os nav-drawer pattern). + const { t } = useTranslation(); + + // Builds the document runtime for a tab. This is the one place the kernel + // construction options live now: a tab's store is created here and owned + // by the TabStore for the tab's whole life, so these can no longer sit on + // DOMDProvider — it ignores every construction prop once handed a store. + const createRuntime = useCallback( + (initMd: string) => + new EditorStore({ + editable: true, + initMd, + placeholder: t("editor.placeholder"), + mode: "rich", + imageLoader: collabImageLoader, + codeTokenizer: tokenize, + inlineRules: appInlineRules, + codeBeautify: beautify, + }), + [t], + ); + return ( - - - + // TabStoreProvider holds the window's open documents and their live + // runtimes, and is the document source for BOTH platforms (see + // useDocumentLoaders). Web is simply the case where nothing ever opens + // a second tab, which keeps one editor tree instead of a tabbed copy + // that has to be kept in step with this one. + + + + + ); } @@ -273,7 +307,7 @@ function EditorAppContent() { const { meta, setMeta, - content, + runtime, version, view, applyBlank, @@ -616,7 +650,19 @@ function EditorAppContent() { const isWeb = !useIsTauri(); const dragging = tauriDragging || webDragging; - if (view === "loading" || meta === null || content === null) { + // Tabs are a desktop concept: a window holds several documents, a web page + // holds one. Everything below the tab bar is the same editor either way — + // useDocumentLoaders reads whichever tab is active, so a switch reaches + // the kernel as an ordinary document swap (`key={version}`). + const { markActiveTabDirty, switchedTabs, closeRequest, decideUnsaved } = + useTabs({ + enabled: !isWeb, + // A tab switch retires the previous document exactly as loading a + // new file into the window does (see the open-file handler above). + onDocumentSwitch: detachSharing, + }); + + if (view === "loading" || meta === null || runtime === null) { // Loading covers ONLY the content area: the top bar (same classes as // the real one in Editor) paints immediately so the page never reads // as a slow full-screen blank. Desktop has no web top bar — the @@ -667,10 +713,16 @@ function EditorAppContent() { return (
+ {isWeb ? null : } {dragging ? (
@@ -679,19 +731,25 @@ function EditorAppContent() {
) : null} + {/* Bring-your-own-store: the view mounts over the active tab's + long-lived runtime instead of constructing one from initMd, so + a tab switch is an attach rather than a re-parse. Construction + props are deliberately absent — the provider ignores them when + handed a store, and the runtime already carries them (see + createRuntime in EditorApp). `renderComponent` stays: it is + view configuration, not document state. Keyed by tab, because + the provider captures its store once on mount. */} + {/* Real DOM focus for the document a tab switch just brought + forward — the kernel binds its key handling to the editable + root, so a document focused only in the model is one where + ⌘Z does nothing. */} + {isWeb ? null : } {/* Hydrates the persisted display mode + binds Cmd+/ — the "more" menu entry (web) and this keystroke (both runtimes) call the same toggle. */} @@ -746,6 +804,9 @@ function EditorAppContent() { aiAvailable={isWeb} aiActive={aiEnabled && aiAgents.length > 0} sidePanel={sidePanel} + embedded={!isWeb} + tabId={isWeb ? undefined : version} + onDirtyChange={isWeb ? undefined : markActiveTabDirty} /> {/* Local collaboration session while AI is on without a live room: same doc/versioning machinery over a no-op @@ -816,6 +877,22 @@ function EditorAppContent() { /> ) : null} + {/* Deliberately unkeyed, like the modals above: the conditional + already gives each prompt a fresh instance. A key here is not + merely redundant, it is a trap — the obvious one to reach for + is the tab id, which is exactly what keys the DOMDProvider + above (`version` IS the active tab id). Closing the active tab + then puts two siblings under one key, and React quietly mounts + a second editor shell without removing the first: two stacked + documents, no error, since the duplicate-key warning is + development-only. */} + {closeRequest ? ( + + ) : null} + {showShareModal ? ( void; @@ -176,6 +179,16 @@ export function Editor({ /** Side panel content for the SidePanelHost slot, resolved by the app * from the side-panel store's active kind. Null = closed. */ sidePanel?: React.ReactNode; + /** Set by the tabbed desktop shell: the tab this editor is showing. + * Dirty state is mirrored to that tab so the tab bar can badge it. */ + tabId?: string; + /** Render as a flex child filling the space under the tab bar instead of + * covering the viewport. */ + embedded?: boolean; + /** Notified (debounced, same cadence as the Rust content push) whenever + * the unsaved-changes state flips. The tabbed shell mirrors it into the + * tab store so the tab bar can badge modified documents. */ + onDirtyChange?: (isDirty: boolean) => void; }) { const { t } = useTranslation(); const renderData = useRenderData(); @@ -445,18 +458,19 @@ export function Editor({ // Tauri: CLI → push full content + dirty flag whenever the doc changes. // Debounced (150ms) since this serializes the whole renderData to markdown. const lastSavedMdRef = useRef(""); + const onDirtyChangeRef = useLatest(onDirtyChange); useEffect(() => { - if (!isTauri()) return; // Treat the initial loaded content as the baseline for dirty detection. // Re-runs only when meta changes (new doc loaded into this window). lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? ""; // eslint-disable-next-line react-hooks/exhaustive-deps }, [meta]); useEffect(() => { - if (!isTauri()) return; const handle = setTimeout(() => { const md = toMarkdown(renderData) ?? ""; const isDirty = md !== lastSavedMdRef.current; + onDirtyChangeRef.current?.(isDirty); + if (!isTauri()) return; tauriCore().then(({ invoke }) => { invoke("update_content", { content: md, isDirty }).catch( () => { }, @@ -464,12 +478,13 @@ export function Editor({ }); }, 150); return () => clearTimeout(handle); - }, [renderData]); + }, [renderData, onDirtyChangeRef]); // Tauri: CLI just saved this window to disk on our behalf. Update the // baseline so subsequent dirty checks compare against the saved content. useTauriEvent("saved-by-cli", () => { lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? ""; + onDirtyChangeRef.current?.(false); // Push an immediate clean-state update so AI sees has_unsaved_changes // flip to false without waiting for the debounce. tauriCore().then(({ invoke }) => { @@ -487,9 +502,13 @@ export function Editor({ useEffect(() => { if (prevSavingRef.current && !saving) { lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? ""; + // Clear the tab badge now rather than waiting for the next + // keystroke — a save does not itself change renderData, so the + // debounced effect above would not re-run. + onDirtyChangeRef.current?.(false); } prevSavingRef.current = saving; - }, [saving]); + }, [saving, onDirtyChangeRef]); // Web: Cmd/Ctrl+S useEffect(() => { @@ -529,7 +548,17 @@ export function Editor({ // context only, no DOM wrapper — same posture as DOMDProvider. -
+ {/* `embedded` (tabbed desktop shell) swaps the viewport-covering + root for a flex child so the tab bar keeps its row. The inner + layer is absolute either way, so it needs a positioned ancestor — + the embedded root is `relative` for exactly that reason. */} +
{/* Format shortcuts (⌘1/⌘K/⌥⌘C/…) live outside the top bar: the desktop build renders no web top bar, and they must work there too. */} @@ -769,6 +798,10 @@ export function Editor({
{ if (domdRef.current?.contains(e.target as Node)) return; diff --git a/features/editor/components/tab-bar.tsx b/features/editor/components/tab-bar.tsx new file mode 100644 index 0000000..724a863 --- /dev/null +++ b/features/editor/components/tab-bar.tsx @@ -0,0 +1,185 @@ +"use client"; +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useTabStore, useTabStoreApi } from "../stores/tab-store"; +import { useTabDragReorder } from "../hooks/use-tab-drag-reorder"; + +export function TabBar() { + const { t } = useTranslation(); + const tabs = useTabStore((s) => s.state.tabs); + const activeTabId = useTabStore((s) => s.state.activeTabId); + const displayMode = useTabStore((s) => s.state.displayMode); + const store = useTabStoreApi(); + const barRef = useRef(null); + + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + } | null>(null); + + const tabIds = tabs.map((t) => t.id); + const { draggingId, onPointerDown, onPointerMove, onPointerUp } = + useTabDragReorder(tabIds, (from, to) => store.reorderTab(from, to)); + + // No useCallback: this project compiles with the React Compiler, which + // memoizes these itself. Hand-written useCallback here defeated it (the + // compiler bails on a component whose existing memoization it cannot + // prove it preserves) and bought nothing. + const requestClose = (tabId: string) => { + window.dispatchEvent( + new CustomEvent("domd-close-tab", { detail: { tabId } }), + ); + }; + + const handleActivate = (tabId: string) => { + if (tabId !== activeTabId) store.activateTab(tabId); + }; + + const handleClose = (e: React.MouseEvent, tabId: string) => { + e.stopPropagation(); + requestClose(tabId); + }; + + const handleMiddleClick = (e: React.MouseEvent, tabId: string) => { + if (e.button === 1) { + e.preventDefault(); + requestClose(tabId); + } + }; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }; + + const handleSetDisplayMode = (mode: "shrink" | "scroll") => { + store.setDisplayMode(mode); + setContextMenu(null); + }; + + const handleNewTab = () => { + window.dispatchEvent(new CustomEvent("domd-new-tab")); + }; + + // A window showing one document looks exactly as it did before tabs + // existed: no strip, no border, no reserved height. Rendering nothing + // rather than hiding it means the flex column never allots the row. + if (tabs.length <= 1) return null; + + return ( +
+
+ {tabs.map((tab) => ( +
handleActivate(tab.id)} + onMouseDown={(e) => handleMiddleClick(e, tab.id)} + onPointerDown={(e) => { + // Don't start drag when clicking the close button + if ((e.target as HTMLElement).closest("button")) + return; + const bar = barRef.current; + if (bar) onPointerDown(e, tab.id, bar); + }} + > + {tab.isDirty && ( + + )} + {tab.meta.name} + +
+ ))} +
+ + + + {contextMenu && ( + <> +
setContextMenu(null)} + /> +
+ + +
+ + )} +
+ ); +} diff --git a/features/editor/components/tab-close-modal.tsx b/features/editor/components/tab-close-modal.tsx new file mode 100644 index 0000000..cd4dcd5 --- /dev/null +++ b/features/editor/components/tab-close-modal.tsx @@ -0,0 +1,105 @@ +"use client"; +/** + * Unsaved-work prompt for closing ONE tab among several. + * + * Window close and quit both go through the native macOS sheet, which speaks + * for a window and has no per-tab counterpart — so this path has to ask + * in-app. It keeps the sheet's SEMANTICS rather than its chrome: three + * outcomes, Escape cancels, Enter saves, and the sheet's button order (Don't + * Save set apart on the left, Cancel then Save on the right, Save default). + * + * The two-button `ask` plugin dialog it replaces had no Cancel at all, so a + * close you triggered by accident could not be called off. + * + * Mount-only, like the sibling modals: the parent renders it conditionally so + * each prompt is a fresh instance with no state to reset. + */ +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +export type UnsavedChoice = "save" | "discard" | "cancel"; + +export function TabCloseModal({ + name, + onChoose, +}: { + /** Document being closed, shown in the question. */ + name: string; + onChoose: (choice: UnsavedChoice) => void; +}) { + const { t } = useTranslation(); + const saveRef = useRef(null); + + // Focus the default button rather than binding Enter by hand: it makes + // Enter-saves fall out of ordinary button behaviour, keeps Tab order + // meaningful, and announces the default to assistive tech. + // + // Giving focus BACK is deliberately not done here. An unmount cleanup + // cannot see which button was pressed, and only Cancel should restore it — + // the other two close the tab, where focus belongs to TabFocusOnSwitch. + // So the decision, and the restore, live with the caller in use-tabs. + useEffect(() => { + const id = requestAnimationFrame(() => saveRef.current?.focus()); + return () => cancelAnimationFrame(id); + }, []); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onChoose("cancel"); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onChoose]); + + return ( +
onChoose("cancel")} + > +
e.stopPropagation()} + className="bg-base-100 rounded-xl border border-base-content/15 shadow-xl p-6 w-96 max-w-[calc(100vw-2rem)]" + > +

+ {t("tabs.unsavedTitle")} +

+

+ {t("tabs.unsavedBody", { name })} +

+
+ {/* Set apart on the left, as the native sheet does — the + one irreversible choice should not sit inside the + cluster your hand is already heading for. */} + + + +
+
+
+ ); +} diff --git a/features/editor/components/tab-focus.tsx b/features/editor/components/tab-focus.tsx new file mode 100644 index 0000000..3bb4593 --- /dev/null +++ b/features/editor/components/tab-focus.tsx @@ -0,0 +1,42 @@ +"use client"; +import { useEffect } from "react"; +import { useEditorDom } from "@do-md/core-react"; + +/** + * Put real DOM focus on the editor when a tab switch mounts this view. + * + * Selecting a tab means "I want to work in this document", and it has to be + * DOM focus specifically: the kernel binds undo/redo and the rest of its key + * handling to the editable root, not to window, so a document that is focused + * only in the model is one where ⌘Z silently does nothing. + * + * `store.focus()` is not enough on its own here. It records focus INTENT and + * the render layer materializes it, which is the right call for gestures + * mid-session — but across a view remount (which is what a tab switch is) the + * intent can land without the new editable root ever taking browser focus. + * Observed directly: after a switch `document.activeElement` was the editor + * root and `store.focused_` was true, a synthetic keydown dispatched at that + * root undid correctly, and yet real keystrokes did nothing until something + * called `.focus()` on the element itself. + * + * The kernel deliberately never steals focus on attach — restoring it is the + * host's call, which is exactly what this is. Mounted inside the provider so + * it can reach the editable root through the kernel's own DOM context rather + * than querying for a kernel-internal attribute. + */ +export function TabFocusOnSwitch({ enabled }: { enabled: boolean }) { + const { textAreaDomRef } = useEditorDom(); + + useEffect(() => { + if (!enabled) return; + // One frame late: the editable root mounts through a ref sink and the + // controller attaches with it, so focusing in the same commit can beat + // the listeners into place. + const raf = requestAnimationFrame(() => { + textAreaDomRef.current?.focus(); + }); + return () => cancelAnimationFrame(raf); + }, [enabled, textAreaDomRef]); + + return null; +} diff --git a/features/editor/hooks/use-document-loaders.ts b/features/editor/hooks/use-document-loaders.ts index 6a2af25..bd4ca58 100644 --- a/features/editor/hooks/use-document-loaders.ts +++ b/features/editor/hooks/use-document-loaders.ts @@ -1,22 +1,64 @@ "use client"; -import { useCallback, useState } from "react"; -import { fetchMarkdown, resolveMarkdownUrl } from "../lib/resolve-url"; +/** + * Resolves a document source and commits it to the window's TabStore. + * + * The API is unchanged from the single-document version — meta, content, + * version, view plus one entry point per source — because everything that + * consumes it (editor-app) should not care whether the window holds one + * document or ten. What changed is where the result lands: the active tab + * instead of local React state. + * + * That indirection is the whole point of the tabbed shell: tabs get the + * upstream editor tree — collaboration, disk reconciliation, AI, the outline + * panel — without a parallel copy of it. + * + * `content` is not returned, because there is no longer a content prop to + * return. Each tab owns a live EditorStore and the provider is handed that + * store directly, so the document never round-trips through React state. + */ +import { useCallback } from "react"; +import { isTauri } from "@/common/lib/platform"; +import { tauriCore } from "@/common/lib/tauri"; +import { setCollabDocId } from "@/features/collaboration/lib/collab-db-tauri"; +import { markKnownDiskContent } from "../lib/disk-sync"; import { buildFrontmatterBlock, ensureDomdId, splitFrontmatter, } from "../lib/frontmatter"; -import { setCollabDocId } from "@/features/collaboration/lib/collab-db-tauri"; -import { markKnownDiskContent } from "../lib/disk-sync"; -import { isTauri } from "@/common/lib/platform"; -import { tauriCore } from "@/common/lib/tauri"; +import { fetchMarkdown, resolveMarkdownUrl } from "../lib/resolve-url"; +import { useTabStore, useTabStoreApi } from "../stores/tab-store"; import type { FileMeta, View } from "../lib/types"; export function useDocumentLoaders() { - const [meta, setMeta] = useState(null); - const [content, setContent] = useState(null); - const [version, setVersion] = useState(0); - const [view, setView] = useState("loading"); + const store = useTabStoreApi(); + const tabs = useTabStore((s) => s.state.tabs); + const activeTabId = useTabStore((s) => s.state.activeTabId); + const active = tabs.find((tab) => tab.id === activeTabId) ?? null; + + const meta = active?.meta ?? null; + /** The active tab's live document runtime, handed to DOMDProvider as + * `store`. With it, every construction-time prop is ignored — the + * runtime already carries them. */ + const runtime = active ? (store.runtimeOf(active.id) ?? null) : null; + /** Editor mount identity: the TAB, and nothing more. + * + * The provider captures its store once on mount, so switching tabs has + * to remount the view. Replacing the DOCUMENT inside a tab must not — + * that resets the runtime in place, and remounting would discard the + * view state (selection, focus, scroll) the kernel just preserved. The + * old `${id}:${docEpoch}` key did exactly that, which is why the epoch + * is gone along with the serialize-and-remount model it belonged to. */ + const version = active?.id ?? "loading"; + const view: View = active && runtime ? "editor" : "loading"; + + const setMeta = useCallback( + (next: FileMeta) => { + const id = store.state.activeTabId; + if (id) store.updateTabMeta(id, next); + }, + [store], + ); const applyBlank = useCallback(() => { if (isTauri()) { @@ -26,77 +68,42 @@ export function useDocumentLoaders() { // block lives in meta and is written out on first save. const docId = crypto.randomUUID(); setCollabDocId(docId); - setMeta({ - kind: "tauri", - path: null, - name: "Untitled.md", - docId, - frontmatter: buildFrontmatterBlock(docId), - }); - } else { - setMeta({ kind: "web", name: "Untitled.md", handle: null }); + store.openInActiveTab( + { + kind: "tauri", + path: null, + name: "Untitled.md", + docId, + frontmatter: buildFrontmatterBlock(docId), + }, + "", + ); + return; } - setContent(""); - setVersion((n) => n + 1); - setView("editor"); - }, []); + store.openInActiveTab({ kind: "web", name: "Untitled.md", handle: null }, ""); + }, [store]); /** Blank web meta with restored local content (draft / collab doc). * The name survives (draft mirrors named docs too) but the file handle * cannot — restored drafts always re-save via the picker. */ const applyLocal = useCallback( (localContent: string, name = "Untitled.md") => { - setMeta({ kind: "web", name, handle: null }); - setContent(localContent); - setVersion((n) => n + 1); - setView("editor"); + store.openInActiveTab( + { kind: "web", name, handle: null }, + localContent, + ); }, - [], + [store], ); - const loadTauriPath = useCallback(async (path: string) => { - const { invoke } = await tauriCore(); - const raw = await invoke("read_file", { path }).catch( - () => null, - ); - const name = path.split("/").pop() ?? path; - if (raw === null) { - // Unreadable file: open blank, without a doc identity. Do NOT - // write anything back — the read failure may be transient. - setMeta({ kind: "tauri", path, name, docId: null }); - setContent(""); - setVersion((n) => n + 1); - setView("editor"); - return; - } - // Document identity: guarantee a frontmatter domd-id, silently - // writing the injected block back to disk. The editor only ever sees - // the body — the block is re-prepended on save (see save-document). - const ensured = ensureDomdId(raw); - // Register the on-disk ground truth either way — the echo guard and - // the no-op-write suppression both key off it. - markKnownDiskContent(path, ensured.changed ? ensured.content : raw); - if (ensured.changed) { - await invoke("write_file", { - path, - content: ensured.content, - }).catch(() => {}); - } - const { prefix, body } = splitFrontmatter(ensured.content); - // Synchronous handoff (the editor-app effect re-applies it after the - // commit; this closes the gap in between). - setCollabDocId(ensured.id); - setMeta({ - kind: "tauri", - path, - name, - docId: ensured.id, - frontmatter: prefix, - }); - setContent(body); - setVersion((n) => n + 1); - setView("editor"); - }, []); + const loadTauriPath = useCallback( + async (path: string) => { + const doc = await readTauriDoc(path); + setCollabDocId(doc.meta.kind === "tauri" ? (doc.meta.docId ?? "") : ""); + store.openInActiveTab(doc.meta, doc.content); + }, + [store], + ); // Drag-drop onto a Tauri window: claim the path in Rust's WindowFiles so // close-behavior and open_or_reuse stay consistent, then load it. @@ -111,69 +118,34 @@ export function useDocumentLoaders() { const loadRemote = useCallback( async (input: string) => { - const resolved = resolveMarkdownUrl(input); - if (!resolved) { + const doc = await readRemoteDoc(input); + if (!doc) { applyBlank(); return; } - try { - const fileContent = await fetchMarkdown( - resolved.url, - resolved.headers, - ); - if (isTauri()) { - // Remote docs get an identity too (kept in-memory, no - // disk write until the user saves) so they can host a - // collaboration session like any other document. - const ensured = ensureDomdId(fileContent); - const { prefix, body } = splitFrontmatter(ensured.content); - setCollabDocId(ensured.id); - setMeta({ - kind: "tauri", - path: null, - name: resolved.filename, - docId: ensured.id, - frontmatter: prefix, - }); - setContent(body); - setVersion((n) => n + 1); - setView("editor"); - return; - } - setMeta({ - kind: "web", - name: resolved.filename, - handle: null, - }); - setContent(fileContent); - setVersion((n) => n + 1); - setView("editor"); - } catch { - applyBlank(); + if (doc.meta.kind === "tauri" && doc.meta.docId) { + setCollabDocId(doc.meta.docId); } + store.openInActiveTab(doc.meta, doc.content); }, - [applyBlank], + [store, applyBlank], ); const loadFromFile = useCallback( async (file: File, handle: FileSystemFileHandle | null) => { const fileContent = await file.text(); - setMeta({ kind: "web", name: file.name, handle }); - setContent(fileContent); - // setContent(""); - // setTimeout(() => { - // window.mockAI(fileContent); - // }, 200); - setVersion((n) => n + 1); - setView("editor"); + store.openInActiveTab( + { kind: "web", name: file.name, handle }, + fileContent, + ); }, - [], + [store], ); return { meta, setMeta, - content, + runtime, version, view, applyBlank, @@ -184,3 +156,110 @@ export function useDocumentLoaders() { loadFromFile, }; } + +export interface LoadedDoc { + meta: FileMeta; + /** Body only — the frontmatter block lives in `meta.frontmatter` and is + * re-prepended by save-document. The editor never sees it. */ + content: string; +} + +/** + * Read a file from disk. Guarantees a frontmatter domd-id (writing the + * injected block back), and registers the on-disk ground truth so the echo + * guard and the no-op-write suppression both have a baseline. + * + * Exported because the tabbed shell loads documents into tabs that are NOT + * the active one, where committing to React state would be wrong. + */ +export async function readTauriDoc(path: string): Promise { + const { invoke } = await tauriCore(); + const raw = await invoke("read_file", { path }).catch(() => null); + const name = path.split("/").pop() ?? path; + if (raw === null) { + // Unreadable file: open blank, without a doc identity. Do NOT write + // anything back — the read failure may be transient. + return { meta: { kind: "tauri", path, name, docId: null }, content: "" }; + } + const ensured = ensureDomdId(raw); + markKnownDiskContent(path, ensured.changed ? ensured.content : raw); + if (ensured.changed) { + await invoke("write_file", { path, content: ensured.content }).catch( + () => {}, + ); + } + const { prefix, body } = splitFrontmatter(ensured.content); + return { + meta: { + kind: "tauri", + path, + name, + docId: ensured.id, + frontmatter: prefix, + }, + content: body, + }; +} + +/** Fetch a remote markdown document. Gets an in-memory identity on desktop + * (no disk write until saved) so it can be shared like any other. */ +export async function readRemoteDoc(input: string): Promise { + const resolved = resolveMarkdownUrl(input); + if (!resolved) return null; + try { + const fileContent = await fetchMarkdown(resolved.url, resolved.headers); + if (!isTauri()) { + return { + meta: { kind: "web", name: resolved.filename, handle: null }, + content: fileContent, + }; + } + const ensured = ensureDomdId(fileContent); + const { prefix, body } = splitFrontmatter(ensured.content); + return { + meta: { + kind: "tauri", + path: null, + name: resolved.filename, + docId: ensured.id, + frontmatter: prefix, + }, + content: body, + }; + } catch { + return null; + } +} + +/** A fresh untitled desktop document, for opening in a NEW tab. */ +export function blankTauriDoc(): LoadedDoc { + const docId = crypto.randomUUID(); + return { + meta: { + kind: "tauri", + path: null, + name: "Untitled.md", + docId, + frontmatter: buildFrontmatterBlock(docId), + }, + content: "", + }; +} + +/** + * Re-read a backgrounded tab's file wholesale. Only ever called for tabs with + * no unsaved edits: a dirty tab's divergence has to be merged, not + * overwritten, which is the mounted DiskReconciler's job. + */ +export async function rereadTauriDoc(path: string): Promise { + const { invoke } = await tauriCore(); + const raw = await invoke("read_file", { path }).catch(() => null); + if (raw === null) return null; + markKnownDiskContent(path, raw); + const { prefix, body, id } = splitFrontmatter(raw); + const name = path.split("/").pop() ?? path; + return { + meta: { kind: "tauri", path, name, docId: id, frontmatter: prefix }, + content: body, + }; +} diff --git a/features/editor/hooks/use-tab-drag-reorder.ts b/features/editor/hooks/use-tab-drag-reorder.ts new file mode 100644 index 0000000..7fb3082 --- /dev/null +++ b/features/editor/hooks/use-tab-drag-reorder.ts @@ -0,0 +1,78 @@ +"use client"; +import { useCallback, useRef, useState } from "react"; + +interface DragState { + tabId: string; + startX: number; + currentIndex: number; +} + +export function useTabDragReorder( + tabIds: string[], + onReorder: (fromIndex: number, toIndex: number) => void, +) { + const [draggingId, setDraggingId] = useState(null); + const dragRef = useRef(null); + const tabRectsRef = useRef>(new Map()); + + const captureRects = useCallback((container: HTMLElement) => { + const rects = new Map(); + const tabs = container.querySelectorAll("[data-tab-id]"); + tabs.forEach((el) => { + const id = el.dataset.tabId!; + rects.set(id, el.getBoundingClientRect()); + }); + tabRectsRef.current = rects; + }, []); + + const onPointerDown = useCallback( + (e: React.PointerEvent, tabId: string, container: HTMLElement) => { + if (e.button !== 0) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + captureRects(container); + const index = tabIds.indexOf(tabId); + dragRef.current = { tabId, startX: e.clientX, currentIndex: index }; + setDraggingId(tabId); + }, + [tabIds, captureRects], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + + const rects = tabRectsRef.current; + let targetIndex = drag.currentIndex; + + for (const [id, rect] of rects) { + const midX = rect.left + rect.width / 2; + const idx = tabIds.indexOf(id); + if (e.clientX < midX && idx < targetIndex) { + targetIndex = idx; + break; + } + if (e.clientX > midX && idx > targetIndex) { + targetIndex = idx; + } + } + + if (targetIndex !== drag.currentIndex) { + onReorder(drag.currentIndex, targetIndex); + drag.currentIndex = targetIndex; + const container = (e.currentTarget as HTMLElement).closest( + "[data-tab-bar]", + ); + if (container) captureRects(container as HTMLElement); + } + }, + [tabIds, onReorder, captureRects], + ); + + const onPointerUp = useCallback(() => { + dragRef.current = null; + setDraggingId(null); + }, []); + + return { draggingId, onPointerDown, onPointerMove, onPointerUp }; +} diff --git a/features/editor/hooks/use-tab-shortcuts.ts b/features/editor/hooks/use-tab-shortcuts.ts new file mode 100644 index 0000000..c4c99bd --- /dev/null +++ b/features/editor/hooks/use-tab-shortcuts.ts @@ -0,0 +1,48 @@ +"use client"; +import { useEffect } from "react"; +import { useApplePlatform } from "@/common/hooks/use-apple-platform"; +import { useTabStoreApi } from "../stores/tab-store"; + +/** + * Tab navigation: ⌘⇧[ / ⌘⇧] (Ctrl+Shift on other platforms). + * + * Deliberately nothing else. ⌘N and ⌘W are native menu accelerators, so Rust + * already emits `menu-new-tab` / `menu-close-tab` and handling them here too + * would fire twice wherever the key also reaches the webview. ⌘1–⌘9 and ⌘T + * belong to the heading and table commands in `@do-md/commands`; both keymaps + * listen on window during bubble, so a second claimant would be resolved by + * mount order rather than by any rule. + * + * Matches on `event.code`, not `event.key`: with Shift held, the bracket keys + * report "{" and "}", so keying off `event.key === "["` never fires. + */ +export function useTabShortcuts({ enabled }: { enabled: boolean }) { + const store = useTabStoreApi(); + const isApple = useApplePlatform(); + + useEffect(() => { + if (!enabled) return; + const handler = (e: KeyboardEvent) => { + const modifier = isApple ? e.metaKey : e.ctrlKey; + if (!modifier || !e.shiftKey) return; + + const forward = e.code === "BracketRight"; + const back = e.code === "BracketLeft"; + if (!forward && !back) return; + + const { tabs, activeTabId } = store.state; + if (tabs.length < 2) return; + const index = tabs.findIndex((tab) => tab.id === activeTabId); + if (index === -1) return; + + e.preventDefault(); + const next = forward + ? (index + 1) % tabs.length + : (index - 1 + tabs.length) % tabs.length; + store.activateTab(tabs[next].id); + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [store, enabled, isApple]); +} diff --git a/features/editor/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts new file mode 100644 index 0000000..1951cfc --- /dev/null +++ b/features/editor/hooks/use-tabs.ts @@ -0,0 +1,400 @@ +"use client"; +/** + * Desktop tab machinery for the editor shell. + * + * Everything here is about which document is open and how documents enter and + * leave the window. The editor itself is untouched: useDocumentLoaders reads + * the active tab, so upstream's editor tree renders the active document and a + * tab switch is just another document swap. + * + * Web never calls this — one page, one document, no tab bar. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { isTauri } from "@/common/lib/platform"; +import { tauriCore, tauriWebviewWindow } from "@/common/lib/tauri"; +import type { UnsavedChoice } from "../components/tab-close-modal"; +import { useLatest } from "@/common/lib/use-latest"; +import { + blankTauriDoc, + readTauriDoc, + rereadTauriDoc, +} from "./use-document-loaders"; +import { saveDocument } from "../lib/save-document"; +import { useTabShortcuts } from "./use-tab-shortcuts"; +import { useTauriEvent } from "./use-tauri-event"; +import { useTabStore, useTabStoreApi } from "../stores/tab-store"; + +/** A tab close waiting on the user's answer. The name is captured when the + * prompt opens rather than read at render time, so a rename or a save landing + * underneath cannot change the question already on screen. */ +export interface TabCloseRequest { + tabId: string; + name: string; +} + +export function useTabs({ + enabled, + onDocumentSwitch, +}: { + /** Desktop only. Called unconditionally (hooks rule) and inert on web, + * where a window is one page holding one document. */ + enabled: boolean; + /** Runs when the ACTIVE tab changes — the same teardown upstream does + * when a different document loads into the window (detachSharing). The + * live collaboration session belongs to the document that was showing, + * and only the active tab has a mounted editor to attach one to. */ + onDocumentSwitch: () => void; +}) { + const store = useTabStoreApi(); + const tabs = useTabStore((s) => s.state.tabs); + const activeTabId = useTabStore((s) => s.state.activeTabId); + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; + const onDocumentSwitchRef = useLatest(onDocumentSwitch); + + useTabShortcuts({ enabled }); + + /** Mirror the editor's unsaved-changes state onto the active tab so the + * tab bar can badge it. Stable identity — Editor stores it in a ref. */ + const markActiveTabDirty = useCallback( + (isDirty: boolean) => { + const id = store.state.activeTabId; + if (id) store.markDirty(id, isDirty); + }, + [store], + ); + + // ── Opening documents into tabs ────────────────────────────────────── + /** Tell Rust the requested document is now the one an `insert` would hit. + * + * `open_or_reuse` clears WindowReady before routing a file to this window, + * because the window is about to show a different document and an + * immediate `insert` must not land in the previous one. Something has to + * re-arm it, and until tabs that was the editor remount. + * + * Neither route can rely on a remount any more. Reusing the lone blank tab + * resets the runtime in place, precisely so view state survives; and + * activating a background tab is a switch Rust cannot tell apart from + * re-activating the tab already showing, where clearing readiness with + * nothing to restore it would leave the CLI waiting for a mark that never + * comes. So readiness is asserted by the host, from the one layer that + * knows the document is in place — and asserted on EVERY route, including + * the one where nothing changed. */ + const markReady = async () => { + if (!isTauri()) return; + const { invoke } = await tauriCore(); + await invoke("benchmark_mark_ready").catch(() => {}); + }; + + /** Reuse the lone untouched blank tab if that is all the window has — + * opening a file from Finder should not leave an empty tab behind. + * (useTauriEvent ref-stores its handler, so this closure is always the + * current one without any extra indirection.) */ + const openPathInTab = async (path: string) => { + const doc = await readTauriDoc(path); + if (store.isOnlyBlankTab()) { + store.replaceTabDoc(store.state.tabs[0].id, doc.meta, doc.content); + } else { + store.addTab(doc.meta, doc.content); + } + await markReady(); + }; + + useTauriEvent("open-file-in-tab", (path) => { + void openPathInTab(path); + }); + + useTauriEvent("activate-tab", (path) => { + const tab = store.findTabByPath(path); + // Mark ready even when the tab was already active: `activateTab` is a + // no-op there, but Rust cleared readiness on the way in and this is the + // only thing that will put it back. + if (tab) store.activateTab(tab.id); + void markReady(); + }); + + useTauriEvent("menu-new-tab", () => { + window.dispatchEvent(new CustomEvent("domd-new-tab")); + }); + + useTauriEvent("menu-close-tab", () => { + window.dispatchEvent( + new CustomEvent("domd-close-tab", { + detail: { tabId: store.state.activeTabId }, + }), + ); + }); + + // ── The per-tab unsaved prompt ─────────────────────────────────────── + // Closing one tab among several has no native counterpart — the macOS + // sheet speaks for a window — so this asks in-app, with the sheet's three + // outcomes rather than the two the `ask` plugin dialog can offer. + // + // The close flow is async and the answer comes from a rendered component, + // so the promise is bridged through a ref: `askUnsaved` parks the resolver + // and shows the modal, `decideUnsaved` settles it. A ref rather than state + // because the event handlers below read it during an event, when a state + // value captured at render time would be stale. + const [closeRequest, setCloseRequest] = useState( + null, + ); + const decideRef = useRef<((choice: UnsavedChoice) => void) | null>(null); + const refocusRef = useRef(null); + + const askUnsaved = useCallback( + (request: TabCloseRequest) => + new Promise((resolve) => { + // Captured before the modal takes focus, so it is whatever the + // user was actually in. Only ever given back on Cancel. + refocusRef.current = document.activeElement as HTMLElement | null; + decideRef.current = resolve; + setCloseRequest(request); + }), + [], + ); + + /** Put the caret back where it was before the prompt. + * + * Cancel ONLY, and it is not optional there: the editor stays mounted and + * focus is sitting on a button that is about to disappear, so the next + * keystroke would go to . The kernel binds its key handling to the + * editable root, which means typing would silently do nothing until you + * clicked back in. + * + * Save and discard skip it because both close the tab, and focus for the + * document that comes forward belongs to TabFocusOnSwitch, after the + * switch. Restoring here too would aim at the outgoing editor and race + * the component whose whole job this is. */ + const refocusAfterCancel = useCallback(() => { + const previous = refocusRef.current; + refocusRef.current = null; + if (!previous) return; + // After the commit that unmounts the modal, or focus lands on an + // element that is on its way out. + requestAnimationFrame(() => { + if (previous.isConnected) previous.focus(); + }); + }, []); + + const decideUnsaved = useCallback((choice: UnsavedChoice) => { + const decide = decideRef.current; + decideRef.current = null; + setCloseRequest(null); + decide?.(choice); + }, []); + + // A prompt outliving its window would leave the close flow awaiting a + // promise nobody can settle. Cancelling is the safe resolution: it is the + // one outcome that touches neither the document nor the disk. + useEffect( + () => () => { + decideRef.current?.("cancel"); + decideRef.current = null; + }, + [], + ); + + /** Ask about a tab whose work would otherwise be lost, and report whether + * the caller may proceed. False means the user cancelled. + * + * Only never-saved documents qualify: a path-backed one is already on + * disk (autosave wrote it), so closing it silently is correct. */ + const resolveUnsaved = useCallback( + async (tabId: string): Promise => { + const tab = store.state.tabs.find((tb) => tb.id === tabId); + if (!tab || !tab.isDirty) return true; + if (tab.meta.kind !== "tauri" || tab.meta.path) return true; + + const choice = await askUnsaved({ tabId, name: tab.meta.name }); + if (choice === "cancel") { + refocusAfterCancel(); + return false; + } + refocusRef.current = null; + if (choice === "discard") return true; + + // One path for every tab: the runtime holds the live document + // whether or not a view is attached, so a background tab needs no + // snapshot and the active tab needs no detour through the mounted + // editor's save handle. saveDocument opens the picker, and a + // cancelled picker cancels the close. + const result = await saveDocument(tab.meta, store.contentOf(tabId)); + if (result.ok) store.updateTabMeta(tabId, result.meta); + return result.ok; + }, + [store, askUnsaved, refocusAfterCancel], + ); + + // ── New / close, driven by the tab bar, shortcuts and native menu ──── + useEffect(() => { + if (!enabled) return; + const handleNewTab = () => { + const doc = blankTauriDoc(); + store.addTab(doc.meta, doc.content); + }; + + const handleCloseTab = (e: Event) => { + const { tabId } = (e as CustomEvent).detail; + if (!store.state.tabs.some((tb) => tb.id === tabId)) return; + // One prompt at a time. ⌘W still reaches the menu while the modal + // is up, and a second close would strand the first request's + // resolver — the close it belongs to would then await forever. + if (decideRef.current) return; + void (async () => { + // Closing the LAST tab closes the window, and that goes + // through the ordinary close so the native save sheet stays + // the single authority on unsaved work — it now sees every + // tab, not just the visible one (see update_tabs). + if (store.state.tabs.length <= 1) { + const { getCurrentWebviewWindow } = + await tauriWebviewWindow(); + await getCurrentWebviewWindow().close(); + return; + } + // Closing one tab among several has no native counterpart: + // the sheet speaks for a window, so ask here instead. + if (!(await resolveUnsaved(tabId))) return; + store.closeTab(tabId); + })(); + }; + + window.addEventListener("domd-new-tab", handleNewTab); + window.addEventListener("domd-close-tab", handleCloseTab); + return () => { + window.removeEventListener("domd-new-tab", handleNewTab); + window.removeEventListener("domd-close-tab", handleCloseTab); + }; + }, [store, enabled, resolveUnsaved]); + + // ── A tab switch is a document switch ──────────────────────────────── + // Driven off the store subscription rather than an effect on activeTabId: + // the teardown must run once per actual switch, keying on the tab id (not + // the doc id) so switching between two never-saved documents still + // detaches — and it is where the state below belongs too, since a + // subscription callback is the sanctioned place to call setState. + // + // `switchedTabs` drives TabFocusOnSwitch, which puts real DOM focus on the + // editor after a switch (see that component for why model-level focus is + // not enough). Only this layer knows a mount came from a switch rather + // than from opening the window's first document, and the empty -> first + // tab transition is not a switch: guarding it keeps upstream's deliberate + // no-autofocus behaviour on first load, and stops the initial load from + // running a pointless collaboration teardown. + const [switchedTabs, setSwitchedTabs] = useState(false); + useEffect( + () => + store.subscribe((next, prev) => { + if (!prev.activeTabId) return; + if (next.activeTabId === prev.activeTabId) return; + setSwitchedTabs(true); + onDocumentSwitchRef.current(); + }), + [store, onDocumentSwitchRef], + ); + + // ── External writes to BACKGROUND tabs ─────────────────────────────── + // The active tab's own DiskReconciler handles its file; this only records + // that a dormant tab's file moved (markPathStale skips the active tab). + useTauriEvent("file-changed", (path) => { + store.markPathStale(path); + }); + + // Consume staleness when a tab returns to the foreground. A clean tab + // adopts the disk content wholesale; a dirty one keeps its edits and asks + // its (now mounted) reconciler for a forced pass, which splices the + // external delta in rather than discarding either side. + useEffect(() => { + if (!enabled || !activeTab?.diskStale) return; + const tabId = activeTab.id; + if (activeTab.isDirty) { + store.requestReconcile(tabId); + return; + } + const meta = activeTab.meta; + if (meta.kind !== "tauri" || !meta.path) { + store.clearDiskStale(tabId); + return; + } + const path = meta.path; + let cancelled = false; + void (async () => { + const doc = await rereadTauriDoc(path); + if (cancelled) return; + if (!doc) { + store.clearDiskStale(tabId); + return; + } + store.replaceTabDoc(tabId, doc.meta, doc.content); + })(); + return () => { + cancelled = true; + }; + }, [activeTab, store, enabled]); + + // ── Push open tab paths to Rust ────────────────────────────────────── + // Feeds open_or_reuse (focus the window already showing a file and + // activate its tab) and the file watcher (watch every open tab, not just + // the window's last-assigned document). + useEffect(() => { + if (!enabled) return; + const payload = tabs.map((tab) => { + const path = tab.meta.kind === "tauri" ? tab.meta.path : null; + const unsaveable = tab.isDirty && !path; + return { + path, + isDirty: tab.isDirty, + // Only for tabs the save sheet could be asked to review. + // Everything else is either on disk already or has nothing to + // lose, and shipping every document's text to Rust on every + // keystroke would be pointless traffic. + content: unsaveable ? store.contentOf(tab.id) : null, + }; + }); + tauriCore().then(({ invoke }) => { + invoke("update_tabs", { tabs: payload }).catch(() => {}); + }); + }, [tabs, enabled, store]); + + // ── The window's assigned document follows the active tab ──────────── + // Drives the window title, and the close flow: Rust lets a window close + // silently when it has an assigned path (a saved document autosaves) and + // shows the native "save changes?" sheet when it does not. Leaving a + // previously-saved tab's path behind while an untitled tab is active + // would drop unsaved work without a prompt. + const activeMeta = activeTab?.meta; + const activePath = + activeMeta?.kind === "tauri" ? (activeMeta.path ?? null) : null; + useEffect(() => { + if (!enabled) return; + tauriCore().then(({ invoke }) => { + if (activePath) { + invoke("set_window_path", { path: activePath }).catch(() => {}); + } else { + invoke("clear_window_path").catch(() => {}); + } + }); + }, [activePath, enabled]); + + // ── Window close is left to Rust, deliberately ─────────────────────── + // There is NO onCloseRequested listener here, and adding one is a trap. + // Tauri's JS wrapper is: + // + // await handler(evt); + // if (!evt.isPreventDefault()) await this.destroy(); + // + // Registering a listener therefore moves responsibility for actually + // destroying the window into JavaScript, while the Rust close handler + // (lib.rs, WindowEvent::CloseRequested — the native "save changes?" + // sheet) is still running its own flow. A handler that prevents, throws, + // or simply never finishes leaves the window with nobody left to close + // it: the red button stops working entirely, which is far worse than the + // edge case such a listener would be guarding. + // + // So the close button behaves exactly as upstream does. The residual gap + // is a background tab holding NEVER-SAVED work when the window closes: + // the native sheet can only speak for the active document. Closing that + // tab directly does prompt (see resolveUnsaved above), which is the path + // people actually take, and saved documents are never at risk because + // autosave and the switch-time flush have already written them. + + return { markActiveTabDirty, switchedTabs, closeRequest, decideUnsaved }; +} diff --git a/features/editor/lib/types.ts b/features/editor/lib/types.ts index b082bad..830a0bb 100644 --- a/features/editor/lib/types.ts +++ b/features/editor/lib/types.ts @@ -20,3 +20,41 @@ export type FileMeta = }; export type View = "loading" | "editor"; + +/** + * One open document. + * + * Deliberately holds no document CONTENT. Each tab owns a long-lived + * `EditorStore` — the document runtime — which lives in the TabStore's + * registry rather than in this state, because immer freezes state and a + * frozen store is a broken one. The runtime is the source of truth for + * content, undo history, selection, focus and scroll; a view attaches to it + * when the tab is active and detaches when it is not, and neither side is + * reconstructed in the process. + * + * That is why there is no `content`, `scrollTop` or `docEpoch` here: nothing + * is serialized out on switch, so nothing can go stale and need a generation + * counter to guard it. + */ +export interface Tab { + id: string; + meta: FileMeta; + /** Unsaved-changes state, mirrored from the runtime while the tab is + * active. A background tab keeps its last-known value, which is correct + * while nothing edits background documents — and is what the window + * close flow reports to Rust for tabs that are not on screen. */ + isDirty: boolean; + /** Set when the file watcher reported an external write while this tab + * was not the active one. Consumed on activation: a clean tab adopts the + * disk content, a dirty tab gets a forced reconcile pass. */ + diskStale: boolean; + /** Bumped to force the mounted DiskReconciler to run a pass — reuses the + * same trigger mechanism as the collab attach. */ + reconcileEpoch: number; +} + +export interface TabStoreState { + tabs: Tab[]; + activeTabId: string; + displayMode: "shrink" | "scroll"; +} diff --git a/features/editor/stores/tab-store.ts b/features/editor/stores/tab-store.ts new file mode 100644 index 0000000..709bfe3 --- /dev/null +++ b/features/editor/stores/tab-store.ts @@ -0,0 +1,237 @@ +import type { EditorStore } from "@do-md/core-react"; +import { nanoid } from "@do-md/utils"; +import { ZenithStore, createReactStore } from "@do-md/zenith"; +import type { FileMeta, Tab, TabStoreState } from "../lib/types"; + +/** Builds a document runtime for one tab. Supplied by the app so the kernel + * options (tokenizer, inline rules, beautifier, image loader, placeholder) + * live in one place — see EditorApp. */ +export type CreateRuntime = (initMd: string) => EditorStore; + +/** + * The open documents of one window, and their live runtimes. + * + * This is the document source for BOTH runtimes of the app, not just the + * tabbed one: useDocumentLoaders reads the active tab and writes through this + * store, so the web shell is simply the case where nothing ever opens a + * second tab. Keeping one path means upstream's editor tree stays the only + * editor tree. + * + * Each tab owns an `EditorStore` for as long as the tab is open. It is + * created when the tab opens and dropped when the tab closes — never on a + * switch. Switching tabs remounts the VIEW over another tab's existing store, + * so undo history, selection, focus and scroll survive (the kernel restores + * the last three on attach), and a large document is never re-parsed just + * because you looked at something else. + * + * The registry is a plain Map on the instance rather than part of `state`: + * immer freezes state, and a frozen EditorStore would be inert. Nothing + * renders from the map directly — components read the runtime for the active + * tab id — so it does not need to be reactive. + * + * Starts EMPTY: the document is resolved in a mount effect (the static export + * cannot know at build time whether it is opening a file, a draft or a blank + * doc), and no tabs is exactly the "loading" view. + */ +export class TabStore extends ZenithStore { + private readonly runtimes = new Map(); + private readonly createRuntime: CreateRuntime; + + constructor(props: { createRuntime: CreateRuntime }) { + super({ tabs: [], activeTabId: "", displayMode: "shrink" }); + this.createRuntime = props.createRuntime; + } + + get activeTab(): Tab | undefined { + return this.state.tabs.find((t) => t.id === this.state.activeTabId); + } + + /** The live document runtime for a tab, or undefined once it is closed. */ + runtimeOf(tabId: string): EditorStore | undefined { + return this.runtimes.get(tabId); + } + + get activeRuntime(): EditorStore | undefined { + return this.runtimes.get(this.state.activeTabId); + } + + /** Current markdown of a tab, live from its runtime. Works for background + * tabs too — that is the point of keeping the store alive. */ + contentOf(tabId: string): string { + return this.runtimes.get(tabId)?.toMarkdown() ?? ""; + } + + /** Load a document into the ACTIVE tab, creating the first tab if the + * window has none. This is the single-document path — what every + * useDocumentLoaders entry point does, and all the web shell ever does. + * Opening a document in a NEW tab is `addTab`. */ + openInActiveTab(meta: FileMeta, content: string): string { + const current = this.activeTab; + if (!current) return this.addTab(meta, content); + this.replaceTabDoc(current.id, meta, content); + return current.id; + } + + /** Returns the id of the tab now showing this document — either a newly + * created one, or the existing tab it deduped into. */ + addTab(meta: FileMeta, content: string): string { + // Dedup: if a tab with the same path is already open, activate it. + if (meta.kind === "tauri" && meta.path) { + const existing = this.state.tabs.find( + (t) => t.meta.kind === "tauri" && t.meta.path === meta.path, + ); + if (existing) { + this.activateTab(existing.id); + return existing.id; + } + } + + const id = nanoid(); + this.runtimes.set(id, this.createRuntime(content)); + this.produce((draft) => { + draft.tabs.push({ + id, + meta, + isDirty: false, + diskStale: false, + reconcileEpoch: 0, + }); + draft.activeTabId = id; + }); + return id; + } + + closeTab(tabId: string): "closed" | "last-tab" { + const { tabs } = this.state; + if (tabs.length <= 1) return "last-tab"; + + const index = tabs.findIndex((t) => t.id === tabId); + if (index === -1) return "closed"; + + this.produce((draft) => { + draft.tabs.splice(index, 1); + if (draft.activeTabId === tabId) { + // Activate neighbor: prefer right, fallback left. + const nextIndex = Math.min(index, draft.tabs.length - 1); + draft.activeTabId = draft.tabs[nextIndex].id; + } + }); + // The document runtime dies with its tab and only with its tab. + // Dropping the reference is the whole teardown: the kernel's per-view + // controller is torn down by the view unmount, and the store holds no + // resources of its own. + this.runtimes.delete(tabId); + return "closed"; + } + + activateTab(tabId: string) { + if (tabId === this.state.activeTabId) return; + this.produce((draft) => { + draft.activeTabId = tabId; + }); + } + + reorderTab(fromIndex: number, toIndex: number) { + if (fromIndex === toIndex) return; + this.produce((draft) => { + const [moved] = draft.tabs.splice(fromIndex, 1); + draft.tabs.splice(toIndex, 0, moved); + }); + } + + updateTabMeta(tabId: string, meta: FileMeta) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) tab.meta = meta; + }); + } + + /** Replace a tab's document — a different file loaded into this tab, or a + * disk re-read. The tab keeps its identity and its runtime; only the + * document inside the runtime is reset, so the view does not remount and + * the editor is never reconstructed. */ + replaceTabDoc(tabId: string, meta: FileMeta, content: string) { + const runtime = this.runtimes.get(tabId); + if (runtime) runtime.resetMD(content); + else this.runtimes.set(tabId, this.createRuntime(content)); + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (!tab) return; + tab.meta = meta; + tab.isDirty = false; + tab.diskStale = false; + }); + } + + markDirty(tabId: string, dirty: boolean) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) tab.isDirty = dirty; + }); + } + + /** Flag every BACKGROUND tab bound to this path as needing a disk + * re-read. The active tab is skipped: its mounted DiskReconciler already + * handles `file-changed` for the live document. */ + markPathStale(path: string) { + this.produce((draft) => { + for (const tab of draft.tabs) { + if (tab.id === draft.activeTabId) continue; + if (tab.meta.kind === "tauri" && tab.meta.path === path) { + tab.diskStale = true; + } + } + }); + } + + clearDiskStale(tabId: string) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) tab.diskStale = false; + }); + } + + /** Ask the tab's mounted reconciler for a forced pass, and clear the + * stale flag now that it has been handed off. */ + requestReconcile(tabId: string) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (!tab) return; + tab.reconcileEpoch += 1; + tab.diskStale = false; + }); + } + + setDisplayMode(mode: "shrink" | "scroll") { + this.produce((draft) => { + draft.displayMode = mode; + }); + } + + findTabByPath(path: string): Tab | undefined { + return this.state.tabs.find( + (t) => t.meta.kind === "tauri" && t.meta.path === path, + ); + } + + /** A lone, untouched, never-saved tab — the blank document a window opens + * with. Opening a file from Finder should reuse it rather than leave an + * empty tab behind. Content comes from the live runtime. */ + isOnlyBlankTab(): boolean { + const { tabs } = this.state; + if (tabs.length !== 1) return false; + const tab = tabs[0]; + return ( + tab.meta.kind === "tauri" && + tab.meta.path === null && + !tab.isDirty && + this.contentOf(tab.id) === "" + ); + } +} + +export const { + StoreProvider: TabStoreProvider, + useStore: useTabStore, + useStoreApi: useTabStoreApi, +} = createReactStore(TabStore); diff --git a/scripts/verify-tab-runtime/run.mts b/scripts/verify-tab-runtime/run.mts new file mode 100644 index 0000000..d234d78 --- /dev/null +++ b/scripts/verify-tab-runtime/run.mts @@ -0,0 +1,61 @@ +/** + * Does a hand-constructed EditorStore — the way the tab registry builds one — + * have a working undo stack? + * + * node --experimental-strip-types --import ./scripts/lib/register-ts-resolve.mjs \ + * scripts/verify-tab-runtime/run.mts + */ +import { EditorStore } from "@do-md/core-react"; + +let passed = 0; +const failures: string[] = []; + +const check = (name: string, ok: boolean, detail = "") => { + if (ok) { + passed += 1; + console.log(` ok ${name}`); + } else { + failures.push(`${name}${detail ? ` — ${detail}` : ""}`); + console.log(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); + } +}; + +// Exactly what createRuntime passes in EditorApp, minus the app's injected +// functions (tokenizer/beautifier/imageLoader are irrelevant to history). +const makeRuntime = (initMd: string) => + new EditorStore({ + editable: true, + initMd, + placeholder: "Start writing Markdown...", + mode: "rich", + }); + +console.log("hand-constructed runtime, undo stack"); + +const store = makeRuntime("# Alpha\n\nalpha body\n"); +const original = store.toMarkdown(); +check("constructs and serializes", original.includes("alpha body"), original); + +check("exposes undo()", typeof store.undo === "function"); +check("exposes redo()", typeof store.redo === "function"); + +// Edit through the public text path, then undo it. +store.insertText(" EDIT"); +const afterEdit = store.toMarkdown(); +check("insertText changes the document", afterEdit !== original, afterEdit); + +// history debounces at 300ms — let it settle before undoing. +await new Promise((r) => setTimeout(r, 600)); +store.undo(); +await new Promise((r) => setTimeout(r, 50)); +const afterUndo = store.toMarkdown(); +check( + "undo() reverts the edit", + afterUndo !== afterEdit, + `after edit: ${JSON.stringify(afterEdit)} / after undo: ${JSON.stringify(afterUndo)}`, +); + +console.log( + `\n${passed} passed, ${failures.length} failed${failures.length ? `:\n - ${failures.join("\n - ")}` : ""}`, +); +process.exit(failures.length ? 1 : 0); diff --git a/src-tauri/src/cli_server.rs b/src-tauri/src/cli_server.rs index 885dfeb..de1e5fc 100644 --- a/src-tauri/src/cli_server.rs +++ b/src-tauri/src/cli_server.rs @@ -460,12 +460,13 @@ async fn handle_close( }; let force = force.unwrap_or(false); if !force { - let dirty = app - .state::() - .get(&label) - .map(|c| c.is_dirty) - .unwrap_or(false); - if dirty { + // The same gate the window's close flow uses, so it accounts for + // EVERY tab rather than the visible one. `destroy()` below bypasses + // CloseRequested, so without this an unsaved, never-saved document in + // a background tab could be discarded by a close the caller believed + // was safe. Saved documents stay closable: autosave already wrote + // them. + if crate::unsaved_untitled_content(app, &label).is_some() { return err( "unsaved_changes", format!("window {} has unsaved changes; pass force=true to discard", label), diff --git a/src-tauri/src/file_watch.rs b/src-tauri/src/file_watch.rs index 093284a..12f5ed4 100644 --- a/src-tauri/src/file_watch.rs +++ b/src-tauri/src/file_watch.rs @@ -1,8 +1,12 @@ //! External-change watcher for open documents. //! -//! A single background task polls every window's assigned file (WindowFiles) -//! once per second and emits `file-changed` (payload: the path) to the -//! owning webview when the file's mtime or size moves. Polling over a +//! A single background task polls every open document once per second and +//! emits `file-changed` (payload: the path) to the owning webview when the +//! file's mtime or size moves. "Open" means the union of each window's +//! assigned file (WindowFiles) and every path in its tab strip (WindowTabs): +//! a background tab has no mounted editor, but its document is still open and +//! must not be silently overwritten when it returns to the foreground. Polling +//! over a //! notify-based watcher on purpose: editors save via atomic rename (new //! inode), cloud sync tools touch files in bursts, and a 1 Hz stat of a //! handful of files is effectively free — no watcher lifecycle to manage. @@ -16,25 +20,25 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; -use crate::WindowFiles; +use crate::{WindowFiles, WindowTabs}; #[derive(Clone, PartialEq)] struct Fingerprint { - path: String, mtime: SystemTime, len: u64, } pub async fn run(app: AppHandle) { - // label -> last observed fingerprint. First sighting of a (label, path) - // pair records a baseline WITHOUT emitting, so opening a file does not - // fire a spurious change event. - let mut seen: HashMap = HashMap::new(); + // (window label, path) -> last observed fingerprint. Keyed by the pair, + // not by the window: one window watches every document in its tab strip. + // First sighting of a pair records a baseline WITHOUT emitting, so + // opening a file does not fire a spurious change event. + let mut seen: HashMap<(String, String), Fingerprint> = HashMap::new(); loop { tokio::time::sleep(Duration::from_millis(1000)).await; - let entries: Vec<(String, String)> = { + let mut entries: Vec<(String, String)> = { let files = app.state::(); let guard = files.0.lock().unwrap(); guard @@ -42,31 +46,37 @@ pub async fn run(app: AppHandle) { .map(|(label, path)| (label.clone(), path.clone())) .collect() }; + // Tabs are the bigger set in practice; the window's assigned file is + // usually among them, so dedupe rather than stat the same file twice. + entries.extend(app.state::().entries()); + entries.sort(); + entries.dedup(); - seen.retain(|label, _| entries.iter().any(|(l, _)| l == label)); + seen.retain(|key, _| entries.contains(key)); - for (label, path) in entries { - let Ok(meta) = std::fs::metadata(&path) else { + for key in entries { + let (label, path) = (&key.0, &key.1); + let Ok(meta) = std::fs::metadata(path) else { // Unreadable / deleted — drop the baseline so a reappearing // file is re-primed instead of compared against stale state. - seen.remove(&label); + seen.remove(&key); continue; }; let fingerprint = Fingerprint { - path: path.clone(), mtime: meta.modified().unwrap_or(UNIX_EPOCH), len: meta.len(), }; - match seen.get(&label) { - Some(prev) if prev.path == path => { + match seen.get(&key) { + Some(prev) => { if *prev != fingerprint { - seen.insert(label.clone(), fingerprint); let _ = app.emit_to(label.as_str(), "file-changed", path); + seen.insert(key, fingerprint); } } - _ => { - // New window or the window switched documents — baseline. - seen.insert(label.clone(), fingerprint); + None => { + // New window, new tab, or a window that switched + // documents — baseline without emitting. + seen.insert(key, fingerprint); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7ad030..82aea94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,12 +130,22 @@ fn build_app_menu>( ], )?; + // Cmd+N opens a TAB in the current window and Shift+Cmd+N a new window — + // the shape every tabbed macOS app uses. Both are menu entries so the + // binding is discoverable and so the accelerators are owned in one place. + let new_tab_item = MenuItem::with_id( + manager, + "new-tab", + menu_i18n::t(locale, "menu.newTab"), + true, + Some("Cmd+N"), + )?; let new_window_item = MenuItem::with_id( manager, "new-window", menu_i18n::t(locale, "menu.newWindow"), true, - Some("Cmd+N"), + Some("Shift+Cmd+N"), )?; let open_url_item = MenuItem::with_id( manager, @@ -170,6 +180,7 @@ fn build_app_menu>( &menu_i18n::t(locale, "menu.file"), true, &[ + &new_tab_item, &new_window_item, &open_url_item, &close_window_item, @@ -437,6 +448,116 @@ static WIN_ID: AtomicU32 = AtomicU32::new(0); pub struct WindowFiles(pub Mutex>); +/// Every file path open as a TAB in each window, pushed from the frontend +/// whenever the tab set changes. +/// +/// `WindowFiles` records one path per window — the document the window was +/// last assigned — which is no longer the whole story now that a window can +/// hold several. Two things read this instead: +/// +/// * `open_or_reuse`, so opening a file already present in some window's tab +/// strip focuses that window and activates the tab rather than opening a +/// duplicate; +/// * the file watcher, so background tabs are watched too and not just the +/// window's last-assigned document. +/// One open tab, as the frontend sees it. +#[derive(Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TabInfo { + /// None for a document that has never been saved. + pub path: Option, + pub is_dirty: bool, + /// Sent ONLY for tabs that are dirty AND never saved — the only ones the + /// native save sheet can be asked to review, and the only ones whose + /// content the Rust side has any reason to hold. + pub content: Option, +} + +pub struct WindowTabs(pub Mutex>>); + +impl WindowTabs { + pub fn set(&self, label: &str, tabs: Vec) { + self.0.lock().unwrap().insert(label.to_string(), tabs); + } + + pub fn remove(&self, label: &str) { + self.0.lock().unwrap().remove(label); + } + + /// The window holding this path in a tab, if any. + pub fn find_window_with_path(&self, path: &str) -> Option { + self.0 + .lock() + .unwrap() + .iter() + .find(|(_, tabs)| { + tabs.iter().any(|t| t.path.as_deref() == Some(path)) + }) + .map(|(label, _)| label.clone()) + } + + /// Snapshot of (window label, tab path) pairs — used by the watcher. + pub fn entries(&self) -> Vec<(String, String)> { + self.0 + .lock() + .unwrap() + .iter() + .flat_map(|(label, tabs)| { + tabs.iter().filter_map(move |t| { + t.path.as_ref().map(|p| (label.clone(), p.clone())) + }) + }) + .collect() + } + + /// Has the frontend reported tab state for this window yet? + pub fn knows(&self, label: &str) -> bool { + self.0.lock().unwrap().contains_key(label) + } + + /// Content of the first tab holding work no autosave will ever write: + /// dirty AND never saved. This is what the close/quit gates ask about, + /// and the point of reporting per-tab state at all — a window must not be + /// able to close over unsaved work sitting in a tab you cannot see. + pub fn first_unsaved_content(&self, label: &str) -> Option { + self.0 + .lock() + .unwrap() + .get(label)? + .iter() + .find(|t| t.is_dirty && t.path.is_none()) + .map(|t| t.content.clone().unwrap_or_default()) + } +} + +/// Does this window hold unsaved, never-saved work — and if so, what should +/// the save sheet show? +/// +/// Tab state is authoritative once the frontend has reported it, because it +/// covers every document in the window rather than just the one on screen. +/// The per-window snapshot remains the fallback for anything that has not +/// reported tabs (a window still starting up). +pub(crate) fn unsaved_untitled_content(app: &AppHandle, label: &str) -> Option { + let tabs = app.state::(); + if tabs.knows(label) { + return tabs.first_unsaved_content(label); + } + let has_path = app + .state::() + .0 + .lock() + .unwrap() + .contains_key(label); + if has_path { + return None; + } + let content_state = app.state::().get(label); + if content_state.as_ref().map_or(false, |c| c.is_dirty) { + return Some(content_state.map(|c| c.content).unwrap_or_default()); + } + None +} + /// Per-window `UntitledDoc` (NSDocument subclass) used to drive the native /// "save changes?" sheet for untitled+dirty docs on macOS. Populated lazily /// on close request, cleared when the close flow resolves. @@ -793,6 +914,26 @@ fn update_content( state.set(window.label(), content, is_dirty); } +/// FE pushes the state of all open tabs whenever the tab set changes — paths +/// for `open_or_reuse` and the file watcher, dirty/untitled for the close and +/// quit gates. +#[tauri::command] +fn update_tabs(window: Window, state: State, tabs: Vec) { + state.set(window.label(), tabs); +} + +/// Drop this window's assigned path — the counterpart to `set_window_path`. +/// +/// With tabs the assignment tracks the ACTIVE document, so activating a +/// never-saved tab has to clear it. The close flow reads this to decide +/// whether a window can close silently (a saved document autosaves; an +/// untitled one needs the "save changes?" sheet), and leaving a stale path +/// behind would let unsaved work disappear without a prompt. +#[tauri::command] +fn clear_window_path(window: Window, state: State) { + state.0.lock().unwrap().remove(window.label()); +} + // ── window helpers ──────────────────────────────────────────────────────────── pub(crate) fn new_empty_window(app: &AppHandle) -> String { @@ -942,17 +1083,41 @@ fn notify_user(title: &str, body: &str) { /// telling the caller whether that window was already showing this file /// (so the AI agent knows "I just opened this" vs "user was already on it"). pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { - let filename = std::path::Path::new(&path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("DOMD") - .to_string(); + let all_windows = app.webview_windows(); + + // Already open as a tab somewhere: focus that window and activate the tab. + let tabs = app.state::(); + if let Some(label) = tabs.find_window_with_path(&path) { + if let Some(win) = all_windows.get(&label) { + if win.is_minimized().unwrap_or(false) { + let _ = win.unminimize(); + } + let _ = win.set_focus(); + // Same reasoning as the open-file-in-tab route below: the window + // is about to show a DIFFERENT document, so readiness has to drop + // or an immediate `insert` lands in the tab that is on screen now. + // This route could not clear it before, because Rust cannot tell + // "switch to a background tab" from "re-activate the tab already + // showing" — and clearing in the second case left the CLI waiting + // for a mark no remount would ever produce. The frontend now + // asserts readiness on both, so clearing here is safe. + app.state::().remove(&label); + let _ = app.emit_to(label.as_str(), "activate-tab", &path); + return OpenOutcome { + window_id: label, + was_already_open: true, + }; + } + // Window is gone but the entry lingers — fall through and re-open. + tabs.remove(&label); + } let state = app.state::(); let mut files = state.0.lock().unwrap(); - let all_windows = app.webview_windows(); - // If this file is already open, just focus that window + // Same check against the per-window assignment. Still needed: a window + // gets its path from `set_window_path` (save-as, drag-drop) before the + // frontend's tab push lands, so this is the authority in that gap. if let Some(label) = files.iter().find(|(_, p)| p.as_str() == path).map(|(l, _)| l.clone()) { if let Some(win) = all_windows.get(&label) { drop(files); @@ -960,6 +1125,9 @@ pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { let _ = win.unminimize(); } let _ = win.set_focus(); + // Readiness drops here too — see the tab-registry route above. + app.state::().remove(&label); + let _ = app.emit_to(label.as_str(), "activate-tab", &path); return OpenOutcome { window_id: label, was_already_open: true, @@ -968,33 +1136,76 @@ pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { // Window was closed but entry remains — remove stale entry and continue files.remove(&label); } + drop(files); - // Find any open window that hasn't been assigned a file yet - let empty_label = all_windows + // Not open anywhere: hand it to an existing window as a NEW TAB rather + // than spawning another window (that was the one-doc-per-window model). + // Prefer the focused window so the file lands where the user is looking. + // + // Both the focused lookup and the fallback walk a deterministic order: + // `webview_windows()` returns a HashMap, so iterating it directly picks an + // arbitrary window whenever no window reports focus (or, in principle, + // when more than one does). Labels are `w` in creation order, so + // sorting on that index makes the fallback "the most recently opened + // window" rather than whatever the hash happened to yield. + let mut labels: Vec = all_windows.keys().cloned().collect(); + labels.sort_by_key(|label| { + label + .trim_start_matches('w') + .parse::() + .unwrap_or(u32::MAX) + }); + let target_label = labels .iter() - .find(|(label, _)| !files.contains_key(label.as_str())) - .map(|(label, _)| label.clone()); - - if let Some(label) = empty_label { - files.insert(label.clone(), path.clone()); - drop(files); - // Unmark ready: the empty window was ready, but now we're loading new - // content into it; the AI shouldn't see "ready" until the new content - // is rendered. benchmark_mark_ready fires again post-mount. + .find(|label| { + all_windows + .get(*label) + .map(|w| w.is_focused().unwrap_or(false)) + .unwrap_or(false) + }) + .cloned() + .or_else(|| labels.last().cloned()); + + if let Some(label) = target_label { + // Record the window's assigned document BEFORE emitting. + // + // The event alone is not enough on a cold start. A Finder open of a + // `.md` launches the app, `setup` creates an empty window, and + // RunEvent::Opened lands here while the webview is still booting — so + // the emit can arrive before anything is listening. The frontend's + // first act is to ask `get_my_path` for the document this window is + // meant to show, and with no entry here that returns null and the + // window blanks itself. Writing it first means the answer is waiting + // whether or not the event was heard, which is how the one-document + // build has always worked. + app.state::() + .0 + .lock() + .unwrap() + .insert(label.clone(), path.clone()); + + // The window is about to display a DIFFERENT document, so it is no + // longer ready in the sense the CLI cares about. `benchmark_mark_ready` + // fires on every editor mount, not just the first, and routing a file + // into a tab remounts the editor — so clearing here is both correct + // and self-healing. Without it, `domd-cli open` followed immediately + // by `insert` races: readiness is still true from the PREVIOUS + // document and the insert lands in the wrong one. app.state::().remove(&label); - if let Some(win) = app.webview_windows().get(&label) { - let _ = win.set_title(&filename); + let _ = app.emit_to(label.as_str(), "open-file-in-tab", &path); + if let Some(win) = all_windows.get(&label) { + if win.is_minimized().unwrap_or(false) { + let _ = win.unminimize(); + } let _ = win.set_focus(); - let _ = win.emit_to(label.as_str(), "open-file", &path); } return OpenOutcome { window_id: label, was_already_open: false, }; } - drop(files); - // All windows already have files — open a new one + // No windows at all — create one for this file. let label = open_file_window(app, path); OpenOutcome { window_id: label, @@ -1146,15 +1357,9 @@ fn terminate_now(app: &AppHandle) { /// These mirror the per-window CloseRequested gate exactly. #[cfg(target_os = "macos")] fn dirty_untitled_labels(app: &AppHandle) -> Vec { - let files = app.state::(); - let files = files.0.lock().unwrap(); - let contents = app.state::(); app.webview_windows() .into_keys() - .filter(|label| { - !files.contains_key(label) - && contents.get(label).map_or(false, |c| c.is_dirty) - }) + .filter(|label| unsaved_untitled_content(app, label).is_some()) .collect() } @@ -1171,23 +1376,14 @@ fn review_queue_then_quit(app: AppHandle, mut queue: Vec) { return; }; // Skip windows that vanished or were saved/cleaned since the snapshot. - let still_pending = { - let files = app.state::(); - let untitled = !files.0.lock().unwrap().contains_key(&label); - let dirty = app - .state::() - .get(&label) - .map_or(false, |c| c.is_dirty); - untitled && dirty && app.get_webview_window(&label).is_some() - }; - if !still_pending { + // Same gate as CloseRequested, so a window that holds unsaved work in + // a BACKGROUND tab is reviewed here too rather than quietly quitting. + if app.get_webview_window(&label).is_none() { continue; } - let content = app - .state::() - .get(&label) - .map(|c| c.content) - .unwrap_or_default(); + let Some(content) = unsaved_untitled_content(&app, &label) else { + continue; + }; let _ = app.get_webview_window(&label).map(|w| w.set_focus()); let app_for_then = app.clone(); start_native_close_flow_with( @@ -1240,7 +1436,8 @@ pub fn run() { .manage(WindowFiles(Mutex::new(HashMap::new()))) .manage(WindowReady(Mutex::new(HashMap::new()))) .manage(WindowSelections(Mutex::new(HashMap::new()))) - .manage(WindowContents(Mutex::new(HashMap::new()))); + .manage(WindowContents(Mutex::new(HashMap::new()))) + .manage(WindowTabs(Mutex::new(HashMap::new()))); #[cfg(target_os = "macos")] let builder = builder.manage(WindowDocs(Mutex::new(HashMap::new()))); let app = builder @@ -1255,6 +1452,8 @@ pub fn run() { read_file_bytes, update_selection, update_content, + update_tabs, + clear_window_path, get_system_locale, set_locale, set_collab_state, @@ -1279,25 +1478,16 @@ pub fn run() { tauri::WindowEvent::CloseRequested { api, .. } => { let label = window.label().to_string(); let app = window.app_handle(); - let has_path = - app.state::().0.lock().unwrap().contains_key(&label); - if has_path { - // Saved file — let it close. - return; - } - // Pathless. Check dirty + content from the FE-pushed - // snapshot. - let content_state = app.state::().get(&label); - let is_dirty = content_state.as_ref().map_or(false, |c| c.is_dirty); - if !is_dirty { - // Blank or untouched — let it close. + // Asks about EVERY document in the window, not just the + // one on screen: with tabs, the unsaved work that must + // not be discarded is often in a tab you cannot see. + let Some(content) = unsaved_untitled_content(app, &label) + else { + // Saved, blank or untouched — let it close. return; - } + }; #[cfg(target_os = "macos")] { - let content = content_state - .map(|c| c.content) - .unwrap_or_default(); api.prevent_close(); // Trigger the native NSDocument flow on the main // thread. Errors are silent — worst case the window @@ -1316,6 +1506,7 @@ pub fn run() { app.state::().remove(&label); app.state::().remove(&label); app.state::().remove(&label); + app.state::().remove(&label); #[cfg(target_os = "macos")] { app.state::().0.lock().unwrap().remove(&label); @@ -1358,13 +1549,26 @@ pub fn run() { *MENU_LOCALE.lock().unwrap() = Some(startup_locale); app.on_menu_event(|app, event| { - if event.id() == "new-window" { + // Cmd+N / Cmd+W act on TABS when a window is focused, and fall + // back to window granularity when none is (e.g. the app is + // active with every window closed). The frontend decides what + // closing the last tab means — see the domd-close-tab handler. + if event.id() == "new-tab" { + // No focused window means no tab strip to add to. + if let Some(win) = app.webview_windows().values().find(|w| { + w.is_focused().unwrap_or(false) + }) { + let _ = win.emit_to(win.label(), "menu-new-tab", ()); + } else { + new_empty_window(app); + } + } else if event.id() == "new-window" { new_empty_window(app); } else if event.id() == "close-window" { if let Some(win) = app.webview_windows().values().find(|w| { w.is_focused().unwrap_or(false) }) { - let _ = win.close(); + let _ = win.emit_to(win.label(), "menu-close-tab", ()); } } else if event.id() == "undo" || event.id() == "redo"