Skip to content

Commit bee4169

Browse files
DLhuglyclaude
andcommitted
feat: add file explorer actions, context menu, drag-drop, and update button fix
- Add right-click context menu with New File, New Folder, Rename, Delete - Add inline rename with auto-selection of filename (excluding extension) - Add drag-and-drop to move files/folders between directories - Add New File and New Folder toolbar buttons in sidebar - Fix update button in status bar to be a visible, clickable green button Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 059967b commit bee4169

5 files changed

Lines changed: 678 additions & 31 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { Component, For, Show, onMount, onCleanup, createSignal } from "solid-js";
2+
3+
export interface ContextMenuItem {
4+
label: string;
5+
icon?: () => any;
6+
action: () => void;
7+
danger?: boolean;
8+
separator?: boolean;
9+
}
10+
11+
interface ContextMenuProps {
12+
x: number;
13+
y: number;
14+
items: ContextMenuItem[];
15+
onClose: () => void;
16+
}
17+
18+
const ContextMenu: Component<ContextMenuProps> = (props) => {
19+
let menuRef: HTMLDivElement | undefined;
20+
const [position, setPosition] = createSignal({ x: props.x, y: props.y });
21+
22+
onMount(() => {
23+
// Adjust position if menu would overflow viewport
24+
if (menuRef) {
25+
const rect = menuRef.getBoundingClientRect();
26+
const vw = window.innerWidth;
27+
const vh = window.innerHeight;
28+
let x = props.x;
29+
let y = props.y;
30+
if (x + rect.width > vw) x = vw - rect.width - 4;
31+
if (y + rect.height > vh) y = vh - rect.height - 4;
32+
if (x < 0) x = 4;
33+
if (y < 0) y = 4;
34+
setPosition({ x, y });
35+
}
36+
37+
function handleClickOutside(e: MouseEvent) {
38+
if (menuRef && !menuRef.contains(e.target as Node)) {
39+
props.onClose();
40+
}
41+
}
42+
function handleEscape(e: KeyboardEvent) {
43+
if (e.key === "Escape") props.onClose();
44+
}
45+
// Use setTimeout so the opening right-click doesn't immediately close it
46+
setTimeout(() => {
47+
document.addEventListener("mousedown", handleClickOutside);
48+
}, 0);
49+
document.addEventListener("keydown", handleEscape);
50+
51+
onCleanup(() => {
52+
document.removeEventListener("mousedown", handleClickOutside);
53+
document.removeEventListener("keydown", handleEscape);
54+
});
55+
});
56+
57+
return (
58+
<div
59+
ref={menuRef}
60+
class="fixed z-50"
61+
style={{
62+
left: `${position().x}px`,
63+
top: `${position().y}px`,
64+
background: "var(--bg-surface)",
65+
border: "1px solid var(--border-default)",
66+
"border-radius": "6px",
67+
"box-shadow": "0 4px 16px rgba(0,0,0,0.3)",
68+
"min-width": "160px",
69+
padding: "4px 0",
70+
"font-size": "12px",
71+
"font-family": "var(--font-sans)",
72+
}}
73+
>
74+
<For each={props.items}>
75+
{(item) => (
76+
<>
77+
<Show when={item.separator}>
78+
<div
79+
style={{
80+
height: "1px",
81+
background: "var(--border-muted)",
82+
margin: "4px 8px",
83+
}}
84+
/>
85+
</Show>
86+
<Show when={item.label}>
87+
<button
88+
class="w-full flex items-center gap-2 px-3 py-1.5 text-left transition-colors"
89+
style={{
90+
color: item.danger ? "var(--accent-red, #ef4444)" : "var(--text-primary)",
91+
background: "transparent",
92+
border: "none",
93+
cursor: "pointer",
94+
"font-size": "12px",
95+
"font-family": "var(--font-sans)",
96+
}}
97+
onMouseEnter={(e) => {
98+
e.currentTarget.style.background = "var(--bg-hover)";
99+
}}
100+
onMouseLeave={(e) => {
101+
e.currentTarget.style.background = "transparent";
102+
}}
103+
onClick={() => {
104+
item.action();
105+
props.onClose();
106+
}}
107+
>
108+
<Show when={item.icon}>
109+
<span
110+
class="flex items-center justify-center shrink-0"
111+
style={{ width: "14px", height: "14px", color: "var(--text-muted)" }}
112+
>
113+
{item.icon!()}
114+
</span>
115+
</Show>
116+
<span>{item.label}</span>
117+
</button>
118+
</Show>
119+
</>
120+
)}
121+
</For>
122+
</div>
123+
);
124+
};
125+
126+
export default ContextMenu;

