@@ -78,6 +78,7 @@ import { CommandPalette, type PaletteItem } from '@/components/drive/CommandPale
7878import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp' ;
7979import { VersionHistory } from '@/components/drive/VersionHistory' ;
8080import { ContextMenu } from '@/components/drive/ContextMenu' ;
81+ import { FolderPicker } from '@/components/drive/FolderPicker' ;
8182import { Menu , type MenuItem } from '@/components/ui/Menu' ;
8283
8384interface 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 ) ;
0 commit comments