feat: move files and folders with Move to… and a folder picker instead of only dragging

This commit is contained in:
Linus Rath
2026-10-28 03:40:10 +01:00
parent 3d72d426d4
commit 408a2c90dc
37 changed files with 382 additions and 5 deletions
@@ -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();
});
});
+37 -3
View File
@@ -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<void>;
/** Move listed items into a folder by id (null = the root). */
onMoveTo: (names: string[], targetId: string | null) => Promise<void>;
/** Move a folder of the tree, which need not be listed, into a folder. */
onMoveNode?: (id: string, targetId: string | null) => Promise<void>;
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<string | null>(null);
const [renameFolder, setRenameFolder] = useState<FileResource | null>(null);
const [shareTargetId, setShareTargetId] = useState<string | null>(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<string | null>(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 && (
<ContextMenuItem icon={Download} label={t("download_count", { count: fileCount })} onClick={() => { closeMenu(); downloadNames(names); }} />
)}
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut(names); }} />
{menuSelection.some((r) => !r.isSymlink) && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy(names); }} />
@@ -888,6 +900,9 @@ export function FileBrowser({
{!resource.isDirectory && !resource.isSymlink && (
<ContextMenuItem icon={CopyPlus} label={t("duplicate")} onClick={() => { closeMenu(); void onDuplicate(resource.name); }} />
)}
{canMove && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names: [resource.name] }); }} />
)}
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut([resource.name]); }} />
{!resource.isSymlink && (
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { 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({
}}
/>
<FolderPickerDialog
isOpen={moveTarget !== null}
onClose={() => 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 && (
<ShareCollectionDialog
client={client}
+14
View File
@@ -90,6 +90,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
duplicateResource,
downloadResources,
moveToFolderId,
moveNode,
cutResources,
copyResources,
pasteResources,
@@ -568,6 +569,18 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
}, [moveToFolderId, t, handleUndo, failureReason]);
const handleMoveNode = useCallback(async (id: string, targetId: string | null) => {
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}
+6 -1
View File
@@ -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 && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
)}
{selection.onMoveTo && (
<ToolButton icon={FolderInput} label={t("move_to")} onClick={selection.onMoveTo} compact />
)}
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
+7 -1
View File
@@ -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 && (
<ContextMenuItem icon={Pencil} label={t("rename")} onClick={() => run(() => onRenameFolder(folder))} />
)}
{onMoveFolder && !folder.isShared && mayRename && (
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => run(() => onMoveFolder(folder))} />
)}
{mayDelete && (
<ContextMenuItem icon={Trash2} label={t("delete")} destructive onClick={() => run(() => onDeleteFolder(folder))} />
)}
+178
View File
@@ -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<string>;
/** 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<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([ROOT]));
const listRef = useRef<HTMLDivElement>(null);
const childrenOf = useMemo(() => {
const map = new Map<string, PickerFolder[]>();
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<HTMLElement>(`[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 (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={title}
onSubmit={(e) => { e.preventDefault(); pick(); }}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>{t("cancel")}</Button>
<Button type="submit" disabled={!canPick}>{confirmText}</Button>
</>
}
>
<div
ref={listRef}
role="tree"
aria-label={t("folders")}
onKeyDown={onKeyDown}
className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1"
>
{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 (
<div
key={row.id || "root"}
role="treeitem"
aria-selected={isSelected}
aria-expanded={row.hasChildren ? isOpen : undefined}
aria-disabled={isCurrent || undefined}
data-folder-id={row.id}
tabIndex={isSelected ? 0 : -1}
onClick={() => 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 ? (
<button
type="button"
tabIndex={-1}
aria-label={isOpen ? t("collapse_folder", { name: row.name }) : t("expand_folder", { name: row.name })}
onClick={(e) => { e.stopPropagation(); toggle(row.id); }}
className="p-0.5 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
>
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</button>
) : (
<span className="w-[18px] shrink-0" />
)}
<Icon className="w-4 h-4 shrink-0 text-primary" />
<span className="truncate" title={row.name}>{row.name}</span>
</div>
);
})}
</div>
</Dialog>
);
}
+3
View File
@@ -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": "ملف نصي جديد",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "فایل متنی جدید",
+3
View File
@@ -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",
+3
View File
@@ -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": "קובץ טקסט חדש",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "新規テキストファイル",
+3
View File
@@ -3896,6 +3896,9 @@
"paste": "붙여넣기",
"move_success": "{count}개의 항목이 이동되었어요",
"move_error": "이동에 실패했어요",
"move_to": "이동…",
"move_to_title": "이동",
"move_here": "여기로 이동",
"paste_success": "붙여넣기 성공",
"paste_error": "붙여넣기에 실패했어요",
"new_text_file": "새 텍스트 파일",
+3
View File
@@ -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",
+3
View File
@@ -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": "Шинэ текст файл",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "Новый текстовый файл",
+3
View File
@@ -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",
+3
View File
@@ -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": "ชื่อ",
+3
View File
@@ -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ı",
+3
View File
@@ -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": "Новий текстовий файл",
+3
View File
@@ -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": "新增文字檔案",
+3
View File
@@ -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": "新建文本文件",
@@ -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();
});
});
+24
View File
@@ -152,6 +152,8 @@ interface FileState {
moveToParent: (names: string[]) => Promise<boolean>;
/** 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<boolean>;
/** 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<boolean>;
cutResources: (names: string[]) => void;
copyResources: (names: string[]) => void;
pasteResources: () => Promise<boolean>;
@@ -1425,6 +1427,28 @@ export const useFileStore = create<FileState>((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