clif-pad-ide/src/components/explorer/FileTree.tsx

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,69 @@
1-
import { Component, For, Show } from "solid-js";
2-
import { fileTree, projectRoot } from "../../stores/fileStore";
1+
import { Component, For, Show, createSignal } from "solid-js";
2+
import { fileTree, projectRoot, refreshFileTree } from "../../stores/fileStore";
3+
import { createFile, createDir, renameEntry } from "../../lib/tauri";
34
import FileTreeItem from "./FileTreeItem";
45

5-
const FileTree: Component<{ onOpenFolder?: () => void }> = (props) => {
6+
const FileTree: Component<{ onOpenFolder?: () => void; creatingType?: "file" | "folder" | null; onCreateDone?: () => void }> = (props) => {
7+
const [isDragOver, setIsDragOver] = createSignal(false);
8+
9+
// Handle drop on empty area (move to project root)
10+
async function handleDrop(e: DragEvent) {
11+
e.preventDefault();
12+
setIsDragOver(false);
13+
14+
const root = projectRoot();
15+
if (!root || !e.dataTransfer) return;
16+
17+
const sourceData = e.dataTransfer.getData("application/x-clif-entry");
18+
if (!sourceData) return;
19+
20+
const source = JSON.parse(sourceData) as { path: string; name: string; is_dir: boolean };
21+
const sep = root.includes("\\") ? "\\" : "/";
22+
const destPath = root + sep + source.name;
23+
24+
if (source.path === destPath) return;
25+
26+
try {
27+
await renameEntry(source.path, destPath);
28+
await refreshFileTree();
29+
} catch (e) {
30+
console.error("Move to root failed:", e);
31+
}
32+
}
33+
34+
async function commitRootCreate(name: string) {
35+
const type = props.creatingType;
36+
props.onCreateDone?.();
37+
if (!name || !type) return;
38+
39+
const root = projectRoot();
40+
if (!root) return;
41+
42+
const sep = root.includes("\\") ? "\\" : "/";
43+
const newPath = root + sep + name;
44+
45+
try {
46+
if (type === "file") {
47+
await createFile(newPath);
48+
} else {
49+
await createDir(newPath);
50+
}
51+
await refreshFileTree();
52+
} catch (e) {
53+
console.error(`Create ${type} failed:`, e);
54+
}
55+
}
56+
657
return (
7-
<div class="flex flex-col h-full">
58+
<div
59+
class="flex flex-col h-full"
60+
onDragOver={(e) => {
61+
e.preventDefault();
62+
setIsDragOver(true);
63+
}}
64+
onDragLeave={() => setIsDragOver(false)}
65+
onDrop={handleDrop}
66+
>
867
<Show
968
when={projectRoot()}
1069
fallback={
@@ -28,7 +87,59 @@ const FileTree: Component<{ onOpenFolder?: () => void }> = (props) => {
2887
</div>
2988
}
3089
>
31-
<div class="flex-1 overflow-y-auto py-1">
90+
<div
91+
class="flex-1 overflow-y-auto py-1"
92+
style={{
93+
background: isDragOver() ? "var(--accent-blue)08" : "transparent",
94+
}}
95+
>
96+
{/* Inline create at root level */}
97+
<Show when={props.creatingType}>
98+
<div
99+
class="flex items-center"
100+
style={{
101+
"padding-left": "0px",
102+
"padding-right": "8px",
103+
height: "24px",
104+
}}
105+
>
106+
<span
107+
class="flex items-center justify-center shrink-0 mr-1"
108+
style={{ width: "16px", height: "16px" }}
109+
>
110+
{props.creatingType === "folder" ? (
111+
<svg width="14" height="14" viewBox="0 0 24 24" fill="#e2b340" stroke="none">
112+
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z" />
113+
</svg>
114+
) : (
115+
<span
116+
class="rounded-full"
117+
style={{ width: "8px", height: "8px", background: "#6b7280", "margin-left": "16px" }}
118+
/>
119+
)}
120+
</span>
121+
<input
122+
class="flex-1 min-w-0 outline-none rounded px-1"
123+
style={{
124+
background: "var(--bg-base)",
125+
color: "var(--text-primary)",
126+
border: "1px solid var(--accent-blue)",
127+
"font-size": "12px",
128+
height: "20px",
129+
"font-family": "var(--font-sans)",
130+
}}
131+
placeholder={props.creatingType === "folder" ? "folder name" : "file name"}
132+
onKeyDown={(e) => {
133+
if (e.key === "Enter") commitRootCreate(e.currentTarget.value.trim());
134+
if (e.key === "Escape") props.onCreateDone?.();
135+
e.stopPropagation();
136+
}}
137+
onBlur={(e) => commitRootCreate(e.currentTarget.value.trim())}
138+
ref={(el) => setTimeout(() => el.focus(), 0)}
139+
/>
140+
</div>
141+
</Show>
142+
32143
<For each={fileTree()}>
33144
{(entry) => <FileTreeItem entry={entry} depth={0} />}
34145
</For>

0 commit comments

Comments
 (0)