@hienlh/ppm 0.13.15 → 0.13.17

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 +29 -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-CEYHVcPO.js} +1 -1
  8. package/dist/web/assets/{chat-tab-DbdDJuLu.js → chat-tab-DgKpBVjE.js} +3 -3
  9. package/dist/web/assets/code-editor-Cq2IOaV8.js +8 -0
  10. package/dist/web/assets/{conflict-editor-DnGfriL5.js → conflict-editor-C3l0f_1F.js} +1 -1
  11. package/dist/web/assets/{database-viewer-AodppoTs.js → database-viewer-Bsj5g3RI.js} +1 -1
  12. package/dist/web/assets/{diff-viewer-DykLUwna.js → diff-viewer-LNzXRYQX.js} +1 -1
  13. package/dist/web/assets/{extension-webview-Bck7QuaB.js → extension-webview-ClSDQWq_.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-rA_8qdHA.js} +1 -1
  16. package/dist/web/assets/{image-preview-DaSmrIvY.js → image-preview-BEC5d-NV.js} +1 -1
  17. package/dist/web/assets/index-VDmxXycP.js +27 -0
  18. package/dist/web/assets/index-nC9UURj4.css +2 -0
  19. package/dist/web/assets/keybindings-store-CNsaPqyF.js +1 -0
  20. package/dist/web/assets/{markdown-renderer-B1me_hz2.js → markdown-renderer-CPVvj7aP.js} +1 -1
  21. package/dist/web/assets/{pdf-preview-Dci7TIL1.js → pdf-preview-Dn0-iiir.js} +1 -1
  22. package/dist/web/assets/{port-forwarding-tab-BeM40G-J.js → port-forwarding-tab-BTScoTXw.js} +1 -1
  23. package/dist/web/assets/{postgres-viewer-CGVBOwA9.js → postgres-viewer-zrm-uI0Y.js} +1 -1
  24. package/dist/web/assets/{settings-tab-CYS8VfNl.js → settings-tab-D3_bKOG3.js} +1 -1
  25. package/dist/web/assets/{sql-query-editor-DstPySPF.js → sql-query-editor-CGIWCq6O.js} +1 -1
  26. package/dist/web/assets/{sqlite-viewer-SUGEk_G1.js → sqlite-viewer-Bvd2SXu-.js} +1 -1
  27. package/dist/web/assets/{terminal-tab-CJvjF79J.js → terminal-tab-BwdYHHa4.js} +1 -1
  28. package/dist/web/assets/{video-preview-gJSKmPQr.js → video-preview-onuXf7EW.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 +84 -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 +51 -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
@@ -0,0 +1,120 @@
1
+ /**
2
+ * InlineTreeInput — inline input for creating/renaming files in the tree.
3
+ * Renders at the correct tree depth with auto-focus.
4
+ * Blur or Enter confirms, Escape cancels.
5
+ */
6
+ import { useState, useRef, useEffect, useCallback } from "react";
7
+ import { File, Folder } from "lucide-react";
8
+ import { cn } from "@/lib/utils";
9
+
10
+ interface InlineTreeInputProps {
11
+ defaultValue: string;
12
+ placeholder: string;
13
+ depth: number;
14
+ icon: "file" | "folder";
15
+ onConfirm: (value: string) => Promise<void>;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function InlineTreeInput({
20
+ defaultValue,
21
+ placeholder,
22
+ depth,
23
+ icon,
24
+ onConfirm,
25
+ onCancel,
26
+ }: InlineTreeInputProps) {
27
+ const [value, setValue] = useState(defaultValue);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [submitting, setSubmitting] = useState(false);
30
+ const inputRef = useRef<HTMLInputElement>(null);
31
+ const confirmedRef = useRef(false);
32
+
33
+ useEffect(() => {
34
+ // Auto-focus and select filename (before last dot) for rename
35
+ const el = inputRef.current;
36
+ if (!el) return;
37
+ requestAnimationFrame(() => {
38
+ el.focus();
39
+ if (defaultValue) {
40
+ const dotIdx = defaultValue.lastIndexOf(".");
41
+ el.setSelectionRange(0, dotIdx > 0 ? dotIdx : defaultValue.length);
42
+ }
43
+ });
44
+ }, [defaultValue]);
45
+
46
+ const doConfirm = useCallback(async () => {
47
+ if (confirmedRef.current || submitting) return;
48
+ const trimmed = value.trim();
49
+ if (!trimmed) {
50
+ onCancel();
51
+ return;
52
+ }
53
+ confirmedRef.current = true;
54
+ setSubmitting(true);
55
+ setError(null);
56
+ try {
57
+ await onConfirm(trimmed);
58
+ } catch (err) {
59
+ confirmedRef.current = false;
60
+ setError(err instanceof Error ? err.message : "Failed");
61
+ setSubmitting(false);
62
+ }
63
+ }, [value, submitting, onConfirm, onCancel]);
64
+
65
+ function handleKeyDown(e: React.KeyboardEvent) {
66
+ if (e.key === "Enter") {
67
+ e.preventDefault();
68
+ doConfirm();
69
+ } else if (e.key === "Escape") {
70
+ e.preventDefault();
71
+ onCancel();
72
+ }
73
+ }
74
+
75
+ function handleBlur() {
76
+ // Blur = confirm (VS Code style)
77
+ if (!confirmedRef.current) {
78
+ doConfirm();
79
+ }
80
+ }
81
+
82
+ const Icon = icon === "folder" ? Folder : File;
83
+
84
+ return (
85
+ <div>
86
+ <div
87
+ className={cn(
88
+ "flex items-center w-full gap-1.5 px-2 py-0.5",
89
+ "min-h-[32px] text-left",
90
+ )}
91
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
92
+ >
93
+ <span className="w-3.5 shrink-0" />
94
+ <Icon className="size-4 shrink-0 text-text-secondary" />
95
+ <input
96
+ ref={inputRef}
97
+ value={value}
98
+ onChange={(e) => { setValue(e.target.value); setError(null); }}
99
+ onKeyDown={handleKeyDown}
100
+ onBlur={handleBlur}
101
+ placeholder={placeholder}
102
+ disabled={submitting}
103
+ className={cn(
104
+ "flex-1 min-w-0 bg-input text-sm px-1.5 py-0.5 rounded border outline-none",
105
+ "focus:ring-1 focus:ring-primary/50",
106
+ error ? "border-destructive" : "border-primary",
107
+ )}
108
+ />
109
+ </div>
110
+ {error && (
111
+ <p
112
+ className="text-[10px] text-destructive truncate"
113
+ style={{ paddingLeft: `${depth * 16 + 8 + 14 + 22}px` }}
114
+ >
115
+ {error}
116
+ </p>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Context menu items for a tree node (file or folder).
3
+ */
4
+ import { Download } from "lucide-react";
5
+ import type { FileNode, ClipboardState } from "@/stores/file-store";
6
+ import {
7
+ ContextMenuContent,
8
+ ContextMenuItem,
9
+ ContextMenuSeparator,
10
+ } from "@/components/ui/adaptive-context-menu";
11
+
12
+ interface TreeNodeContextMenuProps {
13
+ node: FileNode;
14
+ isDir: boolean;
15
+ projectName: string;
16
+ selectedFiles: string[];
17
+ compareSelection: { filePath: string; projectName: string; label: string } | null;
18
+ clipboard: ClipboardState | null;
19
+ onAction: (action: string, node: FileNode) => void;
20
+ }
21
+
22
+ export function TreeNodeContextMenu({
23
+ node,
24
+ isDir,
25
+ projectName,
26
+ selectedFiles,
27
+ compareSelection,
28
+ clipboard,
29
+ onAction,
30
+ }: TreeNodeContextMenuProps) {
31
+ return (
32
+ <ContextMenuContent>
33
+ {isDir && (
34
+ <>
35
+ <ContextMenuItem onClick={() => onAction("new-file", node)}>
36
+ New File
37
+ </ContextMenuItem>
38
+ <ContextMenuItem onClick={() => onAction("new-folder", node)}>
39
+ New Folder
40
+ </ContextMenuItem>
41
+ <ContextMenuSeparator />
42
+ </>
43
+ )}
44
+ <ContextMenuItem onClick={() => onAction("cut", node)}>
45
+ Cut
46
+ </ContextMenuItem>
47
+ <ContextMenuItem onClick={() => onAction("copy-file", node)}>
48
+ Copy
49
+ </ContextMenuItem>
50
+ {isDir && clipboard && (
51
+ <ContextMenuItem onClick={() => onAction("paste", node)}>
52
+ Paste
53
+ </ContextMenuItem>
54
+ )}
55
+ <ContextMenuSeparator />
56
+ <ContextMenuItem onClick={() => onAction("rename", node)}>
57
+ Rename
58
+ </ContextMenuItem>
59
+ <ContextMenuItem
60
+ variant="destructive"
61
+ onClick={() => onAction("delete", node)}
62
+ >
63
+ Delete
64
+ </ContextMenuItem>
65
+ <ContextMenuSeparator />
66
+ <ContextMenuItem onClick={() => onAction("copy-path", node)}>
67
+ Copy Path
68
+ </ContextMenuItem>
69
+ <ContextMenuSeparator />
70
+ <ContextMenuItem onClick={() => onAction("download", node)}>
71
+ <Download className="size-3.5 mr-2" />
72
+ Download{isDir ? " as Zip" : ""}
73
+ </ContextMenuItem>
74
+ {!isDir && (
75
+ <>
76
+ <ContextMenuSeparator />
77
+ <ContextMenuItem onClick={() => onAction("select-for-compare", node)}>
78
+ Select for Compare
79
+ </ContextMenuItem>
80
+ {compareSelection && compareSelection.projectName === projectName && compareSelection.filePath !== node.path && (
81
+ <ContextMenuItem onClick={() => onAction("compare-with-selected", node)}>
82
+ Compare with Selected ({compareSelection.label})
83
+ </ContextMenuItem>
84
+ )}
85
+ </>
86
+ )}
87
+ {!isDir && selectedFiles.length === 2 && (
88
+ <>
89
+ <ContextMenuSeparator />
90
+ <ContextMenuItem onClick={() => onAction("compare-selected", node)}>
91
+ Compare Selected
92
+ </ContextMenuItem>
93
+ </>
94
+ )}
95
+ </ContextMenuContent>
96
+ );
97
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * TreeNode component — renders a single file/folder row in the explorer tree.
3
+ * Handles click, drag/drop, context menu for individual tree items.
4
+ */
5
+ import { useState, useRef, useEffect, memo } from "react";
6
+ import {
7
+ Folder,
8
+ FolderOpen,
9
+ ChevronRight,
10
+ ChevronDown,
11
+ Loader2,
12
+ } from "lucide-react";
13
+ import { useShallow } from "zustand/react/shallow";
14
+ import { useFileStore, getVisiblePaths, type FileNode, type InlineAction } from "@/stores/file-store";
15
+ import { useTabStore } from "@/stores/tab-store";
16
+ import { useCompareStore } from "@/stores/compare-store";
17
+ import { useGitStatusStore, GIT_STATUS_COLORS, type GitFileStatus } from "@/stores/git-status-store";
18
+ import { api, projectUrl } from "@/lib/api-client";
19
+ import { cn } from "@/lib/utils";
20
+ import { toast } from "sonner";
21
+ import { InlineTreeInput } from "./inline-tree-input";
22
+ import {
23
+ ContextMenu,
24
+ ContextMenuTrigger,
25
+ } from "@/components/ui/adaptive-context-menu";
26
+ import { getFileIcon } from "./file-icon-map";
27
+ import { TreeNodeContextMenu } from "./tree-node-context-menu";
28
+
29
+ /** Check if drag event is from OS files (not internal PPM drag) */
30
+ export function isExternalFileDrag(e: React.DragEvent): boolean {
31
+ return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
32
+ }
33
+
34
+ export interface TreeNodeProps {
35
+ node: FileNode;
36
+ depth: number;
37
+ projectName: string;
38
+ onAction: (action: string, node: FileNode) => void;
39
+ onFileDrop: (targetDir: string, files: FileList) => void;
40
+ onFileOpen?: () => void;
41
+ }
42
+
43
+ export const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
44
+ const { expandedPaths, loadedPaths, inflight, toggleExpand, selectedFiles, toggleFileSelect, inlineAction, clearInlineAction, clipboard, focusedPath, setFocusedPath } = useFileStore(
45
+ useShallow((s) => ({
46
+ expandedPaths: s.expandedPaths,
47
+ loadedPaths: s.loadedPaths,
48
+ inflight: s.inflight,
49
+ toggleExpand: s.toggleExpand,
50
+ selectedFiles: s.selectedFiles,
51
+ toggleFileSelect: s.toggleFileSelect,
52
+ inlineAction: s.inlineAction,
53
+ clearInlineAction: s.clearInlineAction,
54
+ clipboard: s.clipboard,
55
+ focusedPath: s.focusedPath,
56
+ setFocusedPath: s.setFocusedPath,
57
+ })),
58
+ );
59
+ const openTab = useTabStore((s) => s.openTab);
60
+ const compareSelection = useCompareStore((s) => s.selection);
61
+ const isDir = node.type === "directory";
62
+ // Git decoration: per-file and per-folder status
63
+ const gitStatus: GitFileStatus | undefined = useGitStatusStore((s) => {
64
+ const map = isDir ? s.folderStatuses.get(projectName) : s.fileStatuses.get(projectName);
65
+ return map?.get(node.path) as GitFileStatus | undefined;
66
+ });
67
+ const gitColor = gitStatus ? GIT_STATUS_COLORS[gitStatus] : undefined;
68
+ const isExpanded = expandedPaths.has(node.path);
69
+ const isSelected = selectedFiles.includes(node.path);
70
+ const isIgnored = node.ignored === true;
71
+ const isCut = clipboard?.operation === "cut" && clipboard.paths.includes(node.path);
72
+ const isFocused = focusedPath === node.path;
73
+ const isLoadingChildren = isDir && isExpanded && !loadedPaths.has(node.path) && inflight.has(node.path);
74
+ const [isDragOver, setIsDragOver] = useState(false);
75
+ const dragCounter = useRef(0);
76
+ const rowRef = useRef<HTMLButtonElement>(null);
77
+
78
+ useEffect(() => {
79
+ if (isFocused && rowRef.current) {
80
+ rowRef.current.scrollIntoView({ block: "nearest" });
81
+ }
82
+ }, [isFocused]);
83
+
84
+ function handleClick(e: React.MouseEvent) {
85
+ // Ctrl+Click: toggle selection
86
+ if (e.metaKey || e.ctrlKey) {
87
+ setFocusedPath(node.path);
88
+ toggleFileSelect(node.path);
89
+ return;
90
+ }
91
+ // Shift+Click: range selection
92
+ if (e.shiftKey && focusedPath != null) {
93
+ const paths = getVisiblePaths();
94
+ const fromIdx = paths.indexOf(focusedPath);
95
+ const toIdx = paths.indexOf(node.path);
96
+ if (fromIdx >= 0 && toIdx >= 0) {
97
+ const start = Math.min(fromIdx, toIdx);
98
+ const end = Math.max(fromIdx, toIdx);
99
+ useFileStore.getState().setSelectedFiles(paths.slice(start, end + 1));
100
+ }
101
+ return;
102
+ }
103
+ // Normal click
104
+ setFocusedPath(node.path);
105
+ useFileStore.getState().clearSelection();
106
+ if (isDir) {
107
+ toggleExpand(projectName, node.path);
108
+ return;
109
+ }
110
+ const ext = node.name.split(".").pop()?.toLowerCase() ?? "";
111
+ const isSqlite = ext === "db" || ext === "sqlite" || ext === "sqlite3";
112
+ openTab({
113
+ type: isSqlite ? "sqlite" : "editor",
114
+ title: node.name,
115
+ metadata: { filePath: node.path, projectName },
116
+ projectId: projectName,
117
+ closable: true,
118
+ });
119
+ onFileOpen?.();
120
+ }
121
+
122
+ function handleDragStart(e: React.DragEvent) {
123
+ const pathValue = isDir ? `${node.path}/` : node.path;
124
+ e.dataTransfer.setData("application/x-ppm-path", pathValue);
125
+ e.dataTransfer.setData("text/plain", node.name);
126
+ e.dataTransfer.effectAllowed = "copyMove";
127
+ }
128
+
129
+ /** Accept both external file drops and internal tree moves on directories */
130
+ function canAcceptDrop(e: React.DragEvent): boolean {
131
+ if (!isDir) return false;
132
+ return isExternalFileDrag(e) || e.dataTransfer.types.includes("application/x-ppm-path");
133
+ }
134
+
135
+ function handleNodeDragEnter(e: React.DragEvent) {
136
+ if (!canAcceptDrop(e)) return;
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ dragCounter.current++;
140
+ if (dragCounter.current === 1) setIsDragOver(true);
141
+ }
142
+ function handleNodeDragLeave(e: React.DragEvent) {
143
+ if (!isDir) return;
144
+ e.stopPropagation();
145
+ dragCounter.current--;
146
+ if (dragCounter.current === 0) setIsDragOver(false);
147
+ }
148
+ function handleNodeDragOver(e: React.DragEvent) {
149
+ if (!canAcceptDrop(e)) return;
150
+ e.preventDefault();
151
+ e.stopPropagation();
152
+ e.dataTransfer.dropEffect = isExternalFileDrag(e) ? "copy" : "move";
153
+ }
154
+ function handleNodeDrop(e: React.DragEvent) {
155
+ if (!isDir) return;
156
+ e.preventDefault();
157
+ e.stopPropagation();
158
+ dragCounter.current = 0;
159
+ setIsDragOver(false);
160
+
161
+ // External file upload
162
+ if (isExternalFileDrag(e)) {
163
+ if (e.dataTransfer.files.length > 0) onFileDrop(node.path, e.dataTransfer.files);
164
+ return;
165
+ }
166
+
167
+ // Internal tree move
168
+ const sourcePath = e.dataTransfer.getData("application/x-ppm-path").replace(/\/$/, "");
169
+ if (!sourcePath) return;
170
+ // Prevent dropping into self or descendant
171
+ if (sourcePath === node.path || node.path.startsWith(`${sourcePath}/`)) return;
172
+ // Prevent no-op (already in this folder)
173
+ const sourceParent = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/")) : "";
174
+ if (sourceParent === node.path) return;
175
+
176
+ const sourceName = sourcePath.includes("/") ? sourcePath.slice(sourcePath.lastIndexOf("/") + 1) : sourcePath;
177
+ const destination = node.path ? `${node.path}/${sourceName}` : sourceName;
178
+ api.post(`${projectUrl(projectName)}/files/move`, { source: sourcePath, destination })
179
+ .then(() => {
180
+ const store = useFileStore.getState();
181
+ store.invalidateIndex();
182
+ store.loadIndex(projectName);
183
+ store.invalidateFolder(projectName, sourceParent);
184
+ store.invalidateFolder(projectName, node.path);
185
+ })
186
+ .catch((err) => {
187
+ toast.error(err instanceof Error ? err.message : "Move failed");
188
+ });
189
+ }
190
+
191
+ const { icon: FileIcon, color: fileIconColor } = isDir
192
+ ? { icon: isExpanded ? FolderOpen : Folder, color: "text-primary" }
193
+ : getFileIcon(node.name);
194
+
195
+ // Compact folders: collapse single-child dir chains into "a/b/c" display
196
+ let displayName = node.name;
197
+ let effectiveNode = node;
198
+ if (isDir && isExpanded && node.children) {
199
+ let current = node;
200
+ while (
201
+ current.children &&
202
+ current.children.length === 1 &&
203
+ current.children[0]!.type === "directory" &&
204
+ expandedPaths.has(current.children[0]!.path)
205
+ ) {
206
+ current = current.children[0]!;
207
+ displayName += `/${current.name}`;
208
+ }
209
+ if (current !== node) effectiveNode = current;
210
+ }
211
+
212
+ const sortedChildren = effectiveNode.children
213
+ ? [...effectiveNode.children].sort((a, b) => {
214
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
215
+ return a.name.localeCompare(b.name);
216
+ })
217
+ : [];
218
+
219
+ const isRenaming = inlineAction?.type === "rename" && inlineAction.existingNode?.path === node.path;
220
+ const isCreatingHere = isDir && inlineAction != null && (inlineAction.parentPath === node.path || inlineAction.parentPath === effectiveNode.path) && inlineAction.type !== "rename";
221
+
222
+ return (
223
+ <div
224
+ onDragEnter={isDir ? handleNodeDragEnter : undefined}
225
+ onDragLeave={isDir ? handleNodeDragLeave : undefined}
226
+ onDragOver={isDir ? handleNodeDragOver : undefined}
227
+ onDrop={isDir ? handleNodeDrop : undefined}
228
+ >
229
+ {isRenaming ? (
230
+ <InlineTreeInput
231
+ defaultValue={node.name}
232
+ placeholder={node.name}
233
+ depth={depth}
234
+ icon={isDir ? "folder" : "file"}
235
+ onConfirm={async (newName) => {
236
+ if (newName === node.name) { clearInlineAction(); return; }
237
+ const parentPath = node.path.includes("/")
238
+ ? node.path.slice(0, node.path.lastIndexOf("/"))
239
+ : "";
240
+ const newPath = parentPath ? `${parentPath}/${newName}` : newName;
241
+ await api.post(`${projectUrl(projectName)}/files/rename`, {
242
+ oldPath: node.path,
243
+ newPath,
244
+ });
245
+ clearInlineAction();
246
+ const store = useFileStore.getState();
247
+ store.invalidateIndex();
248
+ store.loadIndex(projectName);
249
+ store.invalidateFolder(projectName, parentPath);
250
+ }}
251
+ onCancel={clearInlineAction}
252
+ />
253
+ ) : (
254
+ <ContextMenu>
255
+ <ContextMenuTrigger asChild>
256
+ <button
257
+ ref={rowRef}
258
+ draggable
259
+ onDragStart={handleDragStart}
260
+ onClick={handleClick}
261
+ className={cn(
262
+ "flex items-center w-full gap-1.5 px-2 py-1 rounded-sm text-sm",
263
+ "min-h-[32px] hover:bg-surface-elevated transition-colors text-left",
264
+ "select-none",
265
+ (isIgnored || isCut) && "opacity-40",
266
+ isFocused && "bg-surface-elevated",
267
+ isSelected && "bg-primary/15 ring-1 ring-primary/40",
268
+ isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
269
+ )}
270
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
271
+ >
272
+ {isDir ? (
273
+ isLoadingChildren ? (
274
+ <Loader2 className="size-3.5 shrink-0 text-text-subtle animate-spin" />
275
+ ) : isExpanded ? (
276
+ <ChevronDown className="size-3.5 shrink-0 text-text-subtle" />
277
+ ) : (
278
+ <ChevronRight className="size-3.5 shrink-0 text-text-subtle" />
279
+ )
280
+ ) : (
281
+ <span className="w-3.5 shrink-0" />
282
+ )}
283
+ <FileIcon
284
+ className={cn(
285
+ "size-4 shrink-0",
286
+ fileIconColor ?? "text-text-secondary",
287
+ )}
288
+ />
289
+ <span className={cn("truncate", gitColor)}>{displayName}</span>
290
+ {gitStatus && !isDir && (
291
+ <span className={cn("text-[10px] ml-auto shrink-0 font-mono", gitColor)}>
292
+ {gitStatus}
293
+ </span>
294
+ )}
295
+ </button>
296
+ </ContextMenuTrigger>
297
+ <TreeNodeContextMenu
298
+ node={node}
299
+ isDir={isDir}
300
+ projectName={projectName}
301
+ selectedFiles={selectedFiles}
302
+ compareSelection={compareSelection}
303
+ clipboard={clipboard}
304
+ onAction={onAction}
305
+ />
306
+ </ContextMenu>
307
+ )}
308
+
309
+ {isDir && isExpanded && isCreatingHere && (
310
+ <InlineTreeInput
311
+ defaultValue=""
312
+ placeholder={inlineAction!.type === "new-file" ? "filename.ts" : "folder-name"}
313
+ depth={depth + 1}
314
+ icon={inlineAction!.type === "new-file" ? "file" : "folder"}
315
+ onConfirm={async (name) => {
316
+ const type = inlineAction!.type === "new-file" ? "file" : "directory";
317
+ const targetPath = effectiveNode.path || node.path;
318
+ const fullPath = targetPath ? `${targetPath}/${name}` : name;
319
+ await api.post(`${projectUrl(projectName)}/files/create`, { path: fullPath, type });
320
+ clearInlineAction();
321
+ const store = useFileStore.getState();
322
+ store.invalidateIndex();
323
+ store.loadIndex(projectName);
324
+ store.invalidateFolder(projectName, targetPath);
325
+ }}
326
+ onCancel={clearInlineAction}
327
+ />
328
+ )}
329
+
330
+ {isDir && isExpanded && sortedChildren.map((child) => (
331
+ <TreeNode
332
+ key={child.path}
333
+ node={child}
334
+ depth={depth + 1}
335
+ projectName={projectName}
336
+ onAction={onAction}
337
+ onFileDrop={onFileDrop}
338
+ onFileOpen={onFileOpen}
339
+ />
340
+ ))}
341
+ </div>
342
+ );
343
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Hook for file upload and root-level drag & drop in the file tree.
3
+ */
4
+ import { useCallback, useState, useRef } from "react";
5
+ import { useFileStore } from "@/stores/file-store";
6
+ import { getAuthToken, projectUrl } from "@/lib/api-client";
7
+ import { isExternalFileDrag } from "./tree-node";
8
+ import { toast } from "sonner";
9
+
10
+ interface UseFileUploadDragOptions {
11
+ projectName: string | undefined;
12
+ setExpanded: (path: string, expanded: boolean) => void;
13
+ }
14
+
15
+ export function useFileUploadDrag({ projectName, setExpanded }: UseFileUploadDragOptions) {
16
+ const [isRootDragOver, setIsRootDragOver] = useState(false);
17
+ const rootDragCounter = useRef(0);
18
+
19
+ const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
20
+ if (!projectName) return;
21
+ const count = files.length;
22
+ const label = count === 1 ? files[0]!.name : `${count} files`;
23
+ const toastId = toast.loading(`Uploading ${label}…`);
24
+
25
+ const form = new FormData();
26
+ form.append("targetDir", targetDir);
27
+ for (const file of files) form.append("files", file);
28
+ const headers: HeadersInit = {};
29
+ const token = getAuthToken();
30
+ if (token) headers["Authorization"] = `Bearer ${token}`;
31
+ try {
32
+ const res = await fetch(`${projectUrl(projectName)}/files/upload`, {
33
+ method: "POST",
34
+ headers,
35
+ body: form,
36
+ });
37
+ if (!res.ok) {
38
+ const json = await res.json();
39
+ toast.error(`Upload failed: ${json.error ?? "Unknown error"}`, { id: toastId });
40
+ return;
41
+ }
42
+ toast.success(`Uploaded ${label}`, { id: toastId });
43
+ const store = useFileStore.getState();
44
+ if (store.loadedPaths.has(targetDir)) {
45
+ await store.invalidateFolder(projectName, targetDir);
46
+ }
47
+ if (targetDir) setExpanded(targetDir, true);
48
+ } catch (e) {
49
+ toast.error(e instanceof Error ? e.message : "Upload failed", { id: toastId });
50
+ }
51
+ }, [projectName, setExpanded]);
52
+
53
+ function handleRootDragEnter(e: React.DragEvent) {
54
+ if (!isExternalFileDrag(e)) return;
55
+ e.preventDefault();
56
+ rootDragCounter.current++;
57
+ if (rootDragCounter.current === 1) setIsRootDragOver(true);
58
+ }
59
+ function handleRootDragLeave() {
60
+ rootDragCounter.current--;
61
+ if (rootDragCounter.current === 0) setIsRootDragOver(false);
62
+ }
63
+ function handleRootDragOver(e: React.DragEvent) {
64
+ if (!isExternalFileDrag(e)) return;
65
+ e.preventDefault();
66
+ e.dataTransfer.dropEffect = "copy";
67
+ }
68
+ function handleRootDrop(e: React.DragEvent) {
69
+ if (!isExternalFileDrag(e)) return;
70
+ e.preventDefault();
71
+ rootDragCounter.current = 0;
72
+ setIsRootDragOver(false);
73
+ if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
74
+ }
75
+
76
+ return {
77
+ uploadFiles,
78
+ isRootDragOver,
79
+ handleRootDragEnter,
80
+ handleRootDragLeave,
81
+ handleRootDragOver,
82
+ handleRootDrop,
83
+ };
84
+ }