shell-fs.js (7212B)
1 // Writable filesystem for the web shell. The server's read-only tree 2 // (/api/fs, generated from site content) is the base layer; whatever the 3 // visitor creates, edits or deletes is kept as an overlay in localStorage and 4 // replayed on top of it, so their files survive reloads and new site content 5 // still shows up underneath. 6 // 7 // Overlay entries are keyed by absolute path: 8 // null deleted (whiteout over a base node) 9 // {d:1, m, f?:1} directory; f = fresh, replaces whatever the base had 10 // {c, m} file with content c 11 // m is the mtime in ms. 12 13 const STORE_KEY = "prime8.fs.v1"; 14 const QUOTA = 1 << 20; // chars of serialized overlay, well under localStorage limits 15 16 export const enc = new TextEncoder(); 17 18 // --- paths (same rules as internal/vfs) --- 19 20 export function cleanPath(p) { 21 const out = []; 22 for (const part of p.split("/")) { 23 if (part === "" || part === ".") continue; 24 if (part === "..") out.pop(); 25 else out.push(part); 26 } 27 return "/" + out.join("/"); 28 } 29 30 export function resolvePath(home, cwd, p) { 31 if (p === "" || p === "~") return home; 32 if (p.startsWith("~/")) p = home + p.slice(1); 33 else if (!p.startsWith("/")) p = cwd + "/" + p; 34 return cleanPath(p); 35 } 36 37 export function displayPath(home, abs) { 38 if (abs === home) return "~"; 39 if (abs.startsWith(home + "/")) return "~" + abs.slice(home.length); 40 return abs; 41 } 42 43 export function dirname(abs) { 44 const i = abs.lastIndexOf("/"); 45 return i <= 0 ? "/" : abs.slice(0, i); 46 } 47 48 export function basename(abs) { 49 return abs.slice(abs.lastIndexOf("/") + 1); 50 } 51 52 export function inside(abs, base) { 53 return abs === base || abs.startsWith(base === "/" ? "/" : base + "/"); 54 } 55 56 export function lookup(root, abs) { 57 let n = root; 58 for (const part of abs.split("/")) { 59 if (part === "") continue; 60 if (!n.dir) return null; 61 const c = n.children[part]; 62 if (!c) return null; 63 n = c; 64 } 65 return n; 66 } 67 68 export function sortedChildren(n) { 69 return Object.values(n.children || {}).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); 70 } 71 72 export function nodeSize(n) { 73 return n.dir ? 4096 : enc.encode(n.content).length; 74 } 75 76 function mkDir(name, mtime) { 77 return { name, dir: true, children: {}, mtime }; 78 } 79 80 function mkFile(name, content, mtime) { 81 return { name, dir: false, content, mtime }; 82 } 83 84 function cloneBase(n, mtime) { 85 if (!n.dir) return mkFile(n.name, n.content || "", mtime); 86 const d = mkDir(n.name, mtime); 87 for (const [k, c] of Object.entries(n.children || {})) d.children[k] = cloneBase(c, mtime); 88 return d; 89 } 90 91 function depth(p) { 92 return p.split("/").length; 93 } 94 95 function applyOverlay(root, entries) { 96 const keys = Object.keys(entries).sort((a, b) => depth(a) - depth(b) || (a < b ? -1 : 1)); 97 for (const k of keys) { 98 const parent = lookup(root, dirname(k)); 99 if (!parent || !parent.dir) continue; 100 const name = basename(k); 101 const v = entries[k]; 102 if (v === null) delete parent.children[name]; 103 else if (v.d) { 104 const cur = parent.children[name]; 105 if (v.f || !cur || !cur.dir) parent.children[name] = mkDir(name, v.m); 106 else cur.mtime = v.m; 107 } else parent.children[name] = mkFile(name, v.c, v.m); 108 } 109 } 110 111 export class VFS { 112 constructor(data) { 113 this.base = data.root; 114 this.user = data.user; 115 this.home = data.home; 116 const t = new Date(); 117 t.setHours(0, 0, 0, 0); 118 this.baseTime = t.getTime(); 119 this.load(); 120 } 121 122 load() { 123 let saved = ""; 124 try { 125 saved = window.localStorage.getItem(STORE_KEY) || ""; 126 } catch { 127 saved = ""; 128 } 129 this.saved = saved; 130 this.entries = {}; 131 try { 132 const parsed = saved ? JSON.parse(saved) : null; 133 if (parsed && typeof parsed.e === "object" && parsed.e) this.entries = parsed.e; 134 } catch { 135 this.entries = {}; 136 } 137 this.dirty = false; 138 this.rebuild(); 139 } 140 141 rebuild() { 142 this.root = cloneBase(this.base, this.baseTime); 143 applyOverlay(this.root, this.entries); 144 } 145 146 changes() { 147 return Object.keys(this.entries).length; 148 } 149 150 // commit persists the overlay. On quota failure it rolls back to the last 151 // saved state and returns false. 152 commit() { 153 if (!this.dirty) return true; 154 this.dirty = false; 155 const s = JSON.stringify({ v: 1, e: this.entries }); 156 let ok = s.length <= QUOTA; 157 if (ok) { 158 try { 159 window.localStorage.setItem(STORE_KEY, s); 160 } catch { 161 ok = false; 162 } 163 } 164 if (ok) { 165 this.saved = s; 166 return true; 167 } 168 this.load(); 169 return false; 170 } 171 172 reset() { 173 this.entries = {}; 174 try { 175 window.localStorage.removeItem(STORE_KEY); 176 } catch { 177 /* ignore */ 178 } 179 this.saved = ""; 180 this.dirty = false; 181 this.rebuild(); 182 } 183 184 // writable: the visitor owns their home and /tmp; everything else is root's. 185 writable(abs) { 186 return inside(abs, this.home) || inside(abs, "/tmp"); 187 } 188 189 owner(abs) { 190 return inside(abs, this.home) ? this.user : "root"; 191 } 192 193 inBase(abs) { 194 return lookup(this.base, abs) !== null; 195 } 196 197 get(abs) { 198 return lookup(this.root, abs); 199 } 200 201 // checkCreate reports why abs cannot be created/replaced/removed, or "". 202 checkCreate(abs) { 203 if (abs === "/") return "Permission denied"; 204 const parent = this.get(dirname(abs)); 205 if (!parent) return "No such file or directory"; 206 if (!parent.dir) return "Not a directory"; 207 if (!this.writable(dirname(abs)) || !this.writable(abs)) return "Permission denied"; 208 return ""; 209 } 210 211 mkdir(abs) { 212 const parent = this.get(dirname(abs)); 213 const name = basename(abs); 214 const t = Date.now(); 215 parent.children[name] = mkDir(name, t); 216 parent.mtime = t; 217 this.entries[abs] = this.inBase(abs) ? { d: 1, m: t, f: 1 } : { d: 1, m: t }; 218 this.dirty = true; 219 } 220 221 write(abs, content) { 222 const parent = this.get(dirname(abs)); 223 const name = basename(abs); 224 const t = Date.now(); 225 if (!parent.children[name]) parent.mtime = t; 226 parent.children[name] = mkFile(name, content, t); 227 this.entries[abs] = { c: content, m: t }; 228 this.dirty = true; 229 } 230 231 touch(abs) { 232 const n = this.get(abs); 233 const t = Date.now(); 234 n.mtime = t; 235 if (!n.dir) this.entries[abs] = { c: n.content, m: t }; 236 else if (this.entries[abs]) this.entries[abs].m = t; 237 this.dirty = true; 238 } 239 240 remove(abs) { 241 const parent = this.get(dirname(abs)); 242 delete parent.children[basename(abs)]; 243 parent.mtime = Date.now(); 244 const prefix = abs + "/"; 245 for (const k of Object.keys(this.entries)) if (k.startsWith(prefix)) delete this.entries[k]; 246 if (this.inBase(abs)) this.entries[abs] = null; 247 else delete this.entries[abs]; 248 this.dirty = true; 249 } 250 251 // copy materializes node (a whole subtree for a directory) at abs. 252 copy(node, abs) { 253 if (this.get(abs)) this.remove(abs); 254 if (!node.dir) { 255 this.write(abs, node.content); 256 return; 257 } 258 this.mkdir(abs); 259 for (const c of sortedChildren(node)) this.copy(c, abs + "/" + c.name); 260 } 261 262 count(n = this.root) { 263 if (!n.dir) return 1; 264 let total = 1; 265 for (const c of Object.values(n.children)) total += this.count(c); 266 return total; 267 } 268 } 269 270 export { STORE_KEY };