feat(browse): generic directory listing tool — default at folder URLs
All checks were successful
Notify chart dev on beta cut / notify-chart-dev (push) Successful in 5s

A new HTML tool — browse — that lists the contents of any directory.
Designed for ZDDC archives but no ZDDC-specific filtering; just a
straight folder browser with expand/collapse, sort, and name filter.

Modes (auto-detected at page load):
  - Online: when served by zddc-server at a folder URL, queries
    the same URL with Accept: application/json to load the listing
    and renders it. Auto-served as the default at any directory
    under ZDDC_ROOT without an index.html (replacing the previous
    minimal-HTML stub from directory.go).
  - Local: 'Select Directory' button uses FileSystemAccessAPI to
    pick any folder on disk; works in Chromium-based browsers.

Features (Phase 1 — what's in this commit):
  - Tree view with lazy-loaded folders (children fetched on first
    expand).
  - Sort by name / size / extension / date (column header click).
  - Filter by name substring (toolbar input).
  - File click opens in a new tab — for server-backed pages,
    routes through zddc-server's normal handler so .archive
    redirects + apps cascade overrides + ACL all apply.

Phase 2 deferred:
  - ZIP files inline expansion (treat archive entries as virtual
    children).
  - File preview popup (reuse shared/preview-lib.js).
  - Extension multi-select filter.

Wiring:
  - browse/ added to top-level ./build's per-tool list, embed
    block, versions.txt, and the lockstep release commit + tag set.
    All seven tools (archive, transmittal, classifier, mdedit,
    landing, form, browse) advance together on stable cuts.
  - shared/build-lib.sh: browse added to ZDDC_RELEASE_TOOLS and
    verify_channel_links's per-tool loop.
  - zddc/internal/apps/embed.go: //go:embed browse.html +
    EmbeddedBytes("browse") case.
  - zddc/internal/apps/availability.go: browse available at every
    directory (same as archive).
  - zddc/internal/apps/handler.go: MatchAppHTML routes
    /<dir>/browse.html → 'browse'.
  - zddc/internal/handler/directory.go: when a directory request
    arrives with Accept: text/html and no index.html exists,
    serve the embedded browse.html bytes (with a JSON-fallback
    if the embedded slot is empty during bootstrap).
This commit is contained in:
ZDDC 2026-05-03 19:56:51 -05:00
parent 1033d30ad9
commit fb13ff4fd8
17 changed files with 1097 additions and 16 deletions

53
browse/README.md Normal file
View file

@ -0,0 +1,53 @@
# browse — directory listing tool
Generic file browser for any directory. Designed to work with ZDDC
archives but useful for any folder. Single-file HTML, no install.
## How it's used
Two modes, auto-detected at page load:
1. **Online (zddc-server backed).** When this HTML is served by
zddc-server at a folder URL — which it is by default for any
directory under `ZDDC_ROOT` that doesn't have an `index.html`
the JS queries the same URL with `Accept: application/json` to
load the directory's listing and renders it as a sortable,
filterable table.
2. **Local (FileSystemAccessAPI).** Click "Select Directory" in the
header to pick any folder on your computer. Works in
Chromium-based browsers (Chrome, Edge, Brave, etc.). No server
required; the directory is read directly from disk.
## What it does
- Lists files and folders with name, size, type (extension), and
modified date.
- Click a folder to expand inline. Children load lazily on first
expand.
- Click a column header to sort by that column. Click again to
reverse.
- Type in the filter to narrow to entries whose name contains the
substring.
- Click any file to open it in a new tab — for server-backed pages,
this routes through zddc-server's normal handler (so an `.archive`
redirect, an apps cascade override, etc. all work as expected).
## Design notes
- **No ZDDC-specific filtering.** This tool is intentionally
domain-agnostic. The companion `archive` tool layers ZDDC
parsing (project / status / revision filters, tracking-number
resolution) on top of the same listing API. Use `archive` when
you want ZDDC semantics; use `browse` when you just want to see
what's in a folder.
- **Default at directory URLs.** zddc-server's `directory.go`
serves the embedded browse.html bytes for any directory request
with `Accept: text/html` and no `index.html` present. This
means a user navigating to any folder under `ZDDC_ROOT` gets a
usable browser without anyone having to drop a file into the
archive.
- **Apps cascade override.** Like every other ZDDC tool, the
served `browse.html` can be overridden per-folder via a `.zddc
apps:` entry. The default is the embedded copy from the binary;
operators can pin a specific version or URL if they want.

69
browse/build.sh Executable file
View file

