дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
c1e32b091a
коміт
a40a909db5
13 files changed
+531
-100
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? [],
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Заблокувати користувача