Compare commits

...

2 commits

Author SHA1 Message Date
127163dfa2 release: v0.0.13 lockstep
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 3s
Build + deploy releases / build-and-deploy (push) Successful in 8s
Build + deploy releases / notify-chart-prod (push) Successful in 1s
2026-05-03 20:21:06 -05:00
7caf3ecf3f fix(browse): listing fetch + row height + recursive expand/collapse
Three issues from initial v0.0.12 dev/prod testing:

  1. Online listings empty.
     directory.go was missing Vary: Accept on its responses, so
     browser/CDN cached the HTML response (the embedded browse.html)
     and served it again when browse's JS later fetched the same URL
     with Accept: application/json. JSON parse failed, autoDetect
     returned null, empty state showed. Adds Vary: Accept on both
     branches and changes browse.html cache-control to no-cache so
     deployed updates land immediately.

  2. Top-level folder rows tall, shrink as subtree expands.
     The .browse-table had flex:1 in a flex column. <table> in flex
     doesn't reliably distribute height across rows — with few rows,
     each row stretched. Wrap the table in a div with overflow:auto
     and drop flex:1 from the table itself.

  3. Recursive expand/collapse.
     Shift-click (or alt-click) on a folder now expand-all or
     collapse-all its subtree. Plain click still toggles just that
     folder. Implementation: tree.expandSubtree() walks BFS, loading
     each level's children in parallel, re-rendering between levels
     so the user sees progress. tree.collapseSubtree() recursively
     marks the subtree collapsed (children stay loaded for instant
     re-expand).
2026-05-03 20:20:54 -05:00
14 changed files with 299 additions and 96 deletions

View file

@ -5,6 +5,13 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
}
.browse-table-wrap {
flex: 1;
overflow: auto;
min-height: 0;
} }
.toolbar { .toolbar {
@ -53,7 +60,16 @@
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg); background: var(--bg);
flex: 1; /* No flex:1 tables don't reliably distribute extra height across
rows the way flex columns do. With few rows we'd get tall rows
that shrink as more children are loaded. The wrap div handles
scrolling instead. */
}
.browse-table tbody tr {
/* Pin rows to a deterministic height so table layout never
redistributes vertical space across them. */
line-height: 1.4;
} }
.browse-table thead th { .browse-table thead th {

View file

@ -80,21 +80,36 @@
} }
// Tree-row clicks (event delegation on tbody). // Tree-row clicks (event delegation on tbody).
// Click semantics on a folder row:
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody'); var tbody = document.getElementById('browseTbody');
if (tbody) { if (tbody) {
tbody.addEventListener('click', function (e) { tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row'); var row = e.target.closest('tr.tree-row');
if (!row) return; if (!row) return;
var isDir = row.dataset.isdir === 'true'; var isDir = row.dataset.isdir === 'true';
if (!isDir) { if (!isDir) return;
// Let the <a> tag's natural target=_blank handle file
// clicks. Don't intercept.
return;
}
// Folder: toggle on chevron OR anywhere on the row except
// the file link (no link in folder rows).
e.preventDefault(); e.preventDefault();
tree.toggleFolder(parseInt(row.dataset.id, 10)); var id = parseInt(row.dataset.id, 10);
if (e.shiftKey || e.altKey) {
var node = state.nodes.get(id);
if (node && node.expanded) {
tree.collapseSubtree(id);
} else {
tree.expandSubtree(id);
}
} else {
tree.toggleFolder(id);
}
}); });
} }
} }

View file

