From 4d31fe0dda1a6e3720522e6fe38d6717d76f89a4 Mon Sep 17 00:00:00 2001 From: viettranx Date: Wed, 25 Mar 2026 22:17:33 +0700 Subject: [PATCH] fix(workspace): fix file move bugs, migrate DnD to @dnd-kit - Fix cross-scope move in team workspace "All" view (prevented invalid nested paths by detecting mismatched chat_id scopes) - Add error toast on move failure instead of silent catch - Auto-create destination subdirectories on move (os.MkdirAll) - Add admin bypass for team workspace HTTP auth (matching RPC pattern) - Clear stale activePath after move in storage page - Migrate file tree DnD from native HTML5 to @dnd-kit with: - PointerSensor (distance=8 prevents accidental drags) - DragOverlay via portal (fixes offset in Radix Dialog) - Folder auto-expand on 800ms hover during drag - Silent refresh after move to prevent tree flash/scroll loss --- internal/http/storage.go | 5 +- internal/http/workspace_upload.go | 30 ++- ui/web/src/components/shared/drag-preview.tsx | 31 +++ ui/web/src/components/shared/file-tree.tsx | 249 +++++++++++++----- ui/web/src/hooks/use-tree-dnd.ts | 89 +++++++ ui/web/src/i18n/locales/en/teams.json | 4 +- ui/web/src/i18n/locales/vi/teams.json | 4 +- ui/web/src/i18n/locales/zh/teams.json | 4 +- ui/web/src/pages/storage/hooks/use-storage.ts | 6 +- ui/web/src/pages/storage/storage-page.tsx | 9 +- .../teams/board/team-workspace-dialog.tsx | 33 ++- .../pages/teams/hooks/use-team-workspace.ts | 6 +- 12 files changed, 366 insertions(+), 104 deletions(-) create mode 100644 ui/web/src/components/shared/drag-preview.tsx create mode 100644 ui/web/src/hooks/use-tree-dnd.ts 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 ( +
+ + {name} +
+ ); +} 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 && ( - diff --git a/ui/web/src/pages/teams/hooks/use-team-workspace.ts b/ui/web/src/pages/teams/hooks/use-team-workspace.ts index c19d1845..395e27af 100644 --- a/ui/web/src/pages/teams/hooks/use-team-workspace.ts +++ b/ui/web/src/pages/teams/hooks/use-team-workspace.ts @@ -12,8 +12,8 @@ export function useTeamWorkspace() { const [loading, setLoading] = useState(false); const listFiles = useCallback( - async (teamId: string, chatId?: string) => { - setLoading(true); + async (teamId: string, chatId?: string, opts?: { silent?: boolean }) => { + if (!opts?.silent) setLoading(true); try { const res = await ws.call<{ files: TeamWorkspaceFile[]; count: number }>( Methods.TEAMS_WORKSPACE_LIST, @@ -24,7 +24,7 @@ export function useTeamWorkspace() { } catch { return []; } finally { - setLoading(false); + if (!opts?.silent) setLoading(false); } }, [ws],