@hienlh/ppm 0.13.15 → 0.13.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/CLAUDE.md +5 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/http-api.md +1 -1
  5. package/bun.lock +2135 -0
  6. package/bunfig.toml +2 -0
  7. package/dist/web/assets/{audio-preview-YOG6Biao.js → audio-preview-bQ4k3Rdv.js} +1 -1
  8. package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DISlwA7-.js} +3 -3
  9. package/dist/web/assets/code-editor-Cni2pSOw.js +8 -0
  10. package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-82mk659D.js} +1 -1
  11. package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-eCnvGdDi.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-cezBVQp6.js} +1 -1
  13. package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-B5dN_Qrm.js} +1 -1
  14. package/dist/web/assets/file-store-DOxcU_7s.js +1 -0
  15. package/dist/web/assets/{glide-data-grid-BVt0mwcA.js → glide-data-grid-yscGXxJe.js} +1 -1
  16. package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-CGdBnOP0.js} +1 -1
  17. package/dist/web/assets/index-C_pdjLi6.js +27 -0
  18. package/dist/web/assets/index-nC9UURj4.css +2 -0
  19. package/dist/web/assets/keybindings-store-LHrHsvXn.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-DF-Ga1mN.js} +1 -1
  21. package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-C15gYiMf.js} +1 -1
  22. package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BcpVh4oH.js} +1 -1
  23. package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-DqcY70o6.js} +1 -1
  24. package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-CMso6o_A.js} +1 -1
  25. package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-C0Lq3NYC.js} +1 -1
  26. package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-CFHqKvjt.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-ej7HGI3k.js} +1 -1
  28. package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-BZLGMaKk.js} +1 -1
  29. package/dist/web/index.html +3 -3
  30. package/dist/web/sw.js +1 -1
  31. package/package.json +1 -1
  32. package/src/index.ts +0 -0
  33. package/src/server/routes/files.ts +15 -0
  34. package/src/services/file.service.ts +15 -0
  35. package/src/web/components/editor/editor-breadcrumb.tsx +88 -36
  36. package/src/web/components/explorer/file-actions.tsx +12 -129
  37. package/src/web/components/explorer/file-icon-map.ts +69 -0
  38. package/src/web/components/explorer/file-tree.tsx +177 -362
  39. package/src/web/components/explorer/inline-tree-input.tsx +120 -0
  40. package/src/web/components/explorer/tree-node-context-menu.tsx +97 -0
  41. package/src/web/components/explorer/tree-node.tsx +343 -0
  42. package/src/web/components/explorer/use-file-upload-drag.ts +77 -0
  43. package/src/web/components/explorer/use-tree-keyboard-nav.ts +126 -0
  44. package/src/web/components/layout/mobile-nav.tsx +73 -84
  45. package/src/web/components/layout/project-bottom-sheet.tsx +61 -82
  46. package/src/web/components/ui/adaptive-context-menu.tsx +245 -0
  47. package/src/web/components/ui/mobile-bottom-sheet.tsx +155 -0
  48. package/src/web/hooks/use-is-mobile.ts +28 -0
  49. package/src/web/hooks/use-swipe-to-dismiss.ts +46 -0
  50. package/src/web/stores/file-store.ts +74 -3
  51. package/src/web/stores/git-status-store.ts +87 -2
  52. package/dist/web/assets/code-editor-C4nuAsy6.js +0 -8
  53. package/dist/web/assets/file-store-4BpOJthN.js +0 -1
  54. package/dist/web/assets/index-CSK33ACc.css +0 -2
  55. package/dist/web/assets/index-gZKF1YKy.js +0 -27
  56. package/dist/web/assets/keybindings-store-DBKLTPrk.js +0 -1
