mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: move files and folders with Move to… and a folder picker instead of only dragging
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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))} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": "ملف نصي جديد",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "فایل متنی جدید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "קובץ טקסט חדש",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "新規テキストファイル",
|
||||
|
||||
@@ -3896,6 +3896,9 @@
|
||||
"paste": "붙여넣기",
|
||||
"move_success": "{count}개의 항목이 이동되었어요",
|
||||
"move_error": "이동에 실패했어요",
|
||||
"move_to": "이동…",
|
||||
"move_to_title": "이동",
|
||||
"move_here": "여기로 이동",
|
||||
"paste_success": "붙여넣기 성공",
|
||||
"paste_error": "붙여넣기에 실패했어요",
|
||||
"new_text_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",
|
||||
|
||||
@@ -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": "Шинэ текст файл",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Новый текстовый файл",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ชื่อ",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Новий текстовий файл",
|
||||
|
||||
@@ -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": "新增文字檔案",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user