@ -219,34 +219,93 @@
} }
} }
// Load a folder's children (lazy; idempotent re-loads).
async function loadChildren(node) {
if (node.loaded) return;
try {
var raw;
if (state.source === 'server') {
raw = await loader.fetchServerChildren(pathFor(node) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
return;
}
setChildren(node.id, raw);
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
}
}
// Toggle a folder's expanded state. Loads children on first expand. // Toggle a folder's expanded state. Loads children on first expand.
async function toggleFolder(nodeId) { async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId); var n = state.nodes.get(nodeId);
if (!n || !n.isDir) return; if (!n || !n.isDir) return;
if (!n.expanded && !n.loaded) { if (!n.expanded && !n.loaded) {
try { await loadChildren(n);
var raw; if (!n.loaded) return; // load failed
if (state.source === 'server') {
var childPath = state.currentPath
+ n.name + '/'; // server URLs are relative paths
// Walk up the parent chain to build the full path.
childPath = pathFor(n) + '/';
raw = await loader.fetchServerChildren(childPath);
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(n.handle);
} else {
return;
}
window.app.modules.tree.setChildren(nodeId, raw);
} catch (e) {
window.app.modules.events.statusError('Failed to load folder: ' + e.message);
return;
}
} }
n.expanded = !n.expanded; n.expanded = !n.expanded;
render(); render();
} }
// Recursive expand: load + expand all descendants of nodeId. Used
// for Shift-click on a folder. Walks breadth-first, fanning out
// through children, grand-children, etc. until every reachable
// folder is loaded and marked expanded. Status bar shows progress
// because deeply-nested trees can take a while.
//
// Parallelism: kept conservative (per-level fan-out) to avoid
// hammering zddc-server with hundreds of concurrent listing
// fetches. Browsers also throttle per-origin concurrency, but
// queuing politely is friendlier than fighting that.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
var queue = [root];
while (queue.length) {
var batch = queue;
queue = [];
// Load this level's children in parallel (Promise.all).
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
n.expanded = true;
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
if (c && c.isDir) queue.push(c);
}
}
// Re-render after each level so the user sees progress
// rather than a long pause then a sudden full-tree dump.
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
// Recursive collapse: mark this node and every descendant as
// collapsed. Doesn't unload — if the user re-expands later, the
// children are still in memory and re-render is instant.
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
function walk(n) {
n.expanded = false;
for (var i = 0; i < n.childIds.length; i++) {
var c = state.nodes.get(n.childIds[i]);
if (c && c.isDir) walk(c);
}
}
walk(root);
render();
}
// Compute the URL/path for a node by walking parents. // Compute the URL/path for a node by walking parents.
function pathFor(node) { function pathFor(node) {
var parts = []; var parts = [];
@ -269,6 +328,8 @@
setChildren: setChildren, setChildren: setChildren,
render: render, render: render,
toggleFolder: toggleFolder, toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
setSort: function (key) { setSort: function (key) {
if (state.sort.key === key) { if (state.sort.key === key) {
state.sort.dir = -state.sort.dir; state.sort.dir = -state.sort.dir;

View file

@ -43,8 +43,10 @@
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder <li><b>Local</b> — click <i>Select Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click folders to expand, click headers to sort, type <p>Once loaded: click a folder to expand it, <b>shift-click</b>
in the filter to narrow by name. Click any file to open it.</p> to expand its entire subtree (or collapse it again),
click column headers to sort, type in the filter to narrow
by name. Click any file to open it.</p>
</div> </div>
</div> </div>
@ -55,17 +57,19 @@
placeholder="Filter by name (substring)..." /> placeholder="Filter by name (substring)..." />
<span class="toolbar__count" id="entryCount"></span> <span class="toolbar__count" id="entryCount"></span>
</div> </div>
<table class="browse-table" id="browseTable"> <div class="browse-table-wrap">
<thead> <table class="browse-table" id="browseTable">
<tr> <thead>
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th> <tr>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th> <th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th> <th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th> <th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
</tr> <th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</thead> </tr>
<tbody id="browseTbody"></tbody> </thead>
</table> <tbody id="browseTbody"></tbody>
</table>
</div>
</div> </div>
</main> </main>

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button> <button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div> </div>

View file

@ -2113,7 +2113,7 @@ td[data-field="trackingNumber"] {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh Data" style="font-size:1.1rem;"></button>

View file

@ -585,6 +585,13 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
}
.browse-table-wrap {
flex: 1;
overflow: auto;
min-height: 0;
} }
.toolbar { .toolbar {
@ -633,7 +640,16 @@ body {
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 0.9rem;
background: var(--bg); background: var(--bg);
flex: 1; /* No flex:1 — tables don't reliably distribute extra height across
rows the way flex columns do. With few rows we'd get tall rows
that shrink as more children are loaded. The wrap div handles
scrolling instead. */
}
.browse-table tbody tr {
/* Pin rows to a deterministic height so table layout never
redistributes vertical space across them. */
line-height: 1.4;
} }
.browse-table thead th { .browse-table thead th {
@ -775,7 +791,7 @@ body {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
</div> </div>
@ -796,8 +812,10 @@ body {
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder <li><b>Local</b> — click <i>Select Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click folders to expand, click headers to sort, type <p>Once loaded: click a folder to expand it, <b>shift-click</b>
in the filter to narrow by name. Click any file to open it.</p> to expand its entire subtree (or collapse it again),
click column headers to sort, type in the filter to narrow
by name. Click any file to open it.</p>
</div> </div>
</div> </div>
@ -808,17 +826,19 @@ body {
placeholder="Filter by name (substring)..." /> placeholder="Filter by name (substring)..." />
<span class="toolbar__count" id="entryCount"></span> <span class="toolbar__count" id="entryCount"></span>
</div> </div>
<table class="browse-table" id="browseTable"> <div class="browse-table-wrap">
<thead> <table class="browse-table" id="browseTable">
<tr> <thead>
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th> <tr>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th> <th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th> <th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th> <th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
</tr> <th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</thead> </tr>
<tbody id="browseTbody"></tbody> </thead>
</table> <tbody id="browseTbody"></tbody>
</table>
</div>
</div> </div>
</main> </main>
@ -1684,34 +1704,93 @@ body {
} }
} }
// Load a folder's children (lazy; idempotent re-loads).
async function loadChildren(node) {
if (node.loaded) return;
try {
var raw;
if (state.source === 'server') {
raw = await loader.fetchServerChildren(pathFor(node) + '/');
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(node.handle);
} else {
return;
}
setChildren(node.id, raw);
} catch (e) {
window.app.modules.events.statusError(
'Failed to load ' + node.name + ': ' + e.message);
}
}
// Toggle a folder's expanded state. Loads children on first expand. // Toggle a folder's expanded state. Loads children on first expand.
async function toggleFolder(nodeId) { async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId); var n = state.nodes.get(nodeId);
if (!n || !n.isDir) return; if (!n || !n.isDir) return;
if (!n.expanded && !n.loaded) { if (!n.expanded && !n.loaded) {
try { await loadChildren(n);
var raw; if (!n.loaded) return; // load failed
if (state.source === 'server') {
var childPath = state.currentPath
+ n.name + '/'; // server URLs are relative paths
// Walk up the parent chain to build the full path.
childPath = pathFor(n) + '/';
raw = await loader.fetchServerChildren(childPath);
} else if (state.source === 'fs') {
raw = await loader.fetchFsChildren(n.handle);
} else {
return;
}
window.app.modules.tree.setChildren(nodeId, raw);
} catch (e) {
window.app.modules.events.statusError('Failed to load folder: ' + e.message);
return;
}
} }
n.expanded = !n.expanded; n.expanded = !n.expanded;
render(); render();
} }
// Recursive expand: load + expand all descendants of nodeId. Used
// for Shift-click on a folder. Walks breadth-first, fanning out
// through children, grand-children, etc. until every reachable
// folder is loaded and marked expanded. Status bar shows progress
// because deeply-nested trees can take a while.
//
// Parallelism: kept conservative (per-level fan-out) to avoid
// hammering zddc-server with hundreds of concurrent listing
// fetches. Browsers also throttle per-origin concurrency, but
// queuing politely is friendlier than fighting that.
async function expandSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
var status = window.app.modules.events.statusInfo;
status('Expanding subtree…');
var processed = 0;
var queue = [root];
while (queue.length) {
var batch = queue;
queue = [];
// Load this level's children in parallel (Promise.all).
await Promise.all(batch.map(function (n) { return loadChildren(n); }));
for (var i = 0; i < batch.length; i++) {
var n = batch[i];
n.expanded = true;
processed++;
for (var j = 0; j < n.childIds.length; j++) {
var c = state.nodes.get(n.childIds[j]);
if (c && c.isDir) queue.push(c);
}
}
// Re-render after each level so the user sees progress
// rather than a long pause then a sudden full-tree dump.
render();
status('Expanding subtree… (' + processed + ' folders loaded)');
}
status('Expanded ' + processed + ' folder' + (processed === 1 ? '' : 's'));
}
// Recursive collapse: mark this node and every descendant as
// collapsed. Doesn't unload — if the user re-expands later, the
// children are still in memory and re-render is instant.
function collapseSubtree(nodeId) {
var root = state.nodes.get(nodeId);
if (!root || !root.isDir) return;
function walk(n) {
n.expanded = false;
for (var i = 0; i < n.childIds.length; i++) {
var c = state.nodes.get(n.childIds[i]);
if (c && c.isDir) walk(c);
}
}
walk(root);
render();
}
// Compute the URL/path for a node by walking parents. // Compute the URL/path for a node by walking parents.
function pathFor(node) { function pathFor(node) {
var parts = []; var parts = [];
@ -1734,6 +1813,8 @@ body {
setChildren: setChildren, setChildren: setChildren,
render: render, render: render,
toggleFolder: toggleFolder, toggleFolder: toggleFolder,
expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree,
setSort: function (key) { setSort: function (key) {
if (state.sort.key === key) { if (state.sort.key === key) {
state.sort.dir = -state.sort.dir; state.sort.dir = -state.sort.dir;
@ -1834,21 +1915,36 @@ body {
} }
// Tree-row clicks (event delegation on tbody). // Tree-row clicks (event delegation on tbody).
// Click semantics on a folder row:
// - plain click → toggle just this folder
// - shift-click → recursive expand/collapse of the whole
// subtree (matches common file-explorer
// convention; e.g. Finder, VSCode tree,
// Windows Explorer)
// - alt-click → ALSO recursive (alt is sometimes the
// expand-all key on Linux DEs; bind both
// so muscle memory works either way)
// File rows: let the <a> tag's natural target=_blank do its
// job — don't intercept.
var tbody = document.getElementById('browseTbody'); var tbody = document.getElementById('browseTbody');
if (tbody) { if (tbody) {
tbody.addEventListener('click', function (e) { tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row'); var row = e.target.closest('tr.tree-row');
if (!row) return; if (!row) return;
var isDir = row.dataset.isdir === 'true'; var isDir = row.dataset.isdir === 'true';
if (!isDir) { if (!isDir) return;
// Let the <a> tag's natural target=_blank handle file
// clicks. Don't intercept.
return;
}
// Folder: toggle on chevron OR anywhere on the row except
// the file link (no link in folder rows).
e.preventDefault(); e.preventDefault();
tree.toggleFolder(parseInt(row.dataset.id, 10)); var id = parseInt(row.dataset.id, 10);
if (e.shiftKey || e.altKey) {
var node = state.nodes.get(id);
if (node && node.expanded) {
tree.collapseSubtree(id);
} else {
tree.expandSubtree(id);
}
} else {
tree.toggleFolder(id);
}
}); });
} }
} }

View file

@ -1376,7 +1376,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Classifier</span> <span class="app-header__title">ZDDC Classifier</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button> <button id="selectDirectoryBtn" class="btn btn-primary">Select Directory</button>
<button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button> <button id="refreshBtn" class="btn btn-secondary hidden" title="Refresh and rescan directory">Refresh</button>

View file

@ -866,7 +866,7 @@ body {
</g> </g>
</svg> </svg>
<span class="app-header__title">ZDDC Archive</span> <span class="app-header__title">ZDDC Archive</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>

View file

@ -1774,7 +1774,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Markdown</span> <span class="app-header__title">ZDDC Markdown</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button> <button id="select-directory" class="btn btn-primary" title="Select a Directory">Select Directory</button>
</div> </div>

View file

@ -2210,7 +2210,7 @@ dialog.modal--narrow {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title">ZDDC Transmittal</span> <span class="app-header__title">ZDDC Transmittal</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<div class="app-header__spacer"></div> <div class="app-header__spacer"></div>
<div class="app-header__icons"> <div class="app-header__icons">

View file

@ -1,8 +1,8 @@
# Generated by build.sh — do not edit. One <app>=<build label> per line. # Generated by build.sh — do not edit. One <app>=<build label> per line.
archive=v0.0.12 archive=v0.0.13
transmittal=v0.0.12 transmittal=v0.0.13
classifier=v0.0.12 classifier=v0.0.13
mdedit=v0.0.12 mdedit=v0.0.13
landing=v0.0.12 landing=v0.0.13
form=v0.0.12 form=v0.0.13
browse=v0.0.12 browse=v0.0.13

View file

@ -80,6 +80,13 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Vary: Accept is critical — the same URL serves either the JSON
// listing or the embedded browse.html depending on the Accept
// header. Without Vary, browsers/CDNs cache one response and
// serve it for the other Accept value, breaking browse.html's
// auto-detect (which fetches the same URL with Accept: JSON).
w.Header().Set("Vary", "Accept")
if strings.Contains(accept, "application/json") { if strings.Contains(accept, "application/json") {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
@ -109,6 +116,10 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("X-ZDDC-Source", "embedded:browse") w.Header().Set("X-ZDDC-Source", "embedded:browse")
w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") // no-cache here too — browse.html has session-tied content (the
// directory listing it loads via fetch), and we want browser to
// always re-validate so deployed-binary updates appear immediately
// rather than after a 5-minute cache window.
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body) _, _ = w.Write(body)
} }

View file

@ -722,7 +722,7 @@ body.help-open .app-header {
</g> </g>
</svg> </svg>
<span class="app-header__title" id="form-title">ZDDC Form</span> <span class="app-header__title" id="form-title">ZDDC Form</span>
<span class="build-timestamp">v0.0.12</span> <span class="build-timestamp">v0.0.13</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>