@@ -1,25 +1,14 @@
1
- import { useEffect, useCallback, useState, useRef, memo } from "react";
1
+ /**
2
+ * FileTree — the main file explorer container.
3
+ * Renders toolbar, tree nodes via TreeNode, root-level drag/drop, and file actions.
4
+ */
5
+ import { useEffect, useCallback, useState, useRef } from "react";
2
6
  import {
3
- Folder,
4
- FolderOpen,
5
- File,
6
- FileCode,
7
- FileJson,
8
- FileText,
9
- FileType,
10
- FileImage,
11
- FileVideo,
12
- FileAudio,
13
- FileSpreadsheet,
14
- FileArchive,
15
- Database,
16
- ChevronRight,
17
- ChevronDown,
18
- Download,
19
- Loader2,
20
7
  FilePlus,
21
8
  FolderPlus,
22
9
  RefreshCw,
10
+ ChevronsDownUp,
11
+ Crosshair,
23
12
  } from "lucide-react";
24
13
  import { useShallow } from "zustand/react/shallow";
25
14
  import { useFileStore, type FileNode } from "@/stores/file-store";
@@ -36,283 +25,19 @@ import {
36
25
  ContextMenuItem,
37
26
  ContextMenuSeparator,
38
27
  ContextMenuTrigger,
39
- } from "@/components/ui/context-menu";
28
+ } from "@/components/ui/adaptive-context-menu";
40
29
  import { FileActions } from "./file-actions";
30
+ import { TreeNode } from "./tree-node";
31
+ import { InlineTreeInput } from "./inline-tree-input";
41
32
  import { downloadFile, downloadFolder } from "@/lib/file-download";
42
- import { getAuthToken, projectUrl } from "@/lib/api-client";
43
-
44
- /** Check if drag event is from OS files (not internal PPM drag) */
45
- function isExternalFileDrag(e: React.DragEvent): boolean {
46
- return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
47
- }
33
+ import { api, projectUrl } from "@/lib/api-client";
34
+ import { useFileUploadDrag } from "./use-file-upload-drag";
35
+ import { Loader2 } from "lucide-react";
36
+ import { useTreeKeyboardNav } from "./use-tree-keyboard-nav";
48
37
 
49
38
  /** Synthetic root node for creating files/folders at project root */
50
39
  const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
51
40
 
