diff --git a/common/i18n/locales/en.json b/common/i18n/locales/en.json
index 30b685a..8af40b1 100644
--- a/common/i18n/locales/en.json
+++ b/common/i18n/locales/en.json
@@ -11,6 +11,7 @@
"menu": {
"about": "About DOMD",
"checkUpdates": "Check for Updates...",
+ "newTab": "New Tab",
"newWindow": "New Window",
"openUrl": "Open URL...",
"closeWindow": "Close Window",
@@ -404,5 +405,17 @@
"locate": "Could not locate the text to edit — try rephrasing"
},
"localSelf": "Me"
+ },
+ "tabs": {
+ "newTab": "New tab",
+ "closeTab": "Close tab",
+ "shrinkToFit": "Shrink to fit",
+ "scrollHorizontally": "Scroll horizontally",
+ "untitled": "Untitled.md",
+ "unsavedTitle": "Unsaved changes",
+ "unsavedBody": "Save changes to “{{name}}” before closing?",
+ "unsavedSave": "Save",
+ "unsavedDontSave": "Don't Save",
+ "unsavedCancel": "Cancel"
}
}
diff --git a/common/i18n/locales/ja.json b/common/i18n/locales/ja.json
index df3b7d2..7d42410 100644
--- a/common/i18n/locales/ja.json
+++ b/common/i18n/locales/ja.json
@@ -11,6 +11,7 @@
"menu": {
"about": "DOMD について",
"checkUpdates": "アップデートを確認...",
+ "newTab": "新しいタブ",
"newWindow": "新規ウインドウ",
"openUrl": "URL を開く...",
"closeWindow": "ウインドウを閉じる",
@@ -404,5 +405,17 @@
"locate": "編集対象のテキストを特定できませんでした——言い換えてみてください"
},
"localSelf": "自分"
+ },
+ "tabs": {
+ "newTab": "新しいタブ",
+ "closeTab": "タブを閉じる",
+ "shrinkToFit": "幅に合わせる",
+ "scrollHorizontally": "横スクロール",
+ "untitled": "Untitled.md",
+ "unsavedTitle": "未保存の変更",
+ "unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?",
+ "unsavedSave": "保存",
+ "unsavedDontSave": "保存しない",
+ "unsavedCancel": "キャンセル"
}
}
diff --git a/common/i18n/locales/zh.json b/common/i18n/locales/zh.json
index 9811fcd..45662cc 100644
--- a/common/i18n/locales/zh.json
+++ b/common/i18n/locales/zh.json
@@ -11,6 +11,7 @@
"menu": {
"about": "关于 DOMD",
"checkUpdates": "检查更新...",
+ "newTab": "新建标签页",
"newWindow": "新建窗口",
"openUrl": "打开 URL...",
"closeWindow": "关闭窗口",
@@ -404,5 +405,17 @@
"locate": "未能定位要修改的文本——换个说法试试"
},
"localSelf": "我"
+ },
+ "tabs": {
+ "newTab": "新建标签页",
+ "closeTab": "关闭标签页",
+ "shrinkToFit": "自适应宽度",
+ "scrollHorizontally": "横向滚动",
+ "untitled": "Untitled.md",
+ "unsavedTitle": "未保存的更改",
+ "unsavedBody": "关闭前保存对“{{name}}”的更改吗?",
+ "unsavedSave": "保存",
+ "unsavedDontSave": "不保存",
+ "unsavedCancel": "取消"
}
}
diff --git a/features/collaboration/lib/collab-db-tauri.ts b/features/collaboration/lib/collab-db-tauri.ts
index dc21ae3..15521a0 100644
--- a/features/collaboration/lib/collab-db-tauri.ts
+++ b/features/collaboration/lib/collab-db-tauri.ts
@@ -19,8 +19,11 @@ import {
} from "@/plugins/collaboration/crdt-sync";
import type { RoomRecord } from "./types";
-/** Frontmatter domd-id of the document open in this window. One window
- * hosts one document, so module-level state is safe. */
+/** Frontmatter domd-id of the document collaboration currently applies to.
+ * A window can hold several documents in tabs, but only the ACTIVE one has
+ * a mounted editor for a session to attach to — so there is still exactly
+ * one at a time and module-level state stays safe. The tabbed desktop shell
+ * re-points this on every tab switch (see tabbed-editor-app). */
let currentDocId: string | null = null;
export const setCollabDocId = (docId: string): void => {
diff --git a/features/editor/components/editor-app.tsx b/features/editor/components/editor-app.tsx
index b1434dc..d03705f 100644
--- a/features/editor/components/editor-app.tsx
+++ b/features/editor/components/editor-app.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
DOMDProvider,
+ EditorStore,
useEditorStore,
useEditorStoreApi,
useFormatState,
@@ -80,6 +81,11 @@ import { ModeController } from "./mode-controller";
import { NewDocModal } from "./new-doc-modal";
import { ImageDropHandler } from "../hooks/use-image-drop";
import { useDocumentLoaders } from "../hooks/use-document-loaders";
+import { useTabs } from "../hooks/use-tabs";
+import { TabStoreProvider } from "../stores/tab-store";
+import { TabBar } from "./tab-bar";
+import { TabCloseModal } from "./tab-close-modal";
+import { TabFocusOnSwitch } from "./tab-focus";
import { useTauriDragDrop } from "../hooks/use-tauri-drag-drop";
import { useTauriEvent } from "../hooks/use-tauri-event";
import { useWebDragDrop } from "../hooks/use-web-drag-drop";
@@ -256,10 +262,38 @@ export function EditorApp() {
// trigger surface — web top-bar buttons, the native-titlebar event
// bridge, the drawer overlay — reaches the same store without threading
// callbacks through props (claude-os nav-drawer pattern).
+ const { t } = useTranslation();
+
+ // Builds the document runtime for a tab. This is the one place the kernel
+ // construction options live now: a tab's store is created here and owned
+ // by the TabStore for the tab's whole life, so these can no longer sit on
+ // DOMDProvider — it ignores every construction prop once handed a store.
+ const createRuntime = useCallback(
+ (initMd: string) =>
+ new EditorStore({
+ editable: true,
+ initMd,
+ placeholder: t("editor.placeholder"),
+ mode: "rich",
+ imageLoader: collabImageLoader,
+ codeTokenizer: tokenize,
+ inlineRules: appInlineRules,
+ codeBeautify: beautify,
+ }),
+ [t],
+ );
+
return (
-
-
-
+ // TabStoreProvider holds the window's open documents and their live
+ // runtimes, and is the document source for BOTH platforms (see
+ // useDocumentLoaders). Web is simply the case where nothing ever opens
+ // a second tab, which keeps one editor tree instead of a tabbed copy
+ // that has to be kept in step with this one.
+
+
+
+
+
);
}
@@ -273,7 +307,7 @@ function EditorAppContent() {
const {
meta,
setMeta,
- content,
+ runtime,
version,
view,
applyBlank,
@@ -616,7 +650,19 @@ function EditorAppContent() {
const isWeb = !useIsTauri();
const dragging = tauriDragging || webDragging;
- if (view === "loading" || meta === null || content === null) {
+ // Tabs are a desktop concept: a window holds several documents, a web page
+ // holds one. Everything below the tab bar is the same editor either way —
+ // useDocumentLoaders reads whichever tab is active, so a switch reaches
+ // the kernel as an ordinary document swap (`key={version}`).
+ const { markActiveTabDirty, switchedTabs, closeRequest, decideUnsaved } =
+ useTabs({
+ enabled: !isWeb,
+ // A tab switch retires the previous document exactly as loading a
+ // new file into the window does (see the open-file handler above).
+ onDocumentSwitch: detachSharing,
+ });
+
+ if (view === "loading" || meta === null || runtime === null) {
// Loading covers ONLY the content area: the top bar (same classes as
// the real one in Editor) paints immediately so the page never reads
// as a slow full-screen blank. Desktop has no web top bar — the
@@ -667,10 +713,16 @@ function EditorAppContent() {
return (
+ {isWeb ? null : }
{dragging ? (
@@ -679,19 +731,25 @@ function EditorAppContent() {
) : null}
+ {/* Bring-your-own-store: the view mounts over the active tab's
+ long-lived runtime instead of constructing one from initMd, so
+ a tab switch is an attach rather than a re-parse. Construction
+ props are deliberately absent — the provider ignores them when
+ handed a store, and the runtime already carries them (see
+ createRuntime in EditorApp). `renderComponent` stays: it is
+ view configuration, not document state. Keyed by tab, because
+ the provider captures its store once on mount. */}
+ {/* Real DOM focus for the document a tab switch just brought
+ forward — the kernel binds its key handling to the editable
+ root, so a document focused only in the model is one where
+ ⌘Z does nothing. */}
+ {isWeb ? null : }
{/* Hydrates the persisted display mode + binds Cmd+/ —
the "more" menu entry (web) and this keystroke (both
runtimes) call the same toggle. */}
@@ -746,6 +804,9 @@ function EditorAppContent() {
aiAvailable={isWeb}
aiActive={aiEnabled && aiAgents.length > 0}
sidePanel={sidePanel}
+ embedded={!isWeb}
+ tabId={isWeb ? undefined : version}
+ onDirtyChange={isWeb ? undefined : markActiveTabDirty}
/>
{/* Local collaboration session while AI is on without a
live room: same doc/versioning machinery over a no-op
@@ -816,6 +877,22 @@ function EditorAppContent() {
/>
) : null}
+ {/* Deliberately unkeyed, like the modals above: the conditional
+ already gives each prompt a fresh instance. A key here is not
+ merely redundant, it is a trap — the obvious one to reach for
+ is the tab id, which is exactly what keys the DOMDProvider
+ above (`version` IS the active tab id). Closing the active tab
+ then puts two siblings under one key, and React quietly mounts
+ a second editor shell without removing the first: two stacked
+ documents, no error, since the duplicate-key warning is
+ development-only. */}
+ {closeRequest ? (
+
+ ) : null}
+
{showShareModal ? (
void;
@@ -176,6 +179,16 @@ export function Editor({
/** Side panel content for the SidePanelHost slot, resolved by the app
* from the side-panel store's active kind. Null = closed. */
sidePanel?: React.ReactNode;
+ /** Set by the tabbed desktop shell: the tab this editor is showing.
+ * Dirty state is mirrored to that tab so the tab bar can badge it. */
+ tabId?: string;
+ /** Render as a flex child filling the space under the tab bar instead of
+ * covering the viewport. */
+ embedded?: boolean;
+ /** Notified (debounced, same cadence as the Rust content push) whenever
+ * the unsaved-changes state flips. The tabbed shell mirrors it into the
+ * tab store so the tab bar can badge modified documents. */
+ onDirtyChange?: (isDirty: boolean) => void;
}) {
const { t } = useTranslation();
const renderData = useRenderData();
@@ -445,18 +458,19 @@ export function Editor({
// Tauri: CLI → push full content + dirty flag whenever the doc changes.
// Debounced (150ms) since this serializes the whole renderData to markdown.
const lastSavedMdRef = useRef("");
+ const onDirtyChangeRef = useLatest(onDirtyChange);
useEffect(() => {
- if (!isTauri()) return;
// Treat the initial loaded content as the baseline for dirty detection.
// Re-runs only when meta changes (new doc loaded into this window).
lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? "";
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta]);
useEffect(() => {
- if (!isTauri()) return;
const handle = setTimeout(() => {
const md = toMarkdown(renderData) ?? "";
const isDirty = md !== lastSavedMdRef.current;
+ onDirtyChangeRef.current?.(isDirty);
+ if (!isTauri()) return;
tauriCore().then(({ invoke }) => {
invoke("update_content", { content: md, isDirty }).catch(
() => { },
@@ -464,12 +478,13 @@ export function Editor({
});
}, 150);
return () => clearTimeout(handle);
- }, [renderData]);
+ }, [renderData, onDirtyChangeRef]);
// Tauri: CLI just saved this window to disk on our behalf. Update the
// baseline so subsequent dirty checks compare against the saved content.
useTauriEvent("saved-by-cli", () => {
lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? "";
+ onDirtyChangeRef.current?.(false);
// Push an immediate clean-state update so AI sees has_unsaved_changes
// flip to false without waiting for the debounce.
tauriCore().then(({ invoke }) => {
@@ -487,9 +502,13 @@ export function Editor({
useEffect(() => {
if (prevSavingRef.current && !saving) {
lastSavedMdRef.current = toMarkdown(renderDataRef.current) ?? "";
+ // Clear the tab badge now rather than waiting for the next
+ // keystroke — a save does not itself change renderData, so the
+ // debounced effect above would not re-run.
+ onDirtyChangeRef.current?.(false);
}
prevSavingRef.current = saving;
- }, [saving]);
+ }, [saving, onDirtyChangeRef]);
// Web: Cmd/Ctrl+S
useEffect(() => {
@@ -529,7 +548,17 @@ export function Editor({
// context only, no DOM wrapper — same posture as DOMDProvider.
-
+ {/* `embedded` (tabbed desktop shell) swaps the viewport-covering
+ root for a flex child so the tab bar keeps its row. The inner
+ layer is absolute either way, so it needs a positioned ancestor —
+ the embedded root is `relative` for exactly that reason. */}
+
{/* Format shortcuts (⌘1/⌘K/⌥⌘C/…) live outside the top bar: the
desktop build renders no web top bar, and they must work
there too. */}
@@ -769,6 +798,10 @@ export function Editor({
{
if (domdRef.current?.contains(e.target as Node))
return;
diff --git a/features/editor/components/tab-bar.tsx b/features/editor/components/tab-bar.tsx
new file mode 100644
index 0000000..724a863
--- /dev/null
+++ b/features/editor/components/tab-bar.tsx
@@ -0,0 +1,185 @@
+"use client";
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useTabStore, useTabStoreApi } from "../stores/tab-store";
+import { useTabDragReorder } from "../hooks/use-tab-drag-reorder";
+
+export function TabBar() {
+ const { t } = useTranslation();
+ const tabs = useTabStore((s) => s.state.tabs);
+ const activeTabId = useTabStore((s) => s.state.activeTabId);
+ const displayMode = useTabStore((s) => s.state.displayMode);
+ const store = useTabStoreApi();
+ const barRef = useRef(null);
+
+ const [contextMenu, setContextMenu] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+
+ const tabIds = tabs.map((t) => t.id);
+ const { draggingId, onPointerDown, onPointerMove, onPointerUp } =
+ useTabDragReorder(tabIds, (from, to) => store.reorderTab(from, to));
+
+ // No useCallback: this project compiles with the React Compiler, which
+ // memoizes these itself. Hand-written useCallback here defeated it (the
+ // compiler bails on a component whose existing memoization it cannot
+ // prove it preserves) and bought nothing.
+ const requestClose = (tabId: string) => {
+ window.dispatchEvent(
+ new CustomEvent("domd-close-tab", { detail: { tabId } }),
+ );
+ };
+
+ const handleActivate = (tabId: string) => {
+ if (tabId !== activeTabId) store.activateTab(tabId);
+ };
+
+ const handleClose = (e: React.MouseEvent, tabId: string) => {
+ e.stopPropagation();
+ requestClose(tabId);
+ };
+
+ const handleMiddleClick = (e: React.MouseEvent, tabId: string) => {
+ if (e.button === 1) {
+ e.preventDefault();
+ requestClose(tabId);
+ }
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ setContextMenu({ x: e.clientX, y: e.clientY });
+ };
+
+ const handleSetDisplayMode = (mode: "shrink" | "scroll") => {
+ store.setDisplayMode(mode);
+ setContextMenu(null);
+ };
+
+ const handleNewTab = () => {
+ window.dispatchEvent(new CustomEvent("domd-new-tab"));
+ };
+
+ // A window showing one document looks exactly as it did before tabs
+ // existed: no strip, no border, no reserved height. Rendering nothing
+ // rather than hiding it means the flex column never allots the row.
+ if (tabs.length <= 1) return null;
+
+ return (
+
+
+ {tabs.map((tab) => (
+
handleActivate(tab.id)}
+ onMouseDown={(e) => handleMiddleClick(e, tab.id)}
+ onPointerDown={(e) => {
+ // Don't start drag when clicking the close button
+ if ((e.target as HTMLElement).closest("button"))
+ return;
+ const bar = barRef.current;
+ if (bar) onPointerDown(e, tab.id, bar);
+ }}
+ >
+ {tab.isDirty && (
+
+ )}
+ {tab.meta.name}
+
+
+ ))}
+
+
+
+
+ {contextMenu && (
+ <>
+
setContextMenu(null)}
+ />
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/features/editor/components/tab-close-modal.tsx b/features/editor/components/tab-close-modal.tsx
new file mode 100644
index 0000000..cd4dcd5
--- /dev/null
+++ b/features/editor/components/tab-close-modal.tsx
@@ -0,0 +1,105 @@
+"use client";
+/**
+ * Unsaved-work prompt for closing ONE tab among several.
+ *
+ * Window close and quit both go through the native macOS sheet, which speaks
+ * for a window and has no per-tab counterpart — so this path has to ask
+ * in-app. It keeps the sheet's SEMANTICS rather than its chrome: three
+ * outcomes, Escape cancels, Enter saves, and the sheet's button order (Don't
+ * Save set apart on the left, Cancel then Save on the right, Save default).
+ *
+ * The two-button `ask` plugin dialog it replaces had no Cancel at all, so a
+ * close you triggered by accident could not be called off.
+ *
+ * Mount-only, like the sibling modals: the parent renders it conditionally so
+ * each prompt is a fresh instance with no state to reset.
+ */
+import { useEffect, useRef } from "react";
+import { useTranslation } from "react-i18next";
+
+export type UnsavedChoice = "save" | "discard" | "cancel";
+
+export function TabCloseModal({
+ name,
+ onChoose,
+}: {
+ /** Document being closed, shown in the question. */
+ name: string;
+ onChoose: (choice: UnsavedChoice) => void;
+}) {
+ const { t } = useTranslation();
+ const saveRef = useRef(null);
+
+ // Focus the default button rather than binding Enter by hand: it makes
+ // Enter-saves fall out of ordinary button behaviour, keeps Tab order
+ // meaningful, and announces the default to assistive tech.
+ //
+ // Giving focus BACK is deliberately not done here. An unmount cleanup
+ // cannot see which button was pressed, and only Cancel should restore it —
+ // the other two close the tab, where focus belongs to TabFocusOnSwitch.
+ // So the decision, and the restore, live with the caller in use-tabs.
+ useEffect(() => {
+ const id = requestAnimationFrame(() => saveRef.current?.focus());
+ return () => cancelAnimationFrame(id);
+ }, []);
+
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault();
+ onChoose("cancel");
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onChoose]);
+
+ return (
+
+ {/* Set apart on the left, as the native sheet does — the
+ one irreversible choice should not sit inside the
+ cluster your hand is already heading for. */}
+
+
+
+
+
+
+ );
+}
diff --git a/features/editor/components/tab-focus.tsx b/features/editor/components/tab-focus.tsx
new file mode 100644
index 0000000..3bb4593
--- /dev/null
+++ b/features/editor/components/tab-focus.tsx
@@ -0,0 +1,42 @@
+"use client";
+import { useEffect } from "react";
+import { useEditorDom } from "@do-md/core-react";
+
+/**
+ * Put real DOM focus on the editor when a tab switch mounts this view.
+ *
+ * Selecting a tab means "I want to work in this document", and it has to be
+ * DOM focus specifically: the kernel binds undo/redo and the rest of its key
+ * handling to the editable root, not to window, so a document that is focused
+ * only in the model is one where ⌘Z silently does nothing.
+ *
+ * `store.focus()` is not enough on its own here. It records focus INTENT and
+ * the render layer materializes it, which is the right call for gestures
+ * mid-session — but across a view remount (which is what a tab switch is) the
+ * intent can land without the new editable root ever taking browser focus.
+ * Observed directly: after a switch `document.activeElement` was the editor
+ * root and `store.focused_` was true, a synthetic keydown dispatched at that
+ * root undid correctly, and yet real keystrokes did nothing until something
+ * called `.focus()` on the element itself.
+ *
+ * The kernel deliberately never steals focus on attach — restoring it is the
+ * host's call, which is exactly what this is. Mounted inside the provider so
+ * it can reach the editable root through the kernel's own DOM context rather
+ * than querying for a kernel-internal attribute.
+ */
+export function TabFocusOnSwitch({ enabled }: { enabled: boolean }) {
+ const { textAreaDomRef } = useEditorDom();
+
+ useEffect(() => {
+ if (!enabled) return;
+ // One frame late: the editable root mounts through a ref sink and the
+ // controller attaches with it, so focusing in the same commit can beat
+ // the listeners into place.
+ const raf = requestAnimationFrame(() => {
+ textAreaDomRef.current?.focus();
+ });
+ return () => cancelAnimationFrame(raf);
+ }, [enabled, textAreaDomRef]);
+
+ return null;
+}
diff --git a/features/editor/hooks/use-document-loaders.ts b/features/editor/hooks/use-document-loaders.ts
index 6a2af25..bd4ca58 100644
--- a/features/editor/hooks/use-document-loaders.ts
+++ b/features/editor/hooks/use-document-loaders.ts
@@ -1,22 +1,64 @@
"use client";
-import { useCallback, useState } from "react";
-import { fetchMarkdown, resolveMarkdownUrl } from "../lib/resolve-url";
+/**
+ * Resolves a document source and commits it to the window's TabStore.
+ *
+ * The API is unchanged from the single-document version — meta, content,
+ * version, view plus one entry point per source — because everything that
+ * consumes it (editor-app) should not care whether the window holds one
+ * document or ten. What changed is where the result lands: the active tab
+ * instead of local React state.
+ *
+ * That indirection is the whole point of the tabbed shell: tabs get the
+ * upstream editor tree — collaboration, disk reconciliation, AI, the outline
+ * panel — without a parallel copy of it.
+ *
+ * `content` is not returned, because there is no longer a content prop to
+ * return. Each tab owns a live EditorStore and the provider is handed that
+ * store directly, so the document never round-trips through React state.
+ */
+import { useCallback } from "react";
+import { isTauri } from "@/common/lib/platform";
+import { tauriCore } from "@/common/lib/tauri";
+import { setCollabDocId } from "@/features/collaboration/lib/collab-db-tauri";
+import { markKnownDiskContent } from "../lib/disk-sync";
import {
buildFrontmatterBlock,
ensureDomdId,
splitFrontmatter,
} from "../lib/frontmatter";
-import { setCollabDocId } from "@/features/collaboration/lib/collab-db-tauri";
-import { markKnownDiskContent } from "../lib/disk-sync";
-import { isTauri } from "@/common/lib/platform";
-import { tauriCore } from "@/common/lib/tauri";
+import { fetchMarkdown, resolveMarkdownUrl } from "../lib/resolve-url";
+import { useTabStore, useTabStoreApi } from "../stores/tab-store";
import type { FileMeta, View } from "../lib/types";
export function useDocumentLoaders() {
- const [meta, setMeta] = useState(null);
- const [content, setContent] = useState(null);
- const [version, setVersion] = useState(0);
- const [view, setView] = useState("loading");
+ const store = useTabStoreApi();
+ const tabs = useTabStore((s) => s.state.tabs);
+ const activeTabId = useTabStore((s) => s.state.activeTabId);
+ const active = tabs.find((tab) => tab.id === activeTabId) ?? null;
+
+ const meta = active?.meta ?? null;
+ /** The active tab's live document runtime, handed to DOMDProvider as
+ * `store`. With it, every construction-time prop is ignored — the
+ * runtime already carries them. */
+ const runtime = active ? (store.runtimeOf(active.id) ?? null) : null;
+ /** Editor mount identity: the TAB, and nothing more.
+ *
+ * The provider captures its store once on mount, so switching tabs has
+ * to remount the view. Replacing the DOCUMENT inside a tab must not —
+ * that resets the runtime in place, and remounting would discard the
+ * view state (selection, focus, scroll) the kernel just preserved. The
+ * old `${id}:${docEpoch}` key did exactly that, which is why the epoch
+ * is gone along with the serialize-and-remount model it belonged to. */
+ const version = active?.id ?? "loading";
+ const view: View = active && runtime ? "editor" : "loading";
+
+ const setMeta = useCallback(
+ (next: FileMeta) => {
+ const id = store.state.activeTabId;
+ if (id) store.updateTabMeta(id, next);
+ },
+ [store],
+ );
const applyBlank = useCallback(() => {
if (isTauri()) {
@@ -26,77 +68,42 @@ export function useDocumentLoaders() {
// block lives in meta and is written out on first save.
const docId = crypto.randomUUID();
setCollabDocId(docId);
- setMeta({
- kind: "tauri",
- path: null,
- name: "Untitled.md",
- docId,
- frontmatter: buildFrontmatterBlock(docId),
- });
- } else {
- setMeta({ kind: "web", name: "Untitled.md", handle: null });
+ store.openInActiveTab(
+ {
+ kind: "tauri",
+ path: null,
+ name: "Untitled.md",
+ docId,
+ frontmatter: buildFrontmatterBlock(docId),
+ },
+ "",
+ );
+ return;
}
- setContent("");
- setVersion((n) => n + 1);
- setView("editor");
- }, []);
+ store.openInActiveTab({ kind: "web", name: "Untitled.md", handle: null }, "");
+ }, [store]);
/** Blank web meta with restored local content (draft / collab doc).
* The name survives (draft mirrors named docs too) but the file handle
* cannot — restored drafts always re-save via the picker. */
const applyLocal = useCallback(
(localContent: string, name = "Untitled.md") => {
- setMeta({ kind: "web", name, handle: null });
- setContent(localContent);
- setVersion((n) => n + 1);
- setView("editor");
+ store.openInActiveTab(
+ { kind: "web", name, handle: null },
+ localContent,
+ );
},
- [],
+ [store],
);
- const loadTauriPath = useCallback(async (path: string) => {
- const { invoke } = await tauriCore();
- const raw = await invoke("read_file", { path }).catch(
- () => null,
- );
- const name = path.split("/").pop() ?? path;
- if (raw === null) {
- // Unreadable file: open blank, without a doc identity. Do NOT
- // write anything back — the read failure may be transient.
- setMeta({ kind: "tauri", path, name, docId: null });
- setContent("");
- setVersion((n) => n + 1);
- setView("editor");
- return;
- }
- // Document identity: guarantee a frontmatter domd-id, silently
- // writing the injected block back to disk. The editor only ever sees
- // the body — the block is re-prepended on save (see save-document).
- const ensured = ensureDomdId(raw);
- // Register the on-disk ground truth either way — the echo guard and
- // the no-op-write suppression both key off it.
- markKnownDiskContent(path, ensured.changed ? ensured.content : raw);
- if (ensured.changed) {
- await invoke("write_file", {
- path,
- content: ensured.content,
- }).catch(() => {});
- }
- const { prefix, body } = splitFrontmatter(ensured.content);
- // Synchronous handoff (the editor-app effect re-applies it after the
- // commit; this closes the gap in between).
- setCollabDocId(ensured.id);
- setMeta({
- kind: "tauri",
- path,
- name,
- docId: ensured.id,
- frontmatter: prefix,
- });
- setContent(body);
- setVersion((n) => n + 1);
- setView("editor");
- }, []);
+ const loadTauriPath = useCallback(
+ async (path: string) => {
+ const doc = await readTauriDoc(path);
+ setCollabDocId(doc.meta.kind === "tauri" ? (doc.meta.docId ?? "") : "");
+ store.openInActiveTab(doc.meta, doc.content);
+ },
+ [store],
+ );
// Drag-drop onto a Tauri window: claim the path in Rust's WindowFiles so
// close-behavior and open_or_reuse stay consistent, then load it.
@@ -111,69 +118,34 @@ export function useDocumentLoaders() {
const loadRemote = useCallback(
async (input: string) => {
- const resolved = resolveMarkdownUrl(input);
- if (!resolved) {
+ const doc = await readRemoteDoc(input);
+ if (!doc) {
applyBlank();
return;
}
- try {
- const fileContent = await fetchMarkdown(
- resolved.url,
- resolved.headers,
- );
- if (isTauri()) {
- // Remote docs get an identity too (kept in-memory, no
- // disk write until the user saves) so they can host a
- // collaboration session like any other document.
- const ensured = ensureDomdId(fileContent);
- const { prefix, body } = splitFrontmatter(ensured.content);
- setCollabDocId(ensured.id);
- setMeta({
- kind: "tauri",
- path: null,
- name: resolved.filename,
- docId: ensured.id,
- frontmatter: prefix,
- });
- setContent(body);
- setVersion((n) => n + 1);
- setView("editor");
- return;
- }
- setMeta({
- kind: "web",
- name: resolved.filename,
- handle: null,
- });
- setContent(fileContent);
- setVersion((n) => n + 1);
- setView("editor");
- } catch {
- applyBlank();
+ if (doc.meta.kind === "tauri" && doc.meta.docId) {
+ setCollabDocId(doc.meta.docId);
}
+ store.openInActiveTab(doc.meta, doc.content);
},
- [applyBlank],
+ [store, applyBlank],
);
const loadFromFile = useCallback(
async (file: File, handle: FileSystemFileHandle | null) => {
const fileContent = await file.text();
- setMeta({ kind: "web", name: file.name, handle });
- setContent(fileContent);
- // setContent("");
- // setTimeout(() => {
- // window.mockAI(fileContent);
- // }, 200);
- setVersion((n) => n + 1);
- setView("editor");
+ store.openInActiveTab(
+ { kind: "web", name: file.name, handle },
+ fileContent,
+ );
},
- [],
+ [store],
);
return {
meta,
setMeta,
- content,
+ runtime,
version,
view,
applyBlank,
@@ -184,3 +156,110 @@ export function useDocumentLoaders() {
loadFromFile,
};
}
+
+export interface LoadedDoc {
+ meta: FileMeta;
+ /** Body only — the frontmatter block lives in `meta.frontmatter` and is
+ * re-prepended by save-document. The editor never sees it. */
+ content: string;
+}
+
+/**
+ * Read a file from disk. Guarantees a frontmatter domd-id (writing the
+ * injected block back), and registers the on-disk ground truth so the echo
+ * guard and the no-op-write suppression both have a baseline.
+ *
+ * Exported because the tabbed shell loads documents into tabs that are NOT
+ * the active one, where committing to React state would be wrong.
+ */
+export async function readTauriDoc(path: string): Promise {
+ const { invoke } = await tauriCore();
+ const raw = await invoke("read_file", { path }).catch(() => null);
+ const name = path.split("/").pop() ?? path;
+ if (raw === null) {
+ // Unreadable file: open blank, without a doc identity. Do NOT write
+ // anything back — the read failure may be transient.
+ return { meta: { kind: "tauri", path, name, docId: null }, content: "" };
+ }
+ const ensured = ensureDomdId(raw);
+ markKnownDiskContent(path, ensured.changed ? ensured.content : raw);
+ if (ensured.changed) {
+ await invoke("write_file", { path, content: ensured.content }).catch(
+ () => {},
+ );
+ }
+ const { prefix, body } = splitFrontmatter(ensured.content);
+ return {
+ meta: {
+ kind: "tauri",
+ path,
+ name,
+ docId: ensured.id,
+ frontmatter: prefix,
+ },
+ content: body,
+ };
+}
+
+/** Fetch a remote markdown document. Gets an in-memory identity on desktop
+ * (no disk write until saved) so it can be shared like any other. */
+export async function readRemoteDoc(input: string): Promise {
+ const resolved = resolveMarkdownUrl(input);
+ if (!resolved) return null;
+ try {
+ const fileContent = await fetchMarkdown(resolved.url, resolved.headers);
+ if (!isTauri()) {
+ return {
+ meta: { kind: "web", name: resolved.filename, handle: null },
+ content: fileContent,
+ };
+ }
+ const ensured = ensureDomdId(fileContent);
+ const { prefix, body } = splitFrontmatter(ensured.content);
+ return {
+ meta: {
+ kind: "tauri",
+ path: null,
+ name: resolved.filename,
+ docId: ensured.id,
+ frontmatter: prefix,
+ },
+ content: body,
+ };
+ } catch {
+ return null;
+ }
+}
+
+/** A fresh untitled desktop document, for opening in a NEW tab. */
+export function blankTauriDoc(): LoadedDoc {
+ const docId = crypto.randomUUID();
+ return {
+ meta: {
+ kind: "tauri",
+ path: null,
+ name: "Untitled.md",
+ docId,
+ frontmatter: buildFrontmatterBlock(docId),
+ },
+ content: "",
+ };
+}
+
+/**
+ * Re-read a backgrounded tab's file wholesale. Only ever called for tabs with
+ * no unsaved edits: a dirty tab's divergence has to be merged, not
+ * overwritten, which is the mounted DiskReconciler's job.
+ */
+export async function rereadTauriDoc(path: string): Promise {
+ const { invoke } = await tauriCore();
+ const raw = await invoke("read_file", { path }).catch(() => null);
+ if (raw === null) return null;
+ markKnownDiskContent(path, raw);
+ const { prefix, body, id } = splitFrontmatter(raw);
+ const name = path.split("/").pop() ?? path;
+ return {
+ meta: { kind: "tauri", path, name, docId: id, frontmatter: prefix },
+ content: body,
+ };
+}
diff --git a/features/editor/hooks/use-tab-drag-reorder.ts b/features/editor/hooks/use-tab-drag-reorder.ts
new file mode 100644
index 0000000..7fb3082
--- /dev/null
+++ b/features/editor/hooks/use-tab-drag-reorder.ts
@@ -0,0 +1,78 @@
+"use client";
+import { useCallback, useRef, useState } from "react";
+
+interface DragState {
+ tabId: string;
+ startX: number;
+ currentIndex: number;
+}
+
+export function useTabDragReorder(
+ tabIds: string[],
+ onReorder: (fromIndex: number, toIndex: number) => void,
+) {
+ const [draggingId, setDraggingId] = useState(null);
+ const dragRef = useRef(null);
+ const tabRectsRef = useRef