From 5804fcad1659451229204b0c4c630f7f06b0e408 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 10:56:59 +0800 Subject: [PATCH] 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 --- perf/budgets.json | 30 +++ perf/scenarios.ts | 187 ++++++++++++++++++ src/editor/api.ts | 41 ++++ src/editor/core/cm/angular/angular.test.ts | 35 +++- src/editor/core/cm/angular/index.ts | 94 ++++++++- src/editor/lazyEditorExtension.test.ts | 26 +++ src/editor/lsp/lsp.test.ts | 18 +- src/editor/lsp/lsp.ts | 4 +- src/features/database/console.tsx | 3 +- src/features/database/index.ts | 27 ++- src/features/database/sqlLanguage.ts | 2 + src/features/debugger/index.ts | 13 +- src/features/github/index.ts | 166 +++++----------- src/features/github/runtime.ts | 127 ++++++++++++ src/features/github/shell.ts | 5 + .../toolwindow/PullRequestsToolWindow.tsx | 6 +- src/features/logcat/index.ts | 4 +- src/features/plugins/index.ts | 12 +- src/features/plugins/intellijImport.ts | 22 +-- .../plugins/intellijThemesDialogStore.ts | 18 ++ src/features/run/consoleFlush.test.ts | 28 +++ src/features/run/index.ts | 26 ++- src/features/run/store.ts | 82 +++++++- src/features/search/index.ts | 40 ++-- .../search/structure/StructureView.tsx | 3 +- src/features/search/structure/popupStore.ts | 4 + src/features/settings/index.ts | 13 +- .../settings/keymap/AssignShortcut.tsx | 8 +- .../settings/keymap/assignShortcutStore.ts | 8 + .../templates/__tests__/emmet.test.ts | 6 +- src/features/templates/editor.ts | 2 + src/features/templates/emmet/model.ts | 31 ++- src/features/templates/index.ts | 3 +- src/features/vcs/ComparisonTree.tsx | 5 +- src/features/vcs/GitLog.module.css | 2 + src/features/vcs/GitLog.tsx | 122 ++++++++---- .../vcs/__tests__/directoryCompare.test.tsx | 4 +- .../vcs/__tests__/folderCompare.test.tsx | 2 +- src/features/vcs/__tests__/logLoading.test.ts | 1 - .../vcs/__tests__/logUiFeatures.test.tsx | 1 - src/features/vcs/__tests__/logViews.test.tsx | 1 - src/features/vcs/__tests__/multiRoot.test.ts | 3 - src/features/vcs/graph.ts | 69 ++++--- src/features/vcs/historyActions.tsx | 3 +- src/features/vcs/logStore.ts | 37 +--- src/features/welcome/index.ts | 4 +- src/platform/dateFormat.test.ts | 23 +++ src/platform/dateFormat.ts | 20 +- src/platform/locales/text/zh.json | 1 + src/platform/tauriCore.ts | 5 + src/ui/Tree.test.tsx | 30 ++- src/ui/Tree.tsx | 77 +++++--- src/ui/index.ts | 2 +- src/ui/lazy.tsx | 18 ++ src/workbench/perfProbe.ts | 135 ++++++++++++- 55 files changed, 1298 insertions(+), 361 deletions(-) create mode 100644 src/editor/lazyEditorExtension.test.ts create mode 100644 src/features/database/sqlLanguage.ts create mode 100644 src/features/github/runtime.ts create mode 100644 src/features/github/shell.ts create mode 100644 src/features/plugins/intellijThemesDialogStore.ts create mode 100644 src/features/run/consoleFlush.test.ts create mode 100644 src/features/search/structure/popupStore.ts create mode 100644 src/features/settings/keymap/assignShortcutStore.ts create mode 100644 src/platform/dateFormat.test.ts diff --git a/perf/budgets.json b/perf/budgets.json index 783e64ec7..0c2d610cd 100644 --- a/perf/budgets.json +++ b/perf/budgets.json @@ -26,6 +26,36 @@ "typingLarge.mainThreadMsPerKey": 20, "typingLarge.tbtMs": 50, + "typingAngular.openMs": 200, + "typingAngular.p95Ms": 30, + "typingAngular.maxMs": 40, + "typingAngular.mainThreadMsPerKey": 30, + "typingAngular.tbtMs": 50, + + "completion.openMs": 150, + "completion.narrowP95Ms": 20, + "completion.narrowMissedFramePct": 2, + "completion.narrowMainThreadMsPerKey": 35, + "completion.arrowP95Ms": 20, + "completion.arrowMissedFramePct": 2, + "completion.tbtMs": 50, + + "consoleOutput.streamMs": 4600, + "consoleOutput.droppedFramesPct": 2, + "consoleOutput.keyP95Ms": 20, + "consoleOutput.keyMissedFramePct": 10, + "consoleOutput.tbtMs": 50, + + "gitLog.showMs": 300, + "gitLog.droppedFramesPct": 2, + "gitLog.tbtMs": 50, + "gitLog.arrowKeyP95Ms": 20, + "gitLog.arrowKeyMissedFramePct": 5, + + "resizePanels.droppedFramesPct": 2, + "resizePanels.bottomDroppedFramesPct": 2, + "resizePanels.tbtMs": 50, + "keyRepeat.p95Ms": 20, "keyRepeat.maxMs": 33, "keyRepeat.missedFramePct": 2, diff --git a/perf/scenarios.ts b/perf/scenarios.ts index 567dcb5d4..9c194595a 100644 --- a/perf/scenarios.ts +++ b/perf/scenarios.ts @@ -110,6 +110,88 @@ const typingAngular: Scenario = { }, }; +/** + * Code completion with a language server's 2,000 items: the first letter opens + * the popup (after CodeMirror's 100 ms typing delay), each further letter + * narrows it, arrows move the selection. + */ +const completion: Scenario = { + id: "completion", + title: "Code completion over 2,000 items", + async run(h) { + await h.page.evaluate(() => + (window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(2_000), + ); + try { + await h.openFile(`${ROOT}/src/completion/Orders.ts`, tsSource(2_000, "completion")); + const st = await h.editorState(); + await h.setCursor(Math.floor((st?.length ?? 0) / 2)); + await h.parserIdle(); + await h.page.keyboard.press("End"); + await h.page.keyboard.press("Enter"); + const refresh = await h.refreshMs(); + const openMs = await h.measure(() => h.page.keyboard.press("g"), ".cm-tooltip-autocomplete li", "Result"); + const t0 = await h.now(); + const before = await h.counters(); + const narrow = await h.typeMeasured("etOrd", 150); + const work = Harness.delta(before, await h.counters()); + const arrows = await h.pressMeasured(Array(20).fill("ArrowDown"), 60); + await h.page.keyboard.press("Escape"); + return { + openMs, + ...keySummary(narrow, refresh, "narrow"), + narrowMainThreadMsPerKey: work.taskMs / 5, + ...keySummary(arrows, refresh, "arrow"), + tbtMs: totalBlockingTime(await h.longTasksSince(t0)), + }; + } finally { + await h.page.evaluate(() => + (window as never as { __openideaPerf: { fakeCompletion(n: number): void } }).__openideaPerf.fakeCompletion(0), + ); + } + }, +}; + +/** + * A process printing 50,000 log lines (stack frames among them) at 200 lines a + * frame (~4 s) into the Run console, while the user types in an editor: the + * console must keep up and the editor must stay responsive. + */ +const consoleOutput: Scenario = { + id: "consoleOutput", + title: "Type while the Run console streams 50k lines", + async run(h) { + await h.openFile(SAMPLE); + const st = await h.editorState(); + await h.setCursor(Math.floor((st?.length ?? 0) / 2)); + await h.page.keyboard.press("End"); + await h.page.keyboard.press("Enter"); + const refresh = await h.refreshMs(); + const t0 = await h.now(); + await h.inPage((p) => p.startFrames()); + const streamMs = h.page.evaluate(async () => { + const w = window as never as { __openideaPerf: { runAction(id: string, arg?: unknown): Promise } }; + const started = performance.now(); + await w.__openideaPerf.runAction("run.sampleOutput", { lines: 50_000, perFrame: 200 }); + return performance.now() - started; + }); + // The console takes focus with it; type back in the editor while it streams. + await h.page.waitForTimeout(100); + await h.setCursor((await h.editorState())?.head ?? 0); + const keys = await h.typeMeasured(TYPED.slice(0, 40), 70); + const total = await streamMs; + const frames = await h.inPage((p) => p.stopFrames()); + await h.inPage((p) => p.waitShown("body", "processed order #49998", 5_000)); + return { + // 250 frames of output at the display rate is the floor. + streamMs: total, + ...frameSummary(frames, refresh), + ...keySummary(keys, refresh, "key"), + tbtMs: totalBlockingTime(await h.longTasksSince(t0)), + }; + }, +}; + /** Held key: auto-repeat at ~30 Hz must never fall behind. */ const keyRepeat: Scenario = { id: "keyRepeat", @@ -185,6 +267,107 @@ const scroll: Scenario = { }, }; +/** + * Git Log with 20,000 commits on branching lanes, loaded page by page as the + * backend does (150 ms a page, like `git log`): showing it, walking the + * selection with the arrow keys (the details pane follows) and wheel-scrolling + * through several pages. + */ +const gitLog: Scenario = { + id: "gitLog", + title: "Show, navigate and scroll a 20k-commit Git Log", + async run(h) { + await h.page.evaluate(() => + ( + window as never as { __openideaPerf: { fakeGitLog(n: number, latencyMs?: number): void } } + ).__openideaPerf.fakeGitLog(20_000, 150), + ); + const showMs = await h.measure( + () => + h.page.evaluate(() => + (window as never as { __openideaPerf: { runAction(id: string): Promise } }).__openideaPerf.runAction( + "vcs.showLog", + ), + ), + '[role="listbox"][aria-label="Commits"]', + "order flow step 0", + ); + const refresh = await h.refreshMs(); + const list = h.page.getByRole("listbox", { name: "Commits" }); + await list.getByRole("option").first().click(); + const keyLat = await h.pressMeasured(Array(40).fill("ArrowDown"), 50); + const box = (await list.boundingBox())!; + await h.page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + const t0 = await h.now(); + await h.inPage((p) => p.startFrames()); + // Far enough to load several more pages on the way. + for (let i = 0; i < 160; i++) { + await h.page.mouse.wheel(0, i < 120 ? 600 : -600); + await h.page.waitForTimeout(16); + } + await h.page.waitForTimeout(200); + const frames = await h.inPage((p) => p.stopFrames()); + return { + showMs, + ...frameSummary(frames, refresh), + tbtMs: totalBlockingTime(await h.longTasksSince(t0)), + ...keySummary(keyLat, refresh, "arrowKey"), + }; + }, +}; + +/** + * Dragging the splitters with an editor, the Project tree and the Git Log + * open: every pointer move resizes the islands, so each frame relays them out. + */ +const resizePanels: Scenario = { + id: "resizePanels", + title: "Drag the tool window splitters", + async run(h) { + await h.openFile(SAMPLE); + await h.page.evaluate(() => + (window as never as { __openideaPerf: { fakeGitLog(n: number): void } }).__openideaPerf.fakeGitLog(2_000), + ); + await h.measure( + () => + h.page.evaluate(() => + (window as never as { __openideaPerf: { runAction(id: string): Promise } }).__openideaPerf.runAction( + "vcs.showLog", + ), + ), + '[role="listbox"][aria-label="Commits"]', + "order flow step 0", + ); + const refresh = await h.refreshMs(); + const drag = async (name: string, dx: number, dy: number) => { + const box = (await h.page.getByRole("separator", { name }).boundingBox())!; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await h.page.mouse.move(x, y); + await h.page.mouse.down(); + await h.inPage((p) => p.startFrames()); + // Out and back twice, one pointer move a frame. + for (let i = 0; i < 120; i++) { + const t = Math.sin((i / 60) * Math.PI); + await h.page.mouse.move(x + dx * t, y + dy * t); + await h.page.waitForTimeout(16); + } + await h.page.mouse.up(); + await h.page.waitForTimeout(200); + return frameSummary(await h.inPage((p) => p.stopFrames()), refresh); + }; + const t0 = await h.now(); + const side = await drag("Resize left tool window", 300, 0); + const bottom = await drag("Resize bottom tool window", 0, -250); + return { + ...side, + tbtMs: totalBlockingTime(await h.longTasksSince(t0)), + bottomFrameP95Ms: bottom.frameP95Ms, + bottomDroppedFramesPct: bottom.droppedFramesPct, + }; + }, +}; + /** Cold load: navigation → Project tree painted. */ const startup: Scenario = { id: "startup", @@ -369,6 +552,10 @@ export const SCENARIOS: Scenario[] = [ typing, typingLarge, typingAngular, + completion, + consoleOutput, + gitLog, + resizePanels, keyRepeat, scroll, tabs, diff --git a/src/editor/api.ts b/src/editor/api.ts index d7bc2eec6..cc9332941 100644 --- a/src/editor/api.ts +++ b/src/editor/api.ts @@ -65,6 +65,47 @@ export interface EditorContribution { embedded?: boolean; } +/** + * Editor extensions whose code loads with the first editor that wants them: `wants` + * decides synchronously, `load` resolves the real `extensions`. Until it has, the editor + * runs without them and reconfigures once loaded. + */ +export function lazyEditorExtension( + wants: (ctx: EditorDocContext) => boolean, + load: () => Promise<(ctx: EditorDocContext) => Extension>, +): (ctx: EditorDocContext) => Extension { + let loaded: ((ctx: EditorDocContext) => Extension) | null = null; + let pending: Promise<(ctx: EditorDocContext) => Extension> | null = null; + const get = () => + (pending ??= load().then( + (f) => (loaded = f), + (e: unknown) => { + pending = null; + throw e; + }, + )); + return (ctx) => { + if (!wants(ctx)) return []; + if (loaded) return loaded(ctx); + const slot = new Compartment(); + return [ + slot.of([]), + ViewPlugin.define((view) => { + let live = true; + get().then( + (f) => live && view.dispatch({ effects: slot.reconfigure(f(ctx)) }), + (e: unknown) => console.error("could not load editor extensions", e), + ); + return { + destroy: () => { + live = false; + }, + }; + }), + ]; + }; +} + export interface TextEditorHandle { view: EditorView; ctx: EditorDocContext; diff --git a/src/editor/core/cm/angular/angular.test.ts b/src/editor/core/cm/angular/angular.test.ts index e0085036d..f24390120 100644 --- a/src/editor/core/cm/angular/angular.test.ts +++ b/src/editor/core/cm/angular/angular.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { EditorState } from "@codemirror/state"; import { ensureSyntaxTree, foldable, matchBrackets } from "@codemirror/language"; -import { NodeProp, type Tree } from "@lezer/common"; +import { IterMode, NodeProp, type Tree } from "@lezer/common"; import { buildParserFile } from "@lezer/generator"; import { highlightTree, tagHighlighter, tags as t } from "@lezer/highlight"; import { findLanguageDescription, loadSupport } from "../languages"; @@ -556,6 +556,39 @@ describe("Angular templates", () => { } }); + it("reuses the trees of identical text and attributes wherever they are", () => { + const part = '

{{ a + 1 }} @if (b) {

\n'; + tokens(part); + // The same nodes at other offsets come from the memo; tokens are sliced at their positions. + const doc = `
${part}
\n${part}x${part}`; + const all = tokens(doc); + expect(all.filter((tok) => tok == "a:var")).toHaveLength(3); + expect(all.filter((tok) => tok == "title:attr")).toHaveLength(3); + expect(all.filter((tok) => tok == "@if:control")).toHaveLength(3); + const mounts: unknown[] = []; + angularLanguage.parser.parse(doc).iterate({ + mode: IterMode.IgnoreMounts, + enter: (n) => { + const mount = n.name == "Text" && n.tree?.prop(NodeProp.mounted); + if (mount) mounts.push(mount.tree); + }, + }); + expect(mounts).toHaveLength(3); + expect(new Set(mounts).size).toBe(1); + }); + + it("parses only text with Angular syntax as Angular", () => { + const doc = "
    \n
  • Tom & Jerry
  • \n
  • {{ a }} & {{ b }}
  • \n
\n@if (x) {\n}"; + const tree = angularLanguage.parser.parse(doc); + const parentAt = (text: string) => tree.resolveInner(doc.indexOf(text) + 1, 1).parent?.name; + // Plain text and the whitespace between tags stay HTML text. + expect(parentAt("Tom")).toBe("Element"); + expect(parentAt("\n
  • ")).toBe("Element"); + expect(parentAt("{{ a")).toBe("Interpolation"); + expect(tokens(doc)).toContain("@if:control"); + expect(tokens(doc)).toContain("b:var"); + }); + it("is generated from angular.grammar", () => { const dir = import.meta.dirname; const { parser, terms } = buildParserFile(readFileSync(resolve(dir, "angular.grammar"), "utf8"), { diff --git a/src/editor/core/cm/angular/index.ts b/src/editor/core/cm/angular/index.ts index 658e8e2ea..271225542 100644 --- a/src/editor/core/cm/angular/index.ts +++ b/src/editor/core/cm/angular/index.ts @@ -10,7 +10,18 @@ import { LRLanguage, LanguageSupport, foldService, matchBrackets, syntaxTree } f import { html } from "@codemirror/lang-html"; import { javascriptLanguage } from "@codemirror/lang-javascript"; import { styleTags, tags as t } from "@lezer/highlight"; -import { IterMode, parseMixed, type Input, type NestedParse, type SyntaxNode, type SyntaxNodeRef } from "@lezer/common"; +import { + IterMode, + Parser, + parseMixed, + type Input, + type NestedParse, + type PartialParse, + type SyntaxNode, + type SyntaxNodeRef, + type Tree, + type TreeFragment, +} from "@lezer/common"; import { JS_TAGS } from "../jsTags"; import { parser } from "./parser"; @@ -84,8 +95,66 @@ const attrParser = baseParser.configure({ top: "Attribute", }); -const textMixed = { parser: textParser }, - attrMixed = { parser: attrParser }; +/** + * `inner` with its trees remembered by text. Most text and attribute nodes + * an edit recreates (the document's own text, nodes up to the viewport) are + * unchanged, but the mixed parser only reuses nested trees of nodes it kept, so + * each would be parsed again on every keystroke. Their parse depends on + * nothing but their text, and trees are relative to their start. + */ +class TextMemo extends Parser { + private readonly trees = new Map(); + private chars = 0; + constructor(private readonly inner: Parser) { + super(); + } + createParse( + input: Input, + fragments: readonly TreeFragment[], + ranges: readonly { from: number; to: number }[], + ): PartialParse { + const range = ranges.length == 1 ? ranges[0]! : null; + if (!range || range.to - range.from > MEMO_MAX_TEXT) return this.inner.startParse(input, fragments, ranges); + const text = input.read(range.from, range.to); + const known = this.trees.get(text); + if (known) return { advance: () => known, parsedPos: range.to, stoppedAt: null, stopAt() {} }; + const parse = this.inner.startParse(input, fragments, ranges); + const remember = (tree: Tree) => this.remember(text, tree); + return { + advance() { + const tree = parse.advance(); + // Stopping past the text (the outer parse's stop point) still parses all of it. + if (tree && (parse.stoppedAt == null || parse.stoppedAt >= range.to)) remember(tree); + return tree; + }, + get parsedPos() { + return parse.parsedPos; + }, + get stoppedAt() { + return parse.stoppedAt; + }, + stopAt: (pos) => parse.stopAt(pos), + }; + } + private remember(text: string, tree: Tree) { + if (this.trees.has(text)) return; + this.trees.set(text, tree); + this.chars += text.length; + // Oldest first. + for (const [old] of this.trees) { + if (this.chars <= MEMO_CHARS) break; + this.trees.delete(old); + this.chars -= old.length; + } + } +} + +/** Longer texts are parsed incrementally as usual; the memo holds about this much text. */ +const MEMO_MAX_TEXT = 4096, + MEMO_CHARS = 2_000_000; + +const textMixed = { parser: new TextMemo(textParser) }, + attrMixed = { parser: new TextMemo(attrParser) }; // Text is parsed per node, like upstream, so an unfinished `{{` or `@if (` // ends with it. But HTML splits text at each `&` (`@if (a && b) {`), so a run @@ -93,8 +162,15 @@ const textMixed = { parser: textParser }, // document) holding it. const TEXT_NODES = new Set(["Text", "EntityReference", "CharacterReference", "InvalidEntity"]); -/** The runs of text directly in `node` that entities split, `null` without any. */ -function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null { +/** + * Whether text holds Angular syntax: interpolations, blocks and their braces all + * need a `{`, `}` or `@`. Other text (mostly the whitespace between tags) stays + * plain HTML text instead of costing a nested parse each. + */ +const ANGULAR_TEXT = /[{}@]/; + +/** The runs of Angular text directly in `node` that entities split, `null` without any. */ +function entityRuns(node: SyntaxNode, input: Input): { from: number; to: number }[] | null { const runs: { from: number; to: number }[] = []; let from = -1, to = -1, @@ -108,12 +184,12 @@ function entityRuns(node: SyntaxNode): { from: number; to: number }[] | null { parts++; continue; } - if (parts > 1) runs.push({ from, to }); + if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to }); ({ from, to } = child); parts = 1; } while (child.nextSibling()); } - if (parts > 1) runs.push({ from, to }); + if (parts > 1 && ANGULAR_TEXT.test(input.read(from, to))) runs.push({ from, to }); return runs.length ? runs : null; } @@ -125,10 +201,10 @@ function mixAngular(node: SyntaxNodeRef, input: Input): NestedParse | null { return /^[*#([]|\{\{/.test(input.read(node.from, node.to)) ? attrMixed : null; // Text in a run is covered by its parent's overlay, so not asked about. case "Text": - return textMixed; + return ANGULAR_TEXT.test(input.read(node.from, node.to)) ? textMixed : null; case "Document": case "Element": { - const overlay = entityRuns(node.node); + const overlay = entityRuns(node.node, input); return overlay && { parser: node.name == "Document" ? documentRunsParser : runsParser, overlay }; } } diff --git a/src/editor/lazyEditorExtension.test.ts b/src/editor/lazyEditorExtension.test.ts new file mode 100644 index 000000000..633774fcb --- /dev/null +++ b/src/editor/lazyEditorExtension.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; +import { EditorState, Facet } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { lazyEditorExtension, type EditorDocContext } from "./api"; + +const marker = Facet.define(); +const ctx = (language: string) => ({ path: `/p/a.${language}`, language }) as EditorDocContext; +const flush = () => new Promise((resolve) => setTimeout(resolve)); + +describe("lazyEditorExtension", () => { + it("reconfigures wanted editors once loaded and applies directly afterwards", async () => { + const load = vi.fn(async () => (c: EditorDocContext) => marker.of(c.language)); + const extensions = lazyEditorExtension((c) => c.language === "sql", load); + + const first = new EditorView({ state: EditorState.create({ extensions: extensions(ctx("sql")) }) }); + expect(first.state.facet(marker)).toEqual([]); + await flush(); + expect(first.state.facet(marker)).toEqual(["sql"]); + + const second = EditorState.create({ extensions: extensions(ctx("sql")) }); + expect(second.facet(marker)).toEqual(["sql"]); + expect(extensions(ctx("ts"))).toEqual([]); + expect(load).toHaveBeenCalledTimes(1); + first.destroy(); + }); +}); diff --git a/src/editor/lsp/lsp.test.ts b/src/editor/lsp/lsp.test.ts index 6590de5a0..9308d3ea2 100644 --- a/src/editor/lsp/lsp.test.ts +++ b/src/editor/lsp/lsp.test.ts @@ -3,13 +3,15 @@ import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { _addEditor, _removeEditor, documentSynced, type EditorDocContext } from "@/editor/api"; +let tauri = true; vi.mock("@/platform/ipc", async (orig) => { const actual = await orig(); - return { ...actual, isTauri: () => true }; + return { ...actual, isTauri: () => tauri }; }); const { lspRequest, reportLspError } = await import("./lsp"); const { IpcError } = await import("@/platform/ipc"); +const { setInvokeRoute } = await import("@/platform/tauriCore"); describe("lspRequest", () => { it("waits until open editors' edits reached the backend", async () => { @@ -27,6 +29,20 @@ describe("lspRequest", () => { _removeEditor(handle); view.destroy(); }); + + it("outside Tauri, goes through an installed command route only", async () => { + tauri = false; + try { + const run = vi.fn(async () => ({ status: "ok" as const, data: 2 })); + await expect(lspRequest(run)).rejects.toMatchObject({ kind: "unavailable" }); + expect(run).not.toHaveBeenCalled(); + setInvokeRoute(() => null); + expect(await lspRequest(run)).toBe(2); + } finally { + setInvokeRoute(null); + tauri = true; + } + }); }); describe("reportLspError", () => { diff --git a/src/editor/lsp/lsp.ts b/src/editor/lsp/lsp.ts index 6b301f9aa..ff4d49848 100644 --- a/src/editor/lsp/lsp.ts +++ b/src/editor/lsp/lsp.ts @@ -3,6 +3,7 @@ import { Facet } from "@codemirror/state"; import type { EditorView } from "@codemirror/view"; import { whenAllSynced, type EditorDocContext } from "@/editor/api"; import { IpcError, call, isCancelled, isTauri, unavailableText } from "@/platform/ipc"; +import { hasInvokeRoute } from "@/platform/tauriCore"; import { tx } from "@/platform/i18n"; import type { IdeError, ReferencesIncomplete } from "@/bindings"; import { showHint } from "./hint"; @@ -25,7 +26,8 @@ type Result = { status: "ok"; data: T } | { status: "error"; error: IdeError * meanwhile is not sent (the backend ignores cancelling an id it hasn't seen). */ export async function lspRequest(run: () => Promise>, wanted?: () => boolean): Promise { - if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" }); + if (!isTauri() && !hasInvokeRoute()) + throw new IpcError({ kind: "unavailable", message: "No language server available" }); try { // Edits typed just before (⌘B right after typing) must reach the server first. await whenAllSynced(); diff --git a/src/features/database/console.tsx b/src/features/database/console.tsx index 5a0e83341..25d838ca7 100644 --- a/src/features/database/console.tsx +++ b/src/features/database/console.tsx @@ -63,7 +63,8 @@ import { tx } from "@/platform/i18n"; /** `templates` feature's language-data key for finer template contexts. */ const TEMPLATE_CONTEXT_KEY = "openideaTemplateContexts"; -export const SQL_LANGUAGE = "sql"; +import { SQL_LANGUAGE } from "./sqlLanguage"; +export { SQL_LANGUAGE }; const ROW_LIMITS = ["100", "500", "1000", "5000", "50000"]; /** Statement outcomes of the last run, kept on the text (IDEA's gutter check / error and timing). */ diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 9e6980cff..842478fba 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -4,8 +4,8 @@ * results grids, and a table data editor. Connections, introspection and * statements run in Rust (`ide-db`). */ -import { lazyComponent } from "@/ui"; -import { activeEditor, registerEditorContribution } from "@/editor/api"; +import { lazyComponent, lazyWhen } from "@/ui"; +import { activeEditor, lazyEditorExtension, registerEditorContribution } from "@/editor/api"; import { registerActions } from "@/platform/actions"; import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus"; import { useProject } from "@/platform/project"; @@ -17,8 +17,7 @@ import { setFileDecorations, toolWindowService, } from "@/workbench/contributions"; -import { SQL_LANGUAGE, consoleExtension, executeInView, explainInView } from "./console"; -import { DatabaseDialogs } from "./Dialogs"; +import { SQL_LANGUAGE } from "./sqlLanguage"; import { TABLE_KIND, tableTitle } from "./tableInput"; import { nodeTarget, type DbNode } from "./nodes"; import { registerDatabaseTreeActions } from "./treeActions"; @@ -35,6 +34,8 @@ import { watchIdleConnections, } from "./store"; +const useDialogOpen = () => useDatabase((s) => !!s.dialog); + export function activate(): () => void { const disposers = [ registerToolWindow({ @@ -60,8 +61,18 @@ export function activate(): () => void { title: tableTitle, icon: () => "database", }), - registerOverlay({ id: "database.dialogs", component: DatabaseDialogs }), - registerEditorContribution({ id: "database.console", order: 57, extensions: consoleExtension }), + registerOverlay({ + id: "database.dialogs", + component: lazyWhen(useDialogOpen, () => import("./Dialogs").then((m) => m.DatabaseDialogs)), + }), + registerEditorContribution({ + id: "database.console", + order: 57, + extensions: lazyEditorExtension( + (ctx) => ctx.language === SQL_LANGUAGE && !ctx.virtual && !ctx.readOnly, + () => import("./console").then((m) => m.consoleExtension), + ), + }), // A reopened project starts with a fresh backend: forget the old one's state. useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root) return; @@ -151,7 +162,7 @@ export function activate(): () => void { openSourceDialog(null); return; } - await executeInView(e.view, e.ctx.path); + await (await import("./console")).executeInView(e.view, e.ctx.path); }, }, { @@ -189,7 +200,7 @@ export function activate(): () => void { const e = activeEditor(); if (!e || e.ctx.language !== SQL_LANGUAGE) return; if (!useDatabase.getState().loaded) await loadSources(); - await explainInView(e.view, e.ctx.path); + await (await import("./console")).explainInView(e.view, e.ctx.path); }, }, { diff --git a/src/features/database/sqlLanguage.ts b/src/features/database/sqlLanguage.ts new file mode 100644 index 000000000..b89a6151a --- /dev/null +++ b/src/features/database/sqlLanguage.ts @@ -0,0 +1,2 @@ +/** The language id of SQL files and consoles. */ +export const SQL_LANGUAGE = "sql"; diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 0414745d4..a6e39bc29 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -3,7 +3,7 @@ * backend), DAP sessions driven by `IdeEvent::Debug`, the Debug tool window, * execution point + inline values, Evaluate / View Breakpoints dialogs. */ -import { lazyComponent } from "@/ui"; +import { lazyComponent, lazyWhen } from "@/ui"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { selectionValueTooltip, valueTooltip } from "./editor/valueTooltip"; @@ -35,9 +35,13 @@ import { stopSession, useDebug, } from "./state"; -import { DebuggerOverlays } from "./ui/Overlays"; +import { useOverlays } from "./ui/popoverStore"; import { REMOTE_TYPES } from "./remote"; +/** Whether a breakpoint popover, debugger dialog or chooser is open. */ +const useAnyOverlay = () => + useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep)); + export function activate(): () => void { useDebugRunSessions.setState({ Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)), @@ -62,7 +66,10 @@ export function activate(): () => void { component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)), headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)), }), - registerOverlay({ id: "debugger", component: DebuggerOverlays }), + registerOverlay({ + id: "debugger", + component: lazyWhen(useAnyOverlay, () => import("./ui/Overlays").then((m) => m.DebuggerOverlays)), + }), registerMenuGroup({ id: "debugger.evaluate", menus: ["editor"], diff --git a/src/features/github/index.ts b/src/features/github/index.ts index 451a2cb3b..234e71f20 100644 --- a/src/features/github/index.ts +++ b/src/features/github/index.ts @@ -10,70 +10,49 @@ */ import "./ui/palette.css"; import { registerActions, type Action } from "@/platform/actions"; -import { getContext, setContext } from "@/platform/context"; -import { useProject } from "@/platform/project"; +import { setContext } from "@/platform/context"; import { lazyComponent } from "@/ui"; import { - maybeEditorService, registerCloneSource, registerEditorKind, registerGitLogChecks, registerOverlay, registerToolWindow, } from "@/workbench/contributions"; -import { activateReviewDiff, toggleReviewDiffViewed } from "./diff"; -import { reloadList } from "./data/list"; -import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local"; -import { deleteMergedBranch } from "./data/operations"; -import { cachedDetails, loadPullRequestData, reloadPullRequest } from "./data/pullRequest"; -import { refreshConnection, resetConnection, useGhRepository } from "./data/repository"; -import { showSubmitReview } from "./details/SubmitReviewPopup"; -import { activateReviewMode } from "./reviewMode"; +import { useGithubShell } from "./shell"; import { githubHandler, handlerContextKey } from "./toolwindow/handlers"; -import { - openCreatePullRequest, - openPullRequest, - selectOpenedPullRequest, - showPullRequests, - WINDOW, -} from "./toolwindow/open"; -import { - changeRepository, - PullRequestsHeaderActions, - PullRequestsTabs, - PullRequestsToolWindow, - usePullRequestsHeaderMenu, -} from "./toolwindow/PullRequestsToolWindow"; -import { contextPullRequest, resetTabs } from "./toolwindow/store"; -import { - activeTimelinePullRequest, - openTimeline, - TIMELINE_KIND, - timelineIcon, - timelineTitle, -} from "./toolwindow/timelineInput"; -import { GithubDialogHost, numberDialog } from "./ui/dialogs"; +import { GithubDialogHost } from "./ui/dialogs"; +const WINDOW = "pullRequests"; +const TIMELINE_KIND = "github.timeline"; const WHEN = "projectOpen"; const CONNECTED = "projectOpen && githubConnected"; const IN_WINDOW = `activeToolWindow == '${WINDOW}'`; -/** The pull request an action applies to: its argument, the timeline editor, the active tab or the list selection. */ -function targetPullRequest(arg: unknown): number | null { - if (typeof arg === "number") return arg; - return activeTimelinePullRequest() ?? contextPullRequest(); +/** The pull requests UI and data load after start-up (or on first use); registrations stay here. */ +type Runtime = typeof import("./runtime"); +let runtime: Runtime | null = null; +let loading: Promise | null = null; +let dispose: (() => void) | null = null; +let active = false; + +function load(): Promise { + return (loading ??= import("./runtime").then( + (m) => { + runtime = m; + if (active && !dispose) dispose = m.activate(); + return m; + }, + (e: unknown) => { + loading = null; // A failed chunk load may succeed later. + throw e; + }, + )); } -async function withPullRequest(arg: unknown, run: (number: number) => unknown) { - const n = targetPullRequest(arg); - if (n !== null) await run(n); -} +type ActionSpec = Omit & { run?: Action["run"] }; -async function prUrl(number: number): Promise { - return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url; -} - -function actions(): Action[] { +function actions(): ActionSpec[] { return [ // Git › GitHub { @@ -84,7 +63,6 @@ function actions(): Action[] { icon: "toolwindow-pull-requests", keywords: ["github", "pr", "review", "merge request", "code review"], when: WHEN, - run: () => void showPullRequests(), }, { // Github.Create.Pull.Request @@ -95,7 +73,6 @@ function actions(): Action[] { keywords: ["github", "pr", "new pull request", "start pull request", "merge request", "code review"], when: WHEN, keyWhen: IN_WINDOW, - run: () => openCreatePullRequest(), }, { id: "github.checkoutPullRequest", @@ -105,11 +82,6 @@ function actions(): Action[] { icon: "branch", keywords: ["github", "pr"], when: WHEN, - run: async ({ arg }) => { - if (!(await showPullRequests(false))) return; - const n = targetPullRequest(arg) ?? (await numberDialog("Checkout Pull Request")); - if (n) await checkoutPullRequest(n); - }, }, { // Github.Open.In.Browser @@ -118,12 +90,6 @@ function actions(): Action[] { category: "GitHub", icon: "external-link", when: WHEN, - run: async () => { - if (!(await showPullRequests(false))) return; - const n = targetPullRequest(undefined); - const url = n !== null ? await prUrl(n) : useGhRepository.getState().connection?.repository.url; - if (url) await openInBrowser(url); - }, }, { // Github.PullRequest.List.Reload @@ -134,10 +100,6 @@ function actions(): Action[] { keywords: ["refresh list", "reload"], when: WHEN, keyWhen: `${IN_WINDOW} && githubPrTab == 'list'`, - run: async () => { - await refreshConnection(); - if (useGhRepository.getState().connection) await reloadList(); - }, }, // Tool window { @@ -146,8 +108,6 @@ function actions(): Action[] { title: "Change Repository or Account…", category: "GitHub", when: WHEN, - // Not connected yet: the tool window offers the repository selector, or explains how to log in. - run: () => (useGhRepository.getState().connection ? changeRepository() : void showPullRequests()), }, { // pull.request.select.action @@ -156,7 +116,6 @@ function actions(): Action[] { category: "GitHub", icon: "locate", when: `${CONNECTED} && githubTimelineActive`, - run: () => selectOpenedPullRequest(), }, // A pull request { @@ -166,7 +125,6 @@ function actions(): Action[] { description: "View pull request information and timeline", category: "GitHub", when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, (n) => openPullRequest(n)), }, { // Github.PullRequest.Timeline.Show @@ -176,7 +134,6 @@ function actions(): Action[] { category: "GitHub", icon: "github-timeline", when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, (n) => openTimeline(n, { focus: true })), }, { // Github.PullRequest.Details.Reload @@ -186,7 +143,6 @@ function actions(): Action[] { icon: "refresh", when: CONNECTED, keyWhen: `${IN_WINDOW} && githubPrTab == 'pr'`, - run: ({ arg }) => withPullRequest(arg, reloadPullRequest), }, { // Github.PullRequest.Timeline.Update @@ -197,7 +153,6 @@ function actions(): Action[] { icon: "refresh", when: `${CONNECTED} && githubTimelineActive`, keyWhen: "editorAreaFocus", - run: () => withPullRequest(undefined, reloadPullRequest), }, { // Github.PullRequest.Open.Link @@ -207,7 +162,6 @@ function actions(): Action[] { icon: "external-link", keywords: ["open pull request in github"], when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, async (n) => openInBrowser(await prUrl(n))), }, { // Github.PullRequest.Copy.Link @@ -216,7 +170,6 @@ function actions(): Action[] { category: "GitHub", icon: "copy", when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, async (n) => copyUrl(await prUrl(n))), }, { // Github.PullRequest.CheckoutBranch @@ -226,7 +179,6 @@ function actions(): Action[] { category: "GitHub", icon: "branch", when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, checkoutPullRequest), }, { // Github.PullRequest.OpenInWorktree @@ -235,7 +187,6 @@ function actions(): Action[] { description: "Fetch and open the pull request branch in a Git worktree", category: "GitHub", when: CONNECTED, - run: ({ arg }) => withPullRequest(arg, openInWorktree), }, { // GitHub.Timeline.DeleteMergedBranch @@ -246,11 +197,6 @@ function actions(): Action[] { icon: "trash", hidden: true, when: CONNECTED, - run: ({ arg }) => - withPullRequest(arg, async (n) => { - const ref = (await loadPullRequestData(n, "details")).headRef; - if (ref) await deleteMergedBranch(n, ref.id); - }), }, // Implemented by other parts (`provideGithubHandler`) { @@ -260,12 +206,6 @@ function actions(): Action[] { category: "GitHub", // The details panel's popup; without it (the branch popup in review mode) one of its own. when: `${CONNECTED} && (${handlerContextKey("submitReview")} || githubCurrentBranchPullRequest)`, - run: ({ arg }) => - withPullRequest(arg, (n) => { - const handler = githubHandler("submitReview"); - if (handler) handler(n); - else void showSubmitReview(n); - }), }, { // Github.PullRequest.Changed.MarkViewed.Toggle @@ -275,9 +215,6 @@ function actions(): Action[] { category: "GitHub", // The file of the focused review diff, else the details panel's selection. when: `${CONNECTED} && (${handlerContextKey("toggleViewed")} || githubReviewDiff)`, - run: () => { - if (!(getContext().diffEditorFocus && toggleReviewDiffViewed())) githubHandler("toggleViewed")?.(); - }, }, { // Github.Pull.Request.Show.In.Toolwindow @@ -309,7 +246,13 @@ function actions(): Action[] { ]; } +const lazyView = (name: "PullRequestsToolWindow" | "PullRequestsTabs" | "PullRequestsHeaderActions") => + lazyComponent<{ id: string }>(() => import("./toolwindow/PullRequestsToolWindow").then((m) => m[name])); + +const useHeaderMenu = () => useGithubShell((s) => s.headerMenu); + export function activate(): () => void { + active = true; setContext("githubPrTab", "list"); const disposers = [ registerCloneSource({ @@ -327,39 +270,28 @@ export function activate(): () => void { anchor: "left", order: 2, stripe: "onUse", - component: PullRequestsToolWindow, - headerContent: PullRequestsTabs, - headerActions: PullRequestsHeaderActions, - headerMenu: usePullRequestsHeaderMenu, + component: lazyView("PullRequestsToolWindow"), + headerContent: lazyView("PullRequestsTabs"), + headerActions: lazyView("PullRequestsHeaderActions"), + headerMenu: useHeaderMenu, }), registerEditorKind({ kind: TIMELINE_KIND, component: lazyComponent(() => import("./timeline/TimelineEditor").then((m) => m.TimelineEditor)), - title: timelineTitle, - icon: timelineIcon, + title: (input) => + runtime?.timeline.title(input) ?? `#${(input.data as { number?: number } | undefined)?.number ?? "?"}`, + icon: (input) => runtime?.timeline.icon(input) ?? "github-timeline", }), registerOverlay({ id: "github.dialogs", component: GithubDialogHost }), - registerActions(actions()), - useGhRepository.subscribe((s, prev) => { - setContext("githubConnected", !!s.connection); - setContext("githubTimelineActive", activeTimelinePullRequest() !== null); - const a = s.connection?.coordinates; - const b = prev.connection?.coordinates; - if (a?.owner !== b?.owner || a?.name !== b?.name) { - resetTabs(); - clearDiffCache(); - } - }), - useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) resetConnection(); - }), - activateReviewDiff(), - activateReviewMode(), + registerActions(actions().map((a) => ({ ...a, run: a.run ?? (async (c) => (await load()).runs[a.id]!(c)) }))), ]; - const editors = maybeEditorService(); - const stopEditors = editors?.onActiveInputChange?.(() => - setContext("githubTimelineActive", activeTimelinePullRequest() !== null), - ); - if (stopEditors) disposers.push(stopEditors); - return () => disposers.reverse().forEach((d) => d()); + // Review mode follows the current branch: start once the window is up. + const idle = typeof requestIdleCallback === "function" ? requestIdleCallback : (f: () => void) => setTimeout(f, 200); + idle(() => void load().catch((e: unknown) => console.error("could not load GitHub", e))); + return () => { + active = false; + dispose?.(); + dispose = null; + disposers.reverse().forEach((d) => d()); + }; } diff --git a/src/features/github/runtime.ts b/src/features/github/runtime.ts new file mode 100644 index 000000000..f25cfc332 --- /dev/null +++ b/src/features/github/runtime.ts @@ -0,0 +1,127 @@ +/** + * The GitHub integration behind `index.ts`'s registrations, loaded after start-up or on + * first use: action implementations, the connection and review-mode tracking, review in + * the diff viewer. + */ +import type { ActionContext } from "@/platform/actions"; +import { getContext, setContext } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import { maybeEditorService, type EditorInput } from "@/workbench/contributions"; +import { activateReviewDiff, toggleReviewDiffViewed } from "./diff"; +import { reloadList } from "./data/list"; +import { checkoutPullRequest, clearDiffCache, copyUrl, openInBrowser, openInWorktree } from "./data/local"; +import { deleteMergedBranch } from "./data/operations"; +import { cachedDetails, loadPullRequestData, reloadPullRequest } from "./data/pullRequest"; +import { refreshConnection, resetConnection, useGhRepository } from "./data/repository"; +import { showSubmitReview } from "./details/SubmitReviewPopup"; +import { activateReviewMode } from "./reviewMode"; +import { useGithubShell } from "./shell"; +import { githubHandler } from "./toolwindow/handlers"; +import { openCreatePullRequest, openPullRequest, selectOpenedPullRequest, showPullRequests } from "./toolwindow/open"; +import { changeRepository, headerMenu } from "./toolwindow/PullRequestsToolWindow"; +import { contextPullRequest, resetTabs } from "./toolwindow/store"; +import { activeTimelinePullRequest, openTimeline, timelineIcon, timelineTitle } from "./toolwindow/timelineInput"; +import { numberDialog } from "./ui/dialogs"; + +/** The pull request an action applies to: its argument, the timeline editor, the active tab or the list selection. */ +function targetPullRequest(arg: unknown): number | null { + if (typeof arg === "number") return arg; + return activeTimelinePullRequest() ?? contextPullRequest(); +} + +async function withPullRequest(arg: unknown, run: (number: number) => unknown) { + const n = targetPullRequest(arg); + if (n !== null) await run(n); +} + +async function prUrl(number: number): Promise { + return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url; +} + +type Run = (c: ActionContext) => void | Promise; + +/** Implementations of the actions `index.ts` registers, by id. */ +export const runs: Record = { + "github.pullRequests": () => void showPullRequests(), + "github.createPullRequest": () => openCreatePullRequest(), + "github.checkoutPullRequest": async ({ arg }) => { + if (!(await showPullRequests(false))) return; + const n = targetPullRequest(arg) ?? (await numberDialog("Checkout Pull Request")); + if (n) await checkoutPullRequest(n); + }, + "github.openInBrowser": async () => { + if (!(await showPullRequests(false))) return; + const n = targetPullRequest(undefined); + const url = n !== null ? await prUrl(n) : useGhRepository.getState().connection?.repository.url; + if (url) await openInBrowser(url); + }, + "github.refresh": async () => { + await refreshConnection(); + if (useGhRepository.getState().connection) await reloadList(); + }, + // Not connected yet: the tool window offers the repository selector, or explains how to log in. + "github.changeRepository": () => + useGhRepository.getState().connection ? changeRepository() : void showPullRequests(), + "github.selectOpenedPullRequest": () => selectOpenedPullRequest(), + "github.pullRequest.show": ({ arg }) => withPullRequest(arg, (n) => openPullRequest(n)), + "github.pullRequest.showTimeline": ({ arg }) => withPullRequest(arg, (n) => openTimeline(n, { focus: true })), + "github.pullRequest.refresh": ({ arg }) => withPullRequest(arg, reloadPullRequest), + "github.pullRequest.timeline.refresh": () => withPullRequest(undefined, reloadPullRequest), + "github.pullRequest.openInBrowser": ({ arg }) => withPullRequest(arg, async (n) => openInBrowser(await prUrl(n))), + "github.pullRequest.copyUrl": ({ arg }) => withPullRequest(arg, async (n) => copyUrl(await prUrl(n))), + "github.pullRequest.checkoutBranch": ({ arg }) => withPullRequest(arg, checkoutPullRequest), + "github.pullRequest.openInWorktree": ({ arg }) => withPullRequest(arg, openInWorktree), + "github.pullRequest.deleteMergedBranch": ({ arg }) => + withPullRequest(arg, async (n) => { + const ref = (await loadPullRequestData(n, "details")).headRef; + if (ref) await deleteMergedBranch(n, ref.id); + }), + // The details panel's popup; without it (the branch popup in review mode) one of its own. + "github.pullRequest.submitReview": ({ arg }) => + withPullRequest(arg, (n) => { + const handler = githubHandler("submitReview"); + if (handler) handler(n); + else void showSubmitReview(n); + }), + // The file of the focused review diff, else the details panel's selection. + "github.pullRequest.toggleViewed": () => { + if (!(getContext().diffEditorFocus && toggleReviewDiffViewed())) githubHandler("toggleViewed")?.(); + }, +}; + +/** The timeline tab's title and icon once the details are known. */ +export const timeline = { + title: (input: EditorInput) => timelineTitle(input), + icon: (input: EditorInput) => timelineIcon(input), +}; + +export function activate(): () => void { + const sync = () => { + const s = useGhRepository.getState(); + setContext("githubConnected", !!s.connection); + setContext("githubTimelineActive", activeTimelinePullRequest() !== null); + useGithubShell.setState({ headerMenu: headerMenu(s) }); + }; + sync(); + const disposers = [ + useGhRepository.subscribe((s, prev) => { + sync(); + const a = s.connection?.coordinates; + const b = prev.connection?.coordinates; + if (a?.owner !== b?.owner || a?.name !== b?.name) { + resetTabs(); + clearDiffCache(); + } + }), + useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root) resetConnection(); + }), + activateReviewDiff(), + activateReviewMode(), + ]; + const stopEditors = maybeEditorService()?.onActiveInputChange?.(() => + setContext("githubTimelineActive", activeTimelinePullRequest() !== null), + ); + if (stopEditors) disposers.push(stopEditors); + return () => disposers.reverse().forEach((d) => d()); +} diff --git a/src/features/github/shell.ts b/src/features/github/shell.ts new file mode 100644 index 000000000..35bd9206c --- /dev/null +++ b/src/features/github/shell.ts @@ -0,0 +1,5 @@ +/** State `index.ts`'s registrations read before `runtime.ts` has loaded. */ +import { create } from "zustand"; +import type { MenuItem } from "@/ui"; + +export const useGithubShell = create<{ headerMenu: MenuItem[] }>(() => ({ headerMenu: [] })); diff --git a/src/features/github/toolwindow/PullRequestsToolWindow.tsx b/src/features/github/toolwindow/PullRequestsToolWindow.tsx index 43c8f65dd..85ac92b98 100644 --- a/src/features/github/toolwindow/PullRequestsToolWindow.tsx +++ b/src/features/github/toolwindow/PullRequestsToolWindow.tsx @@ -280,10 +280,8 @@ export function PullRequestsHeaderActions() { } /** The "⋮" menu's own items: Change Repository or Account… (connected, several GitHub remotes). */ -export function usePullRequestsHeaderMenu(): MenuItem[] { - const connected = useGhRepository((s) => !!s.connection); - const several = useGhRepository((s) => s.remotes.length > 1); - return connected && several ? CHANGE_REPOSITORY : NONE; +export function headerMenu(s: Pick, "connection" | "remotes">): MenuItem[] { + return s.connection && s.remotes.length > 1 ? CHANGE_REPOSITORY : NONE; } const NONE: MenuItem[] = []; diff --git a/src/features/logcat/index.ts b/src/features/logcat/index.ts index 5eef15f83..8e4d804ca 100644 --- a/src/features/logcat/index.ts +++ b/src/features/logcat/index.ts @@ -3,7 +3,7 @@ * level and text filters of IDEA's Android plugin. */ import { registerToolWindow } from "@/workbench/contributions"; -import { LogcatView } from "./LogcatView"; +import { lazyComponent } from "@/ui"; import { resetLogcat } from "./store"; export function activate(): () => void { @@ -15,7 +15,7 @@ export function activate(): () => void { // Listed under the stripe's "…" until first opened: most projects are not Android ones. stripe: "onUse", order: 26, - component: LogcatView, + component: lazyComponent(() => import("./LogcatView").then((m) => m.LogcatView)), // A device can be watched without a project open. requiresProject: false, }); diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 651ffa04e..d2cf79c39 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -15,7 +15,7 @@ * `line-markers` providers (`lineMarkerGutter.ts`). */ import { registerEditorContribution } from "@/editor/api"; -import { lazyComponent } from "@/ui"; +import { lazyComponent, lazyWhen } from "@/ui"; import { executeAction, registerActions } from "@/platform/actions"; import { useProject } from "@/platform/project"; import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions"; @@ -31,8 +31,7 @@ import { trackIconTheme } from "./iconThemes"; import { pluginLineMarkerGutter } from "./lineMarkerGutter"; import { defineBuiltinUiPoints } from "./points"; import { startViewUi } from "./uiPoints"; -import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; -import { IntellijThemesDialogHost } from "./IntellijThemesDialog"; +import { showInstalledIntellijThemes, useIntellijThemesDialog } from "./intellijThemesDialogStore"; import { startWorkspaceContains } from "./workspaceContains"; import { startIndexedMarkers } from "./indexedMarkers"; import { startPluginNavigation } from "./navigation"; @@ -50,6 +49,11 @@ function registerPluginsPage(): () => void { } /** Settings › Plugins for the Settings window: the page plus the plugin theme. */ +const useIntellijThemesOpen = () => useIntellijThemesDialog((s) => s.open); +const IntellijThemesDialogHost = lazyWhen(useIntellijThemesOpen, () => + import("./IntellijThemesDialog").then((m) => m.IntellijThemesDialogHost), +); + export function activateSettingsPages(): () => void { const disposers = [ registerPluginsPage(), @@ -111,7 +115,7 @@ export function activate(): () => void { // Installing needs a project's window. when: "projectOpen", run: async ({ arg }) => { - await importIntellijTheme(typeof arg === "string" ? arg : undefined); + await (await import("./intellijImport")).importIntellijTheme(typeof arg === "string" ? arg : undefined); }, }, { diff --git a/src/features/plugins/intellijImport.ts b/src/features/plugins/intellijImport.ts index 8a0f9f5aa..8ac03a363 100644 --- a/src/features/plugins/intellijImport.ts +++ b/src/features/plugins/intellijImport.ts @@ -5,7 +5,6 @@ * here (`intellijTheme.ts`), installed through the usual install page. Its * themes join the others; which one to use is chosen in Settings › Appearance. */ -import { create } from "zustand"; import { notifications } from "@/workbench/contributions"; import { pluginsBackend } from "./backend"; import { intellijThemePackage } from "./intellijTheme"; @@ -35,19 +34,8 @@ export async function importIntellijTheme(path?: string): Promise return installFromDisk(dir); } -interface DialogState { - open: boolean; - /** Called with the plugin id after an import from the dialog. */ - onImported: ((id: string) => void) | null; -} - -export const useIntellijThemesDialog = create(() => ({ open: false, onImported: null })); - -/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */ -export function showInstalledIntellijThemes(onImported?: (id: string) => void): void { - useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null }); -} - -export function closeInstalledIntellijThemes(): void { - useIntellijThemesDialog.setState({ open: false, onImported: null }); -} +export { + closeInstalledIntellijThemes, + showInstalledIntellijThemes, + useIntellijThemesDialog, +} from "./intellijThemesDialogStore"; diff --git a/src/features/plugins/intellijThemesDialogStore.ts b/src/features/plugins/intellijThemesDialogStore.ts new file mode 100644 index 000000000..e5aa45fdc --- /dev/null +++ b/src/features/plugins/intellijThemesDialogStore.ts @@ -0,0 +1,18 @@ +import { create } from "zustand"; + +interface DialogState { + open: boolean; + /** Called with the plugin id after an import from the dialog. */ + onImported: ((id: string) => void) | null; +} + +export const useIntellijThemesDialog = create(() => ({ open: false, onImported: null })); + +/** Shows the themes of the JetBrains IDEs on this machine to pick one to import. */ +export function showInstalledIntellijThemes(onImported?: (id: string) => void): void { + useIntellijThemesDialog.setState({ open: true, onImported: onImported ?? null }); +} + +export function closeInstalledIntellijThemes(): void { + useIntellijThemesDialog.setState({ open: false, onImported: null }); +} diff --git a/src/features/run/consoleFlush.test.ts b/src/features/run/consoleFlush.test.ts new file mode 100644 index 000000000..d89385cef --- /dev/null +++ b/src/features/run/consoleFlush.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startSampleOutput, useRun } from "./store"; + +describe("console output", () => { + afterEach(() => vi.useRealTimers()); + + it("shows a flood of output at most every 50 ms, a first line on the next frame", async () => { + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame", "performance"], + }); + const updates: number[] = []; + const stop = useRun.subscribe((s, prev) => { + if (s.versions !== prev.versions) updates.push(performance.now()); + }); + // 60 frames of output, 10 lines each. + const done = startSampleOutput(600, 10); + await vi.advanceTimersByTimeAsync(2_000); + await done; + stop(); + const session = useRun.getState().sessions.at(-1)!; + expect(session.status).not.toBe("running"); + // Creating the session; the first lines (enqueued a frame later) on the next frame; then every ≥ 50 ms. + expect(updates[1]! - updates[0]!).toBeLessThanOrEqual(34); + const gaps = updates.slice(2, -2).map((t, i) => t - updates[i + 1]!); + expect(Math.min(...gaps)).toBeGreaterThanOrEqual(50); + expect(updates.length).toBeLessThan(30); + }); +}); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 2b8fd024b..93f271d6b 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -8,7 +8,7 @@ import { getContext, setContext } from "@/platform/context"; import { onIdeEvent, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { onWorkflowSettingsChange } from "@/platform/workflowSettings"; -import { showContextMenu, lazyComponent, type MenuItem } from "@/ui"; +import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui"; import { menuGroupItems, useDebugRunSessions, @@ -35,7 +35,6 @@ import { runWithExecutor } from "./executors"; import { debugWithAgent, jdwpLaunch } from "./javaDebug"; import { RunDialogsOverlay } from "./dialogs"; import { registerExternalTools } from "./externalTools"; -import { EditConfigsOverlay } from "./EditConfigsDialog"; import { openImport } from "./importConfigs"; import { registerComposerInstall } from "./composerInstall"; import { registerGoActions } from "./goActions"; @@ -74,6 +73,7 @@ import { selectConfig, selectedConfig, startRun, + startSampleOutput, stopSession, trackRecentConfigurations, useRun, @@ -292,8 +292,25 @@ function scheduleReload() { reloadTimer = setTimeout(() => void loadConfigs(), 300); } +const useEditConfigsOpen = () => useRun((s) => s.editorOpen); + export function activate(): () => void { const disposers: Array<() => void> = [ + // The performance harness (`perf/`): a console streaming generated output. + import.meta.env.DEV || import.meta.env.MODE === "perf" + ? registerActions([ + { + id: "run.sampleOutput", + title: "Stream Sample Output", + category: "Run", + hidden: true, + run: async ({ arg }) => { + const { lines = 20_000, perFrame = 200 } = (arg ?? {}) as { lines?: number; perFrame?: number }; + await startSampleOutput(lines, perFrame); + }, + }, + ]) + : () => {}, registerToolWindow({ id: "run", title: "Run", @@ -311,7 +328,10 @@ export function activate(): () => void { registerGoActions(), registerComposerInstall(), registerOverlay({ id: "run.chooser", component: ConfigChooserOverlay }), - registerOverlay({ id: "run.editConfigs", component: EditConfigsOverlay }), + registerOverlay({ + id: "run.editConfigs", + component: lazyWhen(useEditConfigsOpen, () => import("./EditConfigsDialog").then((m) => m.EditConfigsOverlay)), + }), registerOverlay({ id: "run.dialogs", component: RunDialogsOverlay }), registerServiceProvider(runServiceProvider), registerRunningProcessesProvider({ diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 97ccc3374..9c37216d5 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -97,8 +97,13 @@ interface SessionRuntime { /** Machine-readable stdout (`go test -json`, `--teamcity`, the Ruby reporter): fed to the parser, shown decoded. */ stdout: TestStdout | null; queue: OutputChunk[]; - /** rAF id while the page is visible, timeout id while hidden (rAF pauses in background tabs). */ + /** + * rAF id while the page is visible, timeout id while hidden (rAF pauses in + * background tabs) or while waiting out `FLUSH_INTERVAL`. + */ flushHandle: { raf: number } | { timeout: number } | null; + /** When output was last shown (`performance.now()`). */ + flushedAt: number; /** Bumped per start so output from a previous run in the same tab is ignored. */ generation: number; /** Called with every complete output line (e.g. waiting for a JDWP agent). */ @@ -499,6 +504,7 @@ function flush(sessionId: string) { if (!rt) return; cancelFlush(rt); if (rt.queue.length === 0) return; + rt.flushedAt = performance.now(); const chunks = rt.queue.splice(0); for (const c of chunks) { const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text; @@ -519,12 +525,27 @@ function enqueue(sessionId: string, chunk: OutputChunk) { // rAF pauses in background tabs; a timeout keeps consuming output so it doesn't // pile up until the console is shown again (issue #319: the console "kept // scrolling" the previous run's backlog after switching back to it). + const wait = FLUSH_INTERVAL - (performance.now() - rt.flushedAt); rt.flushHandle = document.hidden ? { timeout: window.setTimeout(() => flush(sessionId), 100) } - : { raf: requestAnimationFrame(() => flush(sessionId)) }; + : wait > 0 + ? { + timeout: window.setTimeout( + () => (rt.flushHandle = { raf: requestAnimationFrame(() => flush(sessionId)) }), + wait, + ), + } + : { raf: requestAnimationFrame(() => flush(sessionId)) }; } } +/** + * Shortest time between console updates. A line after a quiet spell shows on the + * next frame; a flood is shown at ~20 Hz instead of every frame, which kept + * typing elsewhere from fitting in a frame (35% missed while 12k lines/s stream). + */ +const FLUSH_INTERVAL = 50; + function cancelFlush(rt: SessionRuntime) { if (rt.flushHandle === null) return; if ("raf" in rt.flushHandle) cancelAnimationFrame(rt.flushHandle.raf); @@ -595,6 +616,7 @@ function newRuntime(config: RunConfig): SessionRuntime { }), queue: [], flushHandle: null, + flushedAt: -Infinity, generation: 0, lineListeners: [], input: "", @@ -1314,3 +1336,59 @@ export function modifyTemporary(config: RunConfig) { editDirty: adopted.detected === false, }); } + +const SAMPLE_LINES = [ + (i: number) => + `2026-11-05 10:42:${String(i % 60).padStart(2, "0")}.${String(i % 1000).padStart(3, "0")} INFO 4242 --- [nio-8080-exec-${i % 10}] c.a.orders.service.OrderService : processed order #${i} in ${i % 97} ms`, + (i: number) => `\tat com.acme.orders.service.OrderService.process(OrderService.kt:${(i % 300) + 1})`, + (i: number) => + `2026-11-05 10:42:01.${String(i % 1000).padStart(3, "0")} DEBUG 4242 --- [ main] o.s.web.servlet.DispatcherServlet : GET "/api/orders/${i}", parameters={}`, +]; + +/** + * Development and the performance harness only: a session whose console gets + * `lines` generated log lines (stack frames among them), `perFrame` per + * animation frame, as from a chatty process. Resolves once all are shown. + */ +export async function startSampleOutput(lines: number, perFrame: number): Promise { + const config: RunConfig = { + id: `sample-output-${sessionSeq + 1}`, + name: "Sample output", + kind: "shell", + command: "", + args: [], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, + }; + const session: RunSession = { + id: `run${++sessionSeq}`, + config, + runId: null, + status: "running", + exitCode: null, + startedAt: Date.now(), + endedAt: null, + stdinClosed: true, + }; + runtimes.set(session.id, newRuntime(config)); + useRun.setState((s) => ({ sessions: [...s.sessions, session], activeSession: session.id })); + bump(session.id); + try { + toolWindowService().show("run"); + } catch { + /* no workbench (tests) */ + } + for (let i = 0; i < lines;) { + await new Promise(requestAnimationFrame); + for (const end = Math.min(lines, i + perFrame); i < end; i++) { + const line = SAMPLE_LINES[i % 20 === 19 ? 1 : i % 7 === 6 ? 2 : 0]!(i); + enqueue(session.id, { kind: i % 101 === 100 ? "stderr" : "stdout", text: `${line}\n` }); + } + } + await new Promise(requestAnimationFrame); + flush(session.id); + finishSession(session.id, 0); +} diff --git a/src/features/search/index.ts b/src/features/search/index.ts index a5cbcd4dc..725549b5a 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -3,7 +3,7 @@ * Recent Files / Recent Locations, Find / Replace in Files + the Find tool * window, the Structure tool window + File Structure popup, and bookmarks. */ -import { lazyComponent } from "@/ui"; +import { lazyComponent, lazyWhen } from "@/ui"; import { activeEditor, registerEditorContribution } from "@/editor/api"; import { executeAction, registerActions } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; @@ -33,15 +33,12 @@ import { import { openLocation } from "./common/open"; import { maybeToolWindowService } from "./common/services"; import { installTracking, resetTracking } from "./common/tracking"; -import { SearchEverywhereOverlay } from "./everywhere/SearchEverywhere"; -import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store"; -import { FindInFilesOverlay } from "./find/FindInFiles"; +import { hideSearchEverywhere, showSearchEverywhere, useSearchEverywhere } from "./everywhere/store"; import { ScopesDialogOverlay, showScopesDialog } from "./find/ScopesDialog"; import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store"; import { disposeFindWindow, openEditorResults, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore"; -import { RecentPopupsOverlay } from "./recent/RecentPopups"; -import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store"; -import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView"; +import { cycleSwitcher, hideRecentPopup, showRecentPopup, useRecentPopup } from "./recent/store"; +import { useFileStructurePopup } from "./structure/popupStore"; import { useOutline, useStructureOptions } from "./structure/store"; import { tx } from "@/platform/i18n"; @@ -115,6 +112,11 @@ function jumpBookmark(dir: 1 | -1) { if (next) void openLocation(next.b.path, { line: next.b.line, col: 0 }); } +const useEverywhereOpen = () => useSearchEverywhere((s) => s.open); +const useRecentOpen = () => useRecentPopup((s) => s.open !== null); +const useFindOpen = () => useFindPopup((s) => s.open); +const useFileStructureOpen = () => useFileStructurePopup((s) => s.open); + export function activate(): void | (() => void) { const se = (tab: Parameters[0]) => () => { closeAllPopups("everywhere"); @@ -125,11 +127,27 @@ export function activate(): void | (() => void) { installTracking(), provideFindResults(openEditorResults), - registerOverlay({ id: "search.everywhere", component: SearchEverywhereOverlay }), - registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }), - registerOverlay({ id: "search.findInFiles", component: FindInFilesOverlay }), + registerOverlay({ + id: "search.everywhere", + component: lazyWhen(useEverywhereOpen, () => + import("./everywhere/SearchEverywhere").then((m) => m.SearchEverywhereOverlay), + ), + }), + registerOverlay({ + id: "search.recent", + component: lazyWhen(useRecentOpen, () => import("./recent/RecentPopups").then((m) => m.RecentPopupsOverlay)), + }), + registerOverlay({ + id: "search.findInFiles", + component: lazyWhen(useFindOpen, () => import("./find/FindInFiles").then((m) => m.FindInFilesOverlay)), + }), registerOverlay({ id: "search.scopes", component: ScopesDialogOverlay }), - registerOverlay({ id: "search.fileStructure", component: FileStructureOverlay }), + registerOverlay({ + id: "search.fileStructure", + component: lazyWhen(useFileStructureOpen, () => + import("./structure/StructureView").then((m) => m.FileStructureOverlay), + ), + }), registerOverlay({ id: "search.bookmarks", component: BookmarkPopupsOverlay }), registerToolWindow({ diff --git a/src/features/search/structure/StructureView.tsx b/src/features/search/structure/StructureView.tsx index c1b55621f..b4aa2655f 100644 --- a/src/features/search/structure/StructureView.tsx +++ b/src/features/search/structure/StructureView.tsx @@ -319,7 +319,8 @@ function StructureToolbar() { // --------------------------------------------------------------------------- // ⌘F12 File Structure popup -export const useFileStructurePopup = create<{ open: boolean }>(() => ({ open: false })); +import { useFileStructurePopup } from "./popupStore"; +export { useFileStructurePopup }; export function FileStructureOverlay() { const open = useFileStructurePopup((s) => s.open); diff --git a/src/features/search/structure/popupStore.ts b/src/features/search/structure/popupStore.ts new file mode 100644 index 000000000..9030f1a72 --- /dev/null +++ b/src/features/search/structure/popupStore.ts @@ -0,0 +1,4 @@ +import { create } from "zustand"; + +/** Whether the File Structure popup is open. */ +export const useFileStructurePopup = create<{ open: boolean }>(() => ({ open: false })); diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 9f1b3d031..b6e00d8c9 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -14,7 +14,7 @@ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { registerActions, type Action } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; -import { isPopupOpen, lazyComponent } from "@/ui"; +import { isPopupOpen, lazyComponent, lazyWhen } from "@/ui"; import { loadSettings, useProject } from "@/platform/project"; import { logError } from "@/platform/errorLog"; import { restoreRemote } from "@/platform/remote"; @@ -35,7 +35,7 @@ import { zoomActions } from "./appearanceRuntime"; import { activateFileTypes } from "./fileTypesRuntime"; import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; -import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut"; +import { showAssignShortcut, useAssignShortcut } from "./keymap/assignShortcutStore"; import { settingsSearchEntries } from "./searchIndex"; import { activatePythonStatus } from "./languages/PythonStatus"; import { activatePhpStatus } from "./languages/PhpStatus"; @@ -105,6 +105,8 @@ async function showSettings(page?: string, newProjects = false) { openSettings(page, { newProjects }); } +const useAssignShortcutOpen = () => useAssignShortcut((s) => s.actionId !== null); + export function activate(): () => void { const disposers = [ trackRuntimeSettings(), @@ -116,7 +118,12 @@ export function activate(): () => void { registerActions(zoomActions()), registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))), activateFileTypes(), - registerOverlay({ id: "settings.assignShortcut", component: AssignShortcutOverlay }), + registerOverlay({ + id: "settings.assignShortcut", + component: lazyWhen(useAssignShortcutOpen, () => + import("./keymap/AssignShortcut").then((m) => m.AssignShortcutOverlay), + ), + }), registerActions([ { // Search Everywhere's ⌥↵ on an action (arg: the action id). diff --git a/src/features/settings/keymap/AssignShortcut.tsx b/src/features/settings/keymap/AssignShortcut.tsx index 2610ad434..ade3401e0 100644 --- a/src/features/settings/keymap/AssignShortcut.tsx +++ b/src/features/settings/keymap/AssignShortcut.tsx @@ -4,7 +4,6 @@ * active keymap (overrides of a bundled keymap, or the user keymap itself). */ import { useMemo } from "react"; -import { create } from "zustand"; import { useActions } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; import type { Keymap } from "@/platform/keymap"; @@ -21,11 +20,8 @@ import { } from "./model"; import { RecordShortcutDialog } from "./RecordShortcutDialog"; -export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null })); - -export function showAssignShortcut(actionId: string) { - useAssignShortcut.setState({ actionId }); -} +import { useAssignShortcut } from "./assignShortcutStore"; +export { showAssignShortcut, useAssignShortcut } from "./assignShortcutStore"; interface KeymapSettings { keymap: string; diff --git a/src/features/settings/keymap/assignShortcutStore.ts b/src/features/settings/keymap/assignShortcutStore.ts new file mode 100644 index 000000000..06a350b1e --- /dev/null +++ b/src/features/settings/keymap/assignShortcutStore.ts @@ -0,0 +1,8 @@ +import { create } from "zustand"; + +/** The action whose shortcut the Assign Shortcut dialog is editing. */ +export const useAssignShortcut = create<{ actionId: string | null }>(() => ({ actionId: null })); + +export function showAssignShortcut(actionId: string) { + useAssignShortcut.setState({ actionId }); +} diff --git a/src/features/templates/__tests__/emmet.test.ts b/src/features/templates/__tests__/emmet.test.ts index 1ce65d33e..09bf8ad9e 100644 --- a/src/features/templates/__tests__/emmet.test.ts +++ b/src/features/templates/__tests__/emmet.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { enclosingTag, expandAt, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model"; +import { beforeAll, describe, expect, it } from "vitest"; +import { enclosingTag, expandAt, loadEmmet, syntaxAt, updateTagEdits, type TextEdit } from "../emmet/model"; + +beforeAll(() => loadEmmet()); const apply = (text: string, edits: TextEdit[]) => [...edits].sort((a, b) => b.from - a.from).reduce((t, e) => t.slice(0, e.from) + e.insert + t.slice(e.to), text); diff --git a/src/features/templates/editor.ts b/src/features/templates/editor.ts index d4e432a59..1e2d27026 100644 --- a/src/features/templates/editor.ts +++ b/src/features/templates/editor.ts @@ -48,6 +48,7 @@ import { import { usePostfixSettings } from "./postfix/store"; import { showChooser, type ChooserItem } from "./popup/store"; import { expandEmmetAtCaret } from "./emmet/editor"; +import { hasEmmet, loadEmmet } from "./emmet/model"; export interface TemplateDocInfo { path: string; @@ -557,6 +558,7 @@ export const templateCompletionSource: CompletionSource = (ctx: CompletionContex // Extension export function templateEditorExtension(ctx: EditorDocContext): Extension { + if (hasEmmet(ctx.language)) void loadEmmet().catch(() => {}); return [ templateDoc.of({ path: ctx.path, language: ctx.language }), fieldCompletion, diff --git a/src/features/templates/emmet/model.ts b/src/features/templates/emmet/model.ts index 19e409a1b..5619d8d0c 100644 --- a/src/features/templates/emmet/model.ts +++ b/src/features/templates/emmet/model.ts @@ -4,7 +4,22 @@ * with Emmet" on the enclosing tag. Pure functions over document text; the * editor glue lives in `./editor`. */ -import expand, { extract, type UserConfig } from "emmet"; +import type { UserConfig } from "emmet"; + +type Emmet = typeof import("emmet"); +let emmet: Emmet | null = null; +let loading: Promise | null = null; + +/** Loads the Emmet engine, kept out of start-up; expansion finds nothing until it has. */ +export function loadEmmet(): Promise { + return (loading ??= import("emmet").then( + (m) => (emmet = m), + (e: unknown) => { + loading = null; + throw e; + }, + )); +} export interface EmmetSyntax { type: "markup" | "stylesheet"; @@ -15,6 +30,9 @@ export interface EmmetSyntax { const MARKUP_LANGUAGES = new Set(["html", "vue", "svelte"]); const STYLESHEET_LANGUAGES: Record = { css: "css", scss: "scss", less: "less", sass: "sass" }; +/** Whether `language` files expand Emmet abbreviations. */ +export const hasEmmet = (language: string) => MARKUP_LANGUAGES.has(language) || language in STYLESHEET_LANGUAGES; + /** Text before the caret that is scanned for context. */ const CONTEXT_LIMIT = 200_000; @@ -96,14 +114,18 @@ export interface Expansion { /** The abbreviation ending at `col` of `line` expanded, or null when there is none. */ export function expandAt(line: string, col: number, syntax: EmmetSyntax): Expansion | null { - const found = extract(line, col, { type: syntax.type, lookAhead: true }); + if (!emmet) { + void loadEmmet().catch(() => {}); + return null; + } + const found = emmet.extract(line, col, { type: syntax.type, lookAhead: true }); if (!found || !found.abbreviation.trim()) return null; const abbr = found.abbreviation; // A bare number or a closing-tag fragment is never meant as an abbreviation. if (/^\d/.test(abbr) || abbr.startsWith("/")) return null; let snippet: string; try { - snippet = expand(abbr, config(syntax, true)); + snippet = emmet.default(abbr, config(syntax, true)); } catch { return null; } @@ -194,9 +216,10 @@ export interface TextEdit { export function updateTagEdits(tag: TagRange, abbreviation: string): TextEdit[] | null { const abbr = abbreviation.trim(); if (!abbr || /[>+^*{]/.test(abbr.replace(/\[[^\]]*\]/g, ""))) return null; + if (!emmet) return null; let html: string; try { - html = expand(abbr, config({ type: "markup", syntax: "html" }, false)); + html = emmet.default(abbr, config({ type: "markup", syntax: "html" }, false)); } catch { return null; } diff --git a/src/features/templates/index.ts b/src/features/templates/index.ts index de5b3ca05..257819ae6 100644 --- a/src/features/templates/index.ts +++ b/src/features/templates/index.ts @@ -21,6 +21,7 @@ import { fileTemplates, useFileTemplates, watchTemplateProject } from "./files/s import type { FileTemplate } from "./files/model"; import { syncTemplatesFromStorage } from "./sync"; import { tagAtCaret, updateTag } from "./emmet/editor"; +import { loadEmmet } from "./emmet/model"; import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog"; import { tx } from "@/platform/i18n"; @@ -35,7 +36,7 @@ async function updateTagWithEmmet() { notifications().notify({ level: "info", title: "No tag at the caret", key: "emmet-update-tag" }); return; } - const abbreviation = await promptAbbreviation(tag); + const [abbreviation] = await Promise.all([promptAbbreviation(tag), loadEmmet()]); editor.view.focus(); if (abbreviation && !updateTag(editor.view, abbreviation)) notifications().notify({ diff --git a/src/features/vcs/ComparisonTree.tsx b/src/features/vcs/ComparisonTree.tsx index e2fd952c6..ec52938df 100644 --- a/src/features/vcs/ComparisonTree.tsx +++ b/src/features/vcs/ComparisonTree.tsx @@ -1,10 +1,11 @@ import { useState, type ReactNode } from "react"; -import { Icon, Tree, fileIconName } from "@/ui"; -import { DiffEditor } from "./DiffEditor"; +import { Icon, Tree, fileIconName, lazyComponent } from "@/ui"; import { diffInput, openDiff, type DiffData } from "./diffInput"; import { dirKeys, type FileTreeNode } from "./fileTree"; import styles from "./common.module.css"; +const DiffEditor = lazyComponent(() => import("./DiffEditor").then((m) => m.DiffEditor)); + /** Shared changes tree and on-demand diff surface for revisions and disk folders. */ export function ComparisonTree({ tree, diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 706d1ac39..b3463bbef 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -161,6 +161,8 @@ min-height: 0; overflow: auto; outline: none; + /* Rows coming and going while scrolling relay out the list alone, not the workbench. */ + contain: strict; } .row { position: absolute; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 06ae38ff3..c52e3277a 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -77,6 +77,7 @@ import { toggleRepository, updateLogOptions, useGitLog, + PAGE, } from "./logStore"; import { logOptions, refLabels, useLogOptions, type LogOptions } from "./logOptions"; import { KIND_COLOR, baseName, dirName, fileCount, formatTime, relTo, type EffectiveKind } from "./model"; @@ -1403,8 +1404,10 @@ function CommitList() { const items = virtualizer.getVirtualItems(); const last = items[items.length - 1]; + // The next page is requested while a page of rows is still ahead, so scrolling + // quickly doesn't reach the end before `git log` answers. useEffect(() => { - if (!finished && !loading && last && last.index >= commits.length - 40) void loadMore(scope ?? undefined); + if (!finished && !loading && last && last.index >= commits.length - PAGE) void loadMore(scope ?? undefined); }, [last, commits.length, finished, loading, active, scope]); const selectedIndex = selected ? commits.findIndex((c) => commitKey(c) === selected) : -1; @@ -1424,6 +1427,28 @@ function CommitList() { else if (e.metaKey || e.ctrlKey) toggleCommitSelection(id); else selectCommit(id); }, []); + // CommitRow is memoized: rows get handlers that stay the same across renders and + // call this render's (assigned before rendering). + const rowEvents = useRef(null); + const rowHandlers = useMemo( + () => ({ + onHover: (edge) => rowEvents.current?.onHover(edge), + onFragment: (from, target, expand) => rowEvents.current?.onFragment(from, target, expand), + onNavigate: (index, key) => rowEvents.current?.onNavigate(index, key), + }), + [], + ); + // Called while a row renders its graph, so it changes with what it reads. + const targetTitle = useCallback( + (index: number, key?: string) => { + const target = commits[index]; + return target + ? `${target.shortId} ${target.summary} — ${target.author}` + : (key && boundaryTitles.get(key)) || + `${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`; + }, + [commits, boundaryTitles], + ); const onKeyDown = (e: KeyboardEvent) => { if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key.length === 1 || e.key === "Backspace" || e.key === "Escape")) { @@ -1595,6 +1620,38 @@ function CommitList() { } }; + rowEvents.current = { + onHover: (edge) => { + setHoverColor(edge); + if (!edge) return; + const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge)); + const target = source?.parents.find((p) => `${source.id}\0${p}` === edge); + if (!source || !target || commits.some((c) => commitKey(c) === target) || boundaryRequests.current.has(target)) + return; + const original = all.find((c) => commitKey(c) === source.id); + if (!original) return; + boundaryRequests.current.add(target); + const split = target.lastIndexOf("\0"), + id = target.slice(split + 1); + void loadLogDetails({ ...original, id }) + .then((details) => + setBoundaryTitles((current) => + new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`), + ), + ) + .catch(() => undefined); + }, + onFragment: fragment, + onNavigate: (index, key) => { + const target = commits[index]; + if (target) selectCommit(commitKey(target), true); + else if (key) { + const split = key.lastIndexOf("\0"); + void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo()); + } + }, + }; + return (
    {earlier && ( @@ -1683,52 +1740,15 @@ function CommitList() { mine={opts.highlightMine && isMine(c, user)} onBranch={!!onBranch?.has(commitKey(c))} opts={opts} - remoteNames={new Set((c as RepoCommit).remoteNames ?? remoteNames)} + remoteNames={rowRemoteNames((c as RepoCommit).remoteNames, remoteNames)} onPick={pick} hoverColor={hoverColor} highlighted={highlighted} highlightedNodes={highlightedNodes} - onHover={(edge) => { - setHoverColor(edge); - if (!edge) return; - const source = displayedGraph.find((c) => c.parents.some((p) => `${c.id}\0${p}` === edge)); - const target = source?.parents.find((p) => `${source.id}\0${p}` === edge); - if ( - !source || - !target || - commits.some((c) => commitKey(c) === target) || - boundaryRequests.current.has(target) - ) - return; - const original = all.find((c) => commitKey(c) === source.id); - if (!original) return; - boundaryRequests.current.add(target); - const split = target.lastIndexOf("\0"), - id = target.slice(split + 1); - void loadLogDetails({ ...original, id }) - .then((details) => - setBoundaryTitles((current) => - new Map(current).set(target, `${id.slice(0, 8)} ${details.message.split("\n")[0]}`), - ), - ) - .catch(() => undefined); - }} - onFragment={fragment} - onNavigate={(index, key) => { - const target = commits[index]; - if (target) selectCommit(commitKey(target), true); - else if (key) { - const split = key.lastIndexOf("\0"); - void goToRef(key.slice(split + 1), split >= 0 ? key.slice(0, split) : currentRepo()); - } - }} - targetTitle={(index, key) => { - const target = commits[index]; - return target - ? `${target.shortId} ${target.summary} — ${target.author}` - : (key && boundaryTitles.get(key)) || - `${tx("Commit is outside the loaded history")} · ${key?.split("\0").at(-1)?.slice(0, 8) ?? ""}`; - }} + onHover={rowHandlers.onHover} + onFragment={rowHandlers.onFragment} + onNavigate={rowHandlers.onNavigate} + targetTitle={targetTitle} columns={columns} bookmarked={!!bookmarks[commitKey(c)]} /> @@ -1840,6 +1860,22 @@ function LogColumnHeader({ ); } +type RowEvents = { + onHover: (edge: string | null) => void; + onFragment: (from: string, target: string, expand: boolean) => void; + onNavigate: (index: number, key?: string) => void; +}; + +const remoteNameSets = new WeakMap>(); + +/** A commit's own remote names (multi-root logs) as one set per array, else the shared one. */ +function rowRemoteNames(own: readonly string[] | undefined, shared: ReadonlySet): ReadonlySet { + if (!own) return shared; + let set = remoteNameSets.get(own); + if (!set) remoteNameSets.set(own, (set = new Set(own))); + return set; +} + const CommitRow = memo(function CommitRow({ id, commit: c, diff --git a/src/features/vcs/__tests__/directoryCompare.test.tsx b/src/features/vcs/__tests__/directoryCompare.test.tsx index 896fc57e1..d4c069d10 100644 --- a/src/features/vcs/__tests__/directoryCompare.test.tsx +++ b/src/features/vcs/__tests__/directoryCompare.test.tsx @@ -84,7 +84,7 @@ describe("disk directory comparison", () => { it("previews on selection and opens the existing diff on double click, including missing sides", async () => { setup(); fireEvent.mouseDown(await screen.findByText("中文.txt"), { button: 0 }); - expect(screen.getByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); + expect(await screen.findByTestId("diff")).toHaveTextContent("/p/left/nested/中文.txt"); fireEvent.doubleClick(screen.getByText("中文.txt")); expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ path: "/p/right/nested/中文.txt" })); expect(directoryDiff("/l", "/r", rows[2]!)?.right.type).toBe("text"); @@ -122,7 +122,7 @@ describe("disk directory comparison", () => { ? ["/p/right", "/p/left", "nested/中文.txt"] : ["/p/left", "/p/right", "nested/中文.txt"], ); - expect(screen.getByTestId("diff")).toBeInTheDocument(); + expect(await screen.findByTestId("diff")).toBeInTheDocument(); }, ); diff --git a/src/features/vcs/__tests__/folderCompare.test.tsx b/src/features/vcs/__tests__/folderCompare.test.tsx index c1b806400..58d703ced 100644 --- a/src/features/vcs/__tests__/folderCompare.test.tsx +++ b/src/features/vcs/__tests__/folderCompare.test.tsx @@ -147,7 +147,7 @@ describe("directory comparison", () => { expect(screen.getByText("−1")).toBeInTheDocument(); expect(screen.queryByTestId("diff")).toBeNull(); fireEvent.mouseDown(screen.getByText("app.ts"), { button: 0 }); - expect(screen.getByTestId("diff")).toHaveTextContent('"rev":"aaa"'); + expect(await screen.findByTestId("diff")).toHaveTextContent('"rev":"aaa"'); expect(screen.getByTestId("diff")).toHaveTextContent("/workspace/client/src/app.ts"); }); diff --git a/src/features/vcs/__tests__/logLoading.test.ts b/src/features/vcs/__tests__/logLoading.test.ts index a331dbb69..c81e95679 100644 --- a/src/features/vcs/__tests__/logLoading.test.ts +++ b/src/features/vcs/__tests__/logLoading.test.ts @@ -67,7 +67,6 @@ beforeEach(() => { logTabs: [], filters: emptyFilters(), commits: [], - rows: [], selection: [], selected: null, done: false, diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx index 4321c269e..a116a8d92 100644 --- a/src/features/vcs/__tests__/logUiFeatures.test.tsx +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -82,7 +82,6 @@ beforeEach(() => { tab: "log", logTabs: [], commits: [], - rows: [], filters: emptyFilters(), selection: [], selected: null, diff --git a/src/features/vcs/__tests__/logViews.test.tsx b/src/features/vcs/__tests__/logViews.test.tsx index d32a19e71..0b7d349d6 100644 --- a/src/features/vcs/__tests__/logViews.test.tsx +++ b/src/features/vcs/__tests__/logViews.test.tsx @@ -42,7 +42,6 @@ beforeEach(() => { tab: "log", logTabs: [], commits: [], - rows: [], filters: emptyFilters(), selection: [], selected: null, diff --git a/src/features/vcs/__tests__/multiRoot.test.ts b/src/features/vcs/__tests__/multiRoot.test.ts index 9949a70f0..5b6aad9d9 100644 --- a/src/features/vcs/__tests__/multiRoot.test.ts +++ b/src/features/vcs/__tests__/multiRoot.test.ts @@ -224,12 +224,10 @@ describe("Git log background refresh", () => { const refresh = reloadLog({ preserve: true }); await vi.waitFor(() => expect(finish).toBeDefined()); expect(useGitLog.getState().commits).toBe(before.commits); - expect(useGitLog.getState().rows).toBe(before.rows); expect(useGitLog.getState().loading).toBe(true); finish({ status: "ok", data: [commit("new"), commit("old")] }); await refresh; expect(useGitLog.getState().commits.map((c) => c.id)).toEqual(["new", "old"]); - expect(useGitLog.getState().rows).toHaveLength(2); expect(useGitLog.getState().selected).toBe(commitKey(before.commits[0]!)); expect(useGitLog.getState().loading).toBe(false); }); @@ -290,7 +288,6 @@ describe("Git log background refresh", () => { log.mockRejectedValue(new Error("unavailable")); await reloadLog({ preserve: true }); expect(useGitLog.getState().commits).toBe(before.commits); - expect(useGitLog.getState().rows).toBe(before.rows); expect(useGitLog.getState().error).toContain("unavailable"); expect(useGitLog.getState().loading).toBe(false); }); diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index 477b34eb3..849cd3744 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -458,7 +458,14 @@ const LONG_EDGE_STUB = 2; const onLane = (i: number) => (s: GraphSegment) => s.from === i && s.to === i; -const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLane(i)) && row.down.some(onLane(i)); +/** Lanes a row's vertical lines pass straight through (other than its node's). */ +function passingLanes(row: GraphRow): Set { + const out = new Set(); + for (const s of row.up) + if (s.from === s.to && s.from !== row.lane && row.down.some((d) => d.from === s.from && d.to === s.from)) + out.add(s.from); + return out; +} /** * Show Long Edges off: the middle of a straight run of a lane through `minLength` or @@ -466,41 +473,41 @@ const passes = (row: GraphRow, i: number) => row.lane !== i && row.up.some(onLan */ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): GraphRow[] { const out = rows.slice(); - const width = rows.reduce((max, row) => Math.max(max, row.width), 0); const edit = (r: number, fn: (row: GraphRow) => GraphRow) => { out[r] = fn(out[r]!); }; - for (let i = 0; i < width; i++) { + const hide = (i: number, start: number, end: number) => { const lane = onLane(i); - let start = -1; - for (let r = 0; r <= rows.length; r++) { - if (r < rows.length && passes(rows[r]!, i)) { - if (start < 0) start = r; - continue; - } - if (start >= 0 && r - start >= minLength) { - const from = start + LONG_EDGE_STUB; - const to = r - 1 - LONG_EDGE_STUB; - for (let k = from; k <= to; k++) { - edit(k, (row) => ({ - ...row, - up: row.up.filter((s) => !lane(s)), - down: row.down.filter((s) => !lane(s)), - upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)), - downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)), - })); - } - edit(from - 1, (row) => ({ - ...row, - down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: r } : s)), - })); - edit(to + 1, (row) => ({ - ...row, - up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)), - })); - } - start = -1; + const from = start + LONG_EDGE_STUB; + const to = end - 1 - LONG_EDGE_STUB; + for (let k = from; k <= to; k++) { + edit(k, (row) => ({ + ...row, + up: row.up.filter((s) => !lane(s)), + down: row.down.filter((s) => !lane(s)), + upTargets: row.upTargets?.filter((_, i) => !lane(row.up[i]!)), + downTargets: row.downTargets?.filter((_, i) => !lane(row.down[i]!)), + })); } + edit(from - 1, (row) => ({ + ...row, + down: row.down.map((s) => (lane(s) ? { ...s, arrow: "down", targetRow: end } : s)), + })); + edit(to + 1, (row) => ({ + ...row, + up: row.up.map((s) => (lane(s) ? { ...s, arrow: "up", targetRow: start - 1 } : s)), + })); + }; + // One pass over the rows, following every lane's current run at once. + const runs = new Map(); + for (let r = 0; r <= rows.length; r++) { + const through = r < rows.length ? passingLanes(rows[r]!) : new Set(); + for (const [lane, start] of runs) { + if (through.has(lane)) continue; + if (r - start >= minLength) hide(lane, start, r); + runs.delete(lane); + } + for (const lane of through) if (!runs.has(lane)) runs.set(lane, r); } return out; } diff --git a/src/features/vcs/historyActions.tsx b/src/features/vcs/historyActions.tsx index 96091d9ad..6de2a6f8d 100644 --- a/src/features/vcs/historyActions.tsx +++ b/src/features/vcs/historyActions.tsx @@ -60,7 +60,6 @@ import { unshelve, } from "./shelf"; import { currentRepo, focusRepo, useVcs } from "./store"; -import { openExternalLog } from "./RevisionBrowser"; const VCS = "vcsAvailable"; const IN_GIT_LOG = "(vcsAvailable || projectOpen) && (activeToolWindow == git || vcsLogFocus)"; @@ -250,7 +249,7 @@ export function registerHistoryActions(): () => void { id: "vcs.log.openExternal", title: "Open External Git Log…", category: "Git Log", - run: openExternalLog, + run: async () => (await import("./RevisionBrowser")).openExternalLog(), }, { id: "vcs.log.goToRef", diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index 071d26363..1f625c38d 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -1,5 +1,5 @@ /** Git log tool window state: filters, paged commits, graph, selection. */ -import { branchesByRepo, commitKey, commitRepo, graphCommit, mergeRepoLogs, type RepoCommit } from "./multiRoot"; +import { branchesByRepo, commitKey, commitRepo, mergeRepoLogs, type RepoCommit } from "./multiRoot"; import { create } from "zustand"; import { useContext } from "react"; import { LogIdContext } from "./logContext"; @@ -8,7 +8,6 @@ import { maybeEditorService } from "@/workbench/contributions"; import type { Branch, BranchInfo, Commit, LogQuery } from "@/bindings"; import { call, commands, nextRequestId, type RequestId } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; -import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; import { logOptions, parseLogOptions, setLogOptions, useLogOptions, type LogOptions } from "./logOptions"; import { clearConsole, errorMessage } from "./ops"; import { currentRepo, currentRepoRoot, repoName, repoOf, useVcs } from "./store"; @@ -48,8 +47,6 @@ export interface LogFilters { interface LogState { filters: LogFilters; commits: RepoCommit[]; - rows: GraphRow[]; - graph: GraphState; loading: boolean; done: boolean; error: string | null; @@ -179,8 +176,6 @@ const initialLog = (): Omit => ({ filters: emptyFilters(), seek: null, commits: [], - rows: [], - graph: emptyGraphState(), loading: false, done: false, error: null, @@ -293,8 +288,6 @@ type LogView = Pick< | "filters" | "seek" | "commits" - | "rows" - | "graph" | "loading" | "done" | "error" @@ -306,13 +299,11 @@ type LogView = Pick< > & { options: LogOptions }; function freshView(filters: LogFilters): LogView { - const { commits, rows, graph, loading, done, error, selected, selection, revealTick, fragments } = initialLog(); + const { commits, loading, done, error, selected, selection, revealTick, fragments } = initialLog(); return { filters, seek: null, commits, - rows, - graph, loading, done, error, @@ -329,26 +320,11 @@ function freshView(filters: LogFilters): LogView { const parked = new Map(); function currentView(): LogView { - const { - filters, - commits, - rows, - graph, - done, - error, - selected, - selection, - revealTick, - fragments, - loading, - generation, - seek, - } = useGitLog.getState(); + const { filters, commits, done, error, selected, selection, revealTick, fragments, loading, generation, seek } = + useGitLog.getState(); return { filters, commits, - rows, - graph, loading, seek, generation, @@ -764,7 +740,7 @@ export function reloadLog( cancelLogRequests(id); const next = nextGeneration(); setViewState(id, () => ({ - ...(preserve ? {} : { commits: [], rows: [], graph: emptyGraphState(), done: false }), + ...(preserve ? {} : { commits: [], done: false }), loading: true, error: null, generation: next, @@ -1027,11 +1003,8 @@ function loadPage(replace: boolean, id: string, before = false): Promise { ]), Number.MAX_SAFE_INTEGER, ); - const { rows, state } = computeGraph(commits.map(graphCommit)); setViewState(id, { commits, - rows, - graph: state, done: before ? cur.done : commits.length >= cap diff --git a/src/features/welcome/index.ts b/src/features/welcome/index.ts index 09cfcf53c..9107788fa 100644 --- a/src/features/welcome/index.ts +++ b/src/features/welcome/index.ts @@ -1,7 +1,7 @@ /** Welcome screen, recent projects (File › Open Recent) and reopening the last project on startup. */ import { registerActions } from "@/platform/actions"; +import { lazyComponent } from "@/ui"; import { provideProjectPopup, provideWelcomeView, registerOverlay } from "@/workbench/contributions"; -import { ProjectPopup } from "./ProjectPopup"; import { RecentProjectsPopup, showRecentProjectsPopup } from "./RecentProjectsPopup"; import { reopenLastProject, trackSession } from "./session"; import { WelcomeView } from "./WelcomeView"; @@ -10,7 +10,7 @@ export function activate(): () => void { const disposers = [ trackSession(), provideWelcomeView(WelcomeView), - provideProjectPopup(ProjectPopup), + provideProjectPopup(lazyComponent(() => import("./ProjectPopup").then((m) => m.ProjectPopup))), registerOverlay({ id: "welcome.recentProjects", component: RecentProjectsPopup }), registerActions([ { diff --git a/src/platform/dateFormat.test.ts b/src/platform/dateFormat.test.ts new file mode 100644 index 000000000..94bb49729 --- /dev/null +++ b/src/platform/dateFormat.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_DATE_FORMAT, formatClock, formatDay, formatPattern } from "./dateFormat"; + +describe("dateFormat", () => { + const d = new Date(2026, 10, 5, 9, 7); + const system = { ...DEFAULT_DATE_FORMAT, overrideSystem: false }; + const long = { year: "numeric", month: "short", day: "numeric" } as const; + + it("formats like the locale methods it replaces", () => { + expect(formatDay(d, system)).toBe(d.toLocaleDateString(undefined, long)); + expect(formatDay(d, system, true)).toBe( + d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }), + ); + expect(formatClock(d, system)).toBe(d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })); + expect(formatPattern(d, "MMMM MMM dd.MM.yyyy")).toBe("November Nov 05.11.2026"); + }); + + it("formats another date with the same cached formatter", () => { + const e = new Date(2025, 0, 31); + formatDay(d, system); + expect(formatDay(e, system)).toBe(e.toLocaleDateString(undefined, long)); + }); +}); diff --git a/src/platform/dateFormat.ts b/src/platform/dateFormat.ts index 8552f135d..919e2a9bd 100644 --- a/src/platform/dateFormat.ts +++ b/src/platform/dateFormat.ts @@ -34,13 +34,23 @@ export function dateFormatOptions(): DateFormatOptions { const pad = (n: number, w = 2) => String(n).padStart(w, "0"); +// `toLocale*String` with options builds a new `Intl.DateTimeFormat` on every call, +// far too slow for lists that format a date per row. +const formatters = new Map(); +function formatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { + const key = `${locale ?? ""}|${JSON.stringify(options)}`; + let f = formatters.get(key); + if (!f) formatters.set(key, (f = new Intl.DateTimeFormat(locale, options))); + return f; +} + /** Formats the date part with a `dd.MM.yyyy`-style pattern. */ export function formatPattern(d: Date, pattern: string): string { const tokens: Record string> = { yyyy: () => String(d.getFullYear()), yy: () => pad(d.getFullYear() % 100), - MMMM: () => d.toLocaleString("en", { month: "long" }), - MMM: () => d.toLocaleString("en", { month: "short" }), + MMMM: () => formatter("en", { month: "long" }).format(d), + MMM: () => formatter("en", { month: "short" }).format(d), MM: () => pad(d.getMonth() + 1), M: () => String(d.getMonth() + 1), dd: () => pad(d.getDate()), @@ -50,7 +60,7 @@ export function formatPattern(d: Date, pattern: string): string { } export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions()): string { - if (!o.overrideSystem) return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + if (!o.overrideSystem) return formatter(undefined, { hour: "2-digit", minute: "2-digit" }).format(d); if (o.use24Hour) return `${pad(d.getHours())}:${pad(d.getMinutes())}`; const h = d.getHours() % 12 || 12; return `${h}:${pad(d.getMinutes())} ${d.getHours() < 12 ? "AM" : "PM"}`; @@ -60,8 +70,8 @@ export function formatClock(d: Date, o: DateFormatOptions = dateFormatOptions()) export function formatDay(d: Date, o: DateFormatOptions = dateFormatOptions(), short = false): string { if (o.overrideSystem) return formatPattern(d, o.pattern); return short - ? d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }) - : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + ? formatter(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }).format(d) + : formatter(undefined, { year: "numeric", month: "short", day: "numeric" }).format(d); } /** Timestamp for lists: pretty ("Today 10:42", "5 min ago") when enabled, else date + time. */ diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 967e047c2..7385a3e18 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -2253,6 +2253,7 @@ "Stop and Rerun": "停止并重新运行", "Stop to Edit": "停止以编辑", "Stopped after {count} matches": "匹配 {count} 处后停止", + "Stream Sample Output": "流式输出示例日志", "Strikeout": "删除线", "Struct": "结构体", "Struct type": "结构体类型", diff --git a/src/platform/tauriCore.ts b/src/platform/tauriCore.ts index dc03bdac4..547c1acf2 100644 --- a/src/platform/tauriCore.ts +++ b/src/platform/tauriCore.ts @@ -19,6 +19,11 @@ export function setInvokeRoute(next: InvokeRoute | null) { route = next; } +/** Whether commands have somewhere to go besides this process's Tauri runtime. */ +export function hasInvokeRoute(): boolean { + return route !== null; +} + export function invoke(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise { return (route?.(cmd, args, options) ?? invokeLocal(cmd, args, options)) as Promise; } diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index 94a3edae1..77f6d5a72 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -309,26 +309,47 @@ describe("siblingPositions", () => { describe("Tree horizontal scrolling", () => { const spacer = (tree: HTMLElement) => tree.querySelector("[data-content-width]"); + // Widths are measured on the next animation frame. + const frame = () => act(() => new Promise((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", () => { + 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("removes widths belonging to collapsed descendants", () => { + 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, ) { @@ -336,16 +357,18 @@ describe("Tree horizontal scrolling", () => { }); 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", () => { + 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, @@ -355,6 +378,7 @@ describe("Tree horizontal scrolling", () => { }); 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); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index 5d29a7c8d..38855aef7 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -130,33 +130,34 @@ function useContentWidth( enabled: boolean, ): number { const [width, setWidth] = useState(0); - const cache = useRef(new Map()); - const measuredRows = useRef[] | null>(null); - const maximum = useRef(0); - useLayoutEffect(() => { - if (!enabled) { - cache.current.clear(); - measuredRows.current = null; - maximum.current = 0; - return; - } + const latestRows = useRef(rows); + const schedule = useRef<(() => void) | null>(null); + useEffect(() => { + if (!enabled) return; const element = scroller.current; if (!element) return; - const mounted = [...element.querySelectorAll("[data-row]")]; + const cache = new Map(); + const observed = new Set(); + let measuredRows: readonly Row[] | null = null; + let maximum = 0; + let frame = 0; const measure = () => { + frame = 0; // Hidden tool windows have no layout. Keep their estimates until shown; // the observer remains attached so showing the window resumes measurement. if (element.clientWidth === 0) return; + const rows = latestRows.current; + const mounted = [...element.querySelectorAll("[data-row]")]; let recompute = false; - if (measuredRows.current !== rows) { + if (measuredRows !== rows) { const keys = new Set(rows.map((row) => row.key)); - for (const [key, value] of cache.current) { + for (const [key, value] of cache) { if (!keys.has(key)) { - cache.current.delete(key); - if (value === maximum.current) recompute = true; + cache.delete(key); + if (value === maximum) recompute = true; } } - measuredRows.current = rows; + measuredRows = rows; } // Measure max-content, not the viewport's min-width floor. Batch writes and // restore before paint so selection backgrounds still fill the viewport. @@ -165,27 +166,47 @@ function useContentWidth( const widths = mounted.map((row) => row.offsetWidth); mounted.forEach((row, index) => { row.style.minWidth = minimums[index]!; - const key = rows[Number(row.dataset.row)]!.key; + const key = rows[Number(row.dataset.row)]?.key; + if (key === undefined) return; const value = widths[index]!; - if (cache.current.get(key) === maximum.current && value < maximum.current) recompute = true; - cache.current.set(key, value); - maximum.current = Math.max(maximum.current, value); + if (cache.get(key) === maximum && value < maximum) recompute = true; + cache.set(key, value); + maximum = Math.max(maximum, value); }); // Ordinary scrolling only touches mounted rows. Scan cached widths only // when the previous maximum was removed or actually became narrower. if (recompute) { - maximum.current = 0; - for (const value of cache.current.values()) maximum.current = Math.max(maximum.current, value); - recompute = false; + maximum = 0; + for (const value of cache.values()) maximum = Math.max(maximum, value); } // Reconcile and measure together: never publish an intermediate shrink. - setWidth(maximum.current); + setWidth(maximum); + // Rows resize without a render when fonts or zoom change. + const current = new Set(mounted); + for (const row of observed) if (!current.has(row)) observer.unobserve(row); + for (const row of mounted) if (!observed.has(row)) observer.observe(row); + observed.clear(); + for (const row of mounted) observed.add(row); }; - measure(); - const observer = new ResizeObserver(measure); + // Measuring forces layout: coalesce renders and resizes into one pass per + // frame instead of measuring inside every commit. + const request = () => { + frame ||= requestAnimationFrame(measure); + }; + const observer = new ResizeObserver(request); observer.observe(element); - for (const row of mounted) observer.observe(row); - return () => observer.disconnect(); + schedule.current = request; + request(); + return () => { + schedule.current = null; + cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [enabled, scroller]); + // Rows or the virtual window may have changed; keep `rows` in step with the DOM. + useLayoutEffect(() => { + latestRows.current = rows; + schedule.current?.(); }); // Offscreen widths are estimates after label/font/indent changes; mounting // measures them afresh, including decreases for the same key. diff --git a/src/ui/index.ts b/src/ui/index.ts index a93a7b13e..b20bcab1d 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -32,7 +32,7 @@ export { fuzzyMatch, wordMatch } from "./match"; export { badgeKind } from "./symbolKind"; export { acquirePopup, isPopupOpen } from "./popupState"; export * from "./BarButton"; -export { lazyComponent, prefetchLazyComponents } from "./lazy"; +export { lazyComponent, lazyWhen, prefetchLazyComponents } from "./lazy"; export { T } from "./T"; export { selectableText, copyMenuFor, copyText } from "./selectableText"; export { useSelectionBoundary } from "./useSelectionBoundary"; diff --git a/src/ui/lazy.tsx b/src/ui/lazy.tsx index b9221e341..565885a3e 100644 --- a/src/ui/lazy.tsx +++ b/src/ui/lazy.tsx @@ -39,6 +39,24 @@ export function lazyComponent

    (load: () => Promise( + useShown: () => boolean, + load: () => Promise>, +): ComponentType

    { + const Lazy = lazyComponent(load); + let shown = false; + return function LazyWhen(props: P) { + const now = useShown(); + shown ||= now; + return shown ? : null; + }; +} + /** * Loads every lazy component's code one at a time while the app is idle, so a * view opened later shows at once. Call after start-up. diff --git a/src/workbench/perfProbe.ts b/src/workbench/perfProbe.ts index e7290e1e1..f53a42f75 100644 --- a/src/workbench/perfProbe.ts +++ b/src/workbench/perfProbe.ts @@ -8,13 +8,15 @@ import { executeAction } from "@/platform/actions"; import { bindingsFor } from "@/platform/keymap"; import { activeEditor } from "@/editor/api"; import { docBackend, type MemoryBackend } from "@/editor/core/backend"; +import type { Commit, CompletionItem, CompletionKind, LogQuery } from "@/bindings"; +import { setInvokeRoute } from "@/platform/tauriCore"; import { editorService } from "./contributions"; export interface PerfProbe { /** Seeds file contents for the in-memory document backend (plain-browser mode). */ seed(path: string, text: string): void; openFile(path: string): Promise; - runAction(id: string): Promise; + runAction(id: string, arg?: unknown): Promise; /** Effective keymap bindings of an action, e.g. `["Ctrl+Shift+A"]`. */ bindings(id: string): string[]; /** Moves the caret of the active editor and focuses it. */ @@ -23,6 +25,104 @@ export interface PerfProbe { parsing(): boolean; /** Document length and caret of the active editor. */ editorState(): { path: string; length: number; lines: number; head: number } | null; + /** + * Answers code completion with `items` generated items (a language server's + * list); other language server requests report no server. `0` removes it. + */ + fakeCompletion(items: number): void; + /** + * Answers the Git Log with `commits` generated commits (a main line with + * feature branches merging into it), paged like the backend, each page after + * `latencyMs` (a `git log` call). `0` commits removes it. + */ + fakeGitLog(commits: number, latencyMs?: number): void; +} + +const KINDS: CompletionKind[] = [ + "method", + "function", + "field", + "variable", + "class", + "interface", + "property", + "keyword", +]; + +// Completion details as a language server reports them (code, not UI text). +const RESULT = "Promise>"; +const qualified = (name: string) => ["com", "acme", name].join("."); + +function completionItem(i: number): CompletionItem { + const kind = KINDS[i % KINDS.length]!; + // Shared prefixes so typing narrows the list gradually, like real symbols. + const label = `${["get", "set", "create", "update", "find", "to", "is", "on"][i % 8]}${["Order", "Item", "Customer", "Invoice", "Payment", "Status"][(i >> 3) % 6]}${i}`; + return { + label, + labelDetail: kind === "method" || kind === "function" ? "(id: string, options?: Options)" : null, + detail: kind === "class" || kind === "interface" ? qualified(label) : RESULT, + kind, + documentation: null, + sortText: String(i).padStart(5, "0"), + filterText: null, + preselect: false, + deprecated: i % 97 === 0, + insertText: label, + isSnippet: false, + replaceRange: null, + replaceRangeTab: null, + additionalEdits: [], + commitCharacters: [], + command: null, + data: null, + server: 1, + }; +} + +function commit(i: number, count: number): Commit { + const id = (n: number) => n.toString(16).padStart(40, "0"); + // Newest first: parents have higher indices. Every 12th commit merges a branch + // started 7 commits back; every third commit skips ahead, keeping a few lanes open. + const merge = i % 12 === 0 && i + 7 < count; + const next = i % 3 === 2 && i + 3 < count ? i + 3 : i + 1; + const parents = i + 1 >= count ? [] : merge ? [id(i + 1), id(i + 7)] : [id(next)]; + const time = Date.UTC(2026, 10, 5) - i * 3_600_000; + const summary = `${["feat", "fix", "refactor", "test"][i % 4]}(orders): ${merge ? "merge" : "update"} order flow step ${i}`; + return { + id: id(i), + shortId: id(i).slice(-8), + parents, + author: ["Ada Lovelace", "Linus Torvalds", "Grace Hopper", "Ken Thompson"][i % 4]!, + email: `dev${i % 4}@acme.dev`, + committer: "CI", + committerEmail: "ci@acme.dev", + timeMs: time, + commitTimeMs: time, + summary, + message: summary, + messageTruncated: false, + refs: i === 0 ? ["HEAD -> main", "origin/main"] : i % 250 === 0 ? [`tag: v1.${count - i}`] : [], + }; +} + +type Route = (cmd: string, args: unknown) => Promise | null; +const routes = new Map(); + +/** Installs (or with `null` removes) one fake backend; the first that answers a command wins. */ +function setRoute(name: string, next: Route | null) { + if (next) routes.set(name, next); + else routes.delete(name); + setInvokeRoute( + routes.size + ? (cmd, args) => { + for (const r of routes.values()) { + const answer = r(cmd, args); + if (answer) return answer; + } + return null; + } + : null, + ); } export function installPerfProbe(): () => void { @@ -33,7 +133,7 @@ export function installPerfProbe(): () => void { backend.files.set(path, text); }, openFile: (path) => editorService().openFile(path), - runAction: (id) => executeAction(id), + runAction: (id, arg) => executeAction(id, arg), bindings: (id) => bindingsFor(id), setCursor(offset) { const view = activeEditor()?.view; @@ -45,6 +145,37 @@ export function installPerfProbe(): () => void { const view = activeEditor()?.view; return !!view && syntaxParserRunning(view); }, + fakeCompletion(count) { + const items = Array.from({ length: count }, (_, i) => completionItem(i)); + setRoute( + "completion", + count + ? (cmd, args) => { + if (cmd === "lsp_completion") return Promise.resolve({ isIncomplete: false, items }); + if (cmd === "lsp_resolve_completion") return Promise.resolve((args as { item: CompletionItem }).item); + if (cmd.startsWith("lsp_")) + return Promise.reject({ kind: "unavailable", message: "No language server available" }); + return null; + } + : null, + ); + }, + fakeGitLog(count, latencyMs = 0) { + const commits = Array.from({ length: count }, (_, i) => commit(i, count)); + setRoute( + "gitLog", + count + ? (cmd, args) => { + if (cmd !== "vcs_log") return null; + const { skip, limit, range } = (args as { query: LogQuery }).query; + // Outgoing commits (`@{u}..HEAD`) and the like: none. + if (range) return Promise.resolve([]); + const page = commits.slice(skip, limit ? skip + limit : undefined); + return new Promise((resolve) => setTimeout(() => resolve(page), latencyMs)); + } + : null, + ); + }, editorState() { const e = activeEditor(); if (!e) return null;