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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 18:43:39 +08:00
1 джерело a971483162
коміт c666cee075
7 files changed
+104 -3

No files matched your search

+1
Переглянути файл
@@ -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"],
];
+32
Переглянути файл
@@ -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"));
+21 -2
Переглянути файл
@@ -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)}
>
<Icon name={icon} size={16} className={clsx(styles.icon, node.dir && styles.folderIcon, dimIcon && styles.faded)} />
@@ -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;
+1
Переглянути файл
@@ -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]),
}));
+26
Переглянути файл
@@ -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);
});
});
+15
Переглянути файл
@@ -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<string>) {
persistExpanded();
}
/** Expands or collapses a folder node; expanding a compacted chain opens every folder in it. */
export function toggleExpanded(node: Pick<ProjectNode, "key" | "chain">) {
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<string>, lead: string | null) {
set({ selected, lead });
}
+8 -1
Переглянути файл
@@ -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": "启用预览标签页"
}