mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user