Compare commits

...
Author SHA1 Message Date
l0ng-ai 071ece852b perf: load the Git Log scenario's pages with git's latency and scroll across several 2026-11-07 10:39:42 +08:00
l0ng-ai cb614eff95 perf(vcs): keep Git Log scrolling smooth as pages load
Each page of the log recomputed hidden long edges by scanning every row once per
lane, and the store laid out the whole graph a second time for fields nothing
read. Long edges are now found in one pass over the rows, the unused store graph
is gone, and the next page is requested while a page of rows is still ahead so
fast scrolling doesn't reach the end before git answers. The commit list also
contains its layout, so rows entering while scrolling no longer relay out the
workbench.
2026-11-07 10:39:40 +08:00
l0ng-ai 50b55ffc9e perf: translate the sample output action and keep fake completion details out of the text catalog 2026-11-07 09:47:19 +08:00
l0ng-ai 7e7c5088f0 Merge remote-tracking branch 'origin/main' into perf/project-tree-expand
# Conflicts:
#	src/editor/core/cm/angular/index.ts
#	src/features/plugins/index.ts
2026-11-07 09:47:17 +08:00
l0ng-ai f363f76e57 perf: measure the Git Log and splitter dragging
gitLog shows 20k generated commits served page by page through a fake vcs_log,
walks the selection with the arrow keys and wheel-scrolls the list.
resizePanels drags the side and bottom splitters with an editor, the Project
tree and the Log open. The perf probe's fake backends can now be combined.
2026-11-07 09:38:45 +08:00
l0ng-ai 6999d4034f perf(vcs): keep Git Log rows from re-rendering on every selection change
Commit rows were memoized but received a new Set and new closures on every
render, so moving the selection re-rendered every visible row, and each row
built a new Intl.DateTimeFormat for its date. Rows now get stable handlers and
shared remote-name sets, and date formatting reuses cached formatters.
2026-11-07 09:38:44 +08:00
l0ng-ai b524d547d5 perf(run): show a flood of console output at most every 50 ms
While a process printed thousands of lines a second, the Run console
appended, re-rendered and laid out on every frame, so typing in an editor
meanwhile missed the next frame 35% of the time. A line after a quiet spell
still shows on the next frame; a flood now updates the console at ~20 Hz,
and typing during it misses 2-5% (p50 16 -> 9.7 ms) with the output done as
fast as before.

The performance harness gets a scenario for it: 50,000 log lines at 200 a
frame (a dev and perf build action, run.sampleOutput) while typing.
2026-11-06 23:32:27 +08:00
l0ng-ai f33b56b92b perf: measure code completion over a 2,000-item list
The harness's probe can answer completion with generated items through the
command route; language server requests go through a route when one is
installed, not only under Tauri. The scenario opens the popup by typing,
narrows it letter by letter and moves the selection, with budgets for each.
2026-11-06 23:16:55 +08:00
l0ng-ai 95c501ea8a perf: budget typing in a large Angular template 2026-11-06 22:58:06 +08:00
l0ng-ai 374f12b7e3 perf(angular): reuse nested trees of unchanged text and attributes
An edit makes the mixed parser recreate many text and attribute nodes it
does not keep: the document's own text and the nodes up to the viewport. It
reuses nested trees only of nodes it kept, so each of those was parsed again
as Angular on every keystroke (~170 nested parses per key in a 12k-line
template). Their parse depends only on their text, so the trees are now
remembered by text, up to about 2M characters.

Typing in the middle of a 12k-line template: main thread per key 57 -> 22 ms,
p50 latency 30 -> 19.5 ms, no long tasks.
2026-11-06 22:52:43 +08:00
l0ng-ai d533750d1c perf(angular): parse only text with Angular syntax as Angular
Every text node of a template got its own nested Angular parse, including
the whitespace between tags, which is most of them. Text without a `{`, `}`
or `@` holds no interpolation or block, so it stays HTML text. A full parse
of a 12k-line template takes about a fifth less work.
2026-11-06 13:22:53 +08:00
l0ng-ai 75718a6d21 perf: load plugin, debugger and project popups when first shown
The IntelliJ theme import, the debugger's popovers and dialogs and the title
bar's project popup now load when first opened. `lazyWhen` keeps a view
mounted once shown, so its own open/closed handling is unchanged.
2026-11-06 08:21:15 +08:00
l0ng-ai 2e12f51cac perf: load dialogs, the SQL console and diff views on first use
- Database dialogs, Assign Shortcut and the Logcat tool window load when shown.
- The SQL console extensions load with the first SQL editor
  (`lazyEditorExtension` reconfigures the editor once they arrive).
- The changes tree loads the diff viewer when a file is selected, and
  "Open Repository Log" loads the revision browser when run.
