From b790d7e1d684fb4247ac335f67bd1a2e0ef5a8db Mon Sep 17 00:00:00 2001 From: Systemofdumb <251506469+Systemofdumb@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:38:09 +0100 Subject: [PATCH 1/8] feat(editor): multiple documents per window (tabs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tab strip to the desktop editor so one window can hold several documents. Web is unchanged. ## Approach Rather than a second editor shell, the tab store becomes the document source that `useDocumentLoaders` reads from. Its public API is unchanged — meta, content, version, view and one entry point per source — so `editor-app` is untouched apart from mounting the tab bar and passing `embedded` to `Editor`. That falls out of something the editor already does: `key={version}` remounts the kernel whenever a document is replaced. A tab switch is that same event with a different document arriving, so tabs inherit the existing editor tree whole — collaboration, disk reconciliation, the outline panel, find & replace, AI, the titlebar bridge — with no parallel copy to keep in step. Web is simply the case where nothing ever opens a second tab. ## Per-document subsystems Only the active tab has a mounted editor, so: - Collaboration binds to the active tab's docId, and a tab switch runs the same teardown as loading a different file into the window. The room record survives in collab.db and resumes when its tab returns. - Disk reconciliation stays with the active editor. For background tabs the watcher records staleness only; on activation a clean tab re-reads from disk and a dirty one gets a forced reconcile, so an external edit merges rather than overwrites. - Documents load through the existing frontmatter/domd-id pipeline, so tabs keep document identity and the block never reaches the editor. ## Not losing work - The outgoing editor flushes a save on tab switch — autosave is debounced 600ms and dies with the unmount. - Closing a dirty, never-saved tab asks first. - The window's assigned path follows the active tab (new `clear_window_path` command), so the native "save changes?" sheet still speaks for whatever is actually on screen. Window close is deliberately left to the existing Rust handler; registering a JS `onCloseRequested` listener moves window destruction into JavaScript and breaks the close button. ## Rust `WindowTabs` tracks each window's open paths. `open_or_reuse` routes a file into a tab of the focused window and activates an existing tab when the file is already open. Cmd+N / Cmd+W act on tabs. The file watcher polls every open tab rather than only each window's last-assigned document. ## Notes Frontend and Rust build clean; eslint output is identical to v0.8.1 with zero added or removed issues. Verified on macOS: two files opening as tabs in one window, re-opening activating the existing tab, and a background tab picking up an external edit on activation. --- common/i18n/locales/en.json | 9 + common/i18n/locales/ja.json | 9 + common/i18n/locales/zh.json | 9 + features/collaboration/lib/collab-db-tauri.ts | 7 +- features/editor/components/editor-app.tsx | 42 ++- features/editor/components/editor.tsx | 43 ++- features/editor/components/tab-bar.tsx | 180 +++++++++++ .../editor/components/tab-editor-bridge.tsx | 86 +++++ features/editor/hooks/use-document-loaders.ts | 303 +++++++++++------- features/editor/hooks/use-tab-drag-reorder.ts | 78 +++++ features/editor/hooks/use-tab-shortcuts.ts | 56 ++++ features/editor/hooks/use-tabs.ts | 270 ++++++++++++++++ features/editor/lib/types.ts | 31 ++ features/editor/stores/tab-store.ts | 221 +++++++++++++ src-tauri/src/file_watch.rs | 52 +-- src-tauri/src/lib.rs | 172 ++++++++-- 16 files changed, 1394 insertions(+), 174 deletions(-) create mode 100644 features/editor/components/tab-bar.tsx create mode 100644 features/editor/components/tab-editor-bridge.tsx create mode 100644 features/editor/hooks/use-tab-drag-reorder.ts create mode 100644 features/editor/hooks/use-tab-shortcuts.ts create mode 100644 features/editor/hooks/use-tabs.ts create mode 100644 features/editor/stores/tab-store.ts diff --git a/common/i18n/locales/en.json b/common/i18n/locales/en.json index 30b685a..1baa679 100644 --- a/common/i18n/locales/en.json +++ b/common/i18n/locales/en.json @@ -404,5 +404,14 @@ "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?" } } diff --git a/common/i18n/locales/ja.json b/common/i18n/locales/ja.json index df3b7d2..54ad441 100644 --- a/common/i18n/locales/ja.json +++ b/common/i18n/locales/ja.json @@ -404,5 +404,14 @@ "locate": "編集対象のテキストを特定できませんでした——言い換えてみてください" }, "localSelf": "自分" + }, + "tabs": { + "newTab": "新しいタブ", + "closeTab": "タブを閉じる", + "shrinkToFit": "幅に合わせる", + "scrollHorizontally": "横スクロール", + "untitled": "Untitled.md", + "unsavedTitle": "未保存の変更", + "unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?" } } diff --git a/common/i18n/locales/zh.json b/common/i18n/locales/zh.json index 9811fcd..3e87023 100644 --- a/common/i18n/locales/zh.json +++ b/common/i18n/locales/zh.json @@ -404,5 +404,14 @@ "locate": "未能定位要修改的文本——换个说法试试" }, "localSelf": "我" + }, + "tabs": { + "newTab": "新建标签页", + "closeTab": "关闭标签页", + "shrinkToFit": "自适应宽度", + "scrollHorizontally": "横向滚动", + "untitled": "Untitled.md", + "unsavedTitle": "未保存的更改", + "unsavedBody": "关闭前保存对“{{name}}”的更改吗?" } } 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..ef03056 100644 --- a/features/editor/components/editor-app.tsx +++ b/features/editor/components/editor-app.tsx @@ -80,6 +80,10 @@ 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 { TabEditorBridge } from "./tab-editor-bridge"; import { useTauriDragDrop } from "../hooks/use-tauri-drag-drop"; import { useTauriEvent } from "../hooks/use-tauri-event"; import { useWebDragDrop } from "../hooks/use-web-drag-drop"; @@ -257,9 +261,16 @@ export function EditorApp() { // bridge, the drawer overlay — reaches the same store without threading // callbacks through props (claude-os nav-drawer pattern). return ( - - - + // TabStoreProvider holds the window's open documents and is the + // document source for BOTH runtimes (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. + + + + + ); } @@ -616,6 +627,18 @@ function EditorAppContent() { const isWeb = !useIsTauri(); const dragging = tauriDragging || webDragging; + // 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 } = useTabs({ + enabled: !isWeb, + saveRef, + // 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 || content === 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 @@ -667,10 +690,16 @@ function EditorAppContent() { return (
+ {isWeb ? null : } {dragging ? (
@@ -692,6 +721,10 @@ function EditorAppContent() { mode="rich" > + {/* Writes the live document back to its tab when this editor + unmounts (a tab switch), and restores its scroll position + on the way in. Inert with a single tab. */} + {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 +779,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 diff --git a/features/editor/components/editor.tsx b/features/editor/components/editor.tsx index c673079..568ac06 100644 --- a/features/editor/components/editor.tsx +++ b/features/editor/components/editor.tsx @@ -155,6 +155,9 @@ export function Editor({ aiAvailable = false, aiActive = false, sidePanel = null, + tabId, + embedded = false, + onDirtyChange, }: { meta: FileMeta; onMetaUpdate: (meta: FileMeta) => 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..82f9714 --- /dev/null +++ b/features/editor/components/tab-bar.tsx @@ -0,0 +1,180 @@ +"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")); + }; + + 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-editor-bridge.tsx b/features/editor/components/tab-editor-bridge.tsx new file mode 100644 index 0000000..13c6669 --- /dev/null +++ b/features/editor/components/tab-editor-bridge.tsx @@ -0,0 +1,86 @@ +"use client"; +import { useEffect, useRef } from "react"; +import { toMarkdown, useRenderData } from "@do-md/core-react"; +import { useLatest } from "@/common/lib/use-latest"; +import { saveDocument } from "../lib/save-document"; +import { useTabStoreApi } from "../stores/tab-store"; + +/** + * Lives inside the editor's provider. Only the ACTIVE tab has a mounted + * editor, so this is where a tab's live state is written back before it goes + * dormant: on unmount (a tab switch or close) it serializes the document and + * scroll position into the TabStore, and flushes a save to disk. + * + * The flush matters because auto-save is debounced 600ms and dies with the + * unmount — switching tabs right after typing would otherwise drop the last + * edits from the file. They would survive in the store, but a tab that looks + * saved and is not on disk is exactly the bug tabs must not introduce. Only + * path-backed documents are flushed: a pathless one would pop a save dialog, + * unacceptable as a side effect of switching tabs. + * + * Identity comes from the store rather than props: this component is bound to + * whichever document its provider was mounted for, and that is precisely the + * (tab, docEpoch) pair the store held at mount. + */ +export function TabEditorBridge() { + const renderData = useRenderData(); + const store = useTabStoreApi(); + const renderDataRef = useLatest(renderData); + + // Captured once: the document this editor instance belongs to. The + // provider is keyed on both, so a change to either remounts us. + const boundRef = useRef({ + tabId: store.state.activeTabId, + docEpoch: store.docEpochOf(store.state.activeTabId), + }); + + // Restore scroll position on mount. + useEffect(() => { + const { tabId } = boundRef.current; + const tab = store.state.tabs.find((t) => t.id === tabId); + if (!tab || tab.scrollTop <= 0) return; + const target = tab.scrollTop; + const raf = requestAnimationFrame(() => { + const scroller = document.querySelector( + "[data-tab-scroll-container]", + ); + if (scroller) scroller.scrollTop = target; + }); + return () => cancelAnimationFrame(raf); + }, [store]); + + // On unmount: serialize content + scroll back to the store, then flush. + // The refs are read in the cleanup ON PURPOSE — the point is to capture + // the document as it stands when the tab goes dormant, not as it stood + // when the effect was set up. + useEffect(() => { + const latestRenderData = renderDataRef; + const bound = boundRef.current; + return () => { + // Superseded by a disk re-read (or the tab is gone): our buffer is + // stale and must not be written back over what replaced it. + if (store.docEpochOf(bound.tabId) !== bound.docEpoch) return; + + const md = toMarkdown(latestRenderData.current) ?? ""; + const scroller = document.querySelector( + "[data-tab-scroll-container]", + ); + store.updateTabContent( + bound.tabId, + md, + scroller?.scrollTop ?? 0, + ); + + const tab = store.state.tabs.find((t) => t.id === bound.tabId); + const meta = tab?.meta; + if (!meta || meta.kind !== "tauri" || !meta.path) return; + // saveDocument dedupes byte-identical writes against the + // known-disk-content registry, so a clean switch costs nothing. + void saveDocument(meta, md).then((result) => { + if (result.ok) store.markDirty(bound.tabId, false); + }); + }; + }, [store, renderDataRef]); + + return null; +} diff --git a/features/editor/hooks/use-document-loaders.ts b/features/editor/hooks/use-document-loaders.ts index 6a2af25..cdcdc61 100644 --- a/features/editor/hooks/use-document-loaders.ts +++ b/features/editor/hooks/use-document-loaders.ts @@ -1,22 +1,54 @@ "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. The editor tree + * upstream builds around `key={version}` already remounts the kernel when a + * document is replaced; a tab switch is the same event with a different + * document arriving, so tabs get upstream's editor — collaboration, disk + * reconciliation, AI, the outline panel — without a parallel copy of it. + */ +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; + const content = active?.content ?? null; + /** Editor identity: changes when a different tab becomes active AND when + * the active tab's document is replaced in place (a disk re-read). Both + * must remount the kernel, since initMd is only read on mount. */ + const version = active ? `${active.id}:${active.docEpoch}` : "loading"; + const view: View = active ? "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 +58,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,63 +108,28 @@ 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 { @@ -184,3 +146,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..03f9bdd --- /dev/null +++ b/features/editor/hooks/use-tab-shortcuts.ts @@ -0,0 +1,56 @@ +"use client"; +import { useEffect } from "react"; +import { useTabStoreApi } from "../stores/tab-store"; + +/** + * Tab navigation shortcuts. + * + * Cmd+N and Cmd+W are deliberately absent: both are native menu accelerators + * (File ▸ New Window / Close Window, repointed at tabs), so Rust already + * emits `menu-new-tab` / `menu-close-tab` for them. Handling them here too + * would open or close two tabs per keypress wherever the key event also + * reaches the webview. Only shortcuts with no menu counterpart live here. + */ +export function useTabShortcuts() { + const store = useTabStoreApi(); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (!e.metaKey) return; + + const { tabs, activeTabId } = store.state; + const activeIndex = tabs.findIndex((t) => t.id === activeTabId); + + // Cmd+Shift+] — next tab + if (e.key === "]" && e.shiftKey) { + e.preventDefault(); + const nextIndex = (activeIndex + 1) % tabs.length; + store.activateTab(tabs[nextIndex].id); + return; + } + + // Cmd+Shift+[ — previous tab + if (e.key === "[" && e.shiftKey) { + e.preventDefault(); + const prevIndex = + (activeIndex - 1 + tabs.length) % tabs.length; + store.activateTab(tabs[prevIndex].id); + return; + } + + // Cmd+1 through Cmd+9 — jump to tab by position + const num = parseInt(e.key); + if (num >= 1 && num <= 9) { + e.preventDefault(); + const targetIndex = num === 9 ? tabs.length - 1 : num - 1; + if (targetIndex < tabs.length) { + store.activateTab(tabs[targetIndex].id); + } + return; + } + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [store]); +} diff --git a/features/editor/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts new file mode 100644 index 0000000..94c7709 --- /dev/null +++ b/features/editor/hooks/use-tabs.ts @@ -0,0 +1,270 @@ +"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 } from "react"; +import { useTranslation } from "react-i18next"; +import { tauriCore, tauriDialog } from "@/common/lib/tauri"; +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"; + +export function useTabs({ + enabled, + saveRef, + onDocumentSwitch, +}: { + /** Desktop only. Called unconditionally (hooks rule) and inert on web, + * where a window is one page holding one document. */ + enabled: boolean; + saveRef: React.MutableRefObject<(() => Promise) | null>; + /** 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 { t } = useTranslation(); + 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 saveRefLatest = useLatest(saveRef); + const onDocumentSwitchRef = useLatest(onDocumentSwitch); + + useTabShortcuts(); + + /** 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 ────────────────────────────────────── + /** 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); + return; + } + store.addTab(doc.meta, doc.content); + }; + + useTauriEvent("open-file-in-tab", (path) => { + void openPathInTab(path); + }); + + useTauriEvent("activate-tab", (path) => { + const tab = store.findTabByPath(path); + if (tab) store.activateTab(tab.id); + }); + + 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 }, + }), + ); + }); + + /** 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, and the outgoing editor flushes again on + * unmount), 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 { ask } = await tauriDialog(); + const shouldSave = await ask( + t("tabs.unsavedBody", { name: tab.meta.name }), + { title: t("tabs.unsavedTitle"), kind: "warning" }, + ); + if (!shouldSave) return true; // discard + + // The active tab's editor holds the live document; a background + // tab's snapshot in the store is authoritative (its editor wrote + // it back on the way out). Either way saveDocument opens the + // picker, and a cancelled picker cancels the close. + if ( + tabId === store.state.activeTabId && + saveRefLatest.current.current + ) { + return await saveRefLatest.current.current(); + } + const result = await saveDocument(tab.meta, tab.content); + return result.ok; + }, + [store, t, saveRefLatest], + ); + + // ── 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; + void (async () => { + if (!(await resolveUnsaved(tabId))) return; + if (store.closeTab(tabId) !== "last-tab") return; + // Closing the only tab closes the window. Everything unsaved + // has just been resolved, so skip the native sheet. + const { invoke } = await tauriCore(); + await invoke("force_close_window").catch(() => {}); + })(); + }; + + 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, and keying on the tab id + // (not the doc id) means switching between two never-saved documents + // still detaches. + useEffect( + () => + store.subscribe((next, prev) => { + if (next.activeTabId === prev.activeTabId) return; + 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 paths = tabs + .map((tab) => (tab.meta.kind === "tauri" ? tab.meta.path : null)) + .filter((path): path is string => path !== null); + tauriCore().then(({ invoke }) => { + invoke("update_tabs", { paths }).catch(() => {}); + }); + }, [tabs, enabled]); + + // ── 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 }; +} diff --git a/features/editor/lib/types.ts b/features/editor/lib/types.ts index b082bad..dc99700 100644 --- a/features/editor/lib/types.ts +++ b/features/editor/lib/types.ts @@ -20,3 +20,34 @@ export type FileMeta = }; export type View = "loading" | "editor"; + +export interface Tab { + id: string; + meta: FileMeta; + /** Body markdown WITHOUT the frontmatter block (that lives in `meta`). + * Authoritative only while the tab is in the background — the mounted + * tab's editor is the source of truth, and TabEditorBridge writes back + * here on unmount. */ + content: string; + scrollTop: number; + isDirty: boolean; + /** Set when the file watcher reported an external write while this tab + * was NOT mounted. Consumed on activation: a clean tab re-reads from + * disk wholesale, a dirty tab gets a forced reconcile pass once its + * editor mounts (see TabEditorSwitch). */ + diskStale: boolean; + /** Bumped to force the mounted DiskReconciler to run a pass — reuses the + * same trigger mechanism as the collab attach. */ + reconcileEpoch: number; + /** Bumped whenever `content` is replaced out-of-band (a disk re-read). + * Part of the editor's mount key, so the new content actually reaches + * the kernel via initMd; also invalidates the outgoing editor's + * write-back so it cannot clobber what we just adopted. */ + docEpoch: 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..081ca52 --- /dev/null +++ b/features/editor/stores/tab-store.ts @@ -0,0 +1,221 @@ +import { nanoid } from "@do-md/utils"; +import { ZenithStore, createReactStore } from "@do-md/zenith"; +import type { FileMeta, Tab, TabStoreState } from "../lib/types"; + +const makeTab = (meta: FileMeta, content: string): Tab => ({ + id: nanoid(), + meta, + content, + scrollTop: 0, + isDirty: false, + diskStale: false, + reconcileEpoch: 0, + docEpoch: 0, +}); + +/** + * The open documents of one window. + * + * This is the document source for BOTH runtimes, 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 — + * features added there work under tabs without being mirrored. + * + * 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 { + constructor() { + super({ tabs: [], activeTabId: "", displayMode: "shrink" }); + } + + get activeTab(): Tab | undefined { + return this.state.tabs.find((t) => t.id === this.state.activeTabId); + } + + /** Load a document into the ACTIVE tab, creating the first tab if the + * window has none yet. This is the single-document path — what every + * useDocumentLoaders entry point does, and what the web shell only ever + * does. Opening a document in a NEW tab is `addTab`. */ + openInActiveTab(meta: FileMeta, content: string): string { + const current = this.activeTab; + if (!current) { + const tab = makeTab(meta, content); + this.produce((draft) => { + draft.tabs.push(tab); + draft.activeTabId = tab.id; + }); + return tab.id; + } + 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 tab = makeTab(meta, content); + this.produce((draft) => { + draft.tabs.push(tab); + draft.activeTabId = tab.id; + }); + return tab.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; + } + }); + 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; + }); + } + + updateTabContent(tabId: string, content: string, scrollTop: number) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) { + tab.content = content; + tab.scrollTop = scrollTop; + } + }); + } + + /** Replace a tab's document wholesale after an external edit (clean tabs + * only — see rereadTauriPathDoc). Bumping docEpoch re-keys the editor so + * the new content is actually loaded, and invalidates the outgoing + * editor's write-back so it cannot restore what we just replaced. */ + replaceTabDoc(tabId: string, meta: FileMeta, content: string) { + this.produce((draft) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (!tab) return; + tab.meta = meta; + tab.content = content; + tab.scrollTop = 0; + tab.isDirty = false; + tab.diskStale = false; + tab.docEpoch += 1; + }); + } + + /** The tab's current docEpoch, or null when the tab is gone. Used by the + * unmounting editor to check that its write-back is still valid. */ + docEpochOf(tabId: string): number | null { + return this.state.tabs.find((t) => t.id === tabId)?.docEpoch ?? null; + } + + 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, + ); + } + + 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.content === "" && + !tab.isDirty + ); + } +} + +export const { + StoreProvider: TabStoreProvider, + useStore: useTabStore, + useStoreApi: useTabStoreApi, +} = createReactStore(TabStore); 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..97b0a03 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -437,6 +437,52 @@ 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. +pub struct WindowTabs(pub Mutex>>); + +impl WindowTabs { + pub fn set(&self, label: &str, paths: Vec) { + self.0.lock().unwrap().insert(label.to_string(), paths); + } + + 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(|(_, paths)| paths.iter().any(|p| p == 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, paths)| { + paths.iter().map(move |p| (label.clone(), p.clone())) + }) + .collect() + } +} + /// 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 +839,24 @@ fn update_content( state.set(window.label(), content, is_dirty); } +/// FE pushes the paths of all open tabs whenever the tab set changes. +#[tauri::command] +fn update_tabs(window: Window, state: State, paths: Vec) { + state.set(window.label(), paths); +} + +/// 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 +1006,32 @@ 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(); + 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 +1039,7 @@ pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { let _ = win.unminimize(); } let _ = win.set_focus(); + let _ = app.emit_to(label.as_str(), "activate-tab", &path); return OpenOutcome { window_id: label, was_already_open: true, @@ -968,33 +1048,59 @@ 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 { + // 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, @@ -1240,7 +1346,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 +1362,8 @@ pub fn run() { read_file_bytes, update_selection, update_content, + update_tabs, + clear_window_path, get_system_locale, set_locale, set_collab_state, @@ -1316,6 +1425,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 +1468,23 @@ pub fn run() { *MENU_LOCALE.lock().unwrap() = Some(startup_locale); app.on_menu_event(|app, event| { + // 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-window" { - new_empty_window(app); + 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() == "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" From cc114024a0c411a8adf8b7ea384914d0e71fcb75 Mon Sep 17 00:00:00 2001 From: Systemofdumb <251506469+Systemofdumb@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:22:25 +0100 Subject: [PATCH 2/8] feat(editor): one long-lived EditorStore per tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each tab now owns a document runtime for as long as it is open. It is created when the tab opens and dropped when the tab closes, never on a switch; switching remounts the VIEW over another tab's existing store via the kernel's bring-your-own-store seam. The registry is a plain Map on TabStore rather than part of its state: immer freezes state, and a frozen EditorStore is an inert one. Nothing renders from it directly — components read the runtime for the active tab id — so it does not need to be reactive. This deletes more than it adds. Gone: tab-editor-bridge entirely, along with serialize-to-markdown on unmount, the docEpoch generation counter that guarded write-back against a stale buffer, the flush-save on switch, and the manual scroll save/restore. `Tab` loses `content`, `scrollTop` and `docEpoch` — there is no snapshot to go stale, so nothing needs a counter to guard it. Undo history, selection, focus and scroll survive a switch because they live in the store and the kernel restores the last three on attach. The mount key is now the tab id alone. Replacing the document inside a tab resets the runtime in place instead of remounting, because remounting would discard the very view state the kernel just preserved. resolveUnsaved collapses to one path: 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 — which removes the saveRef threading through useTabs. Construction options move to a single createRuntime factory in EditorApp, since DOMDProvider ignores them once handed a store. --- features/editor/components/editor-app.tsx | 61 +++++--- .../editor/components/tab-editor-bridge.tsx | 86 ----------- features/editor/hooks/use-document-loaders.ts | 34 +++-- features/editor/hooks/use-tabs.ts | 26 ++-- features/editor/lib/types.ts | 35 +++-- features/editor/stores/tab-store.ts | 144 ++++++++++-------- 6 files changed, 171 insertions(+), 215 deletions(-) delete mode 100644 features/editor/components/tab-editor-bridge.tsx diff --git a/features/editor/components/editor-app.tsx b/features/editor/components/editor-app.tsx index ef03056..04c1dc7 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, @@ -83,7 +84,6 @@ 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 { TabEditorBridge } from "./tab-editor-bridge"; import { useTauriDragDrop } from "../hooks/use-tauri-drag-drop"; import { useTauriEvent } from "../hooks/use-tauri-event"; import { useWebDragDrop } from "../hooks/use-web-drag-drop"; @@ -260,13 +260,34 @@ 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 is the - // document source for BOTH runtimes (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. - + // 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. + @@ -284,7 +305,7 @@ function EditorAppContent() { const { meta, setMeta, - content, + runtime, version, view, applyBlank, @@ -633,13 +654,12 @@ function EditorAppContent() { // the kernel as an ordinary document swap (`key={version}`). const { markActiveTabDirty } = useTabs({ enabled: !isWeb, - saveRef, // 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 || content === null) { + 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 @@ -708,23 +728,20 @@ 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. */} - {/* Writes the live document back to its tab when this editor - unmounts (a tab switch), and restores its scroll position - on the way in. Inert with a single tab. */} - {isWeb ? null : } {/* Hydrates the persisted display mode + binds Cmd+/ — the "more" menu entry (web) and this keystroke (both runtimes) call the same toggle. */} diff --git a/features/editor/components/tab-editor-bridge.tsx b/features/editor/components/tab-editor-bridge.tsx deleted file mode 100644 index 13c6669..0000000 --- a/features/editor/components/tab-editor-bridge.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; -import { useEffect, useRef } from "react"; -import { toMarkdown, useRenderData } from "@do-md/core-react"; -import { useLatest } from "@/common/lib/use-latest"; -import { saveDocument } from "../lib/save-document"; -import { useTabStoreApi } from "../stores/tab-store"; - -/** - * Lives inside the editor's provider. Only the ACTIVE tab has a mounted - * editor, so this is where a tab's live state is written back before it goes - * dormant: on unmount (a tab switch or close) it serializes the document and - * scroll position into the TabStore, and flushes a save to disk. - * - * The flush matters because auto-save is debounced 600ms and dies with the - * unmount — switching tabs right after typing would otherwise drop the last - * edits from the file. They would survive in the store, but a tab that looks - * saved and is not on disk is exactly the bug tabs must not introduce. Only - * path-backed documents are flushed: a pathless one would pop a save dialog, - * unacceptable as a side effect of switching tabs. - * - * Identity comes from the store rather than props: this component is bound to - * whichever document its provider was mounted for, and that is precisely the - * (tab, docEpoch) pair the store held at mount. - */ -export function TabEditorBridge() { - const renderData = useRenderData(); - const store = useTabStoreApi(); - const renderDataRef = useLatest(renderData); - - // Captured once: the document this editor instance belongs to. The - // provider is keyed on both, so a change to either remounts us. - const boundRef = useRef({ - tabId: store.state.activeTabId, - docEpoch: store.docEpochOf(store.state.activeTabId), - }); - - // Restore scroll position on mount. - useEffect(() => { - const { tabId } = boundRef.current; - const tab = store.state.tabs.find((t) => t.id === tabId); - if (!tab || tab.scrollTop <= 0) return; - const target = tab.scrollTop; - const raf = requestAnimationFrame(() => { - const scroller = document.querySelector( - "[data-tab-scroll-container]", - ); - if (scroller) scroller.scrollTop = target; - }); - return () => cancelAnimationFrame(raf); - }, [store]); - - // On unmount: serialize content + scroll back to the store, then flush. - // The refs are read in the cleanup ON PURPOSE — the point is to capture - // the document as it stands when the tab goes dormant, not as it stood - // when the effect was set up. - useEffect(() => { - const latestRenderData = renderDataRef; - const bound = boundRef.current; - return () => { - // Superseded by a disk re-read (or the tab is gone): our buffer is - // stale and must not be written back over what replaced it. - if (store.docEpochOf(bound.tabId) !== bound.docEpoch) return; - - const md = toMarkdown(latestRenderData.current) ?? ""; - const scroller = document.querySelector( - "[data-tab-scroll-container]", - ); - store.updateTabContent( - bound.tabId, - md, - scroller?.scrollTop ?? 0, - ); - - const tab = store.state.tabs.find((t) => t.id === bound.tabId); - const meta = tab?.meta; - if (!meta || meta.kind !== "tauri" || !meta.path) return; - // saveDocument dedupes byte-identical writes against the - // known-disk-content registry, so a clean switch costs nothing. - void saveDocument(meta, md).then((result) => { - if (result.ok) store.markDirty(bound.tabId, false); - }); - }; - }, [store, renderDataRef]); - - return null; -} diff --git a/features/editor/hooks/use-document-loaders.ts b/features/editor/hooks/use-document-loaders.ts index cdcdc61..bd4ca58 100644 --- a/features/editor/hooks/use-document-loaders.ts +++ b/features/editor/hooks/use-document-loaders.ts @@ -8,11 +8,13 @@ * 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. The editor tree - * upstream builds around `key={version}` already remounts the kernel when a - * document is replaced; a tab switch is the same event with a different - * document arriving, so tabs get upstream's editor — collaboration, disk - * reconciliation, AI, the outline panel — without a parallel copy of it. + * 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"; @@ -35,12 +37,20 @@ export function useDocumentLoaders() { const active = tabs.find((tab) => tab.id === activeTabId) ?? null; const meta = active?.meta ?? null; - const content = active?.content ?? null; - /** Editor identity: changes when a different tab becomes active AND when - * the active tab's document is replaced in place (a disk re-read). Both - * must remount the kernel, since initMd is only read on mount. */ - const version = active ? `${active.id}:${active.docEpoch}` : "loading"; - const view: View = active ? "editor" : "loading"; + /** 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) => { @@ -135,7 +145,7 @@ export function useDocumentLoaders() { return { meta, setMeta, - content, + runtime, version, view, applyBlank, diff --git a/features/editor/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts index 94c7709..eca96ce 100644 --- a/features/editor/hooks/use-tabs.ts +++ b/features/editor/hooks/use-tabs.ts @@ -25,13 +25,11 @@ import { useTabStore, useTabStoreApi } from "../stores/tab-store"; export function useTabs({ enabled, - saveRef, onDocumentSwitch, }: { /** Desktop only. Called unconditionally (hooks rule) and inert on web, * where a window is one page holding one document. */ enabled: boolean; - saveRef: React.MutableRefObject<(() => Promise) | null>; /** 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, @@ -43,7 +41,6 @@ export function useTabs({ const tabs = useTabStore((s) => s.state.tabs); const activeTabId = useTabStore((s) => s.state.activeTabId); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; - const saveRefLatest = useLatest(saveRef); const onDocumentSwitchRef = useLatest(onDocumentSwitch); useTabShortcuts(); @@ -97,8 +94,7 @@ export function useTabs({ * 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, and the outgoing editor flushes again on - * unmount), so closing it silently is correct. */ + * 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); @@ -112,20 +108,16 @@ export function useTabs({ ); if (!shouldSave) return true; // discard - // The active tab's editor holds the live document; a background - // tab's snapshot in the store is authoritative (its editor wrote - // it back on the way out). Either way saveDocument opens the - // picker, and a cancelled picker cancels the close. - if ( - tabId === store.state.activeTabId && - saveRefLatest.current.current - ) { - return await saveRefLatest.current.current(); - } - const result = await saveDocument(tab.meta, tab.content); + // 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, t, saveRefLatest], + [store, t], ); // ── New / close, driven by the tab bar, shortcuts and native menu ──── diff --git a/features/editor/lib/types.ts b/features/editor/lib/types.ts index dc99700..830a0bb 100644 --- a/features/editor/lib/types.ts +++ b/features/editor/lib/types.ts @@ -21,29 +21,36 @@ 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; - /** Body markdown WITHOUT the frontmatter block (that lives in `meta`). - * Authoritative only while the tab is in the background — the mounted - * tab's editor is the source of truth, and TabEditorBridge writes back - * here on unmount. */ - content: string; - scrollTop: number; + /** 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 mounted. Consumed on activation: a clean tab re-reads from - * disk wholesale, a dirty tab gets a forced reconcile pass once its - * editor mounts (see TabEditorSwitch). */ + * 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; - /** Bumped whenever `content` is replaced out-of-band (a disk re-read). - * Part of the editor's mount key, so the new content actually reaches - * the kernel via initMd; also invalidates the outgoing editor's - * write-back so it cannot clobber what we just adopted. */ - docEpoch: number; } export interface TabStoreState { diff --git a/features/editor/stores/tab-store.ts b/features/editor/stores/tab-store.ts index 081ca52..709bfe3 100644 --- a/features/editor/stores/tab-store.ts +++ b/features/editor/stores/tab-store.ts @@ -1,54 +1,73 @@ +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"; -const makeTab = (meta: FileMeta, content: string): Tab => ({ - id: nanoid(), - meta, - content, - scrollTop: 0, - isDirty: false, - diskStale: false, - reconcileEpoch: 0, - docEpoch: 0, -}); +/** 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. + * The open documents of one window, and their live runtimes. * - * This is the document source for BOTH runtimes, 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 — - * features added there work under tabs without being mirrored. + * 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 { - constructor() { + 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 yet. This is the single-document path — what every - * useDocumentLoaders entry point does, and what the web shell only ever - * does. Opening a document in a NEW tab is `addTab`. */ + * 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) { - const tab = makeTab(meta, content); - this.produce((draft) => { - draft.tabs.push(tab); - draft.activeTabId = tab.id; - }); - return tab.id; - } + if (!current) return this.addTab(meta, content); this.replaceTabDoc(current.id, meta, content); return current.id; } @@ -56,7 +75,7 @@ export class TabStore extends ZenithStore { /** 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 + // 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, @@ -67,19 +86,24 @@ export class TabStore extends ZenithStore { } } - const tab = makeTab(meta, content); + const id = nanoid(); + this.runtimes.set(id, this.createRuntime(content)); this.produce((draft) => { - draft.tabs.push(tab); - draft.activeTabId = tab.id; + draft.tabs.push({ + id, + meta, + isDirty: false, + diskStale: false, + reconcileEpoch: 0, + }); + draft.activeTabId = id; }); - return tab.id; + return id; } closeTab(tabId: string): "closed" | "last-tab" { const { tabs } = this.state; - if (tabs.length <= 1) { - return "last-tab"; - } + if (tabs.length <= 1) return "last-tab"; const index = tabs.findIndex((t) => t.id === tabId); if (index === -1) return "closed"; @@ -87,11 +111,16 @@ export class TabStore extends ZenithStore { this.produce((draft) => { draft.tabs.splice(index, 1); if (draft.activeTabId === tabId) { - // Activate neighbor: prefer right, fallback left + // 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"; } @@ -117,39 +146,23 @@ export class TabStore extends ZenithStore { }); } - updateTabContent(tabId: string, content: string, scrollTop: number) { - this.produce((draft) => { - const tab = draft.tabs.find((t) => t.id === tabId); - if (tab) { - tab.content = content; - tab.scrollTop = scrollTop; - } - }); - } - - /** Replace a tab's document wholesale after an external edit (clean tabs - * only — see rereadTauriPathDoc). Bumping docEpoch re-keys the editor so - * the new content is actually loaded, and invalidates the outgoing - * editor's write-back so it cannot restore what we just replaced. */ + /** 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.content = content; - tab.scrollTop = 0; tab.isDirty = false; tab.diskStale = false; - tab.docEpoch += 1; }); } - /** The tab's current docEpoch, or null when the tab is gone. Used by the - * unmounting editor to check that its write-back is still valid. */ - docEpochOf(tabId: string): number | null { - return this.state.tabs.find((t) => t.id === tabId)?.docEpoch ?? null; - } - markDirty(tabId: string, dirty: boolean) { this.produce((draft) => { const tab = draft.tabs.find((t) => t.id === tabId); @@ -158,8 +171,8 @@ export class TabStore extends ZenithStore { } /** 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. */ + * 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) { @@ -201,6 +214,9 @@ export class TabStore extends ZenithStore { ); } + /** 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; @@ -208,8 +224,8 @@ export class TabStore extends ZenithStore { return ( tab.meta.kind === "tauri" && tab.meta.path === null && - tab.content === "" && - !tab.isDirty + !tab.isDirty && + this.contentOf(tab.id) === "" ); } } From a0d1b87cd7aab1e68b7c5433205367ec71eb7fed Mon Sep 17 00:00:00 2001 From: Youness <251506469+Systemofdumb@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:46:56 +0100 Subject: [PATCH 3/8] fix(editor): restore real DOM focus after a tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘Z did nothing once you had switched tabs. The undo history was intact the whole time — store.undo() worked — the keystroke simply never reached the kernel, which binds undo/redo and the rest of its key handling to the editable root rather than to window. The first attempt used store.focus(). That records focus INTENT for the render layer to materialize, which is right for gestures mid-session but not across a view remount, which is what a tab switch is. Observed after a switch: document.activeElement WAS the editor root, store.focused_ was true, a synthetic keydown dispatched at that root undid correctly — and real keystrokes still did nothing until something called .focus() on the element. So focus the element, through the kernel's own DOM context rather than by querying for a kernel-internal attribute. Only on an actual switch: the empty -> first tab transition is not one, which keeps upstream's deliberate no-autofocus on first load and stops the initial load running a pointless collaboration teardown. Also adds scripts/verify-tab-runtime: a headless check that a hand-constructed EditorStore — the way the tab registry builds one — has a working undo stack. That is what ruled the registry out as the cause. --- features/editor/components/editor-app.tsx | 8 ++- features/editor/components/tab-focus.tsx | 42 ++++++++++++++++ features/editor/hooks/use-tabs.ts | 41 ++++++++++++--- scripts/verify-tab-runtime/run.mts | 61 +++++++++++++++++++++++ 4 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 features/editor/components/tab-focus.tsx create mode 100644 scripts/verify-tab-runtime/run.mts diff --git a/features/editor/components/editor-app.tsx b/features/editor/components/editor-app.tsx index 04c1dc7..d804b41 100644 --- a/features/editor/components/editor-app.tsx +++ b/features/editor/components/editor-app.tsx @@ -84,6 +84,7 @@ 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 { 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"; @@ -652,7 +653,7 @@ function EditorAppContent() { // 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 } = useTabs({ + const { markActiveTabDirty, switchedTabs } = 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). @@ -742,6 +743,11 @@ function EditorAppContent() { renderComponent={CustomRender} > + {/* 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. */} 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-tabs.ts b/features/editor/hooks/use-tabs.ts index eca96ce..7e6201f 100644 --- a/features/editor/hooks/use-tabs.ts +++ b/features/editor/hooks/use-tabs.ts @@ -9,8 +9,9 @@ * * Web never calls this — one page, one document, no tab bar. */ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { isTauri } from "@/common/lib/platform"; import { tauriCore, tauriDialog } from "@/common/lib/tauri"; import { useLatest } from "@/common/lib/use-latest"; import { @@ -64,9 +65,23 @@ export function useTabs({ const doc = await readTauriDoc(path); if (store.isOnlyBlankTab()) { store.replaceTabDoc(store.state.tabs[0].id, doc.meta, doc.content); - return; + } else { + store.addTab(doc.meta, doc.content); } - store.addTab(doc.meta, doc.content); + // Re-arm readiness for the CLI. + // + // `open_or_reuse` clears WindowReady before routing a file here, + // because the window is about to show a different document and an + // immediate `insert` must not land in the previous one. That relies on + // something re-marking it, and until now the editor remount did. + // + // Reusing the lone blank tab no longer remounts anything: the runtime + // is reset in place precisely so view state survives. So the tab layer + // has to say when the document is actually in place — it is the only + // party that knows, and it is true for both branches above. + if (!isTauri()) return; + const { invoke } = await tauriCore(); + await invoke("benchmark_mark_ready").catch(() => {}); }; useTauriEvent("open-file-in-tab", (path) => { @@ -151,13 +166,25 @@ export function useTabs({ // ── 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, and keying on the tab id - // (not the doc id) means switching between two never-saved documents - // still detaches. + // 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], @@ -258,5 +285,5 @@ export function useTabs({ // people actually take, and saved documents are never at risk because // autosave and the switch-time flush have already written them. - return { markActiveTabDirty }; + return { markActiveTabDirty, switchedTabs }; } 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); From 14c7c193ce735c9e27247688e202fadab44ec527 Mon Sep 17 00:00:00 2001 From: Systemofdumb <251506469+Systemofdumb@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:27:19 +0100 Subject: [PATCH 4/8] feat(editor): tab bar, per-tab close gating, shortcuts and menu entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab bar renders nothing at all with a single tab — no strip, no border, no reserved height, so a one-document window looks exactly as it did before tabs existed. Per-tab dirty/untitled state reported to Rust. `update_tabs` now carries path, dirty and (only for tabs that are dirty AND never saved) content, and one helper answers "does this window hold unsaved work no autosave will ever write, and what should the sheet show?" — consulted by CloseRequested, by the quit review's queue, and by its per-window recheck. Tab state is authoritative once reported, with the old per-window snapshot as the fallback for a window that has not reported yet. A window can no longer close over unsaved work sitting in a tab you cannot see. Cmd+W on the last tab now goes through the ordinary window close rather than force_close_window, so the native sheet stays the single authority. Closing one tab among several still asks here, since the sheet speaks for a window and has no per-tab counterpart. Cmd+N opens a tab, Shift+Cmd+N a new window, both as menu entries with en/zh/ja labels. Tab navigation keeps Cmd+Shift+[ / ] only, and now matches on `event.code`: with Shift held the bracket keys report "{" and "}", so the previous `event.key === "["` test could never have fired. Cmd+1–9 removed — those belong to the heading commands in @do-md/commands. The modifier is platform-appropriate rather than metaKey-only, and the whole hook is desktop-gated. --- common/i18n/locales/en.json | 1 + common/i18n/locales/ja.json | 1 + common/i18n/locales/zh.json | 1 + features/editor/components/tab-bar.tsx | 5 + features/editor/hooks/use-tab-shortcuts.ts | 66 ++++----- features/editor/hooks/use-tabs.ts | 46 ++++-- src-tauri/src/lib.rs | 154 ++++++++++++++------- 7 files changed, 176 insertions(+), 98 deletions(-) diff --git a/common/i18n/locales/en.json b/common/i18n/locales/en.json index 1baa679..30d39b4 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", diff --git a/common/i18n/locales/ja.json b/common/i18n/locales/ja.json index 54ad441..ce8355e 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": "ウインドウを閉じる", diff --git a/common/i18n/locales/zh.json b/common/i18n/locales/zh.json index 3e87023..b695b20 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": "关闭窗口", diff --git a/features/editor/components/tab-bar.tsx b/features/editor/components/tab-bar.tsx index 82f9714..724a863 100644 --- a/features/editor/components/tab-bar.tsx +++ b/features/editor/components/tab-bar.tsx @@ -61,6 +61,11 @@ export function TabBar() { 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 (
{ + if (!enabled) return; const handler = (e: KeyboardEvent) => { - if (!e.metaKey) return; - - const { tabs, activeTabId } = store.state; - const activeIndex = tabs.findIndex((t) => t.id === activeTabId); + const modifier = isApple ? e.metaKey : e.ctrlKey; + if (!modifier || !e.shiftKey) return; - // Cmd+Shift+] — next tab - if (e.key === "]" && e.shiftKey) { - e.preventDefault(); - const nextIndex = (activeIndex + 1) % tabs.length; - store.activateTab(tabs[nextIndex].id); - return; - } + const forward = e.code === "BracketRight"; + const back = e.code === "BracketLeft"; + if (!forward && !back) return; - // Cmd+Shift+[ — previous tab - if (e.key === "[" && e.shiftKey) { - e.preventDefault(); - const prevIndex = - (activeIndex - 1 + tabs.length) % tabs.length; - store.activateTab(tabs[prevIndex].id); - return; - } + const { tabs, activeTabId } = store.state; + if (tabs.length < 2) return; + const index = tabs.findIndex((tab) => tab.id === activeTabId); + if (index === -1) return; - // Cmd+1 through Cmd+9 — jump to tab by position - const num = parseInt(e.key); - if (num >= 1 && num <= 9) { - e.preventDefault(); - const targetIndex = num === 9 ? tabs.length - 1 : num - 1; - if (targetIndex < tabs.length) { - store.activateTab(tabs[targetIndex].id); - } - 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]); + }, [store, enabled, isApple]); } diff --git a/features/editor/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts index 7e6201f..807509e 100644 --- a/features/editor/hooks/use-tabs.ts +++ b/features/editor/hooks/use-tabs.ts @@ -12,7 +12,11 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { isTauri } from "@/common/lib/platform"; -import { tauriCore, tauriDialog } from "@/common/lib/tauri"; +import { + tauriCore, + tauriDialog, + tauriWebviewWindow, +} from "@/common/lib/tauri"; import { useLatest } from "@/common/lib/use-latest"; import { blankTauriDoc, @@ -44,7 +48,7 @@ export function useTabs({ const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; const onDocumentSwitchRef = useLatest(onDocumentSwitch); - useTabShortcuts(); + 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. */ @@ -147,12 +151,20 @@ export function useTabs({ const { tabId } = (e as CustomEvent).detail; if (!store.state.tabs.some((tb) => tb.id === tabId)) 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; - if (store.closeTab(tabId) !== "last-tab") return; - // Closing the only tab closes the window. Everything unsaved - // has just been resolved, so skip the native sheet. - const { invoke } = await tauriCore(); - await invoke("force_close_window").catch(() => {}); + store.closeTab(tabId); })(); }; @@ -235,13 +247,23 @@ export function useTabs({ // the window's last-assigned document). useEffect(() => { if (!enabled) return; - const paths = tabs - .map((tab) => (tab.meta.kind === "tauri" ? tab.meta.path : null)) - .filter((path): path is string => path !== null); + 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", { paths }).catch(() => {}); + invoke("update_tabs", { tabs: payload }).catch(() => {}); }); - }, [tabs, enabled]); + }, [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 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 97b0a03..7a79507 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, @@ -449,11 +460,24 @@ pub struct WindowFiles(pub Mutex>); /// duplicate; /// * the file watcher, so background tabs are watched too and not just the /// window's last-assigned document. -pub struct WindowTabs(pub Mutex>>); +/// 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, paths: Vec) { - self.0.lock().unwrap().insert(label.to_string(), paths); + pub fn set(&self, label: &str, tabs: Vec) { + self.0.lock().unwrap().insert(label.to_string(), tabs); } pub fn remove(&self, label: &str) { @@ -466,7 +490,9 @@ impl WindowTabs { .lock() .unwrap() .iter() - .find(|(_, paths)| paths.iter().any(|p| p == path)) + .find(|(_, tabs)| { + tabs.iter().any(|t| t.path.as_deref() == Some(path)) + }) .map(|(label, _)| label.clone()) } @@ -476,11 +502,60 @@ impl WindowTabs { .lock() .unwrap() .iter() - .flat_map(|(label, paths)| { - paths.iter().map(move |p| (label.clone(), p.clone())) + .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). +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 @@ -839,10 +914,12 @@ fn update_content( state.set(window.label(), content, is_dirty); } -/// FE pushes the paths of all open tabs whenever the tab set changes. +/// 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, paths: Vec) { - state.set(window.label(), paths); +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`. @@ -1252,15 +1329,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() } @@ -1277,23 +1348,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( @@ -1388,25 +1450,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 @@ -1472,7 +1525,8 @@ pub fn run() { // 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-window" { + 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) }) { @@ -1480,6 +1534,8 @@ pub fn run() { } 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) From 52e7556a176c8cd9f07415b9ee206f943d69cc87 Mon Sep 17 00:00:00 2001 From: Systemofdumb <251506469+Systemofdumb@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:30:54 +0100 Subject: [PATCH 5/8] fix(cli): close guard only saw the visible tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `domd-cli close` checked the window-level dirty flag, which under tabs reflects the ACTIVE document. Since it then calls destroy() — deliberately bypassing CloseRequested — an unsaved, never-saved document in a background tab could be discarded by a close the caller believed was safe. Uses the same gate as the window close flow, so it accounts for every tab. Saved documents stay closable (autosave already wrote them) and --force is unchanged. Verified: a window whose visible tab is a clean saved file now refuses to close while a hidden tab holds unsaved work, and --force still overrides. --- src-tauri/src/cli_server.rs | 13 +++++++------ src-tauri/src/lib.rs | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) 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/lib.rs b/src-tauri/src/lib.rs index 7a79507..88ee09a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -537,7 +537,7 @@ impl WindowTabs { /// 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). -fn unsaved_untitled_content(app: &AppHandle, label: &str) -> Option { +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); From 8ffbc2dd1d0323ab87a29e7de66982c1969daadc Mon Sep 17 00:00:00 2001 From: Youness <251506469+Systemofdumb@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:02:35 +0100 Subject: [PATCH 6/8] feat(editor): in-app Save / Don't Save / Cancel prompt for closing a tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing one tab among several has no native counterpart — the macOS sheet speaks for a window — so that path asked in-app through the `ask` plugin dialog, which offers two buttons. There was no Cancel: once you triggered a tab close you could not call it off, on the one path that can destroy work. An in-app modal instead, with the sheet's semantics rather than its chrome: Don't Save / Cancel / Save, Escape cancels, Enter saves, and the sheet's grouping (the irreversible choice set apart on the left). Enter is not bound by hand — the default button takes focus, so it falls out of ordinary button behaviour and Tab order and assistive tech get the right answer for free. Window close and quit are untouched: both still go through the native sheet. The close flow is async and the answer comes from a rendered component, so the resolver parks in a ref. Two guards around that: a second close request while a prompt is open is ignored rather than stranding the first one's resolver, and unmounting with a prompt open resolves it as cancel — the only outcome that touches neither the document nor the disk. Cancel restores focus to what it was, because the editor stays mounted and focus is otherwise left on a button that no longer exists; the kernel binds its key handling to the editable root, so typing would silently do nothing until you clicked back in. Save and discard leave that to TabFocusOnSwitch, which owns focus for the document that comes forward. The modal is deliberately unkeyed, and the comment says why: the obvious key is the tab id, which is exactly what keys the DOMDProvider — closing the ACTIVE tab then puts two siblings under one key, and React mounts a second editor shell without removing the first. Two stacked documents, no error, since the duplicate-key warning is development-only. Verified in both WebKit and Chromium, 18 assertions: Don't Save closes the tab, Cancel keeps it and returns focus to the editor, Escape cancels, and repeated prompt/cancel cycles leave exactly one editor shell. Labels added under tabs.unsavedSave / unsavedDontSave / unsavedCancel in en/zh/ja. tsc clean; eslint output identical to the parent commit. Co-Authored-By: Claude Opus 5 (1M context) --- common/i18n/locales/en.json | 5 +- common/i18n/locales/ja.json | 5 +- common/i18n/locales/zh.json | 5 +- features/editor/components/editor-app.tsx | 30 ++++- .../editor/components/tab-close-modal.tsx | 105 +++++++++++++++++ features/editor/hooks/use-tabs.ts | 109 +++++++++++++++--- 6 files changed, 234 insertions(+), 25 deletions(-) create mode 100644 features/editor/components/tab-close-modal.tsx diff --git a/common/i18n/locales/en.json b/common/i18n/locales/en.json index 30d39b4..8af40b1 100644 --- a/common/i18n/locales/en.json +++ b/common/i18n/locales/en.json @@ -413,6 +413,9 @@ "scrollHorizontally": "Scroll horizontally", "untitled": "Untitled.md", "unsavedTitle": "Unsaved changes", - "unsavedBody": "Save changes to “{{name}}” before closing?" + "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 ce8355e..7d42410 100644 --- a/common/i18n/locales/ja.json +++ b/common/i18n/locales/ja.json @@ -413,6 +413,9 @@ "scrollHorizontally": "横スクロール", "untitled": "Untitled.md", "unsavedTitle": "未保存の変更", - "unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?" + "unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?", + "unsavedSave": "保存", + "unsavedDontSave": "保存しない", + "unsavedCancel": "キャンセル" } } diff --git a/common/i18n/locales/zh.json b/common/i18n/locales/zh.json index b695b20..45662cc 100644 --- a/common/i18n/locales/zh.json +++ b/common/i18n/locales/zh.json @@ -413,6 +413,9 @@ "scrollHorizontally": "横向滚动", "untitled": "Untitled.md", "unsavedTitle": "未保存的更改", - "unsavedBody": "关闭前保存对“{{name}}”的更改吗?" + "unsavedBody": "关闭前保存对“{{name}}”的更改吗?", + "unsavedSave": "保存", + "unsavedDontSave": "不保存", + "unsavedCancel": "取消" } } diff --git a/features/editor/components/editor-app.tsx b/features/editor/components/editor-app.tsx index d804b41..d03705f 100644 --- a/features/editor/components/editor-app.tsx +++ b/features/editor/components/editor-app.tsx @@ -84,6 +84,7 @@ 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"; @@ -653,12 +654,13 @@ function EditorAppContent() { // 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 } = 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, - }); + 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 @@ -875,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; +}) { + 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/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts index 807509e..0c8ce8f 100644 --- a/features/editor/hooks/use-tabs.ts +++ b/features/editor/hooks/use-tabs.ts @@ -9,14 +9,10 @@ * * Web never calls this — one page, one document, no tab bar. */ -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { useCallback, useEffect, useRef, useState } from "react"; import { isTauri } from "@/common/lib/platform"; -import { - tauriCore, - tauriDialog, - tauriWebviewWindow, -} from "@/common/lib/tauri"; +import { tauriCore, tauriWebviewWindow } from "@/common/lib/tauri"; +import type { UnsavedChoice } from "../components/tab-close-modal"; import { useLatest } from "@/common/lib/use-latest"; import { blankTauriDoc, @@ -28,6 +24,14 @@ 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, @@ -41,7 +45,6 @@ export function useTabs({ * and only the active tab has a mounted editor to attach one to. */ onDocumentSwitch: () => void; }) { - const { t } = useTranslation(); const store = useTabStoreApi(); const tabs = useTabStore((s) => s.state.tabs); const activeTabId = useTabStore((s) => s.state.activeTabId); @@ -109,6 +112,75 @@ export function useTabs({ ); }); + // ── 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. * @@ -120,12 +192,13 @@ export function useTabs({ if (!tab || !tab.isDirty) return true; if (tab.meta.kind !== "tauri" || tab.meta.path) return true; - const { ask } = await tauriDialog(); - const shouldSave = await ask( - t("tabs.unsavedBody", { name: tab.meta.name }), - { title: t("tabs.unsavedTitle"), kind: "warning" }, - ); - if (!shouldSave) return true; // discard + 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 @@ -136,7 +209,7 @@ export function useTabs({ if (result.ok) store.updateTabMeta(tabId, result.meta); return result.ok; }, - [store, t], + [store, askUnsaved, refocusAfterCancel], ); // ── New / close, driven by the tab bar, shortcuts and native menu ──── @@ -150,6 +223,10 @@ export function useTabs({ 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 @@ -307,5 +384,5 @@ export function useTabs({ // people actually take, and saved documents are never at risk because // autosave and the switch-time flush have already written them. - return { markActiveTabDirty, switchedTabs }; + return { markActiveTabDirty, switchedTabs, closeRequest, decideUnsaved }; } From d347ec354f5e25079fc09a9da59822dc01623ac4 Mon Sep 17 00:00:00 2001 From: Youness <251506469+Systemofdumb@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:28:16 +0100 Subject: [PATCH 7/8] fix(cli): readiness is now asserted by the host on every open route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `open_or_reuse` clears WindowReady before routing a file to a window, so an `insert` arriving immediately after `open` waits rather than landing in the document that is still on screen. That relied on the editor remount to put readiness back, and under tabs neither route remounts dependably. The activate-tab route could not clear it at all. 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 five seconds for a mark no remount would ever produce — so it cleared nothing, and `open` on a file already in a background tab raced with the switch. Both routes clear it now, and the frontend asserts readiness once the document is actually in place — including the case where nothing changed, which is what makes clearing safe on the ambiguous route. That is the same `invoke("benchmark_mark_ready")` the reuse path already used, lifted into one helper so every route into the window goes through it. Co-Authored-By: Claude Opus 5 (1M context) --- features/editor/hooks/use-tabs.ts | 40 ++++++++++++++++++++----------- src-tauri/src/lib.rs | 11 +++++++++ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/features/editor/hooks/use-tabs.ts b/features/editor/hooks/use-tabs.ts index 0c8ce8f..1951cfc 100644 --- a/features/editor/hooks/use-tabs.ts +++ b/features/editor/hooks/use-tabs.ts @@ -64,6 +64,27 @@ export function useTabs({ ); // ── 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 @@ -75,20 +96,7 @@ export function useTabs({ } else { store.addTab(doc.meta, doc.content); } - // Re-arm readiness for the CLI. - // - // `open_or_reuse` clears WindowReady before routing a file here, - // because the window is about to show a different document and an - // immediate `insert` must not land in the previous one. That relies on - // something re-marking it, and until now the editor remount did. - // - // Reusing the lone blank tab no longer remounts anything: the runtime - // is reset in place precisely so view state survives. So the tab layer - // has to say when the document is actually in place — it is the only - // party that knows, and it is true for both branches above. - if (!isTauri()) return; - const { invoke } = await tauriCore(); - await invoke("benchmark_mark_ready").catch(() => {}); + await markReady(); }; useTauriEvent("open-file-in-tab", (path) => { @@ -97,7 +105,11 @@ export function useTabs({ 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", () => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 88ee09a..8e177f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1093,6 +1093,15 @@ pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { 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, @@ -1116,6 +1125,8 @@ 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, From d532af8ff38b7758e7455421ea4ed479e2f56881 Mon Sep 17 00:00:00 2001 From: Youness <251506469+Systemofdumb@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:43:16 +0100 Subject: [PATCH 8/8] fix(tabs): opening a .md from Finder blanked the window on a cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-clicking a `.md` while the app was closed showed an empty Untitled document instead of the file. Opening one while it was already running worked, as did `domd-cli open` and passing the path as an argument. A cold Finder open is the only route where nothing is listening yet. `setup` creates an empty window, RunEvent::Opened then lands here while the webview is still booting, and the `open-file-in-tab` emit arrives before the frontend has registered for it. The one-document build survives that because its reuse path records the window's assigned document in WindowFiles first, and the frontend's first act is to ask `get_my_path` what it is meant to show. The tab rewrite kept the emit and dropped the write, so the answer was null and the window blanked itself. Records it before emitting again. The event stays as the live path for a window that is already up; the entry is what makes a cold start deterministic rather than a race. The two converge rather than conflict — `addTab` dedupes on path, so a window that both hears the event and reads the entry activates one tab, not two. Verified on a built app across every route: cold Finder open loads the file, warm Finder open adds a tab, re-opening a file already in a background tab activates it with the right content, `domd-cli open` is unchanged, and a plain launch with no file still opens blank. Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/src/lib.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e177f7..82aea94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1167,6 +1167,23 @@ pub(crate) fn open_or_reuse(app: &AppHandle, path: String) -> OpenOutcome { .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