Skip to content
13 changes: 13 additions & 0 deletions common/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"menu": {
"about": "About DOMD",
"checkUpdates": "Check for Updates...",
"newTab": "New Tab",
"newWindow": "New Window",
"openUrl": "Open URL...",
"closeWindow": "Close Window",
Expand Down Expand Up @@ -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"
}
}
13 changes: 13 additions & 0 deletions common/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"menu": {
"about": "DOMD について",
"checkUpdates": "アップデートを確認...",
"newTab": "新しいタブ",
"newWindow": "新規ウインドウ",
"openUrl": "URL を開く...",
"closeWindow": "ウインドウを閉じる",
Expand Down Expand Up @@ -404,5 +405,17 @@
"locate": "編集対象のテキストを特定できませんでした——言い換えてみてください"
},
"localSelf": "自分"
},
"tabs": {
"newTab": "新しいタブ",
"closeTab": "タブを閉じる",
"shrinkToFit": "幅に合わせる",
"scrollHorizontally": "横スクロール",
"untitled": "Untitled.md",
"unsavedTitle": "未保存の変更",
"unsavedBody": "閉じる前に「{{name}}」の変更を保存しますか?",
"unsavedSave": "保存",
"unsavedDontSave": "保存しない",
"unsavedCancel": "キャンセル"
}
}
13 changes: 13 additions & 0 deletions common/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"menu": {
"about": "关于 DOMD",
"checkUpdates": "检查更新...",
"newTab": "新建标签页",
"newWindow": "新建窗口",
"openUrl": "打开 URL...",
"closeWindow": "关闭窗口",
Expand Down Expand Up @@ -404,5 +405,17 @@
"locate": "未能定位要修改的文本——换个说法试试"
},
"localSelf": "我"
},
"tabs": {
"newTab": "新建标签页",
"closeTab": "关闭标签页",
"shrinkToFit": "自适应宽度",
"scrollHorizontally": "横向滚动",
"untitled": "Untitled.md",
"unsavedTitle": "未保存的更改",
"unsavedBody": "关闭前保存对“{{name}}”的更改吗?",
"unsavedSave": "保存",
"unsavedDontSave": "不保存",
"unsavedCancel": "取消"
}
}
7 changes: 5 additions & 2 deletions features/collaboration/lib/collab-db-tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
103 changes: 90 additions & 13 deletions features/editor/components/editor-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
DOMDProvider,
EditorStore,
useEditorStore,
useEditorStoreApi,
useFormatState,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<SidePanelProvider>
<EditorAppContent />
</SidePanelProvider>
// 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.
<TabStoreProvider initialProps={{ createRuntime }}>
<SidePanelProvider>
<EditorAppContent />
</SidePanelProvider>
</TabStoreProvider>
);
}

Expand All @@ -273,7 +307,7 @@ function EditorAppContent() {
const {
meta,
setMeta,
content,
runtime,
version,
view,
applyBlank,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -667,10 +713,16 @@ function EditorAppContent() {

return (
<div
// With tabs the root becomes the column that gives the tab bar its
// row; the editor then renders `embedded` (a flex child) instead
// of covering the viewport. Without tabs the root stays layout-free
// and the editor keeps its own `fixed inset-0`.
className={isWeb ? undefined : "fixed inset-0 flex flex-col"}
onDragOver={isWeb ? dragHandlers.onDragOver : undefined}
onDragLeave={isWeb ? dragHandlers.onDragLeave : undefined}
onDrop={isWeb ? dragHandlers.onDrop : undefined}
>
{isWeb ? null : <TabBar />}
{dragging ? (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-accent/90 pointer-events-none">
<div className="text-lg font-medium text-accent-content">
Expand All @@ -679,19 +731,25 @@ function EditorAppContent() {
</div>
) : 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. */}
<DOMDProvider
key={version}
editable={true}
placeholder={t("editor.placeholder")}
initMd={content}
imageLoader={collabImageLoader}
codeTokenizer={tokenize}
inlineRules={appInlineRules}
codeBeautify={beautify}
store={runtime}
renderComponent={CustomRender}
mode="rich"
>
<ImageDropHandler />
{/* 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 : <TabFocusOnSwitch enabled={switchedTabs} />}
{/* Hydrates the persisted display mode + binds Cmd+/ —
the "more" menu entry (web) and this keystroke (both
runtimes) call the same toggle. */}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 ? (
<TabCloseModal
name={closeRequest.name}
onChoose={decideUnsaved}
/>
) : null}

{showShareModal ? (
<ShareModal
room={collabRoom}
Expand Down
43 changes: 38 additions & 5 deletions features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ export function Editor({
aiAvailable = false,
aiActive = false,
sidePanel = null,
tabId,
embedded = false,
onDirtyChange,
}: {
meta: FileMeta;
onMetaUpdate: (meta: FileMeta) => void;
Expand All @@ -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();
Expand Down Expand Up @@ -445,31 +458,33 @@ 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<string>("");
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(
() => { },
);
});
}, 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<string>("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 }) => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -529,7 +548,17 @@ export function Editor({
// context only, no DOM wrapper — same posture as DOMDProvider.
<TocStoreProvider>
<SearchStoreProvider>
<div className="domd-editor-shell fixed inset-0 bg-base-100 overflow-hidden">
{/* `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. */}
<div
className={
embedded
? "domd-editor-shell relative flex-1 min-h-0 bg-base-100 overflow-hidden"
: "domd-editor-shell fixed inset-0 bg-base-100 overflow-hidden"
}
>
{/* Format shortcuts (⌘1/⌘K/⌥⌘C/…) live outside the top bar: the
desktop build renders no web top bar, and they must work
there too. */}
Expand Down Expand Up @@ -769,6 +798,10 @@ export function Editor({
<div
ref={scrollAreaRef}
className="domd-editor-scroll flex-1 min-h-0 overflow-y-auto"
// Only the ACTIVE tab has a mounted editor, so this
// marks the one scroller whose position
// TabEditorBridge saves and restores across switches.
data-tab-scroll-container={tabId ?? undefined}
onClick={(e) => {
if (domdRef.current?.contains(e.target as Node))
return;
Expand Down
Loading
Loading