Skip to content

Commit 33c3d26

Browse files
feat(drive): rebuilt Move dialog as an intuitive folder picker (#1)
The old Move was a flat, unlabelled list whose "root" option moved a file to folderId=null — OUTSIDE every space, where the Drive can't show it, so files appeared to vanish. Replaced with a real destination picker: - Navigate the space's own folder tree (breadcrumb + click to enter), then "Move here". Destinations stay inside the space (root = the space itself), so a move can never strand a file. - The moved folder and its whole subtree are excluded (no cycles), and "Move here" is disabled when the target is where the items already are. - Works for single and bulk moves; integrates with undo/redo.
1 parent 127185c commit 33c3d26

3 files changed

Lines changed: 199 additions & 37 deletions

File tree

‎apps/web/src/app/(app)/drive/page.tsx‎

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import { CommandPalette, type PaletteItem } from '@/components/drive/CommandPale
7878
import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp';
7979
import { VersionHistory } from '@/components/drive/VersionHistory';
8080
import { ContextMenu } from '@/components/drive/ContextMenu';
81+
import { FolderPicker } from '@/components/drive/FolderPicker';
8182
import { Menu, type MenuItem } from '@/components/ui/Menu';
8283

8384
interface ZkFile {
@@ -146,6 +147,11 @@ export default function EspacesPage() {
146147
const [paletteOpen, setPaletteOpen] = useState(false);
147148
const [helpOpen, setHelpOpen] = useState(false);
148149
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; items: MenuItem[] } | null>(null);
150+
const [movePicker, setMovePicker] = useState<{
151+
items: { kind: 'file' | 'folder'; id: string }[];
152+
exclude: Set<string>;
153+
currentId: string | null;
154+
} | null>(null);
149155

150156
// passphrase prompt
151157
const [askPass, setAskPass] = useState<PublicFolder | null>(null);
@@ -983,21 +989,42 @@ export default function EspacesPage() {
983989
if (entry.kind === 'folder') void renameFolder(entry.folder);
984990
else if (entry.kind === 'file') void renameFile(entry.file);
985991
}
986-
async function move(kind: 'file' | 'folder', id: string) {
987-
const targets = [
988-
{ id: null as string | null, name: t('drive.spaceRoot') },
989-
...allFolders.filter((f) => f.id !== id && f.isZeroKnowledge === isZk),
990-
];
991-
const dest = await choose<string | '__root__'>({
992-
title: t('drive.moveTitle'),
993-
options: targets.map((tg) => ({ value: tg.id ?? '__root__', label: tg.name })),
992+
/** A folder's id plus all its descendants — excluded as move destinations (no cycles). */
993+
function folderSubtree(id: string): Set<string> {
994+
const out = new Set<string>([id]);
995+
const stack = [id];
996+
while (stack.length) {
997+
const p = stack.pop()!;
998+
for (const f of allFolders) if (f.parentId === p && !out.has(f.id)) { out.add(f.id); stack.push(f.id); }
999+
}
1000+
return out;
1001+
}
1002+
function move(kind: 'file' | 'folder', id: string) {
1003+
setMovePicker({
1004+
items: [{ kind, id }],
1005+
exclude: kind === 'folder' ? folderSubtree(id) : new Set<string>(),
1006+
currentId: currentFolderId,
9941007
});
995-
if (dest === null) return;
996-
const target = dest === '__root__' ? null : dest;
997-
await api.patch(`/${kind}s/${id}`, { folderId: target, parentId: target });
998-
if (!isZk) pushUndo({ type: 'move', moves: [{ kind, id, from: currentFolderId, to: target }] });
999-
await Promise.all([loadFolders(), reloadCurrent()]);
1000-
toast(t('drive.moved'), 'success');
1008+
}
1009+
// Perform the pending move once a destination is chosen in the FolderPicker.
1010+
async function performMove(target: string) {
1011+
const picker = movePicker;
1012+
setMovePicker(null);
1013+
if (!picker) return;
1014+
try {
1015+
const moves: { kind: 'file' | 'folder'; id: string; from: string | null; to: string | null }[] = [];
1016+
for (const it of picker.items) {
1017+
if (it.kind === 'folder') await api.patch(`/folders/${it.id}`, { parentId: target });
1018+
else await api.patch(`/files/${it.id}`, { folderId: target });
1019+
moves.push({ kind: it.kind, id: it.id, from: picker.currentId, to: target });
1020+
}
1021+
if (!isZk && moves.length > 0) pushUndo({ type: 'move', moves });
1022+
setSelected(new Set());
1023+
await Promise.all([loadFolders(), reloadCurrent()]);
1024+
toast(moves.length > 1 ? t('drive.bulkMoved', { n: moves.length }) : t('drive.moved'), 'success');
1025+
} catch (err) {
1026+
toast(err instanceof ApiError ? err.message : t('common.opFailed'), 'error');
1027+
}
10011028
}
10021029
async function share(kind: 'file' | 'folder', id: string) {
10031030
const accessMode = await choose<'PUBLIC' | 'CODE' | 'AUTHENTICATED'>({
@@ -1079,33 +1106,20 @@ export default function EspacesPage() {
10791106
if (blob) saveBlob(blob, zkName(it.zk));
10801107
}
10811108
}
1082-
async function bulkMove() {
1109+
function bulkMove() {
10831110
const items = entriesByKeys([...selected]).filter((e) => e.kind !== 'zk');
10841111
if (items.length === 0) return;
1085-
const selfIds = new Set(items.map((e) => (e.kind === 'folder' ? e.folder.id : '')));
1086-
const targets = [
1087-
{ id: null as string | null, name: t('drive.spaceRoot') },
1088-
...allFolders.filter((f) => f.isZeroKnowledge === isZk && !selfIds.has(f.id)),
1089-
];
1090-
const dest = await choose<string | '__root__'>({
1091-
title: t('drive.moveTitle'),
1092-
options: targets.map((tg) => ({ value: tg.id ?? '__root__', label: tg.name })),
1093-
});
1094-
if (dest === null) return;
1095-
const target = dest === '__root__' ? null : dest;
1096-
for (const it of items) {
1097-
if (it.kind === 'folder') await api.patch(`/folders/${it.folder.id}`, { parentId: target });
1098-
else if (it.kind === 'file') await api.patch(`/files/${it.file.id}`, { folderId: target });
1099-
}
1100-
const moves: { kind: 'file' | 'folder'; id: string; from: string | null; to: string | null }[] = [];
1112+
const moveItems: { kind: 'file' | 'folder'; id: string }[] = [];
1113+
const exclude = new Set<string>();
11011114
for (const it of items) {
1102-
if (it.kind === 'folder') moves.push({ kind: 'folder', id: it.folder.id, from: currentFolderId, to: target });
1103-
else if (it.kind === 'file') moves.push({ kind: 'file', id: it.file.id, from: currentFolderId, to: target });
1115+
if (it.kind === 'folder') {
1116+
moveItems.push({ kind: 'folder', id: it.folder.id });
1117+
for (const x of folderSubtree(it.folder.id)) exclude.add(x);
1118+
} else if (it.kind === 'file') {
1119+
moveItems.push({ kind: 'file', id: it.file.id });
1120+
}
11041121
}
1105-
if (moves.length > 0) pushUndo({ type: 'move', moves });
1106-
setSelected(new Set());
1107-
await Promise.all([loadFolders(), reloadCurrent()]);
1108-
toast(t('drive.bulkMoved', { n: items.length }), 'success');
1122+
setMovePicker({ items: moveItems, exclude, currentId: currentFolderId });
11091123
}
11101124

11111125
// ── Clipboard (normal spaces only) ───────────────────────────────────────────
@@ -1849,6 +1863,18 @@ export default function EspacesPage() {
18491863
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
18501864
{viewing && <FileViewer source={viewing} onClose={() => setViewing(null)} />}
18511865
{ctxMenu && <ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} />}
1866+
{movePicker && activeSpace && (
1867+
<FolderPicker
1868+
folders={allFolders}
1869+
rootId={activeSpace.id}
1870+
rootName={activeSpace.name}
1871+
excludeIds={movePicker.exclude}
1872+
currentId={movePicker.currentId}
1873+
count={movePicker.items.length}
1874+
onPick={performMove}
1875+
onClose={() => setMovePicker(null)}
1876+
/>
1877+
)}
18521878
<DropOverlay show={dragging} />
18531879
</div>
18541880
);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
'use client';
2+
3+
/**
4+
* Destination picker for "Move". A small folder browser: navigate the space's tree, then "Move
5+
* here". Destinations stay INSIDE the space (root = the space folder itself), so a move can never
6+
* strand a file outside every space. The moved folder and its subtree are excluded to prevent
7+
* cycles.
8+
*/
9+
import { useMemo, useState } from 'react';
10+
import { createPortal } from 'react-dom';
11+
import { Folder, ChevronRight, Home, X, FolderInput, Check } from 'lucide-react';
12+
import type { PublicFolder } from '@opencoperlock/shared/client';
13+
import { useT } from '@/lib/i18n';
14+
15+
export function FolderPicker({
16+
folders,
17+
rootId,
18+
rootName,
19+
excludeIds,
20+
currentId,
21+
count,
22+
onPick,
23+
onClose,
24+
}: {
25+
folders: PublicFolder[];
26+
rootId: string;
27+
rootName: string;
28+
excludeIds: Set<string>;
29+
currentId: string | null;
30+
count: number;
31+
onPick: (folderId: string) => void;
32+
onClose: () => void;
33+
}) {
34+
const { t } = useT();
35+
const [cwd, setCwd] = useState<string>(rootId);
36+
const byId = useMemo(() => new Map(folders.map((f) => [f.id, f])), [folders]);
37+
const children = useMemo(
38+
() =>
39+
folders
40+
.filter((f) => f.parentId === cwd && !excludeIds.has(f.id))
41+
.sort((a, b) => a.name.localeCompare(b.name)),
42+
[folders, cwd, excludeIds],
43+
);
44+
const trail = useMemo(() => {
45+
const out: PublicFolder[] = [];
46+
let cur: string | null = cwd;
47+
while (cur && cur !== rootId) {
48+
const f = byId.get(cur);
49+
if (!f) break;
50+
out.unshift(f);
51+
cur = f.parentId;
52+
}
53+
return out;
54+
}, [cwd, rootId, byId]);
55+
56+
if (typeof document === 'undefined') return null;
57+
const alreadyHere = cwd === currentId;
58+
const destName = trail.length ? trail[trail.length - 1]!.name : rootName;
59+
60+
return createPortal(
61+
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
62+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
63+
<div className="relative flex max-h-[80vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#15151d] shadow-2xl">
64+
<div className="flex items-start justify-between gap-3 border-b border-white/[0.07] px-5 py-4">
65+
<div>
66+
<h2 className="flex items-center gap-2 font-semibold text-white">
67+
<FolderInput size={17} className="text-violet-300" /> {t('picker.title')}
68+
</h2>
69+
<p className="mt-0.5 text-xs text-zinc-500">{t('picker.moving', { n: count })}</p>
70+
</div>
71+
<button onClick={onClose} aria-label={t('picker.close')} className="-mr-1 rounded-lg p-1.5 text-zinc-500 transition hover:bg-white/5 hover:text-zinc-200">
72+
<X size={18} />
73+
</button>
74+
</div>
75+
76+
{/* breadcrumb */}
77+
<div className="flex flex-wrap items-center gap-1 border-b border-white/[0.05] px-5 py-2.5 text-sm text-zinc-400">
78+
<button className="flex items-center gap-1 rounded px-1.5 py-0.5 transition hover:bg-white/5 hover:text-zinc-100" onClick={() => setCwd(rootId)}>
79+
<Home size={14} /> {rootName}
80+
</button>
81+
{trail.map((f) => (
82+
<span key={f.id} className="flex items-center gap-1">
83+
<ChevronRight size={13} className="text-zinc-600" />
84+
<button className="rounded px-1.5 py-0.5 transition hover:bg-white/5 hover:text-zinc-100" onClick={() => setCwd(f.id)}>
85+
{f.name}
86+
</button>
87+
</span>
88+
))}
89+
</div>
90+
91+
{/* folder list */}
92+
<div className="min-h-[9rem] flex-1 overflow-y-auto p-2">
93+
{children.length === 0 ? (
94+
<p className="px-3 py-8 text-center text-sm text-zinc-500">{t('picker.noSubfolders')}</p>
95+
) : (
96+
children.map((f) => (
97+
<button
98+
key={f.id}
99+
onClick={() => setCwd(f.id)}
100+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-zinc-200 transition hover:bg-white/[0.05]"
101+
>
102+
<Folder size={16} className="shrink-0 text-amber-200" />
103+
<span className="min-w-0 flex-1 truncate">{f.name}</span>
104+
<ChevronRight size={15} className="shrink-0 text-zinc-500" />
105+
</button>
106+
))
107+
)}
108+
</div>
109+
110+
<div className="flex items-center justify-between gap-3 border-t border-white/[0.07] px-5 py-3.5">
111+
<span className="min-w-0 truncate text-xs text-zinc-500">
112+
{alreadyHere ? t('picker.alreadyHere') : t('picker.dest', { name: destName })}
113+
</span>
114+
<button className="btn-primary shrink-0" disabled={alreadyHere} onClick={() => onPick(cwd)}>
115+
<Check size={15} /> {t('picker.moveHere')}
116+
</button>
117+
</div>
118+
</div>
119+
</div>,
120+
document.body,
121+
);
122+
}

‎apps/web/src/lib/i18n.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ const fr: Dict = {
169169
'drive.spaceRoot': 'Racine de l’espace',
170170
'drive.moveTitle': 'Déplacer vers',
171171
'drive.moved': 'Déplacé',
172+
'picker.title': 'Déplacer vers…',
173+
'picker.moving': '{n} élément(s) à déplacer',
174+
'picker.close': 'Fermer',
175+
'picker.noSubfolders': 'Aucun sous-dossier ici.',
176+
'picker.moveHere': 'Déplacer ici',
177+
'picker.alreadyHere': 'Déjà à cet emplacement',
178+
'picker.dest': 'Destination : {name}',
172179
'drive.undone': 'Action annulée',
173180
'drive.redone': 'Action rétablie',
174181
'drive.scUndo': 'Annuler (déplacer / supprimer / renommer)',
@@ -801,6 +808,13 @@ const en: Dict = {
801808
'drive.spaceRoot': 'Space root',
802809
'drive.moveTitle': 'Move to',
803810
'drive.moved': 'Moved',
811+
'picker.title': 'Move to…',
812+
'picker.moving': '{n} item(s) to move',
813+
'picker.close': 'Close',
814+
'picker.noSubfolders': 'No subfolders here.',
815+
'picker.moveHere': 'Move here',
816+
'picker.alreadyHere': 'Already here',
817+
'picker.dest': 'Destination: {name}',
804818
'drive.undone': 'Undone',
805819
'drive.redone': 'Redone',
806820
'drive.scUndo': 'Undo (move / delete / rename)',

0 commit comments

Comments
 (0)