@@ -51,6 +51,7 @@ import {
5151 PackageOpen ,
5252 Check ,
5353 X ,
54+ Users ,
5455} from 'lucide-react' ;
5556import { zip as fzip , unzip as funzip } from 'fflate' ;
5657import type { PublicFile , PublicFolder } from '@opencoperlock/shared/client' ;
@@ -79,6 +80,8 @@ import { ShortcutsHelp } from '@/components/drive/ShortcutsHelp';
7980import { VersionHistory } from '@/components/drive/VersionHistory' ;
8081import { ContextMenu } from '@/components/drive/ContextMenu' ;
8182import { FolderPicker } from '@/components/drive/FolderPicker' ;
83+ import { ZipExplorer } from '@/components/drive/ZipExplorer' ;
84+ import { CrossCopyPicker , type CopyDest } from '@/components/drive/CrossCopyPicker' ;
8285import { Menu , type MenuItem } from '@/components/ui/Menu' ;
8386
8487interface ZkFile {
@@ -138,6 +141,8 @@ export default function EspacesPage() {
138141 const [ viewing , setViewing ] = useState < ViewerSource | null > ( null ) ;
139142 const [ shareLink , setShareLink ] = useState < string | null > ( null ) ;
140143 const [ versionsFor , setVersionsFor ] = useState < PublicFile | null > ( null ) ;
144+ const [ zipView , setZipView ] = useState < { name : string ; entries : Record < string , Uint8Array > } | null > ( null ) ;
145+ const [ copyPicker , setCopyPicker ] = useState < { items : { kind : 'file' | 'folder' ; id : string } [ ] } | null > ( null ) ;
141146 const fileInput = useRef < HTMLInputElement > ( null ) ;
142147
143148 // selection / clipboard / overlays
@@ -1294,11 +1299,83 @@ export default function EspacesPage() {
12941299 }
12951300 }
12961301
1302+ // Open the .zip in a read-only in-memory browser (no extraction into the Drive).
1303+ async function exploreZip ( e : Entry ) {
1304+ setBusy ( t ( 'drive.opening' ) ) ;
1305+ try {
1306+ const archive = await entryBytes ( e ) ;
1307+ if ( ! archive ) return ;
1308+ const entries = await unzipAsync ( archive . data ) ;
1309+ setZipView ( { name : archive . name , entries } ) ;
1310+ } catch ( err ) {
1311+ setError ( err instanceof ApiError ? err . message : t ( 'drive.extractFailed' ) ) ;
1312+ } finally {
1313+ setBusy ( null ) ;
1314+ }
1315+ }
1316+
1317+ // ── Cross-realm copy: personal → shared space ──────────────────────────────
1318+ // Only offered for non-ZK spaces (a blind vault can't hand plaintext to another realm). Reuses the
1319+ // normal endpoints so the server keeps enforcing membership + quota on the destination.
1320+ async function copyFileToShared ( spaceId : string , folderId : string | null , srcId : string , name : string ) {
1321+ const res = await fetch ( api . url ( `/files/${ srcId } /download` ) , { credentials : 'include' } ) ;
1322+ if ( ! res . ok ) throw new ApiError ( res . status , t ( 'drive.copyFailed' ) ) ;
1323+ const data = new Uint8Array ( await res . arrayBuffer ( ) ) ;
1324+ const form = new FormData ( ) ;
1325+ form . append ( 'file' , new File ( [ asPart ( data ) ] , name ) ) ;
1326+ await api . upload ( `/spaces/${ spaceId } /files${ folderId ? `?folderId=${ encodeURIComponent ( folderId ) } ` : '' } ` , form ) ;
1327+ }
1328+ async function copyFolderToShared ( spaceId : string , destParentId : string | null , folderId : string , name : string ) {
1329+ const created = await api . post < { folder : { id : string } } > ( `/spaces/${ spaceId } /folders` , { name, parentId : destParentId } ) ;
1330+ const newId = created . folder . id ;
1331+ const { files } = await api . get < { files : PublicFile [ ] } > ( `/files?folderId=${ encodeURIComponent ( folderId ) } ` ) ;
1332+ for ( const f of files ) await copyFileToShared ( spaceId , newId , f . id , f . name ) ;
1333+ for ( const sub of allFolders . filter ( ( x ) => x . parentId === folderId ) ) {
1334+ await copyFolderToShared ( spaceId , newId , sub . id , sub . name ) ;
1335+ }
1336+ }
1337+ async function performCopyToShared ( dest : CopyDest ) {
1338+ if ( dest . realm !== 'shared' || ! copyPicker ) return ;
1339+ const items = copyPicker . items ;
1340+ setCopyPicker ( null ) ;
1341+ setBusy ( t ( 'drive.copying' ) ) ;
1342+ try {
1343+ let n = 0 ;
1344+ for ( const it of items ) {
1345+ if ( it . kind === 'file' ) {
1346+ const f = files . find ( ( x ) => x . id === it . id ) ;
1347+ if ( f ) await copyFileToShared ( dest . spaceId , dest . folderId , f . id , f . name ) ;
1348+ } else {
1349+ const fld = allFolders . find ( ( x ) => x . id === it . id ) ;
1350+ if ( fld ) await copyFolderToShared ( dest . spaceId , dest . folderId , fld . id , fld . name ) ;
1351+ }
1352+ n += 1 ;
1353+ }
1354+ toast ( t ( 'drive.copiedToShared' , { n, name : dest . label } ) , 'success' ) ;
1355+ } catch ( err ) {
1356+ setError ( err instanceof ApiError ? err . message : t ( 'drive.copyFailed' ) ) ;
1357+ } finally {
1358+ setBusy ( null ) ;
1359+ }
1360+ }
1361+ function copyToShared ( kind : 'file' | 'folder' , id : string ) {
1362+ setCopyPicker ( { items : [ { kind, id } ] } ) ;
1363+ }
1364+ function bulkCopyToShared ( ) {
1365+ const items : { kind : 'file' | 'folder' ; id : string } [ ] = [ ] ;
1366+ for ( const e of entriesByKeys ( [ ...selected ] ) ) {
1367+ if ( e . kind === 'folder' ) items . push ( { kind : 'folder' , id : e . folder . id } ) ;
1368+ else if ( e . kind === 'file' ) items . push ( { kind : 'file' , id : e . file . id } ) ;
1369+ }
1370+ if ( items . length ) setCopyPicker ( { items } ) ;
1371+ }
1372+
12971373 // ── Menu items (shared by the "⋮" overflow menu and the right-click context menu) ──
12981374 function folderMenuItems ( folder : PublicFolder ) : MenuItem [ ] {
12991375 return [
13001376 { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => void renameFolder ( folder ) } ,
13011377 ...( ! isZk ? [ { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => void move ( 'folder' , folder . id ) } ] : [ ] ) ,
1378+ ...( ! isZk ? [ { label : t ( 'drive.actionCopyToShared' ) , icon : Users , onClick : ( ) => copyToShared ( 'folder' , folder . id ) } ] : [ ] ) ,
13021379 ...( ! isZk ? [ { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => void share ( 'folder' , folder . id ) } ] : [ ] ) ,
13031380 { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void deleteFolder ( folder . id ) } ,
13041381 ] ;
@@ -1318,9 +1395,13 @@ export default function EspacesPage() {
13181395 return [
13191396 { label : t ( 'drive.actionRename' ) , icon : Pencil , onClick : ( ) => void renameFile ( file ) } ,
13201397 { label : t ( 'drive.actionMove' ) , icon : FolderInput , onClick : ( ) => void move ( 'file' , file . id ) } ,
1398+ { label : t ( 'drive.actionCopyToShared' ) , icon : Users , onClick : ( ) => copyToShared ( 'file' , file . id ) } ,
13211399 { label : t ( 'drive.actionDuplicate' ) , icon : Files , onClick : ( ) => void duplicateFile ( file . id , file . name , file . mimeType , currentFolderId ! , true ) . then ( reloadCurrent ) } ,
13221400 ...( isZip ( file . name , file . mimeType )
1323- ? [ { label : t ( 'drive.actionExtract' ) , icon : PackageOpen , onClick : ( ) => void extractZip ( { key : `file:${ file . id } ` , kind : 'file' , name : file . name , size : file . sizeBytes , date : 0 , file } ) } ]
1401+ ? [
1402+ { label : t ( 'drive.actionExploreZip' ) , icon : Archive , onClick : ( ) => void exploreZip ( { key : `file:${ file . id } ` , kind : 'file' , name : file . name , size : file . sizeBytes , date : 0 , file } ) } ,
1403+ { label : t ( 'drive.actionExtract' ) , icon : PackageOpen , onClick : ( ) => void extractZip ( { key : `file:${ file . id } ` , kind : 'file' , name : file . name , size : file . sizeBytes , date : 0 , file } ) } ,
1404+ ]
13241405 : [ ] ) ,
13251406 { label : t ( 'drive.actionShare' ) , icon : Share2 , onClick : ( ) => void share ( 'file' , file . id ) } ,
13261407 { label : t ( 'drive.actionVersions' ) , icon : History , onClick : ( ) => setVersionsFor ( file ) } ,
@@ -1330,7 +1411,10 @@ export default function EspacesPage() {
13301411 function zkMenuItems ( zk : ZkFile ) : MenuItem [ ] {
13311412 return [
13321413 ...( isZip ( zkName ( zk ) )
1333- ? [ { label : t ( 'drive.actionExtract' ) , icon : PackageOpen , onClick : ( ) => void extractZip ( { key : `zk:${ zk . id } ` , kind : 'zk' , name : zkName ( zk ) , size : zk . sizeBytes , date : 0 , zk } ) } ]
1414+ ? [
1415+ { label : t ( 'drive.actionExploreZip' ) , icon : Archive , onClick : ( ) => void exploreZip ( { key : `zk:${ zk . id } ` , kind : 'zk' , name : zkName ( zk ) , size : zk . sizeBytes , date : 0 , zk } ) } ,
1416+ { label : t ( 'drive.actionExtract' ) , icon : PackageOpen , onClick : ( ) => void extractZip ( { key : `zk:${ zk . id } ` , kind : 'zk' , name : zkName ( zk ) , size : zk . sizeBytes , date : 0 , zk } ) } ,
1417+ ]
13341418 : [ ] ) ,
13351419 { label : t ( 'drive.actionDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void deleteFile ( zk . id ) } ,
13361420 ] ;
@@ -1347,6 +1431,7 @@ export default function EspacesPage() {
13471431 items . push ( { label : t ( 'drive.actionCopy' ) , icon : Copy , onClick : ( ) => buildClip ( 'copy' ) } ) ;
13481432 items . push ( { label : t ( 'drive.actionCut' ) , icon : Scissors , onClick : ( ) => buildClip ( 'cut' ) } ) ;
13491433 items . push ( { label : t ( 'drive.bulkMove' ) , icon : FolderInput , onClick : ( ) => void bulkMove ( ) } ) ;
1434+ items . push ( { label : t ( 'drive.actionCopyToShared' ) , icon : Users , onClick : ( ) => bulkCopyToShared ( ) } ) ;
13501435 }
13511436 items . push ( { label : t ( 'drive.bulkDelete' ) , icon : Trash2 , danger : true , onClick : ( ) => void bulkDelete ( ) } ) ;
13521437 return items ;
@@ -1385,7 +1470,7 @@ export default function EspacesPage() {
13851470 // ── Keyboard shortcuts ───────────────────────────────────────────────────────
13861471 // A ref keeps the latest handler so we register the window listener only once.
13871472 const keyHandler = ( e : KeyboardEvent ) => {
1388- if ( paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor || ctxMenu ) return ;
1473+ if ( paletteOpen || helpOpen || viewing || askPass || shareLink || versionsFor || ctxMenu || zipView || copyPicker ) return ;
13891474 if ( document . querySelector ( '[class*="z-[100]"]' ) ) return ; // an in-app dialog is open
13901475 const mod = e . metaKey || e . ctrlKey ;
13911476 if ( mod && e . key . toLowerCase ( ) === 'k' ) {
@@ -1859,6 +1944,10 @@ export default function EspacesPage() {
18591944 onRestored = { reloadCurrent }
18601945 />
18611946 ) }
1947+ { zipView && < ZipExplorer name = { zipView . name } entries = { zipView . entries } onClose = { ( ) => setZipView ( null ) } /> }
1948+ { copyPicker && (
1949+ < CrossCopyPicker target = "shared" count = { copyPicker . items . length } onPick = { ( d ) => void performCopyToShared ( d ) } onClose = { ( ) => setCopyPicker ( null ) } />
1950+ ) }
18621951 { paletteOpen && < CommandPalette items = { paletteItems } onSearch = { searchFiles } onClose = { ( ) => setPaletteOpen ( false ) } /> }
18631952 { helpOpen && < ShortcutsHelp onClose = { ( ) => setHelpOpen ( false ) } /> }
18641953 { viewing && < FileViewer source = { viewing } onClose = { ( ) => setViewing ( null ) } /> }
0 commit comments