52
- type FileIconInfo = { icon: React.ComponentType<{ className?: string }>; color?: string };
53
-
54
- const FILE_ICON_MAP: Record<string, FileIconInfo> = {
55
- // Code
56
- ts: { icon: FileCode, color: "text-blue-400" }, tsx: { icon: FileCode, color: "text-blue-400" },
57
- js: { icon: FileCode, color: "text-yellow-400" }, jsx: { icon: FileCode, color: "text-yellow-400" },
58
- py: { icon: FileCode, color: "text-green-400" }, rs: { icon: FileCode, color: "text-orange-400" },
59
- go: { icon: FileCode, color: "text-cyan-400" }, c: { icon: FileCode, color: "text-blue-300" },
60
- cpp: { icon: FileCode, color: "text-blue-300" }, java: { icon: FileCode, color: "text-red-400" },
61
- rb: { icon: FileCode, color: "text-red-400" }, php: { icon: FileCode, color: "text-purple-400" },
62
- swift: { icon: FileCode, color: "text-orange-400" }, kt: { icon: FileCode, color: "text-purple-400" },
63
- dart: { icon: FileCode, color: "text-cyan-400" }, sh: { icon: FileCode, color: "text-green-300" },
64
- html: { icon: FileCode, color: "text-orange-400" }, css: { icon: FileCode, color: "text-blue-400" },
65
- scss: { icon: FileCode, color: "text-pink-400" },
66
- // Data
67
- json: { icon: FileJson, color: "text-yellow-400" },
68
- yaml: { icon: FileType, color: "text-orange-300" }, yml: { icon: FileType, color: "text-orange-300" },
69
- toml: { icon: FileType, color: "text-orange-300" }, ini: { icon: FileType, color: "text-orange-300" },
70
- env: { icon: FileType, color: "text-yellow-300" },
71
- csv: { icon: FileSpreadsheet, color: "text-green-400" },
72
- xls: { icon: FileSpreadsheet, color: "text-green-400" }, xlsx: { icon: FileSpreadsheet, color: "text-green-400" },
73
- // Text/Docs
74
- md: { icon: FileText, color: "text-text-secondary" }, txt: { icon: FileText, color: "text-text-secondary" },
75
- log: { icon: FileText, color: "text-text-subtle" }, pdf: { icon: FileText, color: "text-red-400" },
76
- // Images
77
- png: { icon: FileImage, color: "text-green-400" }, jpg: { icon: FileImage, color: "text-green-400" },
78
- jpeg: { icon: FileImage, color: "text-green-400" }, gif: { icon: FileImage, color: "text-green-400" },
79
- svg: { icon: FileImage, color: "text-yellow-400" }, webp: { icon: FileImage, color: "text-green-400" },
80
- ico: { icon: FileImage, color: "text-green-400" }, bmp: { icon: FileImage, color: "text-green-400" },
81
- // Video
82
- mp4: { icon: FileVideo, color: "text-purple-400" }, webm: { icon: FileVideo, color: "text-purple-400" },
83
- mov: { icon: FileVideo, color: "text-purple-400" }, avi: { icon: FileVideo, color: "text-purple-400" },
84
- mkv: { icon: FileVideo, color: "text-purple-400" },
85
- // Audio
86
- mp3: { icon: FileAudio, color: "text-pink-400" }, wav: { icon: FileAudio, color: "text-pink-400" },
87
- ogg: { icon: FileAudio, color: "text-pink-400" }, flac: { icon: FileAudio, color: "text-pink-400" },
88
- // Database
89
- db: { icon: Database, color: "text-amber-400" }, sqlite: { icon: Database, color: "text-amber-400" },
90
- sqlite3: { icon: Database, color: "text-amber-400" }, sql: { icon: Database, color: "text-amber-400" },
91
- // Archives
92
- zip: { icon: FileArchive, color: "text-amber-300" }, tar: { icon: FileArchive, color: "text-amber-300" },
93
- gz: { icon: FileArchive, color: "text-amber-300" }, rar: { icon: FileArchive, color: "text-amber-300" },
94
- "7z": { icon: FileArchive, color: "text-amber-300" },
95
- };
96
-
97
- const DEFAULT_FILE_ICON: FileIconInfo = { icon: File };
98
-
99
- function getFileIcon(name: string): FileIconInfo {
100
- const ext = name.split(".").pop()?.toLowerCase() ?? "";
101
- return FILE_ICON_MAP[ext] ?? DEFAULT_FILE_ICON;
102
- }
103
-
104
- interface TreeNodeProps {
105
- node: FileNode;
106
- depth: number;
107
- projectName: string;
108
- onAction: (action: string, node: FileNode) => void;
109
- onFileDrop: (targetDir: string, files: FileList) => void;
110
- onFileOpen?: () => void;
111
- }
112
-
113
- const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
114
- const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(
115
- useShallow((s) => ({
116
- expandedPaths: s.expandedPaths,
117
- loadedPaths: s.loadedPaths,
118
- inflight: s.inflight,
119
- toggleExpand: s.toggleExpand,
120
- selectedFiles: s.selectedFiles,
121
- toggleFileSelect: s.toggleFileSelect,
122
- })),
123
- );
124
- const openTab = useTabStore((s) => s.openTab);
125
- const compareSelection = useCompareStore((s) => s.selection);
126
- const isExpanded = expandedPaths.has(node.path);
127
- const isDir = node.type === "directory";
128
- const isSelected = selectedFiles.includes(node.path);
129
- const isIgnored = node.ignored === true;
130
- const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(node.path) && inflight.has(node.path);
131
- const [isDragOver, setIsDragOver] = useState(false);
132
- const dragCounter = useRef(0);
133
-
134
- function handleClick(e: React.MouseEvent) {
135
- if (isDir) {
136
- toggleExpand(projectName, node.path);
137
- return;
138
- }
139
- // Ctrl/Cmd+Click: toggle file selection for compare
140
- if (e.metaKey || e.ctrlKey) {
141
- toggleFileSelect(node.path);
142
- return;
143
- }
144
- const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
145
- const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
146
- openTab({
147
- type: isSqlite ? "sqlite" : "editor",
148
- title: node.name,
149
- metadata: { filePath: node.path, projectName },
150
- projectId: projectName,
151
- closable: true,
152
- });
153
- onFileOpen?.();
154
- }
155
-
156
- function handleDragStart(e: React.DragEvent) {
157
- const pathValue = isDir ? `${node.path}/` : node.path;
158
- e.dataTransfer.setData("application/x-ppm-path", pathValue);
159
- e.dataTransfer.setData("text/plain", node.name);
160
- e.dataTransfer.effectAllowed = "copy";
161
- }
162
-
163
- function handleNodeDragEnter(e: React.DragEvent) {
164
- if (!isDir || !isExternalFileDrag(e)) return;
165
- e.preventDefault();
166
- e.stopPropagation();
167
- dragCounter.current++;
168
- if (dragCounter.current === 1) setIsDragOver(true);
169
- }
170
- function handleNodeDragLeave(e: React.DragEvent) {
171
- if (!isDir) return;
172
- e.stopPropagation();
173
- dragCounter.current--;
174
- if (dragCounter.current === 0) setIsDragOver(false);
175
- }
176
- function handleNodeDragOver(e: React.DragEvent) {
177
- if (!isDir || !isExternalFileDrag(e)) return;
178
- e.preventDefault();
179
- e.stopPropagation();
180
- e.dataTransfer.dropEffect = "copy";
181
- }
182
- function handleNodeDrop(e: React.DragEvent) {
183
- if (!isDir || !isExternalFileDrag(e)) return;
184
- e.preventDefault();
185
- e.stopPropagation();
186
- dragCounter.current = 0;
187
- setIsDragOver(false);
188
- if (e.dataTransfer.files.length > 0) {
189
- onFileDrop(node.path, e.dataTransfer.files);
190
- }
191
- }
192
-
193
- const { icon: FileIcon, color: fileIconColor } = isDir
194
- ? { icon: isExpanded ? FolderOpen : Folder, color: "text-primary" }
195
- : getFileIcon(node.name);
196
-
197
- const sortedChildren = node.children
198
- ? [...node.children].sort((a, b) => {
199
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
200
- return a.name.localeCompare(b.name);
201
- })
202
- : [];
203
-
204
- return (
205
- <div
206
- onDragEnter={isDir ? handleNodeDragEnter : undefined}
207
- onDragLeave={isDir ? handleNodeDragLeave : undefined}
208
- onDragOver={isDir ? handleNodeDragOver : undefined}
209
- onDrop={isDir ? handleNodeDrop : undefined}
210
- >
211
- <ContextMenu>
212
- <ContextMenuTrigger asChild>
213
- <button
214
- draggable
215
- onDragStart={handleDragStart}
216
- onClick={handleClick}
217
- className={cn(
218
- "flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
219
- "min-h-[32px] hover:bg-surface-elevated transition-colors text-left",
220
- "select-none",
221
- isIgnored && "opacity-40",
222
- isSelected && "bg-primary/15 ring-1 ring-primary/40",
223
- isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
224
- )}
225
- style={{ paddingLeft: `${depth * 16 + 8}px` }}
226
- >
227
- {isDir ? (
228
- isLoadingChildren ? (
229
- <Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
230
- ) : isExpanded ? (
231
- <ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
232
- ) : (
233
- <ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
234
- )
235
- ) : (
236
- <span className="w-3.5 shrink-0" />
237
- )}
238
- <FileIcon
239
- className={cn(
240
- "size-4 shrink-0",
241
- fileIconColor ?? "text-text-secondary",
242
- )}
243
- />
244
- <span className="truncate">{node.name}</span>
245
- </button>
246
- </ContextMenuTrigger>
247
- <ContextMenuContent>
248
- {isDir && (
249
- <>
250
- <ContextMenuItem onClick={() => onAction("new-file", node)}>
251
- New File
252
- </ContextMenuItem>
253
- <ContextMenuItem onClick={() => onAction("new-folder", node)}>
254
- New Folder
255
- </ContextMenuItem>
256
- <ContextMenuSeparator />
257
- </>
258
- )}
259
- <ContextMenuItem onClick={() => onAction("rename", node)}>
260
- Rename
261
- </ContextMenuItem>
262
- <ContextMenuItem
263
- variant="destructive"
264
- onClick={() => onAction("delete", node)}
265
- >
266
- Delete
267
- </ContextMenuItem>
268
- <ContextMenuSeparator />
269
- <ContextMenuItem onClick={() => onAction("copy-path", node)}>
270
- Copy Path
271
- </ContextMenuItem>
272
- <ContextMenuSeparator />
273
- <ContextMenuItem onClick={() => onAction("download", node)}>
274
- <Download className="size-3.5 mr-2" />
275
- Download{isDir ? " as Zip" : ""}
276
- </ContextMenuItem>
277
- {!isDir && (
278
- <>
279
- <ContextMenuSeparator />
280
- <ContextMenuItem onClick={() => onAction("select-for-compare", node)}>
281
- Select for Compare
282
- </ContextMenuItem>
283
- {compareSelection && compareSelection.projectName === projectName && compareSelection.filePath !== node.path && (
284
- <ContextMenuItem onClick={() => onAction("compare-with-selected", node)}>
285
- Compare with Selected ({compareSelection.label})
286
- </ContextMenuItem>
287
- )}
288
- </>
289
- )}
290
- {!isDir && selectedFiles.length === 2 && (
291
- <>
292
- <ContextMenuSeparator />
293
- <ContextMenuItem onClick={() => onAction("compare-selected", node)}>
294
- Compare Selected
295
- </ContextMenuItem>
296
- </>
297
- )}
298
- </ContextMenuContent>
299
- </ContextMenu>
300
-
301
- {isDir && isExpanded && sortedChildren.map((child) => (
302
- <TreeNode
303
- key={child.path}
304
- node={child}
305
- depth={depth + 1}
306
- projectName={projectName}
307
- onAction={onAction}
308
- onFileDrop={onFileDrop}
309
- onFileOpen={onFileOpen}
310
- />
311
- ))}
312
- </div>
313
- );
314
- });
315
-
316
41
  interface FileTreeProps {
317
42
  onFileOpen?: () => void;
318
43
  }
