дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
a971483162
коміт
c666cee075
7 files changed
+104
-3
No files matched your search
@@ -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"],
|
||||
];
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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": "启用预览标签页"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача