From 96262a171e82287d99155dcb35b903a6fadc81eb Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 1 Jun 2026 10:46:51 -0500 Subject: [PATCH] feat(browse): full create/edit/rename/delete in local-directory (offline) mode Local folders are picked read-only, so create/edit/rename/delete were either disabled or would fail. Now offline mode supports the same CRUD as server mode, bounded only by what the filesystem grants: - upload.js: ensureWritable() escalates the picked root to readwrite via the FS-Access permission prompt on the first mutation (one prompt, then granted for the session; requires the user gesture every caller has). makeDir/makeFile gain FS-API branches (getDirectoryHandle/getFileHandle {create:true} + createWritable) resolved through handleForDir; removeNode and renameNode (already FS-API) now escalate first. - preview-markdown.js: the markdown editor's save escalates before createWritable, so editing a local .md persists. - events.js: New folder / New markdown file menu items are enabled whenever there's a writable target (server, or a picked local folder) via canCreateHere(); rename/delete were already gated by canMutate (FS-API). The aggregator party-picker stays server-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/events.js | 16 ++++-- browse/js/preview-markdown.js | 4 ++ browse/js/upload.js | 92 +++++++++++++++++++++++++++-------- 3 files changed, 89 insertions(+), 23 deletions(-) diff --git a/browse/js/events.js b/browse/js/events.js index c2c620e..10952c2 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -695,6 +695,7 @@ // aggregatorRoot + the picker). Covers right-clicking a party row // shown in an aggregator listing so "New folder" doesn't 409. function rewriteAggregatorPath(parentDir) { + if (state.source !== 'server') return null; var segs = (parentDir || '').replace(/^\/+|\/+$/g, '').split('/'); if (segs.length < 3 || !segs[0]) return null; var slot = segs[1].toLowerCase(); @@ -1005,6 +1006,13 @@ // Items are kept VISIBLE but DISABLED when they don't apply, so // every menu has the same shape regardless of what the user // right-clicked. Predictable position = muscle memory. + // canCreateHere — whether New folder/file has a writable target: the + // server (ACL decides the rest) or a picked local folder (the + // filesystem permission decides, escalated on first write). + function canCreateHere() { + return state.source === 'server' || (state.source === 'fs' && !!state.rootHandle); + } + function buildTreeRowMenu(ctx) { var serverMode = state.source === 'server'; var canMutate = function (c) { @@ -1064,12 +1072,12 @@ // ── Create new (in the row's parent folder) ── { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function (c) { createInside(c.node, 'markdown'); } }, { separator: true }, @@ -1278,12 +1286,12 @@ return [ { label: 'New folder', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'folder'); } }, { label: 'New markdown file', - disabled: !serverMode, + disabled: !canCreateHere(), action: function () { createInDir(state.currentPath || '/', 'markdown'); } }, // ── Create Transmittal folder (staging/ scope only) ── diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index faad30a..b9854b0 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -275,6 +275,10 @@ async function saveContent(node, content) { if (node.handle && typeof node.handle.createWritable === 'function') { + // Local folders are picked read-only; escalate to readwrite on + // first save (one FS-Access prompt, then granted for the session). + var up = window.app.modules.upload; + if (up && up.ensureWritable) await up.ensureWritable(); var writable = await node.handle.createWritable(); await writable.write(content); await writable.close(); diff --git a/browse/js/upload.js b/browse/js/upload.js index fa52c7f..7493788 100644 --- a/browse/js/upload.js +++ b/browse/js/upload.js @@ -303,31 +303,82 @@ } catch (_e) { /* refresh is best-effort */ } } + // ── Write-permission escalation (FS-API mode) ────────────────────────── + // The local folder is picked read-only (showDirectoryPicker mode:read) + // so browsing never prompts. The first mutation escalates to readwrite + // via the FS-Access permission prompt; granting on the picked root + // covers every descendant handle. Must run under a user gesture — every + // caller is reached from a click/menu action. No-op in server mode or + // on browsers without the permission API. + async function ensureWritable() { + if (state.source !== 'fs') return; + var root = state.rootHandle; + if (!root || typeof root.requestPermission !== 'function') return; + var opts = { mode: 'readwrite' }; + if ((await root.queryPermission(opts)) === 'granted') return; + if ((await root.requestPermission(opts)) === 'granted') return; + throw new Error('Write permission denied — grant edit access to the folder when prompted.'); + } + + // handleForDir resolves a directory PATH (FS-API mode) to its + // FileSystemDirectoryHandle: the picked root for the current scope, + // else the matching expanded node's handle. Returns null if unknown. + function handleForDir(dirPath) { + var tree = window.app.modules.tree; + if (!dirPath.endsWith('/')) dirPath += '/'; + if (dirPath === state.currentPath) return state.rootHandle; + var noSlash = dirPath.replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir || !n.handle) return; + if (tree && tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; + }); + return hit ? hit.handle : null; + } + // ── Create-new helpers ──────────────────────────────────────────────── - // Both go through the same server endpoints used by upload: PUT - // for files (with an empty/template body) and POST + X-ZDDC-Op: - // mkdir for directories. Client-side enforcement is best-effort; - // the server's ACL is the source of truth. + // Server mode: PUT for files (empty/template body) and POST + + // X-ZDDC-Op: mkdir for directories; the server's ACL is the source of + // truth. FS-API mode: create directly in the picked tree via + // getDirectoryHandle/getFileHandle({create:true}) + createWritable — + // limited only by the filesystem permission the user granted. async function makeDir(parentDir, name) { - var url = joinUrl(parentDir, name); - if (!url.endsWith('/')) url += '/'; - var resp = await fetch(url, { - method: 'POST', - credentials: 'same-origin', - headers: { 'X-ZDDC-Op': 'mkdir' } - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var url = joinUrl(parentDir, name); + if (!url.endsWith('/')) url += '/'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-ZDDC-Op': 'mkdir' } + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + await parent.getDirectoryHandle(name, { create: true }); } async function makeFile(parentDir, name, body, contentType) { - var resp = await fetch(joinUrl(parentDir, name), { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': contentType || 'application/octet-stream' }, - body: body == null ? '' : body - }); - if (!resp.ok) throw new Error('HTTP ' + resp.status); + if (state.source === 'server') { + var resp = await fetch(joinUrl(parentDir, name), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + body: body == null ? '' : body + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + var parent = handleForDir(parentDir); + if (!parent) throw new Error('No directory handle for ' + parentDir); + await ensureWritable(); + var fh = await parent.getFileHandle(name, { create: true }); + var w = await fh.createWritable(); + await w.write(body == null ? '' : body); + await w.close(); } // ── Delete + rename ───────────────────────────────────────────────────── @@ -392,6 +443,7 @@ // legacy removeEntry() lives on the PARENT directory handle // and we don't retain ancestor handles). if (node.handle && typeof node.handle.remove === 'function') { + await ensureWritable(); await node.handle.remove({ recursive: !!node.isDir }); return; } @@ -432,6 +484,7 @@ } // FS-API: handle.move(newName) is Chromium 110+. if (node.handle && typeof node.handle.move === 'function') { + await ensureWritable(); await node.handle.move(newName); return; } @@ -546,6 +599,7 @@ removeNode: removeNode, renameNode: renameNode, canMutate: canMutate, + ensureWritable: ensureWritable, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })();