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

+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>