mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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
This commit is contained in:
1 parent
31c41dac54
commit
4d31fe0dda
12 files changed
+366
-104
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <Folder className={`${cls} text-yellow-600`} />;
|
||||
const ext = extOf(name);
|
||||
if (ext === "md" || ext === "mdx") return <FileText className={`${cls} text-blue-500`} />;
|
||||
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
/** Compact drag overlay shown while dragging a file/folder in the tree. */
|
||||
export function DragPreview({ name, isDir }: { name: string; isDir: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-md border bg-popover px-3 py-1.5 text-sm shadow-lg opacity-90 pointer-events-none max-w-[240px]">
|
||||
<PreviewIcon name={name} isDir={isDir} />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <FileText className={`${cls} text-blue-500`} />;
|
||||
// JSON / YAML config
|
||||
if (ext === "json" || ext === "json5") return <FileJson2 className={`${cls} text-yellow-600`} />;
|
||||
if (ext === "yaml" || ext === "yml" || ext === "toml") return <FileCog className={`${cls} text-orange-500`} />;
|
||||
// Spreadsheet / CSV
|
||||
if (ext === "csv") return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
||||
// Shell / terminal
|
||||
if (ext === "sh" || ext === "bash" || ext === "zsh") return <FileTerminal className={`${cls} text-lime-600`} />;
|
||||
// Images
|
||||
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={`${cls} text-emerald-500`} />;
|
||||
// Video
|
||||
if (ext === "mp4" || ext === "webm" || ext === "mov" || ext === "avi" || ext === "mkv") return <FileVideo className={`${cls} text-pink-500`} />;
|
||||
// Audio
|
||||
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac" || ext === "m4a") return <FileAudio className={`${cls} text-orange-500`} />;
|
||||
// Archive
|
||||
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z" || ext === "bz2") return <FileArchive className={`${cls} text-amber-600`} />;
|
||||
// Font
|
||||
if (ext === "ttf" || ext === "otf" || ext === "woff" || ext === "woff2") return <FileType className={`${cls} text-slate-500`} />;
|
||||
// Env / secrets
|
||||
if (ext === "env" || ext === "pem" || ext === "key" || ext === "crt") return <FileLock className={`${cls} text-red-500`} />;
|
||||
// Code (generic)
|
||||
if (CODE_EXTENSIONS.has(ext)) return <FileCode2 className={`${cls} text-orange-500`} />;
|
||||
// Default
|
||||
return <File className={`${cls} text-muted-foreground`} />;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...(enabled ? { ...listeners, ...attributes } : {})}
|
||||
className={isDragging ? "opacity-40" : ""}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div ref={setNodeRef}>
|
||||
{children({ isDropTarget: isOver })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<button
|
||||
@@ -105,30 +159,14 @@ export function TreeItem({
|
||||
);
|
||||
|
||||
if (node.isDir) {
|
||||
return (
|
||||
<div>
|
||||
const folderContent = (isDropTargetActive: boolean) => (
|
||||
<>
|
||||
<div
|
||||
className={`group/tree-item flex w-full items-center gap-1 rounded px-2 py-1 text-left text-sm cursor-pointer ${
|
||||
dragOver ? "bg-primary/10 ring-1 ring-primary" : "hover:bg-accent"
|
||||
isDropTargetActive ? "bg-primary/10 ring-1 ring-primary" : "hover:bg-accent"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
draggable={!!onMove}
|
||||
onDragStart={onMove ? (e) => {
|
||||
e.dataTransfer.setData("text/x-tree-path", node.path);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
} : undefined}
|
||||
onClick={handleToggle}
|
||||
onDragOver={onMove ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(true); } : undefined}
|
||||
onDragEnter={onMove ? (e) => { e.preventDefault(); setDragOver(true); } : undefined}
|
||||
onDragLeave={onMove ? () => setDragOver(false) : undefined}
|
||||
onDrop={onMove ? (e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const src = e.dataTransfer.getData("text/x-tree-path");
|
||||
if (src && src !== node.path && !node.path.startsWith(src + "/")) {
|
||||
onMove(src, node.path);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 transition-transform ${expanded ? "rotate-90" : ""}`}
|
||||
@@ -152,11 +190,12 @@ export function TreeItem({
|
||||
onSelect={onSelect}
|
||||
onDelete={onDelete}
|
||||
onLoadMore={onLoadMore}
|
||||
onMove={onMove}
|
||||
dndEnabled={dndEnabled}
|
||||
|
||||
autoExpandPath={autoExpandPath}
|
||||
showSize={showSize}
|
||||
/>
|
||||
))}
|
||||
{/* Show chevron hint for loadable but not-yet-expanded empty folders */}
|
||||
{expanded && node.hasChildren && node.children.length === 0 && !node.loading && (
|
||||
<div
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer hover:text-foreground"
|
||||
@@ -167,21 +206,29 @@ export function TreeItem({
|
||||
<span>{t("loadMore")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (dndEnabled) {
|
||||
return (
|
||||
<DraggableItem id={node.path} enabled>
|
||||
<DroppableFolder id={node.path} enabled>
|
||||
{({ isDropTarget: active }) => folderContent(active)}
|
||||
</DroppableFolder>
|
||||
</DraggableItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>{folderContent(false)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
// File node
|
||||
const fileContent = (
|
||||
<div
|
||||
className={`group/tree-item flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm cursor-pointer ${
|
||||
isActive ? "bg-accent text-accent-foreground" : "hover:bg-accent/50"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 16 + 20}px` }}
|
||||
draggable={!!onMove}
|
||||
onDragStart={onMove ? (e) => {
|
||||
e.dataTransfer.setData("text/x-tree-path", node.path);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
} : undefined}
|
||||
onClick={() => onSelect(node.path)}
|
||||
>
|
||||
<FileIcon name={node.name} />
|
||||
@@ -190,6 +237,28 @@ export function TreeItem({
|
||||
{deleteBtn}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (dndEnabled) {
|
||||
return (
|
||||
<DraggableItem id={node.path} enabled>
|
||||
{fileContent}
|
||||
</DraggableItem>
|
||||
);
|
||||
}
|
||||
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
/** Find a node by path in the tree. */
|
||||
function findNode(tree: TreeNode[], path: string): TreeNode | undefined {
|
||||
for (const node of tree) {
|
||||
if (node.path === path) return node;
|
||||
if (node.children.length > 0) {
|
||||
const found = findNode(node.children, path);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function FileTreePanel({
|
||||
@@ -212,7 +281,14 @@ export function FileTreePanel({
|
||||
showSize?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [rootDragOver, setRootDragOver] = useState(false);
|
||||
const { sensors, activeId, autoExpandPath, handlers } = useTreeDnd(onMove);
|
||||
const dndEnabled = !!onMove;
|
||||
|
||||
// Find the active node for DragOverlay preview.
|
||||
const activeNode = useMemo(
|
||||
() => (activeId ? findNode(tree, activeId) : undefined),
|
||||
[activeId, tree],
|
||||
);
|
||||
|
||||
if (filesLoading) {
|
||||
return (
|
||||
@@ -224,30 +300,59 @@ export function FileTreePanel({
|
||||
if (tree.length === 0) {
|
||||
return <p className="px-3 py-4 text-sm text-muted-foreground">{t("noFiles")}</p>;
|
||||
}
|
||||
|
||||
const treeContent = (
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* Root-level drop target for moving to root */}
|
||||
{dndEnabled ? (
|
||||
<RootDropZone>
|
||||
{tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled={dndEnabled} autoExpandPath={autoExpandPath}
|
||||
showSize={showSize}
|
||||
/>
|
||||
))}
|
||||
</RootDropZone>
|
||||
) : (
|
||||
tree.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path} node={node} depth={0} activePath={activePath}
|
||||
onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore}
|
||||
dndEnabled={false} autoExpandPath={null}
|
||||
showSize={showSize}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!dndEnabled) return treeContent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex-1 min-h-0 ${rootDragOver ? "bg-primary/5" : ""}`}
|
||||
onDragOver={onMove ? (e) => {
|
||||
// Only highlight root when dragging over empty space (not over a child node).
|
||||
if (e.currentTarget === e.target) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setRootDragOver(true);
|
||||
}
|
||||
} : undefined}
|
||||
onDragLeave={onMove ? (e) => {
|
||||
if (e.currentTarget === e.target) setRootDragOver(false);
|
||||
} : undefined}
|
||||
onDrop={onMove ? (e) => {
|
||||
e.preventDefault();
|
||||
setRootDragOver(false);
|
||||
const src = e.dataTransfer.getData("text/x-tree-path");
|
||||
if (src) onMove(src, ""); // "" = root
|
||||
} : undefined}
|
||||
>
|
||||
{tree.map((node) => (
|
||||
<TreeItem key={node.path} node={node} depth={0} activePath={activePath} onSelect={onSelect} onDelete={onDelete} onLoadMore={onLoadMore} onMove={onMove} showSize={showSize} />
|
||||
))}
|
||||
<DndContext sensors={sensors} {...handlers}>
|
||||
{treeContent}
|
||||
{/* Portal to document.body so DragOverlay isn't offset by Radix Dialog's CSS transform. */}
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeNode ? (
|
||||
<DragPreview name={activeNode.name} isDir={activeNode.isDir} />
|
||||
) : null}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
/** Root-level droppable zone — dropping here moves to root (""). */
|
||||
function RootDropZone({ children }: { children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: "__root__" });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={`min-h-full ${isOver ? "bg-primary/5" : ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
/** Encapsulates @dnd-kit sensor setup and drag state for file tree DnD. */
|
||||
export function useTreeDnd(onMove?: (fromPath: string, toFolder: string) => void) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
|
||||
// Track hover timer for auto-expand.
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoExpandPath, setAutoExpandPath] = useState<string | null>(null);
|
||||
|
||||
const clearHoverTimer = useCallback(() => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const overId = event.over?.id ? String(event.over.id) : null;
|
||||
setOverId(overId);
|
||||
|
||||
// Auto-expand: start timer when hovering a folder.
|
||||
clearHoverTimer();
|
||||
if (overId) {
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setAutoExpandPath(overId);
|
||||
}, 800);
|
||||
}
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
clearHoverTimer();
|
||||
setAutoExpandPath(null);
|
||||
const fromPath = String(event.active.id);
|
||||
const toFolder = event.over?.id ? String(event.over.id) : null;
|
||||
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
|
||||
if (!onMove || toFolder === null) return;
|
||||
|
||||
// Map root drop zone ID to empty string.
|
||||
const dest = toFolder === "__root__" ? "" : toFolder;
|
||||
|
||||
// Prevent dropping onto self or own descendant.
|
||||
if (fromPath === dest || dest.startsWith(fromPath + "/")) return;
|
||||
|
||||
onMove(fromPath, dest);
|
||||
}, [onMove, clearHoverTimer]);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
clearHoverTimer();
|
||||
setAutoExpandPath(null);
|
||||
setActiveId(null);
|
||||
setOverId(null);
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
return {
|
||||
sensors,
|
||||
activeId,
|
||||
overId,
|
||||
autoExpandPath,
|
||||
handlers: {
|
||||
onDragStart: handleDragStart,
|
||||
onDragOver: handleDragOver,
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragCancel: handleDragCancel,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -162,7 +162,9 @@
|
||||
"upload": {
|
||||
"title": "Upload to Workspace",
|
||||
"description": "Upload files for agents to read during collaboration"
|
||||
}
|
||||
},
|
||||
"crossScopeMoveError": "Cannot move files between different scopes",
|
||||
"moveFailed": "Failed to move file"
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Audit Logs",
|
||||
|
||||
@@ -162,7 +162,9 @@
|
||||
"upload": {
|
||||
"title": "Tải lên Workspace",
|
||||
"description": "Tải tệp lên để agent đọc trong quá trình cộng tác"
|
||||
}
|
||||
},
|
||||
"crossScopeMoveError": "Không thể di chuyển tệp giữa các phạm vi khác nhau",
|
||||
"moveFailed": "Di chuyển tệp thất bại"
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "Nhật ký hoạt động",
|
||||
|
||||
@@ -162,7 +162,9 @@
|
||||
"upload": {
|
||||
"title": "上传到工作区",
|
||||
"description": "上传文件供 Agent 在协作中读取"
|
||||
}
|
||||
},
|
||||
"crossScopeMoveError": "无法在不同范围之间移动文件",
|
||||
"moveFailed": "移动文件失败"
|
||||
},
|
||||
"auditLogs": {
|
||||
"title": "审计日志",
|
||||
|
||||
@@ -30,14 +30,14 @@ export function useStorage() {
|
||||
const [baseDir, setBaseDir] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const listFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const listFiles = useCallback(async (opts?: { silent?: boolean }) => {
|
||||
if (!opts?.silent) setLoading(true);
|
||||
try {
|
||||
const res = await http.get<StorageListResponse>("/v1/storage/files");
|
||||
setFiles(res.files ?? []);
|
||||
setBaseDir(res.baseDir ?? "");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!opts?.silent) setLoading(false);
|
||||
}
|
||||
}, [http]);
|
||||
|
||||
|
||||
@@ -155,12 +155,17 @@ export function StoragePage() {
|
||||
if (fromPath === newPath) return; // no-op: same location
|
||||
try {
|
||||
await http.put(`/v1/storage/move?from=${encodeURIComponent(fromPath)}&to=${encodeURIComponent(newPath)}`);
|
||||
handleRefresh();
|
||||
// Clear stale selection if the moved item was active.
|
||||
if (activePath === fromPath || activePath?.startsWith(fromPath + "/")) {
|
||||
setActivePath(null);
|
||||
setFileContent(null);
|
||||
}
|
||||
listFiles({ silent: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Move failed";
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [http, handleRefresh]);
|
||||
}, [http, listFiles, activePath]);
|
||||
|
||||
const deleteName = deleteTarget?.path.split("/").pop() ?? "";
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { FileUploadDialog } from "@/components/shared/file-upload-dialog";
|
||||
import { buildTree, isTextFile } from "@/lib/file-helpers";
|
||||
import { useTeamWorkspace } from "../hooks/use-team-workspace";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
import type { ScopeEntry } from "@/types/team";
|
||||
|
||||
/** Strip chatID prefix from name for WS file ops (backend already scopes by chat_id). */
|
||||
@@ -43,8 +44,9 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
const [cachedScopes, setCachedScopes] = useState<ScopeEntry[]>([]);
|
||||
|
||||
// Load files for the selected scope. On "all" load, also cache scope list.
|
||||
const load = useCallback(() => {
|
||||
listFiles(teamId, scopeValue || undefined).then((result) => {
|
||||
// silent=true skips loading spinner (used after move to avoid tree flash).
|
||||
const load = useCallback((opts?: { silent?: boolean }) => {
|
||||
listFiles(teamId, scopeValue || undefined, opts).then((result) => {
|
||||
// Only update cached scopes from the "all" listing (no filter applied).
|
||||
if (!scopeValue && result.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
@@ -59,8 +61,10 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
setCachedScopes(derived);
|
||||
}
|
||||
});
|
||||
setFileContent(null);
|
||||
setActivePath(null);
|
||||
if (!opts?.silent) {
|
||||
setFileContent(null);
|
||||
setActivePath(null);
|
||||
}
|
||||
}, [teamId, listFiles, scopeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,6 +172,16 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
// In workspace, file names may have chatID prefix — strip it for the API call.
|
||||
const match = files.find((f) => f.name === fromPath);
|
||||
if (!match) return;
|
||||
|
||||
// Prevent cross-scope moves (e.g. dragging from chat123/ to chat456/).
|
||||
if (match.chat_id && toFolder) {
|
||||
const toMatch = files.find((f) => f.name === toFolder && f.is_dir);
|
||||
if (toMatch && toMatch.chat_id && toMatch.chat_id !== match.chat_id) {
|
||||
toast.error(t("workspace.crossScopeMoveError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fromName = wsFileName(match.name, match.chat_id);
|
||||
const fileName = fromName.split("/").pop() ?? fromName;
|
||||
// toFolder may also have chatID prefix — strip it.
|
||||
@@ -184,9 +198,12 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
|
||||
try {
|
||||
await http.put(`/v1/teams/${teamId}/workspace/move?${params.toString()}`);
|
||||
load();
|
||||
} catch { /* silent — server error shown via toast if needed */ }
|
||||
}, [http, teamId, files, load]);
|
||||
load({ silent: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : t("workspace.moveFailed");
|
||||
toast.error(msg);
|
||||
}
|
||||
}, [http, teamId, files, load, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -213,7 +230,7 @@ export function TeamWorkspaceDialog({ open, onOpenChange, teamId, scopes }: Team
|
||||
<Button variant="outline" size="sm" onClick={() => setUploadOpen(true)} className="gap-1">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (loading ? " animate-spin" : "")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user