дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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]>
Цей коміт міститься в:
1 джерело
cde74394d7
коміт
95e79116d1
9 files changed
+485
-33
No files matched your search
@@ -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)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "文件夹排在前面",
|
||||
|
||||
Reference in new issue
Заблокувати користувача