From 95e79116d1514f7a64189d2720d39f18f9aa49a1 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 07:31:26 +0800 Subject: [PATCH] feat(project-tree): show a file's members below it (#624) Finding a declaration in a file meant opening the Structure view. A new Project view option, Show Members, lets files expand into their declarations, including nested ones, and clicking or activating a member opens the file at that symbol. It is off by default. Outlines load when a file expands, including expansion restored on reopening a project or by turning the option on later. They refresh on save and on changes on disk, with stale in-flight results discarded. Files with no outline lose their chevron. File operations, drag and the context menu skip member rows. Also removes a duplicate Sort Folders First menu entry. Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- src/features/project-tree/HeaderActions.tsx | 5 +- .../project-tree/ProjectView.test.tsx | 240 ++++++++++++++++++ src/features/project-tree/ProjectView.tsx | 138 ++++++++-- src/features/project-tree/index.ts | 2 + src/features/project-tree/model.ts | 40 ++- src/features/project-tree/operations.test.ts | 16 +- src/features/project-tree/operations.ts | 3 +- src/features/project-tree/store.ts | 73 +++++- src/platform/locales/text/zh.json | 1 + 9 files changed, 485 insertions(+), 33 deletions(-) diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx index dbab399eb..c82e41c13 100644 --- a/src/features/project-tree/HeaderActions.tsx +++ b/src/features/project-tree/HeaderActions.tsx @@ -18,6 +18,7 @@ export type ToggleOption = { [K in keyof ViewOptions]: ViewOptions[K] extends bo export const OPTION_LABELS: Array<[ToggleOption, string]> = [ ["showHidden", "Show Hidden Files"], ["showExcluded", "Show Excluded Files"], + ["showMembers", "Show Members"], ["compact", "Compact Middle Packages"], ["foldersFirst", "Sort Folders First"], ["autoscrollToSource", "Autoscroll to Source"], @@ -55,7 +56,7 @@ export function useProjectHeaderMenu(): MenuItem[] { const options = useProjectTree((s) => s.options); return [ { kind: "header", label: "Appearance" }, - ...OPTION_LABELS.slice(0, 4).map(([key, label]) => option(key, label, options)), + ...OPTION_LABELS.slice(0, 5).map(([key, label]) => option(key, label, options)), { kind: "separator" }, ...SORT_LABELS.map(([sortBy, label]): MenuItem => ({ id: `sort.${sortBy}`, @@ -65,7 +66,7 @@ export function useProjectHeaderMenu(): MenuItem[] { })), { kind: "separator" }, { kind: "header", label: "Behavior" }, - ...OPTION_LABELS.slice(4).map(([key, label]) => option(key, label, options)), + ...OPTION_LABELS.slice(5).map(([key, label]) => option(key, label, options)), ]; } diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 47ad70519..95ef18bac 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -12,6 +12,35 @@ import { setRefactorBackendForTests } from "./fileRefactor"; import { ProjectView } from "./ProjectView"; import { beginNew } from "./operations"; import { initProjectTree, setViewOption, useProjectTree } from "./store"; +import type { OutlineItem } from "@/bindings"; +import { _emitEditorEvent } from "@/editor/api"; +import { loadOutline } from "./store"; + +const ipc = vi.hoisted(() => ({ + docOpen: vi.fn(), + docClose: vi.fn(), + docOutline: vi.fn(), +})); +vi.mock("@/bindings", async (orig) => { + const real = await orig(); + return { ...real, commands: { ...real.commands, ...ipc } }; +}); + +const ok = (data: T) => ({ status: "ok" as const, data }); +const item = (name: string, kind: OutlineItem["kind"], line: number, children: OutlineItem[] = []): OutlineItem => { + const r = { start: { line, col: 0 }, end: { line, col: 1 } }; + return { name, kind, detail: null, range: r, selection_range: r, children }; +}; + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; const ROOT = "/w/acme"; let fs: MemoryFs; @@ -285,6 +314,217 @@ describe("ProjectView", () => { }); }); +describe("Show Members", () => { + const APP = "/w/acme/src/App.kt"; + + const expand = (tree: HTMLElement, label: string, row: (l: string) => HTMLElement) => { + fireEvent.mouseDown(row(label)); + tree.focus(); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + }; + + it("shows a file's members and opens the symbol on double click", async () => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockResolvedValue( + ok([item("App", "class", 0, [item("render", "method", 4)]), item("main", "function", 10)]), + ); + const { tree, row, rows } = setup(); + act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src"]) })); + await waitFor(() => expect(rows()).toContain("App.kt")); + act(() => setViewOption("showMembers", true)); + expand(tree, "App.kt", row); + await waitFor(() => expect(rows()).toContain("main")); + expect(rows()).toEqual(expect.arrayContaining(["App.kt", "App", "main"])); + + // Nested members appear when the member row itself is expanded. + const appMember = within(tree) + .getAllByRole("treeitem") + .find((r) => r.textContent === "App")!; + fireEvent.mouseDown(appMember); + fireEvent.keyDown(tree, { key: "ArrowRight" }); + await waitFor(() => expect(rows()).toContain("render")); + + // Activating a member jumps to its declaration, not the file row. + fireEvent.doubleClick(row("main")); + const [path, opts] = openFile.mock.calls.at(-1)!; + expect(path).toBe(APP); + expect(opts).toMatchObject({ focus: true, selection: { line: 10, col: 0 } }); + }); + + it.each([true, false])( + "loads restored expanded files when Show Members becomes enabled (initially %s)", + async (enabled) => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockResolvedValue(ok([item("restored", "function", 3)])); + localStorage.setItem(`openidea.projectTree.expanded:${ROOT}`, JSON.stringify([ROOT, "/w/acme/src", APP])); + initProjectTree(ROOT, "acme"); + setViewOption("showMembers", enabled); + ipc.docOutline.mockClear(); + const { rows, row, tree } = setup(); + await waitFor(() => expect(rows()).toContain("App.kt")); + if (!enabled) { + expect(rows()).not.toContain("Loading…"); + expect(row("App.kt")).not.toHaveAttribute("aria-expanded"); + expect(ipc.docOutline).not.toHaveBeenCalled(); + act(() => setViewOption("showMembers", true)); + } + await waitFor(() => expect(rows()).toContain("restored")); + expect(ipc.docOutline).toHaveBeenCalledTimes(1); + fireEvent.mouseDown(row("restored")); + tree.focus(); + fireEvent.keyDown(tree, { key: "Enter" }); + expect(openFile).toHaveBeenLastCalledWith( + APP, + expect.objectContaining({ focus: true, selection: expect.objectContaining({ line: 3 }) }), + ); + act(() => setViewOption("showMembers", false)); + expect(rows()).not.toContain("restored"); + expect(rows()).not.toContain("Loading…"); + expect(ipc.docOutline).toHaveBeenCalledTimes(1); + }, + ); + + it("honours member single-click options and double-click focus", async () => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockResolvedValue(ok([item("main", "function", 10)])); + const { rows, row } = setup(); + act(() => { + setViewOption("showMembers", true); + setViewOption("autoscrollToSource", false); + setViewOption("singleClickOpen", false); + setViewOption("previewTab", false); + useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src", APP]) }); + }); + await waitFor(() => expect(rows()).toContain("main")); + const content = () => row("main").querySelector("[data-project-key]")!; + fireEvent.click(content(), { detail: 1 }); + expect(openFile).not.toHaveBeenCalled(); + act(() => setViewOption("previewTab", true)); + fireEvent.click(content(), { detail: 1 }); + expect(openFile).toHaveBeenLastCalledWith( + APP, + expect.objectContaining({ preview: true, focus: false, selection: expect.objectContaining({ line: 10 }) }), + ); + act(() => setViewOption("singleClickOpen", true)); + fireEvent.click(content(), { detail: 1 }); + expect(openFile).toHaveBeenLastCalledWith( + APP, + expect.objectContaining({ preview: undefined, focus: false, selection: expect.objectContaining({ line: 10 }) }), + ); + openFile.mockClear(); + fireEvent.click(content(), { detail: 1, metaKey: true }); + fireEvent.click(content(), { detail: 2 }); + expect(openFile).not.toHaveBeenCalled(); + fireEvent.doubleClick(content()); + expect(openFile).toHaveBeenCalledTimes(1); + expect(openFile).toHaveBeenLastCalledWith( + APP, + expect.objectContaining({ focus: true, selection: expect.objectContaining({ line: 10 }) }), + ); + }); + + it("refreshes once per editor save and defers collapsed or disabled outlines", async () => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockResolvedValue(ok([item("main", "function", 0)])); + const { rows } = setup(); + act(() => { + setViewOption("showMembers", true); + useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src", APP]) }); + }); + await waitFor(() => expect(rows()).toContain("main")); + ipc.docOutline.mockClear(); + ipc.docOutline.mockResolvedValue(ok([item("saved", "function", 5)])); + act(() => _emitEditorEvent({ type: "docSaved", doc: 7, path: APP })); + await waitFor(() => expect(rows()).toContain("saved")); + expect(ipc.docOutline).toHaveBeenCalledTimes(1); + act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src"]) })); + act(() => _emitEditorEvent({ type: "docSaved", doc: 7, path: APP })); + expect(useProjectTree.getState().outlines.has(APP)).toBe(false); + expect(ipc.docOutline).toHaveBeenCalledTimes(1); + act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src", APP]) })); + await waitFor(() => expect(rows()).toContain("saved")); + expect(ipc.docOutline).toHaveBeenCalledTimes(2); + act(() => setViewOption("showMembers", false)); + act(() => _emitEditorEvent({ type: "docSaved", doc: 7, path: APP })); + expect(useProjectTree.getState().outlines.has(APP)).toBe(false); + expect(ipc.docOutline).toHaveBeenCalledTimes(2); + }); + + it.each([false, true])("discards an outline invalidated during loading (old request fails: %s)", async (fail) => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + const old = deferred>>(); + const fresh = deferred>>(); + ipc.docOutline.mockClear(); + ipc.docOutline.mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise); + const { rows } = setup(); + act(() => { + setViewOption("showMembers", true); + useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src", APP]) }); + }); + await waitFor(() => expect(ipc.docOutline).toHaveBeenCalledTimes(1)); + act(() => _emitEditorEvent({ type: "docSaved", doc: 7, path: APP })); + await waitFor(() => expect(ipc.docOutline).toHaveBeenCalledTimes(2)); + await act(async () => { + if (fail) old.reject(new Error("stale failure")); + else old.resolve(ok([item("stale", "function", 0)])); + }); + expect(useProjectTree.getState().outlines.has(APP)).toBe(false); + // Completing the obsolete request must not remove the new request's dedupe entry. + let pending: Promise; + act(() => { + pending = loadOutline(APP); + }); + expect(ipc.docOutline).toHaveBeenCalledTimes(2); + await act(async () => { + fresh.resolve(ok([item("fresh", "function", 1)])); + await pending; + }); + await waitFor(() => expect(rows()).toContain("fresh")); + expect(rows()).not.toContain("stale"); + expect(ipc.docOutline).toHaveBeenCalledTimes(2); + }); + + it("collapses a file whose members cannot be read", async () => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockRejectedValue(new Error("unsupported")); + const { tree, row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + act(() => setViewOption("showMembers", true)); + const chevron = () => row("README.md").querySelector("[data-tree-toggle]")!; + expect(chevron().hasChildNodes()).toBe(true); + expand(tree, "README.md", row); + await waitFor(() => expect(chevron().hasChildNodes()).toBe(false)); + expect(rows()).not.toContain("main"); + }); + + it("refreshes members when the file changes on disk", async () => { + ipc.docOpen.mockResolvedValue(ok({ id: 7 })); + ipc.docClose.mockResolvedValue(ok(null)); + ipc.docOutline.mockResolvedValue(ok([item("main", "function", 0)])); + const { tree, row, rows } = setup(); + act(() => useProjectTree.setState({ expanded: new Set([ROOT, "/w/acme/src"]) })); + await waitFor(() => expect(rows()).toContain("App.kt")); + act(() => setViewOption("showMembers", true)); + expand(tree, "App.kt", row); + await waitFor(() => expect(rows()).toContain("main")); + + ipc.docOutline.mockClear(); + ipc.docOutline.mockResolvedValue(ok([item("main", "function", 0), item("helper", "function", 5)])); + // An unrelated change keeps the cached outline. + await act(() => fs.createFile("/w/acme/src/New.kt")); + expect(ipc.docOutline).not.toHaveBeenCalled(); + // Modifying the file itself drops the cache and refetches while expanded. + await act(async () => void fs.onChange?.({ created: [], modified: [APP], removed: [] })); + await waitFor(() => expect(rows()).toContain("helper")); + }); +}); + it("renders workspace member roots with their own paths, without the settings directory", async () => { fs = new MemoryFs({ "/z/first.txt": "", "/a/second.txt": "", "/workspace/.openidea/workspace.json": "{}" }); setProjectFsForTests(fs, "/workspace"); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index fcdc1e560..d5ac7a9cd 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -18,7 +18,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import clsx from "clsx"; -import { useEditorRegistry } from "@/editor/api"; +import { revealRange, useEditorRegistry } from "@/editor/api"; import { executeAction } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; @@ -39,10 +39,12 @@ import { fileIconName, folderIconName, showContextMenu, + SymbolBadge, type DragSession, type TreeHandle, type TreeRowState, } from "@/ui"; +import { badgeKind } from "@/ui/symbolKind"; import { contextMenuItems, menuGroupContext, newMenuItems } from "./contextMenu"; import { openNewPopup } from "./newPopupModel"; import { presentationIcon, useJvmPresentation } from "@/platform/filePresentation"; @@ -50,7 +52,7 @@ import { moduleFolderIcon, useProjectPresentation, type BuildPresentation } from import { relativePath } from "@/platform/project"; import { folderKind } from "./folderKind"; import { useHomeDir } from "./home"; -import { keyLike, nodeKeyFor, visibleInfo, type ProjectNode } from "./model"; +import { keyLike, memberNodes, nodeKeyFor, visibleInfo, type ProjectNode } from "./model"; import { lastFileRefactoring, undoFileRefactoring } from "./fileRefactor"; import { cancelEditing, @@ -75,6 +77,7 @@ import { autoExpand, clearRevealRequest, loadDir, + loadOutline, revealPath, selectModel, setDropTarget, @@ -110,8 +113,6 @@ export function showNewPopup(): boolean { const getKey = (n: ProjectNode) => n.key; const getLabel = (n: ProjectNode) => n.label; -const isExpandable = (n: ProjectNode) => n.dir && !n.placeholder; -const loadChildren = (n: ProjectNode) => loadDir(n.path); function iconFor( node: ProjectNode, @@ -325,6 +326,31 @@ const Row = memo(function Row({ ); }); +// --------------------------------------------------------------------------- +// Member rows ("Show Members") + +/** A declaration of the file above: navigates to the symbol, no file operations. */ +const MemberRow = memo(function MemberRow({ + node, + matches, + onClick, + onPointerDown, +}: Pick) { + const item = node.member!; + return ( + onClick(node, e)} + onPointerDown={(e) => onPointerDown(node, e)} + > + + + {item.detail && {item.detail}} + + ); +}); + // --------------------------------------------------------------------------- // Drag & drop @@ -376,6 +402,15 @@ export function ProjectView() { const info = useMemo(() => visibleInfo(model, expanded), [model, expanded]); const mode = useProjectTree((s) => s.mode); + const outlines = useProjectTree((s) => s.outlines); + const showMembers = options.showMembers; + + // Member rows per file, rebuilt only when an outline loads or drops. + const outlineRows = useMemo(() => { + const map = new Map(); + for (const [p, items] of outlines) map.set(p, memberNodes(p, items)); + return map; + }, [outlines]); // Open Files / All Changed Files: show new files' folders expanded. useEffect(() => autoExpand(model), [model]); @@ -385,6 +420,16 @@ export function ProjectView() { for (const p of info.pending) void loadDir(p); }, [info.pending]); + // Restored file expansion and enabling Show Members also need outlines. + useEffect(() => { + if (!showMembers) return; + for (const key of expanded) { + const node = model.byKey.get(key); + if (node && !node.dir && !node.placeholder && !node.group && !outlines.has(node.path)) + void loadOutline(node.path); + } + }, [showMembers, expanded, model, outlines]); + const setTreeHandle = useCallback((h: TreeHandle | null) => { handle.current = h; treeHandle.current = h; @@ -412,7 +457,27 @@ export function ProjectView() { if (options.autoscrollFromSource && activePath) revealPath(activePath, false); }, [activePath, options.autoscrollFromSource]); - const getChildren = useCallback((n: ProjectNode) => model.children.get(n.key), [model]); + const isExpandable = useCallback( + (n: ProjectNode) => + n.dir + ? !n.placeholder + : n.member + ? n.member.children.length > 0 + : // A file whose outline came back empty (unsupported or no members) has nothing to show. + showMembers && !n.placeholder && (outlines.get(n.path)?.length ?? 1) > 0, + [showMembers, outlines], + ); + + const loadChildren = useCallback( + (n: ProjectNode) => (n.member ? Promise.resolve() : n.dir ? loadDir(n.path) : loadOutline(n.path)), + [], + ); + + const getChildren = useCallback( + (n: ProjectNode) => + n.member ? memberNodes(n.path, n.member.children) : n.dir ? model.children.get(n.key) : outlineRows.get(n.path), + [model, outlineRows], + ); const onExpandedChange = useCallback((next: Set, node: ProjectNode, open: boolean) => { // Expanding a compacted chain opens every folder in it, so it stays open if the chain breaks. @@ -423,12 +488,20 @@ export function ProjectView() { const onSelectionChange = useCallback((next: Set, lead: ProjectNode | null) => { setSelection(next, lead?.key ?? null); const { options: o } = useProjectTree.getState(); - if (!revealing.current && o.autoscrollToSource && lead && !lead.dir && !lead.placeholder && next.size === 1) - void openFile(lead.path, { preview: o.previewTab, focus: false }); + if (revealing.current || !o.autoscrollToSource || !lead || lead.dir || lead.placeholder || next.size !== 1) return; + if (lead.member) { + void revealRange( + lead.path, + { start: lead.member.selection_range.start }, + { preview: o.previewTab, focus: false }, + ); + } else void openFile(lead.path, { preview: o.previewTab, focus: false }); }, []); const onActivate = useCallback((node: ProjectNode) => { - if (!node.dir && !node.placeholder) void openFile(node.path, { focus: true }); + if (node.member) { + void revealRange(node.path, { start: node.member.selection_range.start }, { focus: true }); + } else if (!node.dir && !node.placeholder) void openFile(node.path, { focus: true }); }, []); const onContextMenu = useCallback((node: ProjectNode | null, sel: Set, e: ReactMouseEvent) => { @@ -442,7 +515,7 @@ export function ProjectView() { } const nodes = [...sel].map((k) => m.byKey.get(k)).filter((n): n is ProjectNode => !!n && !n.placeholder); const target = nodes.length ? nodes : [node]; - if (target.every((n) => n.group)) return; + if (target.every((n) => n.group) || target.some((n) => n.member)) return; // Contributed groups may read the file first (bounded wait). const at = { x: e.clientX, y: e.clientY }; void prepareMenuGroups("projectView", menuGroupContext(target)).then(() => @@ -464,8 +537,10 @@ export function ProjectView() { if (o.singleClickExpand) toggleExpanded(node); return; } - if (o.singleClickOpen) void openFile(node.path, { focus: false }); - else if (o.previewTab) void openFile(node.path, { preview: true, focus: false }); + if (!o.singleClickOpen && !o.previewTab) return; + const opts = { preview: o.singleClickOpen ? undefined : true, focus: false }; + if (node.member) void revealRange(node.path, { start: node.member.selection_range.start }, opts); + else void openFile(node.path, opts); }, []); // Pointer-driven drag & drop (HTML5 DnD is unreliable inside the Tauri webview). @@ -476,7 +551,15 @@ export function ProjectView() { const onRowPointerDown = useCallback((node: ProjectNode, e: ReactPointerEvent) => { if (e.button === 0) dragged.current = false; - if (e.button !== 0 || node.root || node.placeholder || node.readOnly || useProjectTree.getState().editing) return; + if ( + e.button !== 0 || + node.member || + node.root || + node.placeholder || + node.readOnly || + useProjectTree.getState().editing + ) + return; const before = useProjectTree.getState().selected; const lead = useProjectTree.getState().lead; const startX = e.clientX, @@ -632,20 +715,23 @@ export function ProjectView() { const cutPaths = useMemo(() => new Set(clipboard?.cut ? clipboard.paths : []), [clipboard]); const renderNode = useCallback( - (node: ProjectNode, state: TreeRowState) => ( - - ), + (node: ProjectNode, state: TreeRowState) => + node.member ? ( + + ) : ( + + ), [editing, cutPaths, dropTarget, home, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown], ); diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index e7b2b352e..6897ce40b 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -24,6 +24,7 @@ import { selectedNodes } from "./operations"; import { ProjectView } from "./ProjectView"; import { applyFsChange, + dropOutlines, initProjectTree, reloadAll, reloadLoadedDirs, @@ -106,6 +107,7 @@ export function activate(): () => void { syncOpenFiles(); const unsubEditor = onEditorEvent((e) => { if (e.type === "docOpened" || e.type === "docClosed" || e.type === "activeEditorChanged") syncOpenFiles(); + if (e.type === "docSaved") dropOutlines([e.path]); }); // "All Changed Files": change-list files still on disk (IDEA leaves out unversioned, ignored and deleted ones). const syncChanged = () => setChangedFiles(localChangedPaths(useLocalChanges.getState().lists, null, true)); diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index 4dc77bfd1..fd0099535 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -3,7 +3,7 @@ * nodes (filtering, sorting, IDEA's "Compact Middle Packages") and plans the * incremental refresh for `fsChanged` events. No React, no IPC. */ -import type { FileEntry } from "@/bindings"; +import type { FileEntry, OutlineItem } from "@/bindings"; import { isIgnoredName } from "@/platform/ignoredNames"; import { ancestorsWithin, isInside, isSameOrInside, nameOf, parentOf, relativeTo } from "./paths"; @@ -54,6 +54,8 @@ export interface ProjectNode { group?: boolean; /** Library sources: no new / rename / move / delete. */ readOnly?: boolean; + /** A declaration of the file itself ("Show Members"); navigating lands on its `selection_range`. */ + member?: OutlineItem; } export interface TreeModel { @@ -276,6 +278,42 @@ export function keyLike(like: string, path: string): string { return isLibKey(like) ? like.slice(0, like.indexOf(LIB_SEP, LIB_PREFIX.length) + 1) + path : path; } +// --------------------------------------------------------------------------- +// "Show Members": a file's declarations as rows below the file + +/** + * Key prefix of member rows (`\0sym:\0:#`): never a path, + * so file operations resolving keys through `byKey` skip them naturally. + */ +export const MEMBER_PREFIX = "\0sym:"; + +/** The key of a member row; `n` disambiguates same-named siblings. */ +export function memberKey(file: string, item: OutlineItem, n = 0): string { + return `${MEMBER_PREFIX}${file}\u0000${item.kind}:${item.name}${n ? `#${n}` : ""}`; +} + +/** The member rows of a file's outline, nested symbols as their parent row's children. */ +export function memberNodes(file: string, items: readonly OutlineItem[]): ProjectNode[] { + const seen = new Map(); + return items.map((item) => { + const n = seen.get(item.name) ?? 0; + seen.set(item.name, n + 1); + return { + key: memberKey(file, item, n), + path: file, + chain: [], + label: item.name, + dir: false, + root: false, + entry: null, + ignored: false, + excluded: false, + symlink: false, + member: item, + }; + }); +} + /** * Adds contributed roots (folders outside the project, e.g. scratches, and * groups of them like External Libraries) after the project root. Each folder diff --git a/src/features/project-tree/operations.test.ts b/src/features/project-tree/operations.test.ts index 9bb42a2a3..883115174 100644 --- a/src/features/project-tree/operations.test.ts +++ b/src/features/project-tree/operations.test.ts @@ -1,12 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ProjectNode } from "./model"; +import { MEMBER_PREFIX, type ProjectNode } from "./model"; import { importExternal, paste, selectionAfterDelete, transfer } from "./operations"; import { IpcError } from "@/platform/ipc"; import { pendingFileRefactorDialog } from "./FileRefactorDialogs"; import { useRemote } from "@/platform/remote"; import { provideNotificationService } from "@/workbench/contributions"; import { MemoryFs, setProjectFsForTests } from "./fs"; -import { initProjectTree, loadDir, setClipboard } from "./store"; +import { initProjectTree, loadDir, setClipboard, setSelection } from "./store"; import { setOsClipboardForTests } from "./clipboard"; let fs: MemoryFs; @@ -60,6 +60,18 @@ describe("OS file imports", () => { setOsClipboardForTests(null); }); + it("does not paste into the project root when a member row is selected", async () => { + const files = vi.fn(async () => ["/outside/local.txt"]); + setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => {} }); + const imported = vi.spyOn(fs, "importLocal"); + const key = `${MEMBER_PREFIX}/p/local.txt\0function:main`; + setSelection(new Set([key]), key); + await paste(); + expect(files).not.toHaveBeenCalled(); + expect(imported).not.toHaveBeenCalled(); + expect(pendingFileRefactorDialog()).toBeNull(); + }); + it.each(["paste", "drop"])( "%s imports a client path under the remote root and keeps conflict names", async (entry) => { diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 1e5fe0990..595b3d32f 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -12,7 +12,7 @@ import { runFileRefactoring } from "./fileRefactor"; import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs"; import { beginLocalImport, projectFs } from "./fs"; import { isCancelled } from "@/platform/ipc"; -import type { ProjectNode } from "./model"; +import { MEMBER_PREFIX, type ProjectNode } from "./model"; import { isInside, isSameOrInside, @@ -90,6 +90,7 @@ export function topLevelPaths(nodes: ProjectNode[] = selectedNodes()): string[] /** Folder that receives new / pasted items: the selected folder or the selected file's parent. */ export function targetFolder(node: ProjectNode | null = leadNode()): string | null { + if (!node && get().lead?.startsWith(MEMBER_PREFIX)) return null; const { root } = get(); if (!root) return null; if (!node) return get().members?.[0] ?? (get().members ? null : root); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index a94279e33..a4c9ac53e 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -4,7 +4,9 @@ * Kept outside React so actions (keyboard, Find Action, context menu) share it. */ import { create } from "zustand"; -import type { FileEntry } from "@/bindings"; +import { call } from "@/platform/ipc"; +import { commands, type FileEntry, type OutlineItem } from "@/bindings"; +import { withDocument } from "@/editor/lsp/files"; import { IpcError } from "@/platform/ipc"; import { DEFAULT_TREE_OPTIONS, @@ -43,6 +45,8 @@ export interface ViewOptions extends TreeOptions { singleClickExpand: boolean; /** Single click shows files in the preview tab (IDEA's "Enable Preview Tab"). */ previewTab: boolean; + /** Show a file's declarations as expandable rows below it. */ + showMembers: boolean; } export const DEFAULT_VIEW_OPTIONS: ViewOptions = { @@ -53,6 +57,7 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = { singleClickExpand: false, // IDEA's "Enable Preview Tab" is off by default. previewTab: false, + showMembers: false, }; function storedOptions(): ViewOptions { @@ -104,6 +109,8 @@ interface ProjectTreeState { openFiles: readonly string[]; /** Files with VCS changes (All Changed Files mode). */ changedFiles: readonly string[]; + /** Loaded document outlines per file path, for the "Show Members" rows (empty for unsupported files). */ + outlines: ReadonlyMap; } const OPTIONS_KEY = "openidea.projectTree.options"; @@ -144,6 +151,7 @@ export const useProjectTree = create(() => ({ mode: readMode(), openFiles: [], changedFiles: [], + outlines: new Map(), })); function readMode(): ViewMode { @@ -255,6 +263,7 @@ let persistTimer: ReturnType | undefined; export function initProjectTree(root: string | null, name = "", members: readonly string[] | null = null) { generation++; inflight.clear(); + outlineInflight.clear(); clearTimeout(persistTimer); modeExpanded.clear(); autoExpanded.clear(); @@ -270,6 +279,7 @@ export function initProjectTree(root: string | null, name = "", members: readonl clipboard: null, revealRequest: null, dropTarget: null, + outlines: new Map(), }); } @@ -449,6 +459,19 @@ export async function applyFsChange(event: { created: string[]; modified: string } } await Promise.all(plan.reload.map((p) => loadDir(p, true))); + // Member rows go stale when the file changes on disk: drop and refetch. + if (event.modified.length) dropOutlines(event.modified); + if (event.removed.length) { + const outlines = new Map(get().outlines); + let changed = false; + for (const p of event.removed) + for (const key of [...outlines.keys()]) + if (isSameOrInside(key, p)) { + outlines.delete(key); + changed = true; + } + if (changed) set({ outlines }); + } } /** Re-reads the folders among `dirs` the tree has loaded; the rest stay unread. */ @@ -463,6 +486,54 @@ export async function reloadAll() { await Promise.all([...listings.keys()].map((p) => loadDir(p, true))); } +// --------------------------------------------------------------------------- +// Members ("Show Members") + +const outlineInflight = new Map }>(); + +/** + * Loads a file's outline for its member rows; unsupported files (no language + * support, binary, …) cache an empty list so the chevron collapses away. + */ +export function loadOutline(path: string, force = false): Promise { + const { outlines } = get(); + if (!force && outlines.has(path)) return Promise.resolve(); + const running = outlineInflight.get(path); + if (running) return running.promise; + const gen = generation; + const request = { promise: Promise.resolve() }; + outlineInflight.set(path, request); + request.promise = (async () => { + try { + const items = await withDocument(path, (doc) => call(commands.docOutline(doc))); + if (gen !== generation || outlineInflight.get(path) !== request) return; + set({ outlines: new Map(get().outlines).set(path, items) }); + } catch (e) { + if (gen !== generation || outlineInflight.get(path) !== request) return; + console.warn(`project view: cannot read members of ${path}`, e); + set({ outlines: new Map(get().outlines).set(path, []) }); + } finally { + if (outlineInflight.get(path) === request) outlineInflight.delete(path); + } + })(); + return request.promise; +} + +/** Drops the cached outlines of `paths`; expanded files are re-read at once. */ +export function dropOutlines(paths: readonly string[]) { + const s = get(); + const touched = [...new Set(paths)].filter((p) => s.outlines.has(p) || outlineInflight.has(p)); + if (!touched.length) return; + const outlines = new Map(s.outlines); + for (const p of touched) { + outlines.delete(p); + outlineInflight.delete(p); + } + set({ outlines }); + for (const p of touched) + if (s.options.showMembers && [...s.expanded].some((k) => pathOfKey(k) === p)) void loadOutline(p); +} + // --------------------------------------------------------------------------- // Reveal / expand diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index e4f4925c8..d63459460 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3764,6 +3764,7 @@ "Distraction Free Mode": "无干扰模式", "Exit Distraction Free Mode": "退出无干扰模式", "Show Hidden Files": "显示隐藏文件", + "Show Members": "显示成员", "Show Excluded Files": "显示已排除的文件", "Compact Middle Packages": "压缩中间软件包", "Sort Folders First": "文件夹排在前面",