feat(browse): editors honor server-side write authority + don't steal focus

Listing JSON gains a writable bool per file row, computed by running
the policy decider with ActionWrite against the parent-dir chain
(with the same admin-bypass branch the file API uses). Cost: one
extra decider call per file in the listing, sharing the parent
chain so the cascade walk is amortized.

Browse loader stores writable on every tree node. The markdown and
YAML editors read it and gate their canSave + initial mount:

- !writable markdown → Toast UI Viewer (rendered, no edit toolbar,
  no caret). Banner above explains why save is disabled.
- !writable YAML → CodeMirror readOnly:'nocursor' (selection for
  copy, no caret). Banner above explains why save is disabled.

Both editors gain autofocus:false so keyboard nav in the browse
tree doesn't divert into the editor — arrow keys keep moving through
files and folders without the caret jumping. User clicks (or tabs)
into the editor when they actually want to type.

.zddc files already route through preview-yaml's isZddcFile path;
bare .zddc (no ext) matches because that function checks the
literal name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-18 09:42:36 -05:00
parent ded9ff7883
commit 55328c8c28
8 changed files with 137 additions and 30 deletions

View file

@ -40,3 +40,19 @@ body {
.status-bar--error { color: #b00020; } .status-bar--error { color: #b00020; }
.status-bar--info { color: var(--primary); } .status-bar--info { color: var(--primary); }
/* Read-only banners surfaced by preview-markdown.js / preview-yaml.js
when the listing's `writable` bit was false. Inline at the top of
the editor host so the user can't miss it; muted styling so it
doesn't fight the editor chrome. */
.md-readonly-banner,
.yaml-readonly-banner {
background: rgba(220, 53, 69, 0.10);
color: var(--text);
border-bottom: 1px solid rgba(220, 53, 69, 0.35);
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
}

View file

@ -37,6 +37,11 @@
modTime: e.mod_time ? new Date(e.mod_time) : null, modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(name), ext: e.is_dir ? '' : splitExt(name),
url: e.url || null, url: e.url || null,
// Server-computed write authority — true if the policy
// decider would allow a PUT for the calling principal.
// Absent / false means "save will 403"; preview editors
// read this to mount in read-only mode.
writable: !!e.writable,
// FS-API specific (null in server mode): // FS-API specific (null in server mode):
handle: null handle: null
}; };

View file