2026-11-06 08:18:43 +08:00
l0ng-ai 1ae8194613 perf(search, run): load popups and Edit Configurations when first shown
Search Everywhere, Find in Files, the recent files/locations popups, File
Structure and the Run/Debug Configurations dialog were part of the initial
bundle although each only renders while open. `lazyWhen` mounts a lazy view
only while its condition holds; the idle prefetch still loads them right after
start-up, so opening one stays as fast.
2026-11-06 08:14:23 +08:00
l0ng-ai 4ceae47d1f perf(templates): load Emmet when an HTML or stylesheet editor opens
The Emmet engine (~70 KB minified) was part of the initial bundle; it now loads
with the first editor that can expand abbreviations, or with Update Tag with Emmet.
2026-11-06 08:11:35 +08:00
l0ng-ai b91025a48e perf(github): load the pull requests integration after start-up
Actions, the tool window and the timeline editor stay registered at start-up;
their implementations, the connection and review-mode tracking load on idle
or on first use. Takes ~60 KB gzip off the initial bundle.
2026-11-06 08:11:34 +08:00
l0ng-ai eb1015c19a perf(project-tree): measure row widths once per frame
The horizontal scroll range was measured in a layout effect on every render:
each pass forced layout, its setState re-rendered synchronously and measured
again, and the ResizeObserver was recreated each time, firing once more.
Expanding a 10k-entry folder went from ~26 ms to ~63 ms.

Keep one observer per tree and coalesce renders and resizes into a single
measurement on the next animation frame. Expanding is back to ~30 ms and
arrow-key p50 drops from 3.3 ms to 1.7 ms; the widths behave as before.
2026-11-05 23:58:23 +08:00
55 changed files with 1298 additions and 361 deletions

No files matched your search

