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:
viettranx committed 2026-03-25 22:17:33 +07:00
1 parent 31c41dac54
commit 4d31fe0dda
12 files changed
+366 -104

No files matched your search

+3 -2
View File
@@ -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
}
+19 -11
View File
@@ -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>
);
}
+177 -72
View File
@@ -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>
);
}
+89
View File
@@ -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,
},
};
}
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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]);
+7 -2
View File
@@ -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],