diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index 432e2b259..27ee3c527 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -400,4 +400,24 @@ describe('FileBrowser', () => { fireEvent.click(within(tree()).getByRole('button', { name: /trash/ })); expect(onNavigate).toHaveBeenCalledWith('/.Trash', '*trash'); }); + + it("moves an item to a folder picked in Move to…, without dragging", () => { + const onMoveTo = vi.fn(async () => {}); + renderBrowser({ props: { onMoveTo } }); + fireEvent.contextMenu(within(list()).getByText('notes.txt')); + fireEvent.click(screen.getByText('move_to')); + const dialog = screen.getByRole('dialog', { name: 'move_to_title' }); + expect(within(dialog).getByRole('button', { name: 'move_here' })).toBeDisabled(); + fireEvent.click(within(dialog).getByRole('treeitem', { name: /Documents/ })); + fireEvent.click(within(dialog).getByRole('button', { name: 'move_here' })); + expect(onMoveTo).toHaveBeenCalledWith(['notes.txt'], 'f-docs'); + }); + + it("leaves a folder out of its own Move to… picker", () => { + renderBrowser(); + fireEvent.contextMenu(within(list()).getByText('Documents')); + fireEvent.click(screen.getByText('move_to')); + const dialog = screen.getByRole('dialog', { name: 'move_to_title' }); + expect(within(dialog).queryByRole('treeitem', { name: /Documents/ })).toBeNull(); + }); }); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 489b988c1..66e326d6f 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen, - RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, + RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput, } from "@/components/icons"; import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -14,6 +14,7 @@ import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components import { Avatar } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; import { PromptDialog } from "@/components/ui/prompt-dialog"; +import { FolderPickerDialog } from "@/components/files/folder-picker-dialog"; import { fileNameProblem } from "@/lib/file-name-rules"; import { EmptyFolder, NoSearchResults } from "@/components/files/file-empty-state"; import { EmptyState } from "@/components/ui/empty-state"; @@ -81,6 +82,8 @@ interface FileBrowserProps { onPaste: () => Promise; /** Move listed items into a folder by id (null = the root). */ onMoveTo: (names: string[], targetId: string | null) => Promise; + /** Move a folder of the tree, which need not be listed, into a folder. */ + onMoveNode?: (id: string, targetId: string | null) => Promise; onPreviewImage: (name: string) => void; onPreviewFile: (name: string) => void; /** WOPI document editing (#425): whether a file can open in the document editor. */ @@ -169,6 +172,7 @@ export function FileBrowser({ onCopy, onPaste, onMoveTo, + onMoveNode, onPreviewImage, onPreviewFile, isOfficeEditable, @@ -295,6 +299,8 @@ export function FileBrowser({ const [renameTarget, setRenameTarget] = useState(null); const [renameFolder, setRenameFolder] = useState(null); const [shareTargetId, setShareTargetId] = useState(null); + // "Move to…": listed items by name, or a folder of the tree by id. + const [moveTarget, setMoveTarget] = useState<{ names: string[] } | { folder: FileResource } | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [focusedName, setFocusedName] = useState(null); // The cursor outline follows a click or the arrow keys, but goes away once @@ -704,7 +710,7 @@ export function FileBrowser({ useEffect(() => { const handler = (e: KeyboardEvent) => { if (isTypingTarget(e.target)) return; - if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId) return; + if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId || moveTarget) return; // A preview, editor or confirmation is open on top of the list. if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return; if (accountPickerMode) return; @@ -789,7 +795,7 @@ export function FileBrowser({ window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ - showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, accountPickerMode, selectedList, singleSelected, inTrash, + showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash, selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource, focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus, ]); @@ -817,6 +823,9 @@ export function FileBrowser({ ? selectedList : menuData?.kind === "item" ? [menuData.resource] : []; + // Items move within the own drive; a shared drive's folders are not in the picker. + const canMove = !inTrash && !accountPickerMode && !currentParentId?.includes(":"); + const renderItemMenu = (resource: FileResource) => { if (inTrash) { const names = menuSelection.map((r) => r.name); @@ -845,6 +854,9 @@ export function FileBrowser({ {fileCount > 0 && ( { closeMenu(); downloadNames(names); }} /> )} + {canMove && ( + { closeMenu(); setMoveTarget({ names }); }} /> + )} { closeMenu(); onCut(names); }} /> {menuSelection.some((r) => !r.isSymlink) && ( { closeMenu(); onCopy(names); }} /> @@ -888,6 +900,9 @@ export function FileBrowser({ {!resource.isDirectory && !resource.isSymlink && ( { closeMenu(); void onDuplicate(resource.name); }} /> )} + {canMove && ( + { closeMenu(); setMoveTarget({ names: [resource.name] }); }} /> + )} { closeMenu(); onCut([resource.name]); }} /> {!resource.isSymlink && ( { closeMenu(); onCopy([resource.name]); }} /> @@ -964,6 +979,7 @@ export function FileBrowser({ onShare={fromDrawer((r: FileResource) => setShareTargetId(r.id))} onCreateIn={fromDrawer(createIn)} onRenameFolder={fromDrawer(setRenameFolder)} + onMoveFolder={onMoveNode ? fromDrawer((folder: FileResource) => setMoveTarget({ folder })) : undefined} onDeleteFolder={fromDrawer((folder: FileResource) => void onDeleteNode(folder.id, folder.name))} onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))} onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))} @@ -1119,6 +1135,7 @@ export function FileBrowser({ onDownload: () => downloadNames(selectedList.map((r) => r.name)), onShare: () => singleSelected && setShareTargetId(singleSelected.id), onRename: () => singleSelected && setRenameTarget(singleSelected.name), + onMoveTo: canMove ? () => setMoveTarget({ names: selectedList.map((r) => r.name) }) : undefined, onCut: () => onCut(selectedList.map((r) => r.name)), onCopy: () => onCopy(selectedList.map((r) => r.name)), onDelete: () => deleteNames(selectedList.map((r) => r.name)), @@ -1351,6 +1368,23 @@ export function FileBrowser({ }} /> + setMoveTarget(null)} + title={t("move_to_title")} + confirmText={t("move_here")} + folders={allFolders.filter((f) => !f.isShared && !f.id.includes(":"))} + excludeIds={new Set(moveTarget && "folder" in moveTarget + ? [moveTarget.folder.id] + : (moveTarget?.names ?? []).map((n) => resources.find((r) => r.name === n)).filter((r) => r?.isDirectory).map((r) => r!.id))} + currentId={moveTarget && "folder" in moveTarget ? moveTarget.folder.parentId ?? null : currentParentId} + onPick={(targetId) => { + if (!moveTarget) return; + if ("folder" in moveTarget) void onMoveNode?.(moveTarget.folder.id, targetId); + else void onMoveTo(moveTarget.names, targetId); + }} + /> + {shareTarget && client && onShare && ( { + try { + if (!await moveNode(id, targetId)) return; + toast.success(t("move_success", { count: 1 }), { + action: { label: t("undo"), onClick: handleUndo }, + }); + } catch (err) { + console.error("Failed to move:", err); + toast.error(t("move_error"), failureReason(err)); + } + }, [moveNode, t, handleUndo, failureReason]); + const handlePaste = useCallback(async () => { try { if (!await pasteResources()) return; @@ -799,6 +812,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { onCopy={copyResources} onPaste={handlePaste} onMoveTo={handleMoveTo} + onMoveNode={handleMoveNode} onPreviewImage={handlePreviewImage} onPreviewFile={handlePreviewFile} isOfficeEditable={isOfficeEditable} diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx index 9b5d5f730..8f0f06428 100644 --- a/components/files/files-header.tsx +++ b/components/files/files-header.tsx @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"; import { Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList, PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard, - MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, + MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, FolderInput, } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; @@ -37,6 +37,8 @@ interface SelectionActions { onDownload: () => void; onShare: () => void; onRename: () => void; + /** Move to… a folder picked from the tree; undefined where items can't move. */ + onMoveTo?: () => void; onCut: () => void; onCopy: () => void; onDelete: () => void; @@ -149,6 +151,9 @@ export function FilesHeader({ {selection.canRename && ( )} + {selection.onMoveTo && ( + + )} diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index 5607084d1..54cf6b675 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Plus, HardDrive, File, Folder, FolderOpen, ChevronRight, ChevronDown, Star, StarOff, - Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch, + Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch, FolderInput, } from "@/components/icons"; import { isTrashLocation } from "@/lib/files-trash"; import { Button } from "@/components/ui/button"; @@ -73,6 +73,8 @@ export interface FilesSidebarProps { /** Open a folder, then start creating or uploading there. */ onCreateIn: (path: string, id: string | null, kind: CreateKind) => void; onRenameFolder: (folder: FileResource) => void; + /** Move to… for a folder of the own drive. */ + onMoveFolder?: (folder: FileResource) => void; onDeleteFolder: (folder: FileResource) => void; /** Open the folder holding a recent file, with the file selected. */ onRevealRecent: (id: string) => void; @@ -105,6 +107,7 @@ export function FilesSidebar({ onShare, onCreateIn, onRenameFolder, + onMoveFolder, onDeleteFolder, onRevealRecent, onDownloadRecent, @@ -475,6 +478,9 @@ export function FilesSidebar({ {mayRename && ( run(() => onRenameFolder(folder))} /> )} + {onMoveFolder && !folder.isShared && mayRename && ( + run(() => onMoveFolder(folder))} /> + )} {mayDelete && ( run(() => onDeleteFolder(folder))} /> )} diff --git a/components/files/folder-picker-dialog.tsx b/components/files/folder-picker-dialog.tsx new file mode 100644 index 000000000..908daaf47 --- /dev/null +++ b/components/files/folder-picker-dialog.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ChevronDown, ChevronRight, Folder, FolderOpen, HardDrive } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; + +export interface PickerFolder { + id: string; + name: string; + parentId: string | null; +} + +interface FolderPickerDialogProps { + isOpen: boolean; + onClose: () => void; + /** Called with the chosen folder; null is the root ("My files"). */ + onPick: (folderId: string | null) => void; + title: string; + confirmText: string; + /** Every folder of the drive; the tree is built from their parent links. */ + folders: PickerFolder[]; + /** Folders that can't be picked, with what is below them (what is being moved). */ + excludeIds?: Set; + /** The folder that is pre-selected and can't be picked (where the items already are). */ + currentId?: string | null; +} + +const ROOT = ""; + +/** + * Picks a folder of the user's drive, e.g. to move items there: the folder + * tree with My files at the top, browsed with the mouse or the arrow keys. + */ +export function FolderPickerDialog({ + isOpen, onClose, onPick, title, confirmText, folders, excludeIds, currentId = null, +}: FolderPickerDialogProps) { + const t = useTranslations("files"); + const [selected, setSelected] = useState(null); + const [expanded, setExpanded] = useState>(() => new Set([ROOT])); + const listRef = useRef(null); + + const childrenOf = useMemo(() => { + const map = new Map(); + for (const folder of folders) { + if (excludeIds?.has(folder.id)) continue; + const key = folder.parentId ?? ROOT; + const list = map.get(key) ?? []; + list.push(folder); + map.set(key, list); + } + for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + return map; + }, [folders, excludeIds]); + + // The rows on screen: the root, then every folder whose parents are open. + const rows = useMemo(() => { + const out: { id: string; name: string; depth: number; hasChildren: boolean }[] = [ + { id: ROOT, name: t("my_files"), depth: 0, hasChildren: (childrenOf.get(ROOT)?.length ?? 0) > 0 }, + ]; + const walk = (parent: string, depth: number) => { + if (!expanded.has(parent)) return; + for (const folder of childrenOf.get(parent) ?? []) { + out.push({ id: folder.id, name: folder.name, depth, hasChildren: (childrenOf.get(folder.id)?.length ?? 0) > 0 }); + walk(folder.id, depth + 1); + } + }; + walk(ROOT, 1); + return out; + }, [childrenOf, expanded, t]); + + const current = currentId ?? ROOT; + const canPick = selected !== null && selected !== current; + + const toggle = (id: string, open?: boolean) => setExpanded((prev) => { + const next = new Set(prev); + if (open ?? !next.has(id)) next.add(id); + else next.delete(id); + return next; + }); + + const focusRow = (id: string) => { + setSelected(id); + listRef.current?.querySelector(`[data-folder-id="${CSS.escape(id)}"]`)?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + const index = rows.findIndex((r) => r.id === (selected ?? current)); + const row = rows[index]; + const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; + const openKey = rtl ? "ArrowLeft" : "ArrowRight"; + const closeKey = rtl ? "ArrowRight" : "ArrowLeft"; + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const next = rows[Math.max(0, Math.min(rows.length - 1, index + (e.key === "ArrowDown" ? 1 : -1)))]; + if (next) focusRow(next.id); + } else if (e.key === openKey && row?.hasChildren) { + e.preventDefault(); + toggle(row.id, true); + } else if (e.key === closeKey && row) { + e.preventDefault(); + if (row.hasChildren && expanded.has(row.id)) toggle(row.id, false); + } + }; + + const pick = () => { + if (!canPick) return; + onPick(selected === ROOT ? null : selected); + onClose(); + }; + + return ( + { e.preventDefault(); pick(); }} + footer={ + <> + + + + } + > +
+ {rows.map((row) => { + const isOpen = expanded.has(row.id); + const isSelected = (selected ?? current) === row.id; + const isCurrent = row.id === current; + const Icon = row.id === ROOT ? HardDrive : isOpen && row.hasChildren ? FolderOpen : Folder; + return ( +
setSelected(row.id)} + onDoubleClick={() => { if (row.id !== current) { onPick(row.id === ROOT ? null : row.id); onClose(); } }} + className={cn( + "flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", + isSelected ? "bg-selection" : "hover:bg-muted", + isCurrent && "text-muted-foreground", + )} + style={{ paddingInlineStart: `${8 + row.depth * 16}px` }} + > + {row.hasChildren ? ( + + ) : ( + + )} + + {row.name} +
+ ); + })} +
+
+ ); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index 54aa8fc5d..5e5b9769c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3943,6 +3943,9 @@ "paste": "لصق", "move_success": "{count, plural, one {تم نقل عنصر واحد} other {تم نقل # عنصر}}", "move_error": "فشل النقل", + "move_to": "نقل إلى…", + "move_to_title": "نقل إلى", + "move_here": "نقل إلى هنا", "paste_success": "تم اللصق بنجاح", "paste_error": "فشل اللصق", "new_text_file": "ملف نصي جديد", diff --git a/locales/ca/common.json b/locales/ca/common.json index e42910d17..fab48625c 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3943,6 +3943,9 @@ "paste": "Enganxa", "move_success": "{count, plural, one {1 element mogut} other {# elements moguts}}", "move_error": "No s'ha pogut moure", + "move_to": "Mou a…", + "move_to_title": "Mou a", + "move_here": "Mou aquí", "paste_success": "Enganxat correctament", "paste_error": "No s'ha pogut enganxar", "new_text_file": "Fitxer de text nou", diff --git a/locales/cs/common.json b/locales/cs/common.json index 3a4010b06..4470c7913 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3896,6 +3896,9 @@ "paste": "Vložit", "move_success": "{count, plural, one {Přesunuta 1 položka} few {Přesunuty # položky} other {Přesunuto # položek}}", "move_error": "Přesun selhal", + "move_to": "Přesunout do…", + "move_to_title": "Přesunout do", + "move_here": "Přesunout sem", "paste_success": "Úspěšně vloženo", "paste_error": "Vložení selhalo", "new_text_file": "Nový textový soubor", diff --git a/locales/da/common.json b/locales/da/common.json index 3f9768fc5..d58dffc08 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3942,6 +3942,9 @@ "paste": "Sæt ind", "move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}", "move_error": "Kunne ikke flytte", + "move_to": "Flyt til…", + "move_to_title": "Flyt til", + "move_here": "Flyt hertil", "paste_success": "Indsat", "paste_error": "Kunne ikke indsætte", "new_text_file": "Ny tekstfil", diff --git a/locales/de/common.json b/locales/de/common.json index 0107d1e42..5a6558528 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3896,6 +3896,9 @@ "paste": "Einfügen", "move_success": "{count, plural, one {1 Element verschoben} other {# Elemente verschoben}}", "move_error": "Verschieben fehlgeschlagen", + "move_to": "Verschieben nach…", + "move_to_title": "Verschieben nach", + "move_here": "Hierher verschieben", "paste_success": "Erfolgreich eingefügt", "paste_error": "Einfügen fehlgeschlagen", "new_text_file": "Neue Textdatei", diff --git a/locales/en/common.json b/locales/en/common.json index fc2aa824f..55f7ec975 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3943,6 +3943,9 @@ "paste": "Paste", "move_success": "{count, plural, one {1 item moved} other {# items moved}}", "move_error": "Failed to move", + "move_to": "Move to…", + "move_to_title": "Move to", + "move_here": "Move here", "paste_success": "Pasted successfully", "paste_error": "Failed to paste", "new_text_file": "New Text File", diff --git a/locales/es/common.json b/locales/es/common.json index 785014528..627b37dee 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3896,6 +3896,9 @@ "paste": "Pegar", "move_success": "{count, plural, one {1 elemento movido} other {# elementos movidos}}", "move_error": "Error al mover", + "move_to": "Mover a…", + "move_to_title": "Mover a", + "move_here": "Mover aquí", "paste_success": "Pegado correctamente", "paste_error": "Error al pegar", "new_text_file": "Nuevo archivo de texto", diff --git a/locales/et/common.json b/locales/et/common.json index 0f30ceda6..565c41159 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3943,6 +3943,9 @@ "paste": "Kleebi", "move_success": "{count, plural, one {1 üksus liigutatud} other {# üksust liigutatud}}", "move_error": "Liigutamine ebaõnnestus", + "move_to": "Teisalda…", + "move_to_title": "Teisalda", + "move_here": "Teisalda siia", "paste_success": "Edukalt kleebitud", "paste_error": "Kleepimine ebaõnnestus", "new_text_file": "Uus tekstifail", diff --git a/locales/fa/common.json b/locales/fa/common.json index 2d1843c70..b9328bbe8 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3943,6 +3943,9 @@ "paste": "چسباندن", "move_success": "{count, plural, one {۱ مورد منتقل شد} other {# مورد منتقل شد}}", "move_error": "انتقال ناموفق بود", + "move_to": "انتقال به…", + "move_to_title": "انتقال به", + "move_here": "انتقال به اینجا", "paste_success": "با موفقیت چسبانده شد", "paste_error": "چسباندن ناموفق بود", "new_text_file": "فایل متنی جدید", diff --git a/locales/fr/common.json b/locales/fr/common.json index ce30d0ea4..b566e46ff 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3896,6 +3896,9 @@ "paste": "Coller", "move_success": "{count, plural, one {1 élément déplacé} other {# éléments déplacés}}", "move_error": "Échec du déplacement", + "move_to": "Déplacer vers…", + "move_to_title": "Déplacer vers", + "move_here": "Déplacer ici", "paste_success": "Collé avec succès", "paste_error": "Échec du collage", "new_text_file": "Nouveau fichier texte", diff --git a/locales/he/common.json b/locales/he/common.json index 59970020b..8c38b40f2 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3813,6 +3813,9 @@ "paste": "הדבק", "move_success": "{count, plural, one {פריט אחד הועבר} other {# פריטים הועברו}}", "move_error": "לא הצליח לזוז", + "move_to": "העבר אל…", + "move_to_title": "העבר אל", + "move_here": "העבר לכאן", "paste_success": "הודבק בהצלחה", "paste_error": "ההדבקה נכשלה", "new_text_file": "קובץ טקסט חדש", diff --git a/locales/hu/common.json b/locales/hu/common.json index 7e7ea88e7..f6be406a4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3943,6 +3943,9 @@ "paste": "Beillesztés", "move_success": "{count, plural, one {1 elem áthelyezve} other {# elem áthelyezve}}", "move_error": "Nem sikerült áthelyezni", + "move_to": "Áthelyezés ide…", + "move_to_title": "Áthelyezés ide", + "move_here": "Áthelyezés ide", "paste_success": "Sikeresen beillesztve", "paste_error": "Nem sikerült beilleszteni", "new_text_file": "Új szöveges fájl", diff --git a/locales/it/common.json b/locales/it/common.json index aa925e35e..93b98b0ff 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3896,6 +3896,9 @@ "paste": "Incolla", "move_success": "{count, plural, one {1 elemento spostato} other {# elementi spostati}}", "move_error": "Spostamento non riuscito", + "move_to": "Sposta in…", + "move_to_title": "Sposta in", + "move_here": "Sposta qui", "paste_success": "Incollato con successo", "paste_error": "Incollaggio non riuscito", "new_text_file": "Nuovo file di testo", diff --git a/locales/ja/common.json b/locales/ja/common.json index a4cd9723b..6dc2b36e2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3896,6 +3896,9 @@ "paste": "貼り付け", "move_success": "{count, plural, other {#件のアイテムを移動しました}}", "move_error": "移動に失敗しました", + "move_to": "移動先…", + "move_to_title": "移動先", + "move_here": "ここに移動", "paste_success": "正常に貼り付けました", "paste_error": "貼り付けに失敗しました", "new_text_file": "新規テキストファイル", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6a9e11466..fd6f3f6bf 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3896,6 +3896,9 @@ "paste": "붙여넣기", "move_success": "{count}개의 항목이 이동되었어요", "move_error": "이동에 실패했어요", + "move_to": "이동…", + "move_to_title": "이동", + "move_here": "여기로 이동", "paste_success": "붙여넣기 성공", "paste_error": "붙여넣기에 실패했어요", "new_text_file": "새 텍스트 파일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 3aeb59fa1..0ff583b2d 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3896,6 +3896,9 @@ "paste": "Ielīmēt", "move_success": "Pārvietots {count, plural, one {1 elements} other {# elementi}}", "move_error": "Neizdevās pārvietot", + "move_to": "Pārvietot uz…", + "move_to_title": "Pārvietot uz", + "move_here": "Pārvietot šeit", "paste_success": "Ielīmēts sekmīgi", "paste_error": "Neizdevās ielīmēt", "new_text_file": "Jauns teksta fails", diff --git a/locales/mn/common.json b/locales/mn/common.json index f059e218e..bae3bd82d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3943,6 +3943,9 @@ "paste": "Наах", "move_success": "{count, plural, one {1 зүйл зөөгдлөө} other {# зүйл зөөгдлөө}}", "move_error": "Хөдөлж чадсангүй", + "move_to": "Зөөх…", + "move_to_title": "Зөөх", + "move_here": "Энд зөөх", "paste_success": "Амжилттай буулгалаа", "paste_error": "Буулгаж чадсангүй", "new_text_file": "Шинэ текст файл", diff --git a/locales/nb/common.json b/locales/nb/common.json index 3ac03829f..ee72318e4 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3943,6 +3943,9 @@ "paste": "Lim inn", "move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}", "move_error": "Kunne ikke flytte elementet", + "move_to": "Flytt til…", + "move_to_title": "Flytt til", + "move_here": "Flytt hit", "paste_success": "Limt inn", "paste_error": "Kunne ikke lime inn", "new_text_file": "Ny tekstfil", diff --git a/locales/nl/common.json b/locales/nl/common.json index d33e8414b..1dcaeb32a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3896,6 +3896,9 @@ "paste": "Plakken", "move_success": "{count, plural, one {1 item verplaatst} other {# items verplaatst}}", "move_error": "Verplaatsen mislukt", + "move_to": "Verplaatsen naar…", + "move_to_title": "Verplaatsen naar", + "move_here": "Hierheen verplaatsen", "paste_success": "Succesvol geplakt", "paste_error": "Plakken mislukt", "new_text_file": "Nieuw tekstbestand", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6c527ced8..b9cd474a6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3896,6 +3896,9 @@ "paste": "Wklej", "move_success": "{count, plural, one {Przeniesiono 1 element} other {Przeniesiono # elementów}}", "move_error": "Nie udało się przenieść", + "move_to": "Przenieś do…", + "move_to_title": "Przenieś do", + "move_here": "Przenieś tutaj", "paste_success": "Wklejono pomyślnie", "paste_error": "Nie udało się wkleić", "new_text_file": "Nowy plik tekstowy", diff --git a/locales/pt/common.json b/locales/pt/common.json index 5d4761fa6..2f0b7962c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3896,6 +3896,9 @@ "paste": "Colar", "move_success": "{count, plural, one {1 item movido} other {# itens movidos}}", "move_error": "Falha ao mover", + "move_to": "Mover para…", + "move_to_title": "Mover para", + "move_here": "Mover para cá", "paste_success": "Colado com sucesso", "paste_error": "Falha ao colar", "new_text_file": "Novo arquivo de texto", diff --git a/locales/ro/common.json b/locales/ro/common.json index f2e024816..df0272852 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3943,6 +3943,9 @@ "paste": "Lipiți", "move_success": "{count, plural, one {1 element mutat} few {# elemente mutate} other {# de elemente mutate}}", "move_error": "Nu s-a putut muta", + "move_to": "Mutați în…", + "move_to_title": "Mutați în", + "move_here": "Mutați aici", "paste_success": "S-a lipit cu succes", "paste_error": "Nu s-a putut insera", "new_text_file": "Fișier text nou", diff --git a/locales/ru/common.json b/locales/ru/common.json index 4c95eb9d6..5488903fe 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3896,6 +3896,9 @@ "paste": "Вставить", "move_success": "{count, plural, one {1 элемент перемещён} other {# элементов перемещено}}", "move_error": "Не удалось переместить", + "move_to": "Переместить в…", + "move_to_title": "Переместить в", + "move_here": "Переместить сюда", "paste_success": "Вставлено успешно", "paste_error": "Не удалось вставить", "new_text_file": "Новый текстовый файл", diff --git a/locales/sk/common.json b/locales/sk/common.json index 949530e69..58e9ccb49 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3943,6 +3943,9 @@ "paste": "Vložiť", "move_success": "{count, plural, one {1 položka presunutá} other {# položiek presunutých}}", "move_error": "Presun zlyhal", + "move_to": "Presunúť do…", + "move_to_title": "Presunúť do", + "move_here": "Presunúť sem", "paste_success": "Úspešne vložené", "paste_error": "Vloženie zlyhalo", "new_text_file": "Nový textový súbor", diff --git a/locales/th/common.json b/locales/th/common.json index 924c88b63..13ab0a06d 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1550,6 +1550,9 @@ "modified": "แก้ไขล่าสุด", "more_actions": "การดำเนินการเพิ่มเติมสำหรับ {name}", "move_error": "ย้ายไม่สำเร็จ", + "move_to": "ย้ายไปที่…", + "move_to_title": "ย้ายไปที่", + "move_here": "ย้ายมาที่นี่", "move_success": "{count, plural, other {ย้ายแล้ว # รายการ}}", "my_files": "ไฟล์ของฉัน", "name": "ชื่อ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 5bddd9e7f..1ffe324da 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3942,6 +3942,9 @@ "paste": "Yapıştır", "move_success": "{count, plural, one {1 öge taşındı} other {# öge taşındı}}", "move_error": "Taşınamadı", + "move_to": "Taşı…", + "move_to_title": "Taşı", + "move_here": "Buraya taşı", "paste_success": "Başarıyla yapıştırıldı", "paste_error": "Yapıştırılamadı", "new_text_file": "Yeni Metin Dosyası", diff --git a/locales/uk/common.json b/locales/uk/common.json index 494cc2f63..62eaa9954 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3896,6 +3896,9 @@ "paste": "Вставити", "move_success": "{count, plural, one {1 елемент переміщено} few {# елементи переміщено} many {# елементів переміщено} other {# елементів переміщено}}", "move_error": "Не вдалося переміститися", + "move_to": "Перемістити до…", + "move_to_title": "Перемістити до", + "move_here": "Перемістити сюди", "paste_success": "Вставлено успішно", "paste_error": "Не вдалося вставити", "new_text_file": "Новий текстовий файл", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 114ab9d72..dd508551d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3943,6 +3943,9 @@ "paste": "貼上", "move_success": "{count, plural, one {已移動 1 個項目} other {已移動 # 個項目}}", "move_error": "移動失敗", + "move_to": "移至…", + "move_to_title": "移至", + "move_here": "移到這裡", "paste_success": "貼上成功", "paste_error": "貼上失敗", "new_text_file": "新增文字檔案", diff --git a/locales/zh/common.json b/locales/zh/common.json index 8c6bdd37a..57314dcd6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3896,6 +3896,9 @@ "paste": "粘贴", "move_success": "{count, plural, one {已移动 1 个项目} other {已移动 # 个项目}}", "move_error": "移动失败", + "move_to": "移动到…", + "move_to_title": "移动到", + "move_here": "移到此处", "paste_success": "粘贴成功", "paste_error": "粘贴失败", "new_text_file": "新建文本文件", diff --git a/stores/__tests__/file-store-trash.test.ts b/stores/__tests__/file-store-trash.test.ts index d322b1e00..8abd96955 100644 --- a/stores/__tests__/file-store-trash.test.ts +++ b/stores/__tests__/file-store-trash.test.ts @@ -164,4 +164,13 @@ describe('file-store trash', () => { expect(deletesToTrash('e:team')).toBe(false); expect(deletesToTrash(TRASH_LOCATION)).toBe(false); }); + + it('moves a folder of the tree by id, and not into itself', async () => { + const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs'), dir('arch', 'Archive', null)]); + expect(await useFileStore.getState().moveNode('docs', 'sub')).toBe(false); + expect(await useFileStore.getState().moveNode('docs', 'arch')).toBe(true); + expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBe('arch'); + await useFileStore.getState().undoLastAction(); + expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBeNull(); + }); }); diff --git a/stores/file-store.ts b/stores/file-store.ts index 06e04592f..e57a8924e 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -152,6 +152,8 @@ interface FileState { moveToParent: (names: string[]) => Promise; /** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */ moveToFolderId: (names: string[], targetId: string | null) => Promise; + /** Move any node by id into a folder (null = the root), e.g. a folder of the tree. */ + moveNode: (id: string, targetId: string | null) => Promise; cutResources: (names: string[]) => void; copyResources: (names: string[]) => void; pasteResources: () => Promise; @@ -1425,6 +1427,28 @@ export const useFileStore = create((set, get) => ({ return true; }, + moveNode: async (id: string, targetId: string | null) => { + const { client, allFolders, resources, refresh } = get(); + if (!client) return false; + assertCanAddTo(targetId); + // A folder must not move into itself or anything below it. + const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null])); + for (let cur = targetId; cur != null; cur = parentOf.get(cur) ?? null) { + if (cur === id) return false; + } + const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id); + const from = known?.parentId ?? null; + if (known && from === targetId) return false; + const view = known ? resourceToView(known, parentPath(allFolders, from)) : unknownNodeView(id); + const toPath = parentPath(allFolders, targetId); + if (!(await fileHooks.onBeforeFileMove.intercept({ files: [view], toPath }))) return false; + await client.updateFileNode(id, { parentId: targetId }); + void fileHooks.onFileMove.emit({ files: [view], toPath }); + set({ lastAction: { type: 'move', entries: [{ id, from: { parentId: from }, to: { parentId: targetId } }], sourceParentId: null } }); + await refresh(); + return true; + }, + cutResources: (names: string[]) => { const { currentPath, currentParentId, resources } = get(); const picked = names