mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 03:08:18 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
071ece852b | ||
|
|
cb614eff95 | ||
|
|
50b55ffc9e | ||
|
|
7e7c5088f0 | ||
|
|
f363f76e57 | ||
|
|
6999d4034f | ||
|
|
b524d547d5 | ||
|
|
f33b56b92b | ||
|
|
95c501ea8a | ||
|
|
374f12b7e3 | ||
|
|
d533750d1c | ||
|
|
75718a6d21 | ||
|
|
2e12f51cac | ||
|
|
1ae8194613 | ||
|
|
4ceae47d1f | ||
|
|
b91025a48e | ||
|
|
eb1015c19a |
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 & Jerry</li>\n <li>{{ a }} & {{ 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"), {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** The language id of SQL files and consoles. */
|
||||
export const SQL_LANGUAGE = "sql";
|
||||
@@ -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
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }));
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -2251,6 +2251,7 @@
|
||||
"Stop and Rerun": "停止并重新运行",
|
||||
"Stop to Edit": "停止以编辑",
|
||||
"Stopped after {count} matches": "匹配 {count} 处后停止",
|
||||
"Stream Sample Output": "流式输出示例日志",
|
||||
"Strikeout": "删除线",
|
||||
"Struct": "结构体",
|
||||
"Struct type": "结构体类型",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user