Skip to content

Commit 71695c7

Browse files
working opfs move_dir
1 parent 5bc0d76 commit 71695c7

4 files changed

Lines changed: 239 additions & 96 deletions

File tree

src/fs_impl/localstorage.ts

Lines changed: 84 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -77,94 +77,107 @@ export class LocalStorageFS extends AbstractFileSystem {
7777
localStorage.setItem("fs", JSON.stringify(state));
7878
}
7979

80-
async move_dir_direct(src: string, dest: string, no_overwrite: boolean, move_inside: boolean) {
81-
const state = JSON.parse(localStorage.getItem("fs"));
82-
83-
// using unix style rules, i.e
84-
85-
// mv dir1 dir2 -> rename dir1 to dir2, or move dir1 into dir2 if dir2 already exists (THIS IS WHEN MOVE_INSIDE IS FALSE)
86-
// overwrite any files in the destination directory if they exist in the source directory if no_overwrite is false
87-
// and of course move across any files from the source directory to the destination directory and leave any only in the destination directory alone
88-
89-
// mv dir1 dir2/ -> move dir1 into dir2 (dir2 must exist, dir1 must not exist in dir2) (THIS IS WHEN MOVE_INSIDE IS TRUE, THERE WILL NOT BE A TRAILING / IN THE DESTINATION PATH)
90-
91-
// split path into parts, if root, use single empty string to avoid doubling
92-
const src_parts = src === this._root ? [""] : src.split("/");
93-
const dest_parts = dest.split("/");
94-
95-
// get directory for each part inside the previous one
96-
let current_dir = state;
97-
let current_dir_parent = null;
98-
for (const part of src_parts) {
99-
if (!current_dir[part]) {
100-
throw new PathNotFoundError(src);
80+
async move_dir_direct(src: string, dest: string, move_inside: boolean) {
81+
// (yes, this was ai generated, i just wanted to bring this very outdated fs_impl up to par with opfs. although its idea to make a helper was good, i should have done that years ago)
82+
83+
const state = JSON.parse(localStorage.getItem("fs") || "{}");
84+
85+
// Helper to traverse to a path and return the parent and the target key
86+
// This allows us to modify the parent (delete/assign) later
87+
const get_node = (path: string) => {
88+
const parts = path.split("/").filter(p => p.length > 0);
89+
const basename = parts[parts.length - 1];
90+
let current = state;
91+
92+
// Traverse to parent
93+
for (let i = 0; i < parts.length - 1; i++) {
94+
const part = parts[i];
95+
if (!current[part] || typeof current[part] !== "object") {
96+
throw new Error(`Path not found: ${path}`); // Simulates Linux "No such file or directory"
97+
}
98+
current = current[part];
10199
}
102-
current_dir_parent = current_dir;
103-
current_dir = current_dir[part];
104-
}
105-
106-
// check if source is a directory
107-
if (typeof current_dir !== "object") {
108-
throw new PathNotFoundError(src);
109-
}
110100

111-
// get directory for each part inside the previous one
112-
let dest_current_dir = state;
113-
//let dest_current_dir_parent = null;
114-
for (const part of dest_parts) {
115-
if (!dest_current_dir[part]) {
116-
// if this is the last part, create the directory, otherwise throw an error
117-
// TODO: is this correct? it acts correct, but is it too lax?
118-
if (part === dest_parts[dest_parts.length - 1]) {
119-
dest_current_dir[part] = {};
120-
} else {
121-
throw new PathNotFoundError(dest);
122-
}
101+
return { parent: current, basename: basename, value: current[basename] };
102+
};
103+
104+
// 1. Resolve Source
105+
// We need the parent so we can 'delete' the entry later
106+
const src_node = get_node(src);
107+
if (!src_node.value) throw new PathNotFoundError(src);
108+
if (typeof src_node.value !== "object") throw new PathNotFoundError(src);
109+
110+
// 2. Resolve Destination Parent
111+
// Unlike your original code, we do NOT auto-create the destination path.
112+
// Linux 'mv' fails if you try to move to 'a/b/c' and 'a/b' doesn't exist.
113+
const dest_parts = dest.split("/").filter(p => p.length > 0);
114+
const dest_basename = dest_parts[dest_parts.length - 1];
115+
116+
// Check if the generic destination path exists (e.g. is 'dest' already there?)
117+
let dest_exists = false;
118+
let dest_is_dir = false;
119+
let dest_parent_obj = state; // Default to root
120+
121+
// Traverse to the parent of the destination
122+
for (let i = 0; i < dest_parts.length - 1; i++) {
123+
const part = dest_parts[i];
124+
if (!dest_parent_obj[part] || typeof dest_parent_obj[part] !== "object") {
125+
throw new Error(`Destination parent path not found: ${dest}`);
123126
}
124-
//dest_current_dir_parent = dest_current_dir;
125-
dest_current_dir = dest_current_dir[part];
127+
dest_parent_obj = dest_parent_obj[part];
126128
}
127129

128-
// check if destination is a directory
129-
if (typeof dest_current_dir !== "object") {
130-
throw new PathNotFoundError(dest);
130+
// Check the actual destination node
131+
if (dest_parent_obj[dest_basename]) {
132+
dest_exists = true;
133+
dest_is_dir = typeof dest_parent_obj[dest_basename] === "object";
131134
}
132135

133-
// if we have equivalent paths, do nothing (so we don't accidentally delete the directory when calling delete after move)
134-
if (src === dest) {
135-
console.warn("source and destination are the same");
136-
return;
137-
}
136+
// 3. Apply Logic (Rename vs Move Into)
137+
let final_parent;
138+
let final_name;
138139

139-
// TODO: significant fixes required! moving directories is just a mess
140-
// TODO: need to consolidate exactly when we should be merging directories. its not exactly clear and chatgpt contradicts itself when asking for a formal definition!
140+
if (move_inside || (dest_exists && dest_is_dir)) {
141+
// RULE: Move 'src' INTO 'dest'
141142

142-
if (move_inside) {
143-
// if moving inside, check that the directory named the same as the source does not exist in the destination
144-
if (dest_current_dir[src_parts[src_parts.length - 1]]) {
145-
throw new Error(`Directory already exists in destination: ${dest}`);
143+
if (!dest_exists) {
144+
// "mv dir1 dir2/" but dir2 missing -> Error
145+
throw new Error(`Destination directory not found: ${dest}`);
146146
}
147147

148-
// move directory inside destination
149-
dest_current_dir[src_parts[src_parts.length - 1]] = current_dir;
150-
151-
// delete source directory
152-
delete current_dir_parent[src_parts[src_parts.length - 1]];
148+
// Our new parent is the destination folder itself
149+
final_parent = dest_parent_obj[dest_basename];
150+
final_name = src_node.basename; // Keep original name
153151
} else {
154-
// not moving inside, so merge files and directories from source into destination
155-
for (const key of Object.keys(current_dir)) {
156-
if (dest_current_dir[key] && no_overwrite) {
157-
throw new Error(`File or directory already exists in destination: ${dest}`);
158-
}
152+
// RULE: Rename 'src' TO 'dest'
159153

160-
dest_current_dir[key] = current_dir[key];
154+
if (dest_exists && !dest_is_dir) {
155+
// Trying to overwrite a file with a directory -> Error
156+
throw new Error(`Cannot overwrite non-directory '${dest}' with directory.`);
161157
}
162158

163-
// delete source directory
164-
delete current_dir_parent[src_parts[src_parts.length - 1]];
159+
// Our new parent is the destination's parent
160+
final_parent = dest_parent_obj;
161+
final_name = dest_basename; // New name
165162
}
166163

167-
// save state
164+
// 4. Collision Check (Strict Linux: No Merging)
165+
if (final_parent[final_name]) {
166+
// In Linux, 'mv' fails if the target directory is not empty.
167+
// Since we are moving a directory, we strictly fail here.
168+
throw new Error(`Directory not empty: ${final_name} already exists in destination.`);
169+
}
170+
171+
// 5. Execute Move (Atomic Reference Change)
172+
// This is the beauty of LocalStorage/JSON: No recursive copy needed.
173+
// Just point the new key to the old object.
174+
175+
final_parent[final_name] = src_node.value;
176+
177+
// 6. Delete Source
178+
delete src_node.parent[src_node.basename];
179+
180+
// 7. Save State
168181
localStorage.setItem("fs", JSON.stringify(state));
169182
}
170183

src/fs_impl/opfs.ts

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import {AbstractFileSystem, NonRecursiveDirectoryError, PathNotFoundError} from "../kernel/filesystem";
1+
import {
2+
AbstractFileSystem,
3+
MoveDestinationDirectoryNotEmptyError,
4+
NonRecursiveDirectoryError,
5+
PathNotFoundError
6+
} from "../kernel/filesystem";
27

38
export class OPFSFileSystem extends AbstractFileSystem {
49
private _opfs_handle: FileSystemDirectoryHandle | null = null;
@@ -163,9 +168,126 @@ export class OPFSFileSystem extends AbstractFileSystem {
163168
localStorage.setItem("fs_readonly_paths", JSON.stringify(readonly_list));
164169
}
165170

166-
async move_dir_direct(src: string, dest: string, no_overwrite: boolean, move_inside: boolean) {
167-
// TODO: implement
168-
return Promise.resolve(undefined);
171+
async move_dir_direct(src: string, dest: string, force_move_inside: boolean) {
172+
const root = this.get_root_handle();
173+
174+
// using unix style rules, i.e
175+
// mv dir1 dir2 ->
176+
// (if dir2 exists or force_move_inside) move dir1 into dir2
177+
// (if dir2 doesn't exist) rename dir1 to dir2
178+
// fail if dir2 exists and dir2/dir1 already exists
179+
180+
const src_parts = src.split("/").filter(part => part.length > 0);
181+
const dest_parts = dest.split("/").filter(part => part.length > 0);
182+
183+
const src_basename = src_parts[src_parts.length - 1];
184+
const dest_basename = dest_parts[dest_parts.length - 1];
185+
186+
// get handle for source's parent and source directory
187+
let src_parent_handle = root;
188+
for (let i = 0; i < src_parts.length - 1; i++) {
189+
try {
190+
src_parent_handle = await src_parent_handle.getDirectoryHandle(src_parts[i]);
191+
} catch (err) {
192+
if (err instanceof DOMException && err.name === "NotFoundError") {
193+
throw new PathNotFoundError(src);
194+
}
195+
throw err;
196+
}
197+
}
198+
199+
let src_handle: FileSystemDirectoryHandle;
200+
try {
201+
src_handle = await src_parent_handle.getDirectoryHandle(src_basename);
202+
} catch (err) {
203+
if (err instanceof DOMException && err.name === "NotFoundError") {
204+
throw new PathNotFoundError(src);
205+
}
206+
throw err;
207+
}
208+
209+
// get handle for destination's parent and try to get destination directory (but not an error yet if it doesn't exist)
210+
let dest_parent_handle = root;
211+
for (let i = 0; i < dest_parts.length - 1; i++) {
212+
try {
213+
dest_parent_handle = await dest_parent_handle.getDirectoryHandle(dest_parts[i]);
214+
} catch (err) {
215+
if (err instanceof DOMException && err.name === "NotFoundError") {
216+
throw new PathNotFoundError(dest);
217+
}
218+
throw err;
219+
}
220+
}
221+
222+
let dest_handle: FileSystemDirectoryHandle | null = null;
223+
try {
224+
dest_handle = await dest_parent_handle.getDirectoryHandle(dest_basename);
225+
} catch (err) {
226+
if (err instanceof DOMException && err.name !== "NotFoundError") {
227+
throw err;
228+
}
229+
}
230+
231+
// apply the rules to determine final destination
232+
let final_dest_parent_handle: FileSystemDirectoryHandle;
233+
let final_dest_name: string;
234+
235+
if (dest_handle || force_move_inside) {
236+
// if destination already exists or force_move_inside is true, move source inside destination
237+
238+
if (!dest_handle) {
239+
throw new PathNotFoundError(dest);
240+
}
241+
242+
final_dest_parent_handle = dest_handle;
243+
final_dest_name = src_basename;
244+
} else {
245+
// rename source to destination
246+
247+
final_dest_parent_handle = dest_parent_handle;
248+
final_dest_name = dest_basename;
249+
}
250+
251+
// ensure destination is empty
252+
try {
253+
await final_dest_parent_handle.getDirectoryHandle(final_dest_name);
254+
throw new MoveDestinationDirectoryNotEmptyError(dest);
255+
} catch (err) {
256+
if (err instanceof DOMException && err.name !== "NotFoundError") {
257+
throw err;
258+
}
259+
}
260+
261+
// perform move, first check if the browser supports handle.move, and if not recursively copy and delete
262+
if ("move" in src_handle) {
263+
// @ts-ignore - not part of spec yet
264+
await src_handle.move(final_dest_parent_handle, final_dest_name);
265+
} else {
266+
// copy recursively
267+
const new_dest_handle = await final_dest_parent_handle.getDirectoryHandle(final_dest_name, { create: true });
268+
await this.#copy_directory_recursive(src_handle, new_dest_handle);
269+
270+
// delete source directory
271+
await src_parent_handle.removeEntry(src_basename, { recursive: true });
272+
}
273+
}
274+
275+
async #copy_directory_recursive(src_handle: FileSystemDirectoryHandle, dest_handle: FileSystemDirectoryHandle) {
276+
for await (const [name, handle] of src_handle.entries()) {
277+
if (handle.kind === "file") {
278+
const file_handle = await src_handle.getFileHandle(name);
279+
const file = await file_handle.getFile();
280+
const array_buffer = await file.arrayBuffer();
281+
const dest_file_handle = await dest_handle.getFileHandle(name, { create: true });
282+
const writable = await dest_file_handle.createWritable();
283+
await writable.write(array_buffer);
284+
await writable.close();
285+
} else if (handle.kind === "directory") {
286+
const src_subdir_handle = await src_handle.getDirectoryHandle(name);
287+
const dest_subdir_handle = await dest_handle.getDirectoryHandle(name, { create: true });
288+
await this.#copy_directory_recursive(src_subdir_handle, dest_subdir_handle);
289+
}
290+
}
169291
}
170292

171293
async read_file_direct(path: string, as_uint: boolean) {

src/kernel/filesystem.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export class NonRecursiveDirectoryError extends Error {
1010
}
1111
}
1212

13+
export class MoveDestinationDirectoryNotEmptyError extends Error {
14+
constructor(path: string) {
15+
super(`Destination directory is not empty: ${path}`);
16+
}
17+
}
18+
1319
export class ReadOnlyError extends Error {
1420
constructor(path: string) {
1521
super(`Path is read-only: ${path}`);
@@ -54,7 +60,7 @@ export interface UserspaceFileSystem {
5460
list_dir(path: string, dirs_first?: boolean): Promise<string[]>;
5561
make_dir(path: string): Promise<void>;
5662
delete_dir(path: string, recursive?: boolean): Promise<void>;
57-
move_dir(src: string, dest: string, no_overwrite?: boolean, move_inside?: boolean): Promise<void>;
63+
move_dir(src: string, dest: string, force_move_inside?: boolean): Promise<void>;
5864
set_readonly(path: string, readonly: boolean): Promise<void>;
5965
is_readonly(path: string): Promise<boolean>;
6066
exists(path: string): Promise<boolean>;
@@ -252,7 +258,7 @@ export abstract class AbstractFileSystem {
252258
// (recursive)
253259
abstract make_dir(path: string): Promise<void>;
254260
abstract delete_dir_direct(path: string, recursive: boolean): Promise<void>;
255-
abstract move_dir_direct(src: string, dest: string, no_overwrite: boolean, move_inside: boolean): Promise<void>;
261+
abstract move_dir_direct(src: string, dest: string, force_move_inside: boolean): Promise<void>;
256262

257263
async delete_dir(path: string, recursive = false): Promise<void> {
258264
await this.delete_dir_direct(path, recursive);
@@ -261,8 +267,8 @@ export abstract class AbstractFileSystem {
261267
this.purge_cache(true);
262268
}
263269

264-
async move_dir(src: string, dest: string, no_overwrite = false, move_inside = false): Promise<void> {
265-
await this.move_dir_direct(src, dest, no_overwrite, move_inside);
270+
async move_dir(src: string, dest: string, force_move_inside = false): Promise<void> {
271+
await this.move_dir_direct(src, dest, force_move_inside);
266272

267273
// smart purge cache
268274
this.purge_cache(true);
@@ -509,8 +515,8 @@ export abstract class AbstractFileSystem {
509515
enumerable: true
510516
},
511517
move_dir: {
512-
value: (src: string, dest: string, no_overwrite?: boolean, move_inside?: boolean) => {
513-
return self.move_dir(check_path(src), check_path(dest), no_overwrite, move_inside);
518+
value: (src: string, dest: string, move_inside?: boolean) => {
519+
return self.move_dir(check_path(src), check_path(dest), move_inside);
514520
},
515521
enumerable: true
516522
},

0 commit comments

Comments
 (0)