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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-01 10:46:51 -05:00
parent 56c3353f7b
commit 96262a171e
3 changed files with 89 additions and 23 deletions

View file

@ -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) ──

View file

@ -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();

View file

@ -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
};
})();