@ -0,0 +1,69 @@
#!/bin/sh
set -eu
root_dir=$(cd "$(dirname "$0")" && pwd)
. "$root_dir/../shared/build-lib.sh"
src_html="$root_dir/template.html"
output_dir="$root_dir/dist"
output_html="$output_dir/browse.html"
mkdir -p "$output_dir"
ensure_exists "$src_html"
css_temp=$(mktemp)
js_raw=$(mktemp)
js_temp=$(mktemp)
cleanup() { rm -f "$css_temp" "$js_raw" "$js_temp"; }
trap cleanup EXIT
# CSS files: shared base first, then browse-specific.
concat_files \
"../shared/base.css" \
"css/base.css" \
"css/tree.css" \
> "$css_temp"
# JS files: shared canonical helpers, then browse modules.
# init.js must come first so window.app exists when later modules
# attach to it.
concat_files \
"../shared/zddc.js" \
"../shared/theme.js" \
"js/init.js" \
"js/loader.js" \
"js/tree.js" \
"js/events.js" \
"js/app.js" \
> "$js_raw"
# Escape any literal `</` inside JS string/template literals so the
# inlined <script> block doesn't get terminated prematurely.
escape_js_close_tags "$js_raw" "$js_temp"
tool=browse
compute_build_label "$tool" "$@"
# Replace template placeholders with concatenated CSS/JS + label.
awk -v css_file="$css_temp" -v js_file="$js_temp" \
-v build_label="$build_label" -v favicon="$favicon_data_uri" '
/\{\{CSS_PLACEHOLDER\}\}/ {
while ((getline line < css_file) > 0) print line
close(css_file); next
}
/\{\{JS_PLACEHOLDER\}\}/ {
while ((getline line < js_file) > 0) print line
close(js_file); next
}
{
gsub(/\{\{BUILD_LABEL\}\}/, build_label)
gsub(/\{\{FAVICON\}\}/, favicon)
print
}
' "$src_html" > "$output_html"
echo "Wrote $output_html"
# Promote AFTER the dist file exists so promote_release can copy from
# $output_html. (The order matters — _promote_stable does cp $output_html ...)
promote_release "$tool"

70
browse/css/base.css Normal file
View file

