feat(search): edit files in the Find in Files preview (#2031)

Closes #2004. The Find in Files preview was a read-only standalone view with its own copy of the file. It now attaches to the shared document the editor tabs use (editor/api attachDocument with shared history): edits show up in open tabs and mark them modified, undo/redo go through the text tab's history, Save works, and match highlights follow the edits. Library sources, archives, large/binary files stay read-only; Enter in the preview inserts a newline instead of opening the result.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 01:13:13 +08:00
1 джерело c1e32b091a
коміт a40a909db5
13 files changed
+531 -100

No files matched your search

+24 -1
Переглянути файл
@@ -1,4 +1,4 @@
import { doubleShift, expect, ipcCalls, stubDesktop, test } from "./fixtures";
import { doubleShift, expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
test("double Shift opens Search Everywhere with its tabs; Escape closes it", async ({ workbench: page }) => {
await doubleShift(page);
@@ -45,6 +45,8 @@ test("Search Everywhere finds a file and opens it", async ({ workbench: page })
});
test("Find in Files streams matches by file and opens the chosen one at its line", async ({ workbench: page }) => {
const other = await openFromTree(page, "settings.gradle.kts");
const originalOther = await other.locator(".cm-content").textContent();
await stubDesktop(page);
await page.evaluate(
({ root, service }) => {
@@ -101,6 +103,27 @@ test("Find in Files streams matches by file and opens the chosen one at its line
.toMatchObject({
pattern: "OrderService",
});
const preview = dialog.getByRole("region", { name: "Preview", exact: true }).locator(".cm-content");
await expect(preview).toHaveAttribute("contenteditable", "true");
const originalPreview = await preview.textContent();
await preview.focus();
await page.keyboard.press("Meta+a");
await page.keyboard.insertText("OrderService preview edit");
await expect(preview).toHaveText("OrderService preview edit");
await expect(other.locator(".cm-content")).toHaveText(originalOther!);
await page.keyboard.press("Meta+Backspace");
await expect(preview).toHaveText("");
await expect(other.locator(".cm-content")).toHaveText(originalOther!);
await page.keyboard.press("Meta+z");
await expect(preview).toHaveText(originalPreview!);
await page.keyboard.press("Meta+a");
await page.keyboard.insertText("OrderService preview edit");
await page.keyboard.press("Enter");
await expect(dialog).toBeVisible();
await expect(preview.locator(".cm-line")).toHaveCount(2);
await page.keyboard.press("Meta+z");
await page.keyboard.press("Meta+z");
await expect(preview).toHaveText(originalPreview!);
await matches.getByRole("option", { name: /import com\.acme\.orders\.service\.OrderService/ }).dblclick();
await expect(page.getByRole("tab", { name: "build.gradle.kts" })).toHaveAttribute("aria-selected", "true");
const caretLine = () =>
+24 -12
Переглянути файл
@@ -627,7 +627,8 @@ export function isLibraryPath(path: string): boolean {
* A live view of an open document for a CodeMirror view that is not an editor
* tab (e.g. the editable side of a diff). Edits sync both ways with the
* document shared by all editors of the file, go to the backend, and are saved
* by Save All; the view keeps its own undo history, like a split pane.
* by Save All. With `sharedHistory`, typing uses the text tab's history;
* otherwise the view keeps its own history, like a split pane.
*/
export interface DocumentAttachment {
path: string;
@@ -638,6 +639,10 @@ export interface DocumentAttachment {
/** The document's language mode name (backend-assigned, as the editor tabs use). */
uiMode(): string | null;
readOnly: boolean;
/** Last view state, kept current while unmounted; use it when remounting this attachment. */
editorState(): EditorState | null;
/** Dispatch typing through the document history when `sharedHistory` was requested. */
dispatchTransactions(transactions: readonly Transaction[], view: EditorView): void;
/** Add to the view (one view at a time). */
extension: Extension;
save(options?: { exact?: boolean }): Promise<void>;
@@ -663,16 +668,22 @@ export interface DocumentAttachment {
release(): void;
}
let documentAttacher: ((path: string) => Promise<DocumentAttachment>) | null = null;
export interface DocumentAttachmentOptions {
/** Record typing in the text tab's history when one exists. Use `dispatchTransactions` on the view. */
sharedHistory?: boolean;
}
let documentAttacher: ((path: string, options?: DocumentAttachmentOptions) => Promise<DocumentAttachment>) | null =
null;
/** Opens (or joins) the document of `path` for a non-tab view. */
export function attachDocument(path: string): Promise<DocumentAttachment> {
export function attachDocument(path: string, options?: DocumentAttachmentOptions): Promise<DocumentAttachment> {
if (!documentAttacher) return Promise.reject(new Error("editor not started"));
return documentAttacher(path);
return documentAttacher(path, options);
}
/** Called by editor/core only. */
export function _provideDocumentAttacher(attacher: ((path: string) => Promise<DocumentAttachment>) | null) {
export function _provideDocumentAttacher(attacher: typeof documentAttacher) {
documentAttacher = attacher;
}
@@ -698,21 +709,22 @@ export function _languageMode(name: string): LanguageDescription | undefined {
return languageModes.get(name);
}
// --- read-only code previews ------------------------------------------------------
// --- code previews ------------------------------------------------------
let codePreviewProvider: ((path: string) => Promise<Extension>) | null = null;
let codePreviewProvider: ((path: string, editable?: boolean) => Promise<Extension>) | null = null;
/**
* Extensions for a read-only CodeMirror view showing `path` like an editor tab
* Extensions for a CodeMirror view showing `path` like an editor tab
* (editor font and colours, line numbers, syntax highlighting): previews
* outside editor tabs such as Find in Files. Empty before editor/core starts.
* outside editor tabs such as Find in Files. Read-only unless `editable` is true.
* Empty before editor/core starts.
*/
export function codePreviewExtensions(path: string): Promise<Extension> {
return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]);
export function codePreviewExtensions(path: string, editable = false): Promise<Extension> {
return codePreviewProvider ? codePreviewProvider(path, editable) : Promise.resolve([]);
}
/** @internal editor/core only. */
export function _provideCodePreview(provider: ((path: string) => Promise<Extension>) | null) {
export function _provideCodePreview(provider: typeof codePreviewProvider) {
codePreviewProvider = provider;
}
+6 -5
Переглянути файл
@@ -1,7 +1,7 @@
/**
* Read-only code previews outside editor tabs (Find in Files, bookmarks): the
* Code previews outside editor tabs (Find in Files, bookmarks): the
* editor's font, colours, line numbers and syntax highlighting, without
* editing, folding or contributions.
* folding or contributions. Editing is opt-in.
*/
import { EditorState, type Extension } from "@codemirror/state";
import { EditorView, highlightSpecialChars, lineNumbers } from "@codemirror/view";
@@ -20,6 +20,7 @@ export async function previewExtensions(
path: string,
settings: EditorSettings | undefined,
backend?: DocBackend,
editable = false,
): Promise<Extension> {
const open = editorsFor(path)[0]?.ctx;
// A closed file: its own mode (an Angular project's templates) like an editor would show it.
@@ -32,9 +33,9 @@ export async function previewExtensions(
lineNumbers(),
highlightSpecialChars(),
syntaxHighlighting(openideaHighlightStyle),
EditorState.readOnly.of(true),
EditorView.editable.of(false),
// Not editable, so selection and Copy need the text opted back in and a tab stop.
EditorState.readOnly.of(!editable),
EditorView.editable.of(editable),
// Read-only previews still support selection, Copy and keyboard focus.
EditorView.contentAttributes.of({ "aria-label": tx`Preview of ${path}`, class: selectableText, tabindex: "0" }),
support ?? [],
];
+4 -2
Переглянути файл
@@ -57,8 +57,10 @@ export function activate(): void | (() => void) {
return !!handle && (await formatDocument(handle.view, { explicit: false, selectionOnly: false })).applied;
},
});
_provideDocumentAttacher((path) => attachDocument(ctrl.manager, path));
_provideCodePreview((path) => previewExtensions(path, ctrl.manager.settings()?.editor, ctrl.manager.backend));
_provideDocumentAttacher((path, options) => attachDocument(ctrl.manager, path, options));
_provideCodePreview((path, editable) =>
previewExtensions(path, ctrl.manager.settings()?.editor, ctrl.manager.backend, editable),
);
const disposers = [
installColorScheme(),
provideEditorArea(EditorAreaView),
+95 -12
Переглянути файл
@@ -3,8 +3,15 @@
* diff): local edits flow view → model → backend and other editors; edits
* from elsewhere are mirrored into the view. See `attachDocument` in editor/api.
*/
import { redo, undo } from "@codemirror/commands";
import { Transaction, type Annotation, type ChangeSet, type StateCommand } from "@codemirror/state";
import { history, historyField, redo, undo } from "@codemirror/commands";
import {
Compartment,
EditorState,
Transaction,
type Annotation,
type ChangeSet,
type StateCommand,
} from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import {
_addEditor,
@@ -14,35 +21,62 @@ import {
remoteChange,
transientChange,
type DocumentAttachment,
type DocumentAttachmentOptions,
type TextEditorHandle,
} from "@/editor/api";
import type { DocAttachmentTarget, DocumentManager } from "./document";
class AttachedView implements DocAttachmentTarget {
view: EditorView | null = null;
forwarding = false;
storedState: EditorState | null = null;
history = new Compartment();
historyState?: () => EditorState | null;
adoptHistory?: (state: EditorState) => void;
applyExternal(changes: ChangeSet, annotations: Annotation<unknown>[]) {
this.view?.dispatch({ changes, annotations });
if (this.forwarding) return;
if (this.view) this.view.dispatch({ changes, annotations });
else if (this.storedState) this.storedState = this.storedState.update({ changes, annotations }).state;
}
}
export async function attachDocument(manager: DocumentManager, path: string): Promise<DocumentAttachment> {
export async function attachDocument(
manager: DocumentManager,
path: string,
options: DocumentAttachmentOptions = {},
): Promise<DocumentAttachment> {
const model = await manager.acquire(path);
const target = new AttachedView();
if (options.sharedHistory) {
target.historyState = () => target.view?.state ?? target.storedState;
target.adoptHistory = (state) => {
const value = state.field(historyField, false);
if (!value) return;
for (const extension of [[], [history(), historyField.init(() => value)]]) {
const effects = target.history.reconfigure(extension);
if (target.view) target.view.dispatch({ effects });
else if (target.storedState) target.storedState = target.storedState.update({ effects }).state;
}
};
}
model.attach(target);
let released = false;
let detachHistory: (() => void) | null = null;
const historyTarget = {
path,
state: () => target.view!.state,
dispatch: (tr: Transaction) => target.view?.dispatch(tr),
state: () => (target.view?.state ?? target.storedState)!,
dispatch: (tr: Transaction) => {
if (target.view) target.view.dispatch(tr);
else target.storedState = tr.state;
},
};
const plugin = ViewPlugin.define((view) => {
target.view = view;
let detachHistory = () => {};
let destroyed = false;
queueMicrotask(() => {
if (!destroyed) detachHistory = model.manager.workspaceHistory.attach(historyTarget);
if (!destroyed && !detachHistory) detachHistory = model.manager.workspaceHistory.attach(historyTarget);
});
const handle: TextEditorHandle = { view, ctx: model.ctx() };
_addEditor(handle);
@@ -59,15 +93,22 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
return {
destroy() {
destroyed = true;
detachHistory();
if (!options.sharedHistory) {
detachHistory?.();
detachHistory = null;
}
_removeEditor(handle);
if (target.view === view) target.view = null;
if (target.view === view) {
target.storedState = view.state;
target.view = null;
}
},
};
});
/** Runs a history command where the document's history is (see `DocumentAttachment.undo`). */
const historyCommand = (command: StateCommand): boolean => {
if (model.ctx().readOnly || released || model.closed) return false;
const slot = model.primarySlot();
if (slot?.view) return command(slot.view);
if (slot)
@@ -86,13 +127,51 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
doc: model.doc,
text: () => model.sync.localText.toString(),
uiMode: () => model.getMeta().uiMode,
readOnly: model.getMeta().readOnly || model.virtual,
get readOnly() {
return model.getMeta().readOnly || model.virtual;
},
editorState: () => target.view?.state ?? target.storedState,
dispatchTransactions: (transactions, view) => {
for (const tr of transactions) {
const slot = options.sharedHistory && model.primarySlot();
if (!slot || !tr.docChanged || isExternalChange(tr) || tr.annotation(transientChange)) {
view.update([tr]);
continue;
}
target.forwarding = true;
try {
view.update([tr]);
slot.dispatchLocal({ changes: tr.changes, userEvent: tr.annotation(Transaction.userEvent) });
} finally {
target.forwarding = false;
}
}
},
extension: [
options.sharedHistory ? target.history.of(history()) : [],
options.sharedHistory
? EditorView.domEventHandlers({
beforeinput: (event) => {
if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return false;
event.preventDefault();
historyCommand(event.inputType === "historyUndo" ? undo : redo);
return true;
},
})
: [],
EditorState.changeFilter.of((tr) => isExternalChange(tr) || !model.ctx().readOnly),
options.sharedHistory
? EditorState.transactionExtender.of((tr) =>
model.primarySlot() && tr.docChanged && !isExternalChange(tr)
? { annotations: Transaction.addToHistory.of(false) }
: null,
)
: [],
model.manager.workspaceHistory.extension(historyTarget),
plugin,
documentSynced.of(() => model.sync.whenIdle()),
EditorView.updateListener.of((u) => {
if (released || model.closed) return;
if (released || model.closed || target.forwarding) return;
for (const tr of u.transactions)
if (tr.docChanged && !isExternalChange(tr) && !tr.annotation(transientChange))
model.onLocal(tr.changes, target);
@@ -104,6 +183,7 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
undo: () => historyCommand(undo),
redo: () => historyCommand(redo),
edit: (changes, userEvent = "input") => {
if (model.ctx().readOnly) return;
// Into the document's history, like a typed edit: the text tab's when there is one.
const slot = model.primarySlot();
if (slot) slot.dispatchLocal({ changes, userEvent });
@@ -112,7 +192,10 @@ export async function attachDocument(manager: DocumentManager, path: string): Pr
release: () => {
if (released) return;
released = true;
detachHistory?.();
detachHistory = null;
target.view = null;
target.storedState = null;
model.detach(target);
},
};
+5
Переглянути файл
@@ -71,6 +71,9 @@ export type Banner = "binary" | "tooLarge" | "readOnly" | "library" | null;
export interface DocAttachmentTarget {
/** Applies changes that are already in the model (another view / the backend). */
applyExternal(changes: ChangeSet, annotations: Annotation<unknown>[]): void;
/** Secondary views that share the text tab's undo history hand it over as tabs open/close. */
historyState?(): EditorState | null;
adoptHistory?(state: EditorState): void;
}
export interface DocMeta {
@@ -310,6 +313,8 @@ export class DocModel {
}
removeSlot(slot: EditorSlot) {
if (this.slots.size === 1)
for (const attachment of this.attachments) attachment.adoptHistory?.(slot.currentState());
this.slots.delete(slot);
if (this.lastFocused === slot) this.lastFocused = null;
if (!this.inUse()) this.manager.release(this);
+22 -12
Переглянути файл
@@ -17,6 +17,7 @@ import {
type ChangeSet,
type TransactionSpec,
} from "@codemirror/state";
import { historyField } from "@codemirror/commands";
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
import {
_addEditor,
@@ -84,27 +85,36 @@ export class EditorSlot {
},
};
const source = init?.from;
const secondary = !model.slots.size
? [...model.attachments]
.map((attachment) => attachment.historyState?.())
.find((state) => state?.field(historyField, false))
: null;
const history = secondary?.field(historyField, false);
this.state = EditorState.create({
doc: model.sync.localText,
selection: source ? source.currentState().selection : undefined,
extensions: baseExtensions({
ctx: model.ctx(),
settings: model.settings(),
info: model.info,
language: model.language,
docLength: model.sync.localText.length,
indent: model.effectiveIndent(),
formatIndent: model.formatIndent(),
wrapOverride: model.softWrapOverride,
extra: this.slotExtensions(),
}),
extensions: [
history ? historyField.init(() => history) : [],
baseExtensions({
ctx: model.ctx(),
settings: model.settings(),
info: model.info,
language: model.language,
docLength: model.sync.localText.length,
indent: model.effectiveIndent(),
formatIndent: model.formatIndent(),
wrapOverride: model.softWrapOverride,
extra: this.slotExtensions(),
}),
],
});
this.contributionsVersion = model.manager.contributionsVersion;
if (source?.view) this.scroll = source.view.scrollSnapshot();
else if (source) this.scroll = source.scroll;
if (init?.reveal) this.pendingReveal = init.reveal;
model.addSlot(this);
this.detachHistory = model.manager.workspaceHistory.attach(this.historyTarget);
this.detachHistory = model.manager.workspaceHistory.attach(this.historyTarget, !history);
}
get ctx(): EditorDocContext {
+39
Переглянути файл
@@ -215,3 +215,42 @@ it("refuses redo when a newly opened affected editor has newer typing", async ()
expect(commands.redoWorkspaceEdit).not.toHaveBeenCalled();
expect(b.currentState().doc.toString()).toBe("old own");
});
it("keeps one refactoring history step when a secondary view hands its history to a tab", async () => {
const att = await attachDocument(manager, "/p/A.java", { sharedHistory: true });
attached = new EditorView({
doc: att.text(),
extensions: [att.extension],
dispatchTransactions: att.dispatchTransactions,
});
await Promise.resolve();
await rename();
const tab = await open("/p/A.java");
expect(undoDepth(tab.currentState())).toBe(1);
historyStep(tab);
await vi.waitFor(() => expect(attached!.state.doc.toString()).toBe("old"));
expect(undoDepth(tab.currentState())).toBe(0);
expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce();
attached.destroy();
attached = null;
att.release();
});
it("keeps refactoring markers current while a secondary view is unmounted", async () => {
const att = await attachDocument(manager, "/p/A.java", { sharedHistory: true });
attached = new EditorView({
doc: att.text(),
extensions: att.extension,
dispatchTransactions: att.dispatchTransactions,
});
await Promise.resolve();
attached.destroy();
attached = null;
await rename();
const tab = await open("/p/A.java");
expect(undoDepth(tab.currentState())).toBe(1);
historyStep(tab);
await vi.waitFor(() => expect(tab.currentState().doc.toString()).toBe("old"));
expect(commands.undoWorkspaceEdit).toHaveBeenCalledOnce();
att.release();
});
+2 -1
Переглянути файл
@@ -59,8 +59,9 @@ export class WorkspaceHistory {
return paths.some((root) => path === root || path.startsWith(`${root}/`));
}
attach(target: WorkspaceHistoryTarget): () => void {
attach(target: WorkspaceHistoryTarget, restore = true): () => void {
this.targets.add(target);
if (!restore) return () => this.targets.delete(target);
const records = [...this.records].filter(([, record]) => this.affects(record.paths, target.path));
for (const [id] of records) this.mark(target, id);
// Build the same done/undone marker stacks as existing editors, without
+184 -10
Переглянути файл
@@ -1,11 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { redo, undo } from "@codemirror/commands";
import { _emitEditorEvent, _provideCodePreview, _provideDocumentAttacher, setLibraryRoots } from "@/editor/api";
import { createMemoryBackend } from "@/editor/core/backend";
import { previewExtensions } from "@/editor/core/cm/preview";
import { attachDocument } from "@/editor/core/model/attachment";
import { DocumentManager, useDocMeta } from "@/editor/core/model/document";
import { EditorSlot, type SlotHost } from "@/editor/core/model/slot";
vi.mock("../find/preview", () => ({
loadPreviewText: async () => "fun a() {}\nval order = 1\norder()\n",
clearPreviewCache: () => {},
}));
let manager: DocumentManager;
let backend: ReturnType<typeof createMemoryBackend>;
const commandKey = /Mac/.test(navigator.platform) ? { metaKey: true } : { ctrlKey: true };
const path = "/p/a.txt";
const original = "fun a() {}\nval order = 1\norder()\n";
const host: SlotHost = {
onFocus: () => {},
onBlur: () => {},
onSelection: () => {},
onUserEdit: () => {},
onContextMenu: () => {},
};
beforeEach(() => {
backend = createMemoryBackend({ [path]: original });
manager = new DocumentManager(backend, { settings: () => ({ files: { autoSave: false } }), notify: vi.fn() });
manager.start();
_provideDocumentAttacher((path, options) => attachDocument(manager, path, options));
_provideCodePreview((path, editable) => previewExtensions(path, undefined, undefined, editable));
});
afterEach(() => {
cleanup();
for (const model of manager.byPath.values()) {
model.setMeta({ dirty: false });
_emitEditorEvent({ type: "docSaved", path: model.path, doc: model.doc });
for (const slot of model.slots) slot.dispose();
}
manager.stop();
_provideDocumentAttacher(null);
_provideCodePreview(null);
useDocMeta.setState({}, true);
});
async function preview(container: HTMLElement): Promise<EditorView> {
await act(() => new Promise((resolve) => setTimeout(resolve, 150)));
let view: EditorView | null = null;
await waitFor(() => {
const content = container.querySelector<HTMLElement>(".cm-content");
expect(content?.textContent).toContain("order");
view = EditorView.findFromDOM(content!);
expect(view).toBeTruthy();
});
return view!;
}
const { CodePreview, previewDecorations } = await import("../find/parts");
@@ -35,11 +82,138 @@ describe("CodePreview", () => {
]);
});
it("shows the file read-only in an editor view with the matches highlighted", async () => {
const { container } = render(<CodePreview path="/p/a.kt" line={1} highlights={[{ line: 1, ranges: [[4, 9]] }]} />);
await act(() => new Promise((r) => setTimeout(r, 150)));
it("shows the file editable in an editor view with the matches highlighted", async () => {
const { container } = render(<CodePreview path={path} line={1} highlights={[{ line: 1, ranges: [[4, 9]] }]} />);
await preview(container);
expect(container.querySelector(".cm-content")?.textContent).toContain("val order = 1");
expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).not.toBe("true");
expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).toBe("true");
expect(container.querySelector(".cm-openidea-findMatch")?.textContent).toBe("order");
});
it.each([false, true])("shares edits, document undo and save with a tab (mounted: %s)", async (mounted) => {
const model = await manager.acquire(path);
const tab = new EditorSlot(model, "tab", host);
if (mounted) tab.mount(document.createElement("div"));
const { container, unmount } = render(
<CodePreview path={path} line={1} highlights={[{ line: 1, ranges: [[4, 9]] }]} />,
);
const view = await preview(container);
act(() => view.dispatch({ changes: { from: 0, insert: "// 😀\n" }, userEvent: "input.type" }));
expect(tab.currentState().doc.toString()).toBe("// 😀\n" + original);
expect(model.getMeta().dirty).toBe(true);
expect(view.state.doc.lineAt(view.posAtDOM(container.querySelector(".cm-openidea-findMatch")!)).number).toBe(3);
// The tab's Undo removes preview typing; the preview follows immediately.
act(() => {
undo({ state: tab.currentState(), dispatch: (tr) => tab.dispatchLocal(tr) });
});
expect(view.state.doc.toString()).toBe(original);
act(() => {
view.focus();
fireEvent.keyDown(view.contentDOM, { key: "z", code: "KeyZ", keyCode: 90, ...commandKey, shiftKey: true });
});
expect(view.state.doc.toString()).toBe("// 😀\n" + original);
act(() => fireEvent.keyDown(view.contentDOM, { key: "s", code: "KeyS", keyCode: 83, ...commandKey }));
await waitFor(() => expect(model.getMeta().dirty).toBe(false));
expect(backend.docs.get(model.doc)?.text).toBe(view.state.doc.toString());
unmount();
tab.dispose();
});
it("keeps dirty text and undo when the preview closes and reopens without a tab", async () => {
const first = render(<CodePreview path={path} line={1} highlights={[]} />);
const view = await preview(first.container);
act(() => view.dispatch({ changes: { from: 0, insert: "edited\n" }, userEvent: "input.type" }));
first.unmount();
await act(() => new Promise((resolve) => setTimeout(resolve, 20)));
expect(manager.get(path)?.sync.localText.toString()).toBe("edited\n" + original);
const second = render(<CodePreview path={path} line={1} highlights={[]} />);
const reopened = await preview(second.container);
expect(reopened.state.doc.toString()).toBe("edited\n" + original);
act(() => {
reopened.focus();
fireEvent.keyDown(reopened.contentDOM, { key: "z", code: "KeyZ", keyCode: 90, ...commandKey });
});
expect(reopened.state.doc.toString()).toBe(original);
});
it("hands undo history to a newly opened tab and back when the tab closes", async () => {
const { container } = render(<CodePreview path={path} line={1} highlights={[]} />);
const view = await preview(container);
act(() => view.dispatch({ changes: { from: 0, insert: "preview\n" }, userEvent: "input.type" }));
const model = manager.get(path)!;
let tab: EditorSlot;
act(() => {
tab = new EditorSlot(model, "new-tab", host);
});
act(() => undo({ state: tab!.currentState(), dispatch: (tr) => tab!.dispatchLocal(tr) }));
expect(view.state.doc.toString()).toBe(original);
act(() => redo({ state: tab!.currentState(), dispatch: (tr) => tab!.dispatchLocal(tr) }));
act(() => tab!.dispose());
act(() => {
view.focus();
fireEvent.keyDown(view.contentDOM, { key: "z", code: "KeyZ", keyCode: 90, ...commandKey });
});
expect(view.state.doc.toString()).toBe(original);
});
it("tracks tab edits and history while the dirty preview is unmounted", async () => {
const model = await manager.acquire(path);
const tab = new EditorSlot(model, "tab", host);
const first = render(<CodePreview path={path} line={1} highlights={[]} />);
const view = await preview(first.container);
act(() => view.dispatch({ changes: { from: 0, insert: "preview\n" }, userEvent: "input.type" }));
first.unmount();
tab.dispatchLocal({ changes: { from: 0, insert: "tab\n" }, userEvent: "input.type" });
tab.dispose();
const second = render(<CodePreview path={path} line={1} highlights={[]} />);
const restored = await preview(second.container);
expect(restored.state.doc.toString()).toBe("tab\npreview\n" + original);
act(() =>
fireEvent(
restored.contentDOM,
new InputEvent("beforeinput", { inputType: "historyUndo", bubbles: true, cancelable: true }),
),
);
expect(restored.state.doc.toString()).toBe(original);
});
it.each(["library", "archive", "binary", "large", "file"])("keeps %s documents read-only", async (kind) => {
const clearRoots = kind === "library" ? setLibraryRoots("preview-test", ["/p"]) : () => {};
const open = backend.open.bind(backend);
vi.spyOn(backend, "open").mockImplementation(async (...args) => ({
...(await open(...args)),
readOnly: kind !== "library",
...(kind === "large" ? { large: true } : {}),
}));
try {
const { container } = render(<CodePreview path={path} line={1} highlights={[]} />);
const view = await preview(container);
expect(view.state.readOnly).toBe(true);
expect(view.contentDOM.getAttribute("contenteditable")).toBe("false");
act(() => view.dispatch({ changes: { from: 0, insert: "bad" }, userEvent: "input.type" }));
expect(view.state.doc.toString()).toBe(original);
expect(manager.get(path)?.getMeta().dirty).toBe(false);
} finally {
clearRoots();
}
});
it("Enter inserts a newline; editing keys stay out of the results; Escape bubbles", async () => {
const parentKey = vi.fn();
const { container } = render(
<div onKeyDown={parentKey}>
<CodePreview path={path} line={0} highlights={[]} />
</div>,
);
const view = await preview(container);
act(() => {
view.focus();
fireEvent.keyDown(view.contentDOM, { key: "Enter", code: "Enter" });
});
expect(view.state.doc.lines).toBe(Text.of(original.split("\n")).lines + 1);
act(() => fireEvent.keyDown(view.contentDOM, { key: "Backspace", code: "Backspace" }));
expect(parentKey).not.toHaveBeenCalled();
act(() => fireEvent.keyDown(view.contentDOM, { key: "Escape", code: "Escape" }));
expect(parentKey).toHaveBeenCalledTimes(1);
});
});
+90 -32
Переглянути файл
@@ -1,16 +1,20 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import clsx from "clsx";
import { EditorState, StateEffect, StateField, type Range, type Text } from "@codemirror/state";
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
import { codePreviewExtensions } from "@/editor/api";
import { Decoration, EditorView, keymap, drawSelection, type DecorationSet } from "@codemirror/view";
import { defaultKeymap } from "@codemirror/commands";
import { attachDocument, codePreviewExtensions, type DocumentAttachment } from "@/editor/api";
import { tokenSpans, useLineTokens, type LineSource } from "@/editor/lineTokens";
import { effectiveBindings, registerKeyClaim } from "@/platform/keymap";
import { getAction } from "@/platform/actions";
import type { SearchQuery } from "@/platform/ipc";
import { previewReplacement } from "./session";
import { loadPreviewText } from "./preview";
import { releasePreview, takePreview } from "./preview";
import { notifications } from "@/workbench/contributions";
import type { PreviewLine } from "./replaceFlow";
import styles from "./parts.module.css";
import { T } from "@/ui";
import { tx } from "@/platform/i18n";
import { t, tx } from "@/platform/i18n";
/**
* A result line with matches highlighted; in replace mode shows `old → new`
@@ -99,9 +103,9 @@ export function previewDecorations(doc: Text, line: number, highlights: readonly
}
/**
* Read-only, syntax-highlighted view of `path` (live editor buffer or disk)
* A secondary editor of `path`, sharing the document and its save pipeline,
* scrolled to `line`, with the matches of `highlights` marked; the editor's
* look via `codePreviewExtensions`. Text stays selectable and copyable.
* look via `codePreviewExtensions`. Read-only documents stay selectable.
*/
export function CodePreview({
path,
@@ -123,40 +127,87 @@ export function CodePreview({
latest.current = { line, highlights };
});
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const view = new EditorView({ parent: host, state: EditorState.create({ extensions: [previewMarks, readOnly] }) });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, []);
useEffect(() => {
if (!path) return;
let alive = true;
// Debounced so arrowing through results doesn't load every file.
const t = setTimeout(() => {
Promise.all([loadPreviewText(path), codePreviewExtensions(path)]).then(
([text, extensions]) => {
const view = viewRef.current;
if (!alive || !view) return;
view.setState(EditorState.create({ doc: text, extensions: [extensions, previewMarks, readOnly] }));
setLoaded({ path, version });
setError(null);
},
(e) => alive && setError(String((e as Error)?.message ?? e)),
);
let view: EditorView | null = null;
let attachment: DocumentAttachment | null = null;
let unclaim = () => {};
const timer = setTimeout(async () => {
try {
const held = takePreview(path);
attachment = held ?? (await attachDocument(path, { sharedHistory: true }));
if (!alive) {
attachment.release();
return;
}
const att = attachment;
const extensions = await codePreviewExtensions(path, !att.readOnly);
if (!alive) {
releasePreview(att);
return;
}
const fail = (error: unknown) =>
notifications().notify({ level: "error", title: t("app.unsaved.saveFailed"), body: String(error) });
view = new EditorView({
parent: hostRef.current!,
state:
att.editorState() ??
EditorState.create({
doc: att.text(),
extensions: [
extensions,
previewMarks,
att.extension,
drawSelection(),
keymap.of([
{ key: "Mod-z", run: () => att.undo(), preventDefault: true },
{ key: "Mod-Shift-z", run: () => att.redo(), preventDefault: true },
{ key: "Mod-y", run: () => att.redo(), preventDefault: true },
{
key: "Mod-s",
run: () => {
void att.save().catch(fail);
return true;
},
},
...defaultKeymap.filter((binding) => binding.key !== "Escape"),
]),
att.readOnly ? readOnly : [],
],
}),
dispatchTransactions: (transactions, editor) => att.dispatchTransactions(transactions, editor),
});
// Text-editor actions otherwise target the tab behind the popup. Let this view's keymap own editing.
unclaim = registerKeyClaim(
(event, stroke) =>
!!view?.dom.contains(event.target as Node) &&
effectiveBindings().some(
([binding, id]) => binding === stroke && !!getAction(id)?.when?.includes("editorTextFocus"),
),
);
viewRef.current = view;
setLoaded({ path, version });
setError(null);
} catch (error) {
if (attachment) releasePreview(attachment);
attachment = null;
if (alive) setError(String((error as Error)?.message ?? error));
}
}, 80);
return () => {
alive = false;
clearTimeout(t);
clearTimeout(timer);
unclaim();
viewRef.current = null;
if (view) {
view.destroy();
if (attachment) releasePreview(attachment);
}
};
}, [path, version]);
const current = !!path && loaded?.path === path;
const current = !!path && loaded?.path === path && loaded.version === version;
// A new file or line scrolls to the line; new highlights alone only repaint.
useEffect(() => {
const view = viewRef.current;
@@ -176,7 +227,14 @@ export function CodePreview({
const placeholder = !path ? <T>Select a result to preview</T> : error && !current ? error : null;
return (
<div className={styles.preview} role="region" aria-label={tx("Preview")}>
<div
className={styles.preview}
role="region"
aria-label={tx("Preview")}
onKeyDown={(event) => {
if (event.key !== "Escape") event.stopPropagation();
}}
>
<div ref={hostRef} className={clsx(styles.previewEditor, (!current || placeholder) && styles.previewHidden)} />
{placeholder && <div className={styles.previewEmpty}>{placeholder}</div>}
</div>
+34 -13
Переглянути файл
@@ -1,17 +1,38 @@
import { editorsFor } from "@/editor/api";
import { clearLineTokens, readFileText } from "@/editor/lineTokens";
/**
* File text for the preview pane: the live buffer of an open editor, else a
* read-only backend read (doesn't open a document or touch recent files).
* Shared with the result lines' syntax colours.
*/
export function loadPreviewText(path: string): Promise<string> {
const view = editorsFor(path)[0]?.view;
if (view) return Promise.resolve(view.state.doc.toString());
return readFileText(path);
}
import { useProject } from "@/platform/project";
import { onEditorEvent, type DocumentAttachment } from "@/editor/api";
import { clearLineTokens } from "@/editor/lineTokens";
export function clearPreviewCache() {
clearLineTokens();
}
// Closing a preview must not discard unsaved edits when autosave is off.
const heldPreviews = new Map<string, DocumentAttachment>();
export function takePreview(path: string): DocumentAttachment | undefined {
const held = heldPreviews.get(path);
heldPreviews.delete(path);
return held;
}
export function releasePreview(attachment: DocumentAttachment) {
if (attachment.dirty()) {
const previous = heldPreviews.get(attachment.path);
if (previous && previous !== attachment) previous.release();
heldPreviews.set(attachment.path, attachment);
} else attachment.release();
}
onEditorEvent((event) => {
if (event.type !== "docSaved" && event.type !== "docClosed") return;
const held = heldPreviews.get(event.path);
if (!held) return;
heldPreviews.delete(event.path);
held.release();
});
useProject.subscribe((state, previous) => {
if (state.project?.root === previous.project?.root) return;
for (const attachment of heldPreviews.values()) attachment.release();
heldPreviews.clear();
});
+2
Переглянути файл
@@ -19,6 +19,8 @@ function stubAttacher(docs: Map<string, { text: string; dirty?: boolean; readOnl
dirty: () => !!docs.get(path)!.dirty,
uiMode: () => null,
readOnly: !!docs.get(path)!.readOnly,
editorState: () => null,
dispatchTransactions: (trs, view) => view.update(trs),
extension: EditorView.updateListener.of((u) => {
for (const tr of u.transactions)
if (tr.docChanged) {