дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
perf(editor): stop re-rendering breadcrumbs on every caret move (#256)
The status-bar breadcrumbs, the code breadcrumbs and the closed Navigation Bar popup subscribed to the whole editor status, which is replaced on every keystroke and caret move, so ~30 crumb buttons re-rendered per key. They now select the symbol chain at the caret with a shallow comparison (re-rendering only when it changes), and the popup subscribes to the caret only while open. Typing 38 characters: 1386 → 152 component renders. Co-authored-by: jaw52 <[email protected]>
Цей коміт міститься в:
4 files changed
+136
-16
No files matched your search
@@ -35,3 +35,27 @@ test("opening several files creates tabs and ⌘W closes the active one", async
|
||||
await expect(tabs).toHaveCount(1);
|
||||
await expect(tabs.first()).toHaveText(/build\.gradle\.kts/);
|
||||
});
|
||||
|
||||
test("breadcrumbs follow the caret into the symbol under it", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
const status = page.getByRole("navigation", { name: "Breadcrumbs", exact: true });
|
||||
const code = page.getByRole("navigation", { name: "Code breadcrumbs" });
|
||||
await editor.locator(".cm-line", { hasText: "service.find(status" }).click();
|
||||
await expect(status).toContainText("list()");
|
||||
await expect(code).toContainText("OrderController");
|
||||
await expect(code).toContainText("list()");
|
||||
// Typing inside the method keeps its crumb.
|
||||
await page.keyboard.press("Home");
|
||||
await page.keyboard.type("checked ");
|
||||
await expect(code).toContainText("list()");
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await expect(code).toContainText("create()");
|
||||
await expect(code).not.toContainText("list()");
|
||||
await expect(status).toContainText("create()");
|
||||
// ⌘↑ opens the navigation bar at the caret's symbol.
|
||||
await page.keyboard.press("Meta+ArrowUp");
|
||||
const bar = page.getByRole("toolbar", { name: "Navigation bar" });
|
||||
await expect(bar).toContainText("create()");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(bar).toBeHidden();
|
||||
});
|
||||
@@ -35,9 +35,14 @@ const close = () => useNavBarPopup.setState({ open: false });
|
||||
|
||||
export function NavBarPopup() {
|
||||
const open = useNavBarPopup((s) => s.open);
|
||||
// Closed nearly always: no subscription to the caret, which moves on every keystroke.
|
||||
return open ? <OpenNavBar /> : null;
|
||||
}
|
||||
|
||||
function OpenNavBar() {
|
||||
const seq = useNavBarPopup((s) => s.seq);
|
||||
const status = useEditorStatus((s) => s.status);
|
||||
if (!open || !status) return null;
|
||||
if (!status) return null;
|
||||
return <NavBar key={seq} path={status.path} line={status.line} col={status.col} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Status bar widgets of the active text editor (design footer). */
|
||||
import { Fragment, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { basename, dirname, relativePath, useProject } from "@/platform/project";
|
||||
import type { LineEnding } from "@/platform/ipc";
|
||||
import type { LineEnding, OutlineItem } from "@/platform/ipc";
|
||||
import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { ENCODING_CHOICES, useEditorPrefs } from "@/platform/editorSettings";
|
||||
@@ -23,6 +24,23 @@ function useActiveMeta() {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const NO_SYMBOLS: OutlineItem[] = [];
|
||||
|
||||
/**
|
||||
* Symbols at the caret while `path` is the active editor, `null` otherwise. Selected
|
||||
* from the status store so callers re-render when the chain changes, not on every
|
||||
* keystroke or caret move.
|
||||
*/
|
||||
function useCaretSymbols(path: string | null, outline: readonly OutlineItem[] | undefined) {
|
||||
return useEditorStatus(
|
||||
useShallow((s) => {
|
||||
const st = s.status;
|
||||
if (!st || st.path !== path) return null;
|
||||
return outline ? symbolChain(outline, st.line, st.col) : NO_SYMBOLS;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
interface Crumb {
|
||||
label: string;
|
||||
icon?: string;
|
||||
@@ -37,15 +55,12 @@ interface Crumb {
|
||||
* symbols, with icons; clicking a segment opens a popup of its children.
|
||||
*/
|
||||
export function Breadcrumbs() {
|
||||
const status = useEditorStatus((s) => s.status);
|
||||
const path = useEditorStatus((s) => s.status?.path ?? null);
|
||||
const project = useProject((s) => s.project);
|
||||
const outline = useOutline((s) => (status ? s[status.path] : undefined));
|
||||
const line = status?.line ?? 0;
|
||||
const col = status?.col ?? 0;
|
||||
const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]);
|
||||
const outline = useOutline((s) => (path ? s[path] : undefined));
|
||||
const chain = useCaretSymbols(path, outline) ?? NO_SYMBOLS;
|
||||
const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null);
|
||||
if (!status) return null;
|
||||
const path = status.path;
|
||||
if (!path) return null;
|
||||
const parent = dirname(path);
|
||||
const segments: Crumb[] = [];
|
||||
if (project)
|
||||
@@ -137,12 +152,12 @@ export function CodeBreadcrumbs({
|
||||
const prefs = useEditorPrefs().breadcrumbs;
|
||||
const language = useDocMeta((s) => s[path]?.language);
|
||||
const outline = useOutline((s) => s[path]);
|
||||
const status = useEditorStatus((s) => (s.status?.path === path ? s.status : null));
|
||||
const slot = editorController().slots.get(tabId);
|
||||
const caret = status ?? (slot ? slot.caret() : null);
|
||||
const line = caret?.line ?? 0;
|
||||
const col = caret?.col ?? 0;
|
||||
const chain = useMemo(() => (outline ? symbolChain(outline, line, col) : []), [outline, line, col]);
|
||||
let chain = useCaretSymbols(path, outline);
|
||||
if (!chain) {
|
||||
// Not the active editor (another split): its own caret, as of this render.
|
||||
const caret = editorController().slots.get(tabId)?.caret();
|
||||
chain = outline ? symbolChain(outline, caret?.line ?? 0, caret?.col ?? 0) : NO_SYMBOLS;
|
||||
}
|
||||
if (
|
||||
!prefs.show ||
|
||||
prefs.placement !== position ||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Profiler } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
import type { EditorStatus } from "../controller";
|
||||
|
||||
vi.mock("../controller", async () => {
|
||||
const { create } = await import("zustand");
|
||||
return {
|
||||
editorController: () => ({ openFile: vi.fn(), slots: new Map() }),
|
||||
useEditorStatus: create<{ status: EditorStatus | null }>(() => ({ status: null })),
|
||||
useGotoLine: create(() => ({ open: false })),
|
||||
};
|
||||
});
|
||||
|
||||
const { useEditorStatus } = await import("../controller");
|
||||
const { useOutline } = await import("../model/document");
|
||||
const { Breadcrumbs, CodeBreadcrumbs } = await import("./StatusItems");
|
||||
const { NavBarPopup } = await import("./NavBarPopup");
|
||||
|
||||
const PATH = "/p/Orders.ts";
|
||||
const span = (from: number, to: number) => ({ start: { line: from, col: 0 }, end: { line: to, col: 0 } });
|
||||
const item = (name: string, from: number, to: number, children: OutlineItem[] = []): OutlineItem =>
|
||||
({
|
||||
name,
|
||||
detail: null,
|
||||
kind: "method",
|
||||
range: span(from, to),
|
||||
selection_range: span(from, from),
|
||||
children,
|
||||
}) as OutlineItem;
|
||||
|
||||
const caret = (line: number, col: number): EditorStatus => ({
|
||||
path: PATH,
|
||||
doc: 1,
|
||||
line,
|
||||
col,
|
||||
offset: line * 80 + col,
|
||||
selectedChars: 0,
|
||||
selectedLines: 0,
|
||||
carets: 1,
|
||||
});
|
||||
|
||||
describe("caret-driven status widgets", () => {
|
||||
it("re-render when the symbol at the caret changes, not on every caret move", () => {
|
||||
useOutline.setState({ [PATH]: [item("Orders", 0, 40, [item("place", 2, 10), item("cancel", 12, 20)])] });
|
||||
act(() => useEditorStatus.setState({ status: caret(3, 4) }));
|
||||
let commits = 0;
|
||||
const count = () => void commits++;
|
||||
render(
|
||||
<>
|
||||
<Profiler id="breadcrumbs" onRender={count}>
|
||||
<Breadcrumbs />
|
||||
</Profiler>
|
||||
<Profiler id="code" onRender={count}>
|
||||
<CodeBreadcrumbs path={PATH} tabId="t1" position="bottom" />
|
||||
</Profiler>
|
||||
<Profiler id="navbar" onRender={count}>
|
||||
<NavBarPopup />
|
||||
</Profiler>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getAllByText("place")).toHaveLength(2);
|
||||
commits = 0;
|
||||
|
||||
// Typing inside `place`: line and column move, the chain does not.
|
||||
for (let col = 5; col < 25; col++) act(() => useEditorStatus.setState({ status: caret(4, col) }));
|
||||
expect(commits).toBe(0);
|
||||
|
||||
// Into `cancel`: both breadcrumbs follow.
|
||||
act(() => useEditorStatus.setState({ status: caret(14, 0) }));
|
||||
expect(screen.getAllByText("cancel")).toHaveLength(2);
|
||||
expect(screen.queryByText("place")).toBeNull();
|
||||
expect(commits).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача