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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-25 09:44:46 +08:00
1 джерело 81e0e8e2d0
коміт 82b6980a59
262 files changed
+13085 -1596

No files matched your search

+71 -7
Переглянути файл
@@ -1,11 +1,25 @@
/** "hierarchy" tool window: call hierarchy (⌃⌥H) and type hierarchy (⌃H). */
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useRef } from "react";
import { create } from "zustand";
import { basename, relativePath, useProject } from "@/platform/project";
import { EmptyState, IconButton, Spinner, SymbolBadge, Tabs, Tree, badgeKind, type TreeRowState, T } from "@/ui";
import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions";
import {
EmptyState,
IconButton,
Spinner,
SymbolBadge,
Tabs,
Tree,
badgeKind,
type TreeHandle,
type TreeRowState,
T,
} from "@/ui";
import { openLocation } from "./ipc";
import {
ROOT_ID,
directionLabel,
hierarchyTarget,
loadChildren,
refreshHierarchy,
setDirection,
@@ -16,6 +30,25 @@ import styles from "./HierarchyView.module.css";
type Node = { kind: "root"; id: string } | { kind: "item"; id: string; node: HierarchyNode };
const OPTIONS_KEY = "openidea.hierarchy.view.v1";
/** Autoscroll to Source: selecting a node shows it in the editor (IDEA: off). Persisted. */
export const useHierarchyOptions = create<{ autoscroll: boolean }>(() => {
try {
return { autoscroll: false, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") };
} catch {
return { autoscroll: false };
}
});
useHierarchyOptions.subscribe((s) => {
try {
globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s));
} catch {
/* storage unavailable */
}
});
/** Tool window header: `Callers of foo` content tab. */
export function HierarchyTabs() {
const mode = useHierarchy((s) => s.mode);
@@ -38,6 +71,7 @@ export function HierarchyTabs() {
function HierarchyToolbar() {
const mode = useHierarchy((s) => s.mode);
const direction = useHierarchy((s) => s.direction);
const autoscroll = useHierarchyOptions((s) => s.autoscroll);
return (
<div className={styles.toolbar} role="toolbar" aria-label="Hierarchy actions">
<IconButton
@@ -58,6 +92,17 @@ function HierarchyToolbar() {
/>
<span className={styles.sep} />
<IconButton icon="refresh" size="small" iconSize={16} title="Refresh" onClick={refreshHierarchy} />
<span className={styles.sep} />
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" iconSize={16} />
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" iconSize={16} />
<IconButton
icon="autoscroll-to-source"
size="small"
iconSize={16}
title="Autoscroll to Source"
pressed={autoscroll}
onClick={() => useHierarchyOptions.setState({ autoscroll: !autoscroll })}
/>
</div>
);
}
@@ -78,6 +123,23 @@ export function HierarchyView() {
},
[children],
);
const tree = useRef<TreeHandle>(null);
const targetOf = (n: Node) => {
if (n.kind === "item") return hierarchyTarget(mode, direction, n.node.item);
return root ? { path: root.path, line: root.line, col: root.col } : null;
};
const show = (n: Node, focus: boolean) => {
const t = targetOf(n);
if (t) void maybeEditorService()?.openFile(t.path, { selection: { line: t.line, col: t.col }, focus });
};
// Every node is an occurrence, as in IDEA's hierarchy views.
useOccurrenceNavigator("hierarchy", (dir, focus) => {
const step = tree.current?.selectOccurrence(dir);
if (!step) return null;
show(step.node as Node, focus);
return step;
});
if (!root)
return (
@@ -141,15 +203,17 @@ export function HierarchyView() {
isExpandable={isExpandable}
loadChildren={(n) => (n.kind === "item" ? loadChildren(n.node) : undefined)}
defaultExpanded={[ROOT_ID]}
handleRef={tree}
isOccurrence={() => true}
onSelectionChange={(_sel, n) => {
if (n && useHierarchyOptions.getState().autoscroll) show(n, false);
}}
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);
}
const target = n.kind === "root" ? root : hierarchyTarget(mode, direction, n.node.item);
void openLocation(target.path, target.line, target.col);
}}
/>
</div>
+10
Переглянути файл
@@ -89,3 +89,13 @@
flex: none;
color: var(--info);
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.panel > * {
flex: 1;
min-height: 0;
}
+222 -40
Переглянути файл
@@ -1,13 +1,40 @@
/** Problems tool window: "File" (current editor) and "Project Errors" tabs. */
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { create } from "zustand";
import clsx from "clsx";
import type { Diagnostic, Severity } from "@/bindings";
import { useEditorRegistry } from "@/editor/api";
import { executeAction } from "@/platform/actions";
import { formatBinding, hasPrimaryModifier, hostBinding, shortcutLabel } from "@/platform/keymap";
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 { editorService, useOccurrenceNavigator } from "@/workbench/contributions";
import {
EmptyState,
Icon,
IconButton,
SourcePreview,
SplitGroup,
SplitHandle,
SplitPanel,
Tabs,
Tree,
fileIconName,
showContextMenu,
type MenuItem,
type TreeHandle,
type TreeRowState,
T,
} from "@/ui";
import { previewText } from "./ipc";
import {
DEFAULT_PROBLEM_ORDER,
countSeverities,
fileDiagnostics,
sortProblems,
useProblems,
type ProblemOrder,
type ProblemsMap,
} from "./problemsStore";
import styles from "./ProblemsView.module.css";
type Tab = "file" | "project";
@@ -26,6 +53,56 @@ export const useProblemsView = create<ViewState>((set) => ({
toggle: (sev) => set((s) => ({ show: { ...s.show, [sev]: !s.show[sev] } })),
}));
// ---------------------------------------------------------------------------
// View options (persisted), in the tool window's ⋮ menu. IDEA's defaults:
// no autoscroll, no preview, sorted by severity.
const OPTIONS_KEY = "openidea.problems.view.v1";
interface ProblemsOptions extends ProblemOrder {
/** Selecting a problem opens it in the editor. */
autoscroll: boolean;
/** Source preview beside the list. */
preview: boolean;
}
function loadOptions(): Partial<ProblemsOptions> {
try {
return JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") as Partial<ProblemsOptions>;
} catch {
return {};
}
}
export const useProblemsOptions = create<ProblemsOptions>(() => ({
autoscroll: false,
preview: false,
...DEFAULT_PROBLEM_ORDER,
...loadOptions(),
}));
useProblemsOptions.subscribe((s) => {
try {
globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s));
} catch {
/* storage unavailable */
}
});
/** The Problems tool window's ⋮ menu entries. */
export function useProblemsHeaderMenu(): MenuItem[] {
const o = useProblemsOptions();
const toggle = (key: keyof ProblemsOptions) => () => useProblemsOptions.setState({ [key]: !o[key] });
return [
{ kind: "header", label: "Sort By" },
{ id: "bySeverity", label: "Severity", checked: o.bySeverity, run: toggle("bySeverity") },
{ id: "byName", label: "Name", checked: o.byName, run: toggle("byName") },
{ kind: "separator" },
{ id: "preview", label: "Show Preview", checked: o.preview, run: toggle("preview") },
{ id: "autoscroll", label: "Autoscroll to Source", checked: o.autoscroll, run: toggle("autoscroll") },
];
}
const group = (s: Severity): "error" | "warning" | "info" => (s === "hint" ? "info" : s);
type Node =
@@ -36,10 +113,18 @@ const plural = (n: number, word: string) => (n ? `${n} ${word}${n === 1 ? "" : "
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[] {
export function buildProblemNodes(
map: ProblemsMap,
paths: readonly string[],
show: ViewState["show"],
order: ProblemOrder = DEFAULT_PROBLEM_ORDER,
): Node[] {
const files: Node[] = [];
for (const path of paths) {
const items = fileDiagnostics(map, path).filter((d) => show[group(d.severity)]);
const items = sortProblems(
fileDiagnostics(map, path).filter((d) => show[group(d.severity)]),
order,
);
if (!items.length) continue;
files.push({ kind: "file", key: path, path, items });
}
@@ -47,14 +132,22 @@ export function buildProblemNodes(map: ProblemsMap, paths: readonly string[], sh
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, {
function open(path: string, d: Diagnostic, focus = true): Promise<void> {
return editorService().openFile(path, {
selection: { line: d.range.start.line, col: d.range.start.col },
focus,
preview: !focus,
});
}
const problemText = (d: Diagnostic) => `${d.message}${d.source ? ` (${d.source}${d.code ? `(${d.code})` : ""})` : ""}`;
/** ⌥⏎ on a problem: jump to it and show its quick fixes (the editor's context actions). */
async function showQuickFixes(path: string, d: Diagnostic) {
await open(path, d, true);
void executeAction("code.showIntentions");
}
function useCurrentPath(): string | null {
return useEditorRegistry((s) => s.active?.ctx.path ?? null);
}
@@ -64,6 +157,8 @@ export function ProblemsHeaderActions() {
const toggle = useProblemsView((s) => s.toggle);
return (
<>
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" />
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" />
<IconButton icon="error" title="Show Errors" size="small" pressed={show.error} onClick={() => toggle("error")} />
<IconButton
icon="warning"
@@ -132,14 +227,47 @@ export function ProblemsTabs() {
export function ProblemsView() {
const map = useProblems((s) => s.map);
const { tab, show } = useProblemsView();
const options = useProblemsOptions();
const current = useCurrentPath();
const root = useProject((s) => s.project?.root ?? "");
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const [lead, setLead] = useState<Node | null>(null);
const tree = useRef<TreeHandle>(null);
const order = useMemo(
() => ({ bySeverity: options.bySeverity, byName: options.byName }),
[options.bySeverity, options.byName],
);
const roots = useMemo(() => {
const paths = tab === "file" ? (current ? [current] : []) : [...map.keys()];
return buildProblemNodes(map, paths, show);
}, [map, tab, current, show]);
return buildProblemNodes(map, paths, show, order);
}, [map, tab, current, show, order]);
useOccurrenceNavigator("problems", (dir, focus) => {
const step = tree.current?.selectOccurrence(dir);
const n = step?.node as Node | undefined;
if (!step || n?.kind !== "diag") return null;
void open(n.path, n.d, focus);
return step;
});
const target = (n: Node | null) =>
n?.kind === "diag" ? { path: n.path, d: n.d } : n?.items[0] ? { path: n.path, d: n.items[0] } : null;
const copy = (n: Node | null) => {
if (n?.kind === "diag") void navigator.clipboard?.writeText(problemText(n.d));
};
const onKeyDownCapture = (e: KeyboardEvent<HTMLDivElement>) => {
const t = target(lead);
if (e.key === "Enter" && e.altKey && !e.metaKey && !e.ctrlKey && t) {
e.preventDefault();
e.stopPropagation();
void showQuickFixes(t.path, t.d);
} else if ((e.key === "c" || e.key === "C") && hasPrimaryModifier(e) && !e.altKey && lead?.kind === "diag") {
e.preventDefault();
e.stopPropagation();
copy(lead);
}
};
const expanded = useMemo(
() => new Set(roots.filter((r) => !collapsed.has(r.key)).map((r) => r.key)),
@@ -196,39 +324,93 @@ export function ProblemsView() {
<EmptyState icon="check" title={tab === "file" ? "No problems in this file" : "No problems found"} />
);
const selected = target(lead);
const list = (
<Tree<Node>
aria-label="Problems"
handleRef={tree}
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") void open(n.path, n.d);
else if (n.items[0]) void open(n.path, n.items[0]);
}}
onSelectionChange={(_sel, node) => {
setLead(node);
// Autoscroll to Source (off by default, as in IDEA): show the problem without leaving the list.
if (node?.kind === "diag" && useProblemsOptions.getState().autoscroll) void open(node.path, node.d, false);
}}
onContextMenu={(n, _sel, e) => {
const t = target(n);
if (!n || !t) return;
showContextMenu(e, [
{
id: "showFixes",
label: "Show Quick Fixes",
icon: "bulb",
shortcut: shortcutLabel("code.showIntentions") ?? undefined,
run: () => void showQuickFixes(t.path, t.d),
},
{ id: "jump", label: "Jump to Source", shortcut: "F4", run: () => void open(t.path, t.d) },
{ kind: "separator" },
{
id: "copy",
label: "Copy Problem Description",
icon: "copy",
shortcut: formatBinding(hostBinding("Meta+C")),
disabled: n.kind !== "diag",
run: () => copy(n),
},
]);
}}
activateExpandable={false}
multiSelect={false}
empty={empty}
/>
);
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 className={styles.body} onKeyDownCapture={onKeyDownCapture}>
{options.preview ? (
<SplitGroup orientation="horizontal" id="problems-preview-split">
<SplitPanel id="problems" minSize={120} className={styles.panel}>
{list}
</SplitPanel>
<SplitHandle />
<SplitPanel id="preview" minSize={120} defaultSize="50%" className={styles.panel}>
<SourcePreview
path={selected?.path ?? null}
line={selected?.d.range.start.line ?? 0}
highlights={selected ? [diagHighlight(selected.d)] : []}
load={previewText}
/>
</SplitPanel>
</SplitGroup>
) : (
list
)}
</div>
</div>
);
}
/** The problem's range on its first line. */
function diagHighlight(d: Diagnostic) {
const { start, end } = d.range;
const to = end.line === start.line ? end.col : Number.MAX_SAFE_INTEGER;
return { line: start.line, ranges: [[start.col, Math.max(start.col + 1, to)] as const] };
}
+44
Переглянути файл
@@ -77,3 +77,47 @@
font-style: italic;
color: var(--fg-subtle);
}
.focus {
display: flex;
flex-direction: column;
min-height: 0;
outline: none;
}
.filter {
flex: none;
font-family: var(--font-mono);
color: var(--fg-strong);
background: var(--accent-soft);
border-radius: var(--radius-small);
padding: 0 4px;
}
.noMatch {
color: var(--danger);
}
.empty {
padding: 4px 10px;
color: var(--fg-faint);
font-size: var(--fs-small);
}
.preview {
flex: none;
height: 200px;
border-top: 1px solid var(--border-popup);
overflow: hidden;
}
.previewEditor {
height: 100%;
}
.previewEditor :global(.cm-editor) {
height: 100%;
}
.previewHidden {
visibility: hidden;
}
.previewLine {
background: var(--bg-current-line);
}
.previewMatch {
background: var(--bg-text-search-result);
}
+215 -44
Переглянути файл
@@ -1,70 +1,241 @@
/** ⌥⌘F7 Show Usages: popup list at the caret. */
import { useMemo, useState } from "react";
/**
* ⌥⌘F7 Show Usages: popup list at the caret with speed search (typing
* filters), an optional code preview and "Open in Find Window", which moves
* the found usages to the Find Usages tool window.
*/
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { create } from "zustand";
import clsx from "clsx";
import { EditorState, StateEffect, StateField } from "@codemirror/state";
import { Decoration, EditorView } from "@codemirror/view";
import { codePreviewExtensions } from "@/editor/api";
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 { Icon, IconButton, List, Popup, fileIconName, listNavigate } from "@/ui";
import { openLocation, readFileLines } from "./ipc";
import { showUsageResults, usageCount, useUsagesPopup, type Usage, type UsageGroup } from "./usages";
import { UsagePreview } from "./UsagesView";
import styles from "./UsagesPopup.module.css";
const close = () => useUsagesPopup.setState({ open: false });
const PREVIEW_KEY = "openidea.usages.popupPreview.v1";
function loadPreviewOption(): boolean {
try {
return localStorage.getItem(PREVIEW_KEY) === "true";
} catch {
return false;
}
}
/** The preview pane toggle (remembered). */
export const useUsagesPopupPreview = create<{ preview: boolean }>(() => ({ preview: loadPreviewOption() }));
export function setUsagesPopupPreview(preview: boolean) {
useUsagesPopupPreview.setState({ preview });
try {
localStorage.setItem(PREVIEW_KEY, String(preview));
} catch {
// storage unavailable: the toggle lasts for the session
}
}
/** Usages whose file name or line text contains the typed text (case-insensitive). */
export function filterUsages(items: readonly Usage[], filter: string): Usage[] {
const q = filter.trim().toLowerCase();
if (!q) return items as Usage[];
return items.filter((u) => u.text.toLowerCase().includes(q) || basename(u.path).toLowerCase().includes(q));
}
/** The popup's groups as locations for the Find Usages tool window. */
export function usageLocations(groups: readonly UsageGroup[]) {
return groups.flatMap((g) =>
g.usages.map((u) => ({
path: u.path,
range: { start: { line: u.line, col: u.col }, end: { line: u.line, col: u.endCol } },
})),
);
}
export function UsagesPopup() {
const open = useUsagesPopup((s) => s.open);
return open ? <UsagesPopupInner /> : null;
}
const ROW = 24;
function UsagesPopupInner() {
const { anchor, symbol, groups, note } = useUsagesPopup();
const items = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
const preview = useUsagesPopupPreview((s) => s.preview);
const all = useMemo<Usage[]>(() => groups.flatMap((g) => g.usages), [groups]);
const [filter, setFilter] = useState("");
const items = useMemo(() => filterUsages(all, filter), [all, filter]);
const [active, setActive] = useState(0);
const current = Math.min(active, items.length - 1);
const total = usageCount(groups);
const pick = (u: Usage) => {
const pick = (u: Usage | undefined) => {
if (!u) return;
close();
openLocation(u.path, u.line, u.col);
void openLocation(u.path, u.line, u.col);
};
const openInFindWindow = () => {
close();
void showUsageResults({ symbol, locations: usageLocations(groups) });
};
const onKeyDown = (e: KeyboardEvent) => {
const next = listNavigate(e.key, { items, active: current, wrap: true, pageSize: 10 });
if (next !== null) {
e.preventDefault();
setActive(next);
return;
}
const plain = !e.metaKey && !e.ctrlKey && !e.altKey;
if (e.key === "Enter") {
e.preventDefault();
pick(items[current]);
} else if (e.key === "Escape" && filter) {
// The first Esc clears the speed search, the next one closes.
e.preventDefault();
e.stopPropagation();
setFilter("");
setActive(0);
} else if (e.key === "Backspace" && filter) {
e.preventDefault();
setFilter((f) => f.slice(0, -1));
setActive(0);
} else if (plain && e.key.length === 1 && (filter || e.key !== " ")) {
e.preventDefault();
setFilter((f) => f + e.key);
setActive(0);
}
};
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 className={styles.focus} tabIndex={0} data-autofocus onKeyDown={onKeyDown}>
<div className={styles.header}>
<span className={styles.title}>
Usages{symbol ? ` of ${symbol}` : ""}{" "}
<span className={styles.count}>{filter ? `${items.length} of ${total}` : total} found</span>
</span>
{filter && (
<span className={clsx(styles.filter, !items.length && styles.noMatch)} role="status">
{filter}
</span>
)}
/>
<IconButton
icon="list"
size="small"
title="Show Preview"
pressed={preview}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setUsagesPopupPreview(!preview)}
/>
<button type="button" className={styles.link} onClick={openInFindWindow}>
Open in Find Window{keys ? ` ${keys}` : ""}
</button>
</div>
{note && <div className={styles.note}>{note}</div>}
<div className={styles.body} style={{ height: Math.max(1, Math.min(14, items.length)) * ROW }}>
<List
items={items}
getKey={(u) => `${u.path}:${u.line}:${u.col}`}
activeIndex={current}
onActiveChange={setActive}
focusable={false}
hoverSelects
rowHeight={ROW}
aria-label="Usages"
onActivate={pick}
empty={<div className={styles.empty}>Nothing matches '{filter}'</div>}
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>
{preview && <UsageCodePreview usage={items[current] ?? null} />}
</div>
</Popup>
);
}
const setMark = StateEffect.define<{ from: number; to: number } | null>();
const usageMark = Decoration.mark({ class: styles.previewMatch });
const usageLine = Decoration.line({ class: styles.previewLine });
const marks = StateField.define({
create: () => Decoration.none,
update(value, tr) {
for (const e of tr.effects) {
if (!e.is(setMark)) continue;
if (!e.value) return Decoration.none;
const line = tr.state.doc.lineAt(e.value.from);
const ranges = [usageLine.range(line.from)];
if (e.value.to > e.value.from) ranges.push(usageMark.range(e.value.from, e.value.to));
return Decoration.set(ranges);
}
return value.map(tr.changes);
},
provide: (f) => EditorView.decorations.from(f),
});
/** Read-only view of the usage's file around its line, the usage marked. */
function UsageCodePreview({ usage }: { usage: Usage | null }) {
const host = useRef<HTMLDivElement>(null);
const view = useRef<EditorView | null>(null);
const [loaded, setLoaded] = useState<string | null>(null);
useEffect(() => {
if (!host.current) return;
const v = new EditorView({ parent: host.current, state: EditorState.create({ extensions: marks }) });
view.current = v;
return () => {
v.destroy();
view.current = null;
};
}, []);
const path = usage?.path ?? null;
useEffect(() => {
if (!path) return;
let alive = true;
// Debounced so arrowing through the list doesn't load every file.
const t = setTimeout(() => {
void Promise.all([readFileLines(path), codePreviewExtensions(path)]).then(([lines, extensions]) => {
if (!alive || !view.current) return;
view.current.setState(EditorState.create({ doc: (lines ?? []).join("\n"), extensions: [extensions, marks] }));
setLoaded(path);
});
}, 60);
return () => {
alive = false;
clearTimeout(t);
};
}, [path]);
useEffect(() => {
const v = view.current;
if (!v || !usage || loaded !== usage.path) return;
const doc = v.state.doc;
if (usage.line >= doc.lines) return;
const line = doc.line(usage.line + 1);
const from = line.from + Math.min(usage.col, line.length);
const to = line.from + Math.min(usage.endCol, line.length);
v.dispatch({ effects: [setMark.of({ from, to }), EditorView.scrollIntoView(from, { y: "center" })] });
}, [usage, loaded]);
return (
<div className={styles.preview} role="region" aria-label="Preview">
<div ref={host} className={clsx(styles.previewEditor, loaded !== path && styles.previewHidden)} />
</div>
);
}
+16
Переглянути файл
@@ -102,3 +102,19 @@
font-style: italic;
color: var(--fg-subtle);
}
.excluded .preview,
.excluded .file,
.excluded .line {
opacity: 0.5;
text-decoration: line-through;
}
.panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.panel > * {
flex: 1;
min-height: 0;
}
+244 -46
Переглянути файл
@@ -1,10 +1,43 @@
/** "usages" tool window: Find Usages results grouped by file with line previews. */
import { useCallback, useMemo, useState } from "react";
/**
* "usages" tool window: Find Usages results grouped by file with line
* previews, one tab per search (pinnable, as the Find tool window's tabs).
*/
import { useCallback, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import clsx from "clsx";
import { create } from "zustand";
import { formatMessage, useTx } from "@/platform/i18n";
import { shortcutLabel } from "@/platform/keymap";
import { basename, relativeDir, useProject } from "@/platform/project";
import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, type TreeRowState, T } from "@/ui";
import { openLocation } from "./ipc";
import { trimPreview, usageCount, useUsages, type Usage, type UsageGroup } from "./usages";
import { maybeEditorService, useOccurrenceNavigator } from "@/workbench/contributions";
import {
EmptyState,
Icon,
IconButton,
SourcePreview,
SplitGroup,
SplitHandle,
SplitPanel,
Spinner,
Tabs,
Tree,
fileIconName,
showContextMenu,
type MenuItem,
type TreeHandle,
type TreeRowState,
T,
} from "@/ui";
import { previewText } from "./ipc";
import {
activeUsageSearch,
closeUsageTab,
pinUsageTab,
trimPreview,
usageCount,
useUsages,
type Usage,
type UsageGroup,
} from "./usages";
import styles from "./UsagesView.module.css";
type Node =
@@ -23,29 +56,78 @@ export function UsagePreview({ usage }: { usage: Usage }) {
);
}
/** Tool window header: the current search as a content tab (`Usages of 'foo'`). */
/** Tool window header: one content tab per search (`Usages of 'foo'`). */
export function UsagesTabs() {
const tx = useTx();
const search = useUsages((s) => s.search);
const tabs = useUsages((s) => s.tabs);
const search = useUsages(activeUsageSearch);
if (!search) return null;
const label = search.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: search.symbol }) : tx("Usages");
const label = (t: (typeof tabs)[number]) =>
t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages");
return (
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Usage searches"
items={[{ id: String(search.id), label }]}
items={tabs.map((t) => ({
id: String(t.id),
label: label(t),
closable: true,
icon: t.pinned ? "pin" : undefined,
}))}
activeId={String(search.id)}
onSelect={() => undefined}
onSelect={(id) => useUsages.setState({ active: Number(id) })}
onClose={(id) => closeUsageTab(Number(id))}
onContextMenu={(id, e) => {
const t = tabs.find((x) => x.id === Number(id));
if (!t) return;
showContextMenu(e, [
{ id: "pin", label: t.pinned ? "Unpin Tab" : "Pin Tab", run: () => pinUsageTab(t.id, !t.pinned) },
{ kind: "separator" },
{ id: "close", label: "Close Tab", run: () => closeUsageTab(t.id) },
{
id: "closeOthers",
label: "Close Other Tabs",
disabled: tabs.length < 2,
run: () => tabs.forEach((o) => o.id !== t.id && closeUsageTab(o.id)),
},
]);
}}
/>
);
}
const SUMMARY_KEY = "\u0000summary";
const usageKey = (u: Usage) => `${u.path}:${u.line}:${u.col}`;
/** Excluded (⌫) usages per search. */
const useExcluded = create<{ byTab: Record<number, ReadonlySet<string>> }>(() => ({ byTab: {} }));
const NONE: ReadonlySet<string> = new Set();
/** Usages under a node (itself for a usage). */
function nodeUsages(n: Node, files: readonly Node[]): Usage[] {
if (n.kind === "usage") return [n.usage];
if (n.kind === "file") return n.group.usages;
return files.flatMap((f) => (f.kind === "file" ? f.group.usages : []));
}
/** Toggles exclusion of `usages`: all excluded already → include them, else exclude them all. */
export function toggleExcludedUsages(excluded: ReadonlySet<string>, usages: readonly Usage[]): Set<string> {
const keys = usages.map(usageKey);
const next = new Set(excluded);
if (keys.every((k) => excluded.has(k))) keys.forEach((k) => next.delete(k));
else keys.forEach((k) => next.add(k));
return next;
}
function openUsage(u: Usage, focus: boolean) {
void maybeEditorService()?.openFile(u.path, { selection: { line: u.line, col: u.col }, focus });
}
export function UsagesView() {
const tx = useTx();
const search = useUsages((s) => s.search);
const search = useUsages(activeUsageSearch);
const root = useProject((s) => s.project?.root ?? "");
const [collapsed, setCollapsed] = useState<{ id: number | null; keys: ReadonlySet<string> }>({
id: null,
@@ -66,10 +148,21 @@ export function UsagesView() {
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)
? n.group.usages.map((u) => ({ kind: "usage", key: usageKey(u), usage: u }) as Node)
: undefined,
[files],
);
const tree = useRef<TreeHandle>(null);
const [lead, setLead] = useState<Node | null>(null);
const excluded = useExcluded((s) => (searchId != null ? s.byTab[searchId] : undefined)) ?? NONE;
useOccurrenceNavigator("usages", (dir, focus) => {
const step = tree.current?.selectOccurrence(dir);
const n = step?.node as Node | undefined;
if (!step || n?.kind !== "usage") return null;
openUsage(n.usage, focus);
return step;
});
if (!search)
return (
@@ -81,6 +174,21 @@ export function UsagesView() {
const setAll = (collapse: boolean) =>
setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() });
const exclude = () => {
if (!lead) return;
const next = toggleExcludedUsages(excluded, nodeUsages(lead, files));
useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } }));
// Excluding moves on to the next usage, as in IDEA.
if (next.size > excluded.size) 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();
}
};
const isExcluded = (n: Node) => nodeUsages(n, files).every((u) => excluded.has(usageKey(u)));
const renderNode = (n: Node, _state: TreeRowState) => {
if (n.kind === "summary")
return (
@@ -97,7 +205,7 @@ export function UsagesView() {
if (n.kind === "file") {
const path = n.group.path;
return (
<span className={styles.row}>
<span className={clsx(styles.row, isExcluded(n) && styles.excluded)}>
<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>
@@ -108,7 +216,7 @@ export function UsagesView() {
);
}
return (
<span className={styles.row}>
<span className={clsx(styles.row, excluded.has(n.key) && styles.excluded)}>
<span className={styles.line}>{n.usage.line + 1}</span>
<UsagePreview usage={n.usage} />
</span>
@@ -123,14 +231,18 @@ export function UsagesView() {
title={tx("Rerun Find Usages")}
size="small"
iconSize={16}
shortcut={shortcutLabel("usages.rerun")}
disabled={!search.rerun}
onClick={() => search.rerun?.()}
/>
<span className={styles.toolbarSep} />
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" iconSize={16} />
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" iconSize={16} />
<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}>
<div className={styles.body} onKeyDown={onKeyDown}>
{(search.loading || search.error) && (
<div className={styles.summary} role="status" aria-live="polite">
{search.loading ? (
@@ -146,38 +258,124 @@ export function UsagesView() {
)}
</div>
)}
<Tree<Node>
key={search.id}
aria-label="Usages"
roots={roots}
getKey={(n) => n.key}
getLabel={(n) =>
n.kind === "summary" ? tx("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>
<T>No usages found</T>
</EmptyState>
) : undefined
}
/>
<UsagesSplit preview={<UsagesPreview node={lead} />}>
<Tree<Node>
key={search.id}
aria-label="Usages"
handleRef={tree}
roots={roots}
getKey={(n) => n.key}
getLabel={(n) =>
n.kind === "summary"
? tx("Found usages")
: n.kind === "file"
? basename(n.group.path)
: n.usage.text.trim()
}
getChildren={getChildren}
isExpandable={(n) => n.kind !== "usage"}
isOccurrence={(n) => n.kind === "usage" && !excluded.has(n.key)}
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}
onSelectionChange={(_sel, node) => setLead(node)}
onContextMenu={(n, _sel, e) => {
if (!n) return;
showContextMenu(e, [
{
id: "exclude",
label: isExcluded(n) ? "Include" : "Exclude",
shortcut: "⌫",
run: () => {
setLead(n);
const next = toggleExcludedUsages(excluded, nodeUsages(n, files));
useExcluded.setState((s) => ({ byTab: { ...s.byTab, [search.id]: next } }));
},
},
]);
}}
onActivate={(n) => {
const u = n.kind === "usage" ? n.usage : n.kind === "file" ? n.group.usages[0] : undefined;
if (u) openUsage(u, true);
}}
empty={
!search.loading && !search.error ? (
<EmptyState>
<T>No usages found</T>
</EmptyState>
) : undefined
}
/>
</UsagesSplit>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Preview pane (persisted toggle in the ⋮ menu; on by default, like the Find tool window's).
const OPTIONS_KEY = "openidea.usages.view.v1";
export const useUsagesOptions = create<{ preview: boolean }>(() => {
try {
return { preview: true, ...JSON.parse(globalThis.localStorage?.getItem(OPTIONS_KEY) ?? "{}") };
} catch {
return { preview: true };
}
});
useUsagesOptions.subscribe((s) => {
try {
globalThis.localStorage?.setItem(OPTIONS_KEY, JSON.stringify(s));
} catch {
/* storage unavailable */
}
});
/** The Find Usages tool window's ⋮ menu entries. */
export function useUsagesHeaderMenu(): MenuItem[] {
const preview = useUsagesOptions((s) => s.preview);
return [
{
id: "preview",
label: "Preview Source",
checked: preview,
run: () => useUsagesOptions.setState({ preview: !preview }),
},
];
}
function UsagesSplit({ preview, children }: { preview: ReactNode; children: ReactNode }) {
const show = useUsagesOptions((s) => s.preview);
if (!show) return children;
return (
<SplitGroup orientation="horizontal" id="usages-preview-split">
<SplitPanel id="usages" minSize={160} className={styles.panel}>
{children}
</SplitPanel>
<SplitHandle />
<SplitPanel id="preview" minSize={160} defaultSize="45%" className={styles.panel}>
{preview}
</SplitPanel>
</SplitGroup>
);
}
function UsagesPreview({ node }: { node: Node | null }) {
const usage = node?.kind === "usage" ? node.usage : node?.kind === "file" ? (node.group.usages[0] ?? null) : null;
const search = useUsages(activeUsageSearch);
const highlights = useMemo(() => {
const group = usage && search?.groups.find((g) => g.path === usage.path);
return (group?.usages ?? []).map((u) => ({ line: u.line, ranges: [[u.col, u.endCol] as const] }));
}, [usage, search?.groups]);
return (
<SourcePreview path={usage?.path ?? null} line={usage?.line ?? 0} highlights={highlights} load={previewText} />
);
}
+92
Переглянути файл
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from "vitest";
import type { HierarchyItem, OutlineItem, SymbolKind } from "@/bindings";
const hint = vi.fn();
vi.mock("@/workbench/contributions", async (orig) => ({
...(await orig<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: () => undefined }),
}));
const range = (l1: number, c1: number, l2: number, c2: number) => ({
start: { line: l1, col: c1 },
end: { line: l2, col: c2 },
});
const outlineItem = (name: string, kind: SymbolKind, r: ReturnType<typeof range>, nameAt: [number, number], children: OutlineItem[] = []): OutlineItem => ({
name,
detail: null,
kind,
range: r,
selection_range: range(nameAt[0], nameAt[1], nameAt[0], nameAt[1] + name.length),
children,
});
// class Orders { void list() { int x = 1; } } — class on lines 0-10, `list` on lines 2-6.
const OUTLINE = [
outlineItem("Orders", "class", range(0, 0, 10, 1), [0, 6], [
outlineItem("list", "method", range(2, 2, 6, 3), [2, 7]),
outlineItem("count", "field", range(8, 2, 8, 12), [8, 6]),
]),
];
const item = (name: string, fromRanges: HierarchyItem["fromRanges"] = []): HierarchyItem => ({
name,
kind: "method",
detail: null,
path: "/p/Caller.java",
range: range(10, 0, 20, 1),
selectionRange: range(10, 7, 10, 7 + name.length),
fromRanges,
});
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return {
...real,
call: async <T,>(p: Promise<T>) => p,
commands: {
...real.commands,
docOutline: async () => OUTLINE,
lspCallHierarchy: async (_doc: number, pos: { line: number; col: number }) => {
// The server prepares a hierarchy only on the method name.
if (pos.line !== 2 || pos.col !== 7) throw new real.IpcError({ kind: "notFound", message: "No symbol" });
return [item("main", [range(12, 4, 12, 8)])];
},
},
};
});
vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()),
caretContext: () => ({ doc: 1, path: "/p/Orders.java", line: 4, col: 10, word: "x", view: null }),
withDocument: async <T,>(_path: string, fn: (doc: number) => Promise<T>) => fn(1),
lspRequest: async <T,>(run: () => Promise<T>) => run(),
hint: (...a: unknown[]) => hint(...a),
}));
const { enclosingDeclaration, hierarchyTarget, showHierarchy, useHierarchy, ROOT_ID } = await import("./hierarchy");
describe("hierarchy", () => {
it("finds the innermost enclosing declaration of the wanted kinds", () => {
const calls = new Set<SymbolKind>(["method", "function", "constructor"]);
const types = new Set<SymbolKind>(["class"]);
expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, calls)?.name).toBe("list");
expect(enclosingDeclaration(OUTLINE, { line: 4, col: 10 }, types)?.name).toBe("Orders");
expect(enclosingDeclaration(OUTLINE, { line: 8, col: 4 }, calls)).toBeNull();
expect(enclosingDeclaration(OUTLINE, { line: 12, col: 0 }, types)).toBeNull();
});
it("navigates callers to their call site, other nodes to the symbol", () => {
const caller = item("main", [range(12, 4, 12, 8), range(14, 4, 14, 8)]);
expect(hierarchyTarget("call", "incoming", caller)).toEqual({ path: "/p/Caller.java", line: 12, col: 4 });
expect(hierarchyTarget("call", "outgoing", caller)).toEqual({ path: "/p/Caller.java", line: 10, col: 7 });
expect(hierarchyTarget("type", "incoming", item("Base"))).toEqual({ path: "/p/Caller.java", line: 10, col: 7 });
});
it("retries at the enclosing method when nothing is at the caret", async () => {
useHierarchy.setState({ mode: "call", direction: "incoming" });
await showHierarchy("call");
const s = useHierarchy.getState();
expect(s.root).toEqual({ name: "list", path: "/p/Orders.java", line: 2, col: 7 });
expect(s.children.get(ROOT_ID)?.map((n) => n.item.name)).toEqual(["main"]);
expect(s.failed.size).toBe(0);
expect(hint).not.toHaveBeenCalled();
});
});
+66 -8
Переглянути файл
@@ -1,10 +1,10 @@
/** Call / type hierarchy state, with lazily expanded levels. */
import { create } from "zustand";
import type { HierarchyDirection, HierarchyItem } from "@/bindings";
import { commands } from "@/platform/ipc";
import type { HierarchyDirection, HierarchyItem, OutlineItem, Pos, SymbolKind } from "@/bindings";
import { IpcError, call, commands } from "@/platform/ipc";
import { toolWindowService } from "@/workbench/contributions";
import { basename } from "@/platform/project";
import { caretContext, hint, lspRequest, reportError, withDocument } from "./ipc";
import { caretContext, hint, lspRequest, reportError, withDocument, type CaretContext } from "./ipc";
export type HierarchyMode = "call" | "type";
@@ -88,24 +88,61 @@ function setChildren(generation: number, parent: string, items: HierarchyItem[])
useHierarchy.setState({ children: new Map(s.children).set(parent, nodes) });
}
async function loadRoot() {
type RootResult = "loaded" | "noTarget" | "failed" | "stale";
/** Loads the root's level. `quiet`: a position without a symbol isn't reported (the caller retries). */
async function loadRoot(quiet = false): Promise<RootResult> {
const s = useHierarchy.getState();
const root = s.root;
if (!root) return;
if (!root) return "stale";
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);
return "loaded";
} catch (e) {
if (useHierarchy.getState().generation === generation)
useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) }));
if (useHierarchy.getState().generation !== generation) return "stale";
const noTarget = e instanceof IpcError && e.kind === "notFound";
if (quiet && noTarget) return "noTarget";
useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) }));
reportError(e);
return noTarget ? "noTarget" : "failed";
} finally {
if (useHierarchy.getState().generation === generation) useHierarchy.setState({ loading: false });
}
}
const CALLABLE = new Set<SymbolKind>(["function", "method", "constructor"]);
const TYPES = new Set<SymbolKind>(["class", "interface", "struct", "enum", "trait"]);
const before = (a: Pos, b: Pos) => a.line < b.line || (a.line === b.line && a.col <= b.col);
/** The innermost outline symbol of `kinds` whose extent contains `pos`. */
export function enclosingDeclaration(
items: readonly OutlineItem[],
pos: Pos,
kinds: ReadonlySet<SymbolKind>,
): OutlineItem | null {
for (const item of items) {
if (!before(item.range.start, pos) || !before(pos, item.range.end)) continue;
return enclosingDeclaration(item.children, pos, kinds) ?? (kinds.has(item.kind) ? item : null);
}
return null;
}
/** IDEA: inside a method / class body the hierarchy is the enclosing declaration's. */
async function enclosingRoot(c: CaretContext, mode: HierarchyMode): Promise<HierarchyRoot | null> {
try {
const outline = await call(commands.docOutline(c.doc));
const decl = enclosingDeclaration(outline, { line: c.line, col: c.col }, mode === "call" ? CALLABLE : TYPES);
if (!decl) return null;
const at = decl.selection_range.start;
return { name: decl.name, path: c.path, line: at.line, col: at.col };
} catch {
return null;
}
}
export async function loadChildren(node: HierarchyNode) {
const { mode, direction, generation } = useHierarchy.getState();
const at = node.item.selectionRange.start;
@@ -136,7 +173,28 @@ export async function showHierarchy(mode: HierarchyMode) {
root: { name: c.word ?? basename(c.path), path: c.path, line: c.line, col: c.col },
});
toolWindowService().show("hierarchy");
await loadRoot();
if ((await loadRoot(true)) !== "noTarget") return;
// Nothing at the caret: retry at the enclosing method / class.
const root = await enclosingRoot(c, mode);
const now = useHierarchy.getState();
if (now.mode !== mode || now.root?.path !== c.path || now.root.line !== c.line || now.root.col !== c.col) return;
if (root && (root.line !== c.line || root.col !== c.col)) {
useHierarchy.setState({ root });
await loadRoot();
} else {
useHierarchy.setState((st) => ({ failed: new Set(st.failed).add(ROOT_ID) }));
hint(mode === "call" ? "No method at the caret to show the call hierarchy of" : "No type at the caret to show the hierarchy of");
}
}
/** Where activating a node goes: a caller's call site (as IDEA), else the symbol itself. */
export function hierarchyTarget(
mode: HierarchyMode,
direction: HierarchyDirection,
item: HierarchyItem,
): { path: string; line: number; col: number } {
const at = mode === "call" && direction === "incoming" && item.fromRanges[0] ? item.fromRanges[0].start : item.selectionRange.start;
return { path: item.path, line: at.line, col: at.col };
}
export function setDirection(direction: HierarchyDirection) {
+14 -3
Переглянути файл
@@ -8,10 +8,10 @@ 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 { ProblemsHeaderActions, ProblemsTabs, ProblemsView, useProblemsHeaderMenu } from "./ProblemsView";
import { UsagesTabs, UsagesView, useUsagesHeaderMenu } from "./UsagesView";
import { UsagesPopup } from "./UsagesPopup";
import { findUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages";
import { findUsages, rerunActiveUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages";
import { HierarchyTabs, HierarchyView } from "./HierarchyView";
import { resetHierarchy, showHierarchy } from "./hierarchy";
import { installServerEvents } from "./servers";
@@ -53,6 +53,7 @@ export function activate(): () => void {
component: ProblemsView,
headerContent: ProblemsTabs,
headerActions: ProblemsHeaderActions,
headerMenu: useProblemsHeaderMenu,
useBadge: useErrorBadge,
}),
registerToolWindow({
@@ -64,6 +65,7 @@ export function activate(): () => void {
order: 35,
component: UsagesView,
headerContent: UsagesTabs,
headerMenu: useUsagesHeaderMenu,
}),
registerToolWindow({
id: "hierarchy",
@@ -92,6 +94,15 @@ export function activate(): () => void {
when: EDITOR_WHEN,
run: () => showUsagesPopup(),
},
{
// ⌘R in the Find Usages tool window, as in IDEA.
id: "usages.rerun",
title: "Rerun Find Usages",
category: "Find",
icon: "rerun",
when: "projectOpen && activeToolWindow == 'usages'",
run: rerunActiveUsages,
},
{
id: "find.showUsageResults",
title: "Show Usage Results",
+9 -2
Переглянути файл
@@ -86,6 +86,13 @@ export async function readFileLines(path: string): Promise<string[] | null> {
}
}
/** File text for a preview pane (open buffer, else disk). */
export async function previewText(path: string): Promise<string> {
const lines = await readFileLines(path);
if (!lines) throw new Error("Cannot read the file");
return lines.join("\n");
}
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)];
@@ -111,6 +118,6 @@ export async function withDocument<T>(path: string, fn: (doc: number) => Promise
}
}
export function openLocation(path: string, line: number, col: number) {
void maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true });
export function openLocation(path: string, line: number, col: number): Promise<void> {
return maybeEditorService()?.openFile(path, { selection: { line, col }, focus: true }) ?? Promise.resolve();
}
+24
Переглянути файл
@@ -47,6 +47,30 @@ export function fileDiagnostics(map: ProblemsMap, path: string): Diagnostic[] {
);
}
export interface ProblemOrder {
/** Errors before warnings before infos (IDEA's default). */
bySeverity: boolean;
/** By message text, then position; otherwise by position, then text. */
byName: boolean;
}
export const DEFAULT_PROBLEM_ORDER: ProblemOrder = { bySeverity: true, byName: false };
const natural = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** A file's problems in the Problems view order (Sort by Severity / Name). */
export function sortProblems(items: readonly Diagnostic[], order: ProblemOrder): Diagnostic[] {
const position = (a: Diagnostic, b: Diagnostic) =>
a.range.start.line - b.range.start.line || a.range.start.col - b.range.start.col;
return [...items].sort(
(a, b) =>
(order.bySeverity ? SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] : 0) ||
(order.byName
? natural.compare(a.message, b.message) || position(a, b)
: position(a, b) || natural.compare(a.message, b.message)),
);
}
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]++;
+19 -2
Переглянути файл
@@ -10,14 +10,14 @@ vi.mock("./ipc", async (orig) => ({
readManyFileLines: async () => new Map([["/p/b.rs", ["", " used();"]]]),
}));
const { showUsageResults, useUsages } = await import("./usages");
const { activeUsageSearch, openUsageTab, pinUsageTab, resetUsages, 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!;
const search = activeUsageSearch(useUsages.getState())!;
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();" }] }]);
});
@@ -28,3 +28,20 @@ describe("showUsageResults", () => {
expect(show).not.toHaveBeenCalled();
});
});
describe("usage tabs", () => {
const search = (id: number) => ({ id, symbol: `s${id}`, loading: false, groups: [], error: null, note: null, rerun: null });
it("replace the selected tab unless it is pinned", () => {
resetUsages();
openUsageTab(search(101));
openUsageTab(search(102));
expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102]);
pinUsageTab(102, true);
openUsageTab(search(103));
expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 103]);
expect(activeUsageSearch(useUsages.getState())?.id).toBe(103);
openUsageTab(search(104));
expect(useUsages.getState().tabs.map((t) => t.id)).toEqual([102, 104]);
});
});
+68 -15
Переглянути файл
@@ -62,7 +62,7 @@ export function trimPreview(u: Usage): { text: string; from: number; to: number
return { text, from, to };
}
interface UsageSearch {
export interface UsageSearch {
/** Identifies one search run (UI state such as expansion resets per run). */
id: number;
symbol: string | null;
@@ -73,11 +73,64 @@ interface UsageSearch {
note: string | null;
/** Re-runs the same query (rerun button). */
rerun: (() => void) | null;
/** Kept when a new search runs (Pin Tab). */
pinned?: boolean;
}
let searchSeq = 0;
export const useUsages = create<{ search: UsageSearch | null }>(() => ({ search: null }));
/** One tab per search; as in IDEA a new search replaces the selected tab unless it is pinned. */
interface UsagesState {
tabs: UsageSearch[];
active: number | null;
}
export const useUsages = create<UsagesState>(() => ({ tabs: [], active: null }));
/** The selected tab's search. */
export const activeUsageSearch = (s: UsagesState): UsageSearch | null =>
s.tabs.find((t) => t.id === s.active) ?? s.tabs.at(-1) ?? null;
const MAX_TABS = 8;
/** Shows `search` in a new tab, or in place of the selected one unless that is pinned. */
export function openUsageTab(search: UsageSearch) {
useUsages.setState((s) => {
const reused = s.tabs.find((t) => t.id === s.active && !t.pinned);
const tabs = reused ? s.tabs.map((t) => (t === reused ? search : t)) : [...s.tabs, search];
return { tabs: tabs.slice(-MAX_TABS), active: search.id };
});
}
/** Replaces tab `id` (a rerun or a finished search), keeping its pin and selection. */
function replaceUsageTab(id: number, search: UsageSearch): boolean {
const s = useUsages.getState();
const old = s.tabs.find((t) => t.id === id);
if (!old) return false;
useUsages.setState({
tabs: s.tabs.map((t) => (t === old ? { ...search, pinned: old.pinned } : t)),
active: s.active === id ? search.id : s.active,
});
return true;
}
export function pinUsageTab(id: number, pinned: boolean) {
useUsages.setState((s) => ({ tabs: s.tabs.map((t) => (t.id === id ? { ...t, pinned } : t)) }));
}
export function closeUsageTab(id: number) {
useUsages.setState((s) => {
const i = s.tabs.findIndex((t) => t.id === id);
const tabs = s.tabs.filter((t) => t.id !== id);
const active = s.active === id ? (tabs[Math.min(i, tabs.length - 1)]?.id ?? null) : s.active;
return { tabs, active };
});
}
/** Rerun of the selected tab (⌘R in the Find Usages tool window). */
export function rerunActiveUsages() {
activeUsageSearch(useUsages.getState())?.rerun?.();
}
interface UsagesPopupState {
open: boolean;
@@ -128,7 +181,7 @@ export async function fetchUsages(c: CaretContext): Promise<FetchedUsages> {
/** The window switched projects: results (and in-flight searches) of the previous one are dropped. */
export function resetUsages() {
useUsages.setState({ search: null });
useUsages.setState({ tabs: [], active: null });
useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null });
}
@@ -138,23 +191,22 @@ export async function findUsages() {
hint("Open a file to find usages");
return;
}
const run = async () => {
const run = async (prev: number | null) => {
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 } });
const base = { id, symbol: c.word, rerun: () => void run(id) };
const loading = { ...base, loading: true, groups: [], error: null, note: null };
if (prev === null || !replaceUsageTab(prev, loading)) openUsageTab(loading);
toolWindowService().show("usages");
try {
const { groups, note } = await fetchUsages(c);
if (current()) useUsages.setState({ search: { ...base, loading: false, groups, error: null, note } });
replaceUsageTab(id, { ...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 } });
if (!replaceUsageTab(id, { ...base, loading: false, groups: [], error: message, note: null })) return;
reportError(e);
}
};
await run();
await run(null);
}
/**
@@ -168,11 +220,10 @@ export async function showUsageResults(arg: unknown) {
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 } });
openUsageTab({ ...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 } });
replaceUsageTab(id, { ...base, loading: false, groups: groupUsages(locs, files), error: null, note: null });
}
export async function showUsagesPopup() {
@@ -189,8 +240,10 @@ export async function showUsagesPopup() {
return;
}
if (total === 1) {
// IDEA jumps to the only usage and says so in the target editor.
const u = groups[0]!.usages[0]!;
openLocation(u.path, u.line, u.col);
await openLocation(u.path, u.line, u.col);
hint("Only one usage in Project Files");
return;
}
useUsagesPopup.setState({ open: true, anchor: caretPoint(), symbol: c.word, groups, note });
+86
Переглянути файл
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
const calls: string[] = [];
const loc = { path: "/p/b.ts", range: { start: { line: 1, col: 4 }, end: { line: 1, col: 8 } } };
vi.mock("./ipc", async (orig) => ({
...(await orig<typeof import("./ipc")>()),
caretContext: () => ({ doc: 1, path: "/p/a.ts", line: 0, col: 0, word: "used", view: null }),
lspRequest: async () => [loc],
readManyFileLines: async () => new Map([["/p/b.ts", ["", " used();"]]]),
openLocation: async (path: string, line: number) => void calls.push(`open ${path}:${line}`),
hint: (text: string) => void calls.push(`hint ${text}`),
}));
const showUsageResults = vi.fn();
vi.mock("./usages", async (orig) => ({
...(await orig<typeof import("./usages")>()),
showUsageResults: (a: unknown) => showUsageResults(a),
}));
const { UsagesPopup, filterUsages } = await import("./UsagesPopup");
const { showUsagesPopup, useUsagesPopup } = await import("./usages");
const usage = (path: string, line: number, text: string) => ({ path, line, col: 0, endCol: 3, text });
const groups = [
{ path: "/p/a.ts", usages: [usage("/p/a.ts", 2, "foo(order)"), usage("/p/a.ts", 5, "foo(cart)")] },
{ path: "/p/b.ts", usages: [usage("/p/b.ts", 1, "foo(order, 2)")] },
];
function open() {
render(<UsagesPopup />);
act(() => useUsagesPopup.setState({ open: true, anchor: { x: 0, y: 0 }, symbol: "foo", groups, note: null }));
return screen.getByRole("dialog", { name: "Usages" }).querySelector<HTMLElement>("[tabindex='0']")!;
}
afterEach(() => act(() => useUsagesPopup.setState({ open: false })));
describe("Show Usages popup", () => {
it("filters by typing; Esc clears the filter, then closes", () => {
const body = open();
expect(screen.getAllByRole("option")).toHaveLength(3);
for (const key of "cart") fireEvent.keyDown(body, { key });
expect(screen.getAllByRole("option")).toHaveLength(1);
expect(screen.getByText("1 of 3 found")).toBeInTheDocument();
fireEvent.keyDown(body, { key: "Escape" });
expect(screen.getAllByRole("option")).toHaveLength(3);
expect(useUsagesPopup.getState().open).toBe(true);
fireEvent.keyDown(body, { key: "Escape" });
expect(useUsagesPopup.getState().open).toBe(false);
});
it("matches file names and line text", () => {
const all = groups.flatMap((g) => g.usages);
expect(filterUsages(all, "b.ts")).toHaveLength(1);
expect(filterUsages(all, "ORDER")).toHaveLength(2);
expect(filterUsages(all, "")).toBe(all);
});
it("Open in Find Window moves the found usages instead of searching again", () => {
open();
fireEvent.click(screen.getByText(/Open in Find Window/));
expect(useUsagesPopup.getState().open).toBe(false);
expect(showUsageResults).toHaveBeenCalledWith({
symbol: "foo",
locations: [
{ path: "/p/a.ts", range: { start: { line: 2, col: 0 }, end: { line: 2, col: 3 } } },
{ path: "/p/a.ts", range: { start: { line: 5, col: 0 }, end: { line: 5, col: 3 } } },
{ path: "/p/b.ts", range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } } },
],
});
});
it("the preview pane toggles", () => {
open();
expect(screen.queryByRole("region", { name: "Preview" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Show Preview" }));
expect(screen.getByRole("region", { name: "Preview" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Show Preview" }));
});
it("a single usage is opened with a hint in its editor", async () => {
calls.length = 0;
await showUsagesPopup();
expect(calls).toEqual(["open /p/b.ts:1", "hint Only one usage in Project Files"]);
expect(useUsagesPopup.getState().open).toBe(false);
});
});
+33 -1
Переглянути файл
@@ -2,7 +2,8 @@ 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 { mergeDiagnostics, sortProblems } from "./problemsStore";
import { toggleExcludedUsages } from "./UsagesView";
import { childId, directionLabel } from "./hierarchy";
const loc = (path: string, line: number, col: number, endCol: number) => ({
@@ -83,3 +84,34 @@ describe("hierarchy", () => {
expect(childId("root", item)).toBe("root//a.rs:1:3:f");
});
});
describe("sortProblems", () => {
const d = (severity: "error" | "warning", line: number, message: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
severity,
message,
source: null,
code: null,
});
const items = [d("warning", 1, "b"), d("error", 5, "c"), d("warning", 0, "z"), d("error", 2, "a")];
const lines = (order: { bySeverity: boolean; byName: boolean }) => sortProblems(items, order).map((x) => x.range.start.line);
it("orders by severity, then position (IDEA's default)", () => {
expect(lines({ bySeverity: true, byName: false })).toEqual([2, 5, 0, 1]);
});
it("orders by position or by name when asked", () => {
expect(lines({ bySeverity: false, byName: false })).toEqual([0, 1, 2, 5]);
expect(lines({ bySeverity: false, byName: true })).toEqual([2, 1, 5, 0]);
});
});
describe("toggleExcludedUsages", () => {
const u = (line: number) => ({ path: "/a", line, col: 0, endCol: 1, text: "" });
it("excludes a group unless all of it is excluded already", () => {
const one = toggleExcludedUsages(new Set(), [u(1)]);
expect([...one]).toEqual(["/a:1:0"]);
const both = toggleExcludedUsages(one, [u(1), u(2)]);
expect([...both].sort()).toEqual(["/a:1:0", "/a:2:0"]);
expect(toggleExcludedUsages(both, [u(1), u(2)]).size).toBe(0);
});
});