дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Message dialogs: `Dialog` gets an `icon` (question / info / warning / error) that renders IDEA's alert layout: no title bar, a 32px icon, bold title, text, checkbox under it. Every confirmation helper (prompt, VCS, services, project tree, keymap, LSP choices, run, database, Replace All) uses it. Open Project matches IDEA: "This Window" is the default and has focus. The Code Analysis and TODO commit checks default to Review. Project widget: the title bar project button opens a popup like IDEA's ProjectWidget, with Open… / Clone Repository…, the projects open in other windows (new `window_open_projects` command) and recent projects, filtered by typing. Chinese UI: UI text stays written in English and is translated through a source-text catalog (`locales/text/zh.json`, about 2300 strings). The UI primitives (buttons, checkboxes, selects, tabs, dialogs, menus, tooltips, toasts, settings labels and hints) translate the strings they are given. Action titles and categories are localised in the registry, and static JSX text uses `<T>`, so text follows a locale switch without a reload. The Settings window now syncs the locale too. Settings search matches both languages. Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
260 рядки
9.8 KiB
TypeScript
260 рядки
9.8 KiB
TypeScript
/**
|
|
* Profiler tool window: one tab per snapshot, and per snapshot a flame graph
|
|
* (click to zoom, search highlights), the call tree (top-down) and the method
|
|
* list (self / total). Double-click opens a frame's source when known.
|
|
*/
|
|
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import clsx from "clsx";
|
|
import type { Profile } from "@/platform/ipc";
|
|
import { EmptyState, IconButton, Input, Tabs, Tree, T } from "@/ui";
|
|
import { editorService } from "@/workbench/contributions";
|
|
import { colorSlot, flameLayout, formatWeight, frameLocation, matchingFrames, share } from "./model";
|
|
import { activeSnapshot, closeSnapshot, useProfiler, type ProfilerTab } from "./store";
|
|
import styles from "./Profiler.module.css";
|
|
|
|
const ROW = 18;
|
|
|
|
function openFrame(profile: Profile, frame: number) {
|
|
const f = profile.frames[frame];
|
|
if (!f?.file) return;
|
|
void editorService().openFile(
|
|
f.file,
|
|
f.line !== null ? { selection: { line: f.line, col: 0 }, focus: true } : { focus: true },
|
|
);
|
|
}
|
|
|
|
export function ProfilerTabs() {
|
|
const snapshots = useProfiler((s) => s.snapshots);
|
|
const activeId = useProfiler((s) => s.activeId);
|
|
if (!snapshots.length) return null;
|
|
return (
|
|
<Tabs
|
|
variant="compact"
|
|
aria-label="Profiler snapshots"
|
|
className={styles.tabs}
|
|
activeId={activeId}
|
|
onSelect={(id) => useProfiler.setState({ activeId: id })}
|
|
onClose={closeSnapshot}
|
|
items={snapshots.map((s) => ({
|
|
id: s.id,
|
|
label: s.name,
|
|
closable: true,
|
|
tooltip: `${s.profile.profiler}, ${new Date(s.createdAt).toLocaleTimeString()}`,
|
|
}))}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FlameGraph({ profile, query }: { profile: Profile; query: string }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [width, setWidth] = useState(0);
|
|
const [focus, setFocus] = useState(0);
|
|
useLayoutEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver(() => setWidth(el.clientWidth));
|
|
ro.observe(el);
|
|
setWidth(el.clientWidth);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
const layout = useMemo(() => flameLayout(profile, focus, width > 0 ? 1 / width : 0.002), [profile, focus, width]);
|
|
const matches = useMemo(() => matchingFrames(profile, query), [profile, query]);
|
|
const unit = profile.unit;
|
|
const total = profile.total ?? 0;
|
|
|
|
return (
|
|
<div className={styles.flamePane}>
|
|
{focus !== 0 && (
|
|
<div className={styles.flameBar}>
|
|
<IconButton icon="close" size="small" title="Reset Zoom" onClick={() => setFocus(0)} />
|
|
<span className={styles.muted}>Zoomed into {profile.frames[profile.nodes[focus]!.frame]!.name}</span>
|
|
</div>
|
|
)}
|
|
<div className={styles.flameScroll} ref={ref}>
|
|
<div className={styles.flame} style={{ height: layout.depth * ROW }} role="tree" aria-label="Flame graph">
|
|
{width > 0 &&
|
|
layout.rects.map((r) => {
|
|
const node = profile.nodes[r.node]!;
|
|
const frame = profile.frames[node.frame]!;
|
|
const w = r.width * width;
|
|
const location = frameLocation(profile, node.frame);
|
|
const label = r.node === 0 ? `all (${formatWeight(total, unit)})` : frame.name;
|
|
return (
|
|
<div
|
|
key={r.node}
|
|
role="treeitem"
|
|
aria-label={label}
|
|
className={clsx(
|
|
styles.frame,
|
|
styles[`c${colorSlot(frame.name)}`],
|
|
matches && (matches.has(node.frame) ? styles.match : styles.dim),
|
|
)}
|
|
style={{ left: r.x * width, width: Math.max(w - 1, 1), bottom: r.depth * ROW }}
|
|
title={`${frame.name}${location ? ` (${location})` : ""}\nTotal: ${formatWeight(node.total ?? 0, unit)} (${share(node.total ?? 0, total)})\nSelf: ${formatWeight(node.selfWeight ?? 0, unit)}`}
|
|
onClick={() => setFocus(r.node)}
|
|
onDoubleClick={() => openFrame(profile, node.frame)}
|
|
>
|
|
{w > 28 && <span className={styles.frameText}>{label}</span>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CallTree({ profile }: { profile: Profile }) {
|
|
const total = profile.total ?? 0;
|
|
const roots = useMemo(() => profile.nodes[0]?.children ?? [], [profile]);
|
|
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
|
// The heaviest path starts expanded (IDEA expands the hottest call chain).
|
|
const initial = useMemo(() => {
|
|
const set = new Set<string>();
|
|
let cur = roots[0];
|
|
for (let i = 0; cur !== undefined && i < 30; i++) {
|
|
set.add(String(cur));
|
|
cur = profile.nodes[cur]!.children[0];
|
|
}
|
|
return set;
|
|
}, [profile, roots]);
|
|
return (
|
|
<Tree<number>
|
|
aria-label="Call tree"
|
|
className={styles.tree}
|
|
roots={roots}
|
|
getKey={(n) => String(n)}
|
|
getLabel={(n) => profile.frames[profile.nodes[n]!.frame]!.name}
|
|
getChildren={(n) => (profile.nodes[n]!.children.length ? profile.nodes[n]!.children : undefined)}
|
|
isExpandable={(n) => profile.nodes[n]!.children.length > 0}
|
|
expanded={expanded ?? initial}
|
|
onExpandedChange={(next) => setExpanded(next)}
|
|
activateExpandable
|
|
onActivate={(n) => openFrame(profile, profile.nodes[n]!.frame)}
|
|
renderNode={(n) => {
|
|
const node = profile.nodes[n]!;
|
|
const location = frameLocation(profile, node.frame);
|
|
return (
|
|
<span className={styles.treeRow}>
|
|
<span className={styles.share}>{share(node.total ?? 0, total)}</span>
|
|
<span className={styles.name}>{profile.frames[node.frame]!.name}</span>
|
|
{location && <span className={styles.muted}>{location}</span>}
|
|
<span className={styles.weight}>
|
|
{formatWeight(node.total ?? 0, profile.unit)}
|
|
{(node.selfWeight ?? 0) > 0 && ` · self ${formatWeight(node.selfWeight ?? 0, profile.unit)}`}
|
|
</span>
|
|
</span>
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
type MethodSort = "self" | "total" | "name";
|
|
const MAX_METHODS = 2000;
|
|
|
|
function MethodList({ profile, query }: { profile: Profile; query: string }) {
|
|
const [sort, setSort] = useState<MethodSort>("self");
|
|
const total = profile.total ?? 0;
|
|
const rows = useMemo(() => {
|
|
const matches = matchingFrames(profile, query);
|
|
const list = profile.methods.filter((m) => !matches || matches.has(m.frame));
|
|
const name = (f: number) => profile.frames[f]!.name;
|
|
if (sort === "name") list.sort((a, b) => name(a.frame).localeCompare(name(b.frame)));
|
|
else if (sort === "total") list.sort((a, b) => (b.total ?? 0) - (a.total ?? 0));
|
|
return list.slice(0, MAX_METHODS);
|
|
}, [profile, query, sort]);
|
|
const header = (key: MethodSort, label: string) => (
|
|
<th aria-sort={sort === key ? (key === "name" ? "ascending" : "descending") : "none"}>
|
|
<button type="button" className={styles.headerCell} onClick={() => setSort(key)}>
|
|
{label}
|
|
</button>
|
|
</th>
|
|
);
|
|
return (
|
|
<div className={styles.tableScroll}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
{header("name", "Method")}
|
|
{header("self", "Self")}
|
|
{header("total", "Total")}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((m) => {
|
|
const location = frameLocation(profile, m.frame);
|
|
return (
|
|
<tr key={m.frame} onDoubleClick={() => openFrame(profile, m.frame)}>
|
|
<td className={styles.methodCell}>
|
|
<span className={styles.name}>{profile.frames[m.frame]!.name}</span>
|
|
{location && <span className={styles.muted}>{location}</span>}
|
|
</td>
|
|
<td className={styles.num}>
|
|
{formatWeight(m.selfWeight ?? 0, profile.unit)}{" "}
|
|
<span className={styles.muted}>{share(m.selfWeight ?? 0, total)}</span>
|
|
</td>
|
|
<td className={styles.num}>
|
|
{formatWeight(m.total ?? 0, profile.unit)}{" "}
|
|
<span className={styles.muted}>{share(m.total ?? 0, total)}</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ProfilerView() {
|
|
const snapshot = useProfiler((s) => activeSnapshot(s));
|
|
const tab = useProfiler((s) => s.tab);
|
|
const query = useProfiler((s) => s.query);
|
|
if (!snapshot) {
|
|
return (
|
|
<EmptyState title="No profiler snapshots">
|
|
<span>
|
|
<T>Profile a run configuration to see where it spends its time.</T>
|
|
</span>
|
|
</EmptyState>
|
|
);
|
|
}
|
|
const profile = snapshot.profile;
|
|
return (
|
|
<div className={styles.view}>
|
|
<div className={styles.toolbar}>
|
|
<Tabs
|
|
variant="compact"
|
|
aria-label="Profiler views"
|
|
className={styles.viewTabs}
|
|
activeId={tab}
|
|
onSelect={(id) => useProfiler.setState({ tab: id as ProfilerTab })}
|
|
items={[
|
|
{ id: "flame", label: "Flame Graph" },
|
|
{ id: "tree", label: "Call Tree" },
|
|
{ id: "methods", label: "Method List" },
|
|
]}
|
|
/>
|
|
<span className={styles.muted}>
|
|
{profile.profiler} · {formatWeight(profile.total ?? 0, profile.unit)}
|
|
</span>
|
|
{tab !== "tree" && (
|
|
<Input
|
|
className={styles.search}
|
|
placeholder="Search frames"
|
|
aria-label="Search frames"
|
|
value={query}
|
|
onChange={(e) => useProfiler.setState({ query: e.target.value })}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={styles.body}>
|
|
{tab === "flame" && <FlameGraph key={snapshot.id} profile={profile} query={query} />}
|
|
{tab === "tree" && <CallTree key={snapshot.id} profile={profile} />}
|
|
{tab === "methods" && <MethodList profile={profile} query={query} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|