@@ -322,8 +47,9 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
322
47
  tree, loading, error,
323
48
  loadRoot, loadIndex, loadChildren, invalidateIndex, invalidateFolder,
324
49
  reset, selectedFiles, clearSelection, setExpanded,
325
- // fetchTree kept for uploadFiles refresh
326
- fetchTree,
50
+ fetchTree, inlineAction, setInlineAction, clearInlineAction,
51
+ clipboard, setClipboard, collapseAll,
52
+ focusedPath, setFocusedPath, expandedPaths, toggleExpand,
327
53
  } = useFileStore(
328
54
  useShallow((s) => ({
329
55
  tree: s.tree,
@@ -339,6 +65,16 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
339
65
  clearSelection: s.clearSelection,
340
66
  setExpanded: s.setExpanded,
341
67
  fetchTree: s.fetchTree,
68
+ inlineAction: s.inlineAction,
69
+ setInlineAction: s.setInlineAction,
70
+ clearInlineAction: s.clearInlineAction,
71
+ clipboard: s.clipboard,
72
+ setClipboard: s.setClipboard,
73
+ collapseAll: s.collapseAll,
74
+ focusedPath: s.focusedPath,
75
+ setFocusedPath: s.setFocusedPath,
76
+ expandedPaths: s.expandedPaths,
77
+ toggleExpand: s.toggleExpand,
342
78
  })),
343
79
  );
