Файли
openidea/src/ui/Tree.test.tsx
T
l0ng-ai 5804fcad16 perf: speed up Angular typing, Git Log, console output and start-up; measure more interactions (#1901)
* perf(project-tree): measure row widths once per frame

The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.

* perf(github): load the pull requests integration after start-up

Actions, the tool window and the timeline editor stay registered at start-up;
their implementations, the connection and review-mode tracking load on idle
or on first use. Takes ~60 KB gzip off the initial bundle.

* perf(templates): load Emmet when an HTML or stylesheet editor opens

The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.

* perf(search, run): load popups and Edit Configurations when first shown

Search Everywhere, Find in Files, the recent files/locations popups, File
Structure and the Run/Debug Configurations dialog were part of the initial
bundle although each only renders while open. `lazyWhen` mounts a lazy view
only while its condition holds; the idle prefetch still loads them right after
start-up, so opening one stays as fast.

* perf: load dialogs, the SQL console and diff views on first use

- Database dialogs, Assign Shortcut and the Logcat tool window load when shown.
- The SQL console extensions load with the first SQL editor
  (`lazyEditorExtension` reconfigures the editor once they arrive).
- The changes tree loads the diff viewer when a file is selected, and
  "Open Repository Log" loads the revision browser when run.

* perf: load plugin, debugger and project popups when first shown

The IntelliJ theme import, the debugger's popovers and dialogs and the title
bar's project popup now load when first opened. `lazyWhen` keeps a view
mounted once shown, so its own open/closed handling is unchanged.

* perf(angular): parse only text with Angular syntax as Angular

Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.

* perf(angular): reuse nested trees of unchanged text and attributes

An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.

Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.

* perf: budget typing in a large Angular template

* perf: measure code completion over a 2,000-item list

The harness's probe can answer completion with generated items through the
command route; language server requests go through a route when one is
installed, not only under Tauri. The scenario opens the popup by typing,
narrows it letter by letter and moves the selection, with budgets for each.

* perf(run): show a flood of console output at most every 50 ms

While a process printed thousands of lines a second, the Run console
appended, re-rendered and laid out on every frame, so typing in an editor
meanwhile missed the next frame 35% of the time. A line after a quiet spell
still shows on the next frame; a flood now updates the console at ~20 Hz,
and typing during it misses 2-5% (p50 16 -> 9.7 ms) with the output done as
fast as before.

The performance harness gets a scenario for it: 50,000 log lines at 200 a
frame (a dev and perf build action, run.sampleOutput) while typing.

* perf(vcs): keep Git Log rows from re-rendering on every selection change

Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.

* perf: measure the Git Log and splitter dragging

gitLog shows 20k generated commits served page by page through a fake vcs_log,
walks the selection with the arrow keys and wheel-scrolls the list.
resizePanels drags the side and bottom splitters with an editor, the Project
tree and the Log open. The perf probe's fake backends can now be combined.

* perf: translate the sample output action and keep fake completion details out of the text catalog

* perf(vcs): keep Git Log scrolling smooth as pages load

Each page of the log recomputed hidden long edges by scanning every row once per
lane, and the store laid out the whole graph a second time for fields nothing
read. Long edges are now found in one pass over the rows, the unused store graph
is gone, and the next page is requested while a page of rows is still ahead so
fast scrolling doesn't reach the end before git answers. The commit list also
contains its layout, so rows entering while scrolling no longer relay out the
workbench.

* perf: load the Git Log scenario's pages with git's latency and scroll across several
2026-11-07 10:56:59 +08:00

527 рядки
19 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { createRef } from "react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { Tree, type TreeHandle } from "./Tree";
import { getContext } from "@/platform/context";
interface N {
id: string;
children?: N[];
}
const data: N[] = [
{
id: "src",
children: [{ id: "main", children: [{ id: "App.tsx" }, { id: "index.ts" }] }, { id: "util.ts" }],
},
{ id: "README.md" },
];
function setup(props: Partial<React.ComponentProps<typeof Tree<N>>> = {}) {
const onActivate = vi.fn();
const utils = render(
<Tree<N>
aria-label="Files"
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
onActivate={onActivate}
{...props}
/>,
);
const tree = screen.getByRole("tree");
tree.focus();
const lead = () => {
const id = tree.getAttribute("aria-activedescendant");
return id ? document.getElementById(id)?.textContent : null;
};
const key = (k: string, init: Partial<KeyboardEventInit> = {}) => fireEvent.keyDown(tree, { key: k, ...init });
return { ...utils, tree, lead, key, onActivate };
}
const labels = (tree: HTMLElement) =>
within(tree)
.queryAllByRole("treeitem")
.map((el) => el.textContent);
describe("Tree directory context", () => {
it("does not contribute virtual document URIs as filesystem directories", () => {
const { key } = setup({ getDirectory: () => "jdt://contents/library.jar/package" });
key("Home");
expect(getContext().focusedDirectory).toBeUndefined();
});
it("follows the selected lead, clears on non-path rows, blur and unmount", () => {
const { tree, key, unmount } = setup({
getDirectory: (n) => (n.id === "main" ? null : `/p/${n.id}`),
});
expect(getContext().focusedDirectory).toBeUndefined();
key("ArrowDown");
expect(getContext().focusedDirectory).toBe("/p/src");
key("End", { shiftKey: true });
expect(getContext().focusedDirectory).toBe("/p/README.md");
key("Home");
key("ArrowRight");
key("ArrowRight");
expect(getContext().focusedDirectory).toBeUndefined();
key("Home");
fireEvent.blur(tree);
expect(getContext().focusedDirectory).toBeUndefined();
fireEvent.focus(tree);
expect(getContext().focusedDirectory).toBe("/p/src");
unmount();
expect(getContext().focusedDirectory).toBeUndefined();
});
it("updates a controlled selection while focused and clears a removed selection", () => {
const view = (selected: Set<string>, roots = data) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
selected={selected}
getDirectory={(n) => `/p/${n.id}`}
/>
);
const { rerender } = render(view(new Set(["src"])));
screen.getByRole("tree").focus();
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set()));
expect(getContext().focusedDirectory).toBeUndefined();
rerender(view(new Set(["src"])));
expect(getContext().focusedDirectory).toBe("/p/src");
rerender(view(new Set(["src"]), []));
expect(getContext().focusedDirectory).toBeUndefined();
});
it("clears on focus transfer and an old tree's unmount does not clear the new owner", () => {
const view = (showFirst: boolean) => (
<>
{showFirst && (
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/first"}
/>
)}
<Tree<N>
roots={data}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={() => []}
isExpandable={() => false}
defaultSelected={["src"]}
getDirectory={() => "/second"}
/>
<input aria-label="Editor" />
</>
);
const { rerender } = render(view(true));
const [first, second] = screen.getAllByRole("tree");
first!.focus();
expect(getContext().focusedDirectory).toBe("/first");
second!.focus();
expect(getContext().focusedDirectory).toBe("/second");
rerender(view(false));
expect(getContext().focusedDirectory).toBe("/second");
screen.getByLabelText("Editor").focus();
expect(getContext().focusedDirectory).toBeUndefined();
});
});
describe("Tree keyboard navigation", () => {
it("moves with arrows, expands with Right and collapses / goes to parent with Left", () => {
const { tree, lead, key } = setup();
expect(labels(tree)).toEqual(["src", "README.md"]);
key("ArrowDown");
expect(lead()).toBe("src");
key("ArrowRight");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("ArrowRight");
expect(lead()).toBe("main");
key("ArrowRight");
key("ArrowDown");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(lead()).toBe("main");
key("ArrowLeft");
expect(labels(tree)).toEqual(["src", "main", "util.ts", "README.md"]);
key("End");
expect(lead()).toBe("README.md");
key("Home");
expect(lead()).toBe("src");
});
it("Enter toggles folders and activates leaves", () => {
const { tree, lead, key, onActivate } = setup();
key("ArrowDown");
key("Enter");
expect(labels(tree)).toContain("main");
key("End");
expect(lead()).toBe("README.md");
key("Enter");
expect(onActivate).toHaveBeenCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("F4 and ⌘↓ activate like Enter (Jump to Source)", () => {
const { key, onActivate } = setup();
key("End");
key("F4");
expect(onActivate).toHaveBeenCalledTimes(1);
key("ArrowDown", { metaKey: true });
expect(onActivate).toHaveBeenCalledTimes(2);
expect(onActivate).toHaveBeenLastCalledWith(expect.objectContaining({ id: "README.md" }), expect.anything());
});
it("extends the selection with Shift+Arrow", () => {
const onSelectionChange = vi.fn();
const { key } = setup({ defaultExpanded: ["src"], onSelectionChange });
key("ArrowDown");
key("ArrowDown", { shiftKey: true });
key("ArrowDown", { shiftKey: true });
const last = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect([...last].sort()).toEqual(["main", "src", "util.ts"]);
});
it("lazy-loads children on expand", () => {
const loadChildren = vi.fn();
const { tree, key } = setup({ getChildren: () => undefined, loadChildren });
key("ArrowDown");
key("ArrowRight");
expect(loadChildren).toHaveBeenCalledWith(expect.objectContaining({ id: "src" }));
expect(tree.textContent).toContain("Loading");
});
});
describe("Tree speed search", () => {
it("jumps to and highlights matches, cycles with arrows and clears with Escape", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("i");
key("n");
key("d");
expect(lead()).toBe("index.ts");
expect(screen.getByRole("status")).toHaveTextContent("ind");
expect([...tree.querySelectorAll("mark")].map((m) => m.textContent)).toEqual(["ind"]);
key("Backspace");
// "in" matches "main" (above) and "index.ts": arrows cycle between matches.
key("ArrowUp");
expect(lead()).toBe("main");
key("ArrowDown");
expect(lead()).toBe("index.ts");
key("ArrowDown");
expect(lead()).toBe("main");
key("Escape");
expect(screen.queryByRole("status")).toBeNull();
expect(tree.querySelectorAll("mark")).toHaveLength(0);
});
it("Home / End jump to the first / last match; Left closes the search and collapses", () => {
const { tree, lead, key } = setup({ defaultExpanded: ["src", "main"] });
key("ArrowDown");
key("t");
key("s");
// "ts" matches App.tsx, index.ts and util.ts.
key("End");
expect(lead()).toBe("util.ts");
key("Home");
expect(lead()).toBe("App.tsx");
key("ArrowLeft");
expect(screen.queryByRole("status")).toBeNull();
expect(lead()).toBe("main");
expect(labels(tree)).toContain("App.tsx");
});
it("filter mode hides non-matching rows but keeps ancestors", () => {
const { tree, key } = setup({ defaultExpanded: ["src", "main"], speedSearchMode: "filter" });
key("ArrowDown");
for (const c of "App") key(c);
expect(labels(tree)).toEqual(["src", "main", "App.tsx"]);
});
});
describe("Tree occurrences", () => {
it("selects the next / previous leaf, expanding its ancestors", () => {
const handle = createRef<TreeHandle>();
const onSelectionChange = vi.fn();
const { tree, lead } = setup({ handleRef: handle, onSelectionChange });
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1, total: 4 });
expect(labels(tree)).toEqual(["src", "main", "App.tsx", "index.ts", "util.ts", "README.md"]);
expect(lead()).toBe("App.tsx");
expect(onSelectionChange).toHaveBeenLastCalledWith(
new Set(["App.tsx"]),
expect.objectContaining({ id: "App.tsx" }),
);
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "index.ts", index: 2 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toMatchObject({ key: "App.tsx", index: 1 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
expect(lead()).toBe("App.tsx");
});
it("starts from the selected group and honours isOccurrence", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, isOccurrence: (n) => n.id !== "src" });
key("ArrowDown"); // src
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1)));
expect(step).toMatchObject({ key: "main", index: 1, total: 5 });
act(() => void (step = handle.current!.selectOccurrence(-1)));
expect(step).toBeNull();
});
it("skips the lead's subtree on request", () => {
const handle = createRef<TreeHandle>();
const { key } = setup({ handleRef: handle, defaultExpanded: ["src"] });
key("ArrowDown");
key("ArrowDown"); // main
let step: ReturnType<TreeHandle["selectOccurrence"]> = null;
act(() => void (step = handle.current!.selectOccurrence(1, { skipSubtree: true })));
expect(step).toMatchObject({ key: "util.ts", index: 3 });
});
});
describe("siblingPositions", () => {
it("numbers rows among siblings", async () => {
const { siblingPositions } = await import("./Tree");
expect(
siblingPositions([{ parentKey: null }, { parentKey: "a" }, { parentKey: "a" }, { parentKey: null }]),
).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
]);
});
});
describe("Tree horizontal scrolling", () => {
const spacer = (tree: HTMLElement) => tree.querySelector<HTMLElement>("[data-content-width]");
// Widths are measured on the next animation frame.
const frame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
it("is off unless asked for", () => {
const { tree } = setup();
expect(spacer(tree)).toBeNull();
});
it("remeasures mounted rows with the same key, including narrower content", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640);
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
expect(spacer(tree)?.style.width).toBe("640px");
width.mockReturnValue(100);
key("ArrowDown");
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("measures once per frame however many times it renders", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640);
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
width.mockClear();
key("ArrowDown");
key("ArrowDown");
key("ArrowDown");
expect(width).not.toHaveBeenCalled();
await frame();
expect(width).toHaveBeenCalledTimes(tree.querySelectorAll("[data-row]").length);
} finally {
width.mockRestore();
}
});
it("removes widths belonging to collapsed descendants", async () => {
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
return this.textContent === "util.ts" ? 640 : 100;
});
try {
const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] });
await frame();
expect(spacer(tree)?.style.width).toBe("640px");
key("Home");
key("ArrowLeft");
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
} finally {
width.mockRestore();
}
});
it("measures without the viewport minimum and restores it before returning", async () => {
const minimums: string[] = [];
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(function (
this: HTMLElement,
) {
if (this.hasAttribute("data-row")) minimums.push(this.style.minWidth);
return parseFloat(this.style.minWidth) === 0 ? 100 : 800;
});
try {
const { tree } = setup({ horizontalScroll: true });
await frame();
expect(spacer(tree)?.style.width).toBe("100px");
expect(minimums.length).toBeGreaterThan(0);
expect(minimums.every((value) => parseFloat(value) === 0)).toBe(true);
for (const row of tree.querySelectorAll<HTMLElement>("[data-row]")) expect(row.style.minWidth).toBe("");
} finally {
width.mockRestore();
}
});
});
describe("Tree scroll following", () => {
let scrollTo: ReturnType<typeof vi.fn>;
let original: PropertyDescriptor | undefined;
beforeAll(() => {
// jsdom has no Element.scrollTo; virtual-core calls it to write scrollTop.
scrollTo = vi.fn();
original = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTo");
Object.defineProperty(Element.prototype, "scrollTo", { value: scrollTo, configurable: true, writable: true });
});
afterAll(() => {
if (original) Object.defineProperty(Element.prototype, "scrollTo", original);
else delete (Element.prototype as { scrollTo?: unknown }).scrollTo;
});
it("brings the keyboard lead into view", () => {
const { key } = setup();
scrollTo.mockClear();
key("End");
expect(scrollTo).toHaveBeenCalled();
});
it("keeps the viewport when expanding a folder above the selection", () => {
// The selected file stays selected while rows inserted above it shift its
// index; scrolling then would jump to the selection from the opened folder.
const { tree } = setup({ defaultSelected: ["README.md"] });
scrollTo.mockClear();
const chevron = tree.querySelector("[data-tree-toggle]");
expect(chevron).not.toBeNull();
fireEvent.mouseDown(chevron!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
// The folder did open, inserting rows above the selection.
expect(within(tree).queryAllByRole("treeitem").length).toBeGreaterThan(2);
});
it.each(["defaultSelected", "selected"] as const)("scrolls a late %s lead once when its row loads", (selection) => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => n.id === "src" || !!n.children}
defaultExpanded={["src"]}
{...{ [selection]: new Set(["late.ts"]) }}
/>
);
const { rerender } = render(view([{ id: "src" }]));
scrollTo.mockClear();
// An intermediate load still does not contain the selected row.
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }] }]));
expect(scrollTo).not.toHaveBeenCalled();
rerender(view([{ id: "src", children: [{ id: "main", children: [{ id: "App.tsx" }] }, { id: "late.ts" }] }]));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(screen.getByRole("treeitem", { name: "late.ts" })).toHaveAttribute("aria-selected", "true");
scrollTo.mockClear();
const folder = screen.getByRole("treeitem", { name: "main" });
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(screen.getByRole("treeitem", { name: "App.tsx" })).toBeInTheDocument();
expect(scrollTo).not.toHaveBeenCalled();
fireEvent.mouseDown(folder.querySelector("[data-tree-toggle]")!, { button: 0 });
expect(scrollTo).not.toHaveBeenCalled();
});
it("replaces a pending lead when the user selects another row", () => {
const view = (roots: N[]) => (
<Tree<N>
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => n.id}
getChildren={(n) => n.children}
isExpandable={(n) => !!n.children}
defaultSelected={["late.ts"]}
/>
);
const { rerender } = render(view([{ id: "README.md" }]));
fireEvent.keyDown(screen.getByRole("tree"), { key: "End" });
scrollTo.mockClear();
rerender(view([{ id: "late.ts" }, { id: "README.md" }]));
expect(screen.getByRole("treeitem", { name: "README.md" })).toHaveAttribute("aria-selected", "true");
expect(scrollTo).not.toHaveBeenCalled();
});
it("explicitly reveals an unchanged lead", () => {
const handle = createRef<TreeHandle>();
setup({ handleRef: handle, defaultSelected: ["README.md"] });
scrollTo.mockClear();
act(() => handle.current!.reveal("README.md"));
expect(scrollTo).toHaveBeenCalledTimes(1);
});
it("brings speed search matches into view", () => {
const { key, lead } = setup({ defaultExpanded: ["src", "main"] });
key("Home");
scrollTo.mockClear();
key("u");
expect(lead()).toBe("util.ts");
expect(scrollTo).toHaveBeenCalledTimes(1);
});
});
describe("Tree indents", () => {
// Settings › Appearance › "Use smaller indents in trees" sets `--tree-indent`
// on the root; the row padding reads it so the setting reaches every tree at
// once, without a restart, and chevrons keep aligning with it.
const pads = (root: HTMLElement) =>
within(root)
.queryAllByRole("treeitem")
.map((el) => el.style.paddingLeft);
it("pads each level through --tree-indent, falling back to the indent prop", () => {
const { tree } = setup({ defaultExpanded: ["src"] });
expect(pads(tree)).toEqual([
"calc(4px + 0 * var(--tree-indent, 20px))", // src
"calc(4px + 1 * var(--tree-indent, 20px))", // main
"calc(4px + 1 * var(--tree-indent, 20px))", // util.ts
"calc(4px + 0 * var(--tree-indent, 20px))", // README.md
]);
});
it("honours a custom indent prop as the fallback value", () => {
const { tree } = setup({ defaultExpanded: ["src"], indent: 16 });
expect(pads(tree)[1]).toBe("calc(4px + 1 * var(--tree-indent, 16px))");
});
it("indents lazy-loading placeholder rows past their parent level", () => {
const { tree, key } = setup({ getChildren: () => undefined, loadChildren: () => undefined });
key("ArrowDown");
key("ArrowRight");
// Loading placeholders are `role="presentation"`, not tree items.
const placeholder = tree.querySelector<HTMLElement>('[role="presentation"]');
expect(placeholder?.style.paddingLeft).toBe("calc(24px + 1 * var(--tree-indent, 20px))");
});
});