feat(todo): edit the file right in the TODO preview (#1129)

The TODO preview is an editor on the file, as in IntelliJ: a CodeMirror view attached to the real document, so edits share the buffer with editor tabs and Save All saves them; no editing switch. The caret lands on the selected item, also when another item of the same file is selected. Read-only files fall back to the read-only preview. Dirty buffers are held rather than released (releasing would auto-save or drop the edits), and previewText reads the held text.

Co-authored-by: qtvbwfn <5848026+qtvbwfn@users.noreply.github.com>
This commit is contained in:
Qing
2026-11-04 08:58:53 +08:00
committed by GitHub
co-authored by qtvbwfn
parent 893cbede9c
commit 2f2d3a25ae
8 changed files with 340 additions and 3 deletions
+2
View File
@@ -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;
+1
View File
@@ -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),
+128
View File
@@ -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<string, { text: string; dirty?: boolean; readOnly?: boolean }>) {
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(<PreviewEditor path="/p/a.ts" line={0} col={3} fallbackLoad={previewText} />);
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(<PreviewEditor path="/p/two.ts" line={0} col={3} fallbackLoad={previewText} />);
const content = await waitFor(() => {
const el = document.querySelector<HTMLElement>(".cm-content");
expect(el).toBeTruthy();
return el!;
});
const view = EditorView.findFromDOM(content)!;
expect(view.state.selection.main.head).toBe(3);
rerender(<PreviewEditor path="/p/two.ts" line={2} col={3} fallbackLoad={previewText} />);
expect(EditorView.findFromDOM(document.querySelector<HTMLElement>(".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(<PreviewEditor path="/p/ro.ts" line={0} col={0} fallbackLoad={previewText} />);
// 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(<PreviewEditor path="/p/clean.ts" line={0} col={0} fallbackLoad={previewText} />);
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(<PreviewEditor path="/p/dirty.ts" line={0} col={7} fallbackLoad={previewText} />);
const content = await waitFor(() => {
const el = document.querySelector<HTMLElement>(".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(<PreviewEditor path="/p/held.ts" line={0} col={0} fallbackLoad={previewText} />);
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([]);
});
});
+157
View File
@@ -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<string, DocumentAttachment>();
/** 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<string>;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const [error, setError] = useState<string | null>(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 <SourcePreview path={path} line={line} highlights={highlights} load={fallbackLoad} />;
if (error)
return (
<div className={styles.previewError}>
<T>{error}</T>
</div>
);
return <div ref={hostRef} className={styles.previewEditor} />;
}
+12
View File
@@ -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);
}
+20
View File
@@ -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(<TodoView />);
// 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");
});
});
+12 -2
View File
@@ -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 <SourcePreview path={item?.path ?? null} line={item?.line ?? 0} highlights={highlights} load={previewText} />;
// 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 (
<PreviewEditor
path={item?.path ?? null}
line={item?.line ?? 0}
col={item?.col ?? 0}
highlights={highlights}
fallbackLoad={previewText}
/>
);
}
+8 -1
View File
@@ -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<string> {
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));
}