344
80
  const activeProject = useProjectStore((s) => s.activeProject);
@@ -348,7 +84,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
348
84
  node: FileNode;
349
85
  } | null>(null);
350
86
 
351
- /** Full reload used by toolbar Refresh button and post-upload */
352
87
  const reloadTree = useCallback(() => {
353
88
  if (!activeProject) return;
354
89
  reset();
@@ -356,22 +91,90 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
356
91
  loadIndex(activeProject.name);
357
92
  }, [activeProject, reset, loadRoot, loadIndex]);
358
93
 
359
- // On project switch: reset + load root + load index in parallel + auto-expand root (1 level)
94
+ /** Reveal (scroll to + highlight) the file that's open in the active tab */
95
+ const revealActiveFile = useCallback(async () => {
96
+ if (!activeProject) return;
97
+ const { tabs, activeTabId } = useTabStore.getState();
98
+ const activeTab = tabs.find((t) => t.id === activeTabId);
99
+ const filePath = activeTab?.metadata?.filePath as string | undefined;
100
+ if (!filePath) return;
101
+
102
+ // Expand all parent folders
103
+ const parts = filePath.split("/");
104
+ const projectName = activeProject.name;
105
+ for (let i = 1; i < parts.length; i++) {
106
+ const parentPath = parts.slice(0, i).join("/");
107
+ setExpanded(parentPath, true);
108
+ // Ensure children are loaded
109
+ await loadChildren(projectName, parentPath);
110
+ }
111
+ setFocusedPath(filePath);
112
+ }, [activeProject, setExpanded, loadChildren, setFocusedPath]);
113
+
114
+ /** Paste clipboard files into a target directory */
115
+ const pasteFiles = useCallback(async (targetDir: string) => {
116
+ if (!activeProject || !clipboard) return;
117
+ const projectName = activeProject.name;
118
+ const endpoint = clipboard.operation === "cut" ? "move" : "copy";
119
+ for (const source of clipboard.paths) {
120
+ const name = source.includes("/") ? source.slice(source.lastIndexOf("/") + 1) : source;
121
+ const destination = targetDir ? `${targetDir}/${name}` : name;
122
+ try {
123
+ await api.post(`${projectUrl(projectName)}/files/${endpoint}`, { source, destination });
124
+ } catch (err) {
125
+ toast.error(err instanceof Error ? err.message : `Failed to ${endpoint}`);
126
+ }
127
+ }
128
+ if (clipboard.operation === "cut") setClipboard(null);
129
+ reloadTree();
130
+ }, [activeProject, clipboard, setClipboard, reloadTree]);
131
+
132
+ const treeContainerRef = useRef<HTMLDivElement>(null);
133
+
134
+ /** Ctrl+X / Ctrl+C / Ctrl+V — scoped to file tree container focus */
135
+ const handleClipboardKeyDown = useCallback((e: React.KeyboardEvent) => {
136
+ if (!activeProject) return;
137
+ const target = e.target as HTMLElement;
138
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return;
139
+
140
+ const mod = e.metaKey || e.ctrlKey;
141
+ if (!mod) return;
142
+
143
+ if (e.key === "x" && selectedFiles.length > 0) {
144
+ e.preventDefault();
145
+ setClipboard({ paths: [...selectedFiles], operation: "cut" });
146
+ } else if (e.key === "c" && selectedFiles.length > 0) {
147
+ e.preventDefault();
148
+ setClipboard({ paths: [...selectedFiles], operation: "copy" });
149
+ } else if (e.key === "v" && clipboard) {
150
+ e.preventDefault();
151
+ pasteFiles("");
152
+ }
153
+ }, [activeProject, selectedFiles, clipboard, setClipboard, pasteFiles]);
154
+
155
+ const { handleTreeKeyDown } = useTreeKeyboardNav({
156
+ tree,
157
+ expandedPaths,
158
+ focusedPath,
159
+ setFocusedPath,
160
+ setExpanded,
161
+ toggleExpand,
162
+ projectName: activeProject?.name,
163
+ onAction: handleAction,
164
+ });
165
+
166
+ // On project switch: reset + load root + load index + auto-expand root
360
167
  useEffect(() => {
361
168
  if (!activeProject) return;
362
169
  reset();
363
170
  const name = activeProject.name;
364
-
365
- // Load root entries, then auto-expand the root node itself (path="")
366
171
  loadRoot(name).then(() => {
367
- // Auto-expand root — marks "" as expanded so root-level dirs show children on next expand
368
- // Root entries are already visible; no deeper auto-expand per plan decision
369
172
  useFileStore.getState().setExpanded("", true);
370
173
  });
371
174
  loadIndex(name);
372
175
  }, [activeProject?.name]); // eslint-disable-line react-hooks/exhaustive-deps
