/** * "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 { tokenSpans, useLineTokens } from "@/editor/lineTokens"; import { formatMessage, useTx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; import { basename, relativeDir, useProject } from "@/platform/project"; 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"; import { UsageAccessFilter, filterUsageAccess, supportsUsageAccess, useUsageAccessOptions } from "./UsageAccessFilter"; type Node = | { kind: "summary"; key: string } | { kind: "file"; key: string; group: UsageGroup } | { kind: "usage"; key: string; usage: Usage }; export function UsagePreview({ usage }: { usage: Usage }) { const { text, textCol, from, to } = trimPreview(usage); const tokens = useLineTokens({ path: usage.path, line: usage.line, col: textCol }, text); return ( {tokenSpans(text, 0, from, tokens, "b:")} {text.slice(from, to)} {tokenSpans(text, to, text.length, tokens, "a:")} ); } /** Tool window header: one content tab per search (`Usages of 'foo'`). */ export function UsagesTabs() { const tx = useTx(); const tabs = useUsages((s) => s.tabs); const search = useUsages(activeUsageSearch); if (!search) return null; const label = (t: (typeof tabs)[number]) => t.symbol ? formatMessage(tx("Usages of '{symbol}'"), { symbol: t.symbol }) : tx("Usages"); return ( ({ id: String(t.id), label: label(t), closable: true, icon: t.pinned ? "pin" : undefined, }))} activeId={String(search.id)} 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}:${u.sourceLocation?.range.end.line ?? u.line}:${u.sourceLocation?.range.end.col ?? u.endCol}`; /** Excluded (⌫) usages per search. */ const useExcluded = create<{ byTab: Record> }>(() => ({ byTab: {} })); const NONE: ReadonlySet = 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, usages: readonly Usage[]): Set { 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(activeUsageSearch); const root = useProject((s) => s.project?.root ?? ""); const [collapsed, setCollapsed] = useState<{ id: number | null; keys: ReadonlySet }>({ id: null, keys: new Set(), }); const accessOptions = useUsageAccessOptions(); const allUsages = useMemo(() => search?.groups.flatMap((g) => g.usages) ?? [], [search?.groups]); const files = useMemo( () => (search?.groups ?? []) .map((g) => ({ ...g, usages: filterUsageAccess(g.usages, accessOptions) })) .filter((g) => g.usages.length > 0) .map((g) => ({ kind: "file", key: g.path, group: g })), [search?.groups, accessOptions], ); const roots = useMemo(() => (files.length ? [{ kind: "summary", key: SUMMARY_KEY }] : []), [files.length]); const searchId = search?.id ?? null; const expanded = useMemo(() => { const hidden = collapsed.id === searchId ? collapsed.keys : new Set(); return new Set([SUMMARY_KEY, ...files.map((r) => r.key)].filter((k) => !hidden.has(k))); }, [files, collapsed, searchId]); const getChildren = useCallback( (n: Node): Node[] | undefined => n.kind === "summary" ? files : n.kind === "file" ? n.group.usages.map((u) => ({ kind: "usage", key: usageKey(u), usage: u }) as Node) : undefined, [files], ); const tree = useRef(null); const [lead, setLead] = useState(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 ( Use Find Usages (⌥F7) on a symbol to see its usages here ); const total = usageCount(search.groups); const visibleLead = lead?.kind === "usage" ? files.some((f) => f.kind === "file" && f.group.usages.some((u) => usageKey(u) === lead.key)) ? lead : null : lead?.kind === "file" ? (files.find((f) => f.key === lead.key) ?? null) : roots.length ? lead : null; const setAll = (collapse: boolean) => setCollapsed({ id: search.id, keys: collapse ? new Set(files.map((f) => f.key)) : new Set() }); const exclude = () => { if (!visibleLead) return; const next = toggleExcludedUsages(excluded, nodeUsages(visibleLead, 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) => { 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 ( Found usages {!supportsUsageAccess(allUsages) || (accessOptions.read && accessOptions.write) ? total : `${files.reduce((n, f) => n + (f.kind === "file" ? f.group.usages.length : 0), 0)} / ${total}`}{" "} {tx(total === 1 ? "result" : "results")} {search.note && {search.note}} ); if (n.kind === "file") { const path = n.group.path; return ( {basename(path)} {relativeDir(root, path)} {n.group.usages.length} {tx(n.group.usages.length === 1 ? "usage" : "usages")} ); } return ( {n.usage.line + 1} ); }; return (
search.rerun?.()} /> setAll(false)} /> setAll(true)} />
{(search.loading || search.error) && (
{search.loading ? ( <> {" "} {search.symbol ? formatMessage(tx("Searching for usages of '{symbol}'"), { symbol: search.symbol }) : tx("Searching for usages")} … ) : ( {search.error} )}
)} }> key={`${search.id}:${accessOptions.read}:${accessOptions.write}`} 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 ? ( {total ? tx("No usages match the access filters") : No usages found} ) : undefined } />
); } // --------------------------------------------------------------------------- // 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 ( {children} {preview} ); } 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 ( ); }