From c666cee0758341a36f6c648e3f1bca1100f025f6 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 18:43:39 +0800 Subject: [PATCH] feat(project-tree): Expand directories with a single click (#96) Adds "Expand Directories with Single Click" to the Project view options (Behavior group, off by default). When on, a plain left click anywhere on a directory row expands or collapses it (a compacted path opens its whole chain); a double-click toggles once, and modifier clicks, drag-and-drop, inline rename and the context menu are unaffected. Project view option toggles now show their checked state in Find Action, and the options menu is translated. Fixes #94 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn --- src/features/project-tree/HeaderActions.tsx | 1 + .../project-tree/ProjectView.test.tsx | 32 +++++++++++++++++++ src/features/project-tree/ProjectView.tsx | 23 +++++++++++-- src/features/project-tree/actions.ts | 1 + src/features/project-tree/store.test.ts | 26 +++++++++++++++ src/features/project-tree/store.ts | 15 +++++++++ src/platform/locales/text/zh.json | 9 +++++- 7 files changed, 104 insertions(+), 3 deletions(-) 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": "启用预览标签页" }