373
176
 
374
- // Handle WS file:changed → invalidate folder + index instead of full tree refetch
177
+ // Handle WS file:changed → invalidate folder + index
375
178
  useEffect(() => {
376
179
  if (!activeProject) return;
377
180
  const projectName = activeProject.name;
@@ -384,7 +187,6 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
384
187
  clearTimeout(debounceTimer);
385
188
  debounceTimer = setTimeout(() => {
386
189
  const store = useFileStore.getState();
387
- // Derive parent folder from changed file path
388
190
  const changedPath: string = detail.path ?? "";
389
191
  const parentPath = changedPath.includes("/")
390
192
  ? changedPath.slice(0, changedPath.lastIndexOf("/"))
@@ -402,67 +204,43 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
402
204
  };
403
205
  }, [activeProject]);
404
206
 
405
- const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
406
- if (!activeProject) return;
407
- const form = new FormData();
408
- form.append("targetDir", targetDir);
409
- for (const file of files) form.append("files", file);
410
- const headers: HeadersInit = {};
411
- const token = getAuthToken();
412
- if (token) headers["Authorization"] = `Bearer ${token}`;
413
- try {
414
- const res = await fetch(`${projectUrl(activeProject.name)}/files/upload`, {
415
- method: "POST",
416
- headers,
417
- body: form,
418
- });
419
- if (!res.ok) {
420
- const json = await res.json();
421
- console.error("Upload failed:", json.error);
422
- }
423
- // Invalidate the target folder so it refreshes
424
- const store = useFileStore.getState();
425
- const folderPath = targetDir;
426
- const folderLoadedPaths = store.loadedPaths;
427
- if (folderLoadedPaths.has(folderPath)) {
428
- const lp = new Set(store.loadedPaths);
429
- lp.delete(folderPath);
430
- // Force reload by clearing and re-expanding
431
- await store.invalidateFolder(activeProject.name, folderPath);
432
- }
433
- if (targetDir) setExpanded(targetDir, true);
434
- } catch (e) {
435
- console.error("Upload error:", e);
436
- }
437
- }, [activeProject, setExpanded]);
438
-
439
- const [isRootDragOver, setIsRootDragOver] = useState(false);
440
- const rootDragCounter = useRef(0);
441
-
442
- function handleRootDragEnter(e: React.DragEvent) {
443
- if (!isExternalFileDrag(e)) return;
444
- e.preventDefault();
445
- rootDragCounter.current++;
446
- if (rootDragCounter.current === 1) setIsRootDragOver(true);
447
- }
448
- function handleRootDragLeave() {
449
- rootDragCounter.current--;
450
- if (rootDragCounter.current === 0) setIsRootDragOver(false);
451
- }
452
- function handleRootDragOver(e: React.DragEvent) {
453
- if (!isExternalFileDrag(e)) return;
454
- e.preventDefault();
455
- e.dataTransfer.dropEffect = "copy";
456
- }
457
- function handleRootDrop(e: React.DragEvent) {
458
- if (!isExternalFileDrag(e)) return;
459
- e.preventDefault();
460
- rootDragCounter.current = 0;
461
- setIsRootDragOver(false);
462
- if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
463
- }
207
+ const {
208
+ uploadFiles, isRootDragOver,
209
+ handleRootDragEnter, handleRootDragLeave, handleRootDragOver, handleRootDrop,
210
+ } = useFileUploadDrag({ projectName: activeProject?.name, setExpanded });
464
211
 
