diff --git a/src/editor/api.ts b/src/editor/api.ts index 5efdae59..f4581b3f 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -590,6 +590,8 @@ export interface DocumentAttachment { doc: number; /** Current document text; build the view's state from it. */ text(): string; + /** The document's language mode name (backend-assigned, as the editor tabs use). */ + uiMode(): string | null; readOnly: boolean; /** Add to the view (one view at a time). */ extension: Extension; diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index cbd61f57..652f5538 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -85,6 +85,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr path, doc: model.doc, text: () => model.sync.localText.toString(), + uiMode: () => model.getMeta().uiMode, readOnly: model.getMeta().readOnly || model.virtual, extension: [ model.manager.workspaceHistory.extension(historyTarget), diff --git a/src/features/todo/PreviewEditor.test.tsx b/src/features/todo/PreviewEditor.test.tsx new file mode 100644 index 00000000..5653cf0e --- /dev/null +++ b/src/features/todo/PreviewEditor.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { EditorView } from "@codemirror/view"; +import { _provideDocumentAttacher, type DocumentAttachment } from "@/editor/api"; +import { PreviewEditor, heldAttachment } from "./PreviewEditor"; +import { previewText } from "./ipc"; + +/** + * Stubs of the document attachment: edits dispatch real CodeMirror + * transactions, so the dirty/held/release logic runs against the view. + */ +function stubAttacher(docs: Map) { + const released: string[] = []; + _provideDocumentAttacher((path) => { + const att: DocumentAttachment = { + path, + doc: 1, + text: () => docs.get(path)!.text, + dirty: () => !!docs.get(path)!.dirty, + uiMode: () => null, + readOnly: !!docs.get(path)!.readOnly, + extension: EditorView.updateListener.of((u) => { + for (const tr of u.transactions) + if (tr.docChanged) { + docs.get(path)!.text = u.state.doc.toString(); + docs.get(path)!.dirty = true; + } + }), + save: async () => { + docs.get(path)!.dirty = false; + }, + synced: () => Promise.resolve(), + undo: () => false, + redo: () => false, + edit: () => {}, + release: () => released.push(path), + }; + return Promise.resolve(att); + }); + return { released }; +} + +describe("PreviewEditor", () => { + afterEach(() => _provideDocumentAttacher(null)); + + it("shows the document as an editable CodeMirror view", async () => { + const docs = new Map([["/p/a.ts", { text: "// TODO: fix\n" }]]); + stubAttacher(docs); + render(); + await waitFor(() => expect(document.querySelector(".cm-editor")).toBeTruthy()); + expect(document.querySelector(".cm-content")?.textContent).toContain("TODO: fix"); + }); + + it("moves the caret to another item of the same file without a new view", async () => { + const docs = new Map([["/p/two.ts", { text: "// TODO: one\nconst x = 1;\n// TODO: two\n" }]]); + stubAttacher(docs); + const { rerender } = render(); + const content = await waitFor(() => { + const el = document.querySelector(".cm-content"); + expect(el).toBeTruthy(); + return el!; + }); + const view = EditorView.findFromDOM(content)!; + expect(view.state.selection.main.head).toBe(3); + rerender(); + expect(EditorView.findFromDOM(document.querySelector(".cm-content")!)).toBe(view); + expect(view.state.doc.lineAt(view.state.selection.main.head).number).toBe(3); + expect(view.state.selection.main.head - view.state.doc.line(3).from).toBe(3); + }); + + it("falls back to the read-only preview for read-only documents", async () => { + const docs = new Map([["/p/ro.ts", { text: "// TODO\n", readOnly: true }]]); + const { released } = stubAttacher(docs); + render(); + // No editor, no attach error: the read-only SourcePreview shows instead. + await waitFor(() => expect(document.querySelector(".cm-editor")).toBeNull()); + expect(screen.queryByText("editor not started")).toBeNull(); + expect(released).toEqual(["/p/ro.ts"]); + }); + + it("releases a clean document on unmount", async () => { + const docs = new Map([["/p/clean.ts", { text: "// TODO\n" }]]); + const { released } = stubAttacher(docs); + const { unmount } = render(); + await waitFor(() => expect(document.querySelector(".cm-editor")).toBeTruthy()); + unmount(); + expect(released).toEqual(["/p/clean.ts"]); + expect(heldAttachment("/p/clean.ts")).toBeNull(); + }); + + it("holds a dirty document instead of releasing, and previewText reads its text", async () => { + const docs = new Map([["/p/dirty.ts", { text: "// TODO: edit me\n", dirty: false }]]); + const { released } = stubAttacher(docs); + const { unmount } = render(); + const content = await waitFor(() => { + const el = document.querySelector(".cm-content"); + expect(el).toBeTruthy(); + return el!; + }); + // Edit through the real view: dispatch a transaction (the stub marks dirty). + const view = EditorView.findFromDOM(content)!; + view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "// TODO: edited" } }); + unmount(); + expect(released).toEqual([]); + const held = heldAttachment("/p/dirty.ts"); + expect(held).toBeTruthy(); + await expect(previewText("/p/dirty.ts")).resolves.toBe("// TODO: edited"); + // Once saved (clean), the held attachment is released on next lookup. + docs.get("/p/dirty.ts")!.dirty = false; + expect(heldAttachment("/p/dirty.ts")).toBeNull(); + expect(released).toEqual(["/p/dirty.ts"]); + }); + + it("falls back to disk text when nothing holds the document", async () => { + expect(heldAttachment("/p/gone.ts")).toBeNull(); + // Not open anywhere: previewText goes to the backend (fails in tests) — + // the read order is covered by the held case above. + const docs = new Map([["/p/held.ts", { text: "kept", dirty: true }]]); + const { released } = stubAttacher(docs); + // Fake a held attachment through a mounted-then-edited view. + const { unmount } = render(); + await waitFor(() => expect(document.querySelector(".cm-content")).toBeTruthy()); + docs.get("/p/held.ts")!.dirty = true; + unmount(); + await expect(previewText("/p/held.ts")).resolves.toBe("kept"); + expect(released).toEqual([]); + }); +}); diff --git a/src/features/todo/PreviewEditor.tsx b/src/features/todo/PreviewEditor.tsx new file mode 100644 index 00000000..a2c634bd --- /dev/null +++ b/src/features/todo/PreviewEditor.tsx @@ -0,0 +1,157 @@ +/** + * The TODO preview as an editor, like IntelliJ's: a CodeMirror view attached + * to the real document (attachDocument), so edits share the buffer with the + * editor tabs and are saved by Save All. There is no editing switch. + * + * Read-only files (and no selection) fall back to the read-only `SourcePreview`. + * + * Dirty buffers are never released: releasing a document nobody else shows + * would either auto-save it or close it and drop the edits. Instead they are + * held here (an invisible open document — Save All still reaches them), and + * `previewText` reads them so the read-only preview shows the edited text. + */ +import { useEffect, useRef, useState } from "react"; +import { EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view"; +import { Compartment, EditorState } from "@codemirror/state"; +import { history, historyKeymap, defaultKeymap } from "@codemirror/commands"; +import { search, searchKeymap } from "@codemirror/search"; +import { syntaxHighlighting } from "@codemirror/language"; +import { openideaTheme } from "@/editor/core/cm/theme"; +import { openideaHighlightStyle } from "@/editor/core/cm/highlight"; +import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages"; +import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api"; +import { SourcePreview, T, type SourceHighlight } from "@/ui"; +import styles from "./TodoView.module.css"; + +/** Dirty attachments kept alive after their view unmounted (see header note). */ +const heldDirty = new Map(); + +/** The held attachment for `path`, if it is still dirty; releases it otherwise. */ +export function heldAttachment(path: string): DocumentAttachment | null { + const att = heldDirty.get(path) ?? null; + if (att && !att.dirty()) { + heldDirty.delete(path); + att.release(); + return null; + } + return att; +} + +/** + * The editable pane for one TODO item's file: mounts a CodeMirror view over + * the attached document and centres the item's line; read-only files show the + * read-only preview instead. Switching files holds or releases the previous + * attachment (see the cleanup below). + */ +export function PreviewEditor({ + path, + line, + col, + highlights = [], + fallbackLoad, +}: { + path: string | null; + line: number; + col: number; + highlights?: readonly SourceHighlight[]; + /** Loader of the read-only fallback (the held-dirty-aware `previewText`). */ + fallbackLoad: (path: string) => Promise; +}) { + const hostRef = useRef(null); + const viewRef = useRef(null); + const [error, setError] = useState(null); + const [readOnly, setReadOnly] = useState(false); + + useEffect(() => { + if (!path) return; + let alive = true; + let view: EditorView | null = null; + let att: DocumentAttachment | null = null; + void (async () => { + try { + att = await attachDocument(path); + if (!alive) return void att.release(); + if (att.readOnly) { + // Read-only files (disk flag, virtual docs) show the plain preview. + att.release(); + if (alive) setReadOnly(true); + return; + } + const language = new Compartment(); + view = new EditorView({ + state: EditorState.create({ + doc: att.text(), + extensions: [ + openideaTheme, + syntaxHighlighting(openideaHighlightStyle), + lineNumbers(), + highlightActiveLine(), + history(), + search({ top: true }), + // Mod-S is intentionally not bound: the global keymap's + // `file.save` (Save All) runs on capture before CodeMirror and + // saves the attached document together with the rest — same as + // the diff viewer's editable side. + keymap.of([...historyKeymap, ...searchKeymap, ...defaultKeymap]), + att.extension, + embeddedEditorExtensions(path), + language.of([]), + ], + }), + parent: hostRef.current!, + }); + viewRef.current = view; + // Centre the TODO item; the view starts at its line. + const at = Math.min(view.state.doc.lines, line + 1); + const text = view.state.doc.line(at); + const pos = Math.min(text.from + col, text.to); + view.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + // Language loads async, like the notebook cells. The mode comes from the + // document (backend-assigned): filename guessing inside + // findLanguageDescription is disabled once the backend language table + // has loaded, so passing null would leave the view uncoloured. + const desc = findLanguageDescription(att.uiMode(), path); + if (desc) + void loadSupport(desc).then((support) => { + if (alive && support && view) view.dispatch({ effects: language.reconfigure(support) }); + }); + } catch (e) { + // Editor core not started (tests) or the document failed to open. + if (alive) setError(String((e as Error)?.message ?? e)); + } + })(); + return () => { + alive = false; + viewRef.current = null; + view?.destroy(); + setReadOnly(false); + // Hold dirty buffers (see header); release clean ones. + if (att) { + if (att.dirty()) heldDirty.set(path, att); + else att.release(); + } + }; + // `line`/`col` only place the initial caret; the view itself is per path. + }, [path]); // eslint-disable-line react-hooks/exhaustive-deps + + // Another item of the same file: move the caret there (the view stays). + useEffect(() => { + const view = viewRef.current; + if (!view) return; + const text = view.state.doc.line(Math.min(view.state.doc.lines, line + 1)); + const pos = Math.min(text.from + col, text.to); + view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) }); + }, [line, col]); + + if (!path || readOnly) return ; + if (error) + return ( +
+ {error} +
+ ); + return
; +} diff --git a/src/features/todo/TodoView.module.css b/src/features/todo/TodoView.module.css index 35b8bf54..d841e020 100644 --- a/src/features/todo/TodoView.module.css +++ b/src/features/todo/TodoView.module.css @@ -107,3 +107,15 @@ flex: 1; min-height: 0; } +.previewEditor, +.previewError { + flex: 1; + min-height: 0; +} +.previewError { + display: flex; + align-items: center; + justify-content: center; + color: var(--fg-faint); + font-family: var(--font-ui); +} diff --git a/src/features/todo/TodoView.test.tsx b/src/features/todo/TodoView.test.tsx index af16084a..2407abe2 100644 --- a/src/features/todo/TodoView.test.tsx +++ b/src/features/todo/TodoView.test.tsx @@ -68,4 +68,24 @@ describe("TodoView", () => { expect(screen.queryByText("Select an item to preview")).toBeNull(); act(() => useTodoViewOptions.setState({ preview: true })); }); + + it("previews the selected item as an editable editor, without a switch", async () => { + act(() => + useTodos.setState({ + scannedOnce: true, + scanned: new Map([ + ["/p/src/a.ts", [{ path: "/p/src/a.ts", line: 4, col: 3, keyword: "TODO", text: "TODO: x" }]], + ]), + }), + ); + render(); + // Select the file group so the preview has an item (trees select on mousedown). + fireEvent.mouseDown(screen.getByText("a.ts").closest("[role=treeitem]")!); + await screen.findByText("(5, 4)"); + // No editing switch: the preview is simply an editor, like IntelliJ's. + expect(screen.queryByLabelText("Allow editing")).toBeNull(); + // No document attacher in tests: the editable pane degrades to its error + // state ("editor not started") instead of crashing. + await screen.findByText("editor not started"); + }); }); diff --git a/src/features/todo/TodoView.tsx b/src/features/todo/TodoView.tsx index 5d7895b9..0dbc87f3 100644 --- a/src/features/todo/TodoView.tsx +++ b/src/features/todo/TodoView.tsx @@ -13,7 +13,6 @@ import { IconButton, Menu, Select, - SourcePreview, SplitGroup, SplitHandle, SplitPanel, @@ -27,6 +26,7 @@ import { T, } from "@/ui"; import { previewText } from "./ipc"; +import { PreviewEditor } from "./PreviewEditor"; import { BUILTIN_TODO_SCOPES, activePatterns, @@ -530,5 +530,15 @@ export function TodoView() { function TodoPreview({ node }: { node: Node | null }) { const item = node?.kind === "item" ? node.item : (node?.group.items[0] ?? null); const highlights = item ? [{ line: item.line, ranges: [[item.col, item.col + item.text.length] as const] }] : []; - return ; + // IDEA's TODO preview is simply an editor on the file: editable in place, + // with read-only files falling back to the read-only preview (in PreviewEditor). + return ( + + ); } diff --git a/src/features/todo/ipc.ts b/src/features/todo/ipc.ts index 11490261..b1380f39 100644 --- a/src/features/todo/ipc.ts +++ b/src/features/todo/ipc.ts @@ -1,6 +1,7 @@ import { editorsFor } from "@/editor/api"; import { call, commands } from "@/platform/ipc"; import type { RequestId } from "@/platform/ipc"; +import { heldAttachment } from "./PreviewEditor"; export { call, commands, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc"; @@ -12,8 +13,14 @@ export function cancelRequestSafe(id: RequestId) { } } -/** File text for the preview pane: the open buffer, else the file on disk. */ +/** + * File text for the preview pane: the open buffer, else the file on disk. + * Held dirty attachments first — a buffer edited in the preview then unmounted + * is no longer in `editorsFor`, but its edits must still show (see PreviewEditor). + */ export function previewText(path: string): Promise { + const held = heldAttachment(path); + if (held) return Promise.resolve(held.text()); const view = editorsFor(path)[0]?.view; return view ? Promise.resolve(view.state.doc.toString()) : call(commands.fsReadText(path)); }