+30
View File
@@ -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,
+187
View File
@@ -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<Order>");
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<boolean> } };
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<boolean> } }).__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<boolean> } }).__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,
+41
View File
@@ -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;
+34 -1
View File
@@ -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 = '<p [title]="t">{{ a + 1 }} @if (b) {</p>\n';
tokens(part);
// The same nodes at other offsets come from the memo; tokens are sliced at their positions.
const doc = `<div>${part}</div>\n${part}<span>x</span>${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 = "<ul>\n <li>Tom &amp; Jerry</li>\n <li>{{ a }} &amp; {{ b }}</li>\n</ul>\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 <li>")).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"), {
+85 -9
View File
@@ -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<string, Tree>();
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 };
}
}
+26
View File
@@ -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<string>();
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();
});
});
+17 -1
View File
@@ -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<typeof import("@/platform/ipc")>();
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", () => {
+3 -1
View File
@@ -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<T> = { 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<T>(run: () => Promise<Result<T>>, wanted?: () => boolean): Promise<T> {
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();
+2 -1
View File
@@ -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). */
+19 -8
View File
@@ -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);
},
},
{
+2
View File
@@ -0,0 +1,2 @@
/** The language id of SQL files and consoles. */
export const SQL_LANGUAGE = "sql";
+10 -3
View File
@@ -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"],
+49 -117
View File
@@ -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<Runtime> | null = null;
let dispose: (() => void) | null = null;
let active = false;
function load(): Promise<Runtime> {
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<Action, "run"> & { run?: Action["run"] };
async function prUrl(number: number): Promise<string> {
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());
};
}
+127
View File
@@ -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<string> {
return cachedDetails(number)?.url ?? (await loadPullRequestData(number, "details")).url;
}
type Run = (c: ActionContext) => void | Promise<void>;
/** Implementations of the actions `index.ts` registers, by id. */
export const runs: Record<string, Run> = {
"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());
}
+5
View File
@@ -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: [] }));
@@ -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<ReturnType<typeof useGhRepository.getState>, "connection" | "remotes">): MenuItem[] {
return s.connection && s.remotes.length > 1 ? CHANGE_REPOSITORY : NONE;
}
const NONE: MenuItem[] = [];
+2 -2
View File
@@ -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,
});
+8 -4
View File
@@ -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);
},
},
{
+5 -17
View File
@@ -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<string | null>
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<DialogState>(() => ({ 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";
@@ -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<DialogState>(() => ({ 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 });
}
+28
View File
@@ -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);
});
});
+23 -3
View File
@@ -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,
@@ -276,8 +276,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",
@@ -295,7 +312,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({
+80 -2
View File
@@ -92,8 +92,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). */
@@ -496,6 +501,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;
@@ -516,12 +522,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);
@@ -592,6 +613,7 @@ function newRuntime(config: RunConfig): SessionRuntime {
}),
queue: [],
flushHandle: null,
flushedAt: -Infinity,
generation: 0,
lineListeners: [],
input: "",
@@ -1301,3 +1323,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<void> {
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);
}
+29 -11
View File
@@ -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<typeof showSearchEverywhere>[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({
@@ -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);
@@ -0,0 +1,4 @@
import { create } from "zustand";
/** Whether the File Structure popup is open. */
export const useFileStructurePopup = create<{ open: boolean }>(() => ({ open: false }));
+10 -3
View File
@@ -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).
@@ -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;
@@ -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 });
}
@@ -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);
+2
View File
@@ -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,
+27 -4
View File
@@ -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<Emmet> | null = null;
/** Loads the Emmet engine, kept out of start-up; expansion finds nothing until it has. */
export function loadEmmet(): Promise<Emmet> {
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<string, string> = { 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;
}
+2 -1
View File
@@ -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({
+3 -2
View File
@@ -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<F>({
tree,
+2
View File
@@ -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;
+79 -43
View File
@@ -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";
@@ -1391,8 +1392,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;
@@ -1412,6 +1415,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<RowEvents | null>(null);
const rowHandlers = useMemo<RowEvents>(
() => ({
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")) {
@@ -1583,6 +1608,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 (
<div className={styles.listWrap}>
{earlier && (
@@ -1671,52 +1728,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)]}
/>
@@ -1828,6 +1848,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<readonly string[], ReadonlySet<string>>();
/** 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<string>): ReadonlySet<string> {
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,
@@ -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();
},
);
@@ -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");
});
@@ -67,7 +67,6 @@ beforeEach(() => {
logTabs: [],
filters: emptyFilters(),
commits: [],
rows: [],
selection: [],
selected: null,
done: false,
@@ -82,7 +82,6 @@ beforeEach(() => {
tab: "log",
logTabs: [],
commits: [],
rows: [],
filters: emptyFilters(),
selection: [],
selected: null,
@@ -42,7 +42,6 @@ beforeEach(() => {
tab: "log",
logTabs: [],
commits: [],
rows: [],
filters: emptyFilters(),
selection: [],
selected: null,
@@ -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);
});
+38 -31
View File
@@ -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<number> {
const out = new Set<number>();
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<number, number>();
for (let r = 0; r <= rows.length; r++) {
const through = r < rows.length ? passingLanes(rows[r]!) : new Set<number>();
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;
}
+1 -2
View File
@@ -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",
+5 -32
View File
@@ -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<LogState, "generation" | "viewsVersion"> => ({
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<string, LogView>();
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,
@@ -1017,11 +993,8 @@ function loadPage(replace: boolean, id: string, before = false): Promise<void> {
]),
Number.MAX_SAFE_INTEGER,
);
const { rows, state } = computeGraph(commits.map(graphCommit));
setViewState(id, {
commits,
rows,
graph: state,
done: before
? cur.done
: commits.length >= cap
+2 -2
View File
@@ -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([
{
+23
View File
@@ -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));
});
});
+15 -5
View File
@@ -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<string, Intl.DateTimeFormat>();
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, () => 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. */
+1
View File
@@ -2251,6 +2251,7 @@
"Stop and Rerun": "停止并重新运行",
"Stop to Edit": "停止以编辑",
"Stopped after {count} matches": "匹配 {count} 处后停止",
"Stream Sample Output": "流式输出示例日志",
"Strikeout": "删除线",
"Struct": "结构体",
"Struct type": "结构体类型",
+5
View File
@@ -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<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T> {
return (route?.(cmd, args, options) ?? invokeLocal<T>(cmd, args, options)) as Promise<T>;
}
+27 -3
View File
@@ -309,26 +309,47 @@ describe("siblingPositions", () => {
describe("Tree horizontal scrolling", () => {
const spacer = (tree: HTMLElement) => tree.querySelector<HTMLElement>("[data-content-width]");
// Widths are measured on the next animation frame.
const frame = () => act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
it("is off unless asked for", () => {
const { tree } = setup();
expect(spacer(tree)).toBeNull();
});
it("remeasures mounted rows with the same key, including narrower content", () => {
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);
+49 -28
View File
@@ -130,33 +130,34 @@ function useContentWidth<T>(
enabled: boolean,
): number {
const [width, setWidth] = useState(0);
const cache = useRef(new Map<string, number>());
const measuredRows = useRef<readonly Row<T>[] | 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<HTMLElement>("[data-row]")];
const cache = new Map<string, number>();
const observed = new Set<HTMLElement>();
let measuredRows: readonly Row<T>[] | 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<HTMLElement>("[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<T>(
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.
+1 -1
View File
@@ -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";
+18
View File
@@ -39,6 +39,24 @@ export function lazyComponent<P extends object>(load: () => Promise<ComponentTyp
return LazyComponent;
}
/**
* A lazy component first mounted when `useShown()` holds (a dialog opens), so an
* always-registered overlay doesn't load its code at start-up. It stays mounted
* afterwards: the component still decides what to render when closed.
*/
export function lazyWhen<P extends object>(
useShown: () => boolean,
load: () => Promise<ComponentType<P>>,
): ComponentType<P> {
const Lazy = lazyComponent(load);
let shown = false;
return function LazyWhen(props: P) {
const now = useShown();
shown ||= now;
return shown ? <Lazy {...props} /> : 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.
+133 -2
View File
@@ -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<void>;
runAction(id: string): Promise<boolean>;
runAction(id: string, arg?: unknown): Promise<boolean>;
/** 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<Result<Order>>";
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: "[email protected]",
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<unknown> | null;
const routes = new Map<string, Route>();
/** 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;