465
212
  async function handleAction(action: string, node: FileNode) {
213
+ if (action === "toggle-expand" && node.type === "directory") {
214
+ toggleExpand(activeProject!.name, node.path);
215
+ return;
216
+ }
217
+ if (action === "open-file" && node.type === "file") {
218
+ const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
219
+ const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
220
+ openTab({
221
+ type: isSqlite ? "sqlite" : "editor",
222
+ title: node.name,
223
+ metadata: { filePath: node.path, projectName: activeProject!.name },
224
+ projectId: activeProject!.name,
225
+ closable: true,
226
+ });
227
+ onFileOpen?.();
228
+ return;
229
+ }
230
+ if (action === "cut") {
231
+ const paths = selectedFiles.length > 0 && selectedFiles.includes(node.path) ? [...selectedFiles] : [node.path];
232
+ setClipboard({ paths, operation: "cut" });
233
+ return;
234
+ }
235
+ if (action === "copy-file") {
236
+ const paths = selectedFiles.length > 0 && selectedFiles.includes(node.path) ? [...selectedFiles] : [node.path];
237
+ setClipboard({ paths, operation: "copy" });
238
+ return;
239
+ }
240
+ if (action === "paste" && node.type === "directory") {
241
+ pasteFiles(node.path);
242
+ return;
243
+ }
466
244
  if (action === "copy-path") {
467
245
  navigator.clipboard.writeText(node.path).catch(() => {});
468
246
  return;
@@ -518,6 +296,19 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
518
296
  clearSelection();
519
297
  return;
520
298
  }
299
+ if (action === "new-file" || action === "new-folder") {
300
+ const parentPath = node.type === "directory" ? node.path : "";
301
+ if (parentPath) setExpanded(parentPath, true);
302
+ setInlineAction({ type: action as "new-file" | "new-folder", parentPath });
303
+ return;
304
+ }
305
+ if (action === "rename") {
306
+ const parentPath = node.path.includes("/")
307
+ ? node.path.slice(0, node.path.lastIndexOf("/"))
308
+ : "";
309
+ setInlineAction({ type: "rename", parentPath, existingNode: node });
310
+ return;
311
+ }
521
312
  setActionState({ action, node });
522
313
  }
