@hienlh/ppm 0.11.2 → 0.11.3

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.
@@ -1,4 +1,4 @@
1
- import { useEffect, useCallback, useState, memo } from "react";
1
+ import { useEffect, useCallback, useState, useRef, memo } from "react";
2
2
  import {
3
3
  Folder,
4
4
  FolderOpen,
@@ -30,6 +30,12 @@ import {
30
30
  } from "@/components/ui/context-menu";
31
31
  import { FileActions } from "./file-actions";
32
32
  import { downloadFile, downloadFolder } from "@/lib/file-download";
33
+ import { getAuthToken, projectUrl } from "@/lib/api-client";
34
+
35
+ /** Check if drag event is from OS files (not internal PPM drag) */
36
+ function isExternalFileDrag(e: React.DragEvent): boolean {
37
+ return e.dataTransfer.types.includes("Files") && !e.dataTransfer.types.includes("application/x-ppm-path");
38
+ }
33
39
 
34
40
  /** Synthetic root node for creating files/folders at project root */
35
41
  const ROOT_NODE: FileNode = { name: "", path: "", type: "directory" };
@@ -62,16 +68,19 @@ interface TreeNodeProps {
62
68
  depth: number;
63
69
  projectName: string;
64
70
  onAction: (action: string, node: FileNode) => void;
71
+ onFileDrop: (targetDir: string, files: FileList) => void;
65
72
  onFileOpen?: () => void;
66
73
  }
67
74
 
68
- const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileOpen }: TreeNodeProps) {
75
+ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, onFileDrop, onFileOpen }: TreeNodeProps) {
69
76
  const { expandedPaths, toggleExpand, selectedFiles, toggleFileSelect } = useFileStore(useShallow((s) => ({ expandedPaths: s.expandedPaths, toggleExpand: s.toggleExpand, selectedFiles: s.selectedFiles, toggleFileSelect: s.toggleFileSelect })));
70
77
  const openTab = useTabStore((s) => s.openTab);
71
78
  const isExpanded = expandedPaths.has(node.path);
72
79
  const isDir = node.type === "directory";
73
80
  const isSelected = selectedFiles.includes(node.path);
74
81
  const isIgnored = node.ignored === true;
82
+ const [isDragOver, setIsDragOver] = useState(false);
83
+ const dragCounter = useRef(0);
75
84
 
76
85
  function handleClick(e: React.MouseEvent) {
77
86
  if (isDir) {
@@ -102,6 +111,36 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
102
111
  e.dataTransfer.effectAllowed = "copy";
103
112
  }
104
113
 
114
+ function handleNodeDragEnter(e: React.DragEvent) {
115
+ if (!isDir || !isExternalFileDrag(e)) return;
116
+ e.preventDefault();
117
+ e.stopPropagation();
118
+ dragCounter.current++;
119
+ if (dragCounter.current === 1) setIsDragOver(true);
120
+ }
121
+ function handleNodeDragLeave(e: React.DragEvent) {
122
+ if (!isDir) return;
123
+ e.stopPropagation();
124
+ dragCounter.current--;
125
+ if (dragCounter.current === 0) setIsDragOver(false);
126
+ }
127
+ function handleNodeDragOver(e: React.DragEvent) {
128
+ if (!isDir || !isExternalFileDrag(e)) return;
129
+ e.preventDefault();
130
+ e.stopPropagation();
131
+ e.dataTransfer.dropEffect = "copy";
132
+ }
133
+ function handleNodeDrop(e: React.DragEvent) {
134
+ if (!isDir || !isExternalFileDrag(e)) return;
135
+ e.preventDefault();
136
+ e.stopPropagation();
137
+ dragCounter.current = 0;
138
+ setIsDragOver(false);
139
+ if (e.dataTransfer.files.length > 0) {
140
+ onFileDrop(node.path, e.dataTransfer.files);
141
+ }
142
+ }
143
+
105
144
  const Icon = isDir
106
145
  ? isExpanded
107
146
  ? FolderOpen
@@ -116,7 +155,12 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
116
155
  : [];
117
156
 
118
157
  return (
119
- <div>
158
+ <div
159
+ onDragEnter={isDir ? handleNodeDragEnter : undefined}
160
+ onDragLeave={isDir ? handleNodeDragLeave : undefined}
161
+ onDragOver={isDir ? handleNodeDragOver : undefined}
162
+ onDrop={isDir ? handleNodeDrop : undefined}
163
+ >
120
164
  <ContextMenu>
121
165
  <ContextMenuTrigger asChild>
122
166
  <button
@@ -129,6 +173,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
129
173
  "select-none",
130
174
  isIgnored && "opacity-40",
131
175
  isSelected && "bg-primary/15 ring-1 ring-primary/40",
176
+ isDragOver && "ring-1 ring-dashed ring-primary bg-primary/10",
132
177
  )}
133
178
  style={{ paddingLeft: `${depth * 16 + 8}px` }}
134
179
  >
@@ -198,6 +243,7 @@ const TreeNode = memo(function TreeNode({ node, depth, projectName, onAction, on
198
243
  depth={depth + 1}
199
244
  projectName={projectName}
200
245
  onAction={onAction}
246
+ onFileDrop={onFileDrop}
201
247
  onFileOpen={onFileOpen}
202
248
  />
203
249
  ))}
@@ -239,6 +285,56 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
239
285
  return () => window.removeEventListener("focus", handleFocus);
240
286
  }, [activeProject, fetchTree]);