@ -304,6 +304,11 @@
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
// Server-computed authority gate. The listing's `writable`
// bit reflects what a PUT would do — false here means the
// file API would 403 the save, so we mount in read-only
// mode rather than letting the user type and lose changes.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;
@ -510,16 +515,41 @@
var bodyText = initialParsed.body; var bodyText = initialParsed.body;
var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText)); var initialHash = await hashContent(assembleContent(fmTextarea.value, bodyText));
var editor = new window.toastui.Editor({ var writableMode = canSave(node);
// autofocus:false keeps the keyboard caret in the tree pane —
// arrow-key nav can continue through markdown files without
// diverting into the editor. The user clicks into the editor
// (or tabs to it) when they actually want to type.
var editorOpts = {
el: editorHost, el: editorHost,
height: '100%', height: '100%',
usageStatistics: false,
autofocus: false,
initialValue: bodyText,
};
var editor;
if (!writableMode) {
// Read-only mount uses Toast UI's Viewer (rendered markdown,
// no edit toolbar, no caret). Clear visual signal that the
// file isn't editable — much better than letting the user
// type into an Editor whose Save button is disabled.
editor = window.toastui.Editor.factory(Object.assign({}, editorOpts, {
viewer: true,
}));
// Read-only banner above the editor explains why.
var roBanner = document.createElement('div');
roBanner.className = 'md-readonly-banner';
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
+ ' Read-only — you don\'t have write access to this file.'
+ ' Save is disabled; changes won\'t persist.';
editorHost.insertBefore(roBanner, editorHost.firstChild);
} else {
editor = new window.toastui.Editor(Object.assign({}, editorOpts, {
// WYSIWYG by default — most users want the rendered view // WYSIWYG by default — most users want the rendered view
// out of the gate; the markdown/WYSIWYG toggle in the // out of the gate; the markdown/WYSIWYG toggle in the
// Toast UI toolbar still flips to source mode in one click. // Toast UI toolbar still flips to source mode in one click.
initialEditType: 'wysiwyg', initialEditType: 'wysiwyg',
previewStyle: 'vertical', previewStyle: 'vertical',
initialValue: bodyText,
usageStatistics: false,
toolbarItems: [ toolbarItems: [
['heading', 'bold', 'italic', 'strike'], ['heading', 'bold', 'italic', 'strike'],
['hr', 'quote'], ['hr', 'quote'],
@ -527,7 +557,8 @@
['table', 'image', 'link'], ['table', 'image', 'link'],
['code', 'codeblock'] ['code', 'codeblock']
] ]
}); }));
}
currentInstance = { currentInstance = {
editor: editor, editor: editor,
@ -539,8 +570,7 @@
fmEl: fmTextarea fmEl: fmTextarea
}; };
var writable = canSave(node); if (!writableMode) {
if (!writable) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
fmTextarea.readOnly = true; fmTextarea.readOnly = true;

View file

@ -77,6 +77,10 @@
function canSave(node) { function canSave(node) {
if (isZipMemberNode(node)) return false; if (isZipMemberNode(node)) return false;
if (node.virtual) return false; if (node.virtual) return false;
// Server-computed authority gate. Mirrors the markdown editor's
// check — listing's `writable` bit is the same decision the
// file API would reach on PUT.
if (node.url && window.app.state.source === 'server' && !node.writable) return false;
if (node.handle && typeof node.handle.createWritable === 'function') return true; if (node.handle && typeof node.handle.createWritable === 'function') return true;
if (node.url && window.app.state.source === 'server') return true; if (node.url && window.app.state.source === 'server') return true;
return false; return false;
@ -454,6 +458,7 @@
window.CodeMirror.__zddcYamlLinterReady = true; window.CodeMirror.__zddcYamlLinterReady = true;
} }
var writable = canSave(node);
var editor = window.CodeMirror(editorHost, { var editor = window.CodeMirror(editorHost, {
value: text, value: text,
mode: 'yaml', mode: 'yaml',
@ -463,7 +468,15 @@
indentWithTabs: false, indentWithTabs: false,
lineWrapping: false, lineWrapping: false,
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true } lint: { hasGutters: true },
// autofocus:false keeps the keyboard caret in the browse
// tree pane so arrow-key nav can continue through yaml /
// .zddc files without diverting into the editor. User
// clicks (or tabs) into the editor when they want to type.
autofocus: false,
// CodeMirror's "nocursor" mode is the truest read-only:
// selection allowed for copy, no caret, no edit affordances.
readOnly: !writable ? 'nocursor' : false,
}); });
// Stash the node on the editor so the lint helper can decide // Stash the node on the editor so the lint helper can decide
// whether to apply the .zddc schema layer. // whether to apply the .zddc schema layer.
@ -472,11 +485,15 @@
editor.performLint(); editor.performLint();
currentEditor = editor; currentEditor = editor;
var writable = canSave(node);
if (!writable) { if (!writable) {
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.title = 'Save not available — read-only source.'; saveBtn.title = 'Save not available — read-only source.';
editor.setOption('readOnly', true); // Read-only banner above the editor explains why.
var roBanner = document.createElement('div');
roBanner.className = 'yaml-readonly-banner';
roBanner.innerHTML = '<span aria-hidden="true">🔒</span>'
+ ' Read-only — you don\'t have write access to this file.';
editorHost.insertBefore(roBanner, editorHost.firstChild);
} }
var initialHash = await hashContent(text); var initialHash = await hashContent(text);

View file

@ -38,7 +38,7 @@ func safeJoin(fsRoot, relPath string) (string, bool) {
// The decider is queried per subdirectory; nil falls back to the internal // The decider is queried per subdirectory; nil falls back to the internal
// Go evaluator (policy.InternalDecider) for tests that don't wire up // Go evaluator (policy.InternalDecider) for tests that don't wire up
// an explicit decider. // an explicit decider.
func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden bool) ([]listing.FileInfo, error) { func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, userEmail, baseURL string, includeHidden, elevated bool) ([]listing.FileInfo, error) {
if decider == nil { if decider == nil {
decider = &policy.InternalDecider{} decider = &policy.InternalDecider{}
} }
@ -93,6 +93,16 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
declaredSet[strings.ToLower(name)] = true declaredSet[strings.ToLower(name)] = true
} }
// Parent-dir chain + active-admin status. Files in this directory
// inherit authorization from this chain, so we compute it once
// and reuse for every file entry's Writable bit. Subdirectories
// build their own chain (the child cascade can differ — e.g. a
// per-user fenced home).
parentChain, _ := zddc.EffectivePolicy(fsRoot, absDir)
principal := zddc.Principal{Email: userEmail, Elevated: elevated}
parentActiveAdmin := elevated && userEmail != "" &&
zddc.IsAdminForChain(parentChain, userEmail, false)
for _, entry := range entries { for _, entry := range entries {
name := entry.Name() name := entry.Name()
@ -162,6 +172,20 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
DisplayName: displayName, DisplayName: displayName,
Declared: declared, Declared: declared,
} }
// Writable surfaces whether THIS principal could PUT this file
// — same decision as the file API's authorizeAction would
// reach. Uses the parent-dir chain (computed once above);
// active-admin status short-circuits the per-file decider
// query when the principal already holds admin authority.
fileURL := baseURL + name
if parentActiveAdmin {
fi.Writable = true
} else {
allowed, _ := policy.AllowActionFromChainP(ctx, decider, parentChain, principal, fileURL, policy.ActionWrite)
if allowed {
fi.Writable = true
}
}
result = append(result, fi) result = append(result, fi)
} }

