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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-11-01 07:31:26 +08:00
1 джерело cde74394d7
коміт 95e79116d1
9 files changed
+485 -33

No files matched your search

+3 -2
Переглянути файл
@@ -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)),
];
}
+240
Переглянути файл
@@ -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<typeof import("@/bindings")>();
return { ...real, commands: { ...real.commands, ...ipc } };
});
const ok = <T,>(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 = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((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<ReturnType<typeof ok<OutlineItem[]>>>();
const fresh = deferred<ReturnType<typeof ok<OutlineItem[]>>>();
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<void>;
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");
+112 -26
Переглянути файл
@@ -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<RowProps, "node" | "matches" | "onClick" | "onPointerDown">) {
const item = node.member!;
return (
<span
className={styles.content}
data-project-key={node.key}
onClick={(e) => onClick(node, e)}
onPointerDown={(e) => onPointerDown(node, e)}
>
<SymbolBadge kind={badgeKind(item.kind)} className={styles.icon} />
<HighlightedText className={styles.label} text={item.name} indices={matches} tone="accent" />
{item.detail && <span className={styles.detail}>{item.detail}</span>}
</span>
);
});
// ---------------------------------------------------------------------------
// 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<string, ProjectNode[]>();
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<string>, 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<string>, 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<string>, 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) => (
<Row
node={node}
matches={state.matches}
editing={editing}
cut={cutPaths.has(node.path)}
dropTarget={dropTarget === node.key}
detail={node.root && !node.icon ? tildify(node.path, home) : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onDoubleClick={onRowDoubleClick}
onPointerDown={onRowPointerDown}
/>
),
(node: ProjectNode, state: TreeRowState) =>
node.member ? (
<MemberRow node={node} matches={state.matches} onClick={onRowClick} onPointerDown={onRowPointerDown} />
) : (
<Row
node={node}
matches={state.matches}
editing={editing}
cut={cutPaths.has(node.path)}
dropTarget={dropTarget === node.key}
detail={node.root && !node.icon ? tildify(node.path, home) : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
onDoubleClick={onRowDoubleClick}
onPointerDown={onRowPointerDown}
/>
),
[editing, cutPaths, dropTarget, home, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown],
);
+2
Переглянути файл
@@ -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));
+39 -1
Переглянути файл
@@ -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:<file>\0<kind>:<name>#<n>`): 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<string, number>();
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
+14 -2
Переглянути файл
@@ -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) => {
+2 -1
Переглянути файл
@@ -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);
+72 -1
Переглянути файл
@@ -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<string, readonly OutlineItem[]>;
}
const OPTIONS_KEY = "openidea.projectTree.options";
@@ -144,6 +151,7 @@ export const useProjectTree = create<ProjectTreeState>(() => ({
mode: readMode(),
openFiles: [],
changedFiles: [],
outlines: new Map(),
}));
function readMode(): ViewMode {
@@ -255,6 +263,7 @@ let persistTimer: ReturnType<typeof setTimeout> | 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<string, { promise: Promise<void> }>();
/**
* 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<void> {
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
+1
Переглянути файл
@@ -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": "文件夹排在前面",