523
314
 
@@ -558,14 +349,17 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
558
349
 
559
350
  return (
560
351
  <div
561
- className={cn("flex flex-col h-full", isRootDragOver && "bg-primary/5")}
352
+ ref={treeContainerRef}
353
+ className={cn("flex flex-col h-full outline-none", isRootDragOver && "bg-primary/5")}
354
+ tabIndex={0}
355
+ onKeyDown={(e) => { handleClipboardKeyDown(e); handleTreeKeyDown(e); }}
562
356
  onDragEnter={handleRootDragEnter}
563
357
  onDragLeave={handleRootDragLeave}
564
358
  onDragOver={handleRootDragOver}
565
359
  onDrop={handleRootDrop}
566
360
  >
567
361
  {/* Toolbar */}
568
- <div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
362
+ <div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0 sticky top-0 z-10 bg-surface">
569
363
  <button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
570
364
  <FilePlus className="size-3.5" />
571
365
  </button>
@@ -573,6 +367,12 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
573
367
  <FolderPlus className="size-3.5" />
574
368
  </button>
575
369
  <div className="flex-1" />
370
+ <button onClick={revealActiveFile} title="Reveal Active File" className={toolbarBtnClass}>
371
+ <Crosshair className="size-3.5" />
372
+ </button>
373
+ <button onClick={collapseAll} title="Collapse All" className={toolbarBtnClass}>
374
+ <ChevronsDownUp className="size-3.5" />
375
+ </button>
576
376
  <button onClick={reloadTree} title="Refresh" className={toolbarBtnClass}>
577
377
  <RefreshCw className="size-3.5" />
578
378
  </button>
@@ -583,6 +383,21 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
583
383
  <ContextMenuTrigger asChild>
584
384
  <ScrollArea className="flex-1">
585
385
  <div className="py-1">
386
+ {inlineAction && inlineAction.parentPath === "" && inlineAction.type !== "rename" && (
387
+ <InlineTreeInput
388
+ defaultValue=""
389
+ placeholder={inlineAction.type === "new-file" ? "filename.ts" : "folder-name"}
390
+ depth={0}
391
+ icon={inlineAction.type === "new-file" ? "file" : "folder"}
392
+ onConfirm={async (name) => {
393
+ const type = inlineAction.type === "new-file" ? "file" : "directory";
394
+ await api.post(`${projectUrl(activeProject!.name)}/files/create`, { path: name, type });
395
+ clearInlineAction();
396
+ reloadTree();
397
+ }}
398
+ onCancel={clearInlineAction}
399
+ />
400
+ )}
586
401
  {sorted.map((node) => (
587
402
  <TreeNode
588
403
  key={node.path}
@@ -617,9 +432,9 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
617
432
  </ContextMenuContent>
618
433
  </ContextMenu>
619
434
 
620
- {actionState && (
435
+ {actionState?.action === "delete" && (
621
436
  <FileActions
622
- action={actionState.action}
437
+ action="delete"
623
438
  node={actionState.node}
624
439
  projectName={activeProject.name}
625
440
  onClose={() => setActionState(null)}