241
287
 
288
+ const uploadFiles = useCallback(async (targetDir: string, files: FileList) => {
289
+ if (!activeProject) return;
290
+ const form = new FormData();
291
+ form.append("targetDir", targetDir);
292
+ for (const file of files) form.append("files", file);
293
+ const headers: HeadersInit = {};
294
+ const token = getAuthToken();
295
+ if (token) headers["Authorization"] = `Bearer ${token}`;
296
+ try {
297
+ const res = await fetch(`${projectUrl(activeProject.name)}/files/upload`, {
298
+ method: "POST",
299
+ headers,
300
+ body: form,
301
+ });
302
+ if (!res.ok) {
303
+ const json = await res.json();
304
+ console.error("Upload failed:", json.error);
305
+ }
306
+ loadTree();
307
+ } catch (e) {
308
+ console.error("Upload error:", e);
309
+ }
310
+ }, [activeProject, loadTree]);
311
+
312
+ const [isRootDragOver, setIsRootDragOver] = useState(false);
313
+ const rootDragCounter = useRef(0);
314
+
315
+ function handleRootDragEnter(e: React.DragEvent) {
316
+ if (!isExternalFileDrag(e)) return;
317
+ e.preventDefault();
318
+ rootDragCounter.current++;
319
+ if (rootDragCounter.current === 1) setIsRootDragOver(true);
320
+ }
321
+ function handleRootDragLeave() {
322
+ rootDragCounter.current--;
323
+ if (rootDragCounter.current === 0) setIsRootDragOver(false);
324
+ }
325
+ function handleRootDragOver(e: React.DragEvent) {
326
+ if (!isExternalFileDrag(e)) return;
327
+ e.preventDefault();
328
+ e.dataTransfer.dropEffect = "copy";
329
+ }
330
+ function handleRootDrop(e: React.DragEvent) {
331
+ if (!isExternalFileDrag(e)) return;
332
+ e.preventDefault();
333
+ rootDragCounter.current = 0;
334
+ setIsRootDragOver(false);
335
+ if (e.dataTransfer.files.length > 0) uploadFiles("", e.dataTransfer.files);
336
+ }
337
+
242
338
  function handleAction(action: string, node: FileNode) {
243
339
  if (action === "copy-path") {
244
340
  navigator.clipboard.writeText(node.path).catch(() => {});
@@ -310,7 +406,13 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
310
406
  const toolbarBtnClass = "p-1 rounded-sm text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors";
311
407
 
312
408
  return (
313
- <div className="flex flex-col h-full">
409
+ <div
410
+ className={cn("flex flex-col h-full", isRootDragOver && "bg-primary/5")}
411
+ onDragEnter={handleRootDragEnter}
412
+ onDragLeave={handleRootDragLeave}
413
+ onDragOver={handleRootDragOver}
414
+ onDrop={handleRootDrop}
415
+ >
314
416
  {/* Toolbar */}
315
417
  <div className="flex items-center gap-0.5 px-2 h-8 border-b border-border shrink-0">
316
418
  <button onClick={() => handleAction("new-file", ROOT_NODE)} title="New File" className={toolbarBtnClass}>
@@ -337,6 +439,7 @@ export function FileTree({ onFileOpen }: FileTreeProps = {}) {
337
439
  depth={0}
338
440
  projectName={activeProject.name}
339
441
  onAction={handleAction}
442
+ onFileDrop={uploadFiles}
340
443
  onFileOpen={onFileOpen}
341
444
  />
342
445
  ))}