дзеркало
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()) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { history } from "@codemirror/commands";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { editCommand } from "../appActions";
|
||||
|
||||
describe("Edit menu Undo / Redo", () => {
|
||||
let view: EditorView | null = null;
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
it("use the focused editor's history, including programmatic edits", () => {
|
||||
view = new EditorView({ state: EditorState.create({ doc: "a", extensions: history() }), parent: document.body });
|
||||
view.dispatch({ changes: { from: 1, insert: "b" }, userEvent: "input.complete" });
|
||||
view.contentDOM.focus();
|
||||
editCommand("undo")();
|
||||
expect(view.state.doc.toString()).toBe("a");
|
||||
editCommand("redo")();
|
||||
expect(view.state.doc.toString()).toBe("ab");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { redo, undo } from "@codemirror/commands";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands, isTauri, type UpdateInfo } from "@/platform/ipc";
|
||||
@@ -77,11 +79,20 @@ function notifyUpdate(update: UpdateInfo) {
|
||||
|
||||
/**
|
||||
* Edit menu items on Windows/Linux (macOS uses native roles). They act on the
|
||||
* focused element like the native commands would; CodeMirror handles the
|
||||
* resulting `beforeinput` history events itself.
|
||||
* focused element like the native commands would. Undo / Redo in an editor go
|
||||
* to its own history: the native stack misses programmatic edits (templates,
|
||||
* completion, refactorings).
|
||||
*/
|
||||
function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") {
|
||||
export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste" | "selectAll") {
|
||||
return () => {
|
||||
if (command === "undo" || command === "redo") {
|
||||
const focused = document.activeElement;
|
||||
const view = focused instanceof HTMLElement ? EditorView.findFromDOM(focused) : null;
|
||||
if (view) {
|
||||
(command === "undo" ? undo : redo)(view);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (command === "paste" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]++;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,10 @@ describe("breakpoint click area", () => {
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
const down = (el: Element) => el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
const down = (el: Element) => {
|
||||
el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 }));
|
||||
};
|
||||
const cells = view.dom.querySelectorAll(".extra .cm-gutterElement");
|
||||
down(cells[cells.length - 1]!.firstElementChild!);
|
||||
expect(breakpointsOf(ctx.path)).toHaveLength(0);
|
||||
@@ -98,10 +101,12 @@ describe("breakpoint click area", () => {
|
||||
});
|
||||
|
||||
describe("breakpoint gutter clicks", () => {
|
||||
const click = (view: EditorView, init: MouseEventInit) =>
|
||||
view.dom
|
||||
.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!
|
||||
.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init }));
|
||||
// Plain and modifier clicks act on release, so a drag over the gutter can select lines.
|
||||
const click = (view: EditorView, init: MouseEventInit) => {
|
||||
const cell = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement:not([style*='hidden'])")!;
|
||||
cell.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, ...init }));
|
||||
cell.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0, ...init }));
|
||||
};
|
||||
|
||||
it("⇧-click adds a non-suspending logging breakpoint, ⌥-click a temporary one", () => {
|
||||
localStorage.clear();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
|
||||
import { useExecutionPoint } from "../state";
|
||||
import { useOverlays } from "../ui/popoverStore";
|
||||
import {
|
||||
calleeName,
|
||||
installSmartStepPicking,
|
||||
locateStepTargets,
|
||||
smartStepTargets,
|
||||
startInlineSmartStep,
|
||||
targetsOnLine,
|
||||
} from "../editor/smartStep";
|
||||
|
||||
const ipc = vi.hoisted(() => ({ debugStepInTarget: vi.fn() }));
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const real = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
call: (p: Promise<unknown>) => p,
|
||||
commands: { ...real.commands, debugStepInTarget: (...a: unknown[]) => (ipc.debugStepInTarget(...a), Promise.resolve(null)) },
|
||||
};
|
||||
});
|
||||
|
||||
const target = (id: number, label: string, column: number | null = null, endColumn: number | null = null) => ({
|
||||
id,
|
||||
label,
|
||||
line: null,
|
||||
column,
|
||||
endColumn,
|
||||
});
|
||||
|
||||
describe("locating Smart Step Into targets", () => {
|
||||
it("names the callee of a label", () => {
|
||||
expect(calleeName("parse(str(i))")).toBe("parse");
|
||||
expect(calleeName("java.lang.String.valueOf(int)")).toBe("valueOf");
|
||||
expect(calleeName("<lambda>")).toBeNull();
|
||||
});
|
||||
|
||||
it("places targets by name, in line order, or by adapter columns", () => {
|
||||
const line = " x = parse(str(i)) + str (j)";
|
||||
const r = locateStepTargets(line, 3, [target(1, "str(i)"), target(2, "parse(str(i))"), target(3, "str(j)")]);
|
||||
expect(r).toEqual([
|
||||
{ id: 2, from: 8, to: 13 },
|
||||
{ id: 1, from: 14, to: 17 },
|
||||
{ id: 3, from: 24, to: 27 },
|
||||
]);
|
||||
expect(locateStepTargets(line, 3, [target(7, "whatever", 8, 13), target(8, "x", 14)])).toEqual([
|
||||
{ id: 7, from: 8, to: 13 },
|
||||
{ id: 8, from: 14, to: 17 },
|
||||
]);
|
||||
expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps only the paused line's calls when the adapter lists the whole function", () => {
|
||||
const line = " total = add(total, square(i))";
|
||||
const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')];
|
||||
expect(targetsOnLine(line, 11, all).map((t) => t.id)).toEqual([0, 1]);
|
||||
expect(targetsOnLine(line, 11, [{ ...target(5, "print()"), line: 11 }, { ...target(6, "add()"), line: 12 }]).map((t) => t.id)).toEqual([5]);
|
||||
expect(targetsOnLine("pass", 0, all)).toBe(all);
|
||||
});
|
||||
});
|
||||
|
||||
describe("picking in the editor", () => {
|
||||
const views: EditorView[] = [];
|
||||
const cleanup: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
cleanup.splice(0).forEach((d) => d());
|
||||
views.splice(0).forEach((v) => v.destroy());
|
||||
useOverlays.setState({ smartStep: null });
|
||||
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} });
|
||||
ipc.debugStepInTarget.mockReset();
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const ctx = { doc: 1, path: "/p/app.py", language: "python", uiMode: null, readOnly: false };
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: "def f():\n x = parse(str(i))\n", extensions: [smartStepTargets(ctx)] }),
|
||||
parent: document.body,
|
||||
});
|
||||
views.push(view);
|
||||
const handle: TextEditorHandle = { view, ctx };
|
||||
_addEditor(handle);
|
||||
cleanup.push(() => _removeEditor(handle), installSmartStepPicking());
|
||||
useExecutionPoint.setState({ path: "/p/app.py", line: 1, top: true });
|
||||
return view;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
const key = (k: string, init: KeyboardEventInit = {}) =>
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...init }));
|
||||
const marked = (view: EditorView) =>
|
||||
[...view.dom.querySelectorAll("[data-step-target]")].map((e) => [e.textContent, e.getAttribute("aria-current")]);
|
||||
|
||||
it("highlights the calls, moves with arrows and steps in with Enter", async () => {
|
||||
const view = setup();
|
||||
expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(true);
|
||||
await flush();
|
||||
expect(marked(view)).toEqual([
|
||||
["parse", null],
|
||||
["str", "true"],
|
||||
]);
|
||||
key("ArrowLeft");
|
||||
await flush();
|
||||
expect(marked(view)[0]).toEqual(["parse", "true"]);
|
||||
key("Enter");
|
||||
expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2);
|
||||
expect(useOverlays.getState().smartStep).toBeNull();
|
||||
await flush();
|
||||
expect(marked(view)).toEqual([]);
|
||||
});
|
||||
|
||||
it("Escape cancels; a click on a call steps into it", async () => {
|
||||
const view = setup();
|
||||
startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]);
|
||||
key("Escape");
|
||||
expect(useOverlays.getState().smartStep).toBeNull();
|
||||
expect(ipc.debugStepInTarget).not.toHaveBeenCalled();
|
||||
|
||||
startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")]);
|
||||
await flush();
|
||||
const parse = view.dom.querySelector("[data-step-target='2']")!;
|
||||
parse.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
||||
expect(ipc.debugStepInTarget).toHaveBeenCalledWith(3, 1, 2);
|
||||
});
|
||||
|
||||
it("uses the dialog when the line is not open or the calls cannot be placed", () => {
|
||||
setup();
|
||||
expect(startInlineSmartStep(3, 1, [target(1, "a()"), target(2, "b()")])).toBe(false);
|
||||
useExecutionPoint.setState({ path: "/p/other.py" });
|
||||
expect(startInlineSmartStep(3, 1, [target(1, "str(i)"), target(2, "parse(str(i))")])).toBe(false);
|
||||
expect(useOverlays.getState().smartStep).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from "./state";
|
||||
import { focusedDebugView } from "./ui/focusedView";
|
||||
import { expressionAt } from "./editor/valueTooltip";
|
||||
import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep";
|
||||
import {
|
||||
openBreakpointsDialog,
|
||||
openConfigChooser,
|
||||
@@ -79,7 +80,10 @@ function selectedFrame(s: DebugSession) {
|
||||
return s.frames.find((f) => f.id === s.selectedFrame) ?? s.frames[0] ?? null;
|
||||
}
|
||||
|
||||
/** Smart Step Into: the calls on the current line; one call (or none) steps straight in, like IDEA. */
|
||||
/**
|
||||
* Smart Step Into: the calls on the current line, picked in the editor (the
|
||||
* dialog when they cannot be placed); one call (or none) steps straight in, like IDEA.
|
||||
*/
|
||||
async function smartStepInto() {
|
||||
const t = pausedTarget();
|
||||
if (!t) return;
|
||||
@@ -88,7 +92,7 @@ async function smartStepInto() {
|
||||
await call(commands.debugStep(t.s.id, t.thread, "into"));
|
||||
return;
|
||||
}
|
||||
const targets = await call(commands.debugStepInTargets(t.s.id, frame.id));
|
||||
const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id)));
|
||||
if (targets.length <= 1) {
|
||||
await call(
|
||||
targets[0]
|
||||
@@ -97,7 +101,7 @@ async function smartStepInto() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
openSmartStepChooser(t.s.id, t.thread, targets);
|
||||
if (!startInlineSmartStep(t.s.id, t.thread, targets)) openSmartStepChooser(t.s.id, t.thread, targets);
|
||||
}
|
||||
|
||||
/** Opens the Attach to Process popup and loads the process list. */
|
||||
|
||||
@@ -219,6 +219,20 @@ export function breakpointGutter(ctx: EditorDocContext): Extension {
|
||||
}
|
||||
if (!canPlaceBreakpoint(ctx.language) && !bp) return;
|
||||
e.preventDefault();
|
||||
// Acts on release over the same line: a drag across the gutter selects lines instead.
|
||||
const up = (u: MouseEvent) => {
|
||||
window.removeEventListener("mouseup", up, true);
|
||||
if (u.button === 0 && lineAtEvent(this.view, u) === line) this.onClick(e, line, special, bp);
|
||||
};
|
||||
window.addEventListener("mouseup", up, true);
|
||||
};
|
||||
|
||||
onClick = (
|
||||
e: MouseEvent,
|
||||
line: number,
|
||||
special: ReturnType<typeof specialAt>[number] | undefined,
|
||||
bp: ReturnType<typeof breakpointsOf>[number] | undefined,
|
||||
) => {
|
||||
if (special || bp) {
|
||||
if (e.shiftKey || e.altKey) return;
|
||||
if (special) removeSpecial(special.id);
|
||||
|
||||
@@ -172,3 +172,15 @@
|
||||
.valueType {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
/* Smart Step Into: calls on the paused line framed, the selected one filled. */
|
||||
.stepTarget {
|
||||
border-radius: 2px;
|
||||
box-shadow: inset 0 0 0 1px var(--border-strong);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepTargetSelected {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Smart Step Into in the editor, as in IDEA: the calls on the current line are
|
||||
* highlighted in place; ←/→ (or Tab / ⇧Tab) move between them, Enter or a
|
||||
* click steps into the chosen one, Esc cancels. Hovering a call selects it.
|
||||
* Falls back to the chooser dialog when the calls cannot be placed on the line.
|
||||
*/
|
||||
import { RangeSetBuilder, type Extension } from "@codemirror/state";
|
||||
import { Decoration, EditorView, ViewPlugin, type DecorationSet } from "@codemirror/view";
|
||||
import { editorsFor, activeEditor, type EditorDocContext, type TextEditorHandle } from "@/editor/api";
|
||||
import { call, commands, type DebugSessionId, type StepInTarget } from "@/platform/ipc";
|
||||
import { suspendKeyboardDispatch } from "@/platform/keymap";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { useExecutionPoint } from "../state";
|
||||
import { useOverlays, type InlineStepTarget } from "../ui/popoverStore";
|
||||
import styles from "./editor.module.css";
|
||||
|
||||
const IDENT = /[\p{L}\p{N}_$]/u;
|
||||
|
||||
/** The callee name of a target label: `a.b.parse(str(i))` → `parse`. */
|
||||
export function calleeName(label: string): string | null {
|
||||
const head = label.split("(")[0]!.trim();
|
||||
const name = head.split(/[.:#\s]+/).pop() ?? "";
|
||||
return name && [...name].every((c) => IDENT.test(c)) ? name : null;
|
||||
}
|
||||
|
||||
/** A call of `name`: `name(` not preceded by an identifier character. */
|
||||
function callPattern(name: string, flags: string): RegExp {
|
||||
return new RegExp(`(?<![\\p{L}\\p{N}_$])${name.replace(/\$/g, "\\$")}\\s*\\(`, flags);
|
||||
}
|
||||
|
||||
function identifierEnd(text: string, from: number): number {
|
||||
let i = from;
|
||||
while (i < text.length && IDENT.test(text[i]!)) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places each target on `lineText` (UTF-16 offsets within the line): the
|
||||
* adapter's columns when it reports them for this line, else the next unused
|
||||
* call of the target's name. Null when any target cannot be placed. Sorted by position.
|
||||
*/
|
||||
export function locateStepTargets(lineText: string, line: number, targets: StepInTarget[]): InlineStepTarget[] | null {
|
||||
const used = new Set<number>();
|
||||
const out: InlineStepTarget[] = [];
|
||||
for (const t of targets) {
|
||||
let from = -1;
|
||||
let to = -1;
|
||||
if (t.column != null && (t.line == null || t.line === line) && t.column < lineText.length) {
|
||||
from = t.column;
|
||||
to = t.endColumn != null && t.endColumn > from ? Math.min(t.endColumn, lineText.length) : identifierEnd(lineText, from);
|
||||
} else {
|
||||
const name = calleeName(t.label);
|
||||
if (!name) return null;
|
||||
for (const m of lineText.matchAll(callPattern(name, "gu"))) {
|
||||
if (used.has(m.index!)) continue;
|
||||
from = m.index!;
|
||||
to = from + name.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (from < 0 || to <= from || used.has(from)) return null;
|
||||
used.add(from);
|
||||
out.push({ id: t.id, from, to });
|
||||
}
|
||||
return out.sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Targets that belong to the paused line. Some adapters (debugpy) list every
|
||||
* call left in the function without positions; those are kept only when their
|
||||
* callee appears on the line. Unchanged when nothing would remain.
|
||||
*/
|
||||
export function targetsOnLine(lineText: string, line: number, targets: StepInTarget[]): StepInTarget[] {
|
||||
const kept = targets.filter((t) => {
|
||||
if (t.line != null) return t.line === line;
|
||||
if (t.column != null) return true;
|
||||
const name = calleeName(t.label);
|
||||
return !name || callPattern(name, "u").test(lineText);
|
||||
});
|
||||
return kept.length ? kept : targets;
|
||||
}
|
||||
|
||||
/** Targets of the paused line, when that line is open in an editor. */
|
||||
export function pausedLineTargets(targets: StepInTarget[]): StepInTarget[] {
|
||||
const { path, line } = useExecutionPoint.getState();
|
||||
const doc = path ? editorShowing(path)?.view.state.doc : null;
|
||||
return doc && line < doc.lines ? targetsOnLine(doc.line(line + 1).text, line, targets) : targets;
|
||||
}
|
||||
|
||||
/** Editor to pick in: the active one when it shows `path`, else any showing it. */
|
||||
function editorShowing(path: string): TextEditorHandle | null {
|
||||
const active = activeEditor();
|
||||
if (active?.ctx.path === path) return active;
|
||||
return editorsFor(path)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts inline picking when the paused line is open in an editor and every
|
||||
* target can be placed on it; returns false otherwise (caller shows the dialog).
|
||||
*/
|
||||
export function startInlineSmartStep(session: DebugSessionId, thread: number, targets: StepInTarget[]): boolean {
|
||||
const { path, line, top } = useExecutionPoint.getState();
|
||||
if (!path || !top) return false;
|
||||
const editor = editorShowing(path);
|
||||
const doc = editor?.view.state.doc;
|
||||
if (!editor || !doc || line >= doc.lines) return false;
|
||||
const ranges = locateStepTargets(doc.line(line + 1).text, line, targets);
|
||||
if (!ranges || ranges.length < 2) return false;
|
||||
const first = ranges.findIndex((r) => r.id === targets[0]!.id);
|
||||
useOverlays.setState({ smartStep: { session, thread, targets, inline: { path, line, ranges, index: Math.max(0, first) } } });
|
||||
editor.view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cancelSmartStep() {
|
||||
useOverlays.setState({ smartStep: null });
|
||||
}
|
||||
|
||||
function selectStepTarget(index: number) {
|
||||
const s = useOverlays.getState().smartStep;
|
||||
if (!s?.inline) return;
|
||||
const n = s.inline.ranges.length;
|
||||
useOverlays.setState({ smartStep: { ...s, inline: { ...s.inline, index: ((index % n) + n) % n } } });
|
||||
}
|
||||
|
||||
/** Steps into the target `id` (the selected one by default) and leaves picking mode. */
|
||||
export function chooseStepTarget(id?: number) {
|
||||
const s = useOverlays.getState().smartStep;
|
||||
if (!s) return;
|
||||
const target = id ?? s.inline?.ranges[s.inline.index]?.id;
|
||||
cancelSmartStep();
|
||||
if (target == null) return;
|
||||
void call(commands.debugStepInTarget(s.session, s.thread, target)).catch((e) =>
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "error",
|
||||
title: "Smart Step Into failed",
|
||||
body: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta", "CapsLock"]);
|
||||
|
||||
function targetIdOf(e: Event): number | null {
|
||||
const el = e.target instanceof Element ? e.target.closest<HTMLElement>("[data-step-target]") : null;
|
||||
return el ? Number(el.dataset.stepTarget) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* While picking: shortcuts are suspended and keys / clicks go to the picker
|
||||
* (window capture, ahead of the editor). Any other key, a click elsewhere or
|
||||
* the debugger moving on cancels.
|
||||
*/
|
||||
export function installSmartStepPicking(target: Window = window): () => void {
|
||||
let teardown: (() => void) | null = null;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const s = useOverlays.getState().smartStep?.inline;
|
||||
if (!s || MODIFIERS.has(e.key)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) selectStepTarget(s.index - 1);
|
||||
else if (e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "Tab") selectStepTarget(s.index + 1);
|
||||
else if (e.key === "Enter") chooseStepTarget();
|
||||
else cancelSmartStep();
|
||||
};
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!useOverlays.getState().smartStep?.inline) return;
|
||||
const id = targetIdOf(e);
|
||||
if (id == null) return cancelSmartStep();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
chooseStepTarget(id);
|
||||
};
|
||||
const onMouseOver = (e: MouseEvent) => {
|
||||
const s = useOverlays.getState().smartStep?.inline;
|
||||
const id = targetIdOf(e);
|
||||
if (!s || id == null) return;
|
||||
const index = s.ranges.findIndex((r) => r.id === id);
|
||||
if (index >= 0 && index !== s.index) selectStepTarget(index);
|
||||
};
|
||||
const start = () => {
|
||||
const resume = suspendKeyboardDispatch();
|
||||
target.addEventListener("keydown", onKeyDown, true);
|
||||
target.addEventListener("mousedown", onMouseDown, true);
|
||||
target.addEventListener("mouseover", onMouseOver, true);
|
||||
const unsubExec = useExecutionPoint.subscribe((p, prev) => {
|
||||
if (p.path !== prev.path || p.line !== prev.line || p.top !== prev.top) cancelSmartStep();
|
||||
});
|
||||
teardown = () => {
|
||||
resume();
|
||||
target.removeEventListener("keydown", onKeyDown, true);
|
||||
target.removeEventListener("mousedown", onMouseDown, true);
|
||||
target.removeEventListener("mouseover", onMouseOver, true);
|
||||
unsubExec();
|
||||
};
|
||||
};
|
||||
const sync = () => {
|
||||
const active = !!useOverlays.getState().smartStep?.inline;
|
||||
if (active && !teardown) start();
|
||||
else if (!active && teardown) {
|
||||
teardown();
|
||||
teardown = null;
|
||||
}
|
||||
};
|
||||
const unsub = useOverlays.subscribe(sync);
|
||||
sync();
|
||||
return () => {
|
||||
unsub();
|
||||
teardown?.();
|
||||
teardown = null;
|
||||
};
|
||||
}
|
||||
|
||||
function decorations(view: EditorView, path: string): DecorationSet {
|
||||
const inline = useOverlays.getState().smartStep?.inline;
|
||||
if (!inline || inline.path !== path) return Decoration.none;
|
||||
const doc = view.state.doc;
|
||||
if (inline.line >= doc.lines) return Decoration.none;
|
||||
const line = doc.line(inline.line + 1);
|
||||
const builder = new RangeSetBuilder<Decoration>();
|
||||
inline.ranges.forEach((r, i) => {
|
||||
if (r.to > line.length) return;
|
||||
const selected = i === inline.index;
|
||||
builder.add(
|
||||
line.from + r.from,
|
||||
line.from + r.to,
|
||||
Decoration.mark({
|
||||
class: selected ? `${styles.stepTarget} ${styles.stepTargetSelected}` : styles.stepTarget,
|
||||
attributes: { "data-step-target": String(r.id), ...(selected ? { "aria-current": "true" } : {}) },
|
||||
}),
|
||||
);
|
||||
});
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
/** Highlights the Smart Step Into targets while picking in this editor. */
|
||||
export function smartStepTargets(ctx: EditorDocContext): Extension {
|
||||
const path = ctx.path;
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
private unsub: () => void;
|
||||
private destroyed = false;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.decorations = decorations(view, path);
|
||||
this.unsub = useOverlays.subscribe((s, prev) => {
|
||||
if (s.smartStep === prev.smartStep) return;
|
||||
queueMicrotask(() => !this.destroyed && this.view.dispatch({}));
|
||||
});
|
||||
}
|
||||
update() {
|
||||
this.decorations = decorations(this.view, path);
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
this.unsub();
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { loadExceptionBreakpoints } from "./breakpoints/exceptions";
|
||||
import { loadSpecialBreakpoints } from "./breakpoints/special";
|
||||
import { breakpointGutter } from "./editor/breakpointGutter";
|
||||
import { executionPoint } from "./editor/execution";
|
||||
import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep";
|
||||
import { onHotCodeReplace } from "./hotswap";
|
||||
import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state";
|
||||
import { DebuggerOverlays } from "./ui/Overlays";
|
||||
@@ -50,8 +51,9 @@ export function activate(): () => void {
|
||||
registerEditorContribution({
|
||||
id: "debugger",
|
||||
order: 60,
|
||||
extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip()],
|
||||
extensions: (ctx) => [breakpointGutter(ctx), executionPoint(ctx), valueTooltip(), smartStepTargets(ctx)],
|
||||
}),
|
||||
installSmartStepPicking(),
|
||||
onIdeEvent("debug", (e) => {
|
||||
if (e.event.kind === "hotCodeReplace") onHotCodeReplace(e.session, e.event);
|
||||
else handleDebugEvent(e.session, e.event);
|
||||
|
||||
@@ -956,7 +956,9 @@ function AttachPopup() {
|
||||
// --- Smart Step Into -------------------------------------------------------------
|
||||
|
||||
function SmartStepChooser() {
|
||||
const state = useOverlays((s) => s.smartStep);
|
||||
const all = useOverlays((s) => s.smartStep);
|
||||
// Picked in the editor instead.
|
||||
const state = all?.inline ? null : all;
|
||||
const close = () => useOverlays.setState({ smartStep: null });
|
||||
const pick = (targetId: number) => {
|
||||
if (!state) return;
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
import { create } from "zustand";
|
||||
import type { AttachableProcess, DebugSessionId, RunConfig, StepInTarget } from "@/platform/ipc";
|
||||
|
||||
/** A Smart Step Into target placed on the paused line (UTF-16 offsets within the line). */
|
||||
export interface InlineStepTarget {
|
||||
id: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
interface OverlayState {
|
||||
popover: { path: string; id: string; x: number; y: number; focusLog: boolean } | null;
|
||||
evaluate: { expression: string } | null;
|
||||
@@ -9,8 +16,16 @@ interface OverlayState {
|
||||
chooser: { configs: RunConfig[] } | null;
|
||||
/** Attach to Process popup; `processes` is null while loading. */
|
||||
attach: { processes: AttachableProcess[] | null; error: string | null } | null;
|
||||
/** Smart Step Into chooser. */
|
||||
smartStep: { session: DebugSessionId; thread: number; targets: StepInTarget[] } | null;
|
||||
/**
|
||||
* Smart Step Into: picked in the editor when `inline` is set (targets sorted
|
||||
* by position, `index` the selected one), else in the chooser dialog.
|
||||
*/
|
||||
smartStep: {
|
||||
session: DebugSessionId;
|
||||
thread: number;
|
||||
targets: StepInTarget[];
|
||||
inline?: { path: string; line: number; ranges: InlineStepTarget[]; index: number };
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const useOverlays = create<OverlayState>(() => ({
|
||||
|
||||
@@ -19,9 +19,9 @@ export function ProjectHeaderActions() {
|
||||
<>
|
||||
<IconButton
|
||||
icon="locate"
|
||||
action="navigate.selectIn"
|
||||
action="navigate.selectInProjectView"
|
||||
title="Select Opened File"
|
||||
shortcut={shortcutLabel("navigate.selectIn")}
|
||||
shortcut={shortcutLabel("navigate.selectInProjectView")}
|
||||
size="small"
|
||||
/>
|
||||
<IconButton icon="expand-all" action="project.expandAll" title="Expand All" size="small" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Actions of the project view (Find Action, keymap, other features via `executeAction`). */
|
||||
import { registerActions, type Action } from "@/platform/actions";
|
||||
import { maybeEditorService, toolWindowService } from "@/workbench/contributions";
|
||||
import { activeEditor } from "@/editor/api";
|
||||
import { getAction, registerActions, type Action } from "@/platform/actions";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import { maybeEditorService, menuGroupItems, toolWindowService } from "@/workbench/contributions";
|
||||
import { OPTION_LABELS } from "./HeaderActions";
|
||||
import { revealLabel } from "./contextMenu";
|
||||
import type { ProjectNode } from "./model";
|
||||
@@ -77,6 +79,34 @@ export function selectInProjectView(path?: string | null, focus = true) {
|
||||
return revealPath(target, focus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select In (⌥F1): a chooser of the places that can show `path` (default: the
|
||||
* active editor's file): the project view, contributed targets (`selectIn`
|
||||
* menu groups), the navigation bar, Finder / Explorer and the terminal.
|
||||
*/
|
||||
export function selectInTargets(path: string): MenuItem[] {
|
||||
return [
|
||||
{ kind: "header", label: "Select In" },
|
||||
{ label: "Project View", icon: "toolwindow-project", run: () => void selectInProjectView(path) },
|
||||
...menuGroupItems("selectIn", { paths: [path] }),
|
||||
...(getAction("navigate.navigationBar") && maybeEditorService()?.activePath() === path
|
||||
? [{ kind: "action" as const, action: "navigate.navigationBar", label: "Navigation Bar" }]
|
||||
: []),
|
||||
{ label: revealLabel(), icon: "external-link", run: () => void revealInFinder(nodesFrom(path)) },
|
||||
...(getAction("terminal.openAt")
|
||||
? [{ label: "Terminal", icon: "terminal", run: () => void delegate("terminal.openAt", parentOf(path)) }]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function showSelectIn(path: string) {
|
||||
// At the caret, like IDEA's popup; centred near the top otherwise.
|
||||
const view = activeEditor()?.view;
|
||||
const coords = view && activeEditor()?.ctx.path === path ? view.coordsAtPos(view.state.selection.main.head) : null;
|
||||
const at = coords ? { x: coords.left, y: coords.bottom + 2 } : { x: window.innerWidth / 2 - 100, y: 80 };
|
||||
showContextMenu(at, selectInTargets(path));
|
||||
}
|
||||
|
||||
export function registerProjectActions(): () => void {
|
||||
const copyPath = (kind: PathKind, id: string, title: string): Action => ({
|
||||
id,
|
||||
@@ -97,16 +127,27 @@ export function registerProjectActions(): () => void {
|
||||
|
||||
return registerActions([
|
||||
{
|
||||
// A path argument (links, breadcrumbs, Open In › Project View) selects it in the project view.
|
||||
id: "navigate.selectIn",
|
||||
title: "Select In…",
|
||||
category: "Navigate",
|
||||
when: OPEN,
|
||||
keywords: ["locate", "reveal", "show in"],
|
||||
run: ({ arg }) => {
|
||||
const path = typeof arg === "string" ? arg : (arg as { path?: string } | undefined)?.path;
|
||||
if (path) return void selectInProjectView(path);
|
||||
const target = maybeEditorService()?.activePath() ?? leadNode()?.path;
|
||||
if (target) showSelectIn(target);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "navigate.selectInProjectView",
|
||||
title: "Select in Project View",
|
||||
category: "Navigate",
|
||||
icon: "locate",
|
||||
when: OPEN,
|
||||
keywords: ["locate", "reveal", "scroll from source"],
|
||||
run: ({ arg }) =>
|
||||
void selectInProjectView(
|
||||
typeof arg === "string" ? arg : ((arg as { path?: string } | undefined)?.path ?? null),
|
||||
),
|
||||
keywords: ["locate", "reveal", "scroll from source", "select opened file"],
|
||||
run: ({ arg }) => void selectInProjectView(typeof arg === "string" ? arg : null),
|
||||
},
|
||||
{
|
||||
id: "project.reveal",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { executeAction, getAction, registerActions } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { resolveMenuItems, useContextMenuStore, type MenuItem } from "@/ui";
|
||||
import { provideEditorService, registerMenuGroup, type EditorService } from "@/workbench/contributions";
|
||||
import { registerProjectActions, selectInTargets } from "./actions";
|
||||
import { revealLabel } from "./contextMenu";
|
||||
|
||||
const labels = (items: MenuItem[]) => resolveMenuItems(items).map((r) => (r.kind === "separator" ? "-" : r.label));
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
disposers.splice(0).forEach((d) => d());
|
||||
useContextMenuStore.setState({ menu: null });
|
||||
});
|
||||
|
||||
describe("Select In (⌥F1)", () => {
|
||||
it("lists the project view, contributed targets, Finder and the terminal", () => {
|
||||
disposers.push(
|
||||
registerMenuGroup({ id: "test.selectIn", menus: ["selectIn"], items: () => [{ label: "Structure", run: () => {} }] }),
|
||||
registerActions([{ id: "terminal.openAt", title: "Open Terminal At", run: () => {} }]),
|
||||
);
|
||||
expect(labels(selectInTargets("/p/src/a.ts"))).toEqual([
|
||||
"Select In",
|
||||
"Project View",
|
||||
"Structure",
|
||||
"-",
|
||||
revealLabel(),
|
||||
"Terminal",
|
||||
]);
|
||||
});
|
||||
|
||||
it("without a path shows the chooser for the active file; with one selects it in the project view", async () => {
|
||||
disposers.push(registerProjectActions(), () => setContext("projectOpen", false));
|
||||
setContext("projectOpen", true);
|
||||
provideEditorService({ activePath: () => "/p/src/a.ts" } as unknown as EditorService);
|
||||
expect(getAction("navigate.selectInProjectView")?.title).toBe("Select in Project View");
|
||||
await executeAction("navigate.selectIn");
|
||||
expect(useContextMenuStore.getState().menu?.items[1]).toMatchObject({ label: "Project View" });
|
||||
useContextMenuStore.setState({ menu: null });
|
||||
await executeAction("navigate.selectIn", { path: "/p/src/a.ts" });
|
||||
expect(useContextMenuStore.getState().menu).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,12 @@ import { runDirectory } from "./navigate";
|
||||
import {
|
||||
acceptsInput,
|
||||
clearConsole,
|
||||
closeSession,
|
||||
closeSessionTab,
|
||||
closeStdin,
|
||||
failedTests,
|
||||
rerunFailed,
|
||||
rerunSession,
|
||||
runSelectedTests,
|
||||
runtime,
|
||||
sendStdin,
|
||||
setPendingInput,
|
||||
@@ -152,6 +153,7 @@ function SessionPanel({ session }: { session: RunSession }) {
|
||||
running={running}
|
||||
selected={selectedTest}
|
||||
onSelect={setSelectedTest}
|
||||
onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)}
|
||||
/>
|
||||
</SplitPanel>
|
||||
<SplitHandle />
|
||||
@@ -215,7 +217,7 @@ export function RunTabs() {
|
||||
items={items}
|
||||
activeId={"session" in current ? current.session.id : current.trace.id}
|
||||
onSelect={(id) => useRun.setState({ activeSession: id })}
|
||||
onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : closeSession(id))}
|
||||
onClose={(id) => (traces.some((t) => t.id === id) ? closeStackTrace(id) : void closeSessionTab(id))}
|
||||
panelIdPrefix="openidea-run"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ContextMenuHost } from "@/ui";
|
||||
import { TestResults } from "./testing";
|
||||
import { TestTreeView } from "./TestTreeView";
|
||||
|
||||
const nav = vi.hoisted(() => ({ openTarget: vi.fn(async () => {}) }));
|
||||
vi.mock("./navigate", () => nav);
|
||||
const actions = vi.hoisted(() => ({ executeAction: vi.fn(async () => {}) }));
|
||||
vi.mock("@/platform/actions", async (orig) => ({ ...(await orig<object>()), executeAction: actions.executeAction }));
|
||||
|
||||
function results() {
|
||||
const r = new TestResults();
|
||||
r.apply([
|
||||
{ type: "result", path: ["MathTest", "adds()"], status: "passed" },
|
||||
{ type: "result", path: ["MathTest", "subs()"], status: "failed" },
|
||||
{
|
||||
type: "output",
|
||||
path: ["MathTest", "subs()"],
|
||||
line: "org.opentest4j.AssertionFailedError: expected: <1> but was: <2>",
|
||||
},
|
||||
{ type: "location", path: ["MathTest", "subs()"], location: { path: "MathTest.java", line: 9, resolve: "name" } },
|
||||
]);
|
||||
r.finish(1);
|
||||
return r;
|
||||
}
|
||||
|
||||
function Host(props: { onRunTests?: (ids: string[], debug: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<TestTreeView
|
||||
results={results()}
|
||||
version={1}
|
||||
cwd="/p"
|
||||
selected={null}
|
||||
onSelect={() => {}}
|
||||
running={false}
|
||||
{...props}
|
||||
/>
|
||||
<ContextMenuHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TestTreeView", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("hides passed tests by default and remembers the toggle", () => {
|
||||
const { unmount } = render(<Host />);
|
||||
expect(screen.queryByText("adds()")).toBeNull();
|
||||
expect(screen.getByText("subs()")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show Passed" }));
|
||||
expect(screen.getByText("adds()")).toBeTruthy();
|
||||
unmount();
|
||||
render(<Host />);
|
||||
expect(screen.getByText("adds()")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a click selects without navigating; double-click jumps to the source", () => {
|
||||
render(<Host />);
|
||||
fireEvent.mouseDown(screen.getByText("subs()"));
|
||||
fireEvent.click(screen.getByText("subs()"));
|
||||
expect(nav.openTarget).not.toHaveBeenCalled();
|
||||
fireEvent.doubleClick(screen.getByText("subs()"));
|
||||
expect(nav.openTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "MathTest.java", line: 9 }),
|
||||
"/p",
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("context menu: Run / Debug the test and Show Diff of an assertion failure", () => {
|
||||
const onRunTests = vi.fn();
|
||||
render(<Host onRunTests={onRunTests} />);
|
||||
fireEvent.contextMenu(screen.getByText("subs()"));
|
||||
fireEvent.click(screen.getByText("Debug 'subs()'"));
|
||||
expect(onRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"], true);
|
||||
fireEvent.contextMenu(screen.getByText("subs()"));
|
||||
fireEvent.click(screen.getByText("Show Diff"));
|
||||
expect(actions.executeAction).toHaveBeenCalledWith(
|
||||
"diff.compareTexts",
|
||||
expect.objectContaining({ left: { text: "1", label: "Expected" }, right: { text: "2", label: "Actual" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,51 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useState, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { Icon, IconButton, Spinner, Tree } from "@/ui";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { Icon, IconButton, showContextMenu, Spinner, Tree, type MenuItem } from "@/ui";
|
||||
import { openTarget } from "./navigate";
|
||||
import { nodeStatus, type TestNode, type TestResults, type TestStatus } from "./testing";
|
||||
import { parseComparison } from "./testing/comparison";
|
||||
import styles from "./Run.module.css";
|
||||
|
||||
/** Test tree filters, kept across runs and restarts (IDEA hides passed tests by default). */
|
||||
const FILTERS_KEY = "openidea.run.testFilters";
|
||||
|
||||
interface TestFilters {
|
||||
showPassed: boolean;
|
||||
showIgnored: boolean;
|
||||
}
|
||||
|
||||
export function loadTestFilters(): TestFilters {
|
||||
try {
|
||||
const raw = globalThis.localStorage?.getItem(FILTERS_KEY);
|
||||
const v = raw ? (JSON.parse(raw) as Partial<TestFilters>) : {};
|
||||
return { showPassed: v.showPassed === true, showIgnored: v.showIgnored !== false };
|
||||
} catch {
|
||||
return { showPassed: false, showIgnored: true };
|
||||
}
|
||||
}
|
||||
|
||||
function saveTestFilters(f: TestFilters) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(FILTERS_KEY, JSON.stringify(f));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/** "Show Diff" for an assertion failure: Expected on the left, Actual on the right. */
|
||||
export function showTestDiff(node: TestNode): boolean {
|
||||
const c = parseComparison(node.output);
|
||||
if (!c) return false;
|
||||
void executeAction("diff.compareTexts", {
|
||||
path: `${node.path.join("/")}.txt`,
|
||||
title: `Comparison Failure: ${node.name}`,
|
||||
left: { text: c.expected, label: c.labels?.expected ?? "Expected" },
|
||||
right: { text: c.actual, label: c.labels?.actual ?? "Actual" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number | undefined): string {
|
||||
if (ms === undefined) return "";
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
@@ -62,12 +103,50 @@ export interface TestTreeViewProps {
|
||||
selected: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
running: boolean;
|
||||
/** Run / Debug the selected tests (context menu); absent when the configuration can't be narrowed. */
|
||||
onRunTests?: (ids: string[], debug: boolean) => void;
|
||||
}
|
||||
|
||||
export function TestTreeView({ results, version, cwd, selected, onSelect, running }: TestTreeViewProps) {
|
||||
const [showPassed, setShowPassed] = useState(true);
|
||||
const [showIgnored, setShowIgnored] = useState(true);
|
||||
export function TestTreeView({ results, version, cwd, selected, onSelect, running, onRunTests }: TestTreeViewProps) {
|
||||
const [filters, setFilters] = useState(loadTestFilters);
|
||||
const { showPassed, showIgnored } = filters;
|
||||
const toggle = (key: keyof TestFilters) =>
|
||||
setFilters((f) => {
|
||||
const next = { ...f, [key]: !f[key] };
|
||||
saveTestFilters(next);
|
||||
return next;
|
||||
});
|
||||
const [expanded, setExpanded] = useState<Set<string> | null>(null);
|
||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||
const selectedKeys = useMemo(
|
||||
() => (selected === null ? new Set<string>() : selection.has(selected) ? selection : new Set([selected])),
|
||||
[selected, selection],
|
||||
);
|
||||
|
||||
const jumpToSource = (n: TestNode) => {
|
||||
const target = n.location ?? firstLocated(n)?.location;
|
||||
if (target) void openTarget(target, cwd, true);
|
||||
};
|
||||
|
||||
const contextMenu = (node: TestNode | null, sel: Set<string>, e: MouseEvent) => {
|
||||
if (!node) return;
|
||||
const ids = sel.has(node.id) ? [...sel] : [node.id];
|
||||
const name = ids.length === 1 ? (results.get(ids[0]!)?.name ?? node.name) : `${ids.length} tests`;
|
||||
const located = node.location ?? firstLocated(node)?.location;
|
||||
const items: MenuItem[] = [];
|
||||
if (onRunTests) {
|
||||
items.push(
|
||||
{ label: `Run '${name}'`, verbatim: true, icon: "run", run: () => onRunTests(ids, false) },
|
||||
{ label: `Debug '${name}'`, verbatim: true, icon: "debug", run: () => onRunTests(ids, true) },
|
||||
{ kind: "separator" },
|
||||
);
|
||||
}
|
||||
if (ids.length === 1 && nodeStatus(node) === "failed" && parseComparison(node.output))
|
||||
items.push({ label: "Show Diff", icon: "diff", run: () => void showTestDiff(node) });
|
||||
items.push({ label: "Jump to Source", icon: "external-link", shortcut: "F4", disabled: !located, run: () => jumpToSource(node) });
|
||||
e.preventDefault();
|
||||
showContextMenu(e, items);
|
||||
};
|
||||
|
||||
const counts = useMemo(() => results.counts(), [results, version]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const roots = useMemo(() => {
|
||||
@@ -112,7 +191,7 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin
|
||||
iconSize={13}
|
||||
title="Show Passed"
|
||||
pressed={showPassed}
|
||||
onClick={() => setShowPassed((v) => !v)}
|
||||
onClick={() => toggle("showPassed")}
|
||||
/>
|
||||
<IconButton
|
||||
icon="minus"
|
||||
@@ -120,7 +199,7 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin
|
||||
iconSize={13}
|
||||
title="Show Ignored"
|
||||
pressed={showIgnored}
|
||||
onClick={() => setShowIgnored((v) => !v)}
|
||||
onClick={() => toggle("showIgnored")}
|
||||
/>
|
||||
</div>
|
||||
<Tree<TestNode>
|
||||
@@ -133,16 +212,15 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin
|
||||
isExpandable={(n) => n.children.length > 0}
|
||||
expanded={effectiveExpanded}
|
||||
onExpandedChange={(next) => setExpanded(next)}
|
||||
selected={selected ? new Set([selected]) : new Set()}
|
||||
onSelectionChange={(_sel, lead) => {
|
||||
selected={selectedKeys}
|
||||
// A click only selects (shows the test's output); double-click / Enter / F4 jump to the source, as in IDEA.
|
||||
onSelectionChange={(sel, lead) => {
|
||||
setSelection(sel);
|
||||
onSelect(lead?.id ?? null);
|
||||
if (lead && lead.location && nodeStatus(lead) === "failed") void openTarget(lead.location, cwd, false);
|
||||
}}
|
||||
activateExpandable
|
||||
onActivate={(n) => {
|
||||
const target = n.location ?? firstLocated(n)?.location;
|
||||
if (target) void openTarget(target, cwd, true);
|
||||
}}
|
||||
onActivate={jumpToSource}
|
||||
onContextMenu={contextMenu}
|
||||
renderNode={(n) => {
|
||||
const s = nodeStatus(n);
|
||||
const d = totalDuration(n);
|
||||
@@ -154,7 +232,11 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
empty={<div className={styles.testEmpty}>{running ? "Waiting for test results…" : "No test results"}</div>}
|
||||
empty={
|
||||
<div className={styles.testEmpty}>
|
||||
{running ? "Waiting for test results…" : results.isEmpty ? "No test results" : "Passed tests are hidden"}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -39,17 +39,21 @@ function testKinds(relPath: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Config whose working directory most specifically contains `relPath`. */
|
||||
function closest(configs: RunConfig[], relPath: string): RunConfig | undefined {
|
||||
/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */
|
||||
function allClosest(configs: RunConfig[], relPath: string): RunConfig[] {
|
||||
const score = (c: RunConfig) => {
|
||||
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
|
||||
if (!cwd) return 0;
|
||||
return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1;
|
||||
};
|
||||
return configs
|
||||
.map((c) => [c, score(c)] as const)
|
||||
.filter(([, s]) => s >= 0)
|
||||
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
const scored = configs.map((c) => [c, score(c)] as const).filter(([, s]) => s >= 0);
|
||||
const best = Math.max(...scored.map(([, s]) => s));
|
||||
return scored.filter(([, s]) => s === best).map(([c]) => c);
|
||||
}
|
||||
|
||||
/** Config whose working directory most specifically contains `relPath`. */
|
||||
function closest(configs: RunConfig[], relPath: string): RunConfig | undefined {
|
||||
return allClosest(configs, relPath)[0];
|
||||
}
|
||||
|
||||
function relToCwd(relPath: string, config: RunConfig): string {
|
||||
@@ -111,11 +115,25 @@ function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null
|
||||
};
|
||||
}
|
||||
|
||||
/** File-level test configuration for `relPath` (the closest matching test config, narrowed to the file). */
|
||||
export function testConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null {
|
||||
/**
|
||||
* Test configurations that can run `relPath` equally well (same, closest working
|
||||
* directory: `npm run test` and `npm run test:unit`…); the default one first.
|
||||
*/
|
||||
export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] {
|
||||
const kinds = testKinds(relPath);
|
||||
const candidates = configs.filter((c) => c.isTest && kinds.includes(c.kind));
|
||||
const base = closest(candidates, relPath) ?? candidates[0] ?? implicitPytest(relPath, configs);
|
||||
const best = allClosest(candidates, relPath);
|
||||
if (best.length) return best;
|
||||
const fallback = candidates[0] ?? implicitPytest(relPath, configs);
|
||||
return fallback ? [fallback] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* File-level test configuration for `relPath` (the closest matching test config,
|
||||
* or `base`, narrowed to the file).
|
||||
*/
|
||||
export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunConfig): RunConfig | null {
|
||||
base ??= testBasesFor(relPath, configs)[0];
|
||||
if (!base) return null;
|
||||
const file = relToCwd(relPath, base);
|
||||
switch (base.kind) {
|
||||
|
||||
@@ -2,8 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { menuGroupItems, registerMenuGroup } from "@/workbench/contributions";
|
||||
import { contextMenuItems } from "./contextRun";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { choiceItems, contextMenuItems } from "./contextRun";
|
||||
import { OPTIMISTIC, produceContextChoices } from "./producers";
|
||||
import { gutterTestState, markerIcon, targetMenuItems } from "./runGutter";
|
||||
import { useRun } from "./store";
|
||||
import type { RunTarget } from "./targets";
|
||||
import { TestResults } from "./testing";
|
||||
import { clearTestStates, recordTestStates } from "./testing/states";
|
||||
|
||||
const ROOT = "/work/app";
|
||||
const SCRATCHES = "/Users/me/scratches";
|
||||
@@ -131,3 +137,53 @@ describe("debug entry", () => {
|
||||
expect(labels(rs)[1]).toBe("run.debugContext|Debug 'a.rs'|debug");
|
||||
});
|
||||
});
|
||||
|
||||
describe("run gutter popup", () => {
|
||||
const unit: RunConfig = { ...npmTest, id: "npm:test:unit", name: "npm run test:unit", args: ["run", "test:unit"] };
|
||||
const text = "describe('math', () => {\n it('adds', () => {\n });\n});\n";
|
||||
const adds: RunTarget = { line: 1, kind: "test", name: "math > adds", path: ["math", "adds"] };
|
||||
const itemLabels = (items: MenuItem[]): string[] =>
|
||||
items.map((i) => (i.kind === "separator" ? "---" : "label" in i ? `${i.label}${"submenu" in i && i.submenu ? " ▸" : ""}` : ""));
|
||||
|
||||
beforeEach(() => {
|
||||
useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never });
|
||||
useRun.setState({ configs: [npmTest], loaded: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
useProject.setState({ project: null });
|
||||
useRun.setState({ configs: [], loaded: false });
|
||||
clearTestStates();
|
||||
});
|
||||
|
||||
it("ends with Modify Run Configuration… after the executors", () => {
|
||||
const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text);
|
||||
expect(itemLabels(items)).toEqual(["Run 'math > adds'", "Debug 'math > adds'", "---", "Modify Run Configuration…"]);
|
||||
});
|
||||
|
||||
it("lists each matching test configuration when several match", () => {
|
||||
useRun.setState({ configs: [npmTest, unit] });
|
||||
const items = targetMenuItems(adds, `${ROOT}/src/m.test.ts`, text);
|
||||
expect(itemLabels(items).slice(0, 2)).toEqual(["Run 'math > adds' ▸", "Debug 'math > adds' ▸"]);
|
||||
const run = items[0] as Extract<MenuItem, { submenu?: MenuItem[] }>;
|
||||
expect(run.submenu!.map((i) => ("shortcut" in i ? i.shortcut : null))).toEqual(["test", "npm run test:unit"]);
|
||||
// ⌃⇧R's chooser lists the same, under a header.
|
||||
expect(choiceItems(produceContextChoices({ path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: [npmTest, unit], content: text, line: 1 }, OPTIMISTIC), false)[0]).toEqual({
|
||||
kind: "header",
|
||||
label: "Choose configuration to run",
|
||||
});
|
||||
});
|
||||
|
||||
it("icons follow the last test result", () => {
|
||||
const path = `${ROOT}/src/m.test.ts`;
|
||||
expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run");
|
||||
const r = new TestResults();
|
||||
r.apply([{ type: "result", path: ["src/m.test.ts", "math", "adds"], status: "failed" }]);
|
||||
recordTestStates(r);
|
||||
expect(markerIcon("test", gutterTestState(adds, path))).toBe("gutter-run-failed");
|
||||
expect(markerIcon("suite", gutterTestState({ ...adds, kind: "suite", name: "math", path: ["math"] }, path))).toBe(
|
||||
"gutter-run-failed",
|
||||
);
|
||||
// `main` targets never show results.
|
||||
expect(gutterTestState({ line: 0, kind: "main", name: "main", path: [] }, path)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { executeAction, getAction } from "@/platform/actions";
|
||||
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { showContextMenu, type MenuItem } from "@/ui";
|
||||
import {
|
||||
maybeEditorService,
|
||||
notifications,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
neededTools,
|
||||
OPTIMISTIC,
|
||||
produceContext,
|
||||
produceContextChoices,
|
||||
produceDirectory,
|
||||
produceFile,
|
||||
scriptLanguage,
|
||||
@@ -160,12 +161,43 @@ export async function runContextAction(debug: boolean, arg?: unknown): Promise<b
|
||||
const target = await withArgContent(arg);
|
||||
const input = target ?? editorInput();
|
||||
if (!input) return false;
|
||||
const p = await produceResolved(input, target?.dir ?? false);
|
||||
const dir = target?.dir ?? false;
|
||||
const choices = dir ? [] : produceContextChoices({ ...input, configs: useRun.getState().configs, goModule: goModulePath }, OPTIMISTIC);
|
||||
if (choices.length > 1) {
|
||||
// Several test configurations match equally well: like IDEA, let the user choose.
|
||||
showContextMenu(chooserPoint(target === null), choiceItems(choices, debug));
|
||||
return true;
|
||||
}
|
||||
const p = await produceResolved(input, dir);
|
||||
if (!p) return false;
|
||||
await launchProduced(p, debug);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Menu entries for each of several productions: "Run '<name>'" with the configuration it derives from. */
|
||||
export function choiceItems(choices: readonly Produced[], debug: boolean): MenuItem[] {
|
||||
return [
|
||||
{ kind: "header", label: debug ? "Choose configuration to debug" : "Choose configuration to run" },
|
||||
...choices.map(
|
||||
(p): MenuItem => ({
|
||||
label: `${debug ? "Debug" : "Run"} '${p.name}'`,
|
||||
verbatim: true,
|
||||
icon: debug ? "debug" : "run",
|
||||
shortcut: p.detail,
|
||||
run: () => void launchProduced(p, debug),
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Below the caret for the editor context, else the middle of the window. */
|
||||
function chooserPoint(fromEditor: boolean): { x: number; y: number } {
|
||||
const editor = fromEditor ? activeEditor() : null;
|
||||
const caret = editor?.view.coordsAtPos(editor.view.state.selection.main.head);
|
||||
if (caret) return { x: caret.left, y: caret.bottom + 2 };
|
||||
return { x: Math.round(window.innerWidth / 2 - 120), y: Math.round(window.innerHeight / 3) };
|
||||
}
|
||||
|
||||
/** Runs the context with a run executor (Run with Coverage, Profile…). False when there is nothing to run. */
|
||||
export async function executeContext(executor: string, arg?: unknown): Promise<boolean> {
|
||||
await ensureConfigs();
|
||||
|
||||
@@ -36,6 +36,25 @@ export async function confirmRerun(name: string, instances = 1): Promise<boolean
|
||||
return answer !== null;
|
||||
}
|
||||
|
||||
/** IDEA's question before closing a Run tab whose process still runs. Resolves true for Terminate. */
|
||||
export async function confirmTerminate(name: string): Promise<boolean> {
|
||||
const answer = await confirmChecked({
|
||||
title: `Process '${name}' Is Running`,
|
||||
message: `Do you want to terminate the process '${name}'?`,
|
||||
ok: "Terminate",
|
||||
checkbox: "Do not ask again",
|
||||
});
|
||||
if (answer?.checked && isTauri()) {
|
||||
try {
|
||||
await call(commands.settingsSet("global", "run.confirmTerminate", "false"));
|
||||
await loadSettings();
|
||||
} catch {
|
||||
/* keep asking */
|
||||
}
|
||||
}
|
||||
return answer !== null;
|
||||
}
|
||||
|
||||
/** Asks for the value of `$Prompt$`; null when cancelled. */
|
||||
export function promptValue(title: string): Promise<string | null> {
|
||||
return new Promise((resolve) => useRunDialog.setState({ pending: { title, resolve } }));
|
||||
|
||||
@@ -274,7 +274,7 @@ describe("JUnit XML reports", () => {
|
||||
expect(events).toContainEqual({
|
||||
type: "location",
|
||||
path: ["a.B$Inner", "bad"],
|
||||
location: { path: "B.java", line: 42, resolve: "name" },
|
||||
location: { path: "a/B.java", line: 42, resolve: "name", className: "a.B$Inner" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,9 +35,27 @@ describe("findOutputLinks", () => {
|
||||
expect(link).toEqual({ path: "app/main.py", line: 3, resolve: "path", text: 'File "app/main.py", line 3' });
|
||||
});
|
||||
|
||||
it("java / kotlin frames resolve by file name", () => {
|
||||
it("java / kotlin frames resolve by fully qualified class name", () => {
|
||||
const [link] = files("\tat com.acme.orders.OrderController.list(OrderController.kt:22)");
|
||||
expect(link).toEqual({ path: "OrderController.kt", line: 22, resolve: "name", text: "OrderController.kt:22" });
|
||||
expect(link).toEqual({
|
||||
path: "com/acme/orders/OrderController.kt",
|
||||
line: 22,
|
||||
resolve: "name",
|
||||
className: "com.acme.orders.OrderController",
|
||||
text: "OrderController.kt:22",
|
||||
});
|
||||
// Nested classes and lambdas live in the outer class's file; JDK module prefixes are skipped.
|
||||
expect(files("\tat com.acme.Outer$Inner.lambda$run$0(Outer.java:7)")[0]).toMatchObject({
|
||||
path: "com/acme/Outer.java",
|
||||
className: "com.acme.Outer$Inner",
|
||||
});
|
||||
expect(files("\tat java.base/java.util.ArrayList.get(ArrayList.java:427)")[0]).toMatchObject({
|
||||
path: "java/util/ArrayList.java",
|
||||
className: "java.util.ArrayList",
|
||||
});
|
||||
// The default package, and a bare `(File.java:3)` without a frame.
|
||||
expect(files("\tat Main.main(Main.java:3)")[0]).toMatchObject({ path: "Main.java", className: "Main" });
|
||||
expect(files("(Main.java:3)")[0]).toEqual({ path: "Main.java", line: 3, resolve: "name", text: "Main.java:3" });
|
||||
});
|
||||
|
||||
it("go and generic path:line", () => {
|
||||
|
||||
@@ -10,10 +10,28 @@ export interface FileTarget {
|
||||
line?: number;
|
||||
col?: number;
|
||||
/**
|
||||
* `path`: resolve against the run directory; `name`: a bare file name from a
|
||||
* JVM stack frame (`(OrderController.kt:22)`) — look it up in the project.
|
||||
* `path`: resolve against the run directory; `name`: a file name from a JVM
|
||||
* stack frame — look it up in the project (`path` then is the file under its
|
||||
* package directory, `com/acme/OrderController.kt`, when the frame names the class).
|
||||
*/
|
||||
resolve: "path" | "name";
|
||||
/** JVM frames: the class's binary name (`com.acme.OrderController$Inner`). */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target of a JVM stack frame `at com.acme.Foo.bar(Foo.kt:22)`: the file
|
||||
* under its package directory, so frames resolve by fully qualified class name
|
||||
* (as in IDEA), not by a bare file name that several packages may share.
|
||||
*/
|
||||
export function jvmFrameTarget(className: string | undefined, file: string, line: number | undefined): FileTarget {
|
||||
const pkg = className?.includes(".") ? className.slice(0, className.lastIndexOf(".")).replace(/\./g, "/") : "";
|
||||
return {
|
||||
path: pkg ? `${pkg}/${file}` : file,
|
||||
line,
|
||||
resolve: "name",
|
||||
...(className ? { className } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export type OutputLink =
|
||||
@@ -49,10 +67,11 @@ const PATTERNS: Pattern[] = [
|
||||
},
|
||||
// JVM: at com.acme.OrderController.list(OrderController.kt:22)
|
||||
{
|
||||
re: /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg,
|
||||
re: /(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy|clj)):(\d+)\)/dg,
|
||||
build: (m) => {
|
||||
const [start, end] = groupRange(m, 0);
|
||||
return { kind: "file", start: start + 1, end: end - 1, target: { path: m[1]!, line: num(m[2]), resolve: "name" } };
|
||||
const [start] = groupRange(m, 2);
|
||||
const [, end] = groupRange(m, 3);
|
||||
return { kind: "file", start, end, target: jvmFrameTarget(m[1], m[2]!, num(m[3])) };
|
||||
},
|
||||
},
|
||||
// JS / Node: at foo (src/a.ts:3:7) · at file:///abs/a.mjs:3:7 · at /abs/a.js:3:7
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { jvmFrameTarget } from "./links";
|
||||
|
||||
const files: Record<string, string> = {
|
||||
"/p/src/main/java/com/acme/Util.java": "package com.acme;\nclass Util {}",
|
||||
"/p/other/Util.java": "package org.other;\nclass Util {}",
|
||||
"/p/src/main/kotlin/Orders.kt": "package com.acme.orders\nfun main() {}",
|
||||
};
|
||||
const symbols = vi.hoisted(() => ({ list: [] as unknown[] }));
|
||||
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
isTauri: () => true,
|
||||
nextRequestId: () => 1,
|
||||
call: async (v: unknown) => v,
|
||||
commands: {
|
||||
fsReadDir: async () => [],
|
||||
fsReadText: async (path: string) => files[path] ?? "",
|
||||
findFiles: async (name: string) =>
|
||||
Object.keys(files)
|
||||
.filter((p) => p.endsWith(`/${name}`))
|
||||
.map((path) => ({ path, relPath: path.slice(3) })),
|
||||
lspWorkspaceSymbols: async () => symbols.list,
|
||||
},
|
||||
}));
|
||||
|
||||
const { resolveTarget } = await import("./navigate");
|
||||
|
||||
describe("JVM stack frame resolution", () => {
|
||||
afterEach(() => {
|
||||
symbols.list = [];
|
||||
});
|
||||
|
||||
it("picks the file of the frame's package, not any file of that name", async () => {
|
||||
expect(await resolveTarget(jvmFrameTarget("com.acme.Util", "Util.java", 3), "/p")).toBe(
|
||||
"/p/src/main/java/com/acme/Util.java",
|
||||
);
|
||||
expect(await resolveTarget(jvmFrameTarget("org.other.Util", "Util.java", 3), "/p")).toBe("/p/other/Util.java");
|
||||
});
|
||||
|
||||
it("finds Kotlin files outside their package directory by their package declaration", async () => {
|
||||
expect(await resolveTarget(jvmFrameTarget("com.acme.orders.OrdersKt", "Orders.kt", 1), "/p")).toBe(
|
||||
"/p/src/main/kotlin/Orders.kt",
|
||||
);
|
||||
});
|
||||
|
||||
it("library classes open the language server's source, else nothing", async () => {
|
||||
expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBeNull();
|
||||
symbols.list = [
|
||||
{
|
||||
name: "ArrayList",
|
||||
kind: "class",
|
||||
container: "java.util",
|
||||
location: { path: "jdt://contents/ArrayList.class", uri: "jdt://contents/ArrayList.class" },
|
||||
},
|
||||
];
|
||||
expect(await resolveTarget(jvmFrameTarget("java.util.ArrayList", "ArrayList.java", 427), "/p")).toBe(
|
||||
"jdt://contents/ArrayList.class",
|
||||
);
|
||||
// Same simple name in another package isn't it.
|
||||
expect(await resolveTarget(jvmFrameTarget("java.awt.List$X", "List.java", 1), "/p")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Opens file locations printed in run output (resolving relative and bare names). */
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { call, commands, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { joinPath, type FileTarget } from "./links";
|
||||
@@ -16,13 +16,55 @@ async function exists(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function findByName(path: string): Promise<string | null> {
|
||||
const name = path.split("/").pop() ?? path;
|
||||
const packageOf = (className: string) => (className.includes(".") ? className.slice(0, className.lastIndexOf(".")) : "");
|
||||
|
||||
/** Whether a JVM source file declares `pkg` (Kotlin files needn't sit in their package's directory). */
|
||||
async function declaresPackage(path: string, pkg: string): Promise<boolean> {
|
||||
try {
|
||||
const hits = await call(commands.findFiles(name, 5));
|
||||
const tail = path.replace(/^\.?\//, "");
|
||||
const best = hits.find((h) => h.relPath.endsWith(tail)) ?? hits.find((h) => h.relPath.split("/").pop() === name);
|
||||
return best?.path ?? null;
|
||||
const text = (await call(commands.fsReadText(path))).slice(0, 20_000);
|
||||
const declared = /^\s*package\s+([\w.`]+)/m.exec(text)?.[1]?.replace(/`/g, "") ?? "";
|
||||
return declared === pkg;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function findByName(target: FileTarget): Promise<string | null> {
|
||||
const name = target.path.split("/").pop() ?? target.path;
|
||||
try {
|
||||
const hits = await call(commands.findFiles(name, 50));
|
||||
const tail = target.path.replace(/^\.?\//, "");
|
||||
const exact = hits.find((h) => h.relPath === tail || h.relPath.endsWith(`/${tail}`));
|
||||
if (exact) return exact.path;
|
||||
const named = hits.filter((h) => h.relPath.split("/").pop() === name);
|
||||
// A stack frame names its class: a same-named file of another package is not it.
|
||||
if (target.className) {
|
||||
const pkg = packageOf(target.className);
|
||||
for (const h of named) if (await declaresPackage(h.path, pkg)) return h.path;
|
||||
return null;
|
||||
}
|
||||
return named[0]?.path ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_KINDS = new Set(["class", "interface", "enum", "struct", "trait"]);
|
||||
|
||||
/**
|
||||
* Library / JDK classes: the source the language server has for them (attached
|
||||
* sources or decompiled), opened as a read-only virtual document.
|
||||
*/
|
||||
async function findLibraryClass(className: string): Promise<string | null> {
|
||||
const outer = className.split("$")[0]!;
|
||||
const simple = outer.slice(outer.lastIndexOf(".") + 1);
|
||||
const pkg = packageOf(outer);
|
||||
try {
|
||||
const symbols = await call(commands.lspWorkspaceSymbols(nextRequestId(), simple));
|
||||
const hit = symbols.find(
|
||||
(s) => s.name === simple && TYPE_KINDS.has(s.kind) && (s.container ?? "") === pkg,
|
||||
);
|
||||
return hit ? (hit.location.uri ?? hit.location.path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -45,12 +87,23 @@ export async function resolveTarget(target: FileTarget, cwd: string): Promise<st
|
||||
if (fromRoot !== candidate && (await exists(fromRoot))) return fromRoot;
|
||||
}
|
||||
}
|
||||
return findByName(target.path);
|
||||
return (await findByName(target)) ?? (target.className ? findLibraryClass(target.className) : null);
|
||||
}
|
||||
|
||||
export async function openTarget(target: FileTarget, cwd: string, focus = true): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const path = await resolveTarget(target, cwd);
|
||||
if (!path && target.className) {
|
||||
// A library or JDK frame without sources: nothing to open, which is no error.
|
||||
notifications().notify({
|
||||
group: "run",
|
||||
level: "info",
|
||||
title: "No sources for this class",
|
||||
body: `${target.className.split("$")[0]} is not part of the project, and no library sources are available for it.`,
|
||||
key: "run-link",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!path) {
|
||||
notifications().notify({ group: "run", level: "warning", title: "File not found", body: target.path, key: "run-link" });
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
nodeTypeStripping,
|
||||
OPTIMISTIC,
|
||||
produceContext,
|
||||
produceContextChoices,
|
||||
produceDirectory,
|
||||
produceFile,
|
||||
shebang,
|
||||
@@ -277,6 +278,24 @@ describe("project producers", () => {
|
||||
expect(at(7)).toMatchObject({ name: "m.test.ts", scope: "file" });
|
||||
});
|
||||
|
||||
it("several equally close test configurations: one choice each, the default first", () => {
|
||||
const withUnit = [
|
||||
...configs,
|
||||
cfg({ id: "npm:test:unit", name: "npm run test:unit", kind: "npm", command: "npm", args: ["run", "test:unit"], isTest: true }),
|
||||
];
|
||||
const src = "describe('math', () => {\n it('adds', () => {\n });\n});\n";
|
||||
const input = { path: `${ROOT}/src/m.test.ts`, root: ROOT, configs: withUnit, content: src, line: 1 };
|
||||
const choices = produceContextChoices(input, ALL);
|
||||
expect(choices.map((c) => [c.name, c.detail, c.scope])).toEqual([
|
||||
["math > adds", "npm:test", "element"],
|
||||
["math > adds", "npm run test:unit", "element"],
|
||||
]);
|
||||
expect(choices[1]!.config!.args.slice(0, 2)).toEqual(["run", "test:unit"]);
|
||||
// The file context offers the same choice; a single matching configuration needs none.
|
||||
expect(produceContextChoices({ ...input, line: undefined }, ALL).map((c) => c.detail)).toEqual(["npm:test", "npm run test:unit"]);
|
||||
expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("python: test function > test class > file; __main__ runs the file", () => {
|
||||
const src = ["class TestMath:", " def test_add(self):", " assert 1", "", " x = 1", "", "def helper():", " pass"].join("\n");
|
||||
const at = (line: number) =>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isTestPath } from "@/platform/languageSettings";
|
||||
import { currentRoots } from "@/platform/projectRoots";
|
||||
import { applicationConfig, jvmModuleDir } from "./application";
|
||||
import { formatArgs } from "./configs";
|
||||
import { contextConfig, fileRole, projectPython, testConfigFor } from "./context";
|
||||
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
|
||||
import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets";
|
||||
|
||||
export interface LocatedTool {
|
||||
@@ -67,6 +67,8 @@ export interface Produced {
|
||||
missing?: Missing;
|
||||
/** Compiled single files: build `build`, then debug `config` (the binary). */
|
||||
debug?: { build: RunConfig; config: RunConfig };
|
||||
/** Among several choices: the configuration it derives from (`npm run test:unit`). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -757,6 +759,37 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu
|
||||
return produceFile(input, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every configuration that can run the context, when several test
|
||||
* configurations match it equally well (`npm run test` and `npm run test:unit`):
|
||||
* like IDEA, the user then chooses one. A single production otherwise.
|
||||
*/
|
||||
export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] {
|
||||
const produced = produceContext(input, env);
|
||||
if (!produced?.config || produced.scope === "directory" || !inProject(input.path, input.root)) return produced ? [produced] : [];
|
||||
const rel = relTo(input.path, input.root);
|
||||
const bases = testBasesFor(rel, [...input.configs]);
|
||||
if (bases.length < 2) return [produced];
|
||||
let element: RunTarget | null = null;
|
||||
if (produced.scope === "element") {
|
||||
const targets = fileTargets(input, (input.content ?? "").slice(0, 400_000));
|
||||
element = innermostTarget(input.content ?? "", targets, input.line ?? 0, /\.py$/.test(input.path));
|
||||
if (!element || element.kind === "main") return [produced];
|
||||
} else if (!produced.config.isTest) {
|
||||
return [produced];
|
||||
}
|
||||
const out: Produced[] = [];
|
||||
for (const base of bases) {
|
||||
const found = element
|
||||
? targetConfig(element, rel, [...input.configs], input.content, base)
|
||||
: testConfigFor(rel, [...input.configs], base);
|
||||
const config = found && goTestNamed(found, input, element?.name);
|
||||
if (!config || out.some((o) => o.config?.id === config.id)) continue;
|
||||
out.push({ name: config.name, scope: produced.scope, config, detail: base.name });
|
||||
}
|
||||
return out.length > 1 ? out : [produced];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directories (project view): "Run 'Tests in 'dir''", "Run 'All Tests'"
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import type { EditorDocContext } from "@/editor/api";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { isVirtualUri } from "@/platform/virtualDocs";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import { iconMarkup, showContextMenu } from "@/ui";
|
||||
import { iconMarkup, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { notifications, runExecutors } from "@/workbench/contributions";
|
||||
import { executeContext, launchProduced, produceResolved } from "./contextRun";
|
||||
import { choiceItems, executeContext, launchProduced, modifyContext, produceResolved } from "./contextRun";
|
||||
import { goModulePath, loadGoModules } from "./goModules";
|
||||
import { fileTargets, OPTIMISTIC, produceContext, type ContextInput } from "./producers";
|
||||
import { fileTargets, OPTIMISTIC, produceContextChoices, type ContextInput } from "./producers";
|
||||
import { loadConfigs, useRun } from "./store";
|
||||
import { targetLanguage, type RunTarget } from "./targets";
|
||||
import { onTestStatesChanged, testState, testStatesVersion, type TestState } from "./testing/states";
|
||||
|
||||
/** Languages with gutter run targets (C / C++ `main` included). */
|
||||
const hasGutterTargets = (path: string) => !!targetLanguage(path) || /\.(c|cpp|cc|cxx)$/.test(path);
|
||||
@@ -89,43 +90,56 @@ export function label(t: RunTarget): string {
|
||||
return t.name;
|
||||
}
|
||||
|
||||
function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) {
|
||||
const content = view.state.doc.toString();
|
||||
const p = produceContext(targetInput(path, content.slice(0, 400_000), target.line), OPTIMISTIC);
|
||||
export function targetMenuItems(target: RunTarget, path: string, content: string): MenuItem[] {
|
||||
const input = targetInput(path, content.slice(0, 400_000), target.line);
|
||||
const choices = produceContextChoices(input, OPTIMISTIC);
|
||||
const p = choices[0] ?? null;
|
||||
const name = p?.name ?? label(target);
|
||||
const context = { path, line: target.line, content: content.slice(0, 400_000) };
|
||||
// Several test configurations match: one entry per configuration, as IDEA's gutter does.
|
||||
const pick = (debug: boolean): Partial<MenuItem> =>
|
||||
choices.length > 1
|
||||
? { submenu: choiceItems(choices, debug).filter((i) => i.kind !== "header") }
|
||||
: { run: () => void launch(target, path, debug, content) };
|
||||
// Like IDEA, Debug only where a debugger can run it.
|
||||
const debuggable = !p || !!(p.debug || p.config?.debugAdapter || p.missing);
|
||||
showContextMenu(at, [
|
||||
{
|
||||
label: `Run '${name}'`,
|
||||
icon: "run",
|
||||
shortcut: shortcutLabel("run.runContext"),
|
||||
run: () => void launch(target, path, false, content),
|
||||
},
|
||||
return [
|
||||
{ label: `Run '${name}'`, icon: "run", shortcut: shortcutLabel("run.runContext"), ...pick(false) } as MenuItem,
|
||||
...(debuggable
|
||||
? [
|
||||
{
|
||||
label: `Debug '${name}'`,
|
||||
icon: "debug",
|
||||
shortcut: shortcutLabel("run.debugContext"),
|
||||
run: () => void launch(target, path, true, content),
|
||||
},
|
||||
]
|
||||
? [{ label: `Debug '${name}'`, icon: "debug", shortcut: shortcutLabel("run.debugContext"), ...pick(true) } as MenuItem]
|
||||
: []),
|
||||
...runExecutors()
|
||||
.filter((e) => !!p?.config && e.supports(p.config))
|
||||
.map((e) => ({
|
||||
label: e.label(name),
|
||||
icon: e.icon,
|
||||
run: () => void executeContext(e.id, { path, line: target.line, content: content.slice(0, 400_000) }),
|
||||
})),
|
||||
]);
|
||||
.map((e) => ({ label: e.label(name), icon: e.icon, run: () => void executeContext(e.id, context) })),
|
||||
{ kind: "separator" },
|
||||
{ label: "Modify Run Configuration…", icon: "edit", run: () => void modifyContext(context) },
|
||||
];
|
||||
}
|
||||
|
||||
function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: { x: number; y: number }) {
|
||||
showContextMenu(at, targetMenuItems(target, path, view.state.doc.toString()));
|
||||
}
|
||||
|
||||
/** Results of frameworks that root their test tree at the file (jest/vitest, pytest) also match by file. */
|
||||
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path);
|
||||
|
||||
/** Last test result of a gutter target (IDEA: green / red run icons after a test run). */
|
||||
export function gutterTestState(target: RunTarget, path: string): TestState | null {
|
||||
if (target.kind === "main") return null;
|
||||
return testState({ path: target.path, file: fileRootedResults(path) ? path : undefined });
|
||||
}
|
||||
|
||||
export function markerIcon(kind: RunTarget["kind"], state: TestState | null): string {
|
||||
if (state === "failed") return "gutter-run-failed";
|
||||
if (state === "passed") return "gutter-run-passed";
|
||||
return kind === "suite" ? "gutter-run-all" : "gutter-run";
|
||||
}
|
||||
|
||||
class RunMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly target: PlacedTarget,
|
||||
readonly path: string,
|
||||
readonly state: TestState | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -134,7 +148,8 @@ class RunMarker extends GutterMarker {
|
||||
other instanceof RunMarker &&
|
||||
other.target.kind === this.target.kind &&
|
||||
other.target.name === this.target.name &&
|
||||
other.path === this.path
|
||||
other.path === this.path &&
|
||||
other.state === this.state
|
||||
);
|
||||
}
|
||||
override toDOM(): Node {
|
||||
@@ -143,18 +158,29 @@ class RunMarker extends GutterMarker {
|
||||
el.title = `${targetTitle(this.target)} '${label(this.target)}'`;
|
||||
el.setAttribute("role", "button");
|
||||
el.setAttribute("aria-label", el.title);
|
||||
el.innerHTML = iconMarkup(this.target.kind === "suite" ? "gutter-run-all" : "gutter-run", 14) ?? "";
|
||||
el.innerHTML = iconMarkup(markerIcon(this.target.kind, this.state), 14) ?? "";
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/** The gutter asks for markers on every update: rebuild only when targets or test results changed. */
|
||||
const markerCache = new WeakMap<readonly PlacedTarget[], { version: number; set: RangeSet<GutterMarker> }>();
|
||||
|
||||
function markers(state: EditorState, path: string): RangeSet<GutterMarker> {
|
||||
return RangeSet.of(
|
||||
state.field(targetsField).map((t) => new RunMarker(t, path).range(t.pos)),
|
||||
const targets = state.field(targetsField);
|
||||
const version = testStatesVersion();
|
||||
const cached = markerCache.get(targets);
|
||||
if (cached?.version === version) return cached.set;
|
||||
const set = RangeSet.of(
|
||||
targets.map((t) => new RunMarker(t, path, gutterTestState(t, path)).range(t.pos)),
|
||||
true,
|
||||
);
|
||||
markerCache.set(targets, { version, set });
|
||||
return set;
|
||||
}
|
||||
|
||||
const testStatesChanged = StateEffect.define<null>();
|
||||
|
||||
const DEBOUNCE = 350;
|
||||
|
||||
export function runGutter(ctx: EditorDocContext): Extension {
|
||||
@@ -167,8 +193,13 @@ export function runGutter(ctx: EditorDocContext): Extension {
|
||||
const detector = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
readonly unsubscribe: () => void;
|
||||
constructor(readonly view: EditorView) {
|
||||
this.schedule(0);
|
||||
// Redraw the markers with the new results (the gutter recomputes them on any update).
|
||||
this.unsubscribe = onTestStatesChanged(() => {
|
||||
if (view.state.field(targetsField).length) view.dispatch({ effects: testStatesChanged.of(null) });
|
||||
});
|
||||
}
|
||||
update(u: ViewUpdate) {
|
||||
if (u.docChanged) this.schedule(DEBOUNCE);
|
||||
@@ -187,6 +218,7 @@ export function runGutter(ctx: EditorDocContext): Extension {
|
||||
}
|
||||
destroy() {
|
||||
clearTimeout(this.timer);
|
||||
this.unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,10 +13,11 @@ import { ConsoleModel } from "./console";
|
||||
import { ConsoleView, type ConsoleInput } from "./ConsoleView";
|
||||
import { buildRows, mnemonics } from "./ConfigChooser";
|
||||
import { activate } from "./index";
|
||||
import { resetRuns, startRun, useRun, type RunSession } from "./store";
|
||||
import { closeSessionTab, resetRuns, startRun, useRun, type RunSession } from "./store";
|
||||
|
||||
const confirm = vi.hoisted(() => vi.fn(async () => true));
|
||||
vi.mock("./dialogs", () => ({ confirmRerun: confirm, promptValue: vi.fn(), RunDialogsOverlay: () => null }));
|
||||
const confirmTerminate = vi.hoisted(() => vi.fn(async () => true));
|
||||
vi.mock("./dialogs", () => ({ confirmRerun: confirm, confirmTerminate, promptValue: vi.fn(), RunDialogsOverlay: () => null }));
|
||||
|
||||
const config = (id: string, extra: Partial<RunConfig> = {}): RunConfig => ({
|
||||
id,
|
||||
@@ -51,7 +52,7 @@ beforeEach(() => {
|
||||
provideNotificationService({ notify: () => undefined });
|
||||
});
|
||||
|
||||
describe("single-instance runs (IDEA restartRunProfile)", () => {
|
||||
describe("single-instance runs", () => {
|
||||
it("Run on a running configuration asks, then restarts it in its tab", async () => {
|
||||
const app = config("app");
|
||||
useRun.setState({ sessions: [running("run1", app)] });
|
||||
@@ -85,6 +86,26 @@ describe("single-instance runs (IDEA restartRunProfile)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing a Run tab", () => {
|
||||
it("asks before terminating a running process and keeps the tab when cancelled", async () => {
|
||||
const app = config("app");
|
||||
useRun.setState({ sessions: [running("run1", app)] });
|
||||
confirmTerminate.mockResolvedValueOnce(false);
|
||||
expect(await closeSessionTab("run1")).toBe(false);
|
||||
expect(confirmTerminate).toHaveBeenCalledWith("app");
|
||||
expect(useRun.getState().sessions).toHaveLength(1);
|
||||
expect(await closeSessionTab("run1")).toBe(true);
|
||||
expect(useRun.getState().sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("closes a finished session without asking", async () => {
|
||||
confirmTerminate.mockClear();
|
||||
useRun.setState({ sessions: [{ ...running("run1", config("app")), status: "finished", exitCode: 0 }] });
|
||||
expect(await closeSessionTab("run1")).toBe(true);
|
||||
expect(confirmTerminate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Stop", () => {
|
||||
let dispose: () => void;
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -8,12 +8,15 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc";
|
||||
import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { ConsoleModel } from "./console";
|
||||
import { confirmRerun } from "./dialogs";
|
||||
import { jvmFrameTarget } from "./links";
|
||||
import { confirmRerun, confirmTerminate } from "./dialogs";
|
||||
import { createTestParser, frameworkFor, TestResults, withTestReporting, type TestEvent, type TestOutputParser } from "./testing";
|
||||
import { failedTestPaths, rerunFailedConfig } from "./testing/rerun";
|
||||
import { recordTestStates } from "./testing/states";
|
||||
import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun";
|
||||
|
||||
export type SessionStatus = "starting" | "running" | "finished" | "failed";
|
||||
|
||||
@@ -518,6 +521,7 @@ function finishSession(sessionId: string, code: number | null) {
|
||||
if (frameworkFor(session.config) === "jvm") rt.tests.apply(await jvmReportEvents(session));
|
||||
if (runtimes.get(sessionId) !== rt) return;
|
||||
rt.tests.finish(code);
|
||||
recordTestStates(rt.tests);
|
||||
}
|
||||
patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() });
|
||||
bump(sessionId);
|
||||
@@ -553,10 +557,10 @@ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[
|
||||
const text = [r.message, r.output].filter(Boolean).join("\n");
|
||||
for (const line of text.split("\n").slice(0, 200)) events.push({ type: "output", path, line });
|
||||
const outer = r.className.split("$")[0]!;
|
||||
const frame = new RegExp(`at ${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec(
|
||||
const frame = new RegExp(`at (${outer.replace(/[.$]/g, "\\$&")}(?:\\$[\\w$]+)?)\\.[\\w$<>]+\\(([\\w$-]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)`).exec(
|
||||
r.output ?? "",
|
||||
);
|
||||
if (frame) events.push({ type: "location", path, location: { path: frame[1]!, line: Number(frame[2]), resolve: "name" } });
|
||||
if (frame) events.push({ type: "location", path, location: jvmFrameTarget(frame[1], frame[2]!, Number(frame[3])) });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
@@ -635,6 +639,26 @@ export function rerunFailed(sessionId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run / Debug the tests selected in a session's test tree (IDEA: a temporary
|
||||
* configuration for them). False when the framework can't filter tests.
|
||||
*/
|
||||
export async function runSelectedTests(sessionId: string, nodeIds: readonly string[], debug: boolean): Promise<boolean> {
|
||||
const session = getSession(sessionId);
|
||||
const tests = runtimes.get(sessionId)?.tests;
|
||||
if (!session || !tests) return false;
|
||||
const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n);
|
||||
const paths = leafTestPaths(nodes);
|
||||
const base = session.baseConfig ?? session.config;
|
||||
const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`;
|
||||
const derived = testsConfig(base, paths, name);
|
||||
if (!derived) return false;
|
||||
const config = adoptTemporary({ ...derived, id: `context:tests:${base.id}:${paths.map((p) => p.join("/")).join("|")}` });
|
||||
if (debug) await executeAction("run.debugConfig", config);
|
||||
else await startRun(config);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether the console takes typed input: the process runs and its stdin is open. */
|
||||
export function acceptsInput(session: RunSession): boolean {
|
||||
return session.status === "running" && session.runId !== null && !session.stdinClosed;
|
||||
@@ -676,6 +700,16 @@ export function clearConsole(sessionId: string) {
|
||||
bump(sessionId);
|
||||
}
|
||||
|
||||
/** Closing a Run tab: a process still running is terminated once the user agrees, as IDEA asks. */
|
||||
export async function closeSessionTab(sessionId: string): Promise<boolean> {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) return false;
|
||||
const running = session.status === "running" || session.status === "starting";
|
||||
if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name))) return false;
|
||||
closeSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closeSession(sessionId: string) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
@@ -319,12 +319,14 @@ export function targetConfig(
|
||||
relPath: string,
|
||||
configs: RunConfig[],
|
||||
content?: string,
|
||||
/** The test configuration to narrow (one of `testBasesFor`); the closest by default. */
|
||||
base?: RunConfig,
|
||||
): RunConfig | null {
|
||||
if (target.kind === "main") {
|
||||
if (target.className) return applicationConfig({ className: target.className, spring: target.spring }, relPath, configs);
|
||||
return contextConfig(relPath, configs, content);
|
||||
}
|
||||
const file = testConfigFor(relPath, configs);
|
||||
const file = testConfigFor(relPath, configs, base);
|
||||
if (!file) return null;
|
||||
if (target.className && (file.kind === "maven" || file.kind === "gradle"))
|
||||
return jvmTestConfig(file, target.className, target.kind === "test" ? target.path[target.path.length - 1] : undefined);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseComparison } from "./comparison";
|
||||
|
||||
describe("parseComparison", () => {
|
||||
it("JUnit / opentest4j", () => {
|
||||
expect(parseComparison(["org.opentest4j.AssertionFailedError: expected: <3> but was: <4>"])).toEqual({
|
||||
expected: "3",
|
||||
actual: "4",
|
||||
});
|
||||
expect(parseComparison(['expected: "a b"', ' but was: "a c"'])).toEqual({ expected: '"a b"', actual: '"a c"' });
|
||||
});
|
||||
|
||||
it("kotlin.test", () => {
|
||||
expect(parseComparison(["java.lang.AssertionError: Expected <1>, actual <2>."])).toEqual({
|
||||
expected: "1",
|
||||
actual: "2",
|
||||
});
|
||||
});
|
||||
|
||||
it("jest / vitest", () => {
|
||||
expect(
|
||||
parseComparison([
|
||||
" expect(received).toBe(expected) // Object.is equality",
|
||||
"",
|
||||
" Expected: 3",
|
||||
" Received: 4",
|
||||
]),
|
||||
).toEqual({ expected: "3", actual: "4" });
|
||||
});
|
||||
|
||||
it("rust assert_eq! keeps left / right", () => {
|
||||
expect(parseComparison(["assertion `left == right` failed", " left: 3", " right: 4"])).toEqual({
|
||||
expected: "3",
|
||||
actual: "4",
|
||||
labels: { expected: "Left", actual: "Right" },
|
||||
});
|
||||
});
|
||||
|
||||
it("go testify and pytest", () => {
|
||||
expect(
|
||||
parseComparison(["\tError: \tNot equal: ", "\t \texpected: 3", "\t \tactual : 4"]),
|
||||
).toEqual({
|
||||
expected: "3",
|
||||
actual: "4",
|
||||
});
|
||||
expect(parseComparison(["> assert add(1, 2) == 4", "E assert 3 == 4"])).toEqual({
|
||||
expected: "4",
|
||||
actual: "3",
|
||||
});
|
||||
});
|
||||
|
||||
it("no comparison in other failures", () => {
|
||||
expect(parseComparison(["java.lang.IllegalStateException: boom", "\tat com.acme.A.b(A.java:3)"])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Expected / actual values of an assertion failure, read from a test's captured
|
||||
* output, for the test tree's "Show Diff" (as in IDEA's comparison failure diff).
|
||||
*/
|
||||
|
||||
export interface Comparison {
|
||||
expected: string;
|
||||
actual: string;
|
||||
/** Side labels when the framework doesn't say which side is expected (Rust `left` / `right`). */
|
||||
labels?: { expected: string; actual: string };
|
||||
}
|
||||
|
||||
/** Strips the quotes / angle brackets frameworks wrap values in (`<1>`, `"a"` stays as written). */
|
||||
const unwrap = (s: string) => s.trim().replace(/^<([\s\S]*)>$/, "$1");
|
||||
|
||||
export function parseComparison(output: readonly string[]): Comparison | null {
|
||||
// Output lines reach the parsers with ANSI escapes already stripped.
|
||||
const lines = output;
|
||||
const text = lines.join("\n");
|
||||
let m: RegExpExecArray | null;
|
||||
// JUnit / opentest4j / AssertJ / Hamcrest-style: `expected: <x> but was: <y>`.
|
||||
if ((m = /expected:\s*(<[\s\S]*?>|\S[^\n]*?)\s*but was:\s*(<[\s\S]*?>|[^\n]*)/.exec(text)))
|
||||
return { expected: unwrap(m[1]!), actual: unwrap(m[2]!) };
|
||||
// kotlin.test: `Expected <x>, actual <y>.`
|
||||
if ((m = /Expected <([\s\S]*?)>, actual <([\s\S]*?)>/.exec(text))) return { expected: m[1]!, actual: m[2]! };
|
||||
// Rust `assert_eq!`: ` left: x` / ` right: y` (the macro doesn't say which one is expected).
|
||||
const left = lines.findIndex((l) => /^\s*left:\s/.test(l));
|
||||
if (left >= 0 && /^\s*right:\s/.test(lines[left + 1] ?? ""))
|
||||
return {
|
||||
expected: lines[left]!.replace(/^\s*left:\s*/, ""),
|
||||
actual: lines[left + 1]!.replace(/^\s*right:\s*/, ""),
|
||||
labels: { expected: "Left", actual: "Right" },
|
||||
};
|
||||
// Jest / Vitest: `Expected: x` then `Received: y`.
|
||||
const exp = lines.findIndex((l) => /^\s*Expected:\s/.test(l));
|
||||
const rec = exp >= 0 ? lines.findIndex((l, i) => i > exp && /^\s*Received:\s/.test(l)) : -1;
|
||||
if (exp >= 0 && rec >= 0)
|
||||
return {
|
||||
expected: lines[exp]!.replace(/^\s*Expected:\s*/, ""),
|
||||
actual: lines[rec]!.replace(/^\s*Received:\s*/, ""),
|
||||
};
|
||||
// Go testify: `expected: x` then `actual : y`.
|
||||
const gexp = lines.findIndex((l) => /^\s*(?:Error:\s+Not equal:\s*)?expected:\s/.test(l));
|
||||
const gact = gexp >= 0 ? lines.findIndex((l, i) => i > gexp && /^\s*actual\s*:\s/.test(l)) : -1;
|
||||
if (gexp >= 0 && gact >= 0)
|
||||
return {
|
||||
expected: lines[gexp]!.replace(/^.*?expected:\s*/, ""),
|
||||
actual: lines[gact]!.replace(/^\s*actual\s*:\s*/, ""),
|
||||
};
|
||||
// pytest: `assert actual == expected`.
|
||||
if ((m = /^(?:E\s+)?(?:AssertionError:\s*)?assert\s+(.+?)\s+==\s+(.+)$/m.exec(text)))
|
||||
return { expected: m[2]!.trim(), actual: m[1]!.trim() };
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Gradle test logging (`Class > method() PASSED`) and Maven Surefire reports. */
|
||||
import { jvmFrameTarget } from "../links";
|
||||
import { parseDuration, type TestEvent, type TestOutputParser } from "./types";
|
||||
|
||||
const GRADLE_RE = /^(\S[^>]*?(?:\s>\s[^>]+?)+)\s+(PASSED|FAILED|SKIPPED)(?:\s+\(([\d.]+\s*m?s)\))?$/;
|
||||
const GRADLE_SUMMARY_RE = /^(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/;
|
||||
const FRAME_RE = /\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/;
|
||||
const FRAME_RE =
|
||||
/(?:([\w$]+(?:\.[\w$]+)*)\.[\w$<>-]+)?\(([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)\)|\bat ([\w$.-]+\.(?:java|kt|kts|scala|groovy)):(\d+)/;
|
||||
// [ERROR] com.acme.OrderTest.shouldX -- Time elapsed: 0.01 s <<< FAILURE! (Surefire 3)
|
||||
// shouldX(com.acme.OrderTest) Time elapsed: 0.01 sec <<< FAILURE! (Surefire 2)
|
||||
const SUREFIRE3_RE = /^(?:\[\w+\]\s+)?([\w$.]+)\.([\w$]+)(?:\(\))?\s+--\s+Time elapsed: ([\d.]+ s(?:ec)?)\s+<<<\s+(FAILURE|ERROR)!/;
|
||||
@@ -83,7 +85,9 @@ export function jvmParser(): TestOutputParser {
|
||||
events.push({
|
||||
type: "location",
|
||||
path: failure,
|
||||
location: { path: (f[1] ?? f[3])!, line: Number(f[2] ?? f[4]), resolve: "name" },
|
||||
location: f[2]
|
||||
? jvmFrameTarget(f[1], f[2], Number(f[3]))
|
||||
: { path: f[4]!, line: Number(f[5]), resolve: "name" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,10 +390,12 @@ org.opentest4j.AssertionFailedError: expected: <3> but was: <2>
|
||||
`,
|
||||
);
|
||||
expect(nodeStatus(r.get("com.acme.OrderTest")!)).toBe("failed");
|
||||
// Resolved by the frame's class (package directory + file), not by a bare file name.
|
||||
expect(leaf(r, "com.acme.OrderTest", "shouldTotal")?.location).toEqual({
|
||||
path: "OrderTest.java",
|
||||
path: "com/acme/OrderTest.java",
|
||||
line: 31,
|
||||
resolve: "name",
|
||||
className: "com.acme.OrderTest",
|
||||
});
|
||||
expect(r.get("com.acme.UserTest")?.own).toBe("passed");
|
||||
expect(r.summary).toMatchObject({ passed: 2, failed: 1 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { TestResults } from "./results";
|
||||
import { failedTestPaths, rerunFailedConfig } from "./rerun";
|
||||
import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./rerun";
|
||||
|
||||
const cfg = (kind: string, command: string, args: string[]): RunConfig => ({
|
||||
id: `${kind}:test`,
|
||||
@@ -29,6 +29,38 @@ describe("failedTestPaths", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tests selected in the test tree", () => {
|
||||
it("expands suites to their tests; a suite without reported tests stands for itself", () => {
|
||||
const r = new TestResults();
|
||||
r.apply([
|
||||
{ type: "result", path: ["com.acme.ATest", "one()"], status: "passed" },
|
||||
{ type: "result", path: ["com.acme.ATest", "two()"], status: "failed" },
|
||||
{ type: "result", path: ["com.acme.BTest"], status: "passed" },
|
||||
]);
|
||||
const nodes = [
|
||||
r.get("com.acme.ATest")!,
|
||||
r.get(["com.acme.ATest", "one()"].join("\u0000"))!,
|
||||
r.get("com.acme.BTest")!,
|
||||
];
|
||||
expect(leafTestPaths(nodes)).toEqual([
|
||||
["com.acme.ATest", "one()"],
|
||||
["com.acme.ATest", "two()"],
|
||||
["com.acme.BTest"],
|
||||
]);
|
||||
const c = testsConfig(cfg("gradle", "./gradlew", ["test"]), leafTestPaths(nodes), "3 tests")!;
|
||||
expect(c.name).toBe("3 tests");
|
||||
expect(c.args).toEqual([
|
||||
"test",
|
||||
"--tests",
|
||||
"com.acme.ATest.one",
|
||||
"--tests",
|
||||
"com.acme.ATest.two",
|
||||
"--tests",
|
||||
"com.acme.BTest",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rerunFailedConfig", () => {
|
||||
it("cargo: exact names after --", () => {
|
||||
const c = rerunFailedConfig(cfg("cargo", "cargo", ["test", "--workspace"]), [
|
||||
|
||||
@@ -113,8 +113,29 @@ function jvmArgs(config: RunConfig, paths: string[][]): string[] | null {
|
||||
return [...dropFlag(config.args, ["--tests"]), ...filters.flatMap((f) => ["--tests", f])];
|
||||
}
|
||||
|
||||
/** Leaf test paths under the given nodes (a node without reported children stands for itself). */
|
||||
export function leafTestPaths(nodes: readonly TestNode[]): string[][] {
|
||||
const out: string[][] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (n: TestNode) => {
|
||||
if (n.children.length) return n.children.forEach(visit);
|
||||
const key = n.path.join("\u0000");
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
out.push(n.path);
|
||||
};
|
||||
nodes.forEach(visit);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Config running only `paths` (failed test paths from the results tree), or null when unsupported. */
|
||||
export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null {
|
||||
const baseName = config.name.replace(/^Failed: /, "");
|
||||
return testsConfig(config, paths, `Failed: ${baseName}`);
|
||||
}
|
||||
|
||||
/** Config running only the tests at `paths` (test tree paths), named `name`; null when unsupported. */
|
||||
export function testsConfig(config: RunConfig, paths: string[][], name: string): RunConfig | null {
|
||||
if (!paths.length) return null;
|
||||
let args: string[] | null;
|
||||
switch (frameworkFor(config)) {
|
||||
@@ -137,6 +158,5 @@ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConf
|
||||
args = null;
|
||||
}
|
||||
if (!args) return null;
|
||||
const baseName = config.name.replace(/^Failed: /, "");
|
||||
return { ...config, args, name: `Failed: ${baseName}`, detected: true, env: { ...config.env } };
|
||||
return { ...config, args, name, detected: true, env: { ...config.env } };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestResults } from "./results";
|
||||
import { clearTestStates, onTestStatesChanged, recordTestStates, testState } from "./states";
|
||||
|
||||
function run(events: Parameters<TestResults["apply"]>[0]) {
|
||||
const r = new TestResults();
|
||||
r.apply(events);
|
||||
r.finish(0);
|
||||
recordTestStates(r);
|
||||
}
|
||||
|
||||
describe("test states for the run gutter", () => {
|
||||
afterEach(() => clearTestStates());
|
||||
|
||||
it("matches Rust tests by module path under any crate suite", () => {
|
||||
run([
|
||||
{ type: "result", path: ["src/lib.rs", "tests", "adds"], status: "passed" },
|
||||
{ type: "result", path: ["src/lib.rs", "tests", "subs"], status: "failed" },
|
||||
]);
|
||||
expect(testState({ path: ["tests", "adds"] })).toBe("passed");
|
||||
expect(testState({ path: ["tests", "subs"] })).toBe("failed");
|
||||
// The suite (`mod tests`) failed because one of its tests did.
|
||||
expect(testState({ path: ["tests"] })).toBe("failed");
|
||||
expect(testState({ path: ["tests", "other"] })).toBeNull();
|
||||
});
|
||||
|
||||
it("matches JVM results named by qualified class and `method()`", () => {
|
||||
run([
|
||||
{ type: "result", path: ["com.acme.OrderTest", "lists()"], status: "passed" },
|
||||
{ type: "result", path: ["com.acme.OrderTest", "creates()"], status: "failed" },
|
||||
]);
|
||||
expect(testState({ path: ["OrderTest", "lists"] })).toBe("passed");
|
||||
expect(testState({ path: ["OrderTest", "creates"] })).toBe("failed");
|
||||
expect(testState({ path: ["OrderTest"] })).toBe("failed");
|
||||
});
|
||||
|
||||
it("checks the file for results rooted at it, and any parameter failing fails the test", () => {
|
||||
run([
|
||||
{ type: "result", path: ["tests/test_math.py", "test_add[1]"], status: "passed" },
|
||||
{ type: "result", path: ["tests/test_math.py", "test_add[2]"], status: "failed" },
|
||||
{ type: "result", path: ["src/math.test.ts", "math", "adds"], status: "passed" },
|
||||
]);
|
||||
expect(testState({ path: ["test_add"], file: "/work/app/tests/test_math.py" })).toBe("failed");
|
||||
expect(testState({ path: ["test_add"], file: "/work/app/other/test_math2.py" })).toBeNull();
|
||||
expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed");
|
||||
});
|
||||
|
||||
it("newer runs win and listeners hear about changes", () => {
|
||||
const listener = vi.fn();
|
||||
const off = onTestStatesChanged(listener);
|
||||
run([{ type: "result", path: ["pkg", "TestA"], status: "failed" }]);
|
||||
run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]);
|
||||
expect(testState({ path: ["TestA"] })).toBe("passed");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
// Unchanged results don't redraw the gutters.
|
||||
run([{ type: "result", path: ["pkg", "TestA"], status: "passed" }]);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
off();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a
|
||||
* test (or suite) that failed last time shows a red run icon, a passed one green.
|
||||
* Results are matched to gutter targets by their test path (the trailing name
|
||||
* parts), plus the file for frameworks whose results are rooted at the file.
|
||||
*/
|
||||
import { nodeStatus, type TestNode, type TestResults } from "./results";
|
||||
import type { TestStatus } from "./types";
|
||||
|
||||
export type TestState = "passed" | "failed";
|
||||
|
||||
interface Entry {
|
||||
path: string[];
|
||||
state: TestState;
|
||||
}
|
||||
|
||||
const SEP = "\u0000";
|
||||
const entries = new Map<string, Entry>();
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
|
||||
export function testStatesVersion(): number {
|
||||
return version;
|
||||
}
|
||||
|
||||
export function onTestStatesChanged(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function toState(s: TestStatus | null): TestState | null {
|
||||
return s === "passed" || s === "failed" ? s : null;
|
||||
}
|
||||
|
||||
/** Records the outcome of every finished test and suite of a run (newer runs win). */
|
||||
export function recordTestStates(results: Pick<TestResults, "roots">) {
|
||||
let changed = false;
|
||||
const visit = (n: TestNode) => {
|
||||
const state = toState(nodeStatus(n));
|
||||
if (state) {
|
||||
const key = n.path.join(SEP);
|
||||
if (entries.get(key)?.state !== state) changed = true;
|
||||
entries.set(key, { path: n.path, state });
|
||||
}
|
||||
n.children.forEach(visit);
|
||||
};
|
||||
results.roots.forEach(visit);
|
||||
if (!changed) return;
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function clearTestStates() {
|
||||
if (!entries.size) return;
|
||||
entries.clear();
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** `name()`, `name(int)`, `test_x[param]` → `name` / `test_x`. */
|
||||
const norm = (s: string) => s.replace(/\s*(\(.*\)|\[.*\])$/, "").trim();
|
||||
|
||||
function segmentMatches(result: string, target: string): boolean {
|
||||
const a = norm(result);
|
||||
const b = norm(target);
|
||||
// JVM results name the class by its fully qualified name, gutter targets by the simple one.
|
||||
return a === b || a.endsWith(`.${b}`) || b.endsWith(`.${a}`);
|
||||
}
|
||||
|
||||
const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
|
||||
|
||||
function sameFile(resultRoot: string, relPath: string): boolean {
|
||||
const a = cleanPath(resultRoot);
|
||||
const b = cleanPath(relPath);
|
||||
return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`);
|
||||
}
|
||||
|
||||
export interface StateQuery {
|
||||
/** Test path of the gutter target (describe chain, module path, `[Class, method]`…). */
|
||||
path: string[];
|
||||
/** Project-relative path of the file, checked when results are rooted at their file. */
|
||||
file?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last state of a gutter target: failed when any matching result failed (a
|
||||
* parameterised test failed for one of its parameters), else passed.
|
||||
*/
|
||||
export function testState({ path, file }: StateQuery): TestState | null {
|
||||
if (!path.length) return null;
|
||||
let passed = false;
|
||||
for (const e of entries.values()) {
|
||||
if (e.path.length < path.length) continue;
|
||||
const offset = e.path.length - path.length;
|
||||
if (!path.every((p, i) => segmentMatches(e.path[offset + i]!, p))) continue;
|
||||
if (file !== undefined && (offset === 0 || !sameFile(e.path[0]!, file))) continue;
|
||||
if (e.state === "failed") return "failed";
|
||||
passed = true;
|
||||
}
|
||||
return passed ? "passed" : null;
|
||||
}
|
||||
@@ -10,8 +10,10 @@ export interface TestLocation {
|
||||
/** 1-based, as printed. */
|
||||
line?: number;
|
||||
col?: number;
|
||||
/** `name`: a bare file name to look up in the project (JVM / Go frames). */
|
||||
/** `name`: a file name to look up in the project (JVM / Go frames). */
|
||||
resolve: "path" | "name";
|
||||
/** JVM frames: the class's binary name; `path` is then the file under its package directory. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type TestEvent =
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { BookmarkPopupsOverlay, bookmarkRows } from "../bookmarks/BookmarkPopups";
|
||||
import { bookmarkGutter } from "../bookmarks/gutter";
|
||||
import { loadBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "../bookmarks/store";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
loadBookmarks("/p");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => useBookmarkPopups.setState({ chooser: null, list: false, editing: null }));
|
||||
});
|
||||
|
||||
const set = (items: Bookmark[]) => act(() => updateBookmarks(() => items));
|
||||
|
||||
describe("mnemonic chooser", () => {
|
||||
it("a typed letter assigns that mnemonic (with the description) and closes", () => {
|
||||
set([{ path: "/p/b", line: 0, text: "b", created: 1, mnemonic: "K" }]);
|
||||
render(<BookmarkPopupsOverlay />);
|
||||
act(() => useBookmarkPopups.setState({ chooser: { path: "/p/a", line: 2, text: "fun a()" } }));
|
||||
expect(screen.getByText("Add Mnemonic Bookmark")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "K" }).className).toMatch(/keyAssigned/);
|
||||
fireEvent.change(screen.getByLabelText("Bookmark description"), { target: { value: "entry" } });
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "1" }), { key: "k" });
|
||||
expect(useBookmarkPopups.getState().chooser).toBeNull();
|
||||
const items = useBookmarks.getState().items;
|
||||
expect(items.find((b) => b.path === "/p/a")).toMatchObject({ line: 2, mnemonic: "K", description: "entry" });
|
||||
expect(items.find((b) => b.path === "/p/b")?.mnemonic).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bookmarks popup", () => {
|
||||
it("groups by file; Delete removes and F2 edits the description", () => {
|
||||
set([
|
||||
{ path: "/p/a", line: 1, text: "one", created: 1 },
|
||||
{ path: "/p/a", line: 5, text: "five", created: 2, mnemonic: "3" },
|
||||
{ path: "/p/b", line: 0, text: "zero", created: 3 },
|
||||
]);
|
||||
expect(bookmarkRows(useBookmarks.getState().items).map((r) => r.kind)).toEqual(["file", "mark", "mark", "file", "mark"]);
|
||||
render(<BookmarkPopupsOverlay />);
|
||||
act(() => useBookmarkPopups.setState({ list: true }));
|
||||
const list = screen.getByRole("listbox", { name: "Bookmarks" });
|
||||
expect(screen.getByText("five")).toBeInTheDocument();
|
||||
fireEvent.keyDown(list, { key: "Delete" });
|
||||
expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([5, 0]);
|
||||
fireEvent.keyDown(list, { key: "F2" });
|
||||
expect(useBookmarkPopups.getState().editing).toEqual({ path: "/p/a", line: 5 });
|
||||
const input = screen.getByLabelText("Description");
|
||||
fireEvent.change(input, { target: { value: "main" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(useBookmarks.getState().items[0]!.description).toBe("main");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bookmark gutter", () => {
|
||||
it("⌘/Ctrl-click in the gutter toggles a bookmark before other gutter handlers see it", () => {
|
||||
const toggle = vi.fn();
|
||||
const other = vi.fn();
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", toggle) }),
|
||||
});
|
||||
view.dom.addEventListener("mousedown", other);
|
||||
const gutter = view.dom.querySelector(".cm-gutters")!;
|
||||
gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0, metaKey: true, ctrlKey: true }));
|
||||
expect(toggle).toHaveBeenCalledWith(0);
|
||||
expect(other).not.toHaveBeenCalled();
|
||||
gutter.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
|
||||
expect(toggle).toHaveBeenCalledTimes(1);
|
||||
expect(other).toHaveBeenCalledTimes(1);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
it("shows the mnemonic instead of the bookmark icon", async () => {
|
||||
set([{ path: "/p/a", line: 1, text: "b", created: 1, mnemonic: "Q" }]);
|
||||
const view = new EditorView({
|
||||
parent: document.body,
|
||||
state: EditorState.create({ doc: "a\nb\n", extensions: bookmarkGutter("/p/a", () => {}) }),
|
||||
});
|
||||
await act(() => new Promise((r) => setTimeout(r, 0)));
|
||||
expect(view.dom.querySelector(".cm-openidea-bookmarkMnemonic")?.textContent).toBe("Q");
|
||||
view.destroy();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,13 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assignMnemonic,
|
||||
bookmarkByMnemonic,
|
||||
bookmarkLabel,
|
||||
describeBookmark,
|
||||
linesOf,
|
||||
loadBookmarks,
|
||||
marksOf,
|
||||
toggleMnemonic,
|
||||
renameBookmarks,
|
||||
setFileLines,
|
||||
toggleBookmark,
|
||||
@@ -64,3 +70,44 @@ describe("bookmark store", () => {
|
||||
expect(useBookmarks.getState().items.map((b) => b.line)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mnemonic bookmarks", () => {
|
||||
it("assigns a mnemonic, moving it from another bookmark", () => {
|
||||
let list: Bookmark[] = [];
|
||||
list = assignMnemonic(list, "/p/a", 1, "one", "A", undefined, 1);
|
||||
list = assignMnemonic(list, "/p/b", 2, "two", "A", "second", 2);
|
||||
expect(bookmarkByMnemonic(list, "A")).toMatchObject({ path: "/p/b", line: 2, description: "second" });
|
||||
expect(list.find((b) => b.path === "/p/a")?.mnemonic).toBeUndefined();
|
||||
list = assignMnemonic(list, "/p/b", 2, "two", null, "");
|
||||
expect(list.find((b) => b.path === "/p/b")).toEqual({ path: "/p/b", line: 2, text: "two", created: 2 });
|
||||
});
|
||||
|
||||
it("⌃⇧digit toggles a numbered bookmark", () => {
|
||||
let list = toggleMnemonic([], "/p/a", 3, "x", "1");
|
||||
expect(marksOf(list, "/p/a")).toEqual([{ line: 3, mnemonic: "1" }]);
|
||||
list = toggleMnemonic(list, "/p/a", 5, "y", "1");
|
||||
expect(marksOf(list, "/p/a")).toEqual([
|
||||
{ line: 3, mnemonic: null },
|
||||
{ line: 5, mnemonic: "1" },
|
||||
]);
|
||||
list = toggleMnemonic(list, "/p/a", 5, "y", "1");
|
||||
expect(linesOf(list, "/p/a")).toEqual([3]);
|
||||
});
|
||||
|
||||
it("keeps mnemonics and descriptions when lines move or storage reloads", () => {
|
||||
const list: Bookmark[] = [{ path: "/p/a", line: 1, text: "a", created: 1, mnemonic: "7", description: "note" }];
|
||||
expect(setFileLines(list, "/p/a", [{ line: 4, text: "a" }])[0]).toMatchObject({ line: 4, mnemonic: "7", description: "note" });
|
||||
localStorage.setItem("openidea.bookmarks.v1:/m", JSON.stringify([...list, { path: "/p/b", line: 0, mnemonic: "ab" }]));
|
||||
loadBookmarks("/m");
|
||||
expect(useBookmarks.getState().items.map((b) => b.mnemonic)).toEqual(["7", undefined]);
|
||||
});
|
||||
|
||||
it("F3 on a selection uses it as the description; Rename edits it", () => {
|
||||
let list = toggleBookmark([], "/p/a", 0, "let x = 1;", 1, " important ");
|
||||
expect(bookmarkLabel(list[0]!)).toBe("important");
|
||||
list = describeBookmark(list, "/p/a", 0, "renamed");
|
||||
expect(bookmarkLabel(list[0]!)).toBe("renamed");
|
||||
list = describeBookmark(list, "/p/a", 0, " ");
|
||||
expect(bookmarkLabel(list[0]!)).toBe("let x = 1;");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { Text } from "@codemirror/state";
|
||||
|
||||
vi.mock("../find/preview", () => ({
|
||||
loadPreviewText: async () => "fun a() {}\nval order = 1\norder()\n",
|
||||
clearPreviewCache: () => {},
|
||||
}));
|
||||
|
||||
const { CodePreview, previewDecorations } = await import("../find/parts");
|
||||
|
||||
describe("CodePreview", () => {
|
||||
it("marks matches (the current line's as current) and the current line", () => {
|
||||
const doc = Text.of(["ab", "order order", "x"]);
|
||||
const set = previewDecorations(doc, 1, [
|
||||
{ line: 1, ranges: [[0, 5], [6, 11]] },
|
||||
{ line: 2, ranges: [[0, 9]] },
|
||||
{ line: 7, ranges: [[0, 1]] },
|
||||
]);
|
||||
const out: string[] = [];
|
||||
set.between(0, doc.length, (from, to, d) => {
|
||||
out.push(`${from}-${to}:${(d.spec as { class: string }).class}`);
|
||||
});
|
||||
expect(out).toEqual([
|
||||
"3-3:cm-activeLine",
|
||||
"3-8:cm-openidea-findMatch cm-openidea-findMatch-current",
|
||||
"9-14:cm-openidea-findMatch cm-openidea-findMatch-current",
|
||||
"15-16:cm-openidea-findMatch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the file read-only in an editor view with the matches highlighted", async () => {
|
||||
const { container } = render(
|
||||
<CodePreview path="/p/a.kt" line={1} highlights={[{ line: 1, ranges: [[4, 9]] }]} />,
|
||||
);
|
||||
await act(() => new Promise((r) => setTimeout(r, 150)));
|
||||
expect(container.querySelector(".cm-content")?.textContent).toContain("val order = 1");
|
||||
expect(container.querySelector(".cm-content")?.getAttribute("contenteditable")).not.toBe("true");
|
||||
expect(container.querySelector(".cm-openidea-findMatch")?.textContent).toBe("order");
|
||||
});
|
||||
});
|
||||
@@ -246,3 +246,29 @@ describe("parseCommand", () => {
|
||||
expect(parseCommand("upg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Everywhere initial text", () => {
|
||||
it("each tab starts with its own last query; closing remembers the text", async () => {
|
||||
const { hideSearchEverywhere, selectSearchEverywhereTab, showSearchEverywhere, useSearchEverywhere } = await import(
|
||||
"../everywhere/store"
|
||||
);
|
||||
localStorage.clear();
|
||||
showSearchEverywhere("files");
|
||||
useSearchEverywhere.setState({ query: "Order" });
|
||||
hideSearchEverywhere();
|
||||
showSearchEverywhere("classes");
|
||||
expect(useSearchEverywhere.getState().query).toBe("");
|
||||
useSearchEverywhere.setState({ query: "Pizza" });
|
||||
selectSearchEverywhereTab("symbols");
|
||||
hideSearchEverywhere();
|
||||
showSearchEverywhere("files");
|
||||
expect(useSearchEverywhere.getState().query).toBe("Order");
|
||||
hideSearchEverywhere();
|
||||
showSearchEverywhere("symbols", { query: "sel" });
|
||||
expect(useSearchEverywhere.getState().query).toBe("sel");
|
||||
hideSearchEverywhere();
|
||||
showSearchEverywhere("classes");
|
||||
expect(useSearchEverywhere.getState().query).toBe("");
|
||||
hideSearchEverywhere();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
|
||||
vi.mock("../structure/store", async (orig) => ({
|
||||
...(await orig<typeof import("../structure/store")>()),
|
||||
useLiveOutline: () => undefined,
|
||||
}));
|
||||
|
||||
const { FileStructureOverlay, useFileStructurePopup } = await import("../structure/StructureView");
|
||||
const { useOutline, useStructureOptions } = await import("../structure/store");
|
||||
|
||||
const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => ({
|
||||
name,
|
||||
detail: null,
|
||||
kind,
|
||||
range: { start: { line, col: 0 }, end: { line: line + 1, col: 1 } },
|
||||
selection_range: { start: { line, col: 4 }, end: { line, col: 4 + name.length } },
|
||||
children,
|
||||
});
|
||||
|
||||
function open() {
|
||||
useOutline.setState({
|
||||
path: "/p/Order.kt",
|
||||
doc: 1,
|
||||
loading: false,
|
||||
error: null,
|
||||
items: [item("OrderController", "class", 0, [item("list", "method", 2), item("create", "method", 8)]), item("helper", "function", 22)],
|
||||
});
|
||||
useStructureOptions.setState({ narrowDown: true });
|
||||
render(<FileStructureOverlay />);
|
||||
act(() => useFileStructurePopup.setState({ open: true }));
|
||||
return screen.getByLabelText("Filter symbols");
|
||||
}
|
||||
|
||||
const rows = () => screen.getAllByRole("treeitem").map((r) => r.textContent);
|
||||
|
||||
afterEach(() => act(() => useFileStructurePopup.setState({ open: false })));
|
||||
|
||||
describe("File Structure popup", () => {
|
||||
it("shows the outline as an expanded tree; ←/→ collapse and expand", () => {
|
||||
const input = open();
|
||||
expect(rows()).toEqual(["OrderController", "list", "create", "helper"]);
|
||||
// The first symbol is preselected; ← collapses it, → expands it again.
|
||||
fireEvent.keyDown(input, { key: "ArrowLeft" });
|
||||
expect(rows()).toEqual(["OrderController", "helper"]);
|
||||
fireEvent.keyDown(input, { key: "ArrowRight" });
|
||||
expect(rows()).toEqual(["OrderController", "list", "create", "helper"]);
|
||||
});
|
||||
|
||||
it("narrows down on typing keeping ancestors, or only highlights when that is off", () => {
|
||||
const input = open();
|
||||
fireEvent.change(input, { target: { value: "creat" } });
|
||||
expect(rows()).toEqual(["OrderController", "create"]);
|
||||
fireEvent.click(screen.getByLabelText("Narrow down on typing"));
|
||||
expect(useStructureOptions.getState().narrowDown).toBe(false);
|
||||
expect(rows()).toEqual(["OrderController", "list", "create", "helper"]);
|
||||
});
|
||||
|
||||
it("Esc clears the filter first, then closes", () => {
|
||||
const input = open();
|
||||
fireEvent.change(input, { target: { value: "help" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
expect(useFileStructurePopup.getState().open).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(useFileStructurePopup.getState().open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ReplaceRequest } from "../find/replaceFlow";
|
||||
|
||||
const replaced: ReplaceRequest[] = [];
|
||||
const confirms: unknown[] = [];
|
||||
|
||||
vi.mock("../find/windowStore", async (orig) => ({
|
||||
...(await orig<typeof import("../find/windowStore")>()),
|
||||
ipcFindDeps: () => ({
|
||||
validate: async () => {},
|
||||
start: async (_id: number, _q: unknown, onFile: (f: unknown) => void) => {
|
||||
onFile({
|
||||
path: "/p/a.kt",
|
||||
relPath: "a.kt",
|
||||
matches: [
|
||||
{ line: 1, text: "val order = 1", ranges: [[4, 9]], textOffset: 0 },
|
||||
{ line: 4, text: "order()", ranges: [[0, 5]], textOffset: 0 },
|
||||
],
|
||||
});
|
||||
onFile({ path: "/p/b.kt", relPath: "b.kt", matches: [{ line: 0, text: "// order", ranges: [[3, 8]], textOffset: 0 }] });
|
||||
return null;
|
||||
},
|
||||
cancel: () => {},
|
||||
nextRequestId: () => 1,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../find/replaceFlow", async (orig) => ({
|
||||
...(await orig<typeof import("../find/replaceFlow")>()),
|
||||
startReplace: (r: ReplaceRequest) => {
|
||||
replaced.push(r);
|
||||
const replacements = r.targets.reduce((n, t) => n + (t.only?.length ?? 0), 0);
|
||||
return {
|
||||
result: Promise.resolve({ files: r.targets.length, replacements, failed: 0, cancelled: false, elapsedMs: 0 }),
|
||||
cancel: () => {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("@/workbench/messages", async (orig) => ({
|
||||
...(await orig<typeof import("@/workbench/messages")>()),
|
||||
confirm: async (opts: unknown) => {
|
||||
confirms.push(opts);
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
const { FindInFilesOverlay } = await import("../find/FindInFiles");
|
||||
const { findHistory, hideFindPopup, rememberFindString, runFindPopupCommand, showFindPopup, useFindPopup } =
|
||||
await import("../find/store");
|
||||
|
||||
const settle = () => act(() => new Promise((r) => setTimeout(r, 250)));
|
||||
const selectedRows = () =>
|
||||
within(screen.getByRole("listbox", { name: "Matches" }))
|
||||
.getAllByRole("option")
|
||||
.filter((o) => o.getAttribute("aria-selected") === "true");
|
||||
const field = () => screen.getByLabelText("Text to find");
|
||||
|
||||
beforeEach(() => {
|
||||
replaced.length = 0;
|
||||
confirms.length = 0;
|
||||
localStorage.clear();
|
||||
useFindPopup.setState({ pinned: false, maskEnabled: false, preserveCase: false, context: "any", replacement: "" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => hideFindPopup());
|
||||
});
|
||||
|
||||
describe("Find in Files popup", () => {
|
||||
it("⇧↓ extends the selection; ↵ in Replace mode replaces the selected rows", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
await settle();
|
||||
fireEvent.change(screen.getByLabelText("Replace with"), { target: { value: "item" } });
|
||||
fireEvent.keyDown(field(), { key: "ArrowDown", shiftKey: true });
|
||||
expect(selectedRows()).toHaveLength(2);
|
||||
expect(screen.getByRole("button", { name: "Replace 2 Occurrences" })).toBeEnabled();
|
||||
fireEvent.keyDown(field(), { key: "Enter" });
|
||||
await settle();
|
||||
expect(replaced).toHaveLength(1);
|
||||
expect(replaced[0]!.targets).toEqual([{ path: "/p/a.kt", only: [{ line: 1, col: 4 }, { line: 4, col: 0 }] }]);
|
||||
// The next row still to replace is selected.
|
||||
expect(selectedRows().map((o) => o.textContent)).toEqual([expect.stringContaining("b.kt")]);
|
||||
});
|
||||
|
||||
it("⌘-click toggles rows into the selection", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
await settle();
|
||||
const options = within(screen.getByRole("listbox", { name: "Matches" })).getAllByRole("option");
|
||||
fireEvent.click(options[2]!, { metaKey: true, ctrlKey: true, detail: 1 });
|
||||
expect(selectedRows()).toHaveLength(2);
|
||||
fireEvent.click(options[1]!, { detail: 1 });
|
||||
expect(selectedRows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("⇧⌥↵ replaces all, asking first only when several files are affected", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
await settle();
|
||||
fireEvent.keyDown(field(), { key: "Enter", shiftKey: true, altKey: true });
|
||||
await settle();
|
||||
expect(confirms).toHaveLength(1);
|
||||
expect(replaced[0]!.targets.map((t) => t.path)).toEqual(["/p/a.kt", "/p/b.kt"]);
|
||||
});
|
||||
|
||||
it("⌥ mnemonics toggle Preserve Case and the file mask", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(true, { query: "order" }));
|
||||
await settle();
|
||||
fireEvent.keyDown(field(), { key: "é", code: "KeyE", altKey: true });
|
||||
expect(useFindPopup.getState().preserveCase).toBe(true);
|
||||
fireEvent.keyDown(field(), { key: "k", code: "KeyK", altKey: true });
|
||||
expect(useFindPopup.getState().maskEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("the pin is a remembered toggle that keeps the popup open when a result is opened", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
await settle();
|
||||
const pin = screen.getByRole("button", { name: "Pin Window" });
|
||||
fireEvent.click(pin);
|
||||
expect(pin).toHaveAttribute("aria-pressed", "true");
|
||||
expect(localStorage.getItem("openidea.search.findPinned.v1")).toBe("true");
|
||||
fireEvent.keyDown(field(), { key: "Enter" });
|
||||
expect(useFindPopup.getState().open).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Open in Find Window" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("find-next keys move the selection and ⌥↓ lists recent searches", async () => {
|
||||
rememberFindString("find", "older");
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
await settle();
|
||||
act(() => void runFindPopupCommand("nextResult"));
|
||||
act(() => void runFindPopupCommand("nextResult"));
|
||||
expect(selectedRows()[0]!.textContent).toContain("b.kt");
|
||||
act(() => void runFindPopupCommand("previousResult"));
|
||||
expect(selectedRows()[0]!.textContent).toContain("a.kt");
|
||||
act(() => void runFindPopupCommand("recentSearches"));
|
||||
expect(await screen.findByText("older")).toBeInTheDocument();
|
||||
act(() => hideFindPopup());
|
||||
});
|
||||
|
||||
it("closing remembers the search text", async () => {
|
||||
render(<FindInFilesOverlay />);
|
||||
act(() => showFindPopup(false, { query: "order" }));
|
||||
await settle();
|
||||
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
|
||||
expect(useFindPopup.getState().open).toBe(false);
|
||||
expect(findHistory("find")[0]).toBe("order");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FILE_SET_SCOPES, buildQuery, describeQuery, useFindPopup } from "../find/store";
|
||||
import { FILE_SET_SCOPES, buildQuery, describeQuery, findHistory, rememberFindString, useFindPopup } from "../find/store";
|
||||
import { selectRow, selectedRows, type RowSelection } from "../find/rowSelection";
|
||||
|
||||
describe("Find in Files named scopes", () => {
|
||||
it("defaults to IDEA's Project Files and maps root-based scopes to backend file sets", () => {
|
||||
@@ -13,3 +14,44 @@ describe("Find in Files named scopes", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Find in Files search context and history", () => {
|
||||
it("sends the context filter only when it narrows the search", () => {
|
||||
const base = useFindPopup.getState();
|
||||
expect(buildQuery(base, { kind: "project" }, 0)).not.toHaveProperty("context");
|
||||
const q = buildQuery({ ...base, pattern: "x", context: "exceptComments" }, { kind: "project" }, 0);
|
||||
expect(q.context).toBe("exceptComments");
|
||||
expect(describeQuery(q, "/p")).toBe("'x' in Project, except comments");
|
||||
});
|
||||
|
||||
it("remembers recent strings per field, newest first and unique", () => {
|
||||
localStorage.clear();
|
||||
rememberFindString("find", "a");
|
||||
rememberFindString("find", "b");
|
||||
rememberFindString("find", "a");
|
||||
rememberFindString("replace", "r");
|
||||
rememberFindString("find", " ");
|
||||
expect(findHistory("find")).toEqual(["a", "b"]);
|
||||
expect(findHistory("replace")).toEqual(["r"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("results selection", () => {
|
||||
const rows = ["a", "b", "c", "d"].map((key) => ({ key }));
|
||||
const keys = (sel: RowSelection) => selectedRows(rows, sel).map((r) => r.key);
|
||||
|
||||
it("⇧ extends from the anchor, ⌘ toggles, a plain click selects one", () => {
|
||||
let sel = selectRow(rows, { lead: 0, anchor: 0, keys: null }, 1);
|
||||
sel = selectRow(rows, sel, 3, "range");
|
||||
expect(keys(sel)).toEqual(["b", "c", "d"]);
|
||||
sel = selectRow(rows, sel, 0, "range");
|
||||
expect(keys(sel)).toEqual(["a", "b"]);
|
||||
sel = selectRow(rows, sel, 3, "toggle");
|
||||
expect(keys(sel)).toEqual(["a", "b", "d"]);
|
||||
sel = selectRow(rows, sel, 3, "toggle");
|
||||
expect(keys(sel)).toEqual(["a", "b"]);
|
||||
expect(sel.lead).toBe(1);
|
||||
sel = selectRow(rows, sel, 2);
|
||||
expect(keys(sel)).toEqual(["c"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { FileMatches, SearchQuery } from "@/platform/ipc";
|
||||
import { buildFindTree } from "../find/FindToolWindow";
|
||||
import type { FindDeps } from "../find/session";
|
||||
import { disposeFindWindow, openInFindWindow, pinFindTab, useFindWindow } from "../find/windowStore";
|
||||
|
||||
const q = (pattern: string): SearchQuery => ({
|
||||
pattern,
|
||||
isRegex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
include: [],
|
||||
exclude: [],
|
||||
scope: { kind: "project" },
|
||||
maxMatches: 100,
|
||||
});
|
||||
|
||||
const deps: FindDeps = {
|
||||
validate: async () => {},
|
||||
start: async () => null,
|
||||
cancel: () => {},
|
||||
nextRequestId: () => 1,
|
||||
};
|
||||
|
||||
const file = (rel: string): FileMatches => ({
|
||||
path: `/p/${rel}`,
|
||||
relPath: rel,
|
||||
matches: [{ line: 0, text: "foo", ranges: [[0, 3]], textOffset: 0 }],
|
||||
});
|
||||
|
||||
afterEach(disposeFindWindow);
|
||||
|
||||
describe("Find tool window tabs", () => {
|
||||
it("reuses the selected tab unless it is pinned", () => {
|
||||
const a = openInFindWindow(q("a"), "a", null, deps);
|
||||
const b = openInFindWindow(q("b"), "b", null, deps);
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b"]);
|
||||
expect(useFindWindow.getState().active).toBe(b);
|
||||
expect(b).not.toBe(a);
|
||||
|
||||
pinFindTab(b, true);
|
||||
const c = openInFindWindow(q("c"), "c", null, deps);
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c"]);
|
||||
expect(useFindWindow.getState().active).toBe(c);
|
||||
|
||||
// Selecting the pinned tab again opens the next search beside it.
|
||||
useFindWindow.setState({ active: b });
|
||||
openInFindWindow(q("d"), "d", null, deps);
|
||||
expect(useFindWindow.getState().tabs.map((t) => t.title)).toEqual(["b", "c", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFindTree", () => {
|
||||
const files = [file("src/b.ts"), file("a.ts"), file("src/a.ts")];
|
||||
|
||||
it("lists files flat by path by default", () => {
|
||||
const roots = buildFindTree(files);
|
||||
expect(roots.map((n) => n.kind)).toEqual(["file", "file", "file"]);
|
||||
expect(roots.map((n) => n.key)).toEqual(["f:/p/a.ts", "f:/p/src/a.ts", "f:/p/src/b.ts"]);
|
||||
});
|
||||
|
||||
it("groups files by directory on request", () => {
|
||||
const roots = buildFindTree(files, true);
|
||||
expect(roots.map((n) => (n.kind === "dir" ? [n.label, n.count] : n.kind))).toEqual([
|
||||
["/", 1],
|
||||
["src", 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OutlineItem } from "@/platform/ipc";
|
||||
import { allKeys, bestRow, flattenOutline, keyOutline, sortOutline, symbolPathAt } from "../structure/outline";
|
||||
import { allKeys, filterOutline, keyOutline, parentKeys, sortOutline, symbolPathAt, visibleCount } from "../structure/outline";
|
||||
|
||||
const item = (name: string, kind: OutlineItem["kind"], from: number, to: number, children: OutlineItem[] = []): OutlineItem => ({
|
||||
name,
|
||||
@@ -39,13 +39,29 @@ describe("outline helpers", () => {
|
||||
expect(sortOutline(nodes, false)).toBe(nodes);
|
||||
});
|
||||
|
||||
it("filters keeping ancestors of matches", () => {
|
||||
const rows = flattenOutline(nodes, "creat");
|
||||
expect(rows.map((r) => [r.node.item.name, r.depth, r.matched])).toEqual([
|
||||
["OrderController", 0, false],
|
||||
["create", 1, true],
|
||||
]);
|
||||
expect(bestRow(rows)).toBe(1);
|
||||
expect(flattenOutline(nodes, "")).toHaveLength(5);
|
||||
it("narrows to matches and their ancestors", () => {
|
||||
const f = filterOutline(nodes, "creat", true);
|
||||
expect(f.nodes.map((n) => [n.item.name, n.children.map((c) => c.item.name)])).toEqual([["OrderController", ["create"]]]);
|
||||
expect(f.best).toBe(nodes[0]!.children[1]!.key);
|
||||
expect(f.matches.has(nodes[0]!.key)).toBe(false);
|
||||
expect(filterOutline(nodes, "", true).nodes).toBe(nodes);
|
||||
});
|
||||
|
||||
it("matches word and camel-hump starts, not scattered letters", () => {
|
||||
const loose = keyOutline([item("items.reduce() callback", "function", 0, 1), item("subtotal", "method", 2, 3)]);
|
||||
expect(filterOutline(loose, "sub", true).nodes.map((n) => n.item.name)).toEqual(["subtotal"]);
|
||||
expect(filterOutline(nodes, "OC", true).nodes.map((n) => n.item.name)).toEqual(["OrderController"]);
|
||||
});
|
||||
|
||||
it("only highlights matches when not narrowing down", () => {
|
||||
const f = filterOutline(nodes, "list", false);
|
||||
expect(f.nodes).toBe(nodes);
|
||||
expect(f.order).toEqual([nodes[0]!.children[0]!.key, nodes[0]!.children[2]!.key]);
|
||||
});
|
||||
|
||||
it("counts visible rows and maps parents", () => {
|
||||
expect(visibleCount(nodes, new Set())).toBe(5);
|
||||
expect(visibleCount(nodes, new Set([nodes[0]!.key]))).toBe(2);
|
||||
expect(parentKeys(nodes).get(nodes[0]!.children[1]!.key)).toBe(nodes[0]!.key);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolWindowContribution } from "@/workbench/contributions";
|
||||
import { fromNavigationHistory, recordLocation, renameLocations, type RecentLocation } from "../common/locations";
|
||||
import {
|
||||
fromNavigationHistory,
|
||||
isDismissed,
|
||||
recordLocation,
|
||||
renameLocations,
|
||||
type RecentLocation,
|
||||
} from "../common/locations";
|
||||
import {
|
||||
filterLocations,
|
||||
filterRecentFiles,
|
||||
@@ -107,3 +113,13 @@ describe("navigation history snapshot", () => {
|
||||
expect(merged[1]!.snippet).toEqual(["code"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removed recent locations", () => {
|
||||
it("hide the place until it is visited again", () => {
|
||||
const dismissed = [{ path: "/a", line: 10, time: 100 }];
|
||||
expect(isDismissed(loc("/a", 12, { time: 90 }), dismissed)).toBe(true);
|
||||
expect(isDismissed(loc("/a", 30, { time: 90 }), dismissed)).toBe(false);
|
||||
expect(isDismissed(loc("/b", 10, { time: 90 }), dismissed)).toBe(false);
|
||||
expect(isDismissed(loc("/a", 10, { time: 101 }), dismissed)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
.chooser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 14px 12px;
|
||||
outline: none;
|
||||
}
|
||||
.chooserTitle {
|
||||
color: var(--fg-emphasis);
|
||||
font-weight: 500;
|
||||
}
|
||||
.grids {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
.digits,
|
||||
.letters {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
outline: none;
|
||||
}
|
||||
.digits {
|
||||
grid-template-columns: repeat(3, 24px);
|
||||
align-content: start;
|
||||
}
|
||||
.letters {
|
||||
grid-template-columns: repeat(7, 24px);
|
||||
}
|
||||
.key {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.key:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.key:focus-visible {
|
||||
outline: 2px solid var(--focus-ring, var(--accent));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.keyAssigned {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.keyCurrent {
|
||||
background: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.description {
|
||||
width: 100%;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.legendGap {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.listDialog {
|
||||
max-height: 80vh;
|
||||
}
|
||||
.listBody {
|
||||
display: flex;
|
||||
height: 420px;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border-popup);
|
||||
}
|
||||
.listPane {
|
||||
flex: 0 0 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--border-popup);
|
||||
}
|
||||
.listPane > * {
|
||||
flex: 1;
|
||||
}
|
||||
.previewPane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.fileRow,
|
||||
.markRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fileName {
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.markText {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre;
|
||||
}
|
||||
.dim {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.mnemonic {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 2px;
|
||||
background: var(--accent-soft);
|
||||
font-size: var(--fs-tiny);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Bookmark popups: the mnemonic chooser (⌥F3 / Ctrl+F11), the Bookmarks
|
||||
* popup (⌘F3: list + preview, Enter jumps) and the Edit Description dialog
|
||||
* (F2 / Rename).
|
||||
*/
|
||||
import { useMemo, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { Dialog, Icon, Input, List, Popup, T, fileIconName, type DialogButton } from "@/ui";
|
||||
import { CodePreview } from "../find/parts";
|
||||
import { basename, openLocation, relPath } from "../common/open";
|
||||
import {
|
||||
MNEMONICS,
|
||||
assignMnemonic,
|
||||
bookmarkAt,
|
||||
bookmarkLabel,
|
||||
describeBookmark,
|
||||
removeBookmarks,
|
||||
updateBookmarks,
|
||||
useBookmarkPopups,
|
||||
useBookmarks,
|
||||
type Bookmark,
|
||||
} from "./store";
|
||||
import styles from "./BookmarkPopups.module.css";
|
||||
|
||||
export function BookmarkPopupsOverlay() {
|
||||
const chooser = useBookmarkPopups((s) => s.chooser);
|
||||
const list = useBookmarkPopups((s) => s.list);
|
||||
const editing = useBookmarkPopups((s) => s.editing);
|
||||
return (
|
||||
<>
|
||||
{chooser && <MnemonicChooser key={`${chooser.path}:${chooser.line}`} />}
|
||||
{list && <BookmarksPopup />}
|
||||
{editing && <EditDescriptionDialog key={`${editing.path}:${editing.line}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- mnemonic chooser ---------------------------------------------------------
|
||||
|
||||
const DIGITS = MNEMONICS.filter((m) => /\d/.test(m));
|
||||
const LETTERS = MNEMONICS.filter((m) => /[A-Z]/.test(m));
|
||||
|
||||
function MnemonicChooser() {
|
||||
const target = useBookmarkPopups((s) => s.chooser)!;
|
||||
const items = useBookmarks((s) => s.items);
|
||||
const current = bookmarkAt(items, target.path, target.line);
|
||||
const assigned = useMemo(() => new Set(items.filter((b) => b.mnemonic && b !== current).map((b) => b.mnemonic!)), [items, current]);
|
||||
const [description, setDescription] = useState(current?.description ?? "");
|
||||
const close = () => useBookmarkPopups.setState({ chooser: null });
|
||||
const choose = (mnemonic: string) => {
|
||||
updateBookmarks((list) => assignMnemonic(list, target.path, target.line, target.text, mnemonic, description));
|
||||
close();
|
||||
};
|
||||
const title = !current ? "Add Mnemonic Bookmark" : current.mnemonic ? "Change Mnemonic" : "Assign Mnemonic";
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const inInput = (e.target as HTMLElement).tagName === "INPUT";
|
||||
if (e.key === "Enter" && inInput) {
|
||||
e.preventDefault();
|
||||
if (current?.mnemonic) choose(current.mnemonic);
|
||||
else if (current) {
|
||||
updateBookmarks((list) => describeBookmark(list, target.path, target.line, description));
|
||||
close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (inInput || e.key.length !== 1) return;
|
||||
const key = e.key.toUpperCase();
|
||||
if (!MNEMONICS.includes(key)) return;
|
||||
e.preventDefault();
|
||||
choose(key);
|
||||
};
|
||||
|
||||
const button = (m: string) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.key,
|
||||
m === current?.mnemonic && styles.keyCurrent,
|
||||
assigned.has(m) && styles.keyAssigned,
|
||||
)}
|
||||
aria-pressed={m === current?.mnemonic}
|
||||
onClick={() => choose(m)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
open
|
||||
anchor={target.at ?? { x: window.innerWidth / 2 - 160, y: window.innerHeight / 3 }}
|
||||
placement="bottom-start"
|
||||
onClose={close}
|
||||
trapFocus
|
||||
aria-label={title}
|
||||
>
|
||||
<div className={styles.chooser} onKeyDown={onKeyDown}>
|
||||
<div className={styles.chooserTitle}>
|
||||
<T>{title}</T>
|
||||
</div>
|
||||
<div className={styles.grids}>
|
||||
<div className={styles.digits} data-autofocus tabIndex={-1}>
|
||||
{DIGITS.map(button)}
|
||||
</div>
|
||||
<div className={styles.letters}>{LETTERS.map(button)}</div>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.description}
|
||||
value={description}
|
||||
placeholder="Description"
|
||||
aria-label="Bookmark description"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className={styles.legend}>
|
||||
<span className={clsx(styles.dot, styles.keyAssigned)} />
|
||||
<T>Assigned bookmark</T>
|
||||
<span className={styles.legendGap} />
|
||||
<span className={clsx(styles.dot, styles.keyCurrent)} />
|
||||
<T>Current bookmark</T>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bookmarks popup ------------------------------------------------------------
|
||||
|
||||
type Row = { kind: "file"; key: string; path: string } | { kind: "mark"; key: string; bookmark: Bookmark };
|
||||
|
||||
export function bookmarkRows(items: readonly Bookmark[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let path: string | null = null;
|
||||
for (const b of items) {
|
||||
if (b.path !== path) {
|
||||
path = b.path;
|
||||
rows.push({ kind: "file", key: `f:${b.path}`, path: b.path });
|
||||
}
|
||||
rows.push({ kind: "mark", key: `b:${b.path}:${b.line}`, bookmark: b });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const isFileRow = (r: Row) => r.kind === "file";
|
||||
|
||||
function BookmarksPopup() {
|
||||
const items = useBookmarks((s) => s.items);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const rows = useMemo(() => bookmarkRows(items), [items]);
|
||||
const [index, setIndex] = useState(() => rows.findIndex((r) => r.kind === "mark"));
|
||||
const active = Math.min(index, rows.length - 1);
|
||||
const row = rows[active];
|
||||
const selected = row?.kind === "mark" ? row.bookmark : null;
|
||||
const close = () => useBookmarkPopups.setState({ list: false });
|
||||
const jump = (b: Bookmark | null) => {
|
||||
if (!b) return;
|
||||
close();
|
||||
setTimeout(() => void openLocation(b.path, { line: b.line, col: 0 }), 0);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!selected) return;
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
updateBookmarks((list) => removeBookmarks(list, (b) => b.path === selected.path && b.line === selected.line));
|
||||
} else if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
useBookmarkPopups.setState({ editing: { path: selected.path, line: selected.line } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={close} width={820} title="Bookmarks" position="top" className={styles.listDialog}>
|
||||
<div className={styles.listBody} onKeyDown={onKeyDown}>
|
||||
<div className={styles.listPane}>
|
||||
<List<Row>
|
||||
items={rows}
|
||||
getKey={(r) => r.key}
|
||||
isSection={isFileRow}
|
||||
activeIndex={active}
|
||||
onActiveChange={setIndex}
|
||||
onActivate={(r, _i, e) => {
|
||||
if (e.type === "click" && (e as MouseEvent).detail < 2) return;
|
||||
if (r.kind === "mark") jump(r.bookmark);
|
||||
}}
|
||||
aria-label="Bookmarks"
|
||||
rowHeight={22}
|
||||
sectionHeight={24}
|
||||
empty={<span>{items.length ? null : <T>No bookmarks</T>}</span>}
|
||||
renderSection={(r) =>
|
||||
r.kind === "file" ? (
|
||||
<span className={styles.fileRow}>
|
||||
<Icon name={fileIconName(basename(r.path))} size={14} />
|
||||
<span className={styles.fileName}>{basename(r.path)}</span>
|
||||
<span className={styles.dim}>{root ? relPath(r.path) : r.path}</span>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
renderItem={(r) =>
|
||||
r.kind === "mark" ? (
|
||||
<span className={styles.markRow}>
|
||||
{r.bookmark.mnemonic ? (
|
||||
<span className={styles.mnemonic}>{r.bookmark.mnemonic}</span>
|
||||
) : (
|
||||
<Icon name="bookmark" size={14} />
|
||||
)}
|
||||
<span className={styles.markText}>{bookmarkLabel(r.bookmark) || "(empty line)"}</span>
|
||||
<span className={styles.dim}>{r.bookmark.line + 1}</span>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.previewPane}>
|
||||
<CodePreview path={selected?.path ?? null} line={selected?.line ?? 0} highlights={[]} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Edit Description --------------------------------------------------------------
|
||||
|
||||
function EditDescriptionDialog() {
|
||||
const editing = useBookmarkPopups((s) => s.editing)!;
|
||||
const bookmark = useBookmarks((s) => bookmarkAt(s.items, editing.path, editing.line));
|
||||
const [text, setText] = useState(bookmark?.description ?? bookmark?.text ?? "");
|
||||
const close = () => useBookmarkPopups.setState({ editing: null });
|
||||
const save = () => {
|
||||
updateBookmarks((list) => describeBookmark(list, editing.path, editing.line, text));
|
||||
close();
|
||||
};
|
||||
const buttons: DialogButton[] = [
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "OK", role: "default", onClick: save },
|
||||
];
|
||||
return (
|
||||
<Dialog open onClose={close} title="Edit Bookmark Description" width={420} buttons={buttons}>
|
||||
<Input
|
||||
data-autofocus
|
||||
value={text}
|
||||
aria-label="Description"
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,3 +35,18 @@
|
||||
white-space: pre;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.mnemonic {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 1px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 2px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--fg-strong);
|
||||
font-size: var(--fs-tiny);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { shortcutLabel } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EmptyState, Icon, IconButton, Tree, fileIconName, showContextMenu } from "@/ui";
|
||||
import { basename, openLocation, relPath } from "../common/open";
|
||||
import { removeBookmarks, updateBookmarks, useBookmarks, type Bookmark } from "./store";
|
||||
import { bookmarkLabel, removeBookmarks, updateBookmarks, useBookmarkPopups, useBookmarks, type Bookmark } from "./store";
|
||||
import styles from "./BookmarksView.module.css";
|
||||
|
||||
type Node =
|
||||
@@ -76,9 +76,19 @@ export function BookmarksView() {
|
||||
else if (n.kind === "file") void openLocation(n.path);
|
||||
};
|
||||
|
||||
const rename = (b: Bookmark) => useBookmarkPopups.setState({ editing: { path: b.path, line: b.line } });
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
const nodes = [...selection.current].map((k) => byKey.get(k)).filter((n): n is Node => !!n);
|
||||
if (e.key === "F2" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
const mark = nodes.length === 1 && nodes[0]!.kind === "mark" ? nodes[0]!.bookmark : null;
|
||||
if (!mark) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
rename(mark);
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Delete" && e.key !== "Backspace") return;
|
||||
if (!nodes.length) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -92,7 +102,7 @@ export function BookmarksView() {
|
||||
aria-label="Bookmarks"
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => (n.kind === "mark" ? n.bookmark.text : n.name)}
|
||||
getLabel={(n) => (n.kind === "mark" ? bookmarkLabel(n.bookmark) : n.name)}
|
||||
getChildren={(n) => (n.kind === "mark" ? undefined : n.children)}
|
||||
isExpandable={(n) => n.kind !== "mark"}
|
||||
expanded={expanded}
|
||||
@@ -107,6 +117,9 @@ export function BookmarksView() {
|
||||
e.preventDefault();
|
||||
showContextMenu(e, [
|
||||
...(node && node.kind !== "list" ? [{ label: "Jump to Source", run: () => open(node) }] : []),
|
||||
...(node?.kind === "mark" && targets.length === 1
|
||||
? [{ label: "Rename…", shortcut: "F2", run: () => rename(node.bookmark) }]
|
||||
: []),
|
||||
{ kind: "separator" as const },
|
||||
{ label: targets.length > 1 ? `Delete ${targets.length} Bookmarks` : "Delete Bookmark", danger: true, run: () => removeNodes(targets) },
|
||||
]);
|
||||
@@ -128,11 +141,28 @@ export function BookmarksView() {
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.node}>
|
||||
<Icon name="bookmark" size={16} className={styles.icon} />
|
||||
<span className={styles.label}>
|
||||
{basename(n.bookmark.path)}:{n.bookmark.line + 1}
|
||||
</span>
|
||||
<span className={styles.code}>{n.bookmark.text.trim() || "(empty line)"}</span>
|
||||
{n.bookmark.mnemonic ? (
|
||||
<span className={styles.mnemonic} aria-label={`Mnemonic ${n.bookmark.mnemonic}`}>
|
||||
{n.bookmark.mnemonic}
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="bookmark" size={16} className={styles.icon} />
|
||||
)}
|
||||
{n.bookmark.description ? (
|
||||
<>
|
||||
<span className={styles.label}>{n.bookmark.description}</span>
|
||||
<span className={styles.meta}>
|
||||
{basename(n.bookmark.path)}:{n.bookmark.line + 1}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.label}>
|
||||
{basename(n.bookmark.path)}:{n.bookmark.line + 1}
|
||||
</span>
|
||||
<span className={styles.code}>{n.bookmark.text.trim() || "(empty line)"}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
/**
|
||||
* Bookmark gutter: a narrow CodeMirror gutter with a marker per bookmarked
|
||||
* line. Markers map through edits, and moved lines are written back to the
|
||||
* store so bookmarks follow the code.
|
||||
* line (the mnemonic's letter / digit for mnemonic bookmarks). Markers map
|
||||
* through edits, and moved lines are written back to the store so bookmarks
|
||||
* follow the code. ⌘-click (Ctrl-click) in the gutter toggles a bookmark, as
|
||||
* in IDEA.
|
||||
*/
|
||||
import { RangeSet, StateEffect, StateField, type Extension } from "@codemirror/state";
|
||||
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
|
||||
import { linesOf, setFileLines, updateBookmarks, useBookmarks } from "./store";
|
||||
import { hasPrimaryModifier } from "@/platform/keymap";
|
||||
import { marksOf, setFileLines, updateBookmarks, useBookmarks } from "./store";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
class BookmarkMarker extends GutterMarker {
|
||||
toDOM() {
|
||||
constructor(readonly mnemonic: string | null) {
|
||||
super();
|
||||
}
|
||||
override eq(other: GutterMarker): boolean {
|
||||
return other instanceof BookmarkMarker && other.mnemonic === this.mnemonic;
|
||||
}
|
||||
override toDOM() {
|
||||
if (this.mnemonic) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "cm-openidea-bookmarkMnemonic";
|
||||
el.textContent = this.mnemonic;
|
||||
el.setAttribute("aria-label", `Bookmark ${this.mnemonic}`);
|
||||
return el;
|
||||
}
|
||||
const svg = document.createElementNS(SVG_NS, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("width", "11");
|
||||
@@ -25,7 +41,7 @@ class BookmarkMarker extends GutterMarker {
|
||||
}
|
||||
|
||||
class SpacerMarker extends GutterMarker {
|
||||
toDOM() {
|
||||
override toDOM() {
|
||||
const el = document.createElement("span");
|
||||
el.style.display = "inline-block";
|
||||
el.style.width = "11px";
|
||||
@@ -33,37 +49,49 @@ class SpacerMarker extends GutterMarker {
|
||||
}
|
||||
}
|
||||
|
||||
const marker = new BookmarkMarker();
|
||||
const plain = new BookmarkMarker(null);
|
||||
const spacer = new SpacerMarker();
|
||||
|
||||
const setLines = StateEffect.define<number[]>();
|
||||
type Mark = { line: number; mnemonic: string | null };
|
||||
|
||||
function build(doc: EditorView["state"]["doc"], lines: readonly number[]): RangeSet<GutterMarker> {
|
||||
const ranges = [...new Set(lines)]
|
||||
.filter((l) => l >= 0 && l < doc.lines)
|
||||
.sort((a, b) => a - b)
|
||||
.map((l) => marker.range(doc.line(l + 1).from));
|
||||
const setMarks = StateEffect.define<Mark[]>();
|
||||
|
||||
function build(doc: EditorView["state"]["doc"], marks: readonly Mark[]): RangeSet<BookmarkMarker> {
|
||||
const seen = new Set<number>();
|
||||
const ranges = [...marks]
|
||||
.filter((m) => m.line >= 0 && m.line < doc.lines && !seen.has(m.line) && seen.add(m.line))
|
||||
.sort((a, b) => a.line - b.line)
|
||||
.map((m) => (m.mnemonic ? new BookmarkMarker(m.mnemonic) : plain).range(doc.line(m.line + 1).from));
|
||||
return RangeSet.of(ranges);
|
||||
}
|
||||
|
||||
function linesIn(view: EditorView, set: RangeSet<GutterMarker>): number[] {
|
||||
const out: number[] = [];
|
||||
function marksIn(view: EditorView, set: RangeSet<BookmarkMarker>): Mark[] {
|
||||
const out: Mark[] = [];
|
||||
const iter = set.iter();
|
||||
while (iter.value) {
|
||||
out.push(view.state.doc.lineAt(iter.from).number - 1);
|
||||
out.push({ line: view.state.doc.lineAt(iter.from).number - 1, mnemonic: iter.value.mnemonic });
|
||||
iter.next();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sameLines = (a: readonly number[], b: readonly number[]) => a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
const sameMarks = (a: readonly Mark[], b: readonly Mark[]) =>
|
||||
a.length === b.length && a.every((v, i) => v.line === b[i]!.line && v.mnemonic === b[i]!.mnemonic);
|
||||
|
||||
export function bookmarkGutter(path: string): Extension {
|
||||
const field = StateField.define<RangeSet<GutterMarker>>({
|
||||
create: (state) => build(state.doc, linesOf(useBookmarks.getState().items, path)),
|
||||
/** The 0-based line of a click in the gutter, or null outside it. */
|
||||
function gutterLine(view: EditorView, e: MouseEvent): number | null {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (!t?.closest?.(".cm-gutters") || t.closest(".cm-foldGutter")) return null;
|
||||
const block = view.lineBlockAtHeight(e.clientY - view.documentTop);
|
||||
return block ? view.state.doc.lineAt(block.from).number - 1 : null;
|
||||
}
|
||||
|
||||
export function bookmarkGutter(path: string, toggle: (line: number) => void): Extension {
|
||||
const field = StateField.define<RangeSet<BookmarkMarker>>({
|
||||
create: (state) => build(state.doc, marksOf(useBookmarks.getState().items, path)),
|
||||
update(value, tr) {
|
||||
let next = tr.docChanged ? value.map(tr.changes) : value;
|
||||
for (const e of tr.effects) if (e.is(setLines)) next = build(tr.state.doc, e.value);
|
||||
for (const e of tr.effects) if (e.is(setMarks)) next = build(tr.state.doc, e.value);
|
||||
return next;
|
||||
},
|
||||
});
|
||||
@@ -75,17 +103,27 @@ export function bookmarkGutter(path: string): Extension {
|
||||
constructor(private readonly view: EditorView) {
|
||||
this.unsub = useBookmarks.subscribe((s, prev) => {
|
||||
if (s.items === prev.items) return;
|
||||
const want = linesOf(s.items, path);
|
||||
if (!sameLines(want, linesIn(this.view, this.view.state.field(field)))) {
|
||||
this.view.dispatch({ effects: setLines.of(want) });
|
||||
const want = marksOf(s.items, path);
|
||||
if (!sameMarks(want, marksIn(this.view, this.view.state.field(field)))) {
|
||||
this.view.dispatch({ effects: setMarks.of(want) });
|
||||
}
|
||||
});
|
||||
// Bookmarks may have changed while this editor was being created.
|
||||
const want = linesOf(useBookmarks.getState().items, path);
|
||||
if (!sameLines(want, linesIn(view, view.state.field(field)))) {
|
||||
queueMicrotask(() => view.dispatch({ effects: setLines.of(want) }));
|
||||
const want = marksOf(useBookmarks.getState().items, path);
|
||||
if (!sameMarks(want, marksIn(view, view.state.field(field)))) {
|
||||
queueMicrotask(() => view.dispatch({ effects: setMarks.of(want) }));
|
||||
}
|
||||
// Capture phase: runs before the breakpoint gutter's click handler, which ⌘-click must not reach.
|
||||
view.dom.addEventListener("mousedown", this.onMouseDown, true);
|
||||
}
|
||||
onMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0 || !hasPrimaryModifier(e) || e.shiftKey || e.altKey) return;
|
||||
const line = gutterLine(this.view, e);
|
||||
if (line == null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggle(line);
|
||||
};
|
||||
update(u: ViewUpdate) {
|
||||
if (!u.docChanged || this.pending) return;
|
||||
this.pending = true;
|
||||
@@ -94,20 +132,21 @@ export function bookmarkGutter(path: string): Extension {
|
||||
this.pending = false;
|
||||
const view = this.view;
|
||||
if (!view.dom.isConnected) return;
|
||||
const lines = linesIn(view, view.state.field(field));
|
||||
const stored = linesOf(useBookmarks.getState().items, path);
|
||||
if (sameLines(lines, stored)) return;
|
||||
const marks = marksIn(view, view.state.field(field));
|
||||
const stored = marksOf(useBookmarks.getState().items, path);
|
||||
if (sameMarks(marks, stored)) return;
|
||||
updateBookmarks((list) =>
|
||||
setFileLines(
|
||||
list,
|
||||
path,
|
||||
lines.map((l) => ({ line: l, text: view.state.doc.line(l + 1).text })),
|
||||
marks.map((m) => ({ line: m.line, text: view.state.doc.line(m.line + 1).text })),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
destroy() {
|
||||
this.unsub();
|
||||
this.view.dom.removeEventListener("mousedown", this.onMouseDown, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -137,4 +176,20 @@ const theme = EditorView.baseTheme({
|
||||
strokeWidth: "2",
|
||||
strokeLinejoin: "round",
|
||||
},
|
||||
".cm-openidea-bookmarkMnemonic": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: "11px",
|
||||
height: "12px",
|
||||
padding: "0 1px",
|
||||
borderRadius: "2px",
|
||||
border: "1px solid var(--accent)",
|
||||
color: "var(--fg-strong)",
|
||||
background: "var(--accent-soft)",
|
||||
fontFamily: "var(--font-ui)",
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
lineHeight: "1",
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
/** Line bookmarks (F3), persisted per project in localStorage. */
|
||||
/**
|
||||
* Line bookmarks (F3), persisted per project in localStorage. A bookmark may
|
||||
* carry a mnemonic (0-9 / A-Z, unique: ⌥F3, ⌃⇧digit) and a description.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
|
||||
@@ -9,6 +12,16 @@ export interface Bookmark {
|
||||
/** Line text when the bookmark was set / last moved. */
|
||||
text: string;
|
||||
created: number;
|
||||
/** `0`-`9` or `A`-`Z`; at most one bookmark has each. */
|
||||
mnemonic?: string;
|
||||
/** User text shown instead of the line (F3 on a selection, Rename). */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const MNEMONICS = [..."1234567890", ..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||||
|
||||
export function isMnemonic(c: unknown): c is string {
|
||||
return typeof c === "string" && /^[0-9A-Z]$/.test(c);
|
||||
}
|
||||
|
||||
const KEY_PREFIX = "openidea.bookmarks.v1:";
|
||||
@@ -23,9 +36,80 @@ export function hasBookmark(list: readonly Bookmark[], path: string, line: numbe
|
||||
return list.some((b) => b.path === path && b.line === line);
|
||||
}
|
||||
|
||||
export function toggleBookmark(list: readonly Bookmark[], path: string, line: number, text: string, now = Date.now()): Bookmark[] {
|
||||
export function bookmarkAt(list: readonly Bookmark[], path: string, line: number): Bookmark | undefined {
|
||||
return list.find((b) => b.path === path && b.line === line);
|
||||
}
|
||||
|
||||
const clip = (text: string) => text.trim().slice(0, 200);
|
||||
|
||||
/** F3: removes the line's bookmark or adds one (`description`: the selected text, as IDEA). */
|
||||
export function toggleBookmark(
|
||||
list: readonly Bookmark[],
|
||||
path: string,
|
||||
line: number,
|
||||
text: string,
|
||||
now = Date.now(),
|
||||
description?: string | null,
|
||||
): Bookmark[] {
|
||||
if (hasBookmark(list, path, line)) return list.filter((b) => !(b.path === path && b.line === line));
|
||||
return sortBookmarks([...list, { path, line, text: text.trim().slice(0, 200), created: now }]);
|
||||
const b: Bookmark = { path, line, text: clip(text), created: now };
|
||||
if (description?.trim()) b.description = clip(description);
|
||||
return sortBookmarks([...list, b]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives the line's bookmark (created if needed) `mnemonic`, taking it from any
|
||||
* other bookmark; `null` turns it into a plain bookmark. `description` replaces
|
||||
* the description when given (empty clears it).
|
||||
*/
|
||||
export function assignMnemonic(
|
||||
list: readonly Bookmark[],
|
||||
path: string,
|
||||
line: number,
|
||||
text: string,
|
||||
mnemonic: string | null,
|
||||
description?: string,
|
||||
now = Date.now(),
|
||||
): Bookmark[] {
|
||||
const here = (b: Bookmark) => b.path === path && b.line === line;
|
||||
const out = list.filter((b) => !here(b)).map((b) => (mnemonic && b.mnemonic === mnemonic ? withoutMnemonic(b) : b));
|
||||
const b: Bookmark = { ...(list.find(here) ?? { path, line, text: clip(text), created: now }) };
|
||||
if (mnemonic) b.mnemonic = mnemonic;
|
||||
else delete b.mnemonic;
|
||||
if (description !== undefined) {
|
||||
if (description.trim()) b.description = clip(description);
|
||||
else delete b.description;
|
||||
}
|
||||
return sortBookmarks([...out, b]);
|
||||
}
|
||||
|
||||
function withoutMnemonic(b: Bookmark): Bookmark {
|
||||
const { mnemonic: _, ...rest } = b;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** ⌃⇧digit: the line already has `mnemonic` → remove the bookmark; otherwise assign it. */
|
||||
export function toggleMnemonic(list: readonly Bookmark[], path: string, line: number, text: string, mnemonic: string): Bookmark[] {
|
||||
if (bookmarkAt(list, path, line)?.mnemonic === mnemonic) return list.filter((b) => !(b.path === path && b.line === line));
|
||||
return assignMnemonic(list, path, line, text, mnemonic);
|
||||
}
|
||||
|
||||
export function bookmarkByMnemonic(list: readonly Bookmark[], mnemonic: string): Bookmark | undefined {
|
||||
return list.find((b) => b.mnemonic === mnemonic);
|
||||
}
|
||||
|
||||
/** Rename (F2 in the Bookmarks view): an empty description shows the line again. */
|
||||
export function describeBookmark(list: readonly Bookmark[], path: string, line: number, description: string): Bookmark[] {
|
||||
return list.map((b) => {
|
||||
if (b.path !== path || b.line !== line) return b;
|
||||
const { description: _, ...rest } = b;
|
||||
return description.trim() ? { ...rest, description: clip(description) } : rest;
|
||||
});
|
||||
}
|
||||
|
||||
/** What the Bookmarks view and popup show for a bookmark. */
|
||||
export function bookmarkLabel(b: Bookmark): string {
|
||||
return b.description?.trim() || b.text.trim();
|
||||
}
|
||||
|
||||
export function removeBookmarks(list: readonly Bookmark[], pred: (b: Bookmark) => boolean): Bookmark[] {
|
||||
@@ -47,7 +131,11 @@ export function setFileLines(
|
||||
lines.forEach((l, i) => {
|
||||
if (seen.has(l.line)) return;
|
||||
seen.add(l.line);
|
||||
moved.push({ path, line: l.line, text: l.text.trim().slice(0, 200), created: old[i]?.created ?? Date.now() });
|
||||
const prev = old[i];
|
||||
const b: Bookmark = { path, line: l.line, text: clip(l.text), created: prev?.created ?? Date.now() };
|
||||
if (prev?.mnemonic) b.mnemonic = prev.mnemonic;
|
||||
if (prev?.description) b.description = prev.description;
|
||||
moved.push(b);
|
||||
});
|
||||
return sortBookmarks([...list.filter((b) => b.path !== path), ...moved]);
|
||||
}
|
||||
@@ -66,13 +154,26 @@ export function linesOf(list: readonly Bookmark[], path: string): number[] {
|
||||
return list.filter((b) => b.path === path).map((b) => b.line).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** Lines of `path`'s bookmarks with their mnemonics (gutter), sorted by line. */
|
||||
export function marksOf(list: readonly Bookmark[], path: string): Array<{ line: number; mnemonic: string | null }> {
|
||||
return list
|
||||
.filter((b) => b.path === path)
|
||||
.map((b) => ({ line: b.line, mnemonic: b.mnemonic ?? null }))
|
||||
.sort((a, b) => a.line - b.line);
|
||||
}
|
||||
|
||||
function sanitize(raw: unknown): Bookmark[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return sortBookmarks(
|
||||
raw.filter(
|
||||
(b): b is Bookmark =>
|
||||
!!b && typeof b === "object" && typeof (b as Bookmark).path === "string" && Number.isInteger((b as Bookmark).line),
|
||||
).map((b) => ({ path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 })),
|
||||
).map((b) => {
|
||||
const out: Bookmark = { path: b.path, line: Math.max(0, b.line), text: String(b.text ?? ""), created: Number(b.created) || 0 };
|
||||
if (isMnemonic(b.mnemonic)) out.mnemonic = b.mnemonic;
|
||||
if (typeof b.description === "string" && b.description.trim()) out.description = b.description;
|
||||
return out;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,3 +197,25 @@ export function updateBookmarks(fn: (list: readonly Bookmark[]) => Bookmark[]) {
|
||||
useBookmarks.setState({ items: next });
|
||||
if (scope) saveJson(KEY_PREFIX + scope, next);
|
||||
}
|
||||
|
||||
// --- popups -----------------------------------------------------------------
|
||||
|
||||
export interface MnemonicTarget {
|
||||
path: string;
|
||||
line: number;
|
||||
/** Line text, for a bookmark created by the chooser. */
|
||||
text: string;
|
||||
/** Where to show the chooser (the caret); centred without. */
|
||||
at?: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface BookmarkPopupsState {
|
||||
/** ⌥F3: the mnemonic chooser for this line. */
|
||||
chooser: MnemonicTarget | null;
|
||||
/** ⌘F3: the Bookmarks popup. */
|
||||
list: boolean;
|
||||
/** Edit Description dialog (F2 in the Bookmarks view / popup). */
|
||||
editing: { path: string; line: number } | null;
|
||||
}
|
||||
|
||||
export const useBookmarkPopups = create<BookmarkPopupsState>(() => ({ chooser: null, list: false, editing: null }));
|
||||
@@ -34,6 +34,17 @@ export function mergeLocations(...lists: ReadonlyArray<readonly RecentLocation[]
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A place removed from Recent Locations (Delete); visits after `time` show it again. */
|
||||
export interface DismissedLocation {
|
||||
path: string;
|
||||
line: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export function isDismissed(l: RecentLocation, dismissed: readonly DismissedLocation[]): boolean {
|
||||
return dismissed.some((d) => d.path === l.path && Math.abs(d.line - l.line) <= MERGE_DISTANCE && l.time <= d.time);
|
||||
}
|
||||
|
||||
/** Drops entries pointing at removed files, shifts paths of renamed ones. */
|
||||
export function renameLocations(list: readonly RecentLocation[], from: string, to: string | null): RecentLocation[] {
|
||||
const out: RecentLocation[] = [];
|
||||
|
||||
@@ -7,7 +7,13 @@ import { EditorView, type ViewUpdate } from "@codemirror/view";
|
||||
import { create } from "zustand";
|
||||
import { activeEditor, isExternalChange, onEditorEvent, registerEditorContribution, useEditorRegistry } from "@/editor/api";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { recordLocation, renameLocations, type RecentLocation } from "./locations";
|
||||
import {
|
||||
MERGE_DISTANCE,
|
||||
recordLocation,
|
||||
renameLocations,
|
||||
type DismissedLocation,
|
||||
type RecentLocation,
|
||||
} from "./locations";
|
||||
|
||||
export interface CaretInfo {
|
||||
path: string;
|
||||
@@ -25,6 +31,8 @@ interface TrackingState {
|
||||
/** Files edited in this session (Recent Files "Show edited only"). */
|
||||
edited: ReadonlySet<string>;
|
||||
locations: readonly RecentLocation[];
|
||||
/** Places removed from Recent Locations (the editor's own history can't forget them). */
|
||||
dismissed: readonly DismissedLocation[];
|
||||
}
|
||||
|
||||
export const useTracking = create<TrackingState>(() => ({
|
||||
@@ -32,13 +40,23 @@ export const useTracking = create<TrackingState>(() => ({
|
||||
editVersions: new Map(),
|
||||
edited: new Set(),
|
||||
locations: [],
|
||||
dismissed: [],
|
||||
}));
|
||||
|
||||
/** Recent Locations › Delete: forgets the place until it is visited again. */
|
||||
export function dismissLocation(l: RecentLocation) {
|
||||
const near = (x: RecentLocation) => x.path === l.path && Math.abs(x.line - l.line) <= MERGE_DISTANCE;
|
||||
useTracking.setState((s) => ({
|
||||
locations: s.locations.filter((x) => !near(x)),
|
||||
dismissed: [...s.dismissed.slice(-99), { path: l.path, line: l.line, time: Date.now() }],
|
||||
}));
|
||||
}
|
||||
|
||||
const docPaths = new Map<number, string>();
|
||||
|
||||
/** The window switched projects: the previous one's places and edits don't apply. */
|
||||
export function resetTracking() {
|
||||
useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [] });
|
||||
useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [], dismissed: [] });
|
||||
}
|
||||
|
||||
function bumpVersion(path: string, userEdit: boolean) {
|
||||
|
||||
@@ -48,6 +48,8 @@ export function setSearchEverywherePreview(preview: boolean) {
|
||||
/**
|
||||
* Opens the dialog, or while it is open switches to `tab` (the query is kept);
|
||||
* re-invoking the shown tab toggles "include non-project items", like IDEA.
|
||||
* Without `query` (an editor selection) the field starts with the tab's last
|
||||
* search, so each tab remembers its own text.
|
||||
*/
|
||||
export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {}) {
|
||||
const s = useSearchEverywhere.getState();
|
||||
@@ -59,7 +61,7 @@ export function showSearchEverywhere(tab: SeTab, opts: { query?: string } = {})
|
||||
useSearchEverywhere.setState({
|
||||
open: true,
|
||||
tab,
|
||||
query: opts.query ?? s.query,
|
||||
query: opts.query ?? searchHistory(tab)[0] ?? "",
|
||||
includeNonProject: false,
|
||||
autoEverywhere: true,
|
||||
notFound: null,
|
||||
@@ -95,7 +97,10 @@ export function onSearchEverywhereQuery(query: string) {
|
||||
useSearchEverywhere.setState({ includeNonProject: false, notFound: null });
|
||||
}
|
||||
|
||||
/** Closing remembers the text for the shown tab (also when nothing was opened). */
|
||||
export function hideSearchEverywhere() {
|
||||
const s = useSearchEverywhere.getState();
|
||||
if (s.open) pushSearchHistory(s.tab, s.query);
|
||||
useSearchEverywhere.setState({ open: false });
|
||||
}
|
||||
|
||||
@@ -126,7 +131,7 @@ function loadHistory(): HistoryEntry[] {
|
||||
: [];
|
||||
}
|
||||
|
||||
/** Queries that led somewhere in `tab`, newest first. */
|
||||
/** Queries typed in `tab` (opened or left there), newest first. */
|
||||
export function searchHistory(tab: SeTab): string[] {
|
||||
return loadHistory()
|
||||
.filter((e) => e.tab === tab)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { openEditorPaths, useEditorRegistry } from "@/editor/api";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { useTxTemplate } from "@/platform/i18n";
|
||||
import { call, commands, type SearchScope } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import {
|
||||
@@ -42,16 +44,23 @@ import {
|
||||
type ReplaceRun,
|
||||
type Usage,
|
||||
} from "./replaceFlow";
|
||||
import { SINGLE_FIRST, isRowSelected, selectRow, selectedRows, type RowSelection } from "./rowSelection";
|
||||
import { FindSession, IDLE, matchRows, type FindState, type MatchRow } from "./session";
|
||||
import {
|
||||
FILE_SET_SCOPES,
|
||||
SEARCH_CONTEXTS,
|
||||
buildQuery,
|
||||
describeQuery,
|
||||
findHistory,
|
||||
hideFindPopup,
|
||||
recentMasks,
|
||||
rememberFindString,
|
||||
rememberMask,
|
||||
setFindPopupCommandHandler,
|
||||
setFindPopupPinned,
|
||||
useFindPopup,
|
||||
type FindOptions,
|
||||
type FindPopupCommand,
|
||||
type NamedScope,
|
||||
type ScopeTab,
|
||||
} from "./store";
|
||||
@@ -94,13 +103,17 @@ function displayDirectory(dir: string, root: string | null): string {
|
||||
|
||||
function FindInFilesDialog() {
|
||||
const s = useFindPopup();
|
||||
const txt = useTxTemplate();
|
||||
const root = useProject((p) => p.project?.root ?? null);
|
||||
const editors = useEditorRegistry((r) => r.editors);
|
||||
const [state, setState] = useState<FindState>(IDLE);
|
||||
const [session] = useState(() => new FindSession(ipcFindDeps(), setState));
|
||||
const [index, setIndex] = useState(0);
|
||||
const [sel, setSel] = useState<RowSelection>(SINGLE_FIRST);
|
||||
const setIndex = (i: number) => setSel({ lead: i, anchor: i, keys: null });
|
||||
const [recentPaths, setRecentPaths] = useState<string[] | null>(null);
|
||||
const [maskMenu, setMaskMenu] = useState(false);
|
||||
const [historyMenu, setHistoryMenu] = useState<"find" | "replace" | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState(false);
|
||||
const [previewVersion, setPreviewVersion] = useState(0);
|
||||
const [marks, setMarks] = useState<ReplaceMarks>(NO_MARKS);
|
||||
const [running, setRunning] = useState<ReplaceRun | null>(null);
|
||||
@@ -109,11 +122,21 @@ function FindInFilesDialog() {
|
||||
const [diffPreview, setDiffPreview] = useState<{ key: string; lines: PreviewLine[] } | null>(null);
|
||||
const busy = !!running;
|
||||
const patternRef = useRef<HTMLInputElement>(null);
|
||||
const patternBoxRef = useRef<HTMLDivElement>(null);
|
||||
const replaceBoxRef = useRef<HTMLDivElement>(null);
|
||||
const maskRef = useRef<HTMLDivElement>(null);
|
||||
const maskInputRef = useRef<HTMLInputElement>(null);
|
||||
const contextRef = useRef<HTMLButtonElement>(null);
|
||||
const set = (patch: Partial<FindOptions>) => useFindPopup.setState(patch);
|
||||
|
||||
useEffect(() => () => session.dispose(), [session]);
|
||||
|
||||
// Hidden popup actions (⌘G, F4, ⌥↓ …) win over the editor's bindings while this is set.
|
||||
useEffect(() => {
|
||||
setContext("findPopupOpen", true);
|
||||
return () => setContext("findPopupOpen", false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
patternRef.current?.select();
|
||||
}, [s.session]);
|
||||
@@ -154,11 +177,12 @@ function FindInFilesDialog() {
|
||||
scopeTab: s.scopeTab,
|
||||
directory: s.directory,
|
||||
namedScope: s.namedScope,
|
||||
context: s.context,
|
||||
};
|
||||
const query = useMemo(
|
||||
() => buildQuery(options, scope, POPUP_MAX_MATCHES),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, scope],
|
||||
[s.pattern, s.caseSensitive, s.wholeWord, s.isRegex, s.maskEnabled, s.mask, s.context, scope],
|
||||
);
|
||||
const queryKey = JSON.stringify(query);
|
||||
|
||||
@@ -174,8 +198,9 @@ function FindInFilesDialog() {
|
||||
}, [session, queryKey, rerun]);
|
||||
|
||||
const rows = useMemo(() => matchRows(state.files), [state.files]);
|
||||
const active = rows.length ? Math.min(index, rows.length - 1) : -1;
|
||||
const active = rows.length ? Math.min(sel.lead, rows.length - 1) : -1;
|
||||
const selected = active >= 0 ? rows[active] : undefined;
|
||||
const chosen = useMemo(() => selectedRows(rows, sel), [rows, sel]);
|
||||
const fileHighlights = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
const f = state.files.find((x) => x.path === selected.path);
|
||||
@@ -186,17 +211,22 @@ function FindInFilesDialog() {
|
||||
}, [selected, state.files]);
|
||||
|
||||
const close = () => {
|
||||
rememberFindString("find", s.pattern);
|
||||
if (s.replace) rememberFindString("replace", s.replacement);
|
||||
clearPreviewCache();
|
||||
hideFindPopup();
|
||||
};
|
||||
/** Opens a result; a pinned popup stays open (IDEA's pin). */
|
||||
const openRow = (row: MatchRow | undefined, side = false) => {
|
||||
if (!row) return;
|
||||
const pos = { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol };
|
||||
if (s.pinned) {
|
||||
rememberFindString("find", s.pattern);
|
||||
void openLocation(row.path, pos, { side, focus: false });
|
||||
return;
|
||||
}
|
||||
close();
|
||||
setTimeout(
|
||||
() =>
|
||||
void openLocation(row.path, { line: row.line, col: row.col, endLine: row.line, endCol: row.endCol }, { side }),
|
||||
0,
|
||||
);
|
||||
setTimeout(() => void openLocation(row.path, pos, { side }), 0);
|
||||
};
|
||||
const pin = () => {
|
||||
if (!s.pattern) return;
|
||||
@@ -206,15 +236,21 @@ function FindInFilesDialog() {
|
||||
openInFindWindow(pinned, describeQuery(pinned, root), s.replace ? s.replacement : null, undefined, s.preserveCase);
|
||||
};
|
||||
|
||||
/** IDEA asks before Replace All only when the results span more than one file. */
|
||||
const confirmReplaceAll = async () => {
|
||||
const usages = pendingUsages(rows, marks);
|
||||
if (!usages.length) return;
|
||||
const counts = countUsages(usages);
|
||||
if (rows.length <= 1 || counts.files <= 1) {
|
||||
void replaceAll(usages);
|
||||
return;
|
||||
}
|
||||
const ok = await confirm({
|
||||
title: "Replace All",
|
||||
ok: "Replace",
|
||||
message: (
|
||||
<>
|
||||
{confirmText(countUsages(usages), s.pattern, s.replacement || "")}
|
||||
{confirmText(counts, s.pattern, s.replacement || "")}
|
||||
{marks.excluded.size > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -274,16 +310,18 @@ function FindInFilesDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
const replaceCurrent = async () => {
|
||||
if (!selected) return;
|
||||
const next = await replaceUsages(pendingUsages([selected], marks));
|
||||
/** Replace Selected (↵ in Replace mode): every selected row, then the next row to replace. */
|
||||
const replaceSelected = async () => {
|
||||
if (!chosen.length) return;
|
||||
const last = rows.indexOf(chosen[chosen.length - 1]!);
|
||||
const next = await replaceUsages(pendingUsages(chosen, marks));
|
||||
if (next) {
|
||||
const i = nextPending(rows, active, next);
|
||||
const i = nextPending(rows, last, next);
|
||||
if (i >= 0) setIndex(i);
|
||||
}
|
||||
};
|
||||
const skip = () => {
|
||||
const i = nextPending(rows, active, marks);
|
||||
const i = nextPending(rows, rows.indexOf(chosen[chosen.length - 1]!), marks);
|
||||
if (i >= 0) setIndex(i);
|
||||
};
|
||||
const replaceInFile = async () => {
|
||||
@@ -322,38 +360,103 @@ function FindInFilesDialog() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [previewMode, s.replace, selected?.key, s.replacement, s.preserveCase, queryKey, marks, previewVersion]);
|
||||
|
||||
const moveTo = (next: number, extend: boolean) => setSel((cur) => selectRow(rows, cur, next, extend ? "range" : "single"));
|
||||
const onRowClick = (index: number, e: MouseEvent) => {
|
||||
const mode = e.shiftKey ? "range" : e.metaKey || e.ctrlKey ? "toggle" : "single";
|
||||
setSel((cur) => selectRow(rows, cur, index, mode));
|
||||
};
|
||||
|
||||
const toggleMnemonic = (code: string): boolean => {
|
||||
const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[code] as
|
||||
| "caseSensitive"
|
||||
| "wholeWord"
|
||||
| "isRegex"
|
||||
| undefined;
|
||||
if (toggle) set({ [toggle]: !s[toggle] });
|
||||
else if (code === "KeyK") {
|
||||
set({ maskEnabled: !s.maskEnabled });
|
||||
if (!s.maskEnabled) setTimeout(() => maskInputRef.current?.select(), 0);
|
||||
} else if (code === "KeyE" && s.replace) set({ preserveCase: !s.preserveCase });
|
||||
else if (code === "KeyA" && s.replace) {
|
||||
if (rows.length && !busy) void confirmReplaceAll();
|
||||
} else return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const inField = target.tagName === "INPUT" && target.closest("[data-find-field]");
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const toggle = { KeyC: "caseSensitive", KeyW: "wholeWord", KeyX: "isRegex" }[e.code] as
|
||||
"caseSensitive" | "wholeWord" | "isRegex" | undefined;
|
||||
if (toggle) {
|
||||
e.preventDefault();
|
||||
set({ [toggle]: !s[toggle] });
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && e.altKey && e.shiftKey && !e.metaKey && !e.ctrlKey) {
|
||||
// ⇧⌥↵: Replace All.
|
||||
e.preventDefault();
|
||||
if (s.replace && rows.length && !busy) void confirmReplaceAll();
|
||||
return;
|
||||
}
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && toggleMnemonic(e.code)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!inField) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.metaKey || e.ctrlKey) pin();
|
||||
else openRow(selected, e.shiftKey);
|
||||
else if (s.replace && !e.shiftKey) {
|
||||
if (!busy) void replaceSelected();
|
||||
} else openRow(selected, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return;
|
||||
if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key) || e.altKey) return;
|
||||
const next = listNavigate(e.key, { items: rows, active, pageSize: 9 });
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
setIndex(next);
|
||||
moveTo(next, e.shiftKey);
|
||||
};
|
||||
|
||||
// Keys that are global shortcuts too arrive through hidden actions (see store).
|
||||
const commandRef = useRef<(c: FindPopupCommand) => void>(() => {});
|
||||
useLayoutEffect(() => {
|
||||
commandRef.current = (c) => {
|
||||
if (c === "nextResult" || c === "previousResult") {
|
||||
if (!rows.length) return;
|
||||
const step = c === "nextResult" ? 1 : -1;
|
||||
moveTo((active + step + rows.length) % rows.length, false);
|
||||
} else if (c === "openResult") openRow(selected);
|
||||
else {
|
||||
const el = document.activeElement;
|
||||
setHistoryMenu(replaceBoxRef.current?.contains(el) ? "replace" : "find");
|
||||
}
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
setFindPopupCommandHandler((c) => commandRef.current(c));
|
||||
return () => setFindPopupCommandHandler(null);
|
||||
}, []);
|
||||
|
||||
const summary = statusText(state);
|
||||
const maskItems: MenuItem[] = recentMasks().map((m) => ({
|
||||
label: m,
|
||||
checked: m === s.mask ? true : undefined,
|
||||
run: () => set({ mask: m, maskEnabled: true }),
|
||||
}));
|
||||
const historyItems = (kind: "find" | "replace"): MenuItem[] => {
|
||||
const h = findHistory(kind);
|
||||
return h.length
|
||||
? [
|
||||
{ kind: "header", label: kind === "find" ? "Recent Searches" : "Recent Replacements" },
|
||||
...h.map((q) => ({
|
||||
label: q,
|
||||
verbatim: true,
|
||||
run: () => set(kind === "find" ? { pattern: q } : { replacement: q }),
|
||||
})),
|
||||
]
|
||||
: [{ label: kind === "find" ? "No recent searches" : "No recent replacements", disabled: true }];
|
||||
};
|
||||
const contextItems: MenuItem[] = SEARCH_CONTEXTS.map((c) => ({
|
||||
label: c.label,
|
||||
checked: c.value === s.context ? true : undefined,
|
||||
run: () => set({ context: c.value }),
|
||||
}));
|
||||
const pendingSelected = s.replace ? pendingUsages(chosen, marks).length : 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -364,6 +467,7 @@ function FindInFilesDialog() {
|
||||
transparentBackdrop
|
||||
// IDEA's Find in Files is a modeless dialog: it survives switching to another app.
|
||||
closeOnDeactivate={false}
|
||||
dismissOnBackdrop={!s.pinned}
|
||||
aria-label={s.replace ? "Replace in Files" : "Find in Files"}
|
||||
className={styles.dialog}
|
||||
>
|
||||
@@ -378,6 +482,7 @@ function FindInFilesDialog() {
|
||||
<Checkbox checked={s.maskEnabled} onChange={(v) => set({ maskEnabled: v })} label="File mask:" />
|
||||
<div ref={maskRef} className={clsx(styles.mask, !s.maskEnabled && styles.maskOff)}>
|
||||
<input
|
||||
ref={maskInputRef}
|
||||
className={styles.maskInput}
|
||||
value={s.mask}
|
||||
placeholder="*.kt, !*Test*"
|
||||
@@ -394,10 +499,22 @@ function FindInFilesDialog() {
|
||||
onClick={() => setMaskMenu(true)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton icon="pin" title="Open in Find Window" shortcut="⌘↵" onClick={pin} disabled={!s.pattern} />
|
||||
<IconButton
|
||||
ref={contextRef}
|
||||
icon="filter"
|
||||
title="Search Context"
|
||||
pressed={s.context !== "any"}
|
||||
onClick={() => setContextMenu(true)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="pin"
|
||||
title="Pin Window"
|
||||
pressed={s.pinned}
|
||||
onClick={() => setFindPopupPinned(!s.pinned)}
|
||||
/>
|
||||
</header>
|
||||
<div className={styles.fields}>
|
||||
<div data-find-field>
|
||||
<div data-find-field ref={patternBoxRef}>
|
||||
<SearchField
|
||||
ref={patternRef}
|
||||
data-autofocus
|
||||
@@ -437,7 +554,7 @@ function FindInFilesDialog() {
|
||||
</div>
|
||||
{state.status === "invalid" && state.error && <div className={styles.error}>{state.error}</div>}
|
||||
{s.replace && (
|
||||
<div data-find-field>
|
||||
<div data-find-field ref={replaceBoxRef}>
|
||||
<SearchField
|
||||
value={s.replacement}
|
||||
onChange={(v) => set({ replacement: v })}
|
||||
@@ -450,6 +567,7 @@ function FindInFilesDialog() {
|
||||
id: "preserveCase",
|
||||
icon: "preserve-case",
|
||||
title: "Preserve Case",
|
||||
shortcut: "⌥E",
|
||||
pressed: s.preserveCase,
|
||||
onToggle: (v) => set({ preserveCase: v }),
|
||||
},
|
||||
@@ -500,9 +618,9 @@ function FindInFilesDialog() {
|
||||
items={rows}
|
||||
getKey={(r, i) => `${r.key}:${i}`}
|
||||
activeIndex={active}
|
||||
onActiveChange={setIndex}
|
||||
onActivate={(r, _i, e) => {
|
||||
if (e.type === "click" && (e as MouseEvent).detail < 2) return;
|
||||
isSelected={(r) => isRowSelected(sel, r.key)}
|
||||
onActivate={(r, i, e) => {
|
||||
if (e.type === "click" && (e as MouseEvent).detail < 2) return onRowClick(i, e as MouseEvent);
|
||||
openRow(r, e.shiftKey);
|
||||
}}
|
||||
focusable={false}
|
||||
@@ -571,7 +689,11 @@ function FindInFilesDialog() {
|
||||
</div>
|
||||
<footer className={styles.footer}>
|
||||
<span className={styles.hint}>
|
||||
<T>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</T>
|
||||
{s.replace ? (
|
||||
<T>↵ Replace Selected · ⇧⌥↵ Replace All · ⌘↵ Open in Find Window</T>
|
||||
) : (
|
||||
<T>↵ Open · ⇧↵ Open to the side · ⌘↵ Open in Find Window</T>
|
||||
)}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
{s.replace && progress && (
|
||||
@@ -590,14 +712,15 @@ function FindInFilesDialog() {
|
||||
<Button size="small" variant="secondary" disabled={!selected || busy} onClick={skip}>
|
||||
<T>Skip</T>
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected || busy} onClick={() => void replaceCurrent()}>
|
||||
<T>Replace</T>
|
||||
<Button size="small" disabled={!selected || busy} onClick={() => void replaceSelected()}>
|
||||
{pendingSelected > 1 ? txt(`Replace ${pendingSelected} Occurrences`) : <T>Replace</T>}
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected || busy} onClick={() => void replaceInFile()}>
|
||||
<T>Replace All in This File</T>
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
title="Replace All (⇧⌥↵, ⌥A)"
|
||||
disabled={!rows.length || busy || state.status === "searching"}
|
||||
onClick={() => void confirmReplaceAll()}
|
||||
>
|
||||
@@ -617,6 +740,22 @@ function FindInFilesDialog() {
|
||||
placement="bottom-end"
|
||||
onClose={() => setMaskMenu(false)}
|
||||
/>
|
||||
<Menu
|
||||
open={historyMenu !== null}
|
||||
anchor={historyMenu === "replace" ? replaceBoxRef : patternBoxRef}
|
||||
items={historyMenu ? historyItems(historyMenu) : []}
|
||||
placement="bottom-start"
|
||||
aria-label="Recent searches"
|
||||
onClose={() => setHistoryMenu(null)}
|
||||
/>
|
||||
<Menu
|
||||
open={contextMenu}
|
||||
anchor={contextRef}
|
||||
items={contextItems}
|
||||
placement="bottom-end"
|
||||
aria-label="Search context"
|
||||
onClose={() => setContextMenu(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,3 +88,13 @@
|
||||
opacity: 0.5;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.split {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.splitPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from "react";
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { create } from "zustand";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
@@ -9,18 +9,22 @@ import {
|
||||
EmptyState,
|
||||
Icon,
|
||||
IconButton,
|
||||
SplitGroup,
|
||||
SplitHandle,
|
||||
SplitPanel,
|
||||
Spinner,
|
||||
Tabs,
|
||||
Tree,
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type TreeHandle,
|
||||
type TreeRowState,
|
||||
T,
|
||||
} from "@/ui";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { notifications, useOccurrenceNavigator } from "@/workbench/contributions";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { basename, openLocation, relDir } from "../common/open";
|
||||
import { MatchText } from "./parts";
|
||||
import { CodePreview, MatchText, type PreviewHighlight } from "./parts";
|
||||
import {
|
||||
NO_MARKS,
|
||||
confirmText,
|
||||
@@ -38,12 +42,20 @@ import {
|
||||
type UsageGroup,
|
||||
type Usage,
|
||||
} from "./replaceFlow";
|
||||
import { closeFindTab, rerunFindTab, stopFindTab, useFindWindow, type FindTab } from "./windowStore";
|
||||
import {
|
||||
closeFindTab,
|
||||
pinFindTab,
|
||||
rerunFindTab,
|
||||
stopFindTab,
|
||||
useFindViewOptions,
|
||||
useFindWindow,
|
||||
type FindTab,
|
||||
} from "./windowStore";
|
||||
import styles from "./FindToolWindow.module.css";
|
||||
|
||||
type Node =
|
||||
| { kind: "dir"; key: string; label: string; count: number; children: Node[] }
|
||||
| { kind: "file"; key: string; path: string; name: string; count: number; children: Node[] }
|
||||
| { kind: "file"; key: string; path: string; name: string; dir: string; count: number; children: Node[] }
|
||||
| {
|
||||
kind: "match";
|
||||
key: string;
|
||||
@@ -56,13 +68,17 @@ type Node =
|
||||
starts: Array<{ line: number; col: number }>;
|
||||
};
|
||||
|
||||
/** Groups streamed file matches by directory → file → line (IDEA "Group by directory"). */
|
||||
export function buildFindTree(files: readonly FileMatches[]): Node[] {
|
||||
/**
|
||||
* Streamed file matches as file → line nodes, flat and sorted by path (IDEA's
|
||||
* default), or under their directory with `groupByDirectory`.
|
||||
*/
|
||||
export function buildFindTree(files: readonly FileMatches[], groupByDirectory = false): Node[] {
|
||||
const dirs = new Map<string, Node & { kind: "dir" }>();
|
||||
const flat: Node[] = [];
|
||||
for (const f of files) {
|
||||
const dir = relDir(f.relPath);
|
||||
let d = dirs.get(dir);
|
||||
if (!d) {
|
||||
if (!d && groupByDirectory) {
|
||||
d = { kind: "dir", key: `d:${dir}`, label: dir || "/", count: 0, children: [] };
|
||||
dirs.set(dir, d);
|
||||
}
|
||||
@@ -84,19 +100,35 @@ export function buildFindTree(files: readonly FileMatches[]): Node[] {
|
||||
starts: m.ranges.map(([s]) => ({ line: m.line, col: s + m.textOffset })),
|
||||
};
|
||||
});
|
||||
d.children.push({
|
||||
const file: Node = {
|
||||
kind: "file",
|
||||
key: `f:${f.path}`,
|
||||
path: f.path,
|
||||
name: basename(f.relPath),
|
||||
dir,
|
||||
count: f.matches.length,
|
||||
children,
|
||||
});
|
||||
d.count += f.matches.length;
|
||||
};
|
||||
if (d) {
|
||||
d.children.push(file);
|
||||
d.count += f.matches.length;
|
||||
} else flat.push(file);
|
||||
}
|
||||
if (!groupByDirectory) {
|
||||
const rel = (n: Node) => (n.kind === "file" ? (n.dir ? `${n.dir}/${n.name}` : n.name) : "");
|
||||
return flat.sort((a, b) => rel(a).localeCompare(rel(b)));
|
||||
}
|
||||
return [...dirs.values()].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
/** Preview target of a node: a match, or a file's first match. */
|
||||
function previewOf(node: Node | null): { path: string; line: number } | null {
|
||||
if (!node || node.kind === "dir") return null;
|
||||
if (node.kind === "match") return { path: node.path, line: node.line };
|
||||
const first = node.children[0];
|
||||
return first?.kind === "match" ? { path: node.path, line: first.line } : null;
|
||||
}
|
||||
|
||||
/** The usages under a node (itself for a match). */
|
||||
export function nodeGroups(node: Node): UsageGroup[] {
|
||||
if (node.kind === "match") return [{ key: node.key, path: node.path, starts: node.starts }];
|
||||
@@ -132,10 +164,30 @@ export function FindTabs() {
|
||||
variant="compact"
|
||||
className={styles.tabs}
|
||||
aria-label="Searches"
|
||||
items={tabs.map((t) => ({ id: String(t.id), label: t.title, closable: true }))}
|
||||
items={tabs.map((t) => ({
|
||||
id: String(t.id),
|
||||
label: t.title,
|
||||
closable: true,
|
||||
icon: t.pinned ? "pin" : undefined,
|
||||
}))}
|
||||
activeId={String(tab.id)}
|
||||
onSelect={(id) => useFindWindow.setState({ active: Number(id) })}
|
||||
onClose={(id) => closeFindTab(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: () => pinFindTab(t.id, !t.pinned) },
|
||||
{ kind: "separator" },
|
||||
{ id: "close", label: "Close Tab", run: () => closeFindTab(t.id) },
|
||||
{
|
||||
id: "closeOthers",
|
||||
label: "Close Other Tabs",
|
||||
disabled: tabs.length < 2,
|
||||
run: () => tabs.forEach((o) => o.id !== t.id && closeFindTab(o.id)),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +211,11 @@ export function FindToolWindow() {
|
||||
}
|
||||
|
||||
function FindTabView({ tab }: { tab: FindTab }) {
|
||||
const roots = useMemo(() => buildFindTree(tab.state.files), [tab.state.files]);
|
||||
const groupByDirectory = useFindViewOptions((o) => o.groupByDirectory);
|
||||
const showPreview = useFindViewOptions((o) => o.preview);
|
||||
const roots = useMemo(() => buildFindTree(tab.state.files, groupByDirectory), [tab.state.files, groupByDirectory]);
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
const [leadNode, setLeadNode] = useState<Node | null>(null);
|
||||
const collapsed = useCollapsed((s) => s.byTab[tab.id]);
|
||||
const expanded = useMemo(() => {
|
||||
const all = allExpandableKeys(roots);
|
||||
@@ -187,9 +243,31 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
|
||||
const exclude = () => {
|
||||
const groups = selectedGroups();
|
||||
if (groups.length) setMarks(tab.id, toggleExcluded(marks, groups));
|
||||
if (!groups.length) return;
|
||||
const next = toggleExcluded(marks, groups);
|
||||
setMarks(tab.id, next);
|
||||
// Excluding moves on to the next usage, as in IDEA (including keeps the selection).
|
||||
if (groups.some((g) => groupState(g, next) === "excluded"))
|
||||
tree.current?.selectOccurrence(1, { skipSubtree: true });
|
||||
};
|
||||
|
||||
useOccurrenceNavigator("find", (dir, focus) => {
|
||||
const step = tree.current?.selectOccurrence(dir);
|
||||
const n = step?.node as Node | undefined;
|
||||
if (!step || n?.kind !== "match") return null;
|
||||
void openLocation(n.path, { line: n.line, col: n.col, endLine: n.line, endCol: n.endCol }, { focus });
|
||||
return step;
|
||||
});
|
||||
|
||||
const preview = previewOf(leadNode);
|
||||
const highlights = useMemo<PreviewHighlight[]>(() => {
|
||||
const f = preview && st.files.find((x) => x.path === preview.path);
|
||||
return (f?.matches ?? []).map((m) => ({
|
||||
line: m.line,
|
||||
ranges: m.ranges.map(([a, b]) => [a + m.textOffset, b + m.textOffset] as [number, number]),
|
||||
}));
|
||||
}, [preview?.path, st.files]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const replace = async (usages: Usage[]) => {
|
||||
if (tab.replacement === null || !usages.length || running) return;
|
||||
const run = startReplace(
|
||||
@@ -225,7 +303,7 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
}
|
||||
};
|
||||
|
||||
const preview = async () => {
|
||||
const previewReplace = async () => {
|
||||
if (tab.replacement === null) return;
|
||||
const groups = selectedGroups();
|
||||
const path = groups[0]?.path;
|
||||
@@ -307,7 +385,12 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
<T>Stop</T>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="ghost" disabled={!selection.size || busy} onClick={() => void preview()}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
disabled={!selection.size || busy}
|
||||
onClick={() => void previewReplace()}
|
||||
>
|
||||
<T>Preview</T>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -327,64 +410,85 @@ function FindTabView({ tab }: { tab: FindTab }) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Tree<Node>
|
||||
className={styles.tree}
|
||||
aria-label="Find results"
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => (n.kind === "dir" ? n.label : n.kind === "file" ? n.name : n.text)}
|
||||
getChildren={(n) => (n.kind === "match" ? undefined : n.children)}
|
||||
isExpandable={(n) => n.kind !== "match"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(next) => {
|
||||
const all = allExpandableKeys(roots);
|
||||
setCollapsed(tab.id, new Set([...all].filter((k) => !next.has(k))));
|
||||
}}
|
||||
selected={selection}
|
||||
onSelectionChange={(next) => setSelection(next)}
|
||||
multiSelect
|
||||
onActivate={(n) => {
|
||||
if (n.kind === "match")
|
||||
void openLocation(n.path, { line: n.line, col: n.col, endLine: n.line, endCol: n.endCol }, { focus: true });
|
||||
else if (n.kind === "file") void openLocation(n.path);
|
||||
}}
|
||||
onContextMenu={(_n, sel, e) => {
|
||||
const groups = [...sel].flatMap((k) => (byKey.get(k) ? nodeGroups(byKey.get(k)!) : []));
|
||||
const excludedAll = groups.length > 0 && groups.every((g) => groupState(g, marks) === "excluded");
|
||||
showContextMenu(e, [
|
||||
{
|
||||
id: "exclude",
|
||||
label: excludedAll ? "Include" : "Exclude",
|
||||
shortcut: "⌫",
|
||||
disabled: !groups.length,
|
||||
run: exclude,
|
||||
},
|
||||
...(replaceMode
|
||||
? [
|
||||
{
|
||||
id: "replace",
|
||||
label: "Replace Selected",
|
||||
disabled: busy || !pendingUsages(groups, marks).length,
|
||||
run: () => void replace(pendingUsages(groups, marks)),
|
||||
},
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview Replacement",
|
||||
disabled: busy || !groups.length,
|
||||
run: () => void preview(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}}
|
||||
activateExpandable={false}
|
||||
renderNode={(n, s) => <FindNode node={n} state={s} tab={tab} marks={marks} />}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
{st.status === "searching" || st.status === "validating" ? "Searching…" : "No matches"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<SplitGroup orientation="horizontal" className={styles.split} id="find-results-split">
|
||||
<SplitPanel id="results" minSize={120} className={styles.splitPanel}>
|
||||
<Tree<Node>
|
||||
className={styles.tree}
|
||||
aria-label="Find results"
|
||||
handleRef={tree}
|
||||
roots={roots}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => (n.kind === "dir" ? n.label : n.kind === "file" ? n.name : n.text)}
|
||||
getChildren={(n) => (n.kind === "match" ? undefined : n.children)}
|
||||
isExpandable={(n) => n.kind !== "match"}
|
||||
isOccurrence={(n) => n.kind === "match" && groupState(nodeGroups(n)[0]!, marks) !== "excluded"}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(next) => {
|
||||
const all = allExpandableKeys(roots);
|
||||
setCollapsed(tab.id, new Set([...all].filter((k) => !next.has(k))));
|
||||
}}
|
||||
selected={selection}
|
||||
onSelectionChange={(next, lead) => {
|
||||
setSelection(next);
|
||||
setLeadNode(lead);
|
||||
}}
|
||||
multiSelect
|
||||
onActivate={(n) => {
|
||||
if (n.kind === "match")
|
||||
void openLocation(
|
||||
n.path,
|
||||
{ line: n.line, col: n.col, endLine: n.line, endCol: n.endCol },
|
||||
{ focus: true },
|
||||
);
|
||||
else if (n.kind === "file") void openLocation(n.path, previewOf(n) ? { line: previewOf(n)!.line } : null);
|
||||
}}
|
||||
onContextMenu={(_n, sel, e) => {
|
||||
const groups = [...sel].flatMap((k) => (byKey.get(k) ? nodeGroups(byKey.get(k)!) : []));
|
||||
const excludedAll = groups.length > 0 && groups.every((g) => groupState(g, marks) === "excluded");
|
||||
showContextMenu(e, [
|
||||
{
|
||||
id: "exclude",
|
||||
label: excludedAll ? "Include" : "Exclude",
|
||||
shortcut: "⌫",
|
||||
disabled: !groups.length,
|
||||
run: exclude,
|
||||
},
|
||||
...(replaceMode
|
||||
? [
|
||||
{
|
||||
id: "replace",
|
||||
label: "Replace Selected",
|
||||
disabled: busy || !pendingUsages(groups, marks).length,
|
||||
run: () => void replace(pendingUsages(groups, marks)),
|
||||
},
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview Replacement",
|
||||
disabled: busy || !groups.length,
|
||||
run: () => void previewReplace(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}}
|
||||
activateExpandable={false}
|
||||
renderNode={(n, s) => <FindNode node={n} state={s} tab={tab} marks={marks} />}
|
||||
empty={
|
||||
<span className={styles.empty}>
|
||||
{st.status === "searching" || st.status === "validating" ? "Searching…" : "No matches"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</SplitPanel>
|
||||
{showPreview && (
|
||||
<>
|
||||
<SplitHandle />
|
||||
<SplitPanel id="preview" minSize={120} defaultSize="45%" className={styles.splitPanel}>
|
||||
<CodePreview path={preview?.path ?? null} line={preview?.line ?? 0} highlights={highlights} />
|
||||
</SplitPanel>
|
||||
</>
|
||||
)}
|
||||
</SplitGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -416,6 +520,7 @@ function FindNodeContent({ node, state, tab }: { node: Node; state: TreeRowState
|
||||
<Icon name={fileIconName(node.name)} size={14} className={styles.icon} />
|
||||
<span className={styles.label}>{node.name}</span>
|
||||
<span className={styles.meta}>{node.count}</span>
|
||||
{!state.depth && node.dir && <span className={styles.meta}>{node.dir}</span>}
|
||||
</span>
|
||||
);
|
||||
case "match":
|
||||
@@ -446,17 +551,30 @@ export function FindToolWindowActions() {
|
||||
const searching = tab.state.status === "searching";
|
||||
return (
|
||||
<>
|
||||
<IconButton icon="arrow-up" action="navigate.prevOccurrence" size="small" />
|
||||
<IconButton icon="arrow-down" action="navigate.nextOccurrence" size="small" />
|
||||
{searching ? (
|
||||
<IconButton icon="stop" title="Stop" size="small" onClick={() => stopFindTab(tab.id)} />
|
||||
) : (
|
||||
<IconButton icon="refresh" title="Rerun" size="small" onClick={() => rerunFindTab(tab.id)} />
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Rerun"
|
||||
shortcut={shortcutLabel("find.rerun")}
|
||||
size="small"
|
||||
onClick={() => rerunFindTab(tab.id)}
|
||||
/>
|
||||
)}
|
||||
<IconButton icon="expand-all" title="Expand All" size="small" onClick={() => setCollapsed(tab.id, new Set())} />
|
||||
<IconButton
|
||||
icon="collapse-all"
|
||||
title="Collapse All"
|
||||
size="small"
|
||||
onClick={() => setCollapsed(tab.id, allExpandableKeys(buildFindTree(tab.state.files)))}
|
||||
onClick={() =>
|
||||
setCollapsed(
|
||||
tab.id,
|
||||
allExpandableKeys(buildFindTree(tab.state.files, useFindViewOptions.getState().groupByDirectory)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<IconButton icon="close" title="Close Tab" size="small" onClick={() => closeFindTab(tab.id)} />
|
||||
</>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
background: color-mix(in srgb, var(--success) 18%, transparent);
|
||||
}
|
||||
.preview {
|
||||
/* offsetParent for the scroll-to-line math in CodePreview. */
|
||||
/* offsetParent for the scroll-to-line math in ReplaceDiffPreview. */
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -31,7 +31,16 @@
|
||||
line-height: 19px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.previewEditor {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.previewHidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
.previewEmpty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { EditorState, StateEffect, StateField, type Range, type Text } from "@codemirror/state";
|
||||
import { Decoration, EditorView, type DecorationSet } from "@codemirror/view";
|
||||
import { codePreviewExtensions } from "@/editor/api";
|
||||
import type { SearchQuery } from "@/platform/ipc";
|
||||
import { previewReplacement } from "./session";
|
||||
import { loadPreviewText } from "./preview";
|
||||
@@ -59,7 +62,41 @@ export interface PreviewHighlight {
|
||||
ranges: ReadonlyArray<readonly [number, number]>;
|
||||
}
|
||||
|
||||
/** Read-only code view around `line` with line numbers and highlighted matches. */
|
||||
const setPreviewMarks = StateEffect.define<DecorationSet>();
|
||||
const previewMarks = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update: (value, tr) => {
|
||||
for (const e of tr.effects) if (e.is(setPreviewMarks)) return e.value;
|
||||
return value.map(tr.changes);
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
const matchMark = Decoration.mark({ class: "cm-openidea-findMatch" });
|
||||
const currentMatchMark = Decoration.mark({ class: "cm-openidea-findMatch cm-openidea-findMatch-current" });
|
||||
const currentLineMark = Decoration.line({ class: "cm-activeLine" });
|
||||
const readOnly = [EditorState.readOnly.of(true), EditorView.editable.of(false)];
|
||||
|
||||
/** Match and current-line decorations for `highlights` (0-based lines, UTF-16 columns). */
|
||||
export function previewDecorations(doc: Text, line: number, highlights: readonly PreviewHighlight[]): DecorationSet {
|
||||
const out: Array<Range<Decoration>> = [];
|
||||
if (line >= 0 && line < doc.lines) out.push(currentLineMark.range(doc.line(line + 1).from));
|
||||
for (const h of highlights) {
|
||||
if (h.line < 0 || h.line >= doc.lines) continue;
|
||||
const l = doc.line(h.line + 1);
|
||||
for (const [a, b] of h.ranges) {
|
||||
const from = l.from + Math.min(a, l.length);
|
||||
const to = l.from + Math.min(b, l.length);
|
||||
if (to > from) out.push((h.line === line ? currentMatchMark : matchMark).range(from, to));
|
||||
}
|
||||
}
|
||||
return Decoration.set(out, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only, syntax-highlighted view of `path` (live editor buffer or disk)
|
||||
* scrolled to `line`, with the matches of `highlights` marked; the editor's
|
||||
* look via `codePreviewExtensions`. Text stays selectable and copyable.
|
||||
*/
|
||||
export function CodePreview({
|
||||
path,
|
||||
line,
|
||||
@@ -71,20 +108,37 @@ export function CodePreview({
|
||||
highlights: readonly PreviewHighlight[];
|
||||
version?: number;
|
||||
}) {
|
||||
const [text, setText] = useState<{ path: string; lines: string[] } | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const [loaded, setLoaded] = useState<{ path: string; version: number } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const current = text && text.path === path ? text : null;
|
||||
const latest = useRef({ line, highlights });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { line, highlights };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const view = new EditorView({ parent: host, state: EditorState.create({ extensions: [previewMarks, readOnly] }) });
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
let alive = true;
|
||||
// Debounced so arrowing through results doesn't load every file.
|
||||
const t = setTimeout(() => {
|
||||
loadPreviewText(path).then(
|
||||
(t) => {
|
||||
if (!alive) return;
|
||||
setText({ path, lines: t.split(/\r\n|\r|\n/) });
|
||||
Promise.all([loadPreviewText(path), codePreviewExtensions(path)]).then(
|
||||
([text, extensions]) => {
|
||||
const view = viewRef.current;
|
||||
if (!alive || !view) return;
|
||||
view.setState(EditorState.create({ doc: text, extensions: [extensions, previewMarks, readOnly] }));
|
||||
setLoaded({ path, version });
|
||||
setError(null);
|
||||
},
|
||||
(e) => alive && setError(String((e as Error)?.message ?? e)),
|
||||
@@ -96,42 +150,28 @@ export function CodePreview({
|
||||
};
|
||||
}, [path, version]);
|
||||
|
||||
const start = Math.max(0, line - WINDOW);
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current?.querySelector<HTMLElement>(`[data-line="${line}"]`);
|
||||
if (el && scrollRef.current) {
|
||||
const box = scrollRef.current;
|
||||
box.scrollTop = el.offsetTop - box.clientHeight / 2 + el.offsetHeight / 2;
|
||||
}
|
||||
}, [current, line]);
|
||||
const current = !!path && loaded?.path === path;
|
||||
// A new file or line scrolls to the line; new highlights alone only repaint.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !current) return;
|
||||
const { line, highlights } = latest.current;
|
||||
const doc = view.state.doc;
|
||||
const effects: StateEffect<unknown>[] = [setPreviewMarks.of(previewDecorations(doc, line, highlights))];
|
||||
if (line >= 0 && line < doc.lines) effects.push(EditorView.scrollIntoView(doc.line(line + 1).from, { y: "center" }));
|
||||
view.dispatch({ effects });
|
||||
}, [current, loaded, line]);
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || !current) return;
|
||||
view.dispatch({ effects: setPreviewMarks.of(previewDecorations(view.state.doc, latest.current.line, highlights)) });
|
||||
}, [current, highlights]);
|
||||
|
||||
if (!path)
|
||||
return (
|
||||
<div className={clsx(styles.preview, styles.previewEmpty)}>
|
||||
<T>Select a result to preview</T>
|
||||
</div>
|
||||
);
|
||||
if (error && !current) return <div className={clsx(styles.preview, styles.previewEmpty)}>{error}</div>;
|
||||
if (!current) return <div className={styles.preview} />;
|
||||
|
||||
const byLine = new Map(highlights.map((h) => [h.line, h.ranges]));
|
||||
const end = Math.min(current.lines.length, line + WINDOW);
|
||||
const rows: ReactNode[] = [];
|
||||
for (let n = start; n < end; n++) {
|
||||
const content = current.lines[n] ?? "";
|
||||
const ranges = byLine.get(n);
|
||||
rows.push(
|
||||
<div key={n} data-line={n} className={clsx(styles.codeLine, n === line && styles.currentLine)}>
|
||||
<span className={styles.gutter}>{n + 1}</span>
|
||||
<span className={styles.code}>
|
||||
{ranges ? <MatchText text={content} ranges={ranges} active={n === line} /> : content || " "}
|
||||
</span>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
const placeholder = !path ? <T>Select a result to preview</T> : error && !current ? error : null;
|
||||
return (
|
||||
<div ref={scrollRef} className={styles.preview} role="region" aria-label="Preview">
|
||||
<pre className={styles.pre}>{rows}</pre>
|
||||
<div className={styles.preview} role="region" aria-label="Preview">
|
||||
<div ref={hostRef} className={clsx(styles.previewEditor, (!current || placeholder) && styles.previewHidden)} />
|
||||
{placeholder && <div className={styles.previewEmpty}>{placeholder}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Multi-selection of the Find in Files results list: click / ↑↓ select one row,
|
||||
* ⇧ extends from the anchor, ⌘/Ctrl toggles a row (IDEA's results table).
|
||||
*/
|
||||
|
||||
export interface RowSelection {
|
||||
/** Active row (keyboard focus, preview). */
|
||||
lead: number;
|
||||
/** Start of ⇧ ranges. */
|
||||
anchor: number;
|
||||
/** Keys of the selected rows; `null` when only the lead is selected. */
|
||||
keys: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export const SINGLE_FIRST: RowSelection = { lead: 0, anchor: 0, keys: null };
|
||||
|
||||
export function selectRow(
|
||||
rows: ReadonlyArray<{ key: string }>,
|
||||
sel: RowSelection,
|
||||
index: number,
|
||||
mode: "single" | "range" | "toggle" = "single",
|
||||
): RowSelection {
|
||||
if (index < 0 || index >= rows.length) return sel;
|
||||
if (mode === "range") {
|
||||
const anchor = Math.min(Math.max(0, sel.anchor), rows.length - 1);
|
||||
const [a, b] = anchor <= index ? [anchor, index] : [index, anchor];
|
||||
return { lead: index, anchor, keys: new Set(rows.slice(a, b + 1).map((r) => r.key)) };
|
||||
}
|
||||
if (mode === "toggle") {
|
||||
const keys = new Set(sel.keys ?? (rows[sel.lead] ? [rows[sel.lead]!.key] : []));
|
||||
const key = rows[index]!.key;
|
||||
if (!keys.has(key)) return { lead: index, anchor: index, keys: keys.add(key) };
|
||||
keys.delete(key);
|
||||
const rest = rows.findLastIndex((r) => keys.has(r.key));
|
||||
if (rest < 0) return { lead: index, anchor: index, keys: null };
|
||||
return { lead: rest, anchor: rest, keys };
|
||||
}
|
||||
return { lead: index, anchor: index, keys: null };
|
||||
}
|
||||
|
||||
/** The selected rows in list order (the lead when nothing else is selected). */
|
||||
export function selectedRows<T extends { key: string }>(rows: readonly T[], sel: RowSelection): T[] {
|
||||
if (!rows.length) return [];
|
||||
const lead = Math.min(sel.lead, rows.length - 1);
|
||||
if (!sel.keys) return [rows[lead]!];
|
||||
const out = rows.filter((r) => sel.keys!.has(r.key));
|
||||
return out.length ? out : [rows[lead]!];
|
||||
}
|
||||
|
||||
export function isRowSelected(sel: RowSelection, key: string): boolean {
|
||||
return !!sel.keys?.has(key);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { ProjectFileSet, SearchQuery, SearchScope } from "@/platform/ipc";
|
||||
import type { ProjectFileSet, SearchContext, SearchQuery, SearchScope } from "@/platform/ipc";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
|
||||
export type ScopeTab = "project" | "directory" | "scope";
|
||||
@@ -31,14 +31,20 @@ export interface FindOptions {
|
||||
scopeTab: ScopeTab;
|
||||
directory: string;
|
||||
namedScope: NamedScope;
|
||||
/** IDEA's search context filter (funnel menu): anywhere, only / except comments and string literals. */
|
||||
context: SearchContext;
|
||||
}
|
||||
|
||||
interface FindPopupState extends FindOptions {
|
||||
open: boolean;
|
||||
replace: boolean;
|
||||
session: number;
|
||||
/** Title-bar pin: the popup stays open when a result is opened or the user clicks outside. */
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
const PINNED_KEY = "openidea.search.findPinned.v1";
|
||||
|
||||
export const useFindPopup = create<FindPopupState>(() => ({
|
||||
open: false,
|
||||
replace: false,
|
||||
@@ -54,8 +60,42 @@ export const useFindPopup = create<FindPopupState>(() => ({
|
||||
scopeTab: "project",
|
||||
directory: "",
|
||||
namedScope: "projectFiles",
|
||||
context: "any",
|
||||
pinned: loadJson<unknown>(PINNED_KEY, false) === true,
|
||||
}));
|
||||
|
||||
export function setFindPopupPinned(pinned: boolean) {
|
||||
useFindPopup.setState({ pinned });
|
||||
saveJson(PINNED_KEY, pinned);
|
||||
}
|
||||
|
||||
export const SEARCH_CONTEXTS: ReadonlyArray<{ value: SearchContext; label: string }> = [
|
||||
{ value: "any", label: "Anywhere" },
|
||||
{ value: "inComments", label: "In Comments" },
|
||||
{ value: "inStringLiterals", label: "In String Literals" },
|
||||
{ value: "exceptComments", label: "Except Comments" },
|
||||
{ value: "exceptStringLiterals", label: "Except String Literals" },
|
||||
{ value: "exceptCommentsAndStringLiterals", label: "Except Comments and String Literals" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys the popup handles that are also global shortcuts (⌘G / ⇧⌘G, F4 / ⌘↓, ⌥↓):
|
||||
* hidden actions bound later in the keymap win while the popup is open and
|
||||
* forward here.
|
||||
*/
|
||||
export type FindPopupCommand = "nextResult" | "previousResult" | "openResult" | "recentSearches";
|
||||
let commandHandler: ((c: FindPopupCommand) => void) | null = null;
|
||||
|
||||
export function setFindPopupCommandHandler(handler: ((c: FindPopupCommand) => void) | null) {
|
||||
commandHandler = handler;
|
||||
}
|
||||
|
||||
export function runFindPopupCommand(c: FindPopupCommand): boolean {
|
||||
if (!useFindPopup.getState().open || !commandHandler) return false;
|
||||
commandHandler(c);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface FindArg {
|
||||
directory?: string;
|
||||
query?: string;
|
||||
@@ -105,8 +145,22 @@ export function rememberMask(mask: string) {
|
||||
saveJson(MASKS_KEY, [m, ...recent.filter((x) => x !== m)].slice(0, 10));
|
||||
}
|
||||
|
||||
const HISTORY_KEYS = { find: "openidea.search.findHistory.v1", replace: "openidea.search.replaceHistory.v1" };
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
/** Recent search (or replace) strings of the Find in Files popup, newest first (⌥↓). */
|
||||
export function findHistory(kind: "find" | "replace"): string[] {
|
||||
const v = loadJson<unknown>(HISTORY_KEYS[kind], []);
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
export function rememberFindString(kind: "find" | "replace", text: string) {
|
||||
if (!text.trim()) return;
|
||||
saveJson(HISTORY_KEYS[kind], [text, ...findHistory(kind).filter((x) => x !== text)].slice(0, MAX_HISTORY));
|
||||
}
|
||||
|
||||
export function buildQuery(o: FindOptions, scope: SearchScope, maxMatches: number): SearchQuery {
|
||||
return {
|
||||
const q: SearchQuery = {
|
||||
pattern: o.pattern,
|
||||
isRegex: o.isRegex,
|
||||
caseSensitive: o.caseSensitive,
|
||||
@@ -116,6 +170,8 @@ export function buildQuery(o: FindOptions, scope: SearchScope, maxMatches: numbe
|
||||
scope,
|
||||
maxMatches,
|
||||
};
|
||||
if (o.context !== "any") q.context = o.context;
|
||||
return q;
|
||||
}
|
||||
|
||||
export function describeQuery(q: SearchQuery, root: string | null): string {
|
||||
@@ -127,5 +183,6 @@ export function describeQuery(q: SearchQuery, root: string | null): string {
|
||||
: q.scope.kind === "fileSet"
|
||||
? FILE_SET_LABELS[q.scope.set]
|
||||
: `${q.scope.paths.length} files`;
|
||||
return `'${q.pattern}' in ${where}${q.include.length ? ` (${q.include.join(", ")})` : ""}`;
|
||||
const context = q.context && q.context !== "any" ? SEARCH_CONTEXTS.find((c) => c.value === q.context)?.label : null;
|
||||
return `'${q.pattern}' in ${where}${q.include.length ? ` (${q.include.join(", ")})` : ""}${context ? `, ${context.toLowerCase()}` : ""}`;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
/** Pinned Find results ("Open in Find Window"), one tab per search. */
|
||||
/**
|
||||
* Find tool window results ("Open in Find Window"), one tab per search. As in
|
||||
* IDEA a new search replaces the selected tab unless that tab is pinned.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { call, commands, nextRequestId, type SearchQuery } from "@/platform/ipc";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { loadJson, saveJson } from "../common/open";
|
||||
import { maybeToolWindowService } from "../common/services";
|
||||
import { cancelRequest, streamSearch } from "../common/textSearch";
|
||||
import { FindSession, IDLE, type FindDeps, type FindState } from "./session";
|
||||
@@ -12,6 +17,8 @@ export interface FindTab {
|
||||
replacement: string | null;
|
||||
/** Replace keeps the case of each occurrence. */
|
||||
preserveCase?: boolean;
|
||||
/** Kept when a new search runs (Pin Tab). */
|
||||
pinned?: boolean;
|
||||
state: FindState;
|
||||
}
|
||||
|
||||
@@ -50,7 +57,13 @@ export function openInFindWindow(
|
||||
const session = new FindSession(deps, (state) => patchTab(id, { state }));
|
||||
sessions.set(id, session);
|
||||
useFindWindow.setState((s) => {
|
||||
let tabs = [...s.tabs, { id, title, query, replacement, preserveCase, state: IDLE }];
|
||||
const tab: FindTab = { id, title, query, replacement, preserveCase, state: IDLE };
|
||||
const reused = s.tabs.find((t) => t.id === s.active && !t.pinned);
|
||||
if (reused) {
|
||||
sessions.get(reused.id)?.dispose();
|
||||
sessions.delete(reused.id);
|
||||
}
|
||||
let tabs = reused ? s.tabs.map((t) => (t === reused ? tab : t)) : [...s.tabs, tab];
|
||||
while (tabs.length > MAX_TABS) {
|
||||
const old = tabs[0]!;
|
||||
sessions.get(old.id)?.dispose();
|
||||
@@ -64,6 +77,17 @@ export function openInFindWindow(
|
||||
return id;
|
||||
}
|
||||
|
||||
export function pinFindTab(id: number, pinned: boolean) {
|
||||
patchTab(id, { pinned });
|
||||
}
|
||||
|
||||
/** Rerun of the selected tab (⌘R in the Find tool window). */
|
||||
export function rerunActiveFindTab() {
|
||||
const s = useFindWindow.getState();
|
||||
const id = s.active ?? s.tabs.at(-1)?.id;
|
||||
if (id != null) rerunFindTab(id);
|
||||
}
|
||||
|
||||
export function rerunFindTab(id: number) {
|
||||
const tab = useFindWindow.getState().tabs.find((t) => t.id === id);
|
||||
if (tab) void sessions.get(id)?.search(tab.query);
|
||||
@@ -89,3 +113,46 @@ export function disposeFindWindow() {
|
||||
sessions.clear();
|
||||
useFindWindow.setState({ tabs: [], active: null });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// View options (persisted), in the tool window's ⋮ menu.
|
||||
|
||||
const OPTIONS_KEY = "openidea.find.view.v1";
|
||||
|
||||
export interface FindViewOptions {
|
||||
/** Code preview beside the results (IDEA: on). */
|
||||
preview: boolean;
|
||||
/** Group files by directory; off lists files flat (IDEA's default). */
|
||||
groupByDirectory: boolean;
|
||||
}
|
||||
|
||||
export const useFindViewOptions = create<FindViewOptions>(() => ({
|
||||
preview: true,
|
||||
groupByDirectory: false,
|
||||
...loadJson<Partial<FindViewOptions>>(OPTIONS_KEY, {}),
|
||||
}));
|
||||
|
||||
useFindViewOptions.subscribe((s) =>
|
||||
saveJson(OPTIONS_KEY, { preview: s.preview, groupByDirectory: s.groupByDirectory }),
|
||||
);
|
||||
|
||||
/** The Find tool window's ⋮ menu entries. */
|
||||
export function useFindHeaderMenu(): MenuItem[] {
|
||||
const options = useFindViewOptions();
|
||||
return [
|
||||
{ kind: "header", label: "Group By" },
|
||||
{
|
||||
id: "groupByDirectory",
|
||||
label: "Directory",
|
||||
checked: options.groupByDirectory,
|
||||
run: () => useFindViewOptions.setState({ groupByDirectory: !options.groupByDirectory }),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
id: "preview",
|
||||
label: "Preview Source",
|
||||
checked: options.preview,
|
||||
run: () => useFindViewOptions.setState({ preview: !options.preview }),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -10,24 +10,28 @@ import { shortcutLabel } from "@/platform/keymap";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { BookmarkPopupsOverlay } from "./bookmarks/BookmarkPopups";
|
||||
import { bookmarkGutter } from "./bookmarks/gutter";
|
||||
import {
|
||||
bookmarkByMnemonic,
|
||||
hasBookmark,
|
||||
loadBookmarks,
|
||||
renameBookmarks,
|
||||
toggleBookmark,
|
||||
toggleMnemonic,
|
||||
updateBookmarks,
|
||||
useBookmarkPopups,
|
||||
useBookmarks,
|
||||
type Bookmark,
|
||||
} from "./bookmarks/store";
|
||||
import { maybeToolWindowService } from "./common/services";
|
||||
import { openLocation } from "./common/open";
|
||||
import { maybeToolWindowService } from "./common/services";
|
||||
import { installTracking, resetTracking } from "./common/tracking";
|
||||
import { SearchEverywhereOverlay } from "./everywhere/SearchEverywhere";
|
||||
import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store";
|
||||
import { FindInFilesOverlay } from "./find/FindInFiles";
|
||||
import { hideFindPopup, parseFindArg, showFindPopup, useFindPopup } from "./find/store";
|
||||
import { disposeFindWindow } from "./find/windowStore";
|
||||
import { hideFindPopup, parseFindArg, runFindPopupCommand, showFindPopup, useFindPopup } from "./find/store";
|
||||
import { disposeFindWindow, rerunActiveFindTab, useFindHeaderMenu } from "./find/windowStore";
|
||||
import { RecentPopupsOverlay } from "./recent/RecentPopups";
|
||||
import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store";
|
||||
import { FileStructureOverlay, useFileStructurePopup } from "./structure/StructureView";
|
||||
@@ -42,9 +46,34 @@ function editorSelectionText(): string | null {
|
||||
return text.includes("\n") ? null : text;
|
||||
}
|
||||
|
||||
type PopupKind = "everywhere" | "recent" | "find" | "fileStructure";
|
||||
/** The active editor's caret line (and where to show a popup for it). */
|
||||
function caretLine(): { path: string; line: number; text: string; at?: { x: number; y: number } } | null {
|
||||
const handle = activeEditor();
|
||||
if (!handle) return null;
|
||||
const head = handle.view.state.selection.main.head;
|
||||
const line = handle.view.state.doc.lineAt(head);
|
||||
const coords = handle.view.coordsAtPos(head);
|
||||
return {
|
||||
path: handle.ctx.path,
|
||||
line: line.number - 1,
|
||||
text: line.text,
|
||||
at: coords ? { x: coords.left, y: coords.bottom + 2 } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Bookmark description from the editor selection (F3 on a selection, as in IDEA). */
|
||||
function selectionDescription(): string | null {
|
||||
const view = activeEditor()?.view;
|
||||
if (!view) return null;
|
||||
const { from, to } = view.state.selection.main;
|
||||
const text = view.state.sliceDoc(from, Math.min(to, from + 1000)).replace(/\s+/g, " ").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
type PopupKind = "everywhere" | "recent" | "find" | "fileStructure" | "bookmarks";
|
||||
|
||||
function closeAllPopups(except?: PopupKind) {
|
||||
if (except !== "bookmarks") useBookmarkPopups.setState({ chooser: null, list: false });
|
||||
if (except !== "everywhere") hideSearchEverywhere();
|
||||
if (except !== "recent") hideRecentPopup();
|
||||
if (except !== "find") hideFindPopup();
|
||||
@@ -52,6 +81,12 @@ function closeAllPopups(except?: PopupKind) {
|
||||
useFileStructurePopup.setState({ open: false });
|
||||
}
|
||||
|
||||
function lineText(path: string, line: number): string {
|
||||
const handle = activeEditor();
|
||||
const doc = handle?.ctx.path === path ? handle.view.state.doc : null;
|
||||
return doc && line < doc.lines ? doc.line(line + 1).text : "";
|
||||
}
|
||||
|
||||
function jumpBookmark(dir: 1 | -1) {
|
||||
const handle = activeEditor();
|
||||
const items = useBookmarks.getState().items;
|
||||
@@ -81,6 +116,7 @@ export function activate(): void | (() => void) {
|
||||
registerOverlay({ id: "search.recent", component: RecentPopupsOverlay }),
|
||||
registerOverlay({ id: "search.findInFiles", component: FindInFilesOverlay }),
|
||||
registerOverlay({ id: "search.fileStructure", component: FileStructureOverlay }),
|
||||
registerOverlay({ id: "search.bookmarks", component: BookmarkPopupsOverlay }),
|
||||
|
||||
registerToolWindow({
|
||||
id: "structure",
|
||||
@@ -114,12 +150,29 @@ export function activate(): void | (() => void) {
|
||||
component: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindToolWindow)),
|
||||
headerContent: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindTabs)),
|
||||
headerActions: lazyComponent(() => import("./find/FindToolWindow").then((m) => m.FindToolWindowActions)),
|
||||
headerMenu: useFindHeaderMenu,
|
||||
}),
|
||||
|
||||
registerEditorContribution({
|
||||
id: "search.bookmarks",
|
||||
order: 50,
|
||||
extensions: (ctx) => bookmarkGutter(ctx.path),
|
||||
extensions: (ctx) =>
|
||||
bookmarkGutter(ctx.path, (line) => void executeAction("navigate.toggleBookmark", { path: ctx.path, line })),
|
||||
}),
|
||||
registerMenuGroup({
|
||||
// Select In › Structure: the Structure tool window follows the active editor.
|
||||
id: "search.structure.selectIn",
|
||||
menus: ["selectIn"],
|
||||
items: (_menu, { paths: [path] }) =>
|
||||
path && activeEditor()?.ctx.path === path
|
||||
? [
|
||||
{
|
||||
label: "Structure",
|
||||
icon: "toolwindow-structure",
|
||||
run: () => maybeToolWindowService()?.show("structure", true),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
registerMenuGroup({
|
||||
id: "search.bookmarks",
|
||||
@@ -244,6 +297,15 @@ export function activate(): void | (() => void) {
|
||||
showFindPopup(false, { ...parseFindArg(arg), selection: editorSelectionText() });
|
||||
},
|
||||
},
|
||||
{
|
||||
// Rerun the selected search; ⌘R in the Find tool window, as in IDEA.
|
||||
id: "find.rerun",
|
||||
title: "Rerun Search",
|
||||
category: "Find",
|
||||
icon: "refresh",
|
||||
when: "projectOpen && activeToolWindow == 'find'",
|
||||
run: rerunActiveFindTab,
|
||||
},
|
||||
{
|
||||
id: "find.replaceInPath",
|
||||
title: "Replace in Files…",
|
||||
@@ -255,6 +317,22 @@ export function activate(): void | (() => void) {
|
||||
showFindPopup(true, { ...parseFindArg(arg), selection: editorSelectionText() });
|
||||
},
|
||||
},
|
||||
// Keys of the Find in Files popup that are global shortcuts elsewhere (bound after them).
|
||||
...(
|
||||
[
|
||||
["find.popup.nextResult", "Next Result", "nextResult"],
|
||||
["find.popup.previousResult", "Previous Result", "previousResult"],
|
||||
["find.popup.openResult", "Open Result", "openResult"],
|
||||
["find.popup.recentSearches", "Recent Searches", "recentSearches"],
|
||||
] as const
|
||||
).map(([id, title, command]) => ({
|
||||
id,
|
||||
title,
|
||||
category: "Find",
|
||||
hidden: true,
|
||||
when: "findPopupOpen",
|
||||
run: () => void runFindPopupCommand(command),
|
||||
})),
|
||||
{
|
||||
id: "navigate.fileStructure",
|
||||
title: "File Structure",
|
||||
@@ -262,8 +340,10 @@ export function activate(): void | (() => void) {
|
||||
icon: "structure",
|
||||
keywords: ["outline", "members"],
|
||||
run: () => {
|
||||
// Pressed again IDEA toggles "Show inherited members"; outlines have none, so the popup stays.
|
||||
if (useFileStructurePopup.getState().open) return;
|
||||
closeAllPopups("fileStructure");
|
||||
useFileStructurePopup.setState((s) => ({ open: !s.open }));
|
||||
useFileStructurePopup.setState({ open: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -276,22 +356,60 @@ export function activate(): void | (() => void) {
|
||||
const a = (arg ?? {}) as { path?: unknown; line?: unknown };
|
||||
if (typeof a.path === "string" && typeof a.line === "number") {
|
||||
const { path, line } = a as { path: string; line: number };
|
||||
updateBookmarks((list) => toggleBookmark(list, path, line, ""));
|
||||
updateBookmarks((list) => toggleBookmark(list, path, line, lineText(path, line)));
|
||||
return;
|
||||
}
|
||||
const handle = activeEditor();
|
||||
if (!handle) return;
|
||||
const line = handle.view.state.doc.lineAt(handle.view.state.selection.main.head);
|
||||
updateBookmarks((list) => toggleBookmark(list, handle.ctx.path, line.number - 1, line.text));
|
||||
const at = caretLine();
|
||||
if (!at) return;
|
||||
const description = selectionDescription();
|
||||
updateBookmarks((list) => toggleBookmark(list, at.path, at.line, at.text, Date.now(), description));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "navigate.toggleBookmarkWithMnemonic",
|
||||
title: "Toggle Bookmark with Mnemonic",
|
||||
category: "Bookmarks",
|
||||
when: "projectOpen && textEditorActive",
|
||||
run: () => {
|
||||
const at = caretLine();
|
||||
if (!at) return;
|
||||
closeAllPopups("bookmarks");
|
||||
useBookmarkPopups.setState({ chooser: at });
|
||||
},
|
||||
},
|
||||
...[..."0123456789"].flatMap((d) => [
|
||||
{
|
||||
id: `navigate.toggleBookmark${d}`,
|
||||
title: `Toggle Bookmark ${d}`,
|
||||
category: "Bookmarks",
|
||||
when: "projectOpen && textEditorActive",
|
||||
run: () => {
|
||||
const at = caretLine();
|
||||
if (at) updateBookmarks((list) => toggleMnemonic(list, at.path, at.line, at.text, d));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `navigate.gotoBookmark${d}`,
|
||||
title: `Go to Bookmark ${d}`,
|
||||
category: "Bookmarks",
|
||||
when: "projectOpen",
|
||||
run: () => {
|
||||
const b = bookmarkByMnemonic(useBookmarks.getState().items, d);
|
||||
if (b) void openLocation(b.path, { line: b.line, col: 0 });
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
// ⌘F3 opens the Bookmarks popup, as IDEA; the tool window stays on its own shortcut.
|
||||
id: "navigate.showBookmarks",
|
||||
title: "Show Bookmarks",
|
||||
category: "Bookmarks",
|
||||
icon: "bookmark",
|
||||
when: "projectOpen",
|
||||
run: () => maybeToolWindowService()?.show("bookmarks", true),
|
||||
run: () => {
|
||||
closeAllPopups("bookmarks");
|
||||
useBookmarkPopups.setState({ list: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "navigate.nextBookmark",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import clsx from "clsx";
|
||||
import { editorsFor, getNavigationHistory } from "@/editor/api";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
type ToolWindowContribution,
|
||||
} from "@/workbench/contributions";
|
||||
import { basename, openLocation } from "../common/open";
|
||||
import { fromNavigationHistory, mergeLocations, type RecentLocation } from "../common/locations";
|
||||
import { useTracking } from "../common/tracking";
|
||||
import { fromNavigationHistory, isDismissed, mergeLocations, type RecentLocation } from "../common/locations";
|
||||
import { dismissLocation, useTracking } from "../common/tracking";
|
||||
import {
|
||||
filterLocations,
|
||||
filterRecentFiles,
|
||||
@@ -370,14 +370,16 @@ function dedent(lines: readonly string[]): string[] {
|
||||
function RecentLocationsDialog() {
|
||||
const changedOnly = useRecentPopup((s) => s.changedOnly);
|
||||
const recorded = useTracking((s) => s.locations);
|
||||
const dismissed = useTracking((s) => s.dismissed);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [index, setIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const source = useMemo(() => {
|
||||
return withSnippets(mergeLocations(navigationHistory(), recorded), recorded);
|
||||
}, [recorded]);
|
||||
const all = withSnippets(mergeLocations(navigationHistory(), recorded), recorded);
|
||||
return dismissed.length ? all.filter((l) => !isDismissed(l, dismissed)) : all;
|
||||
}, [recorded, dismissed]);
|
||||
const items = useMemo(
|
||||
() => filterLocations(source, query, { changedOnly, root }),
|
||||
[source, query, changedOnly, root],
|
||||
@@ -394,6 +396,12 @@ function RecentLocationsDialog() {
|
||||
open(items[active], e.shiftKey);
|
||||
return;
|
||||
}
|
||||
// IDEA: Delete removes the selected place (Backspace too while there is no filter text).
|
||||
if ((e.key === "Delete" || (e.key === "Backspace" && !query)) && items[active]) {
|
||||
e.preventDefault();
|
||||
dismissLocation(items[active]!);
|
||||
return;
|
||||
}
|
||||
if (!NAV_KEYS.includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const next = listNavigate(e.key, { items, active, wrap: true, pageSize: 4 });
|
||||
@@ -441,7 +449,11 @@ function RecentLocationsDialog() {
|
||||
getKey={(l) => `${l.path}:${l.line}:${l.time}`}
|
||||
activeIndex={active}
|
||||
onActiveChange={setIndex}
|
||||
onActivate={(l, _i, e) => open(l, e.shiftKey)}
|
||||
onActivate={(l, _i, e) => {
|
||||
// A click selects (and previews the place); a double click opens it.
|
||||
if (e.type === "click" && (e as ReactMouseEvent).detail < 2) return;
|
||||
open(l, e.shiftKey);
|
||||
}}
|
||||
focusable={false}
|
||||
rowHeight={LOCATION_ROW}
|
||||
aria-label="Recent locations"
|
||||
|
||||
@@ -100,3 +100,16 @@
|
||||
.popupList > * {
|
||||
flex: 1;
|
||||
}
|
||||
.popupOptions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-popup);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.popupEmpty {
|
||||
padding: 4px 12px;
|
||||
color: var(--fg-faint);
|
||||
}
|
||||
@@ -3,16 +3,15 @@ import { create } from "zustand";
|
||||
import clsx from "clsx";
|
||||
import { shortcutLabel } from "@/platform/keymap";
|
||||
import {
|
||||
Checkbox,
|
||||
Dialog,
|
||||
EmptyState,
|
||||
HighlightedText,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
SymbolBadge,
|
||||
Tree,
|
||||
fileIconName,
|
||||
listNavigate,
|
||||
type TreeHandle,
|
||||
T,
|
||||
} from "@/ui";
|
||||
@@ -20,12 +19,12 @@ import { badgeKind, basename, openLocation } from "../common/open";
|
||||
import { useTracking } from "../common/tracking";
|
||||
import {
|
||||
allKeys,
|
||||
bestRow,
|
||||
flattenOutline,
|
||||
filterOutline,
|
||||
keyOutline,
|
||||
parentKeys,
|
||||
sortOutline,
|
||||
symbolPathAt,
|
||||
type FlatOutlineRow,
|
||||
visibleCount,
|
||||
type OutlineNode,
|
||||
} from "./outline";
|
||||
import { useLiveOutline, useOutline, useStructureOptions } from "./store";
|
||||
@@ -64,7 +63,7 @@ export function StructureView() {
|
||||
const loading = useOutline((s) => s.loading);
|
||||
const error = useOutline((s) => s.error);
|
||||
const alphabetical = useStructureOptions((s) => s.alphabetical);
|
||||
const autoscroll = useStructureOptions((s) => s.autoscroll);
|
||||
const autoscroll = useStructureOptions((s) => s.autoscrollFromSource);
|
||||
const caret = useTracking((s) => s.caret);
|
||||
const collapsed = useCollapsed((s) => s.keys);
|
||||
const nodes = useOutlineNodes(alphabetical);
|
||||
@@ -128,7 +127,8 @@ export function StructureView() {
|
||||
expanded={expanded}
|
||||
onExpandedChange={(next) => useCollapsed.setState({ keys: new Set(expandable.filter((k) => !next.has(k))) })}
|
||||
onSelectionChange={(_next, lead) => {
|
||||
if (!revealing.current && lead && !isFile(lead)) navigate(path, lead, false);
|
||||
if (revealing.current || !lead || isFile(lead)) return;
|
||||
if (useStructureOptions.getState().autoscrollToSource) navigate(path, lead, false);
|
||||
}}
|
||||
multiSelect={false}
|
||||
activateExpandable
|
||||
@@ -164,7 +164,8 @@ export function StructureView() {
|
||||
|
||||
function StructureToolbar() {
|
||||
const alphabetical = useStructureOptions((s) => s.alphabetical);
|
||||
const autoscroll = useStructureOptions((s) => s.autoscroll);
|
||||
const autoscroll = useStructureOptions((s) => s.autoscrollFromSource);
|
||||
const autoscrollToSource = useStructureOptions((s) => s.autoscrollToSource);
|
||||
const items = useOutline((s) => s.items);
|
||||
return (
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Structure actions">
|
||||
@@ -178,12 +179,20 @@ function StructureToolbar() {
|
||||
/>
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="locate"
|
||||
icon="autoscroll-to-source"
|
||||
title="Autoscroll to Source"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
pressed={autoscrollToSource}
|
||||
onClick={() => useStructureOptions.setState({ autoscrollToSource: !autoscrollToSource })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="autoscroll-from-source"
|
||||
title="Autoscroll from Source"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
pressed={autoscroll}
|
||||
onClick={() => useStructureOptions.setState({ autoscroll: !autoscroll })}
|
||||
onClick={() => useStructureOptions.setState({ autoscrollFromSource: !autoscroll })}
|
||||
/>
|
||||
<span className={styles.grow} />
|
||||
<IconButton
|
||||
@@ -222,43 +231,96 @@ function FileStructureDialog() {
|
||||
const items = useOutline((s) => s.items);
|
||||
const loading = useOutline((s) => s.loading);
|
||||
const alphabetical = useStructureOptions((s) => s.alphabetical);
|
||||
const narrowDown = useStructureOptions((s) => s.narrowDown);
|
||||
const nodes = useOutlineNodes(alphabetical);
|
||||
const [query, setQuery] = useState("");
|
||||
const [index, setIndex] = useState<number | null>(null);
|
||||
// IDEA opens the popup fully expanded.
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const caret = useMemo(() => useTracking.getState().caret, []);
|
||||
const rows = useMemo(() => flattenOutline(nodes, query), [nodes, query]);
|
||||
const tree = useRef<TreeHandle>(null);
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (query) return Math.max(0, bestRow(rows));
|
||||
if (!caret || caret.path !== path) return 0;
|
||||
const key = symbolPathAt(nodes, { line: caret.line, col: caret.col }).pop();
|
||||
return Math.max(
|
||||
0,
|
||||
rows.findIndex((r) => r.node.key === key),
|
||||
);
|
||||
}, [rows, nodes, query, caret, path]);
|
||||
const active = rows.length ? Math.min(index ?? initial, rows.length - 1) : -1;
|
||||
const filter = useMemo(() => filterOutline(nodes, query, narrowDown), [nodes, query, narrowDown]);
|
||||
const parents = useMemo(() => parentKeys(filter.nodes), [filter.nodes]);
|
||||
const byKey = useMemo(() => {
|
||||
const m = new Map<string, OutlineNode>();
|
||||
const walk = (list: readonly OutlineNode[]) => list.forEach((n) => (m.set(n.key, n), walk(n.children)));
|
||||
walk(filter.nodes);
|
||||
return m;
|
||||
}, [filter.nodes]);
|
||||
const expanded = useMemo(() => new Set(allKeys(filter.nodes).filter((k) => !collapsed.has(k))), [filter.nodes, collapsed]);
|
||||
|
||||
// Preselect the symbol at the caret, then the best match while filtering.
|
||||
const caretKey = useMemo(
|
||||
() => (caret && caret.path === path ? (symbolPathAt(nodes, { line: caret.line, col: caret.col }).pop() ?? null) : null),
|
||||
[caret, path, nodes],
|
||||
);
|
||||
const preselect = query.trim() ? filter.best : caretKey;
|
||||
useEffect(() => {
|
||||
const key = preselect ?? filter.nodes[0]?.key;
|
||||
if (key) tree.current?.reveal(key);
|
||||
}, [preselect, filter.nodes]);
|
||||
|
||||
const close = () => useFileStructurePopup.setState({ open: false });
|
||||
const choose = (row: FlatOutlineRow | undefined) => {
|
||||
if (!row || !path) return;
|
||||
const choose = (node: OutlineNode | undefined) => {
|
||||
if (!node || !path) return;
|
||||
close();
|
||||
setTimeout(() => navigate(path, row.node, true), 0);
|
||||
setTimeout(() => navigate(path, node, true), 0);
|
||||
};
|
||||
const setExpanded = (key: string, open: boolean) =>
|
||||
setCollapsed((c) => {
|
||||
const next = new Set(c);
|
||||
if (open) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(rows[active]);
|
||||
return;
|
||||
const lead = tree.current?.leadKey() ?? null;
|
||||
const node = lead ? byKey.get(lead) : undefined;
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
choose(node);
|
||||
break;
|
||||
case "Escape":
|
||||
// The first Esc clears the filter, the next one closes the popup.
|
||||
if (!query) return;
|
||||
e.stopPropagation();
|
||||
setQuery("");
|
||||
break;
|
||||
case "ArrowDown":
|
||||
case "ArrowUp": {
|
||||
const dir = e.key === "ArrowDown" ? 1 : -1;
|
||||
if (query.trim() && !narrowDown && filter.order.length) {
|
||||
// Highlight mode: ↑/↓ jump between matches.
|
||||
const at = lead ? filter.order.indexOf(lead) : -1;
|
||||
const n = filter.order.length;
|
||||
const next = filter.order[at < 0 ? (dir > 0 ? 0 : n - 1) : (at + dir + n) % n]!;
|
||||
for (let p = parents.get(next); p; p = parents.get(p) ?? null) if (collapsed.has(p)) setExpanded(p, true);
|
||||
setTimeout(() => tree.current?.reveal(next), 0);
|
||||
} else tree.current?.moveSelection(dir);
|
||||
break;
|
||||
}
|
||||
case "ArrowRight":
|
||||
if (!node || !node.children.length) return;
|
||||
if (!expanded.has(node.key)) setExpanded(node.key, true);
|
||||
else tree.current?.reveal(node.children[0]!.key);
|
||||
break;
|
||||
case "ArrowLeft": {
|
||||
if (!node) return;
|
||||
if (node.children.length && expanded.has(node.key)) setExpanded(node.key, false);
|
||||
else {
|
||||
const parent = parents.get(node.key);
|
||||
if (parent) tree.current?.reveal(parent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (!["ArrowDown", "ArrowUp", "PageDown", "PageUp"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const next = listNavigate(e.key, { items: rows, active, wrap: true });
|
||||
if (next !== null) setIndex(next);
|
||||
};
|
||||
|
||||
const height = Math.min(420, Math.max(1, rows.length) * POPUP_ROW + 8);
|
||||
const height = Math.min(420, Math.max(1, visibleCount(filter.nodes, collapsed)) * POPUP_ROW + 8);
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -285,7 +347,7 @@ function FileStructureDialog() {
|
||||
autoComplete="off"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIndex(null);
|
||||
setCollapsed(new Set());
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
@@ -297,29 +359,46 @@ function FileStructureDialog() {
|
||||
onClick={() => useStructureOptions.setState({ alphabetical: !alphabetical })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.popupOptions}>
|
||||
<Checkbox
|
||||
label="Narrow down on typing"
|
||||
checked={narrowDown}
|
||||
onChange={(v) => useStructureOptions.setState({ narrowDown: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.popupList} style={{ height }}>
|
||||
<List<FlatOutlineRow>
|
||||
items={rows}
|
||||
getKey={(r) => r.node.key}
|
||||
activeIndex={active}
|
||||
onActiveChange={setIndex}
|
||||
onActivate={(r) => choose(r)}
|
||||
focusable={false}
|
||||
rowHeight={POPUP_ROW}
|
||||
<Tree<OutlineNode>
|
||||
handleRef={tree}
|
||||
aria-label="Symbols"
|
||||
empty={<span>{!path ? "Open a file first" : loading && !items ? "Loading…" : "No symbols"}</span>}
|
||||
renderItem={(r, s) => (
|
||||
<span className={clsx(styles.node, !r.matched && styles.ancestor)} style={{ paddingLeft: r.depth * 14 }}>
|
||||
<SymbolBadge kind={badgeKind(r.node.item.kind)} className={styles.badge} />
|
||||
<HighlightedText
|
||||
className={styles.name}
|
||||
text={r.node.item.name}
|
||||
indices={r.indices}
|
||||
tone={s.active ? "accent" : "strong"}
|
||||
/>
|
||||
{r.node.item.detail && <span className={styles.detail}>{r.node.item.detail}</span>}
|
||||
</span>
|
||||
)}
|
||||
roots={filter.nodes}
|
||||
getKey={(n) => n.key}
|
||||
getLabel={(n) => n.item.name}
|
||||
getChildren={(n) => (n.children.length ? n.children : undefined)}
|
||||
isExpandable={(n) => n.children.length > 0}
|
||||
expanded={expanded}
|
||||
onExpandedChange={(_next, n, open) => setExpanded(n.key, open)}
|
||||
multiSelect={false}
|
||||
speedSearch={false}
|
||||
activateExpandable
|
||||
onActivate={(n) => choose(n)}
|
||||
rowHeight={POPUP_ROW}
|
||||
indent={16}
|
||||
empty={<span className={styles.popupEmpty}>{!path ? "Open a file first" : loading && !items ? "Loading…" : "No symbols"}</span>}
|
||||
renderNode={(n, s) => {
|
||||
const matched = filter.matches.get(n.key);
|
||||
return (
|
||||
<span className={clsx(styles.node, query.trim() && !matched && styles.ancestor)}>
|
||||
<SymbolBadge kind={badgeKind(n.item.kind)} className={styles.badge} />
|
||||
<HighlightedText
|
||||
className={styles.name}
|
||||
text={n.item.name}
|
||||
indices={matched}
|
||||
tone={s.selected ? "accent" : "strong"}
|
||||
/>
|
||||
{n.item.detail && <span className={styles.detail}>{n.item.detail}</span>}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -47,41 +47,58 @@ export function allKeys(nodes: readonly OutlineNode[], out: string[] = []): stri
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface FlatOutlineRow {
|
||||
node: OutlineNode;
|
||||
depth: number;
|
||||
indices: number[];
|
||||
/** Matches the filter itself (vs. shown as an ancestor of a match). */
|
||||
matched: boolean;
|
||||
score: number;
|
||||
export interface OutlineFilter {
|
||||
/** The tree to show: with `narrow`, only matches and their ancestors. */
|
||||
nodes: OutlineNode[];
|
||||
/** Match indices into each matching node's name. */
|
||||
matches: Map<string, number[]>;
|
||||
/** Matching keys in tree order (↑/↓ cycle them when not narrowing). */
|
||||
order: string[];
|
||||
/** The best-scoring match (preselected while filtering). */
|
||||
best: string | null;
|
||||
}
|
||||
|
||||
/** Depth-first rows for the File Structure popup; with a filter, keeps matches and their ancestors. */
|
||||
export function flattenOutline(nodes: readonly OutlineNode[], query: string): FlatOutlineRow[] {
|
||||
const out: FlatOutlineRow[] = [];
|
||||
/** The File Structure popup's speed search: matches keep their ancestors; without `narrow` nothing is hidden. */
|
||||
export function filterOutline(nodes: readonly OutlineNode[], query: string, narrow: boolean): OutlineFilter {
|
||||
const q = query.trim();
|
||||
const walk = (list: readonly OutlineNode[], depth: number): boolean => {
|
||||
let any = false;
|
||||
const matches = new Map<string, number[]>();
|
||||
const order: string[] = [];
|
||||
let best: string | null = null;
|
||||
let bestScore = -Infinity;
|
||||
const walk = (list: readonly OutlineNode[]): OutlineNode[] => {
|
||||
const out: OutlineNode[] = [];
|
||||
for (const node of list) {
|
||||
const hit = q ? matchText(node.item.name, q) : { score: 0, indices: [] };
|
||||
const at = out.length;
|
||||
out.push({ node, depth, indices: hit?.indices ?? [], matched: !!hit, score: hit?.score ?? -Infinity });
|
||||
const child = walk(node.children, depth + 1);
|
||||
if (!hit && !child) out.splice(at, 1);
|
||||
else any = true;
|
||||
const hit = q ? matchText(node.item.name, q, { wordAligned: true }) : null;
|
||||
if (hit) {
|
||||
matches.set(node.key, hit.indices);
|
||||
order.push(node.key);
|
||||
if (hit.score > bestScore) {
|
||||
best = node.key;
|
||||
bestScore = hit.score;
|
||||
}
|
||||
}
|
||||
const children = walk(node.children);
|
||||
if (!q || !narrow || hit || children.length)
|
||||
out.push(narrow ? { ...node, children } : node);
|
||||
}
|
||||
return any;
|
||||
return out;
|
||||
};
|
||||
walk(nodes, 0);
|
||||
const filtered = walk(nodes);
|
||||
return { nodes: q && narrow ? filtered : (nodes as OutlineNode[]), matches, order, best };
|
||||
}
|
||||
|
||||
/** Parent key of every node (`null` for top-level ones). */
|
||||
export function parentKeys(nodes: readonly OutlineNode[], parent: string | null = null, out = new Map<string, string | null>()) {
|
||||
for (const n of nodes) {
|
||||
out.set(n.key, parent);
|
||||
parentKeys(n.children, n.key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Best-scoring matched row (the popup preselects it while filtering). */
|
||||
export function bestRow(rows: readonly FlatOutlineRow[]): number {
|
||||
let best = -1;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (!rows[i]!.matched) continue;
|
||||
if (best < 0 || rows[i]!.score > rows[best]!.score) best = i;
|
||||
}
|
||||
return best;
|
||||
/** Rows a tree of `nodes` shows with the `collapsed` keys collapsed. */
|
||||
export function visibleCount(nodes: readonly OutlineNode[], collapsed: ReadonlySet<string>): number {
|
||||
let n = 0;
|
||||
for (const node of nodes) n += 1 + (collapsed.has(node.key) ? 0 : visibleCount(node.children, collapsed));
|
||||
return n;
|
||||
}
|
||||
@@ -86,13 +86,27 @@ const OPTIONS_KEY = "openidea.structure.options.v1";
|
||||
|
||||
interface StructureOptions {
|
||||
alphabetical: boolean;
|
||||
autoscroll: boolean;
|
||||
/** Select the symbol at the caret (IDEA: off; replaces the old `autoscroll`, which defaulted on). */
|
||||
autoscrollFromSource: boolean;
|
||||
/** Selecting a symbol shows it in the editor (IDEA: on). */
|
||||
autoscrollToSource: boolean;
|
||||
/** File Structure popup: typing hides non-matching symbols (else only highlights them). */
|
||||
narrowDown: boolean;
|
||||
}
|
||||
|
||||
export const useStructureOptions = create<StructureOptions>(() => ({
|
||||
alphabetical: false,
|
||||
autoscroll: true,
|
||||
autoscrollFromSource: false,
|
||||
autoscrollToSource: true,
|
||||
narrowDown: true,
|
||||
...loadJson<Partial<StructureOptions>>(OPTIONS_KEY, {}),
|
||||
}));
|
||||
|
||||
useStructureOptions.subscribe((s) => saveJson(OPTIONS_KEY, { alphabetical: s.alphabetical, autoscroll: s.autoscroll }));
|
||||
useStructureOptions.subscribe((s) =>
|
||||
saveJson(OPTIONS_KEY, {
|
||||
alphabetical: s.alphabetical,
|
||||
autoscrollFromSource: s.autoscrollFromSource,
|
||||
autoscrollToSource: s.autoscrollToSource,
|
||||
narrowDown: s.narrowDown,
|
||||
}),
|
||||
);
|
||||
@@ -488,6 +488,7 @@ const SMART_KEYS = editorPage({
|
||||
options: [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "indentBlock", label: "Indent Block" },
|
||||
{ value: "indentEachLine", label: "Indent Each Line" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -234,6 +234,7 @@ export const EXECUTION_PAGES: PageSpec[] = [
|
||||
{
|
||||
options: [
|
||||
{ kind: "toggle", key: "run.confirmRerun", label: "Confirm rerun with process termination" },
|
||||
{ kind: "toggle", key: "run.confirmTerminate", label: "Confirm closing a tab with a running process" },
|
||||
{
|
||||
kind: "toggle",
|
||||
key: "run.showOnStart",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorSelection, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView, runScopeHandlers } from "@codemirror/view";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
import { languages } from "@codemirror/language-data";
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { CompletionContext, autocompletion, completionStatus, hasNextSnippetField } from "@codemirror/autocomplete";
|
||||
import {
|
||||
EXCLUSIVE_COMPLETION_KEY,
|
||||
expandLiveTemplateAtCaret,
|
||||
insertLiveTemplate,
|
||||
expandPostfixAtCaret,
|
||||
surroundWith,
|
||||
templateCompletionSource,
|
||||
@@ -27,7 +29,7 @@ afterEach(() => {
|
||||
closeChooser();
|
||||
});
|
||||
|
||||
async function editor(language: string, doc: string, cursor = doc.length) {
|
||||
async function editor(language: string, doc: string, cursor = doc.length, extra: Extension[] = []) {
|
||||
const support = await languages.find((l) => l.name === MODES[language])!.load();
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
@@ -42,6 +44,7 @@ async function editor(language: string, doc: string, cursor = doc.length) {
|
||||
uiMode: MODES[language]!,
|
||||
readOnly: false,
|
||||
}),
|
||||
...extra,
|
||||
],
|
||||
});
|
||||
ensureSyntaxTree(state, doc.length, 5000);
|
||||
@@ -50,7 +53,8 @@ async function editor(language: string, doc: string, cursor = doc.length) {
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
const tab = (v: EditorView) => runScopeHandlers(v, new KeyboardEvent("keydown", { key: "Tab" }), "editor");
|
||||
const press = (v: EditorView, key: string) => runScopeHandlers(v, new KeyboardEvent("keydown", { key }), "editor");
|
||||
const tab = (v: EditorView) => press(v, "Tab");
|
||||
|
||||
describe("Tab expansion", () => {
|
||||
it("expands a live template abbreviation", async () => {
|
||||
@@ -110,6 +114,83 @@ describe("surround with", () => {
|
||||
await tick();
|
||||
expect(v.state.doc.toString()).toBe("def f():\n if :\n a()\n b()\n \n");
|
||||
});
|
||||
|
||||
it("lists the language's surrounders with numeric mnemonics; a mnemonic key picks", async () => {
|
||||
const doc = "class A {\n void f() {\n a();\n }\n}\n";
|
||||
const v = await editor("java", doc, doc.indexOf("a();"));
|
||||
surroundWith(v);
|
||||
const req = useChooser.getState().request!;
|
||||
expect(req.mnemonics).toBe(true);
|
||||
expect(req.items.slice(0, 3).map((i) => [i.mnemonic, i.label])).toEqual([
|
||||
["1", "if"],
|
||||
["2", "if / else"],
|
||||
["3", "while"],
|
||||
]);
|
||||
// Expression surrounders need a selection within the line.
|
||||
expect(req.items.some((i) => i.label === "(expr)")).toBe(false);
|
||||
req.onPick(req.items.find((i) => i.mnemonic === "9")!, "");
|
||||
await tick();
|
||||
expect(v.state.doc.toString()).toContain("synchronized (this) {");
|
||||
});
|
||||
|
||||
it("offers expression surrounders for a selection inside a line", async () => {
|
||||
const doc = "fn main() {\n let x = a + b;\n}\n";
|
||||
const v = await editor("rust", doc);
|
||||
v.dispatch({ selection: EditorSelection.range(doc.indexOf("a + b"), doc.indexOf("a + b") + 5) });
|
||||
surroundWith(v);
|
||||
const labels = useChooser.getState().request!.items.map((i) => i.label);
|
||||
expect(labels).toEqual(["(expr)", "!(expr)"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("template fields", () => {
|
||||
const selected = (v: EditorView) => v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to);
|
||||
|
||||
it("Enter moves to the next field like Tab", async () => {
|
||||
const v = await editor("typescript", "fori");
|
||||
expect(tab(v)).toBe(true);
|
||||
await tick();
|
||||
expect(selected(v)).toBe("i");
|
||||
expect(press(v, "Enter")).toBe(true);
|
||||
expect(v.state.doc.toString()).toBe("for (let i = 0; i < .length; i++) {\n\t\n}".replace("\t", " "));
|
||||
expect(v.state.selection.main.head).toBe("for (let i = 0; i < ".length);
|
||||
});
|
||||
|
||||
it("Esc first drops the field's selection, then leaves the template", async () => {
|
||||
const v = await editor("typescript", "fori");
|
||||
tab(v);
|
||||
await tick();
|
||||
expect(press(v, "Escape")).toBe(true);
|
||||
expect(v.state.selection.main.empty).toBe(true);
|
||||
expect(hasNextSnippetField(v.state)).toBe(true);
|
||||
expect(press(v, "Escape")).toBe(true);
|
||||
expect(hasNextSnippetField(v.state)).toBe(false);
|
||||
// Outside a template Enter and Esc are left to the editor.
|
||||
expect(press(v, "Escape")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("⌘J Insert Live Template", () => {
|
||||
it("opens a lookup restricted to live templates and ends with it", async () => {
|
||||
const v = await editor("typescript", "function f() {\n ", undefined, [autocompletion()]);
|
||||
const exclusive = () =>
|
||||
v.state
|
||||
.languageDataAt<(s: EditorState) => unknown>(EXCLUSIVE_COMPLETION_KEY, v.state.selection.main.head)
|
||||
.map((f) => f(v.state))
|
||||
.find(Boolean);
|
||||
expect(exclusive()).toBeUndefined();
|
||||
insertLiveTemplate(v);
|
||||
expect(completionStatus(v.state)).not.toBeNull();
|
||||
expect(exclusive()).toBeTypeOf("function");
|
||||
// No word before the caret: every applicable template, from the caret.
|
||||
const r = await templateCompletionSource(new CompletionContext(v.state, v.state.doc.length, true));
|
||||
expect(r?.from).toBe(v.state.doc.length);
|
||||
expect(r?.options.map((o) => o.label)).toEqual(expect.arrayContaining(["log", "fori"]));
|
||||
v.dispatch({ selection: EditorSelection.cursor(0) });
|
||||
await tick();
|
||||
expect(completionStatus(v.state)).toBeNull();
|
||||
expect(exclusive()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("completion source", () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LiveTemplate } from "../live/model";
|
||||
import { surroundEntries, surroundersFor } from "../live/surrounders";
|
||||
|
||||
const template = (id: string, abbrev: string): LiveTemplate => ({
|
||||
id,
|
||||
abbrev,
|
||||
group: "User",
|
||||
description: "",
|
||||
body: "$SELECTION$",
|
||||
contexts: ["java"],
|
||||
});
|
||||
|
||||
describe("surround with entries", () => {
|
||||
it("numbers surrounders 1-9, 0, then letters", () => {
|
||||
const java = surroundersFor("java", { lines: true, inline: false });
|
||||
expect(java.length).toBeGreaterThan(10);
|
||||
const entries = surroundEntries(java, []);
|
||||
expect(entries.slice(0, 12).map((e) => e.mnemonic)).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "A", "B"]);
|
||||
});
|
||||
|
||||
it("gives templates the first abbreviation character not taken yet", () => {
|
||||
const surrounders = surroundersFor("java", { lines: true, inline: false }).slice(0, 11); // 1-9, 0, A
|
||||
const entries = surroundEntries(surrounders, [template("t1", "T"), template("t2", "at"), template("t3", "ta")]);
|
||||
expect(entries.slice(11).map((e) => [e.mnemonic, e.mnemonicIndex])).toEqual([
|
||||
["T", 0],
|
||||
[null, null],
|
||||
[null, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters statement and expression surrounders by the selection", () => {
|
||||
const kinds = (lines: boolean, inline: boolean) =>
|
||||
new Set(surroundersFor("typescript", { lines, inline }).map((s) => s.kind));
|
||||
expect(kinds(true, false)).toEqual(new Set(["statement"]));
|
||||
expect(kinds(false, true)).toEqual(new Set(["expression"]));
|
||||
expect(kinds(true, true)).toEqual(new Set(["statement", "expression"]));
|
||||
expect(surroundersFor("plaintext", { lines: true, inline: false })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -71,4 +71,40 @@ describe("dialogs", () => {
|
||||
fireEvent.keyDown(body, { key: "Enter" });
|
||||
expect(picked).toBe("b");
|
||||
});
|
||||
|
||||
it("chooser picks by mnemonic and skips section rows", () => {
|
||||
let picked = "";
|
||||
render(<ChooserHost />);
|
||||
act(() =>
|
||||
showChooser({
|
||||
title: "Surround With",
|
||||
anchor: { x: 0, y: 0 },
|
||||
mnemonics: true,
|
||||
items: [
|
||||
{ key: "if", label: "if", mnemonic: "1" },
|
||||
{ key: "s", label: "Live templates", section: true },
|
||||
{ key: "t", label: "T", detail: "tag", mnemonic: "T", mnemonicIndex: 0 },
|
||||
],
|
||||
onPick: (i) => (picked = i.key),
|
||||
}),
|
||||
);
|
||||
const body = screen.getByRole("dialog").querySelector("[tabindex='0']")!;
|
||||
fireEvent.keyDown(body, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(body, { key: "Enter" });
|
||||
expect(picked).toBe("t");
|
||||
act(() =>
|
||||
showChooser({
|
||||
title: "Surround With",
|
||||
anchor: { x: 0, y: 0 },
|
||||
mnemonics: true,
|
||||
items: [
|
||||
{ key: "if", label: "if", mnemonic: "1" },
|
||||
{ key: "t", label: "T", mnemonic: "T", mnemonicIndex: 0 },
|
||||
],
|
||||
onPick: (i) => (picked = i.key),
|
||||
}),
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("dialog").querySelector("[tabindex='0']")!, { key: "t" });
|
||||
expect(picked).toBe("t");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача