perf(editor): keep recently used editor views across tab switches (#2282)

Switching between file tabs destroyed the hidden tab's CodeMirror view and
built a new one on return: every switch re-ran the view's plugins, re-measured
the height map (the scrollbar jumped) and re-requested inlay hints, usages and
diagnostics, which popped in and shifted the text up to a second later.

The eight most recently hidden views are now kept detached and re-attached on
return, with their decorations, height map and plugin state. Edits while hidden
go to the kept view; plugins that skip work while their view is detached catch
up when it is shown again (`viewShown`).

Switching also forces fewer layouts: the Switcher no longer gives focus back to
the editor left behind when it opens another tab, the tab strip only measures
the active tab when it isn't known to be in view, and focus moving between
editors no longer drops the old editor's DOM caret.

The tabs perf scenario counts views rebuilt when switching back (budget 0).
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 14:16:21 +08:00
1 джерело eb72b78abb
коміт 502cc348ba
22 files changed
+344 -87

No files matched your search

+1
Переглянути файл
@@ -73,6 +73,7 @@
"tabs.openSmallMs": 100,
"tabs.switchP95Ms": 33,
"tabs.switcherP50Ms": 33,
"tabs.viewsRebuilt": 0,
"agentTabs.conversationNodes": 20000,
"agentTabs.rowsAlwaysRendered": 0,
+16 -2
Переглянути файл
@@ -449,12 +449,24 @@ const tabs: Scenario = {
await h.openFile(`${ROOT}/src/tabs/${name}`, tsSource(2_000, `tab-${i}`));
}
const switches: number[] = [];
// Recently shown tabs keep their editor view: showing one again must not build a new one.
let viewsRebuilt = 0;
let switchWork = await h.counters();
for (let round = 0; round < 3; round++) {
if (round === 1) switchWork = await h.counters();
for (let i = 0; i < names.length; i++) {
const tab = h.page.getByRole("tab", { name: names[i], exact: true });
switches.push(await h.measure(() => tab.click(), ".cm-content", `// tab-${i}`));
const fresh = await h.page.evaluate(() => {
const view = document.querySelector("[data-editor-host] .cm-editor") as { perfSeen?: boolean } | null;
if (!view || view.perfSeen) return false;
view.perfSeen = true;
return true;
});
if (round > 0 && fresh) viewsRebuilt++;
}
}
const work = Harness.delta(switchWork, await h.counters());
// The Switcher (Ctrl+Tab) back and forth between the two most recent tabs.
const before = await h.counters();
const switcher: number[] = [];
@@ -463,12 +475,14 @@ const tabs: Scenario = {
switcher.push(await h.measure(() => h.pressAction("navigate.switcher"), ".cm-content", `// tab-${tag}`));
await h.page.waitForTimeout(150);
}
const work = Harness.delta(before, await h.counters());
const switcherWork = Harness.delta(before, await h.counters());
return {
openSmallMs,
...latencySummary(switches, "switch"),
switchMainThreadMs: work.taskMs / (names.length * 2),
viewsRebuilt,
...latencySummary(switcher, "switcher"),
switcherMainThreadMs: work.taskMs / switcher.length,
switcherMainThreadMs: switcherWork.taskMs / switcher.length,
};
},
};
+12 -1
Переглянути файл
@@ -28,7 +28,7 @@ import {
type RangeSet,
type Transaction,
} from "@codemirror/state";
import { GutterMarker, ViewPlugin, type EditorView } from "@codemirror/view";
import { GutterMarker, ViewPlugin, type EditorView, type ViewUpdate } from "@codemirror/view";
import type { LanguageDescription } from "@codemirror/language";
import type { LineEnding } from "@/platform/ipc";
import { create } from "zustand";
@@ -402,6 +402,17 @@ export interface ContributedIntention {
/** A background provider has new context actions for the current document. */
export const intentionsChanged = StateEffect.define<null>();
/**
* A hidden editor view is shown again. Recently used tabs keep their view, detached from the
* document, while hidden; plugins that skip work while `view.dom` is disconnected redo it when
* `shownAgain`.
*/
export const viewShown = StateEffect.define<null>();
export function shownAgain(u: ViewUpdate): boolean {
return u.transactions.some((tr) => tr.effects.some((e) => e.is(viewShown)));
}
/** Local fixes of a diagnostic range (hover link and Apply Quick Fix). */
export const quickFixProviders = Facet.define<(view: EditorView, from: number, to: number) => ContributedIntention[]>();
+1 -8
Переглянути файл
@@ -111,14 +111,7 @@ export async function attachDocument(
if (model.ctx().readOnly || released || model.closed) return false;
const slot = model.primarySlot();
if (slot?.view) return command(slot.view);
if (slot)
return command({
state: slot.state,
dispatch: (tr) => {
slot.state = tr.state;
if (tr.docChanged) model.onLocal(tr.changes, slot);
},
});
if (slot) return command({ state: slot.state, dispatch: (tr) => slot.dispatchLocal(tr) });
return target.view ? command(target.view) : false;
};
+49 -3
Переглянути файл
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { indentUnit, language as languageFacet } from "@codemirror/language";
import { HEAVY_LIMIT, registerSaveParticipant, remoteChange } from "@/editor/api";
import { HEAVY_LIMIT, registerSaveParticipant, remoteChange, shownAgain } from "@/editor/api";
import { createMemoryBackend, type MemoryBackend } from "../backend";
import { _resetLanguages } from "../cm/languages";
import { DocumentManager, ModifiedRanges, saveCleanupChanges, useDocMeta } from "./document";
@@ -142,9 +142,10 @@ describe("DocumentManager + slots", () => {
const a = new EditorSlot(model, "t1", host);
const b = new EditorSlot(model, "t2", host);
let sawRemote = false;
const spy = vi.spyOn(a, "applyExternal").mockImplementation(function (this: EditorSlot, changes, annotations) {
const applyExternal = a.applyExternal.bind(a);
const spy = vi.spyOn(a, "applyExternal").mockImplementation((changes, annotations) => {
sawRemote = annotations.some((x) => x.type === remoteChange);
this.state = this.state.update({ changes, annotations }).state;
applyExternal(changes, annotations);
});
backend.patch(model.doc, [{ from: 3, to: 3, insert: "def" }]);
expect(sawRemote).toBe(true);
@@ -301,6 +302,51 @@ describe("DocumentManager + slots", () => {
manager.stop();
}
});
it("keeps a hidden tab's view and shows it again instead of building a new one", async () => {
const { backend, manager } = setup({ "/p/a.txt": "abc" });
const model = await manager.acquire("/p/a.txt");
const slot = new EditorSlot(model, "t1", host);
const shown: boolean[] = [];
slot.listeners.add((u) => shown.push(shownAgain(u)));
const view = slot.mount(document.body);
slot.unmount();
expect(slot.view).toBeNull();
expect(view.dom.isConnected).toBe(false);
// Changes while hidden go to the kept view.
backend.patch(model.doc, [{ from: 3, to: 3, insert: "def" }]);
expect(view.state.doc.toString()).toBe("abcdef");
expect(slot.currentState()).toBe(view.state);
shown.length = 0;
expect(slot.mount(document.body)).toBe(view);
expect(view.dom.isConnected).toBe(true);
expect(shown).toContain(true);
slot.dispose();
manager.stop();
});
it("destroys the least recently hidden views beyond the kept ones, and kept views on dispose", async () => {
const { manager } = setup({ "/p/a.txt": "abc" });
const model = await manager.acquire("/p/a.txt");
const slots = Array.from({ length: 8 }, (_, i) => new EditorSlot(model, `t${i}`, host));
const views = slots.map((slot) => {
const view = slot.mount(document.body);
slot.unmount();
return view;
});
const destroyed = views.map((view) => vi.spyOn(view, "destroy"));
slots.push(new EditorSlot(model, "t8", host));
slots[8]!.mount(document.body);
slots[8]!.unmount();
expect(destroyed[0]).toHaveBeenCalled();
expect(destroyed[1]).not.toHaveBeenCalled();
// A destroyed view leaves its state behind; showing the tab builds a new view on it.
expect(slots[0]!.mount(document.body)).not.toBe(views[0]);
slots[1]!.dispose();
expect(destroyed[1]).toHaveBeenCalled();
for (const slot of slots) slot.dispose();
manager.stop();
});
});
describe("save cleanup", () => {
+104 -33
Переглянути файл
@@ -1,8 +1,10 @@
/**
* 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. Background tabs keep only their state; views
* are created on show and destroyed on hide, so many open tabs cost little.
* 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.
@@ -23,6 +25,7 @@ import {
_addEditor,
_removeEditor,
documentSynced,
viewShown,
isExternalChange,
transientChange,
type EditorDocContext,
@@ -53,11 +56,20 @@ export interface Reveal {
center?: boolean;
}
/** Hidden views kept for showing again, least recently hidden first. */
const KEPT_VIEWS = 8;
const keptSlots: EditorSlot[] = [];
export class EditorSlot {
state: EditorState;
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;
@@ -80,8 +92,8 @@ export class EditorSlot {
path: model.path,
state: () => this.currentState(),
dispatch: (tr) => {
if (this.view) this.view.dispatch(tr);
else this.state = tr.state;
if (this.live) this.live.dispatch(tr);
else this.stored = tr.state;
},
};
const source = init?.from;
@@ -91,7 +103,7 @@ export class EditorSlot {
.find((state) => state?.field(historyField, false))
: null;
const history = secondary?.field(historyField, false);
this.state = EditorState.create({
this.stored = EditorState.create({
doc: model.sync.localText,
selection: source ? source.currentState().selection : undefined,
extensions: [
@@ -121,8 +133,17 @@ export class EditorSlot {
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.view?.state ?? this.state;
return this.state;
}
private slotExtensions() {
@@ -137,6 +158,11 @@ export class EditorSlot {
}
}),
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);
@@ -162,14 +188,28 @@ export class EditorSlot {
mount(parent: HTMLElement): EditorView {
if (this.view) return this.view;
this.syncContributions();
const view = new EditorView({
state: this.state,
parent,
dispatchTransactions: (trs, v) => this.onTransactions(trs, v),
});
this.view = view;
if (this.scroll) view.dispatch({ effects: this.scroll });
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);
@@ -181,22 +221,39 @@ export class EditorSlot {
return view;
}
unmount() {
/** 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;
this.savedTopLine = topLineOf(view);
this.state = view.state;
this.scroll = view.scrollSnapshot();
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);
const top = topLineAt(view, this.scrollPos.top);
this.savedTopLine = top;
this.scroll = EditorView.scrollIntoView(view.state.doc.line(top + 1).from, { y: "start" });
this.stored = view.state;
view.destroy();
}
dispose() {
if (this.disposed) return;
this.detachHistory();
this.unmount();
this.unmount(false);
this.release();
this.disposed = true;
this.model.removeSlot(this);
}
@@ -237,13 +294,13 @@ export class EditorSlot {
}
/** Applies a transaction as if the user made it (synced, undoable). */
dispatchLocal(spec: TransactionSpec) {
if (this.view) {
this.view.dispatch(spec);
dispatchLocal(spec: TransactionSpec | Transaction) {
if (this.live) {
this.live.dispatch(spec);
return;
}
const tr = this.state.update(spec);
this.state = tr.state;
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();
}
@@ -251,15 +308,15 @@ export class EditorSlot {
/** Applies changes that are already in the model (other slot / backend). */
applyExternal(changes: ChangeSet, annotations: Annotation<unknown>[]) {
const spec: TransactionSpec = { changes, annotations };
if (this.view) this.view.dispatch(spec);
else this.state = this.state.update(spec).state;
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.view) this.view.dispatch({ effects });
else this.state = this.state.update({ effects }).state;
if (this.live) this.live.dispatch({ effects });
else this.stored = this.stored.update({ effects }).state;
if (this.handle) this.handle.ctx = this.model.ctx();
}
@@ -278,7 +335,7 @@ export class EditorSlot {
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.state = this.state.update({ selection: EditorSelection.single(anchor, head) }).state;
this.dispatchState({ selection: EditorSelection.single(anchor, head) });
this.pendingReveal = r;
return;
}
@@ -294,7 +351,11 @@ export class EditorSlot {
/** 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.savedTopLine;
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 } : {}) };
}
@@ -313,13 +374,19 @@ export class EditorSlot {
this.view.dispatch({ selection, effects: scroll ? [scroll] : [] });
return;
}
this.state = this.state.update({ selection }).state;
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;
@@ -329,6 +396,10 @@ export class EditorSlot {
}
function topLineOf(view: EditorView): number {
const block = view.lineBlockAtHeight(Math.max(0, view.scrollDOM.scrollTop));
return topLineAt(view, view.scrollDOM.scrollTop);
}
function topLineAt(view: EditorView, scrollTop: number): number {
const block = view.lineBlockAtHeight(Math.max(0, scrollTop));
return view.state.doc.lineAt(block.from).number - 1;
}
+1 -3
Переглянути файл
@@ -62,9 +62,7 @@ async function rename() {
function historyStep(slot: EditorSlot, forward = false) {
return (forward ? redo : undo)({
state: slot.currentState(),
dispatch: (tr) => {
slot.state = tr.state;
},
dispatch: (tr) => slot.dispatchLocal(tr),
});
}
+5 -3
Переглянути файл
@@ -8,7 +8,7 @@
import { Channel } from "@tauri-apps/api/core";
import { EditorSelection, MapMode, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, type CodeLens } from "@/editor/api";
import { codeLenses, shownAgain, type CodeLens } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { commands, nextRequestId, type OutlineItem, type RequestId, type SymbolKind } from "@/platform/ipc";
import { useProject } from "@/platform/project";
@@ -119,6 +119,7 @@ class UsagesLensPlugin {
private offReady: () => void;
private offSettled: () => void;
private offBlame: () => void;
private skipped = false;
constructor(private view: EditorView) {
this.refresh();
@@ -137,7 +138,7 @@ class UsagesLensPlugin {
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.refresh();
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
}
private cancel() {
@@ -153,7 +154,8 @@ class UsagesLensPlugin {
private async run() {
const view = this.view;
if (!view.dom.isConnected) return;
this.skipped = !view.dom.isConnected;
if (this.skipped) return;
const doc = docOf(view);
if (!doc) return;
const gen = this.generation;
+28 -1
Переглянути файл
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { Text } from "@codemirror/state";
import { EditorState, Text } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { viewShown, type EditorDocContext } from "@/editor/api";
import { lspDoc } from "./lsp";
import type { Diagnostic } from "@/bindings";
import {
diagnosticsAt,
diagnosticsExtension,
diagnosticsFor,
installDiagnosticsEvents,
setFileDiagnostics,
@@ -84,3 +89,25 @@ describe("toLintDiagnostics", () => {
expect(toLintDiagnostics(doc, [d(2, 1, 1)])[0]).toMatchObject({ from: doc.length - 1, to: doc.length });
});
});
describe("editor diagnostics in a view", () => {
it("skips a hidden view and catches up when it is shown again", async () => {
const view = new EditorView({
state: EditorState.create({
doc: "let x = 1;",
extensions: [lspDoc.of({ doc: 1, path: "/p/hidden.rs" } as EditorDocContext), diagnosticsExtension()],
}),
parent: document.body,
});
view.dom.remove();
setFileDiagnostics("/p/hidden.rs", "rust-analyzer", [d(0, 4, 5)]);
await Promise.resolve();
expect(diagnosticsAt(view, 4)).toHaveLength(0);
document.body.appendChild(view.dom);
view.dispatch({ effects: viewShown.of(null) });
await Promise.resolve();
expect(diagnosticsAt(view, 4)).toHaveLength(1);
view.destroy();
setFileDiagnostics("/p/hidden.rs", "rust-analyzer", []);
});
});
+8 -3
Переглянути файл
@@ -6,13 +6,13 @@
*/
import type { Text } from "@codemirror/state";
import { EditorSelection, StateEffect, StateField } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { forEachDiagnostic, linter, setDiagnostics, type Diagnostic as LintDiagnostic } from "@codemirror/lint";
import type { Diagnostic, Severity } from "@/bindings";
import { commands, ideEventsReady, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { publishLocalDiagnostics } from "@/platform/localDiagnostics";
import { intentionsChanged, scrollbarMarks, type ScrollbarMark } from "@/editor/api";
import { intentionsChanged, scrollbarMarks, shownAgain, type ScrollbarMark } from "@/editor/api";
import { rangeToOffsets } from "./positions";
import { lspDoc } from "./lsp";
import { showHint } from "./hint";
@@ -190,6 +190,7 @@ const diagnosticsSync = ViewPlugin.fromClass(
class {
private unsubscribe: () => void;
private pending = false;
private skipped = false;
constructor(private view: EditorView) {
const path = view.state.facet(lspDoc)?.path;
this.unsubscribe = path ? subscribeDiagnostics(path, () => this.schedule()) : () => {};
@@ -201,7 +202,8 @@ const diagnosticsSync = ViewPlugin.fromClass(
queueMicrotask(() => {
this.pending = false;
const path = this.view.state.facet(lspDoc)?.path;
if (!path || !this.view.dom.isConnected) return;
this.skipped = !this.view.dom.isConnected;
if (!path || this.skipped) return;
const lint = toLintDiagnostics(this.view.state.doc, diagnosticsFor(path));
const tr = setDiagnostics(this.view.state, lint);
this.view.dispatch({
@@ -210,6 +212,9 @@ const diagnosticsSync = ViewPlugin.fromClass(
});
});
}
update(u: ViewUpdate) {
if (this.skipped && shownAgain(u)) this.schedule();
}
destroy() {
this.unsubscribe();
}
+5 -2
Переглянути файл
@@ -5,6 +5,7 @@
* offers to disable hints of that kind.
*/
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { shownAgain } from "@/editor/api";
import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { call, commands, isTauri, type InlayHint } from "@/platform/ipc";
import { executeAction } from "@/platform/actions";
@@ -162,6 +163,7 @@ const inlayPlugin = ViewPlugin.fromClass(
private unsubscribe: () => void;
private offReady: () => void;
private offModifier: () => void;
private skipped = false;
constructor(private view: EditorView) {
this.refresh();
view.contentDOM.classList.toggle("cm-lsp-navigation-modifier", navigationModifierHeld());
@@ -178,11 +180,12 @@ const inlayPlugin = ViewPlugin.fromClass(
this.offReady = onLspRefresh("inlayHints", () => this.refresh());
}
update(u: ViewUpdate) {
if (u.docChanged || u.viewportChanged) this.refresh();
if (u.docChanged || u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
}
async run() {
const view = this.view;
if (!view.dom.isConnected) return;
this.skipped = !view.dom.isConnected;
if (this.skipped) return;
const doc = docOf(view);
if (!doc) return;
if (!enabled(doc.language) || inlayBackoff.active(doc.doc)) {
+6 -3
Переглянути файл
@@ -1,7 +1,7 @@
/** Language-server and plugin lenses in the shared editor code-vision renderer. */
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { ViewPlugin, type ViewUpdate } from "@codemirror/view";
import { codeLenses, whenSynced, type CodeLens, type EditorDocContext } from "@/editor/api";
import { codeLenses, shownAgain, whenSynced, type CodeLens, type EditorDocContext } from "@/editor/api";
import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
@@ -24,6 +24,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
new (class {
timer: ReturnType<typeof setTimeout> | undefined;
generation = 0;
skipped = false;
off: () => void;
constructor() {
const offs = [
@@ -40,7 +41,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
this.timer = setTimeout(() => void this.run(), 400);
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
if (u.docChanged || (this.skipped && shownAgain(u))) this.schedule();
}
async run() {
const gen = this.generation;
@@ -48,7 +49,9 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
try {
await whenSynced(view);
const result = await call(commands.lspCodeLens(ctx.doc));
if (gen !== this.generation || text !== view.state.doc || !view.dom.isConnected) return;
if (gen !== this.generation || text !== view.state.doc) return;
this.skipped = !view.dom.isConnected;
if (this.skipped) return;
view.dispatch({
effects: set.of(
result.flatMap((l) =>
+5 -3
Переглянути файл
@@ -15,7 +15,7 @@
*/
import { RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { shownAgain, type EditorDocContext } from "@/editor/api";
import { docOf, debounce, isUnavailable, lspRequest } from "@/editor/lsp/lsp";
import { featureBackoff, onServerSettled } from "@/editor/lsp/availability";
import { readManyFileLines, withDocument } from "@/editor/lsp/files";
@@ -295,6 +295,7 @@ function markerSet(placed: readonly PlacedMarker[]): RangeSet<GutterMarker> {
class MarkersPlugin {
private refresh = debounce(() => void this.run(), DEBOUNCE_MS);
private generation = 0;
private skipped = false;
private request: RequestId | null = null;
private offSettings: () => void;
@@ -316,7 +317,7 @@ class MarkersPlugin {
// Other documents' markers may depend on this one.
changes++;
this.refresh();
} else if (u.viewportChanged) this.refresh();
} else if (u.viewportChanged || (this.skipped && shownAgain(u))) this.refresh();
// Other files may have changed meanwhile (a new implementation).
else if (u.focusChanged && u.view.hasFocus) {
const ctx = docOf(u.view);
@@ -337,7 +338,8 @@ class MarkersPlugin {
private async run() {
const view = this.view;
if (!view.dom.isConnected) return;
this.skipped = !view.dom.isConnected;
if (this.skipped) return;
const ctx = docOf(view);
if (!ctx) return;
const gen = ++this.generation;
+6 -4
Переглянути файл
@@ -1,7 +1,7 @@
/** Plugin colors use the same native picker and provider-supplied replacement strings. */
import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
import { GutterMarker, ViewPlugin, gutter, type EditorView, type ViewUpdate } from "@codemirror/view";
import { type EditorDocContext } from "@/editor/api";
import { shownAgain, type EditorDocContext } from "@/editor/api";
import { rangeToOffsets } from "@/editor/lsp/positions";
import { pluginVisuals } from "@/editor/lsp/pluginVisuals";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
@@ -118,6 +118,7 @@ export function pluginColorPreview(ctx: EditorDocContext): Extension {
new (class {
timer: ReturnType<typeof setTimeout> | undefined;
generation = 0;
skipped = false;
off: () => void;
constructor() {
this.off = onNavigationProvidersChanged(() => this.schedule());
@@ -129,15 +130,16 @@ export function pluginColorPreview(ctx: EditorDocContext): Extension {
this.timer = setTimeout(() => void this.run(), 300);
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
if (u.docChanged || (this.skipped && shownAgain(u))) this.schedule();
}
async run() {
const gen = this.generation;
const text = view.state.doc;
try {
const r = await pluginVisuals(view, ctx);
if (gen === this.generation && view.state.doc === text && view.dom.isConnected)
view.dispatch({ effects: setColors.of(r.colors) });
if (gen !== this.generation || view.state.doc !== text) return;
this.skipped = !view.dom.isConnected;
if (!this.skipped) view.dispatch({ effects: setColors.of(r.colors) });
} catch {
if (gen === this.generation && view.dom.isConnected) view.dispatch({ effects: setColors.of([]) });
}
+6 -3
Переглянути файл
@@ -19,7 +19,7 @@ import {
} from "@codemirror/view";
import { highlightingFor } from "@codemirror/language";
import { highlightTree } from "@lezer/highlight";
import { type EditorDocContext } from "@/editor/api";
import { shownAgain, type EditorDocContext } from "@/editor/api";
import { rangeToOffsets } from "@/editor/lsp/positions";
import { pluginVisuals } from "@/editor/lsp/pluginVisuals";
import { findLanguageDescription, languageInfo, loadSupport } from "@/editor/core/cm/languages";
@@ -123,6 +123,7 @@ export function pluginLanguageVisuals(ctx: EditorDocContext): Extension {
timer: ReturnType<typeof setTimeout> | undefined;
generation = 0;
shown = false;
skipped = false;
off: () => void;
constructor() {
const a = onNavigationProvidersChanged(() => this.schedule(true));
@@ -147,7 +148,7 @@ export function pluginLanguageVisuals(ctx: EditorDocContext): Extension {
this.timer = setTimeout(() => void this.run(), 300);
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
if (u.docChanged || (this.skipped && shownAgain(u))) this.schedule();
}
async run() {
const generation = this.generation;
@@ -163,7 +164,9 @@ export function pluginLanguageVisuals(ctx: EditorDocContext): Extension {
marker: new ActionMarker(plugin, { ...a, icon: await resolveMarkerIcon(plugin, a.icon) }, ctx),
})),
);
if (generation !== this.generation || view.state.doc !== text || !view.dom.isConnected) return;
if (generation !== this.generation || view.state.doc !== text) return;
this.skipped = !view.dom.isConnected;
if (this.skipped) return;
this.shown = !!placed.length;
view.dispatch({
effects: [
+6 -3
Переглянути файл
@@ -6,7 +6,7 @@
*/
import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { shownAgain, type EditorDocContext } from "@/editor/api";
import { navigationButton } from "./navigationButton";
import { pluginsBackend } from "./backend";
import { commands, type LineMarkerInfo } from "@/platform/ipc";
@@ -79,6 +79,7 @@ class PluginMarker extends GutterMarker {
class MarkersPlugin {
private timer: ReturnType<typeof setTimeout> | null = null;
private generation = 0;
private skipped = false;
private shown = false;
private off: () => void;
@@ -106,7 +107,7 @@ class MarkersPlugin {
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
if (u.docChanged || (this.skipped && shownAgain(u))) this.schedule();
}
private schedule() {
@@ -128,7 +129,9 @@ class MarkersPlugin {
icon: await resolveMarkerIcon(marker.pluginId, marker.icon),
})),
);
if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return;
if (gen !== this.generation || this.view.state.doc !== text) return;
this.skipped = !this.view.dom.isConnected;
if (this.skipped) return;
const placed = placeMarkers(text, resolved);
if (!placed.length && !this.view.state.field(markersField).length) return;
const effects: StateEffect<unknown>[] = [setMarkers.of(placed)];
+4 -2
Переглянути файл
@@ -479,7 +479,7 @@ describe("Recent Files multi-selection", () => {
describe("Switcher", () => {
afterEach(() => vi.restoreAllMocks());
it("opens the selection when Ctrl is released, once the popup gave focus back, in the same task", async () => {
it("opens the selection when Ctrl is released, once the popup closed, in the same task", async () => {
const tab = (key: string, lastActive: number) => ({
input: { kind: "agent-session", key },
title: key,
@@ -508,7 +508,9 @@ describe("Switcher", () => {
await tick();
expect(screen.getByRole("dialog", { name: "Switcher" })).toBeInTheDocument();
act(() => void fireEvent.keyUp(window, { key: "Control" }));
expect(seen).toEqual([{ key: "task-a", popup: false, focus: editor }]);
// The editor opened takes focus itself: the one left behind doesn't get it back first.
expect(seen).toEqual([{ key: "task-a", popup: false, focus: document.body }]);
expect(editor).not.toHaveFocus();
});
});
+26 -6
Переглянути файл
@@ -62,11 +62,22 @@ export function RecentPopupsOverlay() {
const NAV_KEYS = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"];
let handingFocus = false;
// Asked when the dialog's effects are cleaned up, which may come after `fn` already ran.
const keepFocus = () => {
const keep = !handingFocus;
handingFocus = false;
return keep;
};
/**
* Runs `fn` once the dialog has unmounted and given focus back, in the same frame: the
* popup closing and what it opens paint together, without a frame of the old editor between.
* `takesFocus`: what `fn` opens focuses itself, so focus isn't given back first (focusing the
* editor left behind would lay out the window once more).
*/
const afterClose = (fn: () => void) => {
const afterClose = (fn: () => void, takesFocus = false) => {
handingFocus = takesFocus;
flushSync(hideRecentPopup);
fn();
};
@@ -143,10 +154,12 @@ function RecentFilesDialog() {
const openFile = (item: RecentFileItem | undefined, side = false) => {
if (!item) return;
const editor = item.editor;
afterClose(() =>
editor
? void maybeEditorService()?.openInput(editor.input, { side, focus: true })
: void openLocation(item.path, null, { side }),
afterClose(
() =>
editor
? void maybeEditorService()?.openInput(editor.input, { side, focus: true })
: void openLocation(item.path, null, { side }),
!!editor,
);
};
@@ -282,7 +295,14 @@ function RecentFilesDialog() {
const title = switcher ? "Switcher" : editedOnly ? "Recently Edited Files" : "Recent Files";
const footerPath = (selected?.editor ? selected.rel : selected?.path) ?? (selectedTool ? "" : (root ?? ""));
return (
<Dialog open onClose={hideRecentPopup} width={720} aria-label={title} className={styles.dialog}>
<Dialog
open
onClose={hideRecentPopup}
restoreFocus={keepFocus}
width={720}
aria-label={title}
className={styles.dialog}
>
<header className={styles.header}>
<span className={styles.title}>
<T>{title}</T>
+9 -1
Переглянути файл
@@ -135,6 +135,11 @@ export interface DialogProps {
dismissOnBackdrop?: boolean;
/** Close when the app window loses focus (default: popups only; never modal dialogs). */
closeOnDeactivate?: boolean;
/**
* Asked on close: give focus back to what had it before the dialog (default: always). False
* when what the dialog opens takes focus itself.
*/
restoreFocus?: () => boolean;
/** @deprecated Dialogs never dim the window behind them; kept for call sites. */
transparentBackdrop?: boolean;
/** @deprecated Every centred dialog has window chrome; kept for call sites. */
@@ -219,6 +224,7 @@ function DialogInner({
className,
dismissOnBackdrop,
closeOnDeactivate,
restoreFocus,
platform: platformOverride,
icon,
...aria
@@ -231,9 +237,11 @@ function DialogInner({
const popup = position === "top" || (title == null && !icon);
const onCloseRef = useRef(onClose);
const buttonsRef = useRef(buttons);
const restoreFocusRef = useRef(restoreFocus);
useLayoutEffect(() => {
onCloseRef.current = onClose;
buttonsRef.current = buttons;
restoreFocusRef.current = restoreFocus;
});
const [offset, setOffset] = useState({ x: 0, y: 0 });
@@ -299,7 +307,7 @@ function DialogInner({
return () => {
document.removeEventListener("keydown", onKey);
layer.release();
if (previous?.isConnected) previous.focus({ preventScroll: true });
if (previous?.isConnected && restoreFocusRef.current?.() !== false) previous.focus({ preventScroll: true });
};
}, []);
+28 -3
Переглянути файл
@@ -215,18 +215,43 @@ export function Tabs({
});
}, [dndType]);
// Tab slots wholly in view as last observed, which needs no layout read: switching to one of
// them doesn't measure the strip (a forced layout of the window, mid-switch).
const inView = useRef(new Set<Element>());
// Bring a tab's whole slot (label + close button) into view. Narrowing the strip clips
// the active tab's close button (#1740), and clicking the already-active tab doesn't
// change `activeId`, so the strip must also reveal on clicks and resizes.
const revealTab = useCallback(
(id: string | null) => {
(id: string | null, always = false) => {
const scroller = scrollRef.current;
const tab = id ? tabRefs.current.get(id)?.parentElement : null;
if (scroller && tab) (vertical ? revealVertically : revealHorizontally)(scroller, tab);
if (!scroller || !tab || (!always && inView.current.has(tab))) return;
(vertical ? revealVertically : revealHorizontally)(scroller, tab);
},
[vertical],
);
const tabIds = items.map((it) => it.id).join("\n");
useEffect(() => {
const root = scrollRef.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const seen = inView.current;
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.intersectionRatio >= 1) seen.add(e.target);
else seen.delete(e.target);
}
},
{ root, threshold: 1 },
);
for (const tab of tabRefs.current.values()) if (tab.parentElement) io.observe(tab.parentElement);
return () => {
io.disconnect();
seen.clear();
};
}, [tabIds]);
useLayoutEffect(() => {
const el = scrollRef.current;
if (!el) return;
@@ -235,7 +260,7 @@ export function Tabs({
setOverflow(!vertical && el.scrollWidth > el.clientWidth + 1);
// A resize (e.g. the editor area narrowing) must scroll the active tab back
// into view instead of leaving its close button clipped until a tab switch.
revealTab(latest.current.activeId);
revealTab(latest.current.activeId, true);
};
measure();
if (typeof ResizeObserver === "undefined") return;
+15
Переглянути файл
@@ -38,6 +38,21 @@ describe("focus after popups", () => {
dispose();
});
it("leaves the caret to the next editor when focus moves between editors", () => {
document.body.innerHTML = `<div class="cm-content" contenteditable="true" tabindex="0">first</div><div class="cm-content" contenteditable="true" tabindex="0">second</div>`;
const [first, second] = document.querySelectorAll<HTMLElement>(".cm-content");
const dispose = installFocusTracking(() => {});
first!.focus();
document.getSelection()!.collapse(first!.firstChild, 3);
let caretLeft: Node | null = null;
const probe = () => (caretLeft = document.getSelection()!.anchorNode);
document.addEventListener("focusout", probe);
second!.focus();
document.removeEventListener("focusout", probe);
expect(caretLeft).toBe(first!.firstChild);
dispose();
});
it("drops the editor's caret when focus moves to a menu, so focusing the editor again cannot scroll to it", () => {
document.body.innerHTML = `<div class="cm-content" contenteditable="true" tabindex="0">first line</div><button id="other">x</button>`;
const editor = document.querySelector<HTMLElement>(".cm-content")!;
+3
Переглянути файл
@@ -39,6 +39,9 @@ function dropEditorCaret(e: FocusEvent) {
// The window lost focus and keeps the editor focused for its return.
if (document.activeElement === editor) return;
if (e.relatedTarget instanceof Node && editor.contains(e.relatedTarget)) return;
// Another editor takes the DOM selection itself; a hidden (detached) editor has none left.
// Dropping it would only lay out the window once more while switching editors.
if (!editor.isConnected || (e.relatedTarget instanceof Element && e.relatedTarget.matches(".cm-content"))) return;
const selection = document.getSelection();
if (selection?.anchorNode && editor.contains(selection.anchorNode)) selection.removeAllRanges();
}