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:
parent
56c3353f7b
commit
96262a171e
3 changed files with 89 additions and 23 deletions
|
|
@ -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) ──
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -303,13 +303,48 @@
|
|||
} 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) {
|
||||
if (state.source === 'server') {
|
||||
var url = joinUrl(parentDir, name);
|
||||
if (!url.endsWith('/')) url += '/';
|
||||
var resp = await fetch(url, {
|
||||
|
|
@ -318,9 +353,16 @@
|
|||
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) {
|
||||
if (state.source === 'server') {
|
||||
var resp = await fetch(joinUrl(parentDir, name), {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
|
|
@ -328,6 +370,15 @@
|
|||
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
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue