дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
412 рядки
15 KiB
TypeScript
412 рядки
15 KiB
TypeScript
/**
|
|
* An editor slot is one tab showing a text document in one pane. It owns the
|
|
* tab's EditorState (selection, folds, undo history, scroll) and, while the
|
|
* tab is visible, an EditorView. The most recently used tabs keep their view,
|
|
* detached, while hidden (`KEPT_VIEWS`): showing one again re-attaches it with
|
|
* its decorations, height map and plugin state; older background tabs keep
|
|
* only their state and get a new view when shown.
|
|
*
|
|
* All slots of a document share its DocModel: local edits flow
|
|
* slot → model → backend and are mirrored into the other slots.
|
|
*/
|
|
import {
|
|
type Annotation,
|
|
EditorSelection,
|
|
EditorState,
|
|
Prec,
|
|
StateEffect,
|
|
type Transaction,
|
|
type ChangeSet,
|
|
type TransactionSpec,
|
|
} from "@codemirror/state";
|
|
import { historyField } from "@codemirror/commands";
|
|
import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view";
|
|
import {
|
|
_addEditor,
|
|
_removeEditor,
|
|
documentSynced,
|
|
viewShown,
|
|
isExternalChange,
|
|
transientChange,
|
|
type EditorDocContext,
|
|
type TextEditorHandle,
|
|
} from "@/editor/api";
|
|
import { showContextMenu } from "@/ui";
|
|
import { baseExtensions, compartments, contributionExtensions } from "../cm/setup";
|
|
import { gutterMenuItems, inGutterMenuArea } from "../gutterMenu";
|
|
import type { DocModel } from "./document";
|
|
import type { WorkspaceHistoryTarget } from "./workspaceHistory";
|
|
import { isNavigation } from "./navHistory";
|
|
|
|
export interface SlotHost {
|
|
onFocus(slot: EditorSlot): void;
|
|
onBlur(slot: EditorSlot): void;
|
|
/** `navigatedFrom`: the caret before a navigation transaction (mouse click, find, jump…). */
|
|
onSelection(slot: EditorSlot, navigatedFrom?: { line: number; col: number }): void;
|
|
onUserEdit(slot: EditorSlot, tr: Transaction): void;
|
|
onContextMenu(slot: EditorSlot, e: MouseEvent): void;
|
|
/** Escape in the editor (close the find bar…); return true when handled. */
|
|
onEscape?(slot: EditorSlot): boolean;
|
|
}
|
|
|
|
export interface Reveal {
|
|
anchor: number;
|
|
head?: number;
|
|
/** Scroll so the target is centred (IDEA navigation) instead of minimally. */
|
|
center?: boolean;
|
|
}
|
|
|
|
/** Hidden views kept for showing again, least recently hidden first. */
|
|
const KEPT_VIEWS = 8;
|
|
const keptSlots: EditorSlot[] = [];
|
|
|
|
export class EditorSlot {
|
|
private stored: EditorState;
|
|
private historyTarget: WorkspaceHistoryTarget;
|
|
private detachHistory: () => void;
|
|
/** The view while the tab is visible. */
|
|
view: EditorView | null = null;
|
|
/** The view kept, detached from the document, while the tab is hidden. */
|
|
private kept: EditorView | null = null;
|
|
/** The view's scroll offset, tracked so hiding it needn't read layout. */
|
|
private scrollPos = { top: 0, left: 0 };
|
|
handle: TextEditorHandle | null = null;
|
|
private scroll: StateEffect<unknown> | null = null;
|
|
private pendingReveal: Reveal | null = null;
|
|
/** First visible line (0-based) when the view was last unmounted. */
|
|
private savedTopLine: number | undefined;
|
|
private contributionsVersion: number;
|
|
disposed = false;
|
|
/** Focus the view as soon as it is mounted. */
|
|
wantsFocus = false;
|
|
/** View update observers (find bar counter, etc.). */
|
|
readonly listeners = new Set<(u: ViewUpdate) => void>();
|
|
|
|
constructor(
|
|
readonly model: DocModel,
|
|
readonly tabId: string,
|
|
private host: SlotHost,
|
|
init?: { from?: EditorSlot; reveal?: Reveal },
|
|
) {
|
|
this.historyTarget = {
|
|
path: model.path,
|
|
state: () => this.currentState(),
|
|
dispatch: (tr) => {
|
|
if (this.live) this.live.dispatch(tr);
|
|
else this.stored = tr.state;
|
|
},
|
|
};
|
|
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.stored = EditorState.create({
|
|
doc: model.sync.localText,
|
|
selection: source ? source.currentState().selection : undefined,
|
|
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, !history);
|
|
}
|
|
|
|
get ctx(): EditorDocContext {
|
|
return this.model.ctx();
|
|
}
|
|
|
|
/** The visible or kept view: the one holding the tab's state. */
|
|
private get live(): EditorView | null {
|
|
return this.view ?? this.kept;
|
|
}
|
|
|
|
get state(): EditorState {
|
|
return this.live?.state ?? this.stored;
|
|
}
|
|
|
|
currentState(): EditorState {
|
|
return this.state;
|
|
}
|
|
|
|
private slotExtensions() {
|
|
return [
|
|
this.model.manager.workspaceHistory.extension(this.historyTarget),
|
|
documentSynced.of(() => this.model.sync.whenIdle()),
|
|
EditorView.updateListener.of((u) => {
|
|
for (const l of this.listeners) l(u);
|
|
if (u.focusChanged) {
|
|
if (u.view.hasFocus) this.host.onFocus(this);
|
|
else this.host.onBlur(this);
|
|
}
|
|
}),
|
|
Prec.highest(keymap.of([{ key: "Escape", run: () => this.host.onEscape?.(this) ?? false }])),
|
|
EditorView.domEventObservers({
|
|
scroll: (_e, view) => {
|
|
if (view === this.view) this.scrollPos = { top: view.scrollDOM.scrollTop, left: view.scrollDOM.scrollLeft };
|
|
},
|
|
}),
|
|
EditorView.domEventHandlers({
|
|
contextmenu: (e) => {
|
|
this.host.onContextMenu(this, e);
|
|
return true;
|
|
},
|
|
}),
|
|
ViewPlugin.define((view) => {
|
|
// Gutters sit outside the content DOM. Gutter extensions with their own menu (breakpoints,
|
|
// annotations) handle the event first and prevent its default.
|
|
const onContextMenu = (e: MouseEvent) => {
|
|
if (e.defaultPrevented || !inGutterMenuArea(e.target)) return;
|
|
e.preventDefault();
|
|
const block = view.lineBlockAtHeight(e.clientY - view.documentTop);
|
|
showContextMenu(e, gutterMenuItems(this, view.state.doc.lineAt(block.from).number - 1));
|
|
};
|
|
view.dom.addEventListener("contextmenu", onContextMenu);
|
|
return { destroy: () => view.dom.removeEventListener("contextmenu", onContextMenu) };
|
|
}),
|
|
];
|
|
}
|
|
|
|
// --- view lifecycle -------------------------------------------------------
|
|
|
|
mount(parent: HTMLElement): EditorView {
|
|
if (this.view) return this.view;
|
|
const kept = this.kept;
|
|
let view: EditorView;
|
|
if (kept) {
|
|
this.kept = null;
|
|
keptSlots.splice(keptSlots.indexOf(this), 1);
|
|
parent.appendChild(kept.dom);
|
|
// Detached, it lost its scroll offset.
|
|
kept.scrollDOM.scrollTop = this.scrollPos.top;
|
|
kept.scrollDOM.scrollLeft = this.scrollPos.left;
|
|
view = this.view = kept;
|
|
this.syncContributions();
|
|
// Plugins that skipped work while it was hidden catch up.
|
|
view.dispatch({ effects: this.scroll ? [this.scroll, viewShown.of(null)] : viewShown.of(null) });
|
|
} else {
|
|
this.syncContributions();
|
|
view = this.view = new EditorView({
|
|
state: this.stored,
|
|
parent,
|
|
dispatchTransactions: (trs, v) => this.onTransactions(trs, v),
|
|
});
|
|
if (this.scroll) view.dispatch({ effects: this.scroll });
|
|
}
|
|
this.scroll = null;
|
|
this.handle = { view, ctx: this.model.ctx() };
|
|
_addEditor(this.handle);
|
|
if (this.pendingReveal) {
|
|
const r = this.pendingReveal;
|
|
this.pendingReveal = null;
|
|
this.reveal(r);
|
|
}
|
|
return view;
|
|
}
|
|
|
|
/** Hides the view: keeps it detached for the most recent tabs, else (or `keep: false`) destroys it. */
|
|
unmount(keep = true) {
|
|
const view = this.view;
|
|
if (!view) return;
|
|
if (this.handle) _removeEditor(this.handle);
|
|
this.handle = null;
|
|
this.view = null;
|
|
this.kept = view;
|
|
if (!keep) return this.release();
|
|
view.dom.remove();
|
|
keptSlots.push(this);
|
|
if (keptSlots.length > KEPT_VIEWS) keptSlots.shift()!.release();
|
|
}
|
|
|
|
/** Destroys the kept view, keeping its state. */
|
|
private release() {
|
|
const view = this.kept;
|
|
if (!view) return;
|
|
this.kept = null;
|
|
const i = keptSlots.indexOf(this);
|
|
if (i >= 0) keptSlots.splice(i, 1);
|
|
this.savedTopLine = topLineAt(view, this.scrollPos.top);
|
|
this.scroll = scrollSnapshotAt(view, this.scrollPos);
|
|
this.stored = view.state;
|
|
view.destroy();
|
|
}
|
|
|
|
dispose() {
|
|
if (this.disposed) return;
|
|
this.detachHistory();
|
|
this.unmount(false);
|
|
this.release();
|
|
this.disposed = true;
|
|
this.model.removeSlot(this);
|
|
}
|
|
|
|
focus() {
|
|
this.view?.focus();
|
|
}
|
|
|
|
// --- transactions ---------------------------------------------------------
|
|
|
|
private onTransactions(trs: readonly Transaction[], view: EditorView) {
|
|
view.update(trs);
|
|
let selection = false;
|
|
let reformatted = false;
|
|
let navigatedFrom: { line: number; col: number } | undefined;
|
|
for (const tr of trs) {
|
|
if (!navigatedFrom && tr.selection && isNavigation(tr)) {
|
|
const head = tr.startState.selection.main.head;
|
|
const line = tr.startState.doc.lineAt(head);
|
|
navigatedFrom = { line: line.number - 1, col: head - line.from };
|
|
}
|
|
if (tr.docChanged && !isExternalChange(tr) && !tr.annotation(transientChange)) {
|
|
this.model.onLocal(tr.changes, this);
|
|
if (
|
|
tr.isUserEvent("input") ||
|
|
tr.isUserEvent("delete") ||
|
|
tr.isUserEvent("move") ||
|
|
tr.isUserEvent("undo") ||
|
|
tr.isUserEvent("redo")
|
|
)
|
|
this.host.onUserEdit(this, tr);
|
|
}
|
|
if (tr.docChanged && tr.isUserEvent("format.reformat")) reformatted = true;
|
|
if (tr.selection || tr.docChanged) selection = true;
|
|
}
|
|
if (reformatted) this.model.redetectIndent();
|
|
if (selection) this.host.onSelection(this, navigatedFrom);
|
|
}
|
|
|
|
/** Applies a transaction as if the user made it (synced, undoable). */
|
|
dispatchLocal(spec: TransactionSpec | Transaction) {
|
|
if (this.live) {
|
|
this.live.dispatch(spec);
|
|
return;
|
|
}
|
|
const tr = "startState" in spec ? spec : this.stored.update(spec);
|
|
this.stored = tr.state;
|
|
if (tr.docChanged) this.model.onLocal(tr.changes, this);
|
|
if (tr.docChanged && tr.isUserEvent("format.reformat")) this.model.redetectIndent();
|
|
}
|
|
|
|
/** Applies changes that are already in the model (other slot / backend). */
|
|
applyExternal(changes: ChangeSet, annotations: Annotation<unknown>[]) {
|
|
const spec: TransactionSpec = { changes, annotations };
|
|
if (this.live) this.live.dispatch(spec);
|
|
else this.stored = this.stored.update(spec).state;
|
|
}
|
|
|
|
/** Applies effects (reconfiguration) to the view or the stored state. */
|
|
reconfigure(effects: StateEffect<unknown>[]) {
|
|
if (!effects.length) return;
|
|
if (this.live) this.live.dispatch({ effects });
|
|
else this.stored = this.stored.update({ effects }).state;
|
|
if (this.handle) this.handle.ctx = this.model.ctx();
|
|
}
|
|
|
|
/** Re-applies editor contributions if the registry changed since this state was built. */
|
|
syncContributions(force = false) {
|
|
const version = this.model.manager.contributionsVersion;
|
|
if (!force && version === this.contributionsVersion) return;
|
|
this.contributionsVersion = version;
|
|
this.reconfigure([compartments.contributions.reconfigure(contributionExtensions(this.model.ctx()))]);
|
|
}
|
|
|
|
// --- selection --------------------------------------------------------------
|
|
|
|
reveal(r: Reveal) {
|
|
const len = this.currentState().doc.length;
|
|
const anchor = Math.min(Math.max(0, r.anchor), len);
|
|
const head = Math.min(Math.max(0, r.head ?? anchor), len);
|
|
if (!this.view) {
|
|
this.dispatchState({ selection: EditorSelection.single(anchor, head) });
|
|
this.pendingReveal = r;
|
|
return;
|
|
}
|
|
this.view.dispatch({
|
|
selection: EditorSelection.single(anchor, head),
|
|
effects: EditorView.scrollIntoView(EditorSelection.range(anchor, head), {
|
|
y: r.center === false ? "nearest" : "center",
|
|
}),
|
|
userEvent: "select.reveal",
|
|
});
|
|
}
|
|
|
|
/** Caret and first visible line, for the persisted editor session. */
|
|
viewState(): { anchor: number; head: number; topLine?: number } {
|
|
const main = this.currentState().selection.main;
|
|
const topLine = this.view
|
|
? topLineOf(this.view)
|
|
: this.kept
|
|
? topLineAt(this.kept, this.scrollPos.top)
|
|
: this.savedTopLine;
|
|
return { anchor: main.anchor, head: main.head, ...(topLine !== undefined ? { topLine } : {}) };
|
|
}
|
|
|
|
/** Restores a saved caret / scroll position (session restore). */
|
|
restoreView(v: { anchor: number; head: number; topLine?: number }) {
|
|
const doc = this.currentState().doc;
|
|
const selection = EditorSelection.single(
|
|
Math.min(Math.max(0, v.anchor), doc.length),
|
|
Math.min(Math.max(0, v.head), doc.length),
|
|
);
|
|
const scroll =
|
|
v.topLine !== undefined
|
|
? EditorView.scrollIntoView(doc.line(Math.min(v.topLine + 1, doc.lines)).from, { y: "start" })
|
|
: null;
|
|
if (this.view) {
|
|
this.view.dispatch({ selection, effects: scroll ? [scroll] : [] });
|
|
return;
|
|
}
|
|
this.dispatchState({ selection });
|
|
if (scroll) {
|
|
this.scroll = scroll;
|
|
this.savedTopLine = v.topLine;
|
|
}
|
|
}
|
|
|
|
/** Updates the state of a hidden tab (its kept view, if any). */
|
|
private dispatchState(spec: TransactionSpec) {
|
|
if (this.kept) this.kept.dispatch(spec);
|
|
else this.stored = this.stored.update(spec).state;
|
|
}
|
|
|
|
caret(): { line: number; col: number; offset: number } {
|
|
const s = this.currentState();
|
|
const head = s.selection.main.head;
|
|
const line = s.doc.lineAt(head);
|
|
return { line: line.number - 1, col: head - line.from, offset: head };
|
|
}
|
|
}
|
|
|
|
function topLineOf(view: EditorView): number {
|
|
return topLineAt(view, view.scrollDOM.scrollTop);
|
|
}
|
|
|
|
/** `view.scrollSnapshot()` at a tracked offset: a detached view's scrollDOM reports 0. */
|
|
function scrollSnapshotAt(view: EditorView, pos: { top: number; left: number }): StateEffect<unknown> {
|
|
const atPos = Object.create(view) as EditorView;
|
|
Object.defineProperty(atPos, "scrollDOM", { value: { scrollTop: pos.top, scrollLeft: pos.left } });
|
|
return atPos.scrollSnapshot();
|
|
}
|
|
|
|
function topLineAt(view: EditorView, scrollTop: number): number {
|
|
const block = view.lineBlockAtHeight(Math.max(0, scrollTop));
|
|
return view.state.doc.lineAt(block.from).number - 1;
|
|
}
|