View file

@ -28,7 +28,7 @@ func TestListDirectory_VirtualUserHome_AppearsWhenMissing(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false) got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -56,7 +56,7 @@ func TestListDirectory_VirtualUserHome_SuppressedWhenRealExists(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false) got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "alice@example.com", "/Proj/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -74,7 +74,7 @@ func TestListDirectory_VirtualUserHome_AnonymousNoEntry(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false) got, err := ListDirectory(context.Background(), nil, root, "Proj/working", "" /* no viewer */, "/Proj/working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -92,7 +92,7 @@ func TestListDirectory_VirtualUserHome_OutsideWorkingNoEntry(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false) got, err := ListDirectory(context.Background(), nil, root, "Proj/staging", "alice@example.com", "/Proj/staging/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -113,7 +113,7 @@ func TestListDirectory_VirtualUserHome_DeepWorkingNoEntry(t *testing.T) {
got, err := ListDirectory(context.Background(), nil, root, got, err := ListDirectory(context.Background(), nil, root,
"Proj/working/alice@example.com", "alice@example.com", "Proj/working/alice@example.com", "alice@example.com",
"/Proj/working/alice@example.com/", false) "/Proj/working/alice@example.com/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -132,7 +132,7 @@ func TestListDirectory_VirtualUserHome_CaseFoldWorking(t *testing.T) {
} }
zddc.InvalidateCache(root) zddc.InvalidateCache(root)
got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false) got, err := ListDirectory(context.Background(), nil, root, "Proj/Working", "alice@example.com", "/Proj/Working/", false, false)
if err != nil { if err != nil {
t.Fatalf("list: %v", err) t.Fatalf("list: %v", err)
} }
@ -165,7 +165,7 @@ func TestListDirectory_CanonicalProjectFolder_EmptyWhenMissing(t *testing.T) {
for _, stage := range []string{"working", "staging", "reviewing", "archive"} { for _, stage := range []string{"working", "staging", "reviewing", "archive"} {
got, err := ListDirectory(context.Background(), nil, root, got, err := ListDirectory(context.Background(), nil, root,
"Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false) "Proj/"+stage, "alice@example.com", "/Proj/"+stage+"/", false, false)
if err != nil { if err != nil {
t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err) t.Errorf("ListDirectory(Proj/%s) on missing dir: err = %v, want nil", stage, err)
continue continue
@ -199,7 +199,7 @@ func TestListDirectory_NonCanonicalMissing_StillNotFound(t *testing.T) {
_, err := ListDirectory(context.Background(), nil, root, _, err := ListDirectory(context.Background(), nil, root,
"Proj/random-folder-that-doesnt-exist", "alice@example.com", "Proj/random-folder-that-doesnt-exist", "alice@example.com",
"/Proj/random-folder-that-doesnt-exist/", false) "/Proj/random-folder-that-doesnt-exist/", false, false)
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err) t.Errorf("ListDirectory on a non-canonical missing path: err = %v, want IsNotExist", err)
} }

View file

@ -117,7 +117,7 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
// used by ?zip and ?convert= elsewhere in the dispatcher. // used by ?zip and ?convert= elsewhere in the dispatcher.
includeHidden := r.URL.Query().Has("hidden") includeHidden := r.URL.Query().Has("hidden")
entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden) entries, err := appfs.ListDirectory(ctx, decider, cfg.Root, dirPath, email, baseURL, includeHidden, ElevatedFromContext(r))
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
http.Error(w, "Not Found", http.StatusNotFound) http.Error(w, "Not Found", http.StatusNotFound)

View file

@ -46,4 +46,19 @@ type FileInfo struct {
// page project names without a separate API. Empty when the // page project names without a separate API. Empty when the
// entry has no `.zddc` or its `.zddc` doesn't set `title:`. // entry has no `.zddc` or its `.zddc` doesn't set `title:`.
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
// Writable surfaces whether the calling principal has write
// authority on this entry — answered by running the policy decider
// with ActionWrite on the entry's path, with the same admin-bypass
// branch that the file API uses. Clients (notably the in-place
// markdown editor) read this to mount in read-only mode when a
// PUT would 403, instead of letting the user type then lose their
// changes on save.
//
// omitempty: the listing JSON omits the field when false so the
// wire payload doesn't bloat; clients should treat absence as
// false-or-unknown and gate writes accordingly. Read-only-by-
// default is the safer client-side fallback if the server forgets
// to populate it.
Writable bool `json:"writable,omitempty"`
} }