@@ -10,7 +10,8 @@ import {
1010 EncodingOptions ,
1111 FsClient ,
1212 RmOptions ,
13- StatsLike
13+ StatsLike ,
14+ WritableStreamHandle
1415} from '../../'
1516
1617import { BasicStats } from './BasicStats'
@@ -249,9 +250,7 @@ export class FileSystemAccessApiFsClient implements FsClient {
249250
250251 const oldFilepathStat = await this . stat ( oldPath )
251252 if ( oldFilepathStat . isFile ( ) ) {
252- const data = await this . readFile ( oldPath )
253- await this . writeFile ( newPath , data )
254- await this . rm ( oldPath )
253+ await this . renameFile ( oldPath , newPath )
255254 } else if ( oldFilepathStat . isDirectory ( ) ) {
256255 await this . mkdir ( newPath )
257256 const sourceFolder = await this . getDirectoryByPath ( oldPath )
@@ -263,6 +262,40 @@ export class FileSystemAccessApiFsClient implements FsClient {
263262 }
264263 }
265264
265+ private async renameFile ( oldPath : string , newPath : string ) : Promise < void > {
266+ const { folderPath : oldFolder , leafSegment : oldName } = this . getFolderPathAndLeafSegment ( oldPath )
267+ const { folderPath : newFolder , leafSegment : newName } = this . getFolderPathAndLeafSegment ( newPath )
268+
269+ const oldDir = await this . getDirectoryByPath ( oldFolder )
270+ const fileHandle = await this . getEntry < 'file' > ( oldDir , oldName , 'file' )
271+ if ( ! fileHandle ) {
272+ throw new ENOENT ( oldPath )
273+ }
274+
275+ // Strategy 1: Native move() — zero-copy rename, supported in Chrome and Safari OPFS.
276+ // Always pass (directory, newName) form — Safari doesn't support the move(newName) shorthand.
277+ if ( typeof fileHandle . move === 'function' ) {
278+ const newDir = oldFolder === newFolder ? oldDir : await this . getDirectoryByPath ( newFolder )
279+ await fileHandle . move ( newDir , newName )
280+ return
281+ }
282+
283+ // Strategy 2: Streaming copy — read in chunks, write via stream. Never loads entire file.
284+ const CHUNK_SIZE = 1024 * 1024
285+ const file = await fileHandle . getFile ( )
286+ const writable = await this . createWritableStream ( newPath )
287+ let offset = 0
288+ while ( offset < file . size ) {
289+ const end = Math . min ( offset + CHUNK_SIZE , file . size )
290+ const blob = file . slice ( offset , end )
291+ const chunk = new Uint8Array ( await blob . arrayBuffer ( ) )
292+ await writable . write ( chunk )
293+ offset = end
294+ }
295+ await writable . close ( )
296+ await this . rm ( oldPath )
297+ }
298+
266299 /**
267300 * Symlinks are not supported in the current implementation.
268301 * @throws Error: symlinks are not supported.
@@ -279,6 +312,44 @@ export class FileSystemAccessApiFsClient implements FsClient {
279312 throw new Error ( 'Symlinks are not supported.' )
280313 }
281314
315+ public async createWritableStream ( path : string ) : Promise < WritableStreamHandle > {
316+ const { folderPath, leafSegment } = this . getFolderPathAndLeafSegment ( path )
317+ const targetDir = await this . getDirectoryByPath ( folderPath )
318+
319+ const fileHandle = await targetDir . getFileHandle ( leafSegment , { create : true } )
320+ const writable = await fileHandle . createWritable ( )
321+
322+ return {
323+ write : async ( data : Uint8Array ) => {
324+ // FileSystemWritableFileStream.write() may write the entire underlying
325+ // ArrayBuffer instead of just the TypedArray view when byteOffset > 0.
326+ // This happens with Buffer.slice() which shares the backing memory.
327+ // Create a clean copy when the view doesn't cover the full buffer.
328+ if ( data . byteOffset !== 0 || data . buffer . byteLength !== data . byteLength ) {
329+ data = new Uint8Array ( data )
330+ }
331+ await writable . write ( data )
332+ } ,
333+ close : async ( ) => {
334+ await writable . close ( )
335+ }
336+ }
337+ }
338+
339+ public async readFileSlice ( path : string , start : number , end : number ) : Promise < Uint8Array > {
340+ const { folderPath, leafSegment } = this . getFolderPathAndLeafSegment ( path )
341+ const targetDir = await this . getDirectoryByPath ( folderPath )
342+
343+ const fileHandle = await this . getEntry < 'file' > ( targetDir , leafSegment , 'file' )
344+ if ( ! fileHandle ) {
345+ throw new ENOENT ( path )
346+ }
347+
348+ const file = await fileHandle . getFile ( )
349+ const blob = file . slice ( start , end )
350+ return new Uint8Array ( await blob . arrayBuffer ( ) )
351+ }
352+
282353 /**
283354 * Return true if a entry exists, false if it doesn't exist.
284355 * Rethrows errors that aren't related to entry existance.
@@ -388,13 +459,14 @@ export class FileSystemAccessApiFsClient implements FsClient {
388459
389460 if ( this . options . useSyncAccessHandle ) {
390461 const accessHandle = await fileHandle . createSyncAccessHandle ( )
391- const dataArray = typeof data === 'string' ? this . textEncoder . encode ( data ) : data
462+ const dataArray = typeof data === 'string' ? this . textEncoder . encode ( data ) : new Uint8Array ( data )
392463 accessHandle . write ( dataArray . buffer as ArrayBuffer , { at : 0 } )
393464 await accessHandle . flush ( )
394465 await accessHandle . close ( )
395466 } else {
396467 const writable = await fileHandle . createWritable ( )
397- await writable . write ( typeof data === 'string' ? data : data . buffer as ArrayBuffer )
468+ const writeData = typeof data === 'string' ? data : new Uint8Array ( data )
469+ await writable . write ( writeData )
398470 await writable . close ( )
399471 }
400472 } , 'writeFile' , name )
0 commit comments