@ -0,0 +1,70 @@
/* browse-specific layout on top of shared/base.css */
html, body {
height: 100%;
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font);
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#appMain {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* Empty / first-paint state */
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.empty-state__inner {
max-width: 640px;
color: var(--text-muted);
line-height: 1.5;
}
.empty-state__inner h2 {
color: var(--text);
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.empty-state__inner ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.empty-state__inner li {
margin: 0.4rem 0;
}
.hidden { display: none !important; }
/* Status bar — shows transient errors/info */
.status-bar {
padding: 0.4rem 1rem;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-muted);
min-height: 1.6rem;
line-height: 1.6rem;
flex-shrink: 0;
}
.status-bar--error { color: #b00020; }
.status-bar--info { color: var(--primary); }

181
browse/css/tree.css Normal file
View file

@ -0,0 +1,181 @@
/* Toolbar above the listing */
.browse-root {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar__path {
font-family: Consolas, Monaco, monospace;
font-size: 0.9rem;
color: var(--text-muted);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar__filter {
width: 22rem;
max-width: 100%;
padding: 0.3rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-size: 0.9rem;
}
.toolbar__count {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
/* Table — folders + files in a tree */
.browse-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
background: var(--bg);
flex: 1;
}
.browse-table thead th {
position: sticky;
top: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
text-align: left;
padding: 0.5rem 0.75rem;
font-weight: 600;
color: var(--text);
user-select: none;
z-index: 1;
}
.browse-table th.sortable {
cursor: pointer;
}
.browse-table th.sortable:hover {
background: var(--bg-hover, #e8e8e8);
}
.sort-arrow {
display: inline-block;
width: 0.7rem;
color: var(--text-muted);
font-size: 0.7rem;
margin-left: 0.2rem;
}
.browse-table th.sort-asc .sort-arrow::after { content: "▲"; color: var(--text); }
.browse-table th.sort-desc .sort-arrow::after { content: "▼"; color: var(--text); }
.browse-table tbody td {
padding: 0.3rem 0.75rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.browse-table tbody tr:hover {
background: var(--bg-hover, #f6faff);
}
/* Tree-row — name cell with indent + chevron */
.tree-name {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.tree-name__indent {
flex: 0 0 auto;
}
.tree-name__chevron {
width: 1rem;
text-align: center;
color: var(--text-muted);
cursor: pointer;
user-select: none;
flex: 0 0 1rem;
line-height: 1;
}
.tree-name__chevron--leaf { visibility: hidden; }
.tree-name__chevron::before { content: "▶"; font-size: 0.65rem; }
.tree-row.expanded > td .tree-name__chevron::before { content: "▼"; }
.tree-name__icon {
flex: 0 0 1.1rem;
text-align: center;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
.tree-name__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
}
.tree-name__label.is-folder {
font-weight: 500;
}
.tree-name__label.is-file {
cursor: pointer;
color: var(--primary);
text-decoration: none;
}
.tree-name__label.is-file:hover {
text-decoration: underline;
}
/* Numeric columns right-aligned */
.col-size, .col-date {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: var(--text-muted);
}
.col-ext {
color: var(--text-muted);
font-family: Consolas, Monaco, monospace;
font-size: 0.85rem;
}
/* Loading row */
.tree-row--loading td {
color: var(--text-muted);
font-style: italic;
padding: 0.5rem 1rem 0.5rem calc(0.75rem + 2.4rem);
}
/* When filter hides a row */
.tree-row--filtered { display: none; }

36
browse/js/app.js Normal file
View file

@ -0,0 +1,36 @@
// app.js — bootstrap. Runs after every other module's IIFE has
// registered its functions on window.app.modules.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
var tree = window.app.modules.tree;
var events = window.app.modules.events;
async function bootstrap() {
events.init();
// Try server auto-detect. If this page is served by zddc-server
// (or any server with a Caddy-shaped JSON listing), load the
// current directory automatically. Otherwise show the empty
// state with the "Select Directory" button.
var detected = await loader.autoDetectServerMode();
if (detected) {
tree.setRoot(detected.entries);
events.showBrowseRoot();
document.getElementById('currentPath').textContent = detected.path;
tree.render();
events.statusInfo('Loaded ' + detected.entries.length + ' item'
+ (detected.entries.length === 1 ? '' : 's')
+ ' from ' + detected.path);
}
// Else: empty state stays visible; user can click Select Directory.
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
bootstrap();
}
})();

110
browse/js/events.js Normal file
View file

@ -0,0 +1,110 @@
// events.js — wires up DOM listeners. Idempotent so app.js can call
// init() once on load.
(function () {
'use strict';
var state = window.app.state;
var tree = window.app.modules.tree;
var loader = window.app.modules.loader;
function status(msg, kind) {
var el = document.getElementById('statusBar');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('status-bar--error', 'status-bar--info');
if (kind === 'error') el.classList.add('status-bar--error');
if (kind === 'info') el.classList.add('status-bar--info');
}
function statusError(msg) { status(msg, 'error'); }
function statusInfo(msg) { status(msg, 'info'); }
function statusClear() { status('', null); }
async function pickLocalDir() {
if (typeof window.showDirectoryPicker !== 'function') {
statusError('Your browser does not support local folder selection. Use a recent Chromium-based browser, or open this page via zddc-server.');
return;
}
var handle;
try {
handle = await window.showDirectoryPicker({ mode: 'read' });
} catch (e) {
// User cancelled — silent
return;
}
state.source = 'fs';
state.rootHandle = handle;
state.currentPath = handle.name + '/';
var raw;
try {
raw = await loader.fetchFsChildren(handle);
} catch (e) {
statusError('Failed to read directory: ' + e.message);
return;
}
tree.setRoot(raw);
showBrowseRoot();
document.getElementById('currentPath').textContent = state.currentPath;
tree.render();
statusInfo('Loaded ' + raw.length + ' item' + (raw.length === 1 ? '' : 's'));
}
function showBrowseRoot() {
var empty = document.getElementById('emptyState');
var root = document.getElementById('browseRoot');
if (empty) empty.classList.add('hidden');
if (root) root.classList.remove('hidden');
}
function init() {
// Header buttons
var btn = document.getElementById('addDirectoryBtn');
if (btn) btn.addEventListener('click', pickLocalDir);
// Filter input
var filter = document.getElementById('filterInput');
if (filter) {
filter.addEventListener('input', function () {
tree.setFilter(filter.value);
});
}
// Sort headers
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
(function (th) {
th.addEventListener('click', function () {
tree.setSort(th.dataset.sort);
});
})(ths[i]);
}
// Tree-row clicks (event delegation on tbody).
var tbody = document.getElementById('browseTbody');
if (tbody) {
tbody.addEventListener('click', function (e) {
var row = e.target.closest('tr.tree-row');
if (!row) return;
var isDir = row.dataset.isdir === 'true';
if (!isDir) {
// 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();
tree.toggleFolder(parseInt(row.dataset.id, 10));
});
}
}
// Public API
window.app.modules.events = {
init: init,
statusError: statusError,
statusInfo: statusInfo,
statusClear: statusClear,
showBrowseRoot: showBrowseRoot
};
})();

40
browse/js/init.js Normal file
View file

@ -0,0 +1,40 @@
// Bootstrap window.app for the browse tool. Mirrors the convention
// used by every other ZDDC tool — ./build's CSS/JS concat order means
// this file runs FIRST inside the IIFE-of-IIFEs.
(function () {
'use strict';
if (!window.app) {
window.app = { modules: {}, state: {} };
}
window.app.state = {
// Source: 'server' | 'fs' | null. Determines how the loader
// resolves entries.
source: null,
// For server-source: the URL path of the directory currently
// being viewed. Always starts with '/' and ends with '/'.
// For fs-source: the displayed path string (no semantic
// meaning — just for the toolbar).
currentPath: '/',
// FileSystemAccessAPI root handle (null in server mode).
rootHandle: null,
// Sort state. key: 'name' | 'size' | 'ext' | 'date'. dir: 1 or -1.
sort: { key: 'name', dir: 1 },
// Current filter substring (lowercase).
filterText: '',
// The tree's in-memory representation. Each node:
// { id, name, isDir, size, modTime, ext, url, depth,
// parentId, expanded, loaded, childIds }
// Stored flat in a Map keyed by id; render order derived
// from a depth-first walk.
nodes: new Map(),
rootIds: [],
nextId: 1
};
})();

130
browse/js/loader.js Normal file
View file

@ -0,0 +1,130 @@
// loader.js — fetches directory entries for either source mode.
//
// Server mode: GET <urlPath> with Accept: application/json. zddc-server
// (and Caddy's built-in browse, which we mirror) returns an array of
// FileInfo {name, size, url, mod_time, mode, is_dir, is_symlink}.
//
// FS-API mode: enumerate a FileSystemDirectoryHandle's children. No
// network involved; works on local folders the user picked.
(function () {
'use strict';
var state = window.app.state;
function splitExt(name) {
var i = name.lastIndexOf('.');
if (i <= 0 || i === name.length - 1) return '';
return name.substring(i + 1).toLowerCase();
}
// Build a raw entry from the server's FileInfo shape.
function fromServerEntry(e) {
// Server returns directory names with a trailing "/". Strip
// it for display; the is_dir flag is the canonical signal.
var displayName = e.is_dir ? e.name.replace(/\/$/, '') : e.name;
return {
name: displayName,
isDir: e.is_dir,
size: e.size || 0,
modTime: e.mod_time ? new Date(e.mod_time) : null,
ext: e.is_dir ? '' : splitExt(displayName),
url: e.url || null,
// FS-API specific (null in server mode):
handle: null
};
}
// Build a raw entry from a FileSystemHandle.
async function fromHandle(handle) {
var name = handle.name;
var isDir = handle.kind === 'directory';
var size = 0;
var modTime = null;
if (!isDir) {
try {
var f = await handle.getFile();
size = f.size;
modTime = new Date(f.lastModified);
} catch (_e) {
// permission lost; leave size/modTime defaults
}
}
return {
name: name,
isDir: isDir,
size: size,
modTime: modTime,
ext: isDir ? '' : splitExt(name),
url: null,
handle: handle
};
}
// Fetch children of a directory in server mode.
// path must end with '/' so the request hits the directory route.
async function fetchServerChildren(path) {
if (!path.endsWith('/')) path += '/';
var resp = await fetch(path, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin'
});
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ' fetching ' + path);
}
var data = await resp.json();
if (!Array.isArray(data)) {
throw new Error('Unexpected response shape from ' + path);
}
return data.map(fromServerEntry);
}
// Enumerate a FileSystemDirectoryHandle's immediate children.
async function fetchFsChildren(dirHandle) {
var entries = [];
for await (var [_name, handle] of dirHandle.entries()) {
entries.push(await fromHandle(handle));
}
return entries;
}
// Probe whether THIS page is being served by zddc-server (or any
// server that responds to JSON listing requests). If so, switch to
// server mode automatically and load the current directory.
async function autoDetectServerMode() {
// Only attempt when running over http(s) and the location's
// path looks like a directory. Probing on file:// is pointless.
if (location.protocol !== 'http:' && location.protocol !== 'https:') {
return false;
}
// Strip any /<tool>.html from the path to get the directory.
var path = location.pathname;
// If the URL points at the browse.html itself, the directory
// is the parent. If it's a directory ending in '/', use it.
var dirPath;
if (path.endsWith('/')) {
dirPath = path;
} else {
// e.g. '/some/dir/browse.html' → '/some/dir/'
var slash = path.lastIndexOf('/');
dirPath = slash >= 0 ? path.substring(0, slash + 1) : '/';
}
try {
var entries = await fetchServerChildren(dirPath);
state.source = 'server';
state.currentPath = dirPath;
return { entries: entries, path: dirPath };
} catch (_e) {
// Not a server-backed page (e.g. opened via file://).
return null;
}
}
// Public API
window.app.modules.loader = {
fetchServerChildren: fetchServerChildren,
fetchFsChildren: fetchFsChildren,
autoDetectServerMode: autoDetectServerMode,
splitExt: splitExt
};
})();

288
browse/js/tree.js Normal file
View file

@ -0,0 +1,288 @@
// tree.js — in-memory tree model + DOM rendering.
//
// Nodes are stored flat in state.nodes (Map by id). The visible
// render is a depth-first walk starting from state.rootIds, skipping
// children of unexpanded folders. This decouples model from DOM and
// keeps re-renders linear in the visible-row count.
(function () {
'use strict';
var state = window.app.state;
var loader = window.app.modules.loader;
// ── Model helpers ────────────────────────────────────────────────────
function newNode(raw, parentId, depth) {
var id = state.nextId++;
var node = {
id: id,
name: raw.name,
isDir: raw.isDir,
size: raw.size,
modTime: raw.modTime,
ext: raw.ext,
url: raw.url,
handle: raw.handle,
depth: depth,
parentId: parentId,
expanded: false,
loaded: false,
childIds: []
};
state.nodes.set(id, node);
return node;
}
function clearTree() {
state.nodes.clear();
state.rootIds = [];
state.nextId = 1;
}
// Sort an array of nodes by current sort key. Folders always come
// first within a level (mimics common file managers).
function sortNodes(ids) {
var key = state.sort.key;
var dir = state.sort.dir;
ids.sort(function (a, b) {
var na = state.nodes.get(a);
var nb = state.nodes.get(b);
// Folders before files
if (na.isDir !== nb.isDir) return na.isDir ? -1 : 1;
var av, bv;
switch (key) {
case 'size':
av = na.size; bv = nb.size; break;
case 'ext':
av = na.ext; bv = nb.ext; break;
case 'date':
av = na.modTime ? na.modTime.getTime() : 0;
bv = nb.modTime ? nb.modTime.getTime() : 0;
break;
default:
av = na.name.toLowerCase();
bv = nb.name.toLowerCase();
}
if (av < bv) return -1 * dir;
if (av > bv) return 1 * dir;
return na.name.toLowerCase().localeCompare(nb.name.toLowerCase());
});
}
// Populate state with the root listing.
function setRoot(rawEntries) {
clearTree();
rawEntries.forEach(function (raw) {
var n = newNode(raw, null, 0);
state.rootIds.push(n.id);
});
sortNodes(state.rootIds);
}
// Populate a folder's children. Caller passes raw entries in any order.
function setChildren(parentId, rawEntries) {
var parent = state.nodes.get(parentId);
if (!parent) return;
// Drop any existing children first (re-load case).
parent.childIds.forEach(function (id) { state.nodes.delete(id); });
parent.childIds = [];
rawEntries.forEach(function (raw) {
var n = newNode(raw, parentId, parent.depth + 1);
parent.childIds.push(n.id);
});
sortNodes(parent.childIds);
parent.loaded = true;
}
// Walk visible nodes in render order.
function visibleIds() {
var out = [];
function walk(ids) {
for (var i = 0; i < ids.length; i++) {
out.push(ids[i]);
var n = state.nodes.get(ids[i]);
if (n.isDir && n.expanded) walk(n.childIds);
}
}
// Re-sort everything at all levels so a sort change reorders
// already-loaded children consistently.
sortNodes(state.rootIds);
state.nodes.forEach(function (n) {
if (n.isDir && n.loaded) sortNodes(n.childIds);
});
walk(state.rootIds);
return out;
}
// ── Rendering ────────────────────────────────────────────────────────
function fmtSize(bytes) {
if (bytes == null) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function fmtDate(d) {
if (!d) return '';
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function rowHtml(node) {
var indent = node.depth * 1.2;
var iconChar = node.isDir ? '📁' : '📄';
var labelClass = node.isDir ? 'is-folder' : 'is-file';
var chevronClass = 'tree-name__chevron' + (node.isDir ? '' : ' tree-name__chevron--leaf');
var nameInner;
if (node.isDir) {
nameInner = '<span class="tree-name__label is-folder">'
+ escapeHtml(node.name) + '</span>';
} else {
// File: clickable link. In server mode, href is a real URL
// that opens the file. In FS mode, click handler reads the
// file via the handle and triggers a download (Phase 2).
var href = node.url || '#';
nameInner = '<a class="tree-name__label is-file"'
+ ' href="' + escapeHtml(href) + '"'
+ ' target="_blank" rel="noopener">' + escapeHtml(node.name) + '</a>';
}
return ''
+ '<tr class="tree-row ' + (node.expanded ? 'expanded' : '')
+ '" data-id="' + node.id + '" data-isdir="' + node.isDir + '">'
+ '<td class="col-name">'
+ '<span class="tree-name">'
+ '<span class="tree-name__indent" style="width:' + indent + 'rem;"></span>'
+ '<span class="' + chevronClass + '"></span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>'
+ nameInner
+ '</span>'
+ '</td>'
+ '<td class="col-size">' + (node.isDir ? '' : fmtSize(node.size)) + '</td>'
+ '<td class="col-ext">' + (node.isDir ? '' : escapeHtml(node.ext)) + '</td>'
+ '<td class="col-date">' + fmtDate(node.modTime) + '</td>'
+ '</tr>';
}
function render() {
var tbody = document.getElementById('browseTbody');
if (!tbody) return;
var ids = visibleIds();
var html = '';
for (var i = 0; i < ids.length; i++) {
html += rowHtml(state.nodes.get(ids[i]));
}
tbody.innerHTML = html;
applyFilter();
updateCount();
updateSortHeaders();
}
// Filter is purely DOM-level: hide rows whose name doesn't match.
// Cheap, immediate, no model rebuild.
function applyFilter() {
var f = state.filterText;
var rows = document.querySelectorAll('#browseTbody tr.tree-row');
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var n = state.nodes.get(parseInt(row.dataset.id, 10));
if (!n) continue;
var match = !f || n.name.toLowerCase().indexOf(f) !== -1;
row.classList.toggle('tree-row--filtered', !match);
}
}
function updateCount() {
var el = document.getElementById('entryCount');
if (!el) return;
var rows = document.querySelectorAll('#browseTbody tr.tree-row:not(.tree-row--filtered)');
var total = document.querySelectorAll('#browseTbody tr.tree-row').length;
el.textContent = state.filterText
? rows.length + ' of ' + total + ' shown'
: total + ' item' + (total === 1 ? '' : 's');
}
function updateSortHeaders() {
var ths = document.querySelectorAll('#browseTable thead th.sortable');
for (var i = 0; i < ths.length; i++) {
ths[i].classList.remove('sort-asc', 'sort-desc');
if (ths[i].dataset.sort === state.sort.key) {
ths[i].classList.add(state.sort.dir > 0 ? 'sort-asc' : 'sort-desc');
}
}
}
// Toggle a folder's expanded state. Loads children on first expand.
async function toggleFolder(nodeId) {
var n = state.nodes.get(nodeId);
if (!n || !n.isDir) return;
if (!n.expanded && !n.loaded) {
try {
var raw;
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;
render();
}
// Compute the URL/path for a node by walking parents.
function pathFor(node) {
var parts = [];
var cur = node;
while (cur) {
parts.unshift(cur.name);
cur = cur.parentId == null ? null : state.nodes.get(cur.parentId);
}
if (state.source === 'server') {
// currentPath is the dir containing rootIds — root nodes
// sit DIRECTLY under it.
return state.currentPath.replace(/\/$/, '') + '/' + parts.join('/');
}
return parts.join('/');
}
// Public API
window.app.modules.tree = {
setRoot: setRoot,
setChildren: setChildren,
render: render,
toggleFolder: toggleFolder,
setSort: function (key) {
if (state.sort.key === key) {
state.sort.dir = -state.sort.dir;
} else {
state.sort.key = key;
state.sort.dir = 1;
}
render();
},
setFilter: function (s) {
state.filterText = (s || '').toLowerCase();
applyFilter();
updateCount();
},
pathFor: pathFor
};
})();

78
browse/template.html Normal file
View file

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZDDC Browse</title>
<link rel="icon" type="image/svg+xml" href="{{FAVICON}}">
<style>
{{CSS_PLACEHOLDER}}
</style>
</head>
<body>
<header class="app-header">
<div class="header-left">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<rect width="64" height="64" rx="12" fill="#1e3a5f"/>
<g fill="#fff">
<rect x="14" y="18" width="36" height="7"/>
<polygon points="43,25 50,25 21,43 14,43"/>
<rect x="14" y="43" width="36" height="7"/>
</g>
</svg>
<div class="header-title-group">
<span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span>
</div>
<button id="addDirectoryBtn" class="btn btn-primary">Select Directory</button>
</div>
<div class="header-right">
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
</div>
</header>
<main id="appMain">
<div id="emptyState" class="empty-state">
<div class="empty-state__inner">
<h2>ZDDC Browse</h2>
<p>A simple directory listing for ZDDC archives — and any directory.
Pick how you want to browse:</p>
<ul>
<li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Select Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li>
</ul>
<p>Once loaded: click folders to expand, click headers to sort, type
in the filter to narrow by name. Click any file to open it.</p>
</div>
</div>
<div id="browseRoot" class="browse-root hidden">
<div class="toolbar">
<span class="toolbar__path" id="currentPath"></span>
<input type="search" id="filterInput" class="toolbar__filter"
placeholder="Filter by name (substring)..." />
<span class="toolbar__count" id="entryCount"></span>
</div>
<table class="browse-table" id="browseTable">
<thead>
<tr>
<th data-sort="name" class="col-name sortable">Name <span class="sort-arrow"></span></th>
<th data-sort="size" class="col-size sortable">Size <span class="sort-arrow"></span></th>
<th data-sort="ext" class="col-ext sortable">Type <span class="sort-arrow"></span></th>
<th data-sort="date" class="col-date sortable">Modified <span class="sort-arrow"></span></th>
</tr>
</thead>
<tbody id="browseTbody"></tbody>
</table>
</div>
</main>
<div id="statusBar" class="status-bar"></div>
<script>
{{JS_PLACEHOLDER}}
</script>
</body>
</html>

11
build
View file

@ -157,6 +157,7 @@ sh "$SCRIPT_DIR/classifier/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/mdedit/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/landing/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS sh "$SCRIPT_DIR/form/build.sh" $TOOL_RELEASE_ARGS
sh "$SCRIPT_DIR/browse/build.sh" $TOOL_RELEASE_ARGS
echo "" echo ""
echo "=== Assembling zddc/dist/web/ ===" echo "=== Assembling zddc/dist/web/ ==="
@ -176,7 +177,8 @@ cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$SCRIPT_DIR/zddc/dist/web/
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html" cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$SCRIPT_DIR/zddc/dist/web/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html" cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$SCRIPT_DIR/zddc/dist/web/mdedit.html"
cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html" cp "$SCRIPT_DIR/form/dist/form.html" "$SCRIPT_DIR/zddc/dist/web/form.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form}.html" cp "$SCRIPT_DIR/browse/dist/browse.html" "$SCRIPT_DIR/zddc/dist/web/browse.html"
echo "Wrote zddc/dist/web/{index,archive,transmittal,classifier,mdedit,form,browse}.html"
# Mirror the five cascade-served HTMLs into the apps embed source dir so the # Mirror the five cascade-served HTMLs into the apps embed source dir so the
# next `go build` of zddc-server picks them up via //go:embed. ONLY happens # next `go build` of zddc-server picks them up via //go:embed. ONLY happens
@ -193,6 +195,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html" cp "$SCRIPT_DIR/transmittal/dist/transmittal.html" "$EMBED_DIR/transmittal.html"
cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html" cp "$SCRIPT_DIR/classifier/dist/classifier.html" "$EMBED_DIR/classifier.html"
cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html" cp "$SCRIPT_DIR/mdedit/dist/mdedit.html" "$EMBED_DIR/mdedit.html"
cp "$SCRIPT_DIR/browse/dist/browse.html" "$EMBED_DIR/browse.html"
echo "Populated $EMBED_DIR/ for //go:embed" echo "Populated $EMBED_DIR/ for //go:embed"
# The form renderer lives next to its handler (no cascade needed — it's a # The form renderer lives next to its handler (no cascade needed — it's a
@ -207,7 +210,7 @@ if [ "$RELEASE_CHANNEL" = "beta" ] || [ "$RELEASE_CHANNEL" = "stable" ]; then
VERSIONS_FILE="$EMBED_DIR/versions.txt" VERSIONS_FILE="$EMBED_DIR/versions.txt"
{ {
echo "# Generated by build.sh — do not edit. One <app>=<build label> per line." echo "# Generated by build.sh — do not edit. One <app>=<build label> per line."
for _tool in archive transmittal classifier mdedit landing form; do for _tool in archive transmittal classifier mdedit landing form browse; do
_label_file="$BUILD_LABELS_DIR/${_tool}.label" _label_file="$BUILD_LABELS_DIR/${_tool}.label"
if [ -f "$_label_file" ]; then if [ -f "$_label_file" ]; then
_label=$(cat "$_label_file") _label=$(cat "$_label_file")
@ -743,7 +746,7 @@ if [ "$RELEASE_CHANNEL" = "stable" ]; then
# Tag the seven artifacts at HEAD. Pre-flight already validated that # Tag the seven artifacts at HEAD. Pre-flight already validated that
# any pre-existing tag is in HEAD's history, so this is safe. # any pre-existing tag is in HEAD's history, so this is safe.
_head=$(git -C "$SCRIPT_DIR" rev-parse HEAD) _head=$(git -C "$SCRIPT_DIR" rev-parse HEAD)
for _t in archive transmittal classifier mdedit landing form zddc-server; do for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
_tag="${_t}-v${RELEASE_VERSION}" _tag="${_t}-v${RELEASE_VERSION}"
if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then if git -C "$SCRIPT_DIR" rev-parse -q --verify "refs/tags/$_tag" >/dev/null; then
_existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag") _existing=$(git -C "$SCRIPT_DIR" rev-list -n 1 "$_tag")
@ -777,7 +780,7 @@ else
echo "Version: v$RELEASE_VERSION" echo "Version: v$RELEASE_VERSION"
echo "" echo ""
echo "Tags created locally on main (push when ready):" echo "Tags created locally on main (push when ready):"
for _t in archive transmittal classifier mdedit landing form zddc-server; do for _t in archive transmittal classifier mdedit landing form browse zddc-server; do
echo " ${_t}-v${RELEASE_VERSION}" echo " ${_t}-v${RELEASE_VERSION}"
done done
echo " git push origin main && git push origin --tags" echo " git push origin main && git push origin --tags"

View file

@ -222,7 +222,7 @@ _emit_build_label_sidecar() {
# Tools that participate in the lockstep release. Source of truth — used # Tools that participate in the lockstep release. Source of truth — used
# by helpers that enumerate "all release artifacts" (matrix render, # by helpers that enumerate "all release artifacts" (matrix render,
# coordinated next-stable, channel-link verifier). # coordinated next-stable, channel-link verifier).
ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form zddc-server" ZDDC_RELEASE_TOOLS="archive transmittal classifier mdedit landing form browse zddc-server"
# Compute the next-stable target for a single tool — patch-bump of its own # Compute the next-stable target for a single tool — patch-bump of its own
# latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's # latest <tool>-vX.Y.Z tag. Used by compute_build_label so a tool's
@ -663,7 +663,7 @@ verify_channel_links() {
_missing=0 _missing=0
_verified=0 _verified=0
for _t in archive transmittal classifier mdedit landing form; do for _t in archive transmittal classifier mdedit landing form browse; do
for _ch in stable beta alpha; do for _ch in stable beta alpha; do
_f="$_rdir/${_t}_${_ch}.html" _f="$_rdir/${_t}_${_ch}.html"
if [ -e "$_f" ]; then if [ -e "$_f" ]; then

View file

@ -18,6 +18,8 @@ var (
// requestDir. Rules: // requestDir. Rules:
// //
// - archive: every directory (multi-project, project, archive, vendor) // - archive: every directory (multi-project, project, archive, vendor)
// - browse: every directory (generic file listing — also the default
// served at folder URLs without an index.html; see directory.go)
// - classifier: requestDir is, or descends from, a folder named // - classifier: requestDir is, or descends from, a folder named
// "Incoming", "Working", or "Staging" (the directories where // "Incoming", "Working", or "Staging" (the directories where
// incoming/outgoing files get classified) // incoming/outgoing files get classified)
@ -37,6 +39,8 @@ func AppAvailableAt(root, requestDir, app string) bool {
switch app { switch app {
case "archive": case "archive":
return true return true
case "browse":
return true
case "landing": case "landing":
return requestDir == root return requestDir == root
case "classifier": case "classifier":

View file

@ -27,6 +27,9 @@ var embeddedMdedit []byte
//go:embed embedded/index.html //go:embed embedded/index.html
var embeddedLanding []byte var embeddedLanding []byte
//go:embed embedded/browse.html
var embeddedBrowse []byte
// EmbeddedBytes returns the embedded HTML for app, or nil if either app is // EmbeddedBytes returns the embedded HTML for app, or nil if either app is
// not one of the canonical names or the embedded slot is empty (no build // not one of the canonical names or the embedded slot is empty (no build
// has populated it). // has populated it).
@ -43,6 +46,8 @@ func EmbeddedBytes(app string) []byte {
b = embeddedMdedit b = embeddedMdedit
case "landing": case "landing":
b = embeddedLanding b = embeddedLanding
case "browse":
b = embeddedBrowse
default: default:
return nil return nil
} }

View file

View file

@ -62,6 +62,8 @@ func MatchAppHTML(requestPath string) (app string, requestDirRel string) {
return "classifier", dir return "classifier", dir
case "mdedit.html": case "mdedit.html":
return "mdedit", dir return "mdedit", dir
case "browse.html":
return "browse", dir
} }
return "", "" return "", ""
} }

View file

@ -2,13 +2,13 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
"codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/config"
appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs" appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
@ -89,14 +89,26 @@ func ServeDirectory(cfg config.Config, w http.ResponseWriter, r *http.Request) {
return return
} }
// Minimal HTML for accidental browser navigation // Browser HTML fallback: serve the embedded `browse` tool. It's a
// single-file SPA whose autoDetectServerMode loads the JSON listing
// for the current directory and renders it as a sortable, filterable
// tree. Same bytes that get served at /<dir>/browse.html — but at
// the bare directory URL too, so any zddc-served folder presents a
// usable file browser to anyone who navigates to it.
body := apps.EmbeddedBytes("browse")
if len(body) == 0 {
// Bootstrap state: a fresh build hasn't populated browse.html
// into the embed yet. Fall through to JSON for clients that
// will still parse it.
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
if err := json.NewEncoder(w).Encode(entries); err != nil {
slog.Error("encoding directory listing (no-embed fallback)", "err", err)
}
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, w.Header().Set("X-ZDDC-Source", "embedded:browse")
`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Index of %s</title></head>`+ w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
`<body><h1>Index of %s</h1>`+ _, _ = w.Write(body)
`<p>This server is designed to be used with the ZDDC Archive Browser. `+
`Directory listings are available as JSON (Accept: application/json).</p>`+
`</body></html>`,
urlPath, urlPath,
)
} }