diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index a08a281dd..89c2b7a7b 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -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 = () => diff --git a/src/editor/api.ts b/src/editor/api.ts index cc9332941..9804e1361 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -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; @@ -663,16 +668,22 @@ export interface DocumentAttachment { release(): void; } -let documentAttacher: ((path: string) => Promise) | 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) | null = + null; /** Opens (or joins) the document of `path` for a non-tab view. */ -export function attachDocument(path: string): Promise { +export function attachDocument(path: string, options?: DocumentAttachmentOptions): Promise { 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) | 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) | null = null; +let codePreviewProvider: ((path: string, editable?: boolean) => Promise) | 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 { - return codePreviewProvider ? codePreviewProvider(path) : Promise.resolve([]); +export function codePreviewExtensions(path: string, editable = false): Promise { + return codePreviewProvider ? codePreviewProvider(path, editable) : Promise.resolve([]); } /** @internal editor/core only. */ -export function _provideCodePreview(provider: ((path: string) => Promise) | null) { +export function _provideCodePreview(provider: typeof codePreviewProvider) { codePreviewProvider = provider; } diff --git a/src/editor/core/cm/preview.ts b/src/editor/core/cm/preview.ts index ba7f3449d..b3951d5fc 100644 --- a/src/editor/core/cm/preview.ts +++ b/src/editor/core/cm/preview.ts @@ -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 { 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 ?? [], ]; diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index 3097ffdfd..479033b8e 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -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), diff --git a/src/editor/core/model/attachment.ts b/src/editor/core/model/attachment.ts index 652f55381..0720754cc 100644 --- a/src/editor/core/model/attachment.ts +++ b/src/editor/core/model/attachment.ts @@ -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[]) { - 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 { +export async function attachDocument( + manager: DocumentManager, + path: string, + options: DocumentAttachmentOptions = {}, +): Promise { 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); }, }; diff --git a/src/editor/core/model/document.ts b/src/editor/core/model/document.ts index 95abb33b8..8bd06bde4 100644 --- a/src/editor/core/model/document.ts +++ b/src/editor/core/model/document.ts @@ -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[]): 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); diff --git a/src/editor/core/model/slot.ts b/src/editor/core/model/slot.ts index 998b64e0c..3f2467af1 100644 --- a/src/editor/core/model/slot.ts +++ b/src/editor/core/model/slot.ts @@ -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 { diff --git a/src/editor/core/model/workspaceHistory.test.ts b/src/editor/core/model/workspaceHistory.test.ts index 001d76f7f..fc9e29821 100644 --- a/src/editor/core/model/workspaceHistory.test.ts +++ b/src/editor/core/model/workspaceHistory.test.ts @@ -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(); +}); diff --git a/src/editor/core/model/workspaceHistory.ts b/src/editor/core/model/workspaceHistory.ts index 3a05ea440..e24dbbf54 100644 --- a/src/editor/core/model/workspaceHistory.ts +++ b/src/editor/core/model/workspaceHistory.ts @@ -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 diff --git a/src/features/search/__tests__/codePreview.test.tsx b/src/features/search/__tests__/codePreview.test.tsx index 111cd0407..19e325083 100644 --- a/src/features/search/__tests__/codePreview.test.tsx +++ b/src/features/search/__tests__/codePreview.test.tsx @@ -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; +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 { + await act(() => new Promise((resolve) => setTimeout(resolve, 150))); + let view: EditorView | null = null; + await waitFor(() => { + const content = container.querySelector(".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(); - 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(); + 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( + , + ); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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( +
+ +
, + ); + 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); + }); }); diff --git a/src/features/search/find/parts.tsx b/src/features/search/find/parts.tsx index eb626ad22..5c2c2e0bc 100644 --- a/src/features/search/find/parts.tsx +++ b/src/features/search/find/parts.tsx @@ -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 ? Select a result to preview : error && !current ? error : null; return ( -
+
{ + if (event.key !== "Escape") event.stopPropagation(); + }} + >
{placeholder &&
{placeholder}
}
diff --git a/src/features/search/find/preview.ts b/src/features/search/find/preview.ts index 7d56128f4..becf80451 100644 --- a/src/features/search/find/preview.ts +++ b/src/features/search/find/preview.ts @@ -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 { - 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(); + +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(); +}); diff --git a/src/features/todo/PreviewEditor.test.tsx b/src/features/todo/PreviewEditor.test.tsx index b0c95ecb6..4a8c8f378 100644 --- a/src/features/todo/PreviewEditor.test.tsx +++ b/src/features/todo/PreviewEditor.test.tsx @@ -19,6 +19,8 @@ function stubAttacher(docs: Map !!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) {