дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача