Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+61
Переглянути файл
@@ -0,0 +1,61 @@
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.tabs {
min-width: 0;
border: 0;
background: none;
}
.toolbar {
height: 32px;
flex: none;
display: flex;
align-items: center;
gap: 2px;
padding: 0 6px;
}
.sep {
width: 1px;
height: 16px;
margin: 0 4px;
background: var(--border);
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.body > * {
flex: 1;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.name {
color: var(--fg);
}
.detail {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg-subtle);
}
.muted {
flex: none;
color: var(--fg-subtle);
}
.error {
flex: none;
font-size: var(--fs-small);
color: var(--danger);
}
+149
Переглянути файл
@@ -0,0 +1,149 @@
/** "hierarchy" tool window: call hierarchy (⌃⌥H) and type hierarchy (⌃H). */
import { useCallback, useMemo } from "react";
import { basename, relativePath, useProject } from "@/platform/project";
import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState } from "@/ui";
import { openLocation } from "./ipc";
import {
ROOT_ID,
directionLabel,
loadChildren,
refreshHierarchy,
setDirection,
useHierarchy,
type HierarchyNode,
} from "./hierarchy";
import styles from "./HierarchyView.module.css";
type Node = { kind: "root"; id: string } | { kind: "item"; id: string; node: HierarchyNode };
/** Tool window header: `Callers of foo` content tab. */
export function HierarchyTabs() {
const mode = useHierarchy((s) => s.mode);
const direction = useHierarchy((s) => s.direction);
const root = useHierarchy((s) => s.root);
if (!root) return null;
const label = `${directionLabel(mode, direction)} of ${root.name}`;
return (
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Hierarchies"
items={[{ id: "current", label }]}
activeId="current"
onSelect={() => undefined}
/>
);
}
function HierarchyToolbar() {
const mode = useHierarchy((s) => s.mode);
const direction = useHierarchy((s) => s.direction);
return (
<div className={styles.toolbar} role="toolbar" aria-label="Hierarchy actions">
<IconButton
icon="arrow-up"
size="small"
iconSize={16}
title={`${directionLabel(mode, "incoming")} Hierarchy`}
pressed={direction === "incoming"}
onClick={() => setDirection("incoming")}
/>
<IconButton
icon="arrow-down"
size="small"
iconSize={16}
title={`${directionLabel(mode, "outgoing")} Hierarchy`}
pressed={direction === "outgoing"}
onClick={() => setDirection("outgoing")}
/>
<span className={styles.sep} />
<IconButton icon="refresh" size="small" iconSize={16} title="Refresh" onClick={refreshHierarchy} />
</div>
);
}
export function HierarchyView() {
const { mode, direction, root, children, failed, loading, generation } = useHierarchy();
const projectRoot = useProject((s) => s.project?.root ?? "");
const roots = useMemo<Node[]>(() => (root ? [{ kind: "root", id: ROOT_ID }] : []), [root]);
const getChildren = useCallback(
(n: Node): Node[] | undefined =>
children.get(n.id)?.map((c) => ({ kind: "item", id: c.id, node: c }) as Node),
[children],
);
const isExpandable = useCallback(
(n: Node) => {
const loaded = children.get(n.id);
return loaded === undefined || loaded.length > 0;
},
[children],
);
if (!root)
return (
<EmptyState>Use Call Hierarchy (⌃⌥H) or Type Hierarchy (⌃H) on a symbol to see its hierarchy here</EmptyState>
);
const renderNode = (n: Node, _state: TreeRowState) => {
if (n.kind === "root") {
return (
<span className={styles.row}>
<SymbolBadge kind={mode === "call" ? "function" : "class"} />
<span className={styles.name}>{root.name}</span>
<span className={styles.muted}>
{basename(root.path)}:{root.line + 1}
</span>
{loading && <Spinner size={12} />}
{failed.has(ROOT_ID) && <span className={styles.error}>failed to load</span>}
{!loading && !failed.has(ROOT_ID) && children.get(ROOT_ID)?.length === 0 && (
<span className={styles.muted}>— no {directionLabel(mode, direction).toLowerCase()} found</span>
)}
</span>
);
}
const { item } = n.node;
const calls = item.fromRanges.length;
return (
<span className={styles.row} title={relativePath(projectRoot, item.path)}>
<SymbolBadge kind={badgeKind(item.kind)} />
<span className={styles.name}>{item.name}</span>
{item.detail && <span className={styles.detail}>{item.detail}</span>}
<span className={styles.muted}>
{basename(item.path)}:{item.selectionRange.start.line + 1}
</span>
{calls > 1 && <span className={styles.muted}>({calls} calls)</span>}
{failed.has(n.id) && <span className={styles.error}>failed to load</span>}
</span>
);
};
return (
<div className={styles.view}>
<HierarchyToolbar />
<div className={styles.body}>
<Tree<Node>
key={generation}
aria-label={mode === "call" ? "Call hierarchy" : "Type hierarchy"}
roots={roots}
getKey={(n) => n.id}
getLabel={(n) => (n.kind === "root" ? root.name : n.node.item.name)}
getChildren={getChildren}
isExpandable={isExpandable}
loadChildren={(n) => (n.kind === "item" ? loadChildren(n.node) : undefined)}
defaultExpanded={[ROOT_ID]}
renderNode={renderNode}
activateExpandable
multiSelect={false}
onActivate={(n) => {
if (n.kind === "root") openLocation(root.path, root.line, root.col);
else {
const target = n.node.item.selectionRange.start;
openLocation(n.node.item.path, target.line, target.col);
}
}}
/>
</div>
</div>
);
}
+91
Переглянути файл
@@ -0,0 +1,91 @@
.view {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.body > * {
flex: 1;
min-height: 0;
}
.tabs {
min-width: 0;
border: 0;
background: none;
}
.tabFile {
margin-left: 4px;
color: var(--fg-muted);
}
.tabCount {
color: var(--fg-muted);
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.fileIcon {
flex: none;
color: var(--fg-muted);
}
.fileName {
color: var(--fg-strong);
}
.fileError {
text-decoration: underline wavy var(--danger);
text-decoration-skip-ink: none;
text-underline-offset: 3px;
}
.selectedText {
color: var(--fg-strong);
}
.muted {
flex: none;
color: var(--fg-subtle);
}
.count {
color: var(--fg-subtle);
margin-left: 4px;
}
.source {
flex: none;
margin-left: auto;
padding-right: 8px;
color: var(--fg-faint);
}
.message {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: var(--fg);
}
.deprecated {
text-decoration: line-through;
}
.position {
flex: none;
color: var(--fg-subtle);
}
.sev-error {
flex: none;
color: var(--danger);
}
.sev-warning {
flex: none;
color: var(--warning);
}
.sev-info,
.sev-hint {
flex: none;
color: var(--info);
}
+218
Переглянути файл
@@ -0,0 +1,218 @@
/** Problems tool window: "File" (current editor) and "Project Errors" tabs. */
import { useCallback, useMemo, useState } from "react";
import { create } from "zustand";
import clsx from "clsx";
import type { Diagnostic, Severity } from "@/bindings";
import { useEditorRegistry } from "@/editor/api";
import { basename, relativeDir, useProject } from "@/platform/project";
import { editorService } from "@/workbench/contributions";
import { EmptyState, Icon, IconButton, Tabs, Tree, fileIconName, type TreeRowState } from "@/ui";
import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore";
import styles from "./ProblemsView.module.css";
type Tab = "file" | "project";
interface ViewState {
tab: Tab;
show: Record<"error" | "warning" | "info", boolean>;
setTab(tab: Tab): void;
toggle(sev: "error" | "warning" | "info"): void;
}
export const useProblemsView = create<ViewState>((set) => ({
tab: "file",
show: { error: true, warning: true, info: true },
setTab: (tab) => set({ tab }),
toggle: (sev) => set((s) => ({ show: { ...s.show, [sev]: !s.show[sev] } })),
}));
const group = (s: Severity): "error" | "warning" | "info" => (s === "hint" ? "info" : s);
type Node =
| { kind: "file"; key: string; path: string; items: Diagnostic[] }
| { kind: "diag"; key: string; path: string; d: Diagnostic };
const plural = (n: number, word: string) => (n ? `${n} ${word}${n === 1 ? "" : "s"}` : "");
const SEVERITY_ICON: Record<Severity, string> = { error: "error", warning: "warning", info: "info", hint: "info" };
export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], show: ViewState["show"]): Node[] {
const files: Node[] = [];
for (const path of paths) {
const items = fileDiagnostics(map, path).filter((d) => show[group(d.severity)]);
if (!items.length) continue;
files.push({ kind: "file", key: path, path, items });
}
const rank = (n: Node) => (n.kind === "file" ? (n.items.some((d) => d.severity === "error") ? 0 : 1) : 2);
return files.sort((a, b) => rank(a) - rank(b) || a.path.localeCompare(b.path));
}
function open(path: string, d: Diagnostic, focus = true) {
void editorService().openFile(path, {
selection: { line: d.range.start.line, col: d.range.start.col },
focus,
preview: !focus,
});
}
function useCurrentPath(): string | null {
return useEditorRegistry((s) => s.active?.ctx.path ?? null);
}
export function ProblemsHeaderActions() {
const show = useProblemsView((s) => s.show);
const toggle = useProblemsView((s) => s.toggle);
return (
<>
<IconButton icon="error" title="Show Errors" size="small" pressed={show.error} onClick={() => toggle("error")} />
<IconButton icon="warning" title="Show Warnings" size="small" pressed={show.warning} onClick={() => toggle("warning")} />
<IconButton icon="info" title="Show Infos and Hints" size="small" pressed={show.info} onClick={() => toggle("info")} />
</>
);
}
/** Scope tabs in the tool window header: "File: name 2" / "Project Errors". */
export function ProblemsTabs() {
const map = useProblems((s) => s.map);
const tab = useProblemsView((s) => s.tab);
const setTab = useProblemsView((s) => s.setTab);
const current = useCurrentPath();
const fileCount = current ? countSeverities(fileDiagnostics(map, current)) : null;
const projectErrors = useMemo(() => {
let n = 0;
for (const sources of map.values()) for (const items of sources.values()) n += items.filter((d) => d.severity === "error").length;
return n;
}, [map]);
return (
<Tabs
variant="compact"
aria-label="Problems scope"
className={styles.tabs}
activeId={tab}
onSelect={(id) => setTab(id as Tab)}
items={[
{
id: "file",
label: current ? (
<>
File <span className={styles.tabFile}>{basename(current)}</span>
</>
) : (
"File"
),
text: "File",
trailing:
fileCount && fileCount.error + fileCount.warning > 0 ? (
<span className={styles.tabCount}>{fileCount.error + fileCount.warning}</span>
) : undefined,
},
{
id: "project",
label: "Project Errors",
trailing: projectErrors ? <span className={styles.tabCount}>{projectErrors}</span> : undefined,
},
]}
/>
);
}
export function ProblemsView() {
const map = useProblems((s) => s.map);
const { tab, show } = useProblemsView();
const current = useCurrentPath();
const root = useProject((s) => s.project?.root ?? "");
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const roots = useMemo(() => {
const paths = tab === "file" ? (current ? [current] : []) : [...map.keys()];
return buildProblemNodes(map, paths, show);
}, [map, tab, current, show]);
const expanded = useMemo(() => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)), [roots, collapsed]);
const getChildren = useCallback(
(n: Node): Node[] | undefined =>
n.kind === "file"
? n.items.map((d, i) => ({ kind: "diag", key: `${n.path}\u0000${i}`, path: n.path, d }) as Node)
: undefined,
[],
);
const renderNode = (n: Node, state: TreeRowState) => {
if (n.kind === "file") {
const c = countSeverities(n.items);
const parts = [
plural(c.error, "error"),
plural(c.warning, "warning"),
plural(c.info + c.hint, "info"),
].filter(Boolean);
return (
<span className={styles.row}>
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
<span className={clsx(styles.fileName, c.error > 0 && styles.fileError, state.selected && styles.selectedText)}>
{basename(n.path)}
</span>
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
<span className={styles.count}>{parts.join(", ")}</span>
</span>
);
}
const d = n.d;
return (
<span className={styles.row} title={d.message}>
<Icon name={SEVERITY_ICON[d.severity]} size={14} className={styles[`sev-${d.severity}`]} />
<span className={clsx(styles.message, d.deprecated && styles.deprecated)}>{d.message.split("\n")[0]}</span>
<span className={styles.position}>:{d.range.start.line + 1}</span>
{d.source && (
<span className={styles.source}>
{d.source}
{d.code ? `(${d.code})` : ""}
</span>
)}
</span>
);
};
const empty =
tab === "file" && !current ? (
<EmptyState icon="problems" title="No file is open" />
) : (
<EmptyState icon="check" title={tab === "file" ? "No problems in this file" : "No problems found"} />
);
return (
<div className={styles.view}>
<div className={styles.body}>
<Tree<Node>
aria-label="Problems"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) => (n.kind === "file" ? basename(n.path) : n.d.message)}
getChildren={getChildren}
isExpandable={(n) => n.kind === "file"}
expanded={expanded}
onExpandedChange={(_next, node, isOpen) =>
setCollapsed((prev) => {
const s = new Set(prev);
if (isOpen) s.delete(node.key);
else s.add(node.key);
return s;
})
}
renderNode={renderNode}
onActivate={(n) => {
if (n.kind === "diag") open(n.path, n.d);
else if (n.items[0]) open(n.path, n.items[0]);
}}
onSelectionChange={(_sel, lead) => {
// IDEA's autoscroll-to-source: selecting a problem shows it without leaving the list.
if (lead?.kind === "diag") open(lead.path, lead.d, false);
}}
activateExpandable={false}
multiSelect={false}
empty={empty}
/>
</div>
</div>
);
}
+85
Переглянути файл
@@ -0,0 +1,85 @@
.dot {
width: 6px;
height: 6px;
flex: none;
border-radius: 50%;
background: var(--fg-faint);
margin-right: 6px;
display: inline-block;
}
.ok {
background: var(--success);
}
.busy {
background: var(--accent);
animation: pulse 1.2s var(--ease) infinite alternate;
}
.error {
background: var(--danger);
}
.idle {
background: var(--fg-faint);
}
@keyframes pulse {
to {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.busy {
animation: none;
}
}
.panel {
width: 340px;
padding: 6px 0;
font-family: var(--font-ui);
font-size: var(--fs-ui);
}
.header {
padding: 2px 12px 6px;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.server {
padding: 4px 12px;
}
.server + .server {
border-top: 1px solid var(--border-popup);
}
.line {
display: flex;
align-items: center;
gap: 2px;
min-height: 24px;
}
.name {
color: var(--fg-emphasis);
margin-right: 8px;
}
.status {
color: var(--fg-subtle);
font-size: var(--fs-small);
}
.spacer {
flex: 1;
}
.meta {
padding-left: 12px;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.message {
margin-top: 4px;
padding-left: 12px;
font-size: var(--fs-small);
color: var(--fg-muted);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.install {
font-family: var(--font-mono);
color: var(--fg);
-webkit-user-select: text;
user-select: text;
}
+76
Переглянути файл
@@ -0,0 +1,76 @@
/** Status bar widget listing language servers, with a popup to restart them. */
import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { BarButton, IconButton, Popup } from "@/ui";
import { relativePath, useProject } from "@/platform/project";
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
import styles from "./ServerStatus.module.css";
export function ServerStatus() {
const map = useServers((s) => s.servers);
const servers = useMemo(() => [...map.values()].sort((a, b) => a.name.localeCompare(b.name)), [map]);
const root = useProject((s) => s.project?.root ?? "");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
const summary = serversSummary(servers);
if (!summary) return null;
return (
<>
<BarButton
ref={ref}
aria-expanded={open}
aria-label={`Language servers: ${summary.text}`}
tooltip="Language servers"
onClick={() => setOpen((v) => !v)}
>
<span className={clsx(styles.dot, styles[summary.tone])} aria-hidden />
{summary.text}
</BarButton>
<Popup
open={open}
anchor={ref}
placement="top-end"
offset={6}
onClose={() => setOpen(false)}
aria-label="Language servers"
>
<div className={styles.panel}>
<div className={styles.header}>Language Servers</div>
{servers.map((s) => (
<div key={s.id} className={styles.server}>
<div className={styles.line}>
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
<span className={styles.name}>{s.name}</span>
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
<span className={styles.spacer} />
<IconButton
icon="refresh"
size="small"
title={`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
</div>
{(s.languages.length > 0 || s.root) && (
<div className={styles.meta}>
{s.languages.join(", ")}
{s.root && s.root !== root ? ` · ${relativePath(root, s.root) || s.root}` : ""}
</div>
)}
{s.message && (
<div className={clsx(styles.message, s.status === "missing" && styles.install)}>{s.message}</div>
)}
</div>
))}
</div>
</Popup>
</>
);
}
function toneOf(status: string): "ok" | "busy" | "error" | "idle" {
if (status === "ready") return "ok";
if (status === "starting" || status === "busy") return "busy";
if (status === "crashed" || status === "missing") return "error";
return "idle";
}
+79
Переглянути файл
@@ -0,0 +1,79 @@
.popup {
width: min(680px, 90vw);
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
height: 26px;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 0 10px;
border-bottom: 1px solid var(--border-popup);
font-size: var(--fs-small);
color: var(--fg-muted);
}
.title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fg-emphasis);
}
.count {
color: var(--fg-subtle);
}
.link {
flex: none;
border: none;
background: none;
padding: 0;
font: inherit;
color: var(--link);
cursor: pointer;
}
.link:hover {
color: var(--link-hover);
text-decoration: underline;
}
.body {
display: flex;
flex-direction: column;
min-height: 24px;
padding: 2px 0;
box-sizing: content-box;
}
.body > * {
flex: 1;
}
.row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
}
.icon {
flex: none;
color: var(--fg-muted);
}
.spacer {
flex: 1;
}
.where {
flex: none;
font-size: var(--fs-small);
color: var(--fg-subtle);
}
.note {
flex: none;
padding: 3px 10px;
border-bottom: 1px solid var(--border-popup);
font-size: var(--fs-small);
font-style: italic;
color: var(--fg-subtle);
}
+70
Переглянути файл
@@ -0,0 +1,70 @@
/** ⌥⌘F7 Show Usages: popup list at the caret. */
import { useMemo, useState } from "react";
import { basename } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { Icon, List, Popup, fileIconName } from "@/ui";
import { openLocation } from "./ipc";
import { usageCount, useUsagesPopup, type Usage } from "./usages";
import { UsagePreview } from "./UsagesView";
import styles from "./UsagesPopup.module.css";
const close = () => useUsagesPopup.setState({ open: false });
export function UsagesPopup() {
const open = useUsagesPopup((s) => s.open);
return open ? <UsagesPopupInner /> : null;
}
function UsagesPopupInner() {
const { anchor, symbol, groups, note } = useUsagesPopup();
const items = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
const [active, setActive] = useState(0);
const total = usageCount(groups);
const pick = (u: Usage) => {
close();
openLocation(u.path, u.line, u.col);
};
const keys = shortcutLabel("find.usages");
return (
<Popup open anchor={anchor} onClose={close} aria-label="Usages" className={styles.popup}>
<div className={styles.header}>
<span className={styles.title}>
Usages{symbol ? ` of ${symbol}` : ""} <span className={styles.count}>{total} found</span>
</span>
<button
type="button"
className={styles.link}
onClick={() => {
close();
void executeAction("find.usages");
}}
>
Open in Find Window{keys ? ` ${keys}` : ""}
</button>
</div>
{note && <div className={styles.note}>{note}</div>}
<div className={styles.body} style={{ height: Math.min(14, items.length) * 24 }}>
<List
items={items}
getKey={(u) => `${u.path}:${u.line}:${u.col}`}
activeIndex={Math.min(active, items.length - 1)}
onActiveChange={setActive}
hoverSelects
aria-label="Usages"
onActivate={pick}
renderItem={(u) => (
<div className={styles.row}>
<Icon name={fileIconName(basename(u.path))} size={14} className={styles.icon} />
<UsagePreview usage={u} />
<span className={styles.spacer} />
<span className={styles.where}>
{basename(u.path)}:{u.line + 1}
</span>
</div>
)}
/>
</div>
</Popup>
);
}
+104
Переглянути файл
@@ -0,0 +1,104 @@
.view {
height: 100%;
display: flex;
min-height: 0;
}
.tabs {
min-width: 0;
border: 0;
background: none;
}
.toolbar {
flex: none;
width: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 4px 0;
border-right: 1px solid var(--border);
}
.toolbarSep {
width: 16px;
height: 1px;
margin: 3px 0;
background: var(--border);
}
.summary {
height: 26px;
flex: none;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
font-size: var(--fs-ui);
color: var(--fg-subtle);
white-space: nowrap;
}
.groupLabel {
color: var(--fg);
}
.error {
color: var(--danger);
}
.body {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.body > * {
flex: 1;
min-height: 0;
}
.row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
white-space: nowrap;
}
.icon {
flex: none;
}
.file {
color: var(--fg);
}
.muted,
.count {
flex: none;
color: var(--fg-subtle);
}
.line {
flex: none;
min-width: 3ch;
text-align: right;
font-family: var(--font-mono);
font-size: var(--fs-ui);
color: var(--fg-line-number);
}
.preview {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-mono);
font-size: var(--fs-ui);
color: var(--fg);
}
.match {
background: var(--bg-search-match);
color: var(--fg-search-match, inherit);
border-radius: 2px;
}
.note {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--fs-small);
font-style: italic;
color: var(--fg-subtle);
}
+163
Переглянути файл
@@ -0,0 +1,163 @@
/** "usages" tool window: Find Usages results grouped by file with line previews. */
import { useCallback, useMemo, useState } from "react";
import { basename, relativeDir, useProject } from "@/platform/project";
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState } from "@/ui";
import { openLocation } from "./ipc";
import { trimPreview, usageCount, useUsages, type Usage, type UsageGroup } from "./usages";
import styles from "./UsagesView.module.css";
type Node =
| { kind: "summary"; key: string }
| { kind: "file"; key: string; group: UsageGroup }
| { kind: "usage"; key: string; usage: Usage };
export function UsagePreview({ usage }: { usage: Usage }) {
const { text, from, to } = trimPreview(usage);
return (
<span className={styles.preview}>
{text.slice(0, from)}
<mark className={styles.match}>{text.slice(from, to)}</mark>
{text.slice(to)}
</span>
);
}
/** Tool window header: the current search as a content tab (`Usages of 'foo'`). */
export function UsagesTabs() {
const search = useUsages((s) => s.search);
if (!search) return null;
const label = search.symbol ? `Usages of '${search.symbol}'` : "Usages";
return (
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Usage searches"
items={[{ id: String(search.id), label }]}
activeId={String(search.id)}
onSelect={() => undefined}
/>
);
}
const SUMMARY_KEY = "\u0000summary";
export function UsagesView() {
const search = useUsages((s) => s.search);
const root = useProject((s) => s.project?.root ?? "");
const [collapsed, setCollapsed] = useState<{ id: number | null; keys: ReadonlySet<string> }>({
id: null,
keys: new Set(),
});
const files = useMemo<Node[]>(
() => (search?.groups ?? []).map((g) => ({ kind: "file", key: g.path, group: g })),
[search?.groups],
);
const roots = useMemo<Node[]>(() => (files.length ? [{ kind: "summary", key: SUMMARY_KEY }] : []), [files.length]);
const searchId = search?.id ?? null;
const expanded = useMemo(() => {
const hidden = collapsed.id === searchId ? collapsed.keys : new Set<string>();
return new Set([SUMMARY_KEY, ...files.map((r) => r.key)].filter((k) => !hidden.has(k)));
}, [files, collapsed, searchId]);
const getChildren = useCallback(
(n: Node): Node[] | undefined =>
n.kind === "summary"
? files
: n.kind === "file"
? n.group.usages.map((u) => ({ kind: "usage", key: `${u.path}:${u.line}:${u.col}`, usage: u }) as Node)
: undefined,
[files],
);
if (!search) return <EmptyState>Use Find Usages (⌥F7) on a symbol to see its usages here</EmptyState>;
const total = usageCount(search.groups);
const setAll = (collapse: boolean) =>
setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() });
const renderNode = (n: Node, _state: TreeRowState) => {
if (n.kind === "summary")
return (
<span className={styles.row}>
<span className={styles.groupLabel}>Found usages</span>
<span className={styles.count}>
{total} {total === 1 ? "result" : "results"}
</span>
{search.note && <span className={styles.note}>{search.note}</span>}
</span>
);
if (n.kind === "file") {
const path = n.group.path;
return (
<span className={styles.row}>
<Icon name={fileIconName(basename(path))} size={16} className={styles.icon} />
<span className={styles.file}>{basename(path)}</span>
<span className={styles.muted}>{relativeDir(root, path)}</span>
<span className={styles.count}>
{n.group.usages.length} {n.group.usages.length === 1 ? "usage" : "usages"}
</span>
</span>
);
}
return (
<span className={styles.row}>
<span className={styles.line}>{n.usage.line + 1}</span>
<UsagePreview usage={n.usage} />
</span>
);
};
return (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label="Usages actions">
<IconButton
icon="rerun"
title="Rerun Find Usages"
size="small"
iconSize={16}
disabled={!search.rerun}
onClick={() => search.rerun?.()}
/>
<span className={styles.toolbarSep} />
<IconButton icon="expand-all" title="Expand All" size="small" iconSize={16} onClick={() => setAll(false)} />
<IconButton icon="collapse-all" title="Collapse All" size="small" iconSize={16} onClick={() => setAll(true)} />
</div>
<div className={styles.body}>
{(search.loading || search.error) && (
<div className={styles.summary} role="status" aria-live="polite">
{search.loading ? (
<>
<Spinner size={12} /> Searching for usages{search.symbol ? ` of '${search.symbol}'` : ""}…
</>
) : (
<span className={styles.error}>{search.error}</span>
)}
</div>
)}
<Tree<Node>
key={search.id}
aria-label="Usages"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) =>
n.kind === "summary" ? "Found usages" : n.kind === "file" ? basename(n.group.path) : n.usage.text.trim()
}
getChildren={getChildren}
isExpandable={(n) => n.kind !== "usage"}
expanded={expanded}
onExpandedChange={(next) =>
setCollapsed({
id: search.id,
keys: new Set([SUMMARY_KEY, ...files.map((f) => f.key)].filter((k) => !next.has(k))),
})
}
renderNode={renderNode}
multiSelect={false}
onActivate={(n) => {
const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined;
if (u) openLocation(u.path, u.line, u.col);
}}
empty={!search.loading && !search.error ? <EmptyState>No usages found</EmptyState> : undefined}
/>
</div>
</div>
);
}
+47
Переглянути файл
@@ -0,0 +1,47 @@
/** Project files that mention a word (Find in Files engine, whole-word, case-sensitive). */
import { Channel } from "@tauri-apps/api/core";
import { IpcError, call, commands, type RequestId, type SearchEvent } from "@/platform/ipc";
export interface CandidateFiles {
paths: string[];
/** The search stopped at its match limit: later files were not looked at. */
limitHit: boolean;
}
export function filesMentioning(request: RequestId, word: string): Promise<CandidateFiles> {
return new Promise((resolve, reject) => {
const paths: string[] = [];
let settled = false;
const finish = (limitHit: boolean, cancelled = false) => {
if (settled) return;
settled = true;
if (cancelled) reject(new IpcError({ kind: "cancelled", message: "cancelled" }));
else resolve({ paths, limitHit });
};
const channel = new Channel<SearchEvent>();
channel.onmessage = (ev) => {
if (settled) return;
if (ev.type === "file") paths.push(ev.file.path);
else finish(ev.summary.limitHit, ev.summary.cancelled);
};
const query = {
pattern: word,
isRegex: false,
caseSensitive: true,
wholeWord: true,
include: [],
exclude: [],
scope: { kind: "project" as const },
maxMatches: 0,
};
call(commands.searchStart(request, query, channel)).then(
// `done` normally arrives on the channel; don't hang if it never does.
() => setTimeout(() => finish(false), 2000),
(e) => {
if (settled) return;
settled = true;
reject(e);
},
);
});
}
+37
Переглянути файл
@@ -0,0 +1,37 @@
/** "problems" file decorations: files with errors (and their folders) get IDEA's red underline. */
import { setFileDecorations, type FileDecoration } from "@/workbench/contributions";
import { countSeverities, fileDiagnostics, useProblems, type ProblemsMap } from "./problemsStore";
export function problemDecorations(map: ProblemsMap): Map<string, FileDecoration> {
const out = new Map<string, FileDecoration>();
for (const path of map.keys()) {
const c = countSeverities(fileDiagnostics(map, path));
if (!c.error) continue;
out.set(path, {
error: true,
propagate: true,
tooltip: `${c.error} ${c.error === 1 ? "error" : "errors"}${c.warning ? `, ${c.warning} ${c.warning === 1 ? "warning" : "warnings"}` : ""}`,
});
}
return out;
}
export function installProblemDecorations(): () => void {
let scheduled = false;
const push = () => {
if (scheduled) return;
scheduled = true;
// Diagnostics often arrive in bursts (one event per file); coalesce.
setTimeout(() => {
scheduled = false;
setFileDecorations("problems", problemDecorations(useProblems.getState().map));
}, 50);
};
const unsub = useProblems.subscribe((s, prev) => {
if (s.map !== prev.map) push();
});
return () => {
unsub();
setFileDecorations("problems", new Map());
};
}
+139
Переглянути файл
@@ -0,0 +1,139 @@
/** Call / type hierarchy state, with lazily expanded levels. */
import { create } from "zustand";
import type { HierarchyDirection, HierarchyItem } from "@/bindings";
import { commands } from "@/platform/ipc";
import { toolWindowService } from "@/workbench/contributions";
import { basename } from "@/platform/project";
import { caretContext, hint, lspRequest, reportError, withDocument } from "./ipc";
export type HierarchyMode = "call" | "type";
export interface HierarchyNode {
id: string;
item: HierarchyItem;
}
export interface HierarchyRoot {
name: string;
path: string;
line: number;
col: number;
}
interface HierarchyState {
mode: HierarchyMode;
direction: HierarchyDirection;
root: HierarchyRoot | null;
/** Parent id ("root" for the top level) → loaded children. */
children: ReadonlyMap<string, HierarchyNode[]>;
/** Parent ids whose children failed to load. */
failed: ReadonlySet<string>;
loading: boolean;
/** Bumped per (re)query so stale responses are dropped and the tree resets. */
generation: number;
}
export const ROOT_ID = "root";
export const useHierarchy = create<HierarchyState>(() => ({
mode: "call",
direction: "incoming",
root: null,
children: new Map(),
failed: new Set(),
loading: false,
generation: 0,
}));
export function directionLabel(mode: HierarchyMode, direction: HierarchyDirection): string {
if (mode === "call") return direction === "incoming" ? "Callers" : "Callees";
return direction === "incoming" ? "Supertypes" : "Subtypes";
}
function request(mode: HierarchyMode, doc: number, line: number, col: number, direction: HierarchyDirection) {
return lspRequest(() =>
mode === "call"
? commands.lspCallHierarchy(doc, { line, col }, direction)
: commands.lspTypeHierarchy(doc, { line, col }, direction),
);
}
export function childId(parent: string, item: HierarchyItem): string {
const p = item.selectionRange.start;
return `${parent}/${item.path}:${p.line}:${p.col}:${item.name}`;
}
function setChildren(generation: number, parent: string, items: HierarchyItem[]) {
const s = useHierarchy.getState();
if (s.generation !== generation) return;
const seen = new Set<string>();
const nodes: HierarchyNode[] = [];
for (const item of items) {
const id = childId(parent, item);
if (seen.has(id)) continue;
seen.add(id);
nodes.push({ id, item });
}
useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) });
}
async function loadRoot() {
const s = useHierarchy.getState();
const root = s.root;
if (!root) return;
const generation = s.generation + 1;
useHierarchy.setState({ generation, children: new Map(), failed: new Set(), loading: true });
try {
const items = await withDocument(root.path, (doc) => request(s.mode, doc, root.line, root.col, s.direction));
setChildren(generation, ROOT_ID, items);
} catch (e) {
if (useHierarchy.getState().generation === generation)
useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) }));
reportError(e);
} finally {
if (useHierarchy.getState().generation === generation) useHierarchy.setState({ loading: false });
}
}
export async function loadChildren(node: HierarchyNode) {
const { mode, direction, generation } = useHierarchy.getState();
const at = node.item.selectionRange.start;
try {
const items = await withDocument(node.item.path, (doc) => request(mode, doc, at.line, at.col, direction));
setChildren(generation, node.id, items);
} catch (e) {
if (useHierarchy.getState().generation !== generation) return;
useHierarchy.setState((st) => ({
failed: new Set(st.failed).add(node.id),
children: new Map(st.children).set(node.id, []),
}));
reportError(e);
}
}
export async function showHierarchy(mode: HierarchyMode) {
const c = caretContext();
if (!c) {
hint("Open a file to show the hierarchy");
return;
}
const s = useHierarchy.getState();
const direction = s.mode === mode ? s.direction : mode === "call" ? "incoming" : "outgoing";
useHierarchy.setState({
mode,
direction,
root: { name: c.word ?? basename(c.path), path: c.path, line: c.line, col: c.col },
});
toolWindowService().show("hierarchy");
await loadRoot();
}
export function setDirection(direction: HierarchyDirection) {
if (useHierarchy.getState().direction === direction) return;
useHierarchy.setState({ direction });
void loadRoot();
}
export function refreshHierarchy() {
void loadRoot();
}
+119
Переглянути файл
@@ -0,0 +1,119 @@
/**
* Code insight: Problems tool window, Find / Show Usages, call and
* type hierarchies, and language server status. Editor-side LSP features live
* in `editor/lsp`; the two talk only through actions.
*/
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerOverlay, registerStatusItem, registerToolWindow } from "@/workbench/contributions";
import { clearProblems, installProblemsEvents, useProblems } from "./problemsStore";
import { installProblemDecorations } from "./decorations";
import { ProblemsHeaderActions, ProblemsTabs, ProblemsView } from "./ProblemsView";
import { UsagesTabs, UsagesView } from "./UsagesView";
import { UsagesPopup } from "./UsagesPopup";
import { findUsages, showUsageResults, showUsagesPopup } from "./usages";
import { HierarchyTabs, HierarchyView } from "./HierarchyView";
import { showHierarchy } from "./hierarchy";
import { installServerEvents } from "./servers";
import { ServerStatus } from "./ServerStatus";
const EDITOR_WHEN = "textEditorActive && (editorFocus || popupOpen || !toolWindowFocus)";
function useErrorBadge(): number | null {
return useProblems((s) => {
let n = 0;
for (const sources of s.map.values())
for (const items of sources.values()) for (const d of items) if (d.severity === "error") n++;
return n || null;
});
}
export function activate(): () => void {
let root = useProject.getState().project?.root ?? null;
const unsubProject = useProject.subscribe((s) => {
const r = s.project?.root ?? null;
if (r !== root) {
root = r;
clearProblems();
}
});
const disposers = [
unsubProject,
installProblemsEvents(),
installProblemDecorations(),
installServerEvents(),
registerToolWindow({
id: "problems",
title: "Problems",
icon: "toolwindow-problems",
anchor: "bottom",
order: 30,
component: ProblemsView,
headerContent: ProblemsTabs,
headerActions: ProblemsHeaderActions,
useBadge: useErrorBadge,
}),
registerToolWindow({
id: "usages",
title: "Find Usages",
icon: "toolwindow-usages",
anchor: "bottom",
stripe: "onUse",
order: 35,
component: UsagesView,
headerContent: UsagesTabs,
}),
registerToolWindow({
id: "hierarchy",
title: "Hierarchy",
icon: "toolwindow-hierarchy",
anchor: "right",
stripe: "onUse",
order: 40,
component: HierarchyView,
headerContent: HierarchyTabs,
}),
registerStatusItem({
id: "code-insight.servers",
align: "right",
priority: 5,
component: ServerStatus,
title: "Language Services",
}),
registerOverlay({ id: "code-insight.usagesPopup", component: UsagesPopup }),
registerActions([
{ id: "find.usages", title: "Find Usages", category: "Find", when: EDITOR_WHEN, run: () => findUsages() },
{
id: "find.showUsages",
title: "Show Usages",
category: "Find",
when: EDITOR_WHEN,
run: () => showUsagesPopup(),
},
{
id: "find.showUsageResults",
title: "Show Usage Results",
category: "Find",
hidden: true,
description:
"Shows `{ symbol, locations }` (found elsewhere, e.g. by Safe Delete) in the Find Usages tool window.",
run: ({ arg }) => showUsageResults(arg),
},
{
id: "navigate.callHierarchy",
title: "Call Hierarchy",
category: "Navigate",
when: EDITOR_WHEN,
run: () => showHierarchy("call"),
},
{
id: "navigate.typeHierarchy",
title: "Type Hierarchy",
category: "Navigate",
when: EDITOR_WHEN,
run: () => showHierarchy("type"),
},
]),
];
return () => disposers.reverse().forEach((d) => d());
}
+116
Переглянути файл
@@ -0,0 +1,116 @@
/** IPC helpers shared by the code-insight views. */
import type { EditorView } from "@codemirror/view";
import { activeEditor, editorsFor } from "@/editor/api";
import { isVirtualUri, virtualDocument } from "@/platform/virtualDocs";
import { executeAction } from "@/platform/actions";
import { IpcError, call, commands, isCancelled, isTauri } from "@/platform/ipc";
import type { IdeError } from "@/bindings";
import { maybeEditorService, notifications } from "@/workbench/contributions";
type Result<T> = { status: "ok"; data: T } | { status: "error"; error: IdeError };
export async function lspRequest<T>(run: () => Promise<Result<T>>): Promise<T> {
if (!isTauri()) throw new IpcError({ kind: "unavailable", message: "No language server available" });
try {
return await call(run());
} catch (e) {
if (e instanceof IpcError) throw e;
throw new IpcError({ kind: "internal", message: e instanceof Error ? e.message : String(e) });
}
}
export const isUnavailable = (e: unknown) => e instanceof IpcError && e.kind === "unavailable";
/** Small hint at the caret (via editor/lsp), or a notification without an editor. */
export function hint(text: string, tone: "info" | "warning" | "error" = "info") {
if (activeEditor()) void executeAction("editor.showHint", { text, tone });
else notifications().notify({ group: "lsp", level: tone === "error" ? "error" : "info", title: text, key: "code-insight.hint" });
}
export function reportError(e: unknown) {
if (isCancelled(e)) return;
hint(isUnavailable(e) ? "No language server available" : e instanceof Error ? e.message : String(e), isUnavailable(e) ? "info" : "error");
}
export interface CaretContext {
doc: number;
path: string;
line: number;
col: number;
/** Identifier at the caret, if any (for titles). */
word: string | null;
view: EditorView | null;
}
export function caretContext(): CaretContext | null {
const cursor = maybeEditorService()?.activeCursor();
const ed = activeEditor();
if (cursor) {
const view = ed && ed.ctx.doc === cursor.doc ? ed.view : null;
return { ...cursor, word: view ? wordAt(view, cursor.offset) : null, view };
}
if (!ed) return null;
const head = ed.view.state.selection.main.head;
const line = ed.view.state.doc.lineAt(head);
return { doc: ed.ctx.doc, path: ed.ctx.path, line: line.number - 1, col: head - line.from, word: wordAt(ed.view, head), view: ed.view };
}
function wordAt(view: EditorView, offset: number): string | null {
const w = view.state.wordAt(offset);
return w ? view.state.sliceDoc(w.from, w.to) : null;
}
/** Screen point below the caret of the active editor. */
export function caretPoint(): { x: number; y: number } {
const ed = activeEditor();
if (ed) {
const c = ed.view.coordsAtPos(ed.view.state.selection.main.head);
if (c) return { x: c.left, y: c.bottom };
const r = ed.view.dom.getBoundingClientRect();
return { x: r.left + 40, y: r.top + 40 };
}
return { x: window.innerWidth / 2 - 200, y: window.innerHeight / 3 };
}
/** File lines from an open editor, else a read-only backend read. */
export async function readFileLines(path: string): Promise<string[] | null> {
const open = editorsFor(path)[0];
if (open) return open.view.state.doc.toJSON();
if (!isTauri()) return null;
try {
if (isVirtualUri(path)) return (await virtualDocument(path)).text.split(/\r\n?|\n/);
const text = await call(commands.fsReadText(path));
return text.split(/\r\n?|\n/);
} catch {
return null;
}
}
export async function readManyFileLines(paths: readonly string[], concurrency = 4): Promise<Map<string, string[] | null>> {
const out = new Map<string, string[] | null>();
const queue = [...new Set(paths)];
const worker = async () => {
for (let p = queue.shift(); p !== undefined; p = queue.shift()) out.set(p, await readFileLines(p));
};
await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
return out;
}
/**
* Runs `fn` with a backend document id for `path`, holding a reference only
* for the duration of the call (ref-counted, so open editors are unaffected).
*/
export async function withDocument<T>(path: string, fn: (doc: number) => Promise<T>): Promise<T> {
const open = editorsFor(path)[0];
if (open) return fn(open.ctx.doc);
const info = await lspRequest(() => commands.docOpen(path));
try {
return await fn(info.id);
} finally {
void commands.docClose(info.id);
}
}
export function openLocation(path: string, line: number, col: number) {
void maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true });
}
+71
Переглянути файл
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { Diagnostic } from "@/bindings";
import {
applyDiagnosticsEvent,
clearProblems,
countSeverities,
fileDiagnostics,
mergeDiagnostics,
projectCounts,
useProblems,
worstSeverity,
} from "./problemsStore";
import { problemDecorations } from "./decorations";
const d = (line: number, severity: Diagnostic["severity"] = "error", source: string | null = null): Diagnostic => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
severity,
message: `line ${line}`,
source,
code: null,
});
describe("mergeDiagnostics", () => {
it("replaces per (file, source) and keeps other sources", () => {
let map = mergeDiagnostics(new Map(), "/a", "ra", [d(3), d(1)]);
map = mergeDiagnostics(map, "/a", "clippy", [d(2, "warning")]);
expect(fileDiagnostics(map, "/a").map((x) => [x.range.start.line, x.source])).toEqual([
[1, "ra"],
[2, "clippy"],
[3, "ra"],
]);
map = mergeDiagnostics(map, "/a", "ra", [d(5, "info")]);
expect(fileDiagnostics(map, "/a").map((x) => x.range.start.line)).toEqual([2, 5]);
});
it("drops files whose sources are all empty and is a no-op for unknown clears", () => {
const a = mergeDiagnostics(new Map(), "/a", "ra", [d(1)]);
expect(mergeDiagnostics(a, "/a", "other", [])).toBe(a);
expect(mergeDiagnostics(a, "/b", "ra", [])).toBe(a);
const cleared = mergeDiagnostics(a, "/a", "ra", []);
expect(cleared.has("/a")).toBe(false);
});
it("keeps an item's own source", () => {
const map = mergeDiagnostics(new Map(), "/a", "eslint", [d(1, "error", "eslint/no-undef")]);
expect(fileDiagnostics(map, "/a")[0]!.source).toBe("eslint/no-undef");
});
});
describe("counts", () => {
it("counts per severity", () => {
let map = mergeDiagnostics(new Map(), "/a", "ra", [d(1), d(2, "warning")]);
map = mergeDiagnostics(map, "/b", "ra", [d(1, "warning"), d(2, "hint")]);
expect(projectCounts(map)).toEqual({ error: 1, warning: 2, info: 0, hint: 1 });
expect(countSeverities(fileDiagnostics(map, "/b"))).toEqual({ error: 0, warning: 1, info: 0, hint: 1 });
expect(worstSeverity(fileDiagnostics(map, "/b"))).toBe("warning");
expect(worstSeverity([])).toBeNull();
});
});
describe("store + decorations", () => {
beforeEach(() => clearProblems());
it("applies events and decorates only files with errors", () => {
applyDiagnosticsEvent("/p/src/a.rs", "ra", [d(1), d(2, "warning")]);
applyDiagnosticsEvent("/p/src/b.rs", "ra", [d(1, "warning")]);
const deco = problemDecorations(useProblems.getState().map);
expect([...deco.keys()]).toEqual(["/p/src/a.rs"]);
expect(deco.get("/p/src/a.rs")).toEqual({ error: true, propagate: true, tooltip: "1 error, 1 warning" });
});
});
+87
Переглянути файл
@@ -0,0 +1,87 @@
/**
* Project-wide diagnostics for the Problems tool window, status bar and file
* decorations. The backend sends full replacements per (file, producer), so
* the store keeps one list per source and merges them per file.
*/
import { create } from "zustand";
import type { Diagnostic, Severity } from "@/bindings";
import { onIdeEvent } from "@/platform/ipc";
export type FileDiagnostics = ReadonlyMap<string, readonly Diagnostic[]>;
/** path → source → diagnostics. */
export type ProblemsMap = ReadonlyMap<string, FileDiagnostics>;
export interface SeverityCounts {
error: number;
warning: number;
info: number;
hint: number;
}
/** Replaces `source`'s diagnostics for `path`; returns the same map when nothing changed. */
export function mergeDiagnostics(map: ProblemsMap, path: string, source: string, items: readonly Diagnostic[]): ProblemsMap {
const prev = map.get(path);
if (!items.length && !prev?.has(source)) return map;
const next = new Map(map);
const sources = new Map(prev ?? []);
if (items.length) sources.set(source, items.map((d) => (d.source ? d : { ...d, source })));
else sources.delete(source);
if (sources.size) next.set(path, sources);
else next.delete(path);
return next;
}
const SEVERITY_ORDER: Record<Severity, number> = { error: 0, warning: 1, info: 2, hint: 3 };
/** All diagnostics of a file across sources, ordered by position then severity. */
export function fileDiagnostics(map: ProblemsMap, path: string): Diagnostic[] {
const sources = map.get(path);
if (!sources) return [];
return [...sources.values()]
.flat()
.sort(
(a, b) =>
a.range.start.line - b.range.start.line ||
a.range.start.col - b.range.start.col ||
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
);
}
export function countSeverities(diags: Iterable<Diagnostic>): SeverityCounts {
const c: SeverityCounts = { error: 0, warning: 0, info: 0, hint: 0 };
for (const d of diags) c[d.severity]++;
return c;
}
export function projectCounts(map: ProblemsMap): SeverityCounts {
const c: SeverityCounts = { error: 0, warning: 0, info: 0, hint: 0 };
for (const sources of map.values()) for (const items of sources.values()) for (const d of items) c[d.severity]++;
return c;
}
export function worstSeverity(diags: Iterable<Diagnostic>): Severity | null {
let best: Severity | null = null;
for (const d of diags) if (best === null || SEVERITY_ORDER[d.severity] < SEVERITY_ORDER[best]) best = d.severity;
return best;
}
interface ProblemsState {
map: ProblemsMap;
}
export const useProblems = create<ProblemsState>(() => ({ map: new Map() }));
export function applyDiagnosticsEvent(path: string, source: string, items: readonly Diagnostic[]) {
useProblems.setState((s) => {
const map = mergeDiagnostics(s.map, path, source, items);
return map === s.map ? s : { map };
});
}
export function clearProblems() {
useProblems.setState({ map: new Map() });
}
export function installProblemsEvents(): () => void {
return onIdeEvent("diagnostics", (e) => applyDiagnosticsEvent(e.path, e.source, e.items));
}
+131
Переглянути файл
@@ -0,0 +1,131 @@
/** Language server status: `lsp_servers` snapshot + `languageServer` events. */
import { getAction } from "@/platform/actions";
import { create } from "zustand";
import type { ServerId, ServerInfo, ServerStatus } from "@/bindings";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
export interface ServerEntry {
id: ServerId;
name: string;
status: ServerStatus;
message: string | null;
languages: string[];
root: string | null;
}
interface ServersState {
servers: ReadonlyMap<ServerId, ServerEntry>;
}
export const useServers = create<ServersState>(() => ({ servers: new Map() }));
const notifiedMissing = new Set<string>();
function notifyMissing(e: ServerEntry) {
if (notifiedMissing.has(e.name)) return;
notifiedMissing.add(e.name);
// features/tools shows the same notification with an Install button.
if (getAction("tools.install")) return;
notifications().notify({
group: "lsp",
level: "warning",
title: `Language server '${e.name}' is not installed`,
body: e.message ?? "Code insight features are unavailable for these files until it is installed.",
key: `lsp-missing-${e.name}`,
});
}
export function upsertServer(entry: Partial<ServerEntry> & { id: ServerId; name: string; status: ServerStatus }) {
const prev = useServers.getState().servers.get(entry.id);
const next: ServerEntry = {
languages: prev?.languages ?? [],
root: prev?.root ?? null,
message: null,
...prev,
...entry,
};
useServers.setState((s) => ({ servers: new Map(s.servers).set(entry.id, next) }));
if (next.status === "missing") notifyMissing(next);
}
export async function refreshServers() {
if (!useProject.getState().project) {
useServers.setState({ servers: new Map() });
return;
}
if (!isTauri()) return;
try {
const list: ServerInfo[] = await call(commands.lspServers());
const prev = useServers.getState().servers;
const map = new Map<ServerId, ServerEntry>();
for (const s of list)
map.set(s.id, {
id: s.id,
name: s.name,
status: s.status,
languages: s.languages,
root: s.root,
message: prev.get(s.id)?.message ?? null,
});
useServers.setState({ servers: map });
for (const e of map.values()) if (e.status === "missing") notifyMissing(e);
} catch {
// No project / backend not ready; events will fill in.
}
}
export async function restartServer(id: ServerId) {
try {
await call(commands.lspRestart(id));
} catch (e) {
notifications().notify({
group: "lsp",
level: "error",
title: "Failed to restart language server",
body: e instanceof Error ? e.message : String(e),
});
}
}
export function installServerEvents(): () => void {
const off = onIdeEvent("languageServer", (e) => {
const known = useServers.getState().servers.has(e.server);
upsertServer({ id: e.server, name: e.name, status: e.status, message: e.message });
if (!known) void refreshServers();
});
let root = useProject.getState().project?.root ?? null;
void refreshServers();
const unsub = useProject.subscribe((s) => {
const r = s.project?.root ?? null;
if (r === root) return;
root = r;
notifiedMissing.clear();
void refreshServers();
});
return () => {
off();
unsub();
};
}
export const STATUS_LABEL: Record<ServerStatus, string> = {
starting: "Starting…",
ready: "Running",
busy: "Busy",
crashed: "Crashed",
stopped: "Stopped",
missing: "Not installed",
};
/** One-line summary for the status bar. */
export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null {
if (!servers.length) return null;
const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing");
const busy = servers.filter((s) => s.status === "starting" || s.status === "busy");
const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle";
const names = [...new Set(servers.map((s) => s.name))];
const text = names.length === 1 ? names[0]! : `${names[0]} +${names.length - 1}`;
return { text, tone };
}
+83
Переглянути файл
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { IdeError, Locals, Range } from "@/bindings";
type R<T> = Promise<{ status: "ok"; data: T } | { status: "error"; error: IdeError }>;
const ok = <T>(data: T) => Promise.resolve({ status: "ok" as const, data });
const err = (kind: IdeError["kind"]) => Promise.resolve({ status: "error" as const, error: { kind, message: kind } as IdeError });
const cmd = {
lspReferences: vi.fn((): R<unknown> => err("unavailable")),
syntaxSupport: vi.fn(() => ok({ tree: true, locals: true })),
syntaxLocals: vi.fn((): R<Locals> => ok({ status: "noIdentifier" })),
syntaxIdentifiers: vi.fn((): R<unknown> => ok([])),
syntaxIdentifiersInFiles: vi.fn((): R<unknown> => ok([])),
fsReadText: vi.fn(() => ok("a\nb\nc")),
};
const filesMentioning = vi.fn(async () => ({ paths: ["/p/b.ts"], limitHit: false }));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, isTauri: () => true, commands: { ...actual.commands, ...cmd } };
});
vi.mock("./candidates", () => ({ filesMentioning: () => filesMentioning() }));
const { _addEditor } = await import("@/editor/api");
const { fetchUsages } = await import("./usages");
const { LOCAL_NOTE, TEXT_NOTE } = await import("./syntaxUsages");
const r = (line: number, col: number, endCol: number): Range => ({ start: { line, col }, end: { line, col: endCol } });
const view = new EditorView({ state: EditorState.create({ doc: "let x = 1;\nx + y;" }) });
_addEditor({ view, ctx: { doc: 3, path: "/p/a.ts", language: "typescript", uiMode: null, readOnly: false } });
const caret = { doc: 3, path: "/p/a.ts", line: 1, col: 0, word: "x", view };
beforeEach(() => {
for (const f of Object.values(cmd)) f.mockClear();
filesMentioning.mockClear();
});
describe("Find Usages fallback", () => {
it("uses the server when it answers, even with nothing", async () => {
cmd.lspReferences.mockReturnValueOnce(ok([]));
expect(await fetchUsages(caret)).toEqual({ groups: [], note: null });
expect(cmd.syntaxLocals).not.toHaveBeenCalled();
});
it("lists a certain local's references when the server is not ready", async () => {
cmd.syntaxLocals.mockReturnValueOnce(
ok({
status: "local",
symbol: { name: "x", kind: "var", definition: r(0, 4, 5), scope: r(0, 0, 20), references: [r(1, 0, 1)], certain: true, conflicts: [] },
}),
);
const res = await fetchUsages(caret);
expect(res.note).toBe(LOCAL_NOTE);
expect(res.groups.map((g) => [g.path, g.usages.map((u) => [u.line, u.col, u.text])])).toEqual([["/p/a.ts", [[1, 0, "x + y;"]]]]);
expect(filesMentioning).not.toHaveBeenCalled();
});
it("searches the project for identifier occurrences of non-locals", async () => {
cmd.lspReferences.mockReturnValueOnce(err("internal"));
cmd.syntaxLocals.mockReturnValueOnce(ok({ status: "notLocal", name: "y", range: r(1, 4, 5), reason: "unresolved" }));
cmd.syntaxIdentifiersInFiles.mockReturnValueOnce(
ok([
{ path: "/p/b.ts", occurrences: [{ range: r(2, 0, 1), kind: "identifier", qualified: false }] },
{ path: "/p/a.ts", occurrences: [{ range: r(1, 4, 5), kind: "identifier", qualified: false }] },
]),
);
const res = await fetchUsages(caret);
expect(res.note).toBe(TEXT_NOTE);
expect(cmd.syntaxIdentifiersInFiles.mock.calls[0]).toEqual(expect.arrayContaining([["/p/b.ts", "/p/a.ts"], "y"]));
expect(res.groups.map((g) => g.path)).toEqual(["/p/a.ts", "/p/b.ts"]);
});
it("keeps the server error when the language has no grammar", async () => {
cmd.syntaxSupport.mockReturnValueOnce(ok({ tree: false, locals: false }));
const other = new EditorView({ state: EditorState.create({ doc: "x" }) });
_addEditor({ view: other, ctx: { doc: 4, path: "/p/a.txt", language: "plaintext", uiMode: null, readOnly: false } });
await expect(fetchUsages({ doc: 4, path: "/p/a.txt", line: 0, col: 0, word: "x", view: other })).rejects.toMatchObject({
kind: "unavailable",
});
});
});
+60
Переглянути файл
@@ -0,0 +1,60 @@
/**
* Find Usages without a language server (IDEA's dumb mode): a certain local's
* same-scope references, otherwise identifier occurrences of the name across
* the project (comments and strings excluded) — labelled as text occurrences.
*/
import type { Location, Range } from "@/bindings";
import { nextRequestId } from "@/platform/ipc";
import { editorsFor, whenSynced } from "@/editor/api";
import { structure } from "@/editor/structure";
import { filesMentioning } from "./candidates";
import type { CaretContext } from "./ipc";
export const LOCAL_NOTE = "Based on syntax (language server not ready)";
export const TEXT_NOTE = "Text occurrences (language server not ready)";
export interface SyntaxUsages {
locs: Location[];
/** Shown with the results: where they came from and how far to trust them. */
note: string;
}
const IDENT = /^[\p{L}_$][\p{L}\p{N}_$]*$/u;
function inside(range: Range, pos: Range["start"]) {
const after = (a: Range["start"], b: Range["start"]) => a.line - b.line || a.col - b.col;
return after(range.start, pos) <= 0 && after(pos, range.end) <= 0;
}
/** Null when the structure service cannot answer for this file (no grammar, no editor). */
export async function syntaxUsages(c: CaretContext): Promise<SyntaxUsages | null> {
const language = c.view ? editorsFor(c.path).find((e) => e.view === c.view)?.ctx.language : editorsFor(c.path)[0]?.ctx.language;
if (!language) return null;
const support = await structure.support(language);
if (!support.tree) return null;
if (c.view) await whenSynced(c.view);
const pos = { line: c.line, col: c.col };
let name = c.word;
if (support.locals) {
const locals = await structure.locals(c.doc, pos);
if (locals.status === "noIdentifier") return { locs: [], note: LOCAL_NOTE };
if (locals.status === "local") {
const { symbol } = locals;
if (symbol.certain)
return { locs: symbol.references.map((range) => ({ path: c.path, range })), note: LOCAL_NOTE };
// Re-declared in the same scope: every plain occurrence in that scope, as text.
const occ = await structure.identifiers(c.doc, symbol.name);
return {
locs: occ.filter((o) => !o.qualified && inside(symbol.scope, o.range.start)).map((o) => ({ path: c.path, range: o.range })),
note: TEXT_NOTE,
};
}
name = locals.name;
}
if (!name || !IDENT.test(name)) return null;
const { paths, limitHit } = await filesMentioning(nextRequestId(), name);
if (!paths.includes(c.path)) paths.push(c.path);
const files = await structure.identifiersInFiles(paths, name);
const locs = files.flatMap((f) => f.occurrences.map((o) => ({ path: f.path, range: o.range })));
return { locs, note: limitHit ? `${TEXT_NOTE} · search limit reached, results may be incomplete` : TEXT_NOTE };
}
+30
Переглянути файл
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from "vitest";
const show = vi.fn();
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show }),
}));
vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()),
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]),
}));
const { showUsageResults, useUsages } = await import("./usages");
describe("showUsageResults", () => {
it("shows given usages in the Find Usages tool window", async () => {
const loc = { path: "/p/b.rs", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
await showUsageResults({ symbol: "used", locations: [loc] });
expect(show).toHaveBeenCalledWith("usages");
const search = useUsages.getState().search!;
expect(search).toMatchObject({ symbol: "used", loading: false, error: null, rerun: null });
expect(search.groups).toEqual([{ path: "/p/b.rs", usages: [{ path: "/p/b.rs", line: 1, col: 4, endCol: 8, text: " used();" }] }]);
});
it("ignores malformed arguments", async () => {
show.mockClear();
await showUsageResults({ symbol: "x" });
expect(show).not.toHaveBeenCalled();
});
});
+194
Переглянути файл
@@ -0,0 +1,194 @@
/** Find Usages (⌥F7 → "usages" tool window) and Show Usages (⌥⌘F7 → popup). */
import { create } from "zustand";
import type { Location } from "@/bindings";
import { commands, isCancelled } from "@/platform/ipc";
import { toolWindowService } from "@/workbench/contributions";
import {
caretContext,
caretPoint,
hint,
lspRequest,
openLocation,
readManyFileLines,
reportError,
type CaretContext,
} from "./ipc";
import { syntaxUsages } from "./syntaxUsages";
export interface Usage {
path: string;
line: number;
col: number;
/** End column on `line` (clamped to the line for multi-line ranges). */
endCol: number;
/** The usage's line text (empty when the file couldn't be read). */
text: string;
}
export interface UsageGroup {
path: string;
usages: Usage[];
}
/** Groups reference locations by file (sorted by path, then position), attaching line previews. */
export function groupUsages(locs: readonly Location[], files: ReadonlyMap<string, readonly string[] | null>): UsageGroup[] {
const byPath = new Map<string, Usage[]>();
const seen = new Set<string>();
for (const l of locs) {
const key = `${l.path}:${l.range.start.line}:${l.range.start.col}`;
if (seen.has(key)) continue;
seen.add(key);
const text = files.get(l.path)?.[l.range.start.line] ?? "";
const endCol = l.range.end.line === l.range.start.line ? l.range.end.col : Math.max(text.length, l.range.start.col);
let list = byPath.get(l.path);
if (!list) byPath.set(l.path, (list = []));
list.push({ path: l.path, line: l.range.start.line, col: l.range.start.col, endCol, text });
}
return [...byPath.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([path, usages]) => ({ path, usages: usages.sort((a, b) => a.line - b.line || a.col - b.col) }));
}
export function usageCount(groups: readonly UsageGroup[]): number {
return groups.reduce((n, g) => n + g.usages.length, 0);
}
/** Trims leading whitespace of a preview line, shifting the match range accordingly. */
export function trimPreview(u: Usage): { text: string; from: number; to: number } {
const lead = u.text.length - u.text.trimStart().length;
const text = u.text.trim();
const from = Math.max(0, u.col - lead);
const to = Math.max(from, Math.min(text.length, u.endCol - lead));
return { text, from, to };
}
interface UsageSearch {
/** Identifies one search run (UI state such as expansion resets per run). */
id: number;
symbol: string | null;
loading: boolean;
groups: UsageGroup[];
error: string | null;
/** Results came from the syntax fallback: says so (e.g. "Text occurrences (language server not ready)"). */
note: string | null;
/** Re-runs the same query (rerun button). */
rerun: (() => void) | null;
}
let searchSeq = 0;
export const useUsages = create<{ search: UsageSearch | null }>(() => ({ search: null }));
interface UsagesPopupState {
open: boolean;
anchor: { x: number; y: number };
symbol: string | null;
groups: UsageGroup[];
note: string | null;
}
export const useUsagesPopup = create<UsagesPopupState>(() => ({
open: false,
anchor: { x: 0, y: 0 },
symbol: null,
groups: [],
note: null,
}));
interface FetchedUsages {
groups: UsageGroup[];
note: string | null;
}
/**
* The server's references; when it is not ready or fails, the syntax fallback
* (an empty server answer is respected). Without a fallback the server error stands.
*/
export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
let locs: Location[];
let note: string | null = null;
try {
locs = await lspRequest(() => commands.lspReferences(c.doc, { line: c.line, col: c.col }, false));
} catch (e) {
if (isCancelled(e)) throw e;
let fb = null;
try {
fb = await syntaxUsages(c);
} catch (fe) {
if (isCancelled(fe)) throw fe;
console.debug("[syntax] usages fallback", fe);
}
if (!fb) throw e;
locs = fb.locs;
note = fb.note;
}
const files = await readManyFileLines(locs.map((l) => l.path));
return { groups: groupUsages(locs, files), note };
}
export async function findUsages() {
const c = caretContext();
if (!c) {
hint("Open a file to find usages");
return;
}
const run = async () => {
const id = ++searchSeq;
const current = () => useUsages.getState().search?.id === id;
const base = { id, symbol: c.word, rerun: () => void run() };
useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } });
toolWindowService().show("usages");
try {
const { groups, note } = await fetchUsages(c);
if (current()) useUsages.setState({ search: { ...base, loading: false, groups, error: null, note } });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (!current()) return;
useUsages.setState({ search: { ...base, loading: false, groups: [], error: message, note: null } });
reportError(e);
}
};
await run();
}
/**
* Shows already-found usages in the tool window (Safe Delete's "View Usages").
* Ignores malformed arguments.
*/
export async function showUsageResults(arg: unknown) {
const a = (arg ?? {}) as { symbol?: unknown; locations?: unknown };
if (!Array.isArray(a.locations)) return;
const locs = a.locations as Location[];
const symbol = typeof a.symbol === "string" ? a.symbol : null;
const id = ++searchSeq;
const base = { id, symbol, rerun: null };
useUsages.setState({ search: { ...base, loading: true, groups: [], error: null, note: null } });
toolWindowService().show("usages");
const files = await readManyFileLines(locs.map((l) => l.path));
if (useUsages.getState().search?.id !== id) return;
useUsages.setState({ search: { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null } });
}
export async function showUsagesPopup() {
const c = caretContext();
if (!c) {
hint("Open a file to show usages");
return;
}
try {
const { groups, note } = await fetchUsages(c);
const total = usageCount(groups);
if (!total) {
hint(c.word ? `No usages of '${c.word}' found` : "No usages found");
return;
}
if (total === 1) {
const u = groups[0]!.usages[0]!;
openLocation(u.path, u.line, u.col);
return;
}
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
} catch (e) {
reportError(e);
}
}
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { groupUsages, trimPreview, usageCount } from "./usages";
import { serversSummary, type ServerEntry } from "./servers";
import { buildProblemNodes } from "./ProblemsView";
import { mergeDiagnostics } from "./problemsStore";
import { childId, directionLabel } from "./hierarchy";
const loc = (path: string, line: number, col: number, endCol: number) => ({
path,
range: { start: { line, col }, end: { line, col: endCol } },
});
describe("usages", () => {
it("groups by file with previews, sorted and de-duplicated", () => {
const files = new Map<string, string[] | null>([
["/p/b.ts", ["", " const x = foo();"]],
["/p/a.ts", null],
]);
const groups = groupUsages(
[loc("/p/b.ts", 1, 12, 15), loc("/p/a.ts", 4, 0, 3), loc("/p/b.ts", 1, 12, 15), loc("/p/b.ts", 0, 0, 0)],
files,
);
expect(groups.map((g) => [g.path, g.usages.map((u) => u.line)])).toEqual([
["/p/a.ts", [4]],
["/p/b.ts", [0, 1]],
]);
expect(usageCount(groups)).toBe(3);
expect(groups[0]!.usages[0]!.text).toBe("");
expect(trimPreview(groups[1]!.usages[1]!)).toEqual({ text: "const x = foo();", from: 10, to: 13 });
});
});
describe("serversSummary", () => {
const s = (name: string, status: ServerEntry["status"]): ServerEntry => ({
id: Math.random(),
name,
status,
message: null,
languages: [],
root: null,
});
it("summarises names and the worst state", () => {
expect(serversSummary([])).toBeNull();
expect(serversSummary([s("rust-analyzer", "ready")])).toEqual({ text: "rust-analyzer", tone: "ok" });
expect(serversSummary([s("vtsls", "ready"), s("eslint", "starting")])).toEqual({ text: "vtsls +1", tone: "busy" });
expect(serversSummary([s("gopls", "missing")])!.tone).toBe("error");
});
});
describe("buildProblemNodes", () => {
it("lists files with errors first and applies the severity filter", () => {
const diag = (severity: "error" | "warning" | "hint") => ({
range: { start: { line: 0, col: 0 }, end: { line: 0, col: 1 } },
severity,
message: severity,
source: null,
code: null,
});
let map = mergeDiagnostics(new Map(), "/a", "s", [diag("warning")]);
map = mergeDiagnostics(map, "/b", "s", [diag("error"), diag("hint")]);
const all = buildProblemNodes(map, ["/a", "/b"], { error: true, warning: true, info: true });
expect(all.map((n) => n.key)).toEqual(["/b", "/a"]);
const errorsOnly = buildProblemNodes(map, ["/a", "/b"], { error: true, warning: false, info: false });
expect(errorsOnly.map((n) => n.key)).toEqual(["/b"]);
expect(errorsOnly[0]!.kind === "file" && errorsOnly[0]!.items.length).toBe(1);
});
});
describe("hierarchy", () => {
it("labels directions and builds unique child ids", () => {
expect(directionLabel("call", "incoming")).toBe("Callers");
expect(directionLabel("type", "outgoing")).toBe("Subtypes");
const item = {
name: "f",
kind: "function" as const,
detail: null,
path: "/a.rs",
range: { start: { line: 1, col: 0 }, end: { line: 3, col: 1 } },
selectionRange: { start: { line: 1, col: 3 }, end: { line: 1, col: 4 } },
fromRanges: [],
};
expect(childId("root", item)).toBe("root//a.rs:1:3:f");
});
});