diff --git a/internal/http/storage.go b/internal/http/storage.go
index 0f63f1c8..643e7a46 100644
--- a/internal/http/storage.go
+++ b/internal/http/storage.go
@@ -572,8 +572,9 @@ func (h *StorageHandler) handleMove(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)})
return
}
- if _, err := os.Stat(destDir); os.IsNotExist(err) {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "destination directory does not exist"})
+ if err := os.MkdirAll(destDir, 0750); err != nil {
+ slog.Error("storage.move_mkdir_failed", "dir", destDir, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
diff --git a/internal/http/workspace_upload.go b/internal/http/workspace_upload.go
index c2b06ea8..c8cb9161 100644
--- a/internal/http/workspace_upload.go
+++ b/internal/http/workspace_upload.go
@@ -15,6 +15,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
@@ -54,11 +55,14 @@ func (h *WorkspaceUploadHandler) handleUpload(w http.ResponseWriter, r *http.Req
return
}
- // Team membership check.
+ // Team membership check (admins bypass — same pattern as RPC handlers).
userID := store.UserIDFromContext(ctx)
- if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
- writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
- return
+ role := permissions.Role(store.RoleFromContext(ctx))
+ if !permissions.HasMinRole(role, permissions.RoleAdmin) {
+ if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
+ return
+ }
}
// Determine workspace mode (shared vs isolated).
@@ -197,11 +201,14 @@ func (h *WorkspaceUploadHandler) handleMove(w http.ResponseWriter, r *http.Reque
return
}
- // Team membership check.
+ // Team membership check (admins bypass — same pattern as RPC handlers).
userID := store.UserIDFromContext(ctx)
- if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
- writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
- return
+ role := permissions.Role(store.RoleFromContext(ctx))
+ if !permissions.HasMinRole(role, permissions.RoleAdmin) {
+ if has, err := h.teamStore.HasTeamAccess(ctx, teamID, userID); err != nil || !has {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": i18n.T(locale, i18n.MsgPermissionDenied, "team workspace")})
+ return
+ }
}
fromName := r.URL.Query().Get("from")
@@ -265,10 +272,11 @@ func (h *WorkspaceUploadHandler) handleMove(w http.ResponseWriter, r *http.Reque
return
}
- // Ensure destination parent exists.
+ // Auto-create destination parent directories (security already validated above).
destDir := filepath.Dir(destPath)
- if _, err := os.Stat(destDir); os.IsNotExist(err) {
- writeJSON(w, http.StatusBadRequest, map[string]string{"error": "destination directory does not exist"})
+ if err := os.MkdirAll(destDir, 0750); err != nil {
+ slog.Error("workspace_move: mkdir failed", "dir", destDir, "error", err)
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create directory")})
return
}
diff --git a/ui/web/src/components/shared/drag-preview.tsx b/ui/web/src/components/shared/drag-preview.tsx
new file mode 100644
index 00000000..0f191216
--- /dev/null
+++ b/ui/web/src/components/shared/drag-preview.tsx
@@ -0,0 +1,31 @@
+import {
+ Folder,
+ FileText,
+ FileCode2,
+ File,
+ FileImage,
+ FileJson2,
+} from "lucide-react";
+import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS } from "@/lib/file-helpers";
+
+const cls = "h-4 w-4 shrink-0";
+
+function PreviewIcon({ name, isDir }: { name: string; isDir: boolean }) {
+ if (isDir) return ;
+ const ext = extOf(name);
+ if (ext === "md" || ext === "mdx") return ;
+ if (ext === "json" || ext === "json5") return ;
+ if (IMAGE_EXTENSIONS.has(ext)) return ;
+ if (CODE_EXTENSIONS.has(ext)) return ;
+ return ;
+}
+
+/** Compact drag overlay shown while dragging a file/folder in the tree. */
+export function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
+ return (
+
+ );
+}
diff --git a/ui/web/src/components/shared/file-tree.tsx b/ui/web/src/components/shared/file-tree.tsx
index 7a989950..2002459c 100644
--- a/ui/web/src/components/shared/file-tree.tsx
+++ b/ui/web/src/components/shared/file-tree.tsx
@@ -1,5 +1,12 @@
-import { useState } from "react";
+import { useState, useEffect, useCallback, useMemo } from "react";
+import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
+import {
+ DndContext,
+ DragOverlay,
+ useDroppable,
+ useDraggable,
+} from "@dnd-kit/core";
import {
Folder,
FolderOpen,
@@ -21,39 +28,76 @@ import {
Trash2,
} from "lucide-react";
import { extOf, CODE_EXTENSIONS, IMAGE_EXTENSIONS, formatSize, type TreeNode } from "@/lib/file-helpers";
+import { useTreeDnd } from "@/hooks/use-tree-dnd";
+import { DragPreview } from "@/components/shared/drag-preview";
const cls = "h-4 w-4 shrink-0";
function FileIcon({ name }: { name: string }) {
const ext = extOf(name);
-
- // Markdown
if (ext === "md" || ext === "mdx") return ;
- // JSON / YAML config
if (ext === "json" || ext === "json5") return ;
if (ext === "yaml" || ext === "yml" || ext === "toml") return ;
- // Spreadsheet / CSV
if (ext === "csv") return ;
- // Shell / terminal
if (ext === "sh" || ext === "bash" || ext === "zsh") return ;
- // Images
if (IMAGE_EXTENSIONS.has(ext)) return ;
- // Video
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return ;
- // Audio
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return ;
- // Archive
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return ;
- // Font
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return ;
- // Env / secrets
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return ;
- // Code (generic)
if (CODE_EXTENSIONS.has(ext)) return ;
- // Default
return ;
}
+/** Draggable wrapper for tree items (files and folders). */
+function DraggableItem({
+ id,
+ enabled,
+ children,
+}: {
+ id: string;
+ enabled: boolean;
+ children: React.ReactNode;
+}) {
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
+ id,
+ disabled: !enabled,
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** Droppable wrapper for folder items. */
+function DroppableFolder({
+ id,
+ enabled,
+ children,
+}: {
+ id: string;
+ enabled: boolean;
+ children: (props: { isDropTarget: boolean }) => React.ReactNode;
+}) {
+ const { setNodeRef, isOver } = useDroppable({
+ id,
+ disabled: !enabled,
+ });
+
+ return (
+
+ {children({ isDropTarget: isOver })}
+
+ );
+}
+
export function TreeItem({
node,
depth,
@@ -61,7 +105,8 @@ export function TreeItem({
onSelect,
onDelete,
onLoadMore,
- onMove,
+ dndEnabled,
+ autoExpandPath,
showSize,
}: {
node: TreeNode;
@@ -70,22 +115,31 @@ export function TreeItem({
onSelect: (path: string) => void;
onDelete?: (path: string, isDir: boolean) => void;
onLoadMore?: (path: string) => void;
- onMove?: (fromPath: string, toFolder: string) => void;
+ dndEnabled: boolean;
+ autoExpandPath: string | null;
showSize?: boolean;
}) {
const { t } = useTranslation("common");
const [expanded, setExpanded] = useState(depth === 0);
- const [dragOver, setDragOver] = useState(false);
const isActive = activePath === node.path;
- const handleToggle = () => {
+ // Auto-expand folder when hovered during drag for 800ms.
+ useEffect(() => {
+ if (autoExpandPath === node.path && node.isDir && !expanded) {
+ setExpanded(true);
+ if (node.hasChildren && node.children.length === 0 && !node.loading) {
+ onLoadMore?.(node.path);
+ }
+ }
+ }, [autoExpandPath, node.path, node.isDir, expanded, node.hasChildren, node.children.length, node.loading, onLoadMore]);
+
+ const handleToggle = useCallback(() => {
const willExpand = !expanded;
setExpanded(willExpand);
- // Lazy load: if expanding a folder with hasChildren but no loaded children
if (willExpand && node.isDir && node.hasChildren && node.children.length === 0 && !node.loading) {
onLoadMore?.(node.path);
}
- };
+ }, [expanded, node.isDir, node.hasChildren, node.children.length, node.loading, node.path, onLoadMore]);
const deleteBtn = onDelete && !node.protected && (