diff --git a/src/features/project-tree/HeaderActions.tsx b/src/features/project-tree/HeaderActions.tsx
index c629caa8f..29ee809ec 100644
--- a/src/features/project-tree/HeaderActions.tsx
+++ b/src/features/project-tree/HeaderActions.tsx
@@ -19,6 +19,7 @@ export const OPTION_LABELS: Array<[keyof ViewOptions, string]> = [
["autoscrollToSource", "Autoscroll to Source"],
["autoscrollFromSource", "Autoscroll from Source"],
["singleClickOpen", "Open Files with Single Click"],
+ ["singleClickExpand", "Expand Directories with Single Click"],
["previewTab", "Enable Preview Tab"],
];
diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx
index 2770da916..476a83a28 100644
--- a/src/features/project-tree/ProjectView.test.tsx
+++ b/src/features/project-tree/ProjectView.test.tsx
@@ -109,6 +109,38 @@ describe("ProjectView", () => {
act(() => setViewOption("previewTab", false));
});
+ it("expands and collapses folders on single click only when enabled", async () => {
+ const { row, rows } = setup();
+ await waitFor(() => expect(rows()).toContain("README.md"));
+ const src = () => row("src").querySelector("[data-project-path]")!;
+ const click = (detail = 1, init: MouseEventInit = {}) => {
+ fireEvent.mouseDown(row("src"), { detail, ...init });
+ fireEvent.click(src(), { detail, ...init });
+ };
+ click();
+ expect(rows()).not.toContain("App.kt");
+
+ act(() => setViewOption("singleClickExpand", true));
+ click();
+ await waitFor(() => expect(rows()).toContain("App.kt"));
+ click();
+ expect(rows()).not.toContain("App.kt");
+
+ // A double click toggles once: the second click and the dblclick are ignored.
+ click(1);
+ click(2);
+ fireEvent.doubleClick(src());
+ await waitFor(() => expect(rows()).toContain("App.kt"));
+
+ // Modifier clicks only change the selection.
+ fireEvent.mouseDown(row("README.md"));
+ click(1, { metaKey: true });
+ expect(useProjectTree.getState().selected).toEqual(new Set(["/w/acme/README.md", "/w/acme/src"]));
+ click(1, { shiftKey: true });
+ expect(rows()).toContain("App.kt");
+ act(() => setViewOption("singleClickExpand", false));
+ });
+
it("cancels a new file when its name field loses focus", async () => {
const { rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx
index 04cca9510..9c48f5d36 100644
--- a/src/features/project-tree/ProjectView.tsx
+++ b/src/features/project-tree/ProjectView.tsx
@@ -72,6 +72,7 @@ import {
selectModel,
setDropTarget,
setExpanded,
+ toggleExpanded,
setSelection,
useProjectTree,
type Editing,
@@ -221,6 +222,7 @@ interface RowProps {
detail: string | null;
onEditDone: () => void;
onClick: (node: ProjectNode, e: ReactMouseEvent) => void;
+ onDoubleClick: (node: ProjectNode, e: ReactMouseEvent) => void;
onPointerDown: (node: ProjectNode, e: ReactPointerEvent) => void;
}
@@ -233,6 +235,7 @@ const Row = memo(function Row({
detail,
onEditDone,
onClick,
+ onDoubleClick,
onPointerDown,
}: RowProps) {
const deco = useFileDecoration(node.path);
@@ -252,6 +255,7 @@ const Row = memo(function Row({
data-dir={node.dir || undefined}
title={deco.tooltip}
onClick={(e) => onClick(node, e)}
+ onDoubleClick={(e) => onDoubleClick(node, e)}
onPointerDown={(e) => onPointerDown(node, e)}
>
@@ -408,17 +412,30 @@ export function ProjectView() {
const focusTree = useCallback(() => requestAnimationFrame(() => handle.current?.focus()), []);
+ // A pointer drag that ends on the row it started from still clicks it.
+ const dragged = useRef(false);
+
const onRowClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => {
if (e.button !== 0 || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey || e.detail > 1) return;
- if (node.dir || node.placeholder) return;
+ if (node.placeholder || dragged.current) return;
const { options: o, editing: ed } = useProjectTree.getState();
if (ed) return;
+ if (node.dir) {
+ 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 });
}, []);
// Pointer-driven drag & drop (HTML5 DnD is unreliable inside the Tauri webview).
+ // The single click already toggled the folder; double-click must not toggle it back.
+ const onRowDoubleClick = useCallback((node: ProjectNode, e: ReactMouseEvent) => {
+ if (node.dir && !node.placeholder && useProjectTree.getState().options.singleClickExpand) e.stopPropagation();
+ }, []);
+
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;
const before = useProjectTree.getState().selected;
const lead = useProjectTree.getState().lead;
@@ -430,6 +447,7 @@ export function ProjectView() {
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
active = true;
+ dragged.current = true;
const m = selectModel(useProjectTree.getState());
const keys = before.has(node.key) ? [...before] : [node.key];
if (before.has(node.key)) setSelection(before, lead);
@@ -577,10 +595,11 @@ export function ProjectView() {
detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)}
onEditDone={focusTree}
onClick={onRowClick}
+ onDoubleClick={onRowDoubleClick}
onPointerDown={onRowPointerDown}
/>
),
- [editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowPointerDown],
+ [editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown],
);
if (!root) return null;
diff --git a/src/features/project-tree/actions.ts b/src/features/project-tree/actions.ts
index ed85f9218..68b2a1e08 100644
--- a/src/features/project-tree/actions.ts
+++ b/src/features/project-tree/actions.ts
@@ -153,6 +153,7 @@ export function registerProjectActions(): () => void {
title: `Project View: ${label}`,
category: "View",
keywords: ["project", "tree", "toggle"],
+ toggled: () => useProjectTree.getState().options[key],
run: () => setViewOption(key, !useProjectTree.getState().options[key]),
}));
diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts
index e03560afa..6c2fd5d77 100644
--- a/src/features/project-tree/store.test.ts
+++ b/src/features/project-tree/store.test.ts
@@ -10,6 +10,8 @@ import {
revealPath,
setExpanded,
setSelection,
+ setViewOption,
+ toggleExpanded,
useProjectTree,
} from "./store";
import { beginNew, commitEditing, copySelection, paste, transfer } from "./operations";
@@ -173,3 +175,27 @@ describe("file operations", () => {
expect(fs.nodes.has("/p/docs/guide.md")).toBe(true);
});
});
+
+describe("single-click expansion", () => {
+ it("toggles a folder, opening every folder of a compacted chain", async () => {
+ await loadDir(ROOT);
+ await loadDir("/p/src");
+ await vi.waitFor(() => expect(childLabels(ROOT)).toContain("src/main/kotlin"));
+ const chain = currentModel().byKey.get("/p/src")!;
+ toggleExpanded(chain);
+ expect([...useProjectTree.getState().expanded]).toEqual(
+ expect.arrayContaining(["/p/src", "/p/src/main", "/p/src/main/kotlin"]),
+ );
+ toggleExpanded(chain);
+ expect(useProjectTree.getState().expanded.has("/p/src")).toBe(false);
+ });
+
+ it("is off by default and its option persists across reloads", async () => {
+ expect(useProjectTree.getState().options.singleClickExpand).toBe(false);
+ setViewOption("singleClickExpand", true);
+ vi.resetModules();
+ const fresh = await import("./store");
+ expect(fresh.useProjectTree.getState().options.singleClickExpand).toBe(true);
+ setViewOption("singleClickExpand", false);
+ });
+});
diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts
index d4322905b..95a9dc265 100644
--- a/src/features/project-tree/store.ts
+++ b/src/features/project-tree/store.ts
@@ -24,6 +24,7 @@ import {
planRefresh,
visibleEntries,
type Placeholder,
+ type ProjectNode,
type TreeModel,
type TreeOptions,
} from "./model";
@@ -38,6 +39,8 @@ export interface ViewOptions extends TreeOptions {
autoscrollToSource: boolean;
/** Single click opens files (as regular tabs). */
singleClickOpen: boolean;
+ /** Single click on a folder row expands / collapses it. */
+ singleClickExpand: boolean;
/** Single click shows files in the preview tab (IDEA's "Enable Preview Tab"). */
previewTab: boolean;
}
@@ -47,6 +50,7 @@ export const DEFAULT_VIEW_OPTIONS: ViewOptions = {
autoscrollFromSource: false,
autoscrollToSource: false,
singleClickOpen: false,
+ singleClickExpand: false,
// IDEA's "Enable Preview Tab" is off by default.
previewTab: false,
};
@@ -297,6 +301,17 @@ export function setExpanded(expanded: ReadonlySet) {
persistExpanded();
}
+/** Expands or collapses a folder node; expanding a compacted chain opens every folder in it. */
+export function toggleExpanded(node: Pick) {
+ const next = new Set(get().expanded);
+ if (next.has(node.key)) next.delete(node.key);
+ else {
+ next.add(node.key);
+ for (const p of node.chain) next.add(keyLike(node.key, p));
+ }
+ setExpanded(next);
+}
+
export function setSelection(selected: ReadonlySet, lead: string | null) {
set({ selected, lead });
}
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index 66271df8e..f1152e488 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -3649,5 +3649,12 @@
"Process literals": "处理字面量",
"Process comments": "处理注释",
"Distraction Free Mode": "无干扰模式",
- "Exit Distraction Free Mode": "退出无干扰模式"
+ "Exit Distraction Free Mode": "退出无干扰模式",
+ "Show Hidden Files": "显示隐藏文件",
+ "Show Excluded Files": "显示已排除的文件",
+ "Compact Middle Packages": "压缩中间软件包",
+ "Sort Folders First": "文件夹排在前面",
+ "Open Files with Single Click": "单击打开文件",
+ "Expand Directories with Single Click": "单击展开目录",
+ "Enable Preview Tab": "启用预览标签页"
}