дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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).
Цей коміт міститься в:
1 джерело
eb72b78abb
коміт
502cc348ba
22 files changed
+344
-87
No files matched your search
@@ -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([]) });
|
||||
}
|
||||
|
||||
@@ -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,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)];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача