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;