/** 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, T } 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((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 = { 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 ( <> toggle("error")} /> toggle("warning")} /> 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 ( setTab(id as Tab)} items={[ { id: "file", label: current ? ( <> File {basename(current)} ) : ( "File" ), text: "File", trailing: fileCount && fileCount.error + fileCount.warning > 0 ? ( {fileCount.error + fileCount.warning} ) : undefined, }, { id: "project", label: "Project Errors", trailing: projectErrors ? {projectErrors} : 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>(() => 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 ( 0 && styles.fileError, state.selected && styles.selectedText)} > {basename(n.path)} {relativeDir(root, n.path)} {parts.join(", ")} ); } const d = n.d; return ( {d.message.split("\n")[0]} :{d.range.start.line + 1} {d.source && ( {d.source} {d.code ? `(${d.code})` : ""} )} ); }; const empty = tab === "file" && !current ? ( ) : ( ); return (
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} />
); }