дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat: Second pass aligning interactions with IntelliJ IDEA (#23)
* feat: Second pass aligning interactions with IntelliJ IDEA Result tool windows - Next/Previous Occurrence (⌥⌘↓/↑) across Find, Usages, Problems, TODO, Inspection and Hierarchy, with an "N of M" status hint - Find: reuse the unpinned tab, Pin Tab, source preview, group by file or directory, exclude moves to the next result, Rerun - Usages: tabs with pinning, exclude and preview - Problems, TODO, Inspection: no autoscroll by default, severity-first sorting, context menu with quick fixes and copy description - Trees: F4/⌘↓ jump to source; speed search Home/End and arrow handling Find in Files, bookmarks, navigation - Replace mode: Enter replaces the selection, ⇧⌥Enter replaces all, multi-select - Pin Window toggle, highlighted read-only preview, search/replace history (⌥↓), Alt mnemonics, search context filter (comments / string literals) - Mnemonic bookmarks (⌥F3, ⌃⇧digit, ⌃digit), ⌘F3 bookmarks popup, descriptions, ⌘-click gutter toggles a bookmark - Select In popup (⌥F1); Recent Locations double-click and Delete; Search Everywhere remembers the query per tab Code insight and refactoring - Extract/Introduce continue with in-place rename and name suggestions; expression and occurrence choosers with live highlighting - Safe Delete confirms first; numbered Refactor This - Quick Documentation history, in-popup links and a Documentation tool window; Quick Definition switches between targets and shows the whole declaration - Goto chooser lists symbol names and can pin to the Find window - Parameter Info Tab/⇧Tab, intention preview, Reformat dialog and summary, inlay hint ⌘-click and context menu - Live templates: Enter moves to the next field, two-step Esc, ⌘J as a lookup, language surrounders with mnemonics - File Structure popup as a filterable tree; Show Usages filter and preview; call hierarchy jumps to call sites Editor - Paste re-indents each line by default; Move Statement moves whole statements in and out of blocks and re-indents - Type over closing quotes of existing strings; ⌘D/⌘⌫ cover folded regions; ⌥⌫ deletes the preceding word - ⌃G / ⌃⌘G whole-word rules and wrap hint; double-modifier caret cloning - Visual-column block selection, nested Collapse All, recursive folding, fold selection toggle, Go to Matching Brace, gutter drag selects lines, sticky line and error stripe clicks, Insert toggles overwrite mode Run, VCS, workbench - Gutter icons show the last test result; Modify Run Configuration; test tree hides passed tests, context menu and assertion diff - Stack frames resolve by fully qualified class name; context run chooser - Inline Smart Step Into - Git Log multi-select filters, "me" and date range; favourite branches, Fetch and tags in the branch popup; stash Keep index and Unstash options - Terminal tab titles follow the shell; Distraction Free persists; gear menu; sticky balloons move to the notifications tool window after a while * fix(vcs): Label restored local changes as yours after Smart Checkout; name the branch checked out Conflicts from restoring stashed changes show the user's uncommitted changes on the left like other operations, and the checkout notification names the new branch instead of the previous one. * fix: Close the merge editor on Apply; confirm before terminating a running Run tab Like IDEA, applying a merge result closes its tab, and closing a Run tab whose process still runs asks before terminating it (with a Settings toggle and "Do not ask again"). * fix(terminal): Don't start a new session in the background after closing the last tab The hidden Terminal window stays mounted, so it reopened a session as soon as the last tab closed. It now opens the Local tab only once the window is shown again, as IDEA does. * fix(vcs): Start the merge result from a three-way merge; close the editor on Apply The result pane now begins with non-conflicting changes merged and each conflict showing the base text, resolved from the side panes, instead of raw conflict markers. Apply now reports success, so the tab closes. fix(search): Match File Structure filter on word starts * fix: Limit Smart Step Into to the paused line; drop usages hints on callbacks; show full intention titles Adapters that list every remaining call in the function (debugpy) made Smart Step Into fall back to a chooser that offered calls from later lines. Targets are now narrowed to the calls on the paused line, so they are picked in place. Anonymous functions and callbacks no longer get a 'no usages' hint, and intention titles are no longer cut short when there is room for them. * fix: Undo / Redo from the Edit menu and Find Action use the editor's history They ran the webview's native undo, which misses programmatic edits such as templates, completion and refactorings, so nothing happened in the editor.
Цей коміт міститься в:
1 джерело
81e0e8e2d0
коміт
82b6980a59
262 files changed
+13085
-1596
No files matched your search
@@ -278,3 +278,49 @@
|
||||
.previewNew {
|
||||
color: var(--vcs-added);
|
||||
}
|
||||
|
||||
/* Inspection Results: tree on the left, problem details on the right. */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.excluded .message,
|
||||
.excluded .strong {
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--border);
|
||||
font-size: var(--fs-ui);
|
||||
}
|
||||
.detailsEmpty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
.detailsTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.detailsMessage {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: var(--fg);
|
||||
}
|
||||
.detailsActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
@@ -1,13 +1,40 @@
|
||||
/** Inspection Results tool window: severity → inspection → file → problem. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
/**
|
||||
* Inspection Results tool window: inspection → file → problem (or severity
|
||||
* first, on request), with the selected problem's details on the right.
|
||||
*/
|
||||
import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { editorService } from "@/workbench/contributions";
|
||||
import { EmptyState, Icon, IconButton, ProgressBar, Tree, fileIconName, type TreeRowState } from "@/ui";
|
||||
import { editorService, useOccurrenceNavigator } from "@/workbench/contributions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
ProgressBar,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Tree,
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
type TreeHandle,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import {
|
||||
cancelInspection,
|
||||
groupInspection,
|
||||
problemId,
|
||||
rerunInspection,
|
||||
severityKey,
|
||||
toggleExcludedProblems,
|
||||
useInspection,
|
||||
type InspectionKindGroup,
|
||||
type InspectionProblem,
|
||||
type SeverityKey,
|
||||
} from "./inspect";
|
||||
@@ -15,7 +42,7 @@ import styles from "./Analysis.module.css";
|
||||
|
||||
type Node =
|
||||
| { kind: "severity"; key: string; severity: SeverityKey; count: number; children: Node[] }
|
||||
| { kind: "inspection"; key: string; name: string; count: number; children: Node[] }
|
||||
| { kind: "inspection"; key: string; name: string; severity: SeverityKey; count: number; children: Node[] }
|
||||
| { kind: "file"; key: string; path: string; count: number; children: Node[] }
|
||||
| { kind: "problem"; key: string; p: InspectionProblem };
|
||||
|
||||
@@ -25,47 +52,152 @@ const SEVERITY_LABEL: Record<SeverityKey, string> = {
|
||||
info: "Weak warnings and hints",
|
||||
};
|
||||
|
||||
export function buildInspectionNodes(groups: ReturnType<typeof groupInspection>): Node[] {
|
||||
// ---------------------------------------------------------------------------
|
||||
// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults:
|
||||
// grouped by inspection, no autoscroll.
|
||||
|
||||
const OPTIONS_KEY = "openidea.inspection.view.v1";
|
||||
|
||||
interface InspectionOptions {
|
||||
groupBySeverity: boolean;
|
||||
autoscroll: boolean;
|
||||
}
|
||||
|
||||
function loadOptions(): Partial<InspectionOptions> {
|
||||
try {
|
||||
return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial<InspectionOptions>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const useInspectionOptions = create<InspectionOptions>(() => ({
|
||||
groupBySeverity: false,
|
||||
autoscroll: false,
|
||||
...loadOptions(),
|
||||
}));
|
||||
|
||||
useInspectionOptions.subscribe((s) => {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
});
|
||||
|
||||
/** The Inspection Results tool window's ⋮ menu entries. */
|
||||
export function useInspectionHeaderMenu(): MenuItem[] {
|
||||
const o = useInspectionOptions();
|
||||
return [
|
||||
{
|
||||
id: "groupBySeverity",
|
||||
label: "Group by Severity",
|
||||
checked: o.groupBySeverity,
|
||||
run: () => useInspectionOptions.setState({ groupBySeverity: !o.groupBySeverity }),
|
||||
},
|
||||
{
|
||||
id: "autoscroll",
|
||||
label: "Autoscroll to Source",
|
||||
checked: o.autoscroll,
|
||||
run: () => useInspectionOptions.setState({ autoscroll: !o.autoscroll }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const SEVERITY_RANK: Record<SeverityKey, number> = { error: 0, warning: 1, info: 2 };
|
||||
|
||||
/** Inspections across severities, merged by name (the same check may report errors and warnings). */
|
||||
function mergeKinds(
|
||||
groups: ReturnType<typeof groupInspection>,
|
||||
): Array<InspectionKindGroup & { severity: SeverityKey }> {
|
||||
const byKind = new Map<string, InspectionKindGroup & { severity: SeverityKey }>();
|
||||
for (const g of groups) {
|
||||
for (const k of g.kinds) {
|
||||
const prev = byKind.get(k.kind);
|
||||
if (!prev) {
|
||||
byKind.set(k.kind, {
|
||||
...k,
|
||||
files: k.files.map((f) => ({ ...f, problems: [...f.problems] })),
|
||||
severity: g.severity,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
prev.count += k.count;
|
||||
for (const f of k.files) {
|
||||
const same = prev.files.find((x) => x.path === f.path);
|
||||
if (same) same.problems.push(...f.problems);
|
||||
else prev.files.push({ ...f, problems: [...f.problems] });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pos = (p: InspectionProblem) => p.d.range.start.line * 1e6 + p.d.range.start.col;
|
||||
return [...byKind.values()]
|
||||
.map((k) => {
|
||||
k.files.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
||||
k.files.forEach((f) => f.problems.sort((a, b) => pos(a) - pos(b)));
|
||||
return k;
|
||||
})
|
||||
.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.kind.localeCompare(b.kind));
|
||||
}
|
||||
|
||||
function kindNode(k: InspectionKindGroup, severity: SeverityKey, prefix: string): Node {
|
||||
const kindKey = `${prefix}\u0000${k.kind}`;
|
||||
return {
|
||||
kind: "inspection",
|
||||
key: kindKey,
|
||||
name: k.kind,
|
||||
severity,
|
||||
count: k.count,
|
||||
children: k.files.map((f) => {
|
||||
const fileKey = `${kindKey}\u0000${f.path}`;
|
||||
return {
|
||||
kind: "file",
|
||||
key: fileKey,
|
||||
path: f.path,
|
||||
count: f.problems.length,
|
||||
children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node),
|
||||
} as Node;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Result tree: inspection → file → problem, under severity groups with `bySeverity`. */
|
||||
export function buildInspectionNodes(groups: ReturnType<typeof groupInspection>, bySeverity = true): Node[] {
|
||||
if (!bySeverity) return mergeKinds(groups).map((k) => kindNode(k, k.severity, "kind"));
|
||||
return groups.map((s) => ({
|
||||
kind: "severity",
|
||||
key: s.severity,
|
||||
severity: s.severity,
|
||||
count: s.count,
|
||||
children: s.kinds.map((k) => {
|
||||
const kindKey = `${s.severity}\u0000${k.kind}`;
|
||||
return {
|
||||
kind: "inspection",
|
||||
key: kindKey,
|
||||
name: k.kind,
|
||||
count: k.count,
|
||||
children: k.files.map((f) => {
|
||||
const fileKey = `${kindKey}\u0000${f.path}`;
|
||||
return {
|
||||
kind: "file",
|
||||
key: fileKey,
|
||||
path: f.path,
|
||||
count: f.problems.length,
|
||||
children: f.problems.map((p, i) => ({ kind: "problem", key: `${fileKey}\u0000${i}`, p }) as Node),
|
||||
} as Node;
|
||||
}),
|
||||
} as Node;
|
||||
}),
|
||||
children: s.kinds.map((k) => kindNode(k, s.severity, s.severity)),
|
||||
}));
|
||||
}
|
||||
|
||||
function open(p: InspectionProblem, focus = true) {
|
||||
void editorService().openFile(p.path, {
|
||||
function nodeProblems(n: Node): InspectionProblem[] {
|
||||
return n.kind === "problem" ? [n.p] : n.children.flatMap(nodeProblems);
|
||||
}
|
||||
|
||||
function open(p: InspectionProblem, focus = true): Promise<void> {
|
||||
return editorService().openFile(p.path, {
|
||||
selection: { line: p.d.range.start.line, col: p.d.range.start.col },
|
||||
focus,
|
||||
preview: !focus,
|
||||
});
|
||||
}
|
||||
|
||||
/** Jump to the problem and show the editor's quick fixes there. */
|
||||
async function showFixes(p: InspectionProblem) {
|
||||
await open(p, true);
|
||||
void executeAction("code.showIntentions");
|
||||
}
|
||||
|
||||
export function InspectionHeaderActions() {
|
||||
const running = useInspection((s) => s.running);
|
||||
const last = useInspection((s) => s.last);
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" />
|
||||
<IconButton
|
||||
icon="rerun"
|
||||
title="Rerun Inspection"
|
||||
@@ -81,15 +213,18 @@ export function InspectionHeaderActions() {
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
||||
|
||||
export function InspectionView() {
|
||||
const { files, running, total, done, summary, scopeLabel, error } = useInspection();
|
||||
const { files, running, total, done, summary, scopeLabel, error, excluded } = useInspection();
|
||||
const bySeverity = useInspectionOptions((o) => o.groupBySeverity);
|
||||
const root = useProject((s) => s.project?.root ?? "");
|
||||
const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values())), [files]);
|
||||
const roots = useMemo(() => buildInspectionNodes(groupInspection(files.values()), bySeverity), [files, bySeverity]);
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
// Severities and inspections start expanded; files too unless there are many problems.
|
||||
const [lead, setLead] = useState<Node | null>(null);
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
// Groups start expanded; files too unless there are many problems.
|
||||
const expanded = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
const total = roots.reduce((n, r) => n + (r.kind === "severity" ? r.count : 0), 0);
|
||||
const depthOpen = total <= 200 ? 3 : 2;
|
||||
const total = roots.reduce((n, r) => n + (r.kind === "problem" ? 0 : r.count), 0);
|
||||
const depthOpen = (total <= 200 ? 3 : 2) - (bySeverity ? 0 : 1);
|
||||
const visit = (nodes: Node[], depth: number) => {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === "problem") continue;
|
||||
@@ -99,15 +234,40 @@ export function InspectionView() {
|
||||
};
|
||||
visit(roots, 0);
|
||||
return out;
|
||||
}, [roots, collapsed]);
|
||||
}, [roots, collapsed, bySeverity]);
|
||||
|
||||
const getChildren = useCallback((n: Node) => (n.kind === "problem" ? undefined : n.children), []);
|
||||
const isExcluded = (n: Node) => nodeProblems(n).every((p) => excluded.has(problemId(p)));
|
||||
|
||||
useOccurrenceNavigator("inspection", (dir, focus) => {
|
||||
const step = tree.current?.selectOccurrence(dir);
|
||||
const n = step?.node as Node | undefined;
|
||||
if (!step || n?.kind !== "problem") return null;
|
||||
void open(n.p, focus);
|
||||
return step;
|
||||
});
|
||||
|
||||
const exclude = (n: Node | null) => {
|
||||
if (!n) return;
|
||||
const problems = nodeProblems(n);
|
||||
const wasExcluded = isExcluded(n);
|
||||
toggleExcludedProblems(problems);
|
||||
// Excluding moves on to the next problem, as in IDEA.
|
||||
if (!wasExcluded) tree.current?.selectOccurrence(1, { skipSubtree: true });
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.key === "Backspace" || e.key === "Delete") && !e.metaKey && !e.altKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
exclude(lead);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (n: Node, state: TreeRowState) => {
|
||||
const off = isExcluded(n) && styles.excluded;
|
||||
switch (n.kind) {
|
||||
case "severity":
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
<Icon name={n.severity} size={14} className={styles[`sev-${n.severity}`]} />
|
||||
<span className={styles.strong}>{SEVERITY_LABEL[n.severity]}</span>
|
||||
<span className={styles.count}>{plural(n.count, "problem")}</span>
|
||||
@@ -115,14 +275,15 @@ export function InspectionView() {
|
||||
);
|
||||
case "inspection":
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
{!bySeverity && <Icon name={n.severity} size={14} className={styles[`sev-${n.severity}`]} />}
|
||||
<span className={styles.strong}>{n.name}</span>
|
||||
<span className={styles.count}>{n.count}</span>
|
||||
</span>
|
||||
);
|
||||
case "file":
|
||||
return (
|
||||
<span className={styles.row}>
|
||||
<span className={clsx(styles.row, off)}>
|
||||
<Icon name={fileIconName(basename(n.path))} size={16} className={styles.fileIcon} />
|
||||
<span className={state.selected ? styles.strong : undefined}>{basename(n.path)}</span>
|
||||
<span className={styles.muted}>{relativeDir(root, n.path)}</span>
|
||||
@@ -131,7 +292,7 @@ export function InspectionView() {
|
||||
);
|
||||
case "problem":
|
||||
return (
|
||||
<span className={styles.row} title={n.p.d.message}>
|
||||
<span className={clsx(styles.row, off)} title={n.p.d.message}>
|
||||
<span className={styles.message}>{n.p.d.message.split("\n")[0]}</span>
|
||||
<span className={styles.muted}>:{n.p.d.range.start.line + 1}</span>
|
||||
</span>
|
||||
@@ -145,7 +306,8 @@ export function InspectionView() {
|
||||
? `${scopeLabel}: ${plural(summary.problems, "problem")} in ${plural(summary.files, "file")}` +
|
||||
(summary.skipped ? `, ${summary.skipped} without a language server` : "") +
|
||||
(summary.failed ? `, ${summary.failed} failed` : "") +
|
||||
(summary.cancelled ? " (stopped)" : "")
|
||||
(summary.cancelled ? " (stopped)" : "") +
|
||||
(excluded.size ? `, ${excluded.size} excluded` : "")
|
||||
: null;
|
||||
|
||||
return (
|
||||
@@ -162,52 +324,132 @@ export function InspectionView() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<Tree<Node>
|
||||
aria-label="Inspection results"
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) =>
|
||||
n.kind === "severity"
|
||||
? SEVERITY_LABEL[n.severity]
|
||||
: n.kind === "inspection"
|
||||
? n.name
|
||||
: n.kind === "file"
|
||||
? basename(n.path)
|
||||
: n.p.d.message
|
||||
}
|
||||
getChildren={getChildren}
|
||||
isExpandable={(n) => n.kind !== "problem"}
|
||||
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 === "problem") open(n.p);
|
||||
}}
|
||||
onSelectionChange={(_sel, lead) => {
|
||||
if (lead?.kind === "problem") open(lead.p, false);
|
||||
}}
|
||||
activateExpandable={false}
|
||||
multiSelect={false}
|
||||
empty={
|
||||
error ? (
|
||||
<EmptyState icon="error" title={error} />
|
||||
) : running ? (
|
||||
<EmptyState icon="problems" title="Inspecting…" />
|
||||
) : summary ? (
|
||||
<EmptyState icon="check" title="No problems found" />
|
||||
) : (
|
||||
<EmptyState icon="problems" title="Run Code › Inspect Code… to analyse files" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.body} onKeyDown={onKeyDown}>
|
||||
<SplitGroup orientation="horizontal" id="inspection-details-split">
|
||||
<SplitPanel id="results" minSize={160} className={styles.panel}>
|
||||
<Tree<Node>
|
||||
aria-label="Inspection results"
|
||||
handleRef={tree}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) =>
|
||||
n.kind === "severity"
|
||||
? SEVERITY_LABEL[n.severity]
|
||||
: n.kind === "inspection"
|
||||
? n.name
|
||||
: n.kind === "file"
|
||||
? basename(n.path)
|
||||
: n.p.d.message
|
||||
}
|
||||
getChildren={getChildren}
|
||||
isExpandable={(n) => n.kind !== "problem"}
|
||||
isOccurrence={(n) => n.kind === "problem" && !excluded.has(problemId(n.p))}
|
||||
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 === "problem") void open(n.p);
|
||||
}}
|
||||
onSelectionChange={(_sel, node) => {
|
||||
setLead(node);
|
||||
// Autoscroll to Source (off by default, as in IDEA).
|
||||
if (node?.kind === "problem" && useInspectionOptions.getState().autoscroll) void open(node.p, false);
|
||||
}}
|
||||
onContextMenu={(n, _sel, e) => {
|
||||
if (!n) return;
|
||||
showContextMenu(e, [
|
||||
...(n.kind === "problem"
|
||||
? [
|
||||
{ id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(n.p) },
|
||||
{
|
||||
id: "fixes",
|
||||
label: "Show Quick Fixes",
|
||||
icon: "bulb",
|
||||
shortcut: shortcutLabel("code.showIntentions") ?? undefined,
|
||||
run: () => void showFixes(n.p),
|
||||
},
|
||||
{ kind: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{ id: "exclude", label: isExcluded(n) ? "Include" : "Exclude", shortcut: "⌫", run: () => exclude(n) },
|
||||
]);
|
||||
}}
|
||||
activateExpandable={false}
|
||||
multiSelect={false}
|
||||
empty={
|
||||
error ? (
|
||||
<EmptyState icon="error" title={error} />
|
||||
) : running ? (
|
||||
<EmptyState icon="problems" title="Inspecting…" />
|
||||
) : summary ? (
|
||||
<EmptyState icon="check" title="No problems found" />
|
||||
) : (
|
||||
<EmptyState icon="problems" title="Run Code › Inspect Code… to analyse files" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SplitPanel>
|
||||
{roots.length > 0 && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="details" minSize={160} defaultSize="40%" className={styles.panel}>
|
||||
<ProblemDetails node={lead} root={root} />
|
||||
</SplitPanel>
|
||||
</>
|
||||
)}
|
||||
</SplitGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Right pane: the selected problem (or group) in full, with its actions. */
|
||||
function ProblemDetails({ node, root }: { node: Node | null; root: string }) {
|
||||
if (!node || node.kind === "severity" || node.kind === "file") {
|
||||
const count = node ? nodeProblems(node).length : 0;
|
||||
return (
|
||||
<div className={clsx(styles.details, styles.detailsEmpty)}>
|
||||
{node ? plural(count, "problem") : <T>Select a problem to see its details</T>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (node.kind === "inspection") {
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.detailsTitle}>{node.name}</div>
|
||||
<div className={styles.muted}>
|
||||
{SEVERITY_LABEL[node.severity]} · {plural(node.count, "problem")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { p } = node;
|
||||
const sev = severityKey(p.d);
|
||||
return (
|
||||
<div className={styles.details}>
|
||||
<div className={styles.detailsTitle}>
|
||||
<Icon name={sev} size={14} className={styles[`sev-${sev}`]} />
|
||||
<span>{p.d.source ? `${p.d.source}${p.d.code ? ` (${p.d.code})` : ""}` : <T>Language server</T>}</span>
|
||||
</div>
|
||||
<p className={styles.detailsMessage}>{p.d.message}</p>
|
||||
<div className={styles.muted}>
|
||||
{relativeDir(root, p.path) ? `${relativeDir(root, p.path)}/` : ""}
|
||||
{basename(p.path)}:{p.d.range.start.line + 1}:{p.d.range.start.col + 1}
|
||||
</div>
|
||||
<div className={styles.detailsActions}>
|
||||
<Button size="small" icon="bulb" onClick={() => void showFixes(p)}>
|
||||
<T>Show Quick Fixes</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" onClick={() => void open(p)}>
|
||||
<T>Jump to Source</T>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -68,6 +68,23 @@ describe("Inspect Code grouping", () => {
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(nodes[0]!.kind).toBe("severity");
|
||||
});
|
||||
|
||||
it("groups by inspection by default, worst severity first, merging severities", () => {
|
||||
const withWarning = [...files, { path: "/p/d.ts", relPath: "d.ts", diagnostics: [diag(5, "warning", "ts", "2304")] }];
|
||||
const nodes = buildInspectionNodes(groupInspection(withWarning), false);
|
||||
expect(nodes.map((n) => (n.kind === "inspection" ? [n.name, n.severity, n.count] : n.kind))).toEqual([
|
||||
["ts(2304)", "error", 4],
|
||||
["ts(7006)", "error", 1],
|
||||
["eslint(no-unused-vars)", "warning", 1],
|
||||
["Language server", "info", 1],
|
||||
]);
|
||||
const ts2304 = nodes[0]!;
|
||||
expect(ts2304.kind !== "problem" && ts2304.children.map((f) => (f.kind === "file" ? f.path : ""))).toEqual([
|
||||
"/p/b.ts",
|
||||
"/p/c.ts",
|
||||
"/p/d.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Structural replace", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AnalysisDialogHost } from "./dialogs";
|
||||
import { resetDuplicates } from "./duplicates";
|
||||
import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView";
|
||||
import { resetInspection } from "./inspect";
|
||||
import { InspectionHeaderActions, InspectionView } from "./InspectionView";
|
||||
import { InspectionHeaderActions, InspectionView, useInspectionHeaderMenu } from "./InspectionView";
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
@@ -29,6 +29,7 @@ export function activate(): () => void {
|
||||
order: 36,
|
||||
component: InspectionView,
|
||||
headerActions: InspectionHeaderActions,
|
||||
headerMenu: useInspectionHeaderMenu,
|
||||
}),
|
||||
registerToolWindow({
|
||||
id: "duplicates",
|
||||
|
||||
@@ -99,6 +99,8 @@ interface InspectState {
|
||||
error: string | null;
|
||||
/** Scope of the last run (Rerun). */
|
||||
last: ScopeChoice | null;
|
||||
/** Problems excluded from the results (⌫), by `problemId`. */
|
||||
excluded: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const initialInspection = (): InspectState => ({
|
||||
@@ -110,8 +112,22 @@ const initialInspection = (): InspectState => ({
|
||||
scopeLabel: null,
|
||||
error: null,
|
||||
last: null,
|
||||
excluded: new Set(),
|
||||
});
|
||||
|
||||
/** Stable identity of a problem across regroupings. */
|
||||
export const problemId = (p: InspectionProblem) =>
|
||||
`${p.path}\u0000${p.d.range.start.line}:${p.d.range.start.col}\u0000${kindOf(p.d)}\u0000${p.d.message}`;
|
||||
|
||||
/** Excludes `problems`, or includes them again when all are excluded already. */
|
||||
export function toggleExcludedProblems(problems: readonly InspectionProblem[]) {
|
||||
const ids = problems.map(problemId);
|
||||
const excluded = new Set(useInspection.getState().excluded);
|
||||
if (ids.every((id) => excluded.has(id))) ids.forEach((id) => excluded.delete(id));
|
||||
else ids.forEach((id) => excluded.add(id));
|
||||
useInspection.setState({ excluded });
|
||||
}
|
||||
|
||||
export const useInspection = create<InspectState>(initialInspection);
|
||||
|
||||
let inflight: RequestId | null = null;
|
||||
@@ -146,6 +162,7 @@ export async function runInspection(choice: ScopeChoice): Promise<void> {
|
||||
error: null,
|
||||
scopeLabel: describeScope(choice, scope),
|
||||
last: choice,
|
||||
excluded: new Set(),
|
||||
});
|
||||
toolWindowService().show("inspection");
|
||||
if (!isTauri()) {
|
||||
|
||||
Reference in new issue
Заблокувати користувача