From 94b2e294481128601552da564ebfb71d272a95b0 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 14 May 2026 12:12:42 -0500 Subject: [PATCH] =?UTF-8?q?feat(browse):=20SPA=20overhaul=20=E2=80=94=20co?= =?UTF-8?q?ntext=20menu,=20YAML=20editor,=20icons,=20hovercard,=20deep=20l?= =?UTF-8?q?inks,=20autofilter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major upgrade to the browse tool's UX, plus a few shared modules other tools can adopt. User-facing: - Right-click context menu on tree rows AND empty pane space. Traditional file-manager grouping (Open / Download / New / Rename-Delete / Copy / Tree ops / View). Items stay visible but disabled when not applicable so muscle memory carries. Generic shared/context-menu.js framework supports normal items, toggles, submenus, separators, danger styling. - YAML editor for .yaml / .yml / .zddc files (CodeMirror 5 vendored at shared/vendor/codemirror-yaml.min.*). js-yaml lint on every change for parse errors. For .zddc cascade files, an additional schema-aware lint pass flags unknown keys, bad enum values, and wrong types. - Per-row drag-drop upload using webkitGetAsEntry (folder uploads work recursively). Per-row drop indicator; doc-level overlay still fires for blank-space drops at drop_target scopes. - New folder / New markdown file context-menu items (server mode). Rename + Delete with native confirm() dialog. File-API helpers removeNode / renameNode use the existing PUT/POST/DELETE endpoints. - Hover info card with the row's full metadata (ZDDC fields + filesystem info + path/URL). Interactive — mouse into it, drag-select text, Ctrl/Cmd-C or right-click → Copy. 200ms grace before dismiss. - Autofilter input at the top of the tree pane. Same grammar as archive's column filters (zddc.filter.parse / matches). Filters files; folders without matches collapse out. Non-matching folders force-open visually when descendants match, without mutating the user's actual expand state. - Two-line ZDDC label: title-first, tracking/rev/status as monospace meta below. Icon column anchors to the title line. Chevron is a Lucide outline `chevron-right` SVG, rotated 90° on `.expanded`. - File-type Lucide icon sprite (shared/icons.js — 16 outline glyphs, ~5 KB). PDF / Word / Spreadsheet / Slides / Image / Video / Audio / CAD / Web / Config / Code / Archive get distinct icons; folders tinted with --primary. - Header wraps gracefully at narrow viewports (shared/base.css flex-wrap + title min-width:0 ellipsis). Body becomes flex column in browse so a wrapping header doesn't break #appMain height. - Markdown editor opens in WYSIWYG mode by default. YAML front-matter + TOC sidebar reworked: flexbox layout (single visible resizer between FM and TOC), both bodies overflow:auto for X+Y scrollbars. - `?file=` deep links open browse pre-positioned at a specific file. Multi-segment paths walk into subdirectories on the way. Auto-flips Show hidden when a segment is dot/underscore-prefixed. - Refresh + show-hidden toggle preserve expansion / selection / preview pinning. Path-keyed snapshot survives a re-fetched listing. - "Add Local Directory" → "Use Local Directory" across the four tools that have it (browse, archive, classifier, +transmittal comment). Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/build.sh | 11 + browse/css/preview-yaml.css | 110 +++++ browse/css/tree.css | 390 +++++++++++++---- browse/js/app.js | 75 ++++ browse/js/download.js | 93 ++-- browse/js/events.js | 585 ++++++++++++++++++++++++-- browse/js/hovercard.js | 258 ++++++++++++ browse/js/init.js | 18 +- browse/js/preview-markdown.js | 16 +- browse/js/preview-yaml.js | 533 +++++++++++++++++++++++ browse/js/preview.js | 13 + browse/js/tree.js | 294 +++++++++++-- browse/js/upload.js | 353 ++++++++++++++-- browse/template.html | 59 ++- shared/base.css | 31 +- shared/context-menu.css | 109 +++++ shared/context-menu.js | 381 +++++++++++++++++ shared/icons.js | 162 +++++++ shared/vendor/codemirror-yaml.min.css | 79 ++++ shared/vendor/codemirror-yaml.min.js | 1 + 20 files changed, 3301 insertions(+), 270 deletions(-) create mode 100644 browse/css/preview-yaml.css create mode 100644 browse/js/hovercard.js create mode 100644 browse/js/preview-yaml.js create mode 100644 shared/context-menu.css create mode 100644 shared/context-menu.js create mode 100644 shared/icons.js create mode 100644 shared/vendor/codemirror-yaml.min.css create mode 100644 shared/vendor/codemirror-yaml.min.js diff --git a/browse/build.sh b/browse/build.sh index 72838b6..cd71584 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -27,8 +27,12 @@ concat_files \ "../shared/nav.css" \ "../shared/logo.css" \ "../shared/vendor/toastui-editor.min.css" \ + "../shared/vendor/codemirror-yaml.min.css" \ + "../shared/context-menu.css" \ + "../shared/elevation.css" \ "css/base.css" \ "css/tree.css" \ + "css/preview-yaml.css" \ > "$css_temp" # JS files: shared canonical helpers, then browse modules. @@ -39,6 +43,8 @@ concat_files \ concat_files \ "../shared/vendor/jszip.min.js" \ "../shared/vendor/utif.min.js" \ + "../shared/vendor/js-yaml.min.js" \ + "../shared/vendor/codemirror-yaml.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ @@ -49,12 +55,17 @@ concat_files \ "../shared/logo.js" \ "../shared/help.js" \ "../shared/preview-lib.js" \ + "../shared/context-menu.js" \ + "../shared/elevation.js" \ + "../shared/icons.js" \ "../shared/zddc-source.js" \ "js/init.js" \ "js/loader.js" \ "js/tree.js" \ "js/preview.js" \ "js/preview-markdown.js" \ + "js/preview-yaml.js" \ + "js/hovercard.js" \ "js/grid.js" \ "js/upload.js" \ "js/download.js" \ diff --git a/browse/css/preview-yaml.css b/browse/css/preview-yaml.css new file mode 100644 index 0000000..082edb1 --- /dev/null +++ b/browse/css/preview-yaml.css @@ -0,0 +1,110 @@ +/* preview-yaml.css — YAML editor pane styling. Mirrors the + .md-shell info-header geometry; everything below is a CodeMirror 5 + host with dark-mode overrides so the editor blends into the theme + instead of fighting it. */ + +.yaml-shell { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + background: var(--bg); +} + +.yaml-shell__editor { + min-height: 0; + overflow: hidden; + position: relative; +} + +/* Schema-label badge — extends .md-shell__source so it sits next to + "local"/"server"/"read-only (zip)" with the same chip styling. The + primary-colored variant distinguishes ".zddc schema" from the + plain "YAML" label. */ +.yaml-shell__schema { + font-style: normal; +} +.yaml-shell__schema:not(:empty) { + border-color: var(--primary); + color: var(--primary); +} + +/* CodeMirror has to fill the grid cell. The vendored CSS sets + `height: 300px` by default — we override to 100% so it grows with + the preview pane. */ +.yaml-shell__editor .CodeMirror { + height: 100%; + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.45; + background: var(--bg); + color: var(--text); +} + +.yaml-shell__editor .CodeMirror-gutters { + background: var(--bg-secondary); + border-right: 1px solid var(--border); +} + +.yaml-shell__editor .CodeMirror-linenumber { + color: var(--text-muted); +} + +.yaml-shell__editor .CodeMirror-cursor { + border-left-color: var(--text); +} + +.yaml-shell__editor .CodeMirror-selected { + background: var(--bg-selected); +} + +.yaml-shell__editor .CodeMirror-focused .CodeMirror-selected { + background: var(--primary-light); +} + +/* YAML token tints. CM5 emits semantic class names from the yaml + mode; map them onto our palette so themes flip with the OS / data + attribute. */ +.yaml-shell__editor .cm-keyword, +.yaml-shell__editor .cm-atom { color: var(--primary); font-weight: 600; } +.yaml-shell__editor .cm-string { color: #2e8b57; } +.yaml-shell__editor .cm-comment { color: var(--text-muted); font-style: italic; } +.yaml-shell__editor .cm-number { color: #b06000; } +.yaml-shell__editor .cm-meta { color: #6f42c1; } + +@media (prefers-color-scheme: dark) { + html:not([data-theme="light"]) .yaml-shell__editor .cm-string { color: #98c379; } + html:not([data-theme="light"]) .yaml-shell__editor .cm-number { color: #e5c07b; } + html:not([data-theme="light"]) .yaml-shell__editor .cm-meta { color: #c678dd; } +} +[data-theme="dark"] .yaml-shell__editor .cm-string { color: #98c379; } +[data-theme="dark"] .yaml-shell__editor .cm-number { color: #e5c07b; } +[data-theme="dark"] .yaml-shell__editor .cm-meta { color: #c678dd; } + +/* Lint markers: keep CM's defaults for the gutter dots but make the + inline underline play nicely with our background. Errors stay red, + warnings amber. */ +.yaml-shell__editor .CodeMirror-lint-mark-error { + background-image: none; + border-bottom: 2px wavy var(--danger); +} +.yaml-shell__editor .CodeMirror-lint-mark-warning { + background-image: none; + border-bottom: 2px wavy var(--warning); +} + +/* Tooltip popping out of a lint marker — uses the shared menu shadow + so it doesn't look like a separate component. */ +.CodeMirror-lint-tooltip { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.10); + font-family: var(--font); + font-size: 0.82rem; + padding: 0.3rem 0.55rem; + max-width: 32rem; +} diff --git a/browse/css/tree.css b/browse/css/tree.css index 56ee0f5..9c6d912 100644 --- a/browse/css/tree.css +++ b/browse/css/tree.css @@ -4,15 +4,33 @@ html, body { margin: 0; padding: 0; height: 100%; - overflow: hidden; font-family: var(--font); color: var(--text); background-color: var(--bg); } +/* Body is a flex column so the header (which may wrap to a second + row at narrow viewports), #appMain, and the status bar each get + their natural height — no more fixed-pixel calc() that breaks + when the header reflows. Horizontal overflow scrolls on the body + as a final fallback when content can't shrink any further. */ +body { + display: flex; + flex-direction: column; + height: 100vh; + overflow-x: auto; + overflow-y: hidden; + /* Hard floor for the body. Below this, the html-level scrollbar + picks up and the user can pan horizontally rather than seeing + the right edge clipped. */ + min-width: 320px; +} + #appMain { position: relative; - height: calc(100vh - 2.65rem); /* clear .app-header */ + flex: 1 1 auto; + min-height: 0; + height: auto; /* override the old calc(100vh - 2.65rem) */ display: flex; flex-direction: column; overflow: hidden; @@ -109,12 +127,6 @@ html, body { vertical-align: -0.15em; } -.toolbar__count { - font-size: 0.8rem; - color: var(--text-muted); - white-space: nowrap; -} - /* ── Two-pane browse view ────────────────────────────────────────────────── */ .browse-view { @@ -139,6 +151,42 @@ html, body { flex-shrink: 0; } +.tree-pane__toolbar { + padding: 0.4rem 0.5rem; + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); + flex-shrink: 0; +} + +/* Single-input autofilter — same grammar as the archive app's column + filters (terms, quotes, !negation, multi-word AND). type=search so + the browser ships the native clear-X for free; the .filter-active + class amber-highlights the input while a query is set, matching + the archive `.column-filter.filter-active` cue. */ +.tree-filter { + width: 100%; + box-sizing: border-box; + padding: 0.3rem 0.5rem; + font-family: var(--font); + font-size: 0.85rem; + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + outline: none; + transition: border-color 0.12s, background 0.12s; +} + +.tree-filter:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px var(--primary-light); +} + +.tree-filter.filter-active { + background: rgba(234, 179, 8, 0.18); + border-color: rgba(234, 179, 8, 0.7); +} + .tree-pane__body { flex: 1; overflow: auto; @@ -250,9 +298,12 @@ html, body { .tree-row { display: flex; - align-items: center; + /* Top-aligned so the chevron + icon anchor to the title line on + two-line ZDDC rows. Single-line rows are unaffected because the + icon, chevron, and label all share a top edge. */ + align-items: flex-start; gap: 0.25rem; - padding: 0.15rem 0.5rem; + padding: 0.2rem 0.5rem; cursor: pointer; user-select: none; border-radius: 0; @@ -268,37 +319,76 @@ html, body { color: var(--text); } +/* Per-row drop target highlight: applied while a file/folder drag is + hovering this row. The dashed outline reads as "drop here" without + shifting layout. */ +.tree-row.is-droptarget { + background: var(--primary-light); + outline: 2px dashed var(--primary); + outline-offset: -2px; +} + .tree-row.is-selected .tree-name__label { color: var(--text); } .tree-name__chevron { - display: inline-block; + /* Fixed-width slot so leaf rows (empty chevron) still align with + expandable rows. The SVG inside is sized via the rule below. + Top-anchored to the title-line baseline by the row's flex-start + alignment + this small top offset. */ + display: inline-flex; + align-items: center; + justify-content: center; width: 1rem; - text-align: center; - color: var(--text-muted); + height: 1.2em; flex-shrink: 0; - font-family: monospace; - font-size: 0.65rem; + color: var(--text-muted); } -.tree-row[data-isdir="true"] .tree-name__chevron::before, -.tree-row[data-iszip="true"] .tree-name__chevron::before { - content: "▸"; +.tree-name__chevron svg { + width: 0.85em; + height: 0.85em; + transition: transform 0.12s ease; } -.tree-row[data-isdir="true"].expanded .tree-name__chevron::before, -.tree-row[data-iszip="true"].expanded .tree-name__chevron::before { - content: "▾"; -} - -.tree-name__chevron--leaf::before { - content: ""; +/* Expanded state — rotate the same chevron 90° rather than swapping + to a second glyph. Smooth, single-sprite, and consistent with the + way most modern file trees indicate expand state. */ +.tree-row.expanded .tree-name__chevron svg { + transform: rotate(90deg); } .tree-name__icon { flex-shrink: 0; - font-size: 0.95rem; + /* Fixed-width column keeps label alignment consistent regardless + of which symbol the row picks. Height matches one line of label + text so the icon anchors to the title row on two-line layouts. */ + width: 1.2em; + height: 1.2em; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.tree-name__icon svg { + width: 1em; + height: 1em; + display: block; +} + +/* Folder rows get the primary accent so directories stand out from + files at a glance — same convention as macOS Finder / GNOME Files. */ +.tree-row[data-isdir="true"] .tree-name__icon, +.tree-row[data-iszip="true"] .tree-name__icon { + color: var(--primary); +} + +/* Selected rows tint icon to match the label color (the bg-selected + token already differentiates the row background). */ +.tree-row.is-selected .tree-name__icon { + color: var(--text); } .tree-name__label { @@ -306,6 +396,48 @@ html, body { text-overflow: ellipsis; white-space: nowrap; color: var(--text); + min-width: 0; +} + +/* Two-line ZDDC variant. Top line is monospace + small + muted so the + trackingNumber / revision / status fields line up vertically across + adjacent rows (every field has a fixed width by convention). Bottom + line is the human-readable title at normal weight. */ +.tree-name__label--zddc { + display: flex; + flex-direction: column; + line-height: 1.15; + /* Tight gap between meta and title; tweak by 1-2 px if the rows + feel crowded on dense lists. */ + gap: 0.05rem; +} + +.tree-name__meta { + font-family: var(--font-mono); + font-size: 0.7rem; + /* Explicit weight: the folder-row rule below bolds .tree-name__label, + which would otherwise inherit through to the meta span. We want + the meta to stay light + muted on every row. */ + font-weight: 400; + color: var(--text-muted); + /* Belt-and-braces: monospace already gives column-alignment, but + tabular-nums hardens it on the rare proportional fallback. */ + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-name__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); +} + +.tree-row.is-selected .tree-name__title { + color: var(--text); } .tree-row[data-isdir="true"] .tree-name__label, @@ -427,12 +559,15 @@ html, body { overflow: hidden; } -/* Sidebar (col 1): two stacked sections — Front matter (top, fixed - default 180 px, drag-resizable) and TOC (bottom, takes the rest). */ +/* Sidebar (col 1): three stacked items — Front matter (fixed height, + drag-resizable), the horizontal resizer (between FM and TOC), then + the TOC section taking the remaining height. Flexbox keeps the + resizer position unambiguous; the previous grid-overlay approach + was hard to read and prone to misplacement. */ .md-shell__sidebar { grid-area: sidebar; - display: grid; - grid-template-rows: 180px 1fr; /* JS overrides on resize */ + display: flex; + flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid var(--border); @@ -460,20 +595,17 @@ html, body { outline: none; } -/* Horizontal resizer between front-matter and TOC inside the sidebar. - Spans both rows by placement, then absolutely positioned to overlay - the grid-row boundary. */ +/* Horizontal resizer — a real flex item between FM and TOC. Drag + it up/down to change the front-matter pane's height; the JS + handler updates fmSection.style.height directly. */ .md-shell__fmresizer { - grid-column: 1; - grid-row: 1; - align-self: end; - justify-self: stretch; + flex: 0 0 6px; height: 6px; - margin-bottom: -3px; cursor: row-resize; - background: transparent; - z-index: 2; + background: var(--border); transition: background 0.12s; + /* Subtle "grab" affordance — a slightly darker bar appears on + hover so users see this is the drag handle. */ } .md-shell__fmresizer:hover, .md-shell__fmresizer.is-dragging, @@ -558,15 +690,30 @@ html, body { } .md-side { - display: grid; - grid-template-rows: auto 1fr; + display: flex; + flex-direction: column; min-height: 0; overflow: hidden; } -.md-side--toc { - border-top: 1px solid var(--border); + +/* Front-matter section: fixed (resizable) height, set inline by the + markdown plugin's mount + drag-handler. flex:0 0 auto so the + explicit height wins over the parent flex layout. */ +.md-side--fm { + flex: 0 0 auto; } + +/* TOC section: takes everything that's left. min-height:0 so the + inner body's overflow:auto kicks in instead of pushing the + resizer off-screen. */ +.md-side--toc { + flex: 1 1 auto; + min-height: 0; +} + .md-side__header { + /* Header is its own flex item so the body can stretch to fill. */ + flex: 0 0 auto; padding: 0.35rem 0.75rem; background: var(--bg-secondary); border-bottom: 1px solid var(--border); @@ -576,8 +723,13 @@ html, body { letter-spacing: 0.06em; color: var(--text-muted); } + .md-side__body { - overflow-y: auto; + /* Both axes — the textarea uses white-space:pre so long YAML + lines need horizontal scroll, and the TOC entries below now + extend their full width so deep headings need it too. */ + flex: 1 1 auto; + overflow: auto; min-height: 0; padding: 0.3rem 0; font-size: 0.85rem; @@ -604,10 +756,11 @@ html, body { cursor: pointer; border-left: 2px solid transparent; transition: background 0.1s, border-color 0.1s, color 0.1s; - /* Truncate long headings rather than wrap; the title attribute - carries the full text. */ - overflow: hidden; - text-overflow: ellipsis; + /* Single-line items but no ellipsis — long headings extend the + item's intrinsic width, and the parent .md-side__body has + overflow:auto, so they create a horizontal scrollbar instead + of getting clipped. The title attribute still carries the + full text for SR users. */ white-space: nowrap; } .md-toc__item:hover { @@ -670,44 +823,105 @@ html, body { cursor: not-allowed; } -/* ── Sort control ────────────────────────────────────────────────────────── */ -.sort-control { - display: inline-flex; - align-items: center; - gap: 0.35rem; - font-size: 0.8rem; - color: var(--text-muted); - white-space: nowrap; -} - -.sort-control__label { - user-select: none; -} - -.sort-control__select { - font-family: var(--font); - font-size: 0.8rem; - padding: 0.2rem 0.4rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); - color: var(--text); - cursor: pointer; -} - -.sort-control__select:focus { - outline: 2px solid var(--primary); - outline-offset: -1px; -} - -.sort-control__checkbox { - /* Pair with the "Show hidden" label as a unified control. The - parent .sort-control already does horizontal flex + gap, so the - checkbox just needs sensible vertical alignment + a clickable - hit target. */ - margin: 0; - cursor: pointer; -} - /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced by the .md-shell BEM block above. */ + +/* ── Hover info card ────────────────────────────────────────────────────── */ +/* Singleton element appended to by browse/js/hovercard.js. + Replaces the native title="…" tooltip on tree rows with a rich + metadata view (ZDDC parse fields + filesystem info). */ +.tree-hovercard { + position: fixed; + z-index: 9000; + max-width: 28rem; + min-width: 17rem; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), + 0 2px 6px rgba(0, 0, 0, 0.10); + padding: 0.5rem 0.7rem 0.45rem; + font-family: var(--font); + font-size: 0.8rem; + line-height: 1.35; + opacity: 0; + visibility: hidden; + /* pointer-events:auto so the user can mouse into the card to + select text. The hide is delayed (HIDE_DELAY_MS in hovercard.js) + so the cursor has time to traverse the gap between row and card + before the card dismisses. */ + pointer-events: auto; + /* The tree rows set user-select:none — explicitly allow it here + so dragging across the card builds a real selection that can be + Ctrl/Cmd-C'd or right-click-Copied via the browser's native menu. */ + user-select: text; + cursor: default; + transition: opacity 0.1s ease; +} + +.tree-hovercard.is-visible { + opacity: 1; + visibility: visible; +} + +/* Highlight selected text inside the card with the primary accent so + it reads as "yes, you can copy this" rather than the default browser + selection color. */ +.tree-hovercard ::selection { + background: var(--primary-light); + color: var(--text); +} + +.tree-hovercard__header { + margin-bottom: 0.35rem; +} + +.tree-hovercard__title { + font-weight: 600; + font-size: 0.95rem; + line-height: 1.2; + color: var(--text); + word-break: break-word; +} + +.tree-hovercard__sub { + margin-top: 0.15rem; + font-family: var(--font-mono); + font-size: 0.72rem; + color: var(--text-muted); + letter-spacing: 0.01em; +} + +.tree-hovercard__list { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.12rem 0.7rem; + align-items: baseline; +} + +.tree-hovercard__key { + color: var(--text-muted); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.tree-hovercard__val { + color: var(--text); + font-size: 0.82rem; + word-break: break-word; +} + +.tree-hovercard__val--mono { + font-family: var(--font-mono); + font-size: 0.78rem; +} + +/* Separator stretches across both grid columns. Bleed into the + card's padding so it visually reads as a divider, not a hairline. */ +.tree-hovercard__sep { + grid-column: 1 / -1; + border-top: 1px solid var(--border); + margin: 0.25rem -0.7rem; +} diff --git a/browse/js/app.js b/browse/js/app.js index 0ceb90c..014bff3 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -19,9 +19,76 @@ // Expose for events.js's client-side rescope on dblclick. window.app.modules.augmentRoot = passThroughEntries; + // Walk a `?file=` path segment-by-segment from the current root. + // Each non-leaf segment is matched against the parent's children + // by name; if found and it's a folder, expand+load it (so its + // children populate state.nodes) and recurse into them. The leaf + // segment becomes the selected/previewed entry. Silently no-ops + // when any segment doesn't resolve — deep links aren't a hard + // contract, just an affordance. + async function openDeepLink(path) { + var segs = path.split('/').filter(Boolean); + if (segs.length === 0) return; + var tree = window.app.modules.tree; + var prev = window.app.modules.preview; + + // Lookup helper: find a node by name within a given parent's + // immediate children. Top-level walk uses state.rootIds. + function findChild(parentIds, name) { + for (var i = 0; i < parentIds.length; i++) { + var n = window.app.state.nodes.get(parentIds[i]); + if (n && n.name === name) return n; + } + return null; + } + + var ids = window.app.state.rootIds; + for (var i = 0; i < segs.length; i++) { + var node = findChild(ids, segs[i]); + if (!node) return; // segment not present in this listing + if (i === segs.length - 1) { + // Leaf — select + preview. + window.app.state.selectedId = node.id; + window.app.state.lastPreviewedNodeId = node.id; + tree.render(); + if (prev && !node.isDir) prev.showFilePreview(node); + return; + } + // Intermediate — must be a folder we can expand into. + if (!(node.isDir || node.isZip)) return; + if (!node.loaded) { + await tree.toggleFolder(node.id); // loads + sets expanded + } else if (!node.expanded) { + node.expanded = true; + } + ids = node.childIds; + } + } + async function bootstrap() { events.init(); + // Honor ?file= deep links: external clients (the profile + // page's "edit your .zddc files" list, future bookmarks, etc.) + // can link directly to "open browse at , with this entry + // selected and previewed". Single-segment names (?file=foo.md) + // match in the current directory; multi-segment paths + // (?file=a/b/foo.md) walk into a/ then b/ then open foo.md, + // loading intermediate directories on the way. + // + // When the LEAF (or any intermediate segment) is hidden + // (.zddc, .form.yaml, …), flip showHidden ON BEFORE the + // initial listing fetch so dotfiles appear in the tree. + var qs = new URLSearchParams(location.search); + var deepFile = qs.get('file'); + if (deepFile) { + var segs = deepFile.split('/').filter(Boolean); + for (var si = 0; si < segs.length; si++) { + var c = segs[si].charAt(0); + if (c === '.' || c === '_') { state.showHidden = true; break; } + } + } + // 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 @@ -40,6 +107,14 @@ // response, re-resolve so an /incoming URL auto-activates // grid mode. if (events.applyResolvedViewMode) events.applyResolvedViewMode(); + + // Final step of the deep link: walk the path segment by + // segment, expanding + loading intermediate directories + // before opening the leaf. Single-segment names use the + // same code path with one iteration. + if (deepFile) { + await openDeepLink(deepFile); + } } // Else: empty state stays visible; user can click Select Directory. diff --git a/browse/js/download.js b/browse/js/download.js index ed3334d..5dc4171 100644 --- a/browse/js/download.js +++ b/browse/js/download.js @@ -1,13 +1,16 @@ -// download.js — "Download (zip)" for the currently-viewed directory. +// download.js — per-node downloads, surfaced through the tree's +// right-click menu (downloadFile / downloadFolder). // -// Server mode: just point an at "?zip=1" — -// zddc-server streams an ACL-filtered .zip of the subtree, so nothing -// is held in the browser. +// downloadFile: a single file. Server mode lets the browser pull +// node.url (zddc-server emits Content-Disposition); FS-API mode +// reads bytes through the file handle and blob-downloads. // -// FS-API (offline) mode: there's no server, so we walk the picked -// folder ourselves, bundle every file with JSZip, and download the -// blob. A two-pass walk (metadata first, then bytes) lets us warn -// before loading a very large tree into memory. +// downloadFolder: an arbitrary directory node as a .zip. Server +// mode points an at "/?zip=1" so zddc-server +// streams an ACL-filtered archive without buffering on the client. +// FS-API mode walks the picked handle in two passes — metadata +// first, then bytes — so we can warn before loading a very large +// tree into memory. (function () { 'use strict'; @@ -103,39 +106,75 @@ ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)'); } - function downloadServerSubtree() { - var dir = (state.currentPath || '/').replace(/\/$/, ''); - var name = (dir.split('/').filter(Boolean).pop()) || 'download'; - events().statusInfo('Preparing ' + name + '.zip…'); - downloadUrl(name + '.zip', dir + '/?zip=1'); - // The browser owns the download from here; clear the hint shortly. - setTimeout(function () { events().statusClear(); }, 2500); - } - var busy = false; - async function downloadCurrentSubtree() { + // Download a single file node. Server mode: rely on the node's + // own URL (the server emits Content-Disposition). FS mode: read + // bytes through the handle and trigger a blob download. Works + // for ordinary files, for .zip members (the loader sets node.url + // for zip members in server mode and a ZipFileHandle offline), + // and for the .zip file itself. + async function downloadFile(node) { if (busy) return; - var btn = document.getElementById('downloadZipBtn'); + if (!node || node.isDir) { + events().statusError('Not a file: ' + (node && node.name)); + return; + } busy = true; - if (btn) btn.disabled = true; try { - if (state.source === 'server') { - downloadServerSubtree(); - } else if (state.source === 'fs' && state.rootHandle) { - await downloadFsSubtree(state.rootHandle); + if (node.url) { + events().statusInfo('Downloading ' + node.name + '…'); + downloadUrl(node.name, node.url); + setTimeout(function () { events().statusClear(); }, 2500); + } else if (node.handle && typeof node.handle.getFile === 'function') { + events().statusInfo('Preparing ' + node.name + '…'); + var f = await node.handle.getFile(); + var blob = new Blob([await f.arrayBuffer()]); + downloadBlob(node.name, blob); + events().statusInfo('Downloaded ' + node.name); } else { - events().statusError('Nothing to download — open a directory first.'); + events().statusError('No download path for ' + node.name); + } + } catch (e) { + events().statusError('Download failed: ' + (e && e.message ? e.message : e)); + } finally { + busy = false; + } + } + + // Download an arbitrary folder node as a .zip — same dispatch as + // downloadCurrentSubtree but scoped to the picked node instead of + // state.currentPath / state.rootHandle. Server mode hits + // "/?zip=1"; FS mode walks the directory handle. + async function downloadFolder(node) { + if (busy) return; + if (!node || !node.isDir) { + events().statusError('Not a folder: ' + (node && node.name)); + return; + } + busy = true; + try { + if (state.source === 'server') { + var tree = window.app.modules.tree; + var dir = tree.pathFor(node).replace(/\/$/, ''); + events().statusInfo('Preparing ' + node.name + '.zip…'); + downloadUrl(node.name + '.zip', dir + '/?zip=1'); + setTimeout(function () { events().statusClear(); }, 2500); + } else if (state.source === 'fs' && node.handle + && node.handle.kind === 'directory') { + await downloadFsSubtree(node.handle); + } else { + events().statusError('Cannot download ' + node.name); } } catch (e) { events().statusError('Download failed: ' + (e && e.message ? e.message : e)); } finally { busy = false; - if (btn) btn.disabled = false; } } window.app.modules.download = { - downloadCurrentSubtree: downloadCurrentSubtree + downloadFile: downloadFile, + downloadFolder: downloadFolder }; })(); diff --git a/browse/js/events.js b/browse/js/events.js index 87b02d6..302e588 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -69,7 +69,6 @@ function applySourceUI() { var add = document.getElementById('addDirectoryBtn'); var refresh = document.getElementById('refreshHeaderBtn'); - var dlZip = document.getElementById('downloadZipBtn'); if (add) { if (state.source === 'server') { add.classList.remove('btn-primary'); @@ -86,18 +85,16 @@ refresh.classList.add('hidden'); } } - // "Download (zip)" is meaningful once a directory is loaded - // (server or local); it zips the directory currently in view. - if (dlZip) { - if (state.source) { - dlZip.classList.remove('hidden'); - } else { - dlZip.classList.add('hidden'); - } - } } async function refreshListing() { + // Snapshot expanded paths + selection BEFORE setRoot clears the + // tree, then re-apply after the new root is in place. Keeps + // the user's layout (which folders were open, which row was + // highlighted, what the preview was pinned to) stable across + // a refresh — including the auto-refresh triggered by the + // "Show hidden files" toggle. + var snap = tree.snapshotState(); if (state.source === 'server') { var raw; try { @@ -107,6 +104,7 @@ return; } tree.setRoot(raw); + await tree.restoreState(snap); tree.render(); statusInfo('Refreshed (' + raw.length + ' item' + (raw.length === 1 ? '' : 's') + ')'); @@ -119,6 +117,7 @@ return; } tree.setRoot(raw2); + await tree.restoreState(snap); tree.render(); statusInfo('Refreshed'); } @@ -132,38 +131,31 @@ var refresh = document.getElementById('refreshHeaderBtn'); if (refresh) refresh.addEventListener('click', refreshListing); - var dlZip = document.getElementById('downloadZipBtn'); - if (dlZip) dlZip.addEventListener('click', function () { - var d = window.app.modules.download; - if (d) d.downloadCurrentSubtree(); - }); - - // Sort dropdown — change → tree re-renders with the new sort. - // Format of option value: ":". Defaults match - // state.sort initial values (name:asc). - var sortSel = document.getElementById('sortBy'); - if (sortSel) { - sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc'); - sortSel.addEventListener('change', function () { - var parts = sortSel.value.split(':'); - var key = parts[0]; - var dir = parts[1] === 'desc' ? -1 : 1; - tree.setSortExplicit(key, dir); + // Tree autofilter — parses input through zddc.filter.parse so + // the same query grammar that the archive app uses (terms, + // quotes, !negation, multi-word AND) works here. The AST is + // cached on state.filterAST; tree.render reads it and skips + // non-matching rows. Escape clears. + var filterInput = document.getElementById('treeFilter'); + if (filterInput) { + var filterDebounce = null; + var applyFilter = function () { + var raw = filterInput.value || ''; + state.filterText = raw; + state.filterAST = raw ? window.zddc.filter.parse(raw) : null; + filterInput.classList.toggle('filter-active', !!raw); + tree.render(); + }; + filterInput.addEventListener('input', function () { + if (filterDebounce) clearTimeout(filterDebounce); + filterDebounce = setTimeout(applyFilter, 80); }); - } - - // "Show hidden" checkbox — toggles state.showHidden, which the - // loader reads to append ?hidden=1 to listing requests. Re-uses - // the existing refreshListing flow so the tree pulls a fresh - // listing. ACL is still server-side; this just relaxes the - // client-visible filter for entries the user is already - // allowed to read. - var hiddenCb = document.getElementById('showHidden'); - if (hiddenCb) { - hiddenCb.checked = !!state.showHidden; - hiddenCb.addEventListener('change', function () { - state.showHidden = hiddenCb.checked; - refreshListing(); + filterInput.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && filterInput.value) { + e.preventDefault(); + filterInput.value = ''; + applyFilter(); + } }); } @@ -314,9 +306,520 @@ } navigateIntoFolder(node); }); + + // Right-click → context menu. Two surfaces: + // - on a tree row: per-row menu (Open, Rename, Delete, …) + // - on empty space in the pane: directory-scope menu + // (New folder, Refresh, Sort by, …) + treeBody.addEventListener('contextmenu', function (e) { + e.preventDefault(); + var row = e.target.closest('.tree-row'); + if (row) { + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + state.selectedId = id; + tree.render(); + window.zddc.menu.open({ + x: e.clientX, + y: e.clientY, + context: { node: node, row: row }, + items: buildTreeRowMenu + }); + } else { + window.zddc.menu.open({ + x: e.clientX, + y: e.clientY, + context: { dir: state.currentPath || '/' }, + items: buildPaneMenu + }); + } + }); + + // Per-row drag-drop. Any row is a drop target — folders + // upload into themselves; files upload into their parent + // folder. Highlighting is purely visual; server-side ACL + // is the source of truth (a 403 surfaces as an error toast). + wirePerRowDrop(treeBody); } } + // ── Per-row drag/drop targets ───────────────────────────────────────── + + // Translate a node into the directory that should receive uploads + // dropped onto its row. Folders → themselves; files → their parent. + // Returns a server path with a trailing slash, or null when there's + // no usable destination (offline mode, virtual node, etc.). + function targetDirForNode(node) { + if (!node || node.virtual) return null; + if (state.source !== 'server') return null; + if (node.isZip) return null; // can't upload INTO a zip via PUT + var dirNode = node; + if (!node.isDir) { + if (node.parentId == null) { + // Top-level file → upload to current scope. + return state.currentPath || '/'; + } + dirNode = state.nodes.get(node.parentId); + if (!dirNode) return null; + } + var p = tree.pathFor(dirNode); + if (!p.endsWith('/')) p += '/'; + return p; + } + + function dragHasFiles(e) { + if (!e.dataTransfer || !e.dataTransfer.types) return false; + var types = e.dataTransfer.types; + for (var i = 0; i < types.length; i++) { + if (types[i] === 'Files') return true; + } + return false; + } + + function wirePerRowDrop(treeBody) { + var lastOver = null; + function clearHighlight() { + if (lastOver) { + lastOver.classList.remove('is-droptarget'); + lastOver = null; + } + } + treeBody.addEventListener('dragover', function (e) { + if (!dragHasFiles(e)) return; + var row = e.target.closest('.tree-row'); + if (!row) { clearHighlight(); return; } + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + var dest = targetDirForNode(node); + if (!dest) { + if (e.dataTransfer) e.dataTransfer.dropEffect = 'none'; + clearHighlight(); + return; + } + e.preventDefault(); // signals "this is a drop target" + e.stopPropagation(); // suppress doc-level overlay + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + if (lastOver !== row) { + clearHighlight(); + row.classList.add('is-droptarget'); + lastOver = row; + } + }); + treeBody.addEventListener('dragleave', function (e) { + // dragleave fires on row crossings too — only clear when the + // pointer actually leaves the tree body. + if (!e.relatedTarget || !treeBody.contains(e.relatedTarget)) { + clearHighlight(); + } + }); + treeBody.addEventListener('drop', async function (e) { + if (!dragHasFiles(e)) return; + var row = e.target.closest('.tree-row'); + clearHighlight(); + if (!row) return; + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (!node) return; + var dest = targetDirForNode(node); + if (!dest) return; + e.preventDefault(); + e.stopPropagation(); // pre-empt doc-level handler + var up = window.app.modules.upload; + if (!up) return; + try { + await up.uploadToDir(dest, e.dataTransfer); + } catch (err) { + statusError('Upload failed: ' + (err.message || err)); + } + }); + } + + // ── Create new folder / file (server mode) ──────────────────────────── + + // Reject names with path separators, leading dots, or empty input — + // mirrors the server-side hidden-segment / no-traversal guards so + // the user sees the rejection without a round-trip. + function validateName(name) { + name = (name || '').trim(); + if (!name) return { ok: false, msg: 'Name required.' }; + if (name.indexOf('/') !== -1) return { ok: false, msg: 'No slashes allowed.' }; + if (name === '.' || name === '..') return { ok: false, msg: 'Invalid name.' }; + if (name.charAt(0) === '.' || name.charAt(0) === '_') { + return { ok: false, msg: 'Names beginning with "." or "_" are reserved.' }; + } + return { ok: true, name: name }; + } + + // Resolve "the directory new items go into" for a given row. + // Folders/zips: create inside them. Files: create alongside (in + // their parent). Used by the row-context New menu items. + function parentDirFor(node) { + var parentDir; + if (!node) { + parentDir = state.currentPath || '/'; + } else if (node.isDir || node.isZip) { + parentDir = tree.pathFor(node); + } else if (node.parentId != null) { + var parent = state.nodes.get(node.parentId); + parentDir = parent ? tree.pathFor(parent) : (state.currentPath || '/'); + } else { + parentDir = state.currentPath || '/'; + } + if (!parentDir.endsWith('/')) parentDir += '/'; + return parentDir; + } + + async function createInDir(parentDir, kind) { + var up = window.app.modules.upload; + if (!up) return; + var promptMsg = kind === 'folder' + ? 'New folder name (under ' + parentDir + '):' + : 'New markdown filename (under ' + parentDir + '):'; + var defaultName = kind === 'folder' ? 'new-folder' : 'new.md'; + var raw = window.prompt(promptMsg, defaultName); + if (raw == null) return; + var v = validateName(raw); + if (!v.ok) { + statusError(v.msg); + return; + } + try { + if (kind === 'folder') { + await up.makeDir(parentDir, v.name); + statusInfo('Created folder ' + v.name); + } else { + var name = /\.(md|markdown)$/i.test(v.name) ? v.name : v.name + '.md'; + var template = '# ' + name.replace(/\.(md|markdown)$/i, '') + '\n\n'; + await up.makeFile(parentDir, name, template, 'text/markdown; charset=utf-8'); + statusInfo('Created ' + name); + } + await reloadDir(parentDir); + } catch (e) { + statusError('Create failed: ' + (e.message || e)); + } + } + + function createInside(node, kind) { return createInDir(parentDirFor(node), kind); } + + // Reload a directory's children in the tree so a create/delete/ + // rename is reflected. Works for both the current scope (root) + // and any expanded subdirectory. + async function reloadDir(dirPath) { + var loader = window.app.modules.loader; + if (!loader) return; + if (!dirPath.endsWith('/')) dirPath += '/'; + // Root-scope reload — refresh the visible top-level listing. + if (dirPath === state.currentPath) { + try { + var es = state.source === 'server' + ? await loader.fetchServerChildren(dirPath) + : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); + tree.setRoot(es); + } catch (_e) { /* swallow */ } + tree.render(); + return; + } + // Otherwise find the node whose path matches and reload it. + var noSlash = dirPath.replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir) return; + if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; + }); + if (hit) { + try { + var raw = state.source === 'server' + ? await loader.fetchServerChildren(dirPath) + : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); + tree.setChildren(hit.id, raw); + hit.expanded = true; + } catch (_e) { /* swallow */ } + tree.render(); + } + } + + // ── Rename / Delete ─────────────────────────────────────────────────── + + async function renameNode(node) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return; + var raw = window.prompt('Rename "' + node.name + '" to:', node.name); + if (raw == null) return; + var v = validateName(raw); + if (!v.ok) { statusError(v.msg); return; } + if (v.name === node.name) return; + try { + await up.renameNode(node, v.name); + statusInfo('Renamed to ' + v.name); + var parentPath = node.parentId != null + ? tree.pathFor(state.nodes.get(node.parentId)) + : (state.currentPath || '/'); + await reloadDir(parentPath); + } catch (e) { + statusError('Rename failed: ' + (e.message || e)); + } + } + + async function deleteNode(node) { + var up = window.app.modules.upload; + if (!up || !up.canMutate(node)) return; + var what = node.isDir ? 'folder' : 'file'; + // Native confirm() is intentional — destructive actions + // benefit from the browser's blocking, OS-styled dialog + // (signals "this is serious"). A custom modal would look + // friendlier; we want it to NOT look friendly. + var msg = 'Permanently delete this ' + what + '?\n\n' + node.name; + if (node.isDir) { + msg += '\n\nThis will remove every file inside it.'; + } + if (!window.confirm(msg)) return; + try { + await up.removeNode(node); + statusInfo('Deleted ' + node.name); + // Clear selection / preview when they pointed at the + // now-gone node, so the right pane doesn't keep a ghost. + if (state.selectedId === node.id) state.selectedId = null; + if (state.lastPreviewedNodeId === node.id) { + state.lastPreviewedNodeId = null; + var pb = document.getElementById('previewBody'); + if (pb) pb.innerHTML = + '
Click a file in the tree to preview it.
'; + var pt = document.getElementById('previewTitle'); + if (pt) pt.textContent = 'No file selected'; + var pm = document.getElementById('previewMeta'); + if (pm) pm.textContent = ''; + } + var parentPath = node.parentId != null + ? tree.pathFor(state.nodes.get(node.parentId)) + : (state.currentPath || '/'); + await reloadDir(parentPath); + } catch (e) { + statusError('Delete failed: ' + (e.message || e)); + } + } + + // Shared submenu (used by both the row menu and the pane menu). + // Toggle items so the active sort is checked in both surfaces. + var SORT_BY_ITEMS = [ + { label: 'Name', + checked: function () { return state.sort.key === 'name'; }, + action: function () { tree.setSortExplicit('name', 1); } }, + { label: 'Modified', + checked: function () { return state.sort.key === 'date'; }, + action: function () { tree.setSortExplicit('date', -1); } }, + { label: 'Size', + checked: function () { return state.sort.key === 'size'; }, + action: function () { tree.setSortExplicit('size', -1); } }, + { label: 'Type', + checked: function () { return state.sort.key === 'ext'; }, + action: function () { tree.setSortExplicit('ext', 1); } } + ]; + + // Row context menu — traditional file-manager layout: + // Open / Open in new tab / Pop out preview + // ─ + // Download (label flips on type) + // ─ + // New folder / New markdown file + // ─ + // Rename / Delete (permission-gated, disabled + // when the row can't be mutated) + // ─ + // Copy path / Copy name + // ─ + // Expand / Collapse / Navigate into + // ─ + // Sort by … / Show hidden files + // + // 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. + function buildTreeRowMenu(ctx) { + var serverMode = state.source === 'server'; + var canMutate = function (c) { + var up = window.app.modules.upload; + return !!(up && up.canMutate(c.node)); + }; + return [ + // ── Open / preview cluster ── + { + label: function (c) { + if (c.node.isDir) return 'Open'; + if (c.node.isZip) return 'Open archive'; + return 'Preview'; + }, + disabled: function (c) { return !!c.node.virtual; }, + action: function (c) { + if (c.node.isDir || c.node.isZip) { + tree.toggleFolder(c.node.id); + } else { + var p = previewMod(); + if (p) p.showFilePreview(c.node); + } + } + }, + { + label: 'Open in new tab', + accel: 'Ctrl+Click', + disabled: function (c) { return !c.node.url; }, + action: function (c) { + if (c.node.url) window.open(c.node.url, '_blank', 'noopener'); + } + }, + { + label: 'Pop out preview', + disabled: function (c) { return c.node.isDir || c.node.isZip; }, + action: function (c) { + var p = previewMod(); + if (p) p.showFilePreview(c.node, { popup: true }); + } + }, + { separator: true }, + + // ── Download (single item; label flips on type) ── + { + label: function (c) { return c.node.isDir ? 'Download ZIP' : 'Download'; }, + icon: '⤓', + disabled: function (c) { return !!c.node.virtual; }, + action: function (c) { + var d = window.app.modules.download; + if (!d) return; + if (c.node.isDir) d.downloadFolder(c.node); + else d.downloadFile(c.node); + } + }, + { separator: true }, + + // ── Create new (in the row's parent folder) ── + { + label: 'New folder', + disabled: !serverMode, + action: function (c) { createInside(c.node, 'folder'); } + }, + { + label: 'New markdown file', + disabled: !serverMode, + action: function (c) { createInside(c.node, 'markdown'); } + }, + { separator: true }, + + // ── Rename + Delete (the permission-gated pair) ── + { + label: 'Rename…', + disabled: function (c) { return !canMutate(c); }, + action: function (c) { renameNode(c.node); } + }, + { + label: 'Delete…', + icon: '🗑', + danger: true, + disabled: function (c) { return !canMutate(c); }, + action: function (c) { deleteNode(c.node); } + }, + { separator: true }, + + // ── Clipboard / identifiers ── + { + label: 'Copy path', + action: function (c) { + var path = tree.pathFor(c.node); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(path).then( + function () { statusInfo('Copied: ' + path); }, + function () { statusError('Clipboard copy denied'); } + ); + } else { + statusInfo(path); + } + } + }, + { + label: 'Copy name', + action: function (c) { + // Always include the file extension. node.name + // already does for normal listings, but re-joining + // via zddc.joinExtension is defensive against any + // upstream that ever returns the basename split. + var n = c.node.name; + var ext = c.node.ext; + if (!c.node.isDir && ext + && !n.toLowerCase().endsWith('.' + ext.toLowerCase())) { + n = window.zddc.joinExtension(n, ext); + } + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(n); + } + statusInfo('Copied: ' + n); + } + }, + { separator: true }, + + // ── Tree-view ops (folder/zip rows only) ── + { + label: 'Expand subtree', + accel: 'Shift+Click', + disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, + action: function (c) { tree.expandSubtree(c.node.id); } + }, + { + label: 'Collapse subtree', + disabled: function (c) { return !(c.node.isDir || c.node.isZip); }, + action: function (c) { tree.collapseSubtree(c.node.id); } + }, + { + label: 'Navigate into', + accel: 'Dbl-click', + disabled: function (c) { return !c.node.isDir; }, + action: function (c) { navigateIntoFolder(c.node); } + }, + { separator: true }, + + // ── View ── + { label: 'Sort by', items: SORT_BY_ITEMS }, + { label: 'Show hidden files', + checked: function () { return !!state.showHidden; }, + action: function () { + state.showHidden = !state.showHidden; + refreshListing(); + } } + ]; + } + + // Right-click on empty space in the tree pane → directory-scope + // menu. Operations apply to the current scope (state.currentPath), + // not any specific row. + function buildPaneMenu() { + var serverMode = state.source === 'server'; + return [ + { + label: 'New folder', + disabled: !serverMode, + action: function () { createInDir(state.currentPath || '/', 'folder'); } + }, + { + label: 'New markdown file', + disabled: !serverMode, + action: function () { createInDir(state.currentPath || '/', 'markdown'); } + }, + { separator: true }, + { + label: 'Refresh', + accel: 'F5', + action: function () { refreshListing(); } + }, + { separator: true }, + { label: 'Sort by', items: SORT_BY_ITEMS }, + { label: 'Show hidden files', + checked: function () { return !!state.showHidden; }, + action: function () { + state.showHidden = !state.showHidden; + refreshListing(); + } } + ]; + } + // View mode is URL-driven, not UI-driven. // // ?view=grid → grid mode (only honored where classifier is diff --git a/browse/js/hovercard.js b/browse/js/hovercard.js new file mode 100644 index 0000000..bbbcd61 --- /dev/null +++ b/browse/js/hovercard.js @@ -0,0 +1,258 @@ +// hovercard.js — rich-metadata tooltip for tree rows. +// +// Replaces the native title="…" attribute with a custom card that +// surfaces every field we know about for a row: parsed ZDDC fields +// (trackingNumber / revision / status / title / date), type, size, +// modTime, on-server path, and URL. A delayed reveal (~350 ms) keeps +// the card out of the way during fast traversal; it dismisses on +// any click, right-click, scroll, or row change. +// +// Singleton DOM element appended to ; positioned fixed. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + var SHOW_DELAY_MS = 350; + // Grace period after the cursor leaves the row before the card + // hides. Lets the user move INTO the card to select / copy text; + // the card cancels this timer on mouseenter. + var HIDE_DELAY_MS = 200; + + var state = window.app.state; + var card = null; + var showTimer = null; + var hideTimer = null; + var currentRow = null; + + function ensureCard() { + if (card) return card; + card = document.createElement('div'); + card.className = 'tree-hovercard'; + card.setAttribute('aria-hidden', 'true'); + // Mouse interaction inside the card: cancel any pending hide + // so the user can stay in it as long as they want, then re- + // schedule on leave. Pointer-events:auto in the CSS lets the + // mouse enter; user-select:text (default) lets them drag a + // selection; right-click inside fires the browser's native + // Copy menu since we never call preventDefault for it here. + card.addEventListener('mouseenter', cancelHide); + card.addEventListener('mouseleave', scheduleHide); + document.body.appendChild(card); + return card; + } + + function cancelHide() { + if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } + } + + function scheduleHide() { + cancelHide(); + hideTimer = setTimeout(hide, HIDE_DELAY_MS); + } + + function hide() { + if (showTimer) { clearTimeout(showTimer); showTimer = null; } + cancelHide(); + if (card) card.classList.remove('is-visible'); + currentRow = null; + } + + // ── Formatting (kept local so this module is self-contained) ── + + function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + 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 typeLabelFor(node) { + if (node.isDir) return 'Folder'; + if (node.isZip) return 'Zip archive'; + if (node.ext) return node.ext.toUpperCase() + ' file'; + return 'File'; + } + + function buildRowsHtml(node) { + var tree = window.app.modules.tree; + var z = window.zddc; + var parsed = null; + if (z) { + parsed = node.isDir + ? z.parseFolder(node.name) + : z.parseFilename(node.name); + } + + var html = ''; + + // ZDDC fields first when the basename parses. + if (parsed && parsed.valid) { + if (parsed.date) html += kv('Date', parsed.date, true); + if (parsed.trackingNumber) html += kv('Tracking number', parsed.trackingNumber, true); + if (parsed.revision) html += kv('Revision', parsed.revision, true); + if (parsed.status) html += kv('Status', parsed.status, true); + if (parsed.title) html += kv('Title', parsed.title); + html += '
'; + } else if (node.displayName) { + // Operator-supplied display name — only useful as info if + // it differs from the on-disk name. + html += kv('Display name', node.displayName); + } + + html += kv('Type', typeLabelFor(node)); + if (!node.isDir) html += kv('Filename', node.name, true); + if (!node.isDir && node.size != null) html += kv('Size', fmtSize(node.size)); + if (node.modTime) html += kv('Modified', fmtDate(node.modTime)); + if (node.virtual) html += kv('Virtual', 'Not yet created on disk'); + + // Path comes last (longest, most likely to wrap). + var path = tree ? tree.pathFor(node) : ''; + if (path) html += kv('Path', path, true); + if (node.url && node.url !== path) html += kv('URL', node.url, true); + + return html; + } + + function kv(key, val, mono) { + return '' + escapeHtml(key) + '' + + '' + escapeHtml(val) + ''; + } + + function render(node) { + var z = window.zddc; + var parsed = z + ? (node.isDir ? z.parseFolder(node.name) : z.parseFilename(node.name)) + : null; + + var primary, secondary = ''; + if (parsed && parsed.valid) { + primary = parsed.title; + var parts = node.isDir + ? [parsed.date, parsed.trackingNumber, parsed.status] + : [parsed.trackingNumber, parsed.revision, parsed.status]; + secondary = parts.filter(Boolean).join(' · '); + } else if (node.displayName) { + primary = node.displayName; + } else { + primary = node.name; + } + + card.innerHTML = '' + + '
' + + '
' + escapeHtml(primary) + '
' + + (secondary + ? '
' + escapeHtml(secondary) + '
' + : '') + + '
' + + '
' + buildRowsHtml(node) + '
'; + } + + function position(row) { + // Two-pass measure: temporarily make visible-but-invisible so + // we can read offsetWidth / offsetHeight, compute placement, + // then reveal at the final coordinates. + card.style.left = '0px'; + card.style.top = '0px'; + card.style.visibility = 'hidden'; + card.classList.add('is-visible'); + var cw = card.offsetWidth; + var ch = card.offsetHeight; + var rect = row.getBoundingClientRect(); + var GAP = 8; + var x = rect.right + GAP; + if (x + cw > window.innerWidth - GAP) { + x = rect.left - cw - GAP; + } + if (x < GAP) { + // Fallback: anchor under the row (last resort when the + // pane is wide enough that neither side fits). + x = Math.max(GAP, Math.min(rect.left, window.innerWidth - cw - GAP)); + } + var y = rect.top; + if (y + ch > window.innerHeight - GAP) { + y = Math.max(GAP, window.innerHeight - ch - GAP); + } + if (y < GAP) y = GAP; + card.style.left = x + 'px'; + card.style.top = y + 'px'; + card.style.visibility = ''; + } + + function showFor(row, node) { + ensureCard(); + render(node); + position(row); + card.classList.add('is-visible'); + } + + function init() { + var treeBody = document.getElementById('treeBody'); + if (!treeBody) return; + + treeBody.addEventListener('mouseover', function (e) { + // Returning to the tree from the card cancels any pending + // hide; the show logic below handles row changes. + cancelHide(); + var row = e.target.closest('.tree-row'); + if (row === currentRow) return; + // Row → row or row → empty space — reset. + if (showTimer) { clearTimeout(showTimer); showTimer = null; } + if (card) card.classList.remove('is-visible'); + currentRow = row || null; + if (!row) return; + showTimer = setTimeout(function () { + if (currentRow !== row) return; + var id = parseInt(row.dataset.id, 10); + var node = state.nodes.get(id); + if (node) showFor(row, node); + }, SHOW_DELAY_MS); + }); + + // Leaving the tree schedules a hide rather than hiding + // immediately, so the cursor has time to traverse the gap to + // the card. The card's own mouseenter cancels the hide. + treeBody.addEventListener('mouseleave', scheduleHide); + treeBody.addEventListener('contextmenu', hide); + window.addEventListener('scroll', hide, true); + window.addEventListener('resize', hide); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') hide(); + }); + + // Click anywhere outside the card dismisses it. Clicks INSIDE + // the card are allowed through so the user can drag-select + // text, right-click for the browser's native Copy menu, or + // hit Ctrl/Cmd-C. + document.addEventListener('mousedown', function (e) { + if (!card || !card.classList.contains('is-visible')) return; + if (card.contains(e.target)) return; + hide(); + }, true); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + window.app.modules.hovercard = { hide: hide }; +})(); diff --git a/browse/js/init.js b/browse/js/init.js index e7975b2..9e3b8c6 100644 --- a/browse/js/init.js +++ b/browse/js/init.js @@ -8,6 +8,15 @@ window.app = { modules: {}, state: {} }; } + // Mount the shared Lucide outline-icon sprite into before + // the tree first renders. The sprite is hidden (display:none on + // the outer ) — it only exists so per-row + // refs resolve. Falls back to deferring until DOMContentLoaded + // when isn't ready yet. + if (window.zddc && window.zddc.icons) { + window.zddc.icons.inject(); + } + window.app.state = { // Source: 'server' | 'fs' | null. Determines how the loader // resolves entries. @@ -61,6 +70,13 @@ // scopeDefaultTool: cascade's default_tool at currentPath // (empty when no default declared) scopeDropTarget: false, - scopeDefaultTool: '' + scopeDefaultTool: '', + + // Autofilter — when non-empty, the tree hides files that + // don't match and folders whose subtree has no matches. + // Parsed once on input change so visibleIds() / rowHtml() + // can run filter.matches(text, ast) cheaply per node. + filterText: '', + filterAST: null }; })(); diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index a97ba13..81cf7fb 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -346,13 +346,18 @@ container.appendChild(shell); // ── Sidebar (col 1): front matter (top) + TOC (bottom) ────────────── + // Sidebar is a flex column: FM section (fixed height, set + // inline below) + horizontal resizer + TOC section (1fr). var sidebar = document.createElement('div'); sidebar.className = 'md-shell__sidebar'; - sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr'; shell.appendChild(sidebar); var fmSection = document.createElement('section'); fmSection.className = 'md-side md-side--fm'; + // Front-matter height is driven inline (persisted across + // remounts via lastFmHeight) so the resizer's drag-handler + // mutates a single source of truth. + fmSection.style.height = lastFmHeight + 'px'; var fmHeader = document.createElement('div'); fmHeader.className = 'md-side__header'; fmHeader.textContent = 'YAML front matter'; @@ -502,7 +507,10 @@ var editor = new window.toastui.Editor({ el: editorHost, height: '100%', - initialEditType: 'markdown', + // WYSIWYG by default — most users want the rendered view + // out of the gate; the markdown/WYSIWYG toggle in the + // Toast UI toolbar still flips to source mode in one click. + initialEditType: 'wysiwyg', previewStyle: 'vertical', initialValue: bodyText, usageStatistics: false, @@ -592,7 +600,7 @@ var dy = e.clientY - startY; var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy)); lastFmHeight = h; - sidebar.style.gridTemplateRows = h + 'px 1fr'; + fmSection.style.height = h + 'px'; e.preventDefault(); } function onUp() { @@ -616,7 +624,7 @@ var step = e.key === 'ArrowUp' ? -24 : 24; var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step)); lastFmHeight = h; - sidebar.style.gridTemplateRows = h + 'px 1fr'; + fmSection.style.height = h + 'px'; }); })(); diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js new file mode 100644 index 0000000..f2568d3 --- /dev/null +++ b/browse/js/preview-yaml.js @@ -0,0 +1,533 @@ +// preview-yaml.js — YAML editor plugin for the browse preview pane. +// +// Routes any .yaml / .yml file, plus the .zddc cascade files +// (`.zddc` and `*.zddc.yaml`), through a CodeMirror 5 editor with +// syntax highlighting and live linting. js-yaml.loadAll feeds parse +// errors into CM's lint gutter; for .zddc files an additional +// schema-aware pass flags unknown keys, bad enum values, and wrong +// types. +// +// Layout (single column): +// ┌─────────────────────────────────────────────────────────────┐ +// │ name | dirty | status | source | [Save] │ +// ├─────────────────────────────────────────────────────────────┤ +// │ CodeMirror editor (line numbers + lint gutter) │ +// └─────────────────────────────────────────────────────────────┘ +// +// Save (Ctrl+S) writes back via PUT (server mode) or +// FileSystemWritableFileStream (FS-API). Zip members and +// virtual nodes are read-only — Save stays disabled. +(function () { + 'use strict'; + + if (!window.app || !window.app.modules) return; + + function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + // ── Filename routing ──────────────────────────────────────────────────── + + // True for .zddc cascade files — `.zddc` (literal name, no ext) + // and `.zddc.yaml` (e.g. `defaults.zddc.yaml`). These + // get the schema-aware lint layer. + function isZddcFile(name) { + if (!name) return false; + if (name === '.zddc') return true; + return /\.zddc\.ya?ml$/i.test(name); + } + + function isYamlFile(node) { + if (!node || !node.name) return false; + if (isZddcFile(node.name)) return true; + var ext = (node.ext || '').toLowerCase(); + return ext === 'yaml' || ext === 'yml'; + } + + // ── Save (mirrors preview-markdown.js) ───────────────────────────────── + + async function saveContent(node, content) { + if (node.handle && typeof node.handle.createWritable === 'function') { + var writable = await node.handle.createWritable(); + await writable.write(content); + await writable.close(); + return; + } + if (node.url && window.app.state.source === 'server') { + var resp = await fetch(node.url, { + method: 'PUT', + headers: { 'Content-Type': 'application/x-yaml; charset=utf-8' }, + body: content, + credentials: 'same-origin' + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return; + } + throw new Error('No write target for this file (read-only source).'); + } + + function isZipMemberNode(node) { + if (node.handle && node.handle.isZipEntry) return true; + if (node.url && window.app.state.source === 'server' + && /\.zip\//i.test(node.url)) return true; + return false; + } + + function canSave(node) { + if (isZipMemberNode(node)) return false; + if (node.virtual) return false; + if (node.handle && typeof node.handle.createWritable === 'function') return true; + if (node.url && window.app.state.source === 'server') return true; + return false; + } + + async function hashContent(text) { + if (!window.crypto || !window.crypto.subtle) return null; + var enc = new TextEncoder().encode(text); + var buf = await window.crypto.subtle.digest('SHA-256', enc); + var bytes = new Uint8Array(buf); + var hex = ''; + for (var i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, '0'); + } + return hex; + } + + // ── .zddc schema ──────────────────────────────────────────────────────── + // + // Mirrors the Go-side decoder in zddc/internal/zddc/*. Allowed + // tool names are the embedded set (always available) plus the + // composable ones served when declared in apps:. Unknown keys at + // any level surface as warnings — typos like `defaul_tool` are + // common and the cascade silently ignores them. + + var ALLOWED_TOOLS = { + archive: 1, browse: 1, landing: 1, transmittal: 1, classifier: 1, + tables: 1, form: 1 + }; + + var TOP_KEYS = { + title: 'string', + acl: 'acl', + admins: 'string[]', + roles: 'rolemap', + available_tools: 'tools[]', + default_tool: 'tool', + dir_tool: 'tool', + auto_own: 'bool', + auto_own_fenced: 'bool', + virtual: 'bool', + drop_target: 'bool', + worm: 'string[]', + paths: 'pathmap', + display: 'stringmap', + apps: 'appsmap', + apps_pubkey: 'string', + tables: 'stringmap', + convert: 'convert', + created_by: 'string', + inherit: 'bool' + }; + + var ACL_KEYS = { inherit: 'bool', permissions: 'stringmap', + allow: 'string[]', deny: 'string[]' }; + var ROLE_KEYS = { members: 'string[]', reset: 'bool' }; + var CONVERT_KEYS = { client: 'string', project: 'string', + contractor: 'string', project_number: 'string' }; + + function typeOf(v) { + if (v === null || v === undefined) return 'null'; + if (Array.isArray(v)) return 'array'; + return typeof v; // 'string' | 'number' | 'boolean' | 'object' + } + + // Collect schema issues for a parsed .zddc document. Each issue is + // { keyPath: string[], message: string, severity: 'error' | 'warning' }. + // keyPath is used by findLine() to locate the offending source line. + function validateZddc(doc) { + var issues = []; + if (typeOf(doc) === 'null') return issues; + if (typeOf(doc) !== 'object') { + issues.push({ keyPath: [], severity: 'error', + message: 'Root must be a map (got ' + typeOf(doc) + ').' }); + return issues; + } + walkObject(doc, TOP_KEYS, [], issues); + return issues; + } + + function walkObject(obj, schema, path, issues) { + for (var key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; + var here = path.concat([key]); + var kind = schema[key]; + if (!kind) { + issues.push({ keyPath: here, severity: 'warning', + message: 'Unknown key "' + key + '" — typo? It will be silently ignored.' }); + continue; + } + checkValue(obj[key], kind, here, issues); + } + } + + function checkValue(val, kind, path, issues) { + var t = typeOf(val); + switch (kind) { + case 'string': + if (t !== 'string' && t !== 'null') addTypeErr(path, kind, t, issues); + return; + case 'bool': + if (t !== 'boolean' && t !== 'null') addTypeErr(path, kind, t, issues); + return; + case 'string[]': + if (t !== 'array' && t !== 'null') addTypeErr(path, kind, t, issues); + return; + case 'tools[]': + if (t !== 'array' && t !== 'null') { + addTypeErr(path, kind, t, issues); return; + } + if (t === 'array') { + for (var i = 0; i < val.length; i++) { + if (typeOf(val[i]) !== 'string') { + issues.push({ keyPath: path, severity: 'error', + message: 'available_tools[' + i + '] must be a string.' }); + } else if (!ALLOWED_TOOLS[val[i]]) { + issues.push({ keyPath: path, severity: 'warning', + message: 'Unknown tool "' + val[i] + + '". Known: ' + Object.keys(ALLOWED_TOOLS).join(', ') + '.' }); + } + } + } + return; + case 'tool': + if (t === 'null') return; + if (t !== 'string') { addTypeErr(path, kind, t, issues); return; } + if (!ALLOWED_TOOLS[val]) { + issues.push({ keyPath: path, severity: 'warning', + message: 'Unknown tool "' + val + '". Known: ' + + Object.keys(ALLOWED_TOOLS).join(', ') + '.' }); + } + return; + case 'stringmap': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + for (var k in val) { + if (!Object.prototype.hasOwnProperty.call(val, k)) continue; + if (typeOf(val[k]) !== 'string') { + issues.push({ keyPath: path.concat([k]), severity: 'error', + message: 'Value must be a string (got ' + + typeOf(val[k]) + ').' }); + } + } + return; + case 'pathmap': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + for (var seg in val) { + if (!Object.prototype.hasOwnProperty.call(val, seg)) continue; + if (seg.indexOf('/') !== -1) { + issues.push({ keyPath: path.concat([seg]), severity: 'error', + message: 'Path keys must be a single segment — ' + + 'nest blocks instead of using "' + seg + '".' }); + } + var v = val[seg]; + if (typeOf(v) === 'null') continue; + if (typeOf(v) !== 'object') { + issues.push({ keyPath: path.concat([seg]), severity: 'error', + message: 'paths.' + seg + ' must be a map of cascade rules.' }); + continue; + } + walkObject(v, TOP_KEYS, path.concat([seg]), issues); + } + return; + case 'appsmap': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + for (var app in val) { + if (!Object.prototype.hasOwnProperty.call(val, app)) continue; + if (!ALLOWED_TOOLS[app]) { + issues.push({ keyPath: path.concat([app]), severity: 'warning', + message: 'Unknown tool "' + app + '" in apps:.' }); + } + if (typeOf(val[app]) !== 'string') { + issues.push({ keyPath: path.concat([app]), severity: 'error', + message: 'apps.' + app + ' must be a spec string ' + + '(channel | v | URL | path).' }); + } + } + return; + case 'rolemap': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + for (var rn in val) { + if (!Object.prototype.hasOwnProperty.call(val, rn)) continue; + var rv = val[rn]; + if (typeOf(rv) === 'null') continue; + if (typeOf(rv) !== 'object') { + issues.push({ keyPath: path.concat([rn]), severity: 'error', + message: 'roles.' + rn + ' must be a map ({members, reset}).' }); + continue; + } + walkObject(rv, ROLE_KEYS, path.concat([rn]), issues); + } + return; + case 'acl': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + walkObject(val, ACL_KEYS, path, issues); + return; + case 'convert': + if (t === 'null') return; + if (t !== 'object') { addTypeErr(path, kind, t, issues); return; } + walkObject(val, CONVERT_KEYS, path, issues); + return; + } + } + + function addTypeErr(path, expected, got, issues) { + issues.push({ keyPath: path, severity: 'error', + message: 'Expected ' + expected + ', got ' + got + '.' }); + } + + // Locate the source line for a key path. .zddc files are + // block-style YAML in practice (no flow style, no anchors), so a + // simple indent-aware scan works: for each segment, find a line + // matching ":" whose indent is deeper than the + // previously-matched line. Falls back to line 0 if no match. + function findLine(source, keyPath) { + if (!keyPath || keyPath.length === 0) return 0; + var lines = source.split('\n'); + var prevIndent = -1; + var prevLine = 0; + for (var i = 0; i < keyPath.length; i++) { + var key = keyPath[i]; + var found = -1; + // Escape regex metachars in the key. + var keyRe = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + var re = new RegExp('^(\\s*)"?' + keyRe + '"?\\s*:'); + for (var j = prevLine; j < lines.length; j++) { + var m = lines[j].match(re); + if (m && m[1].length > prevIndent) { + found = j; + prevIndent = m[1].length; + prevLine = j + 1; + break; + } + } + if (found === -1) return prevLine > 0 ? prevLine - 1 : 0; + } + return prevLine > 0 ? prevLine - 1 : 0; + } + + // ── CodeMirror lint helper ────────────────────────────────────────────── + + function registerLinter(CM) { + // The lint helper signature: function(text, options, editor) → annotations[] + // Each annotation: { from, to, message, severity }. + CM.registerHelper('lint', 'yaml', function (text, _opts, editor) { + var out = []; + if (!window.jsyaml) return out; + var parsed; + try { + // loadAll handles multi-doc YAML; we only validate the + // first doc against the schema (the .zddc cascade reads + // only the first document). + var docs = []; + window.jsyaml.loadAll(text, function (d) { docs.push(d); }); + parsed = docs[0]; + } catch (e) { + var mark = e.mark; + var pos = mark ? CM.Pos(mark.line, mark.column) : CM.Pos(0, 0); + out.push({ from: pos, to: pos, severity: 'error', + message: e.message || String(e) }); + return out; + } + // Schema layer — only for .zddc cascade files. + var node = editor._zddcNode; + if (node && isZddcFile(node.name)) { + var issues = validateZddc(parsed); + for (var i = 0; i < issues.length; i++) { + var ln = findLine(text, issues[i].keyPath); + out.push({ + from: CM.Pos(ln, 0), + to: CM.Pos(ln, (editor.getLine(ln) || '').length), + severity: issues[i].severity, + message: issues[i].message + }); + } + } + return out; + }); + } + + // ── Mount ─────────────────────────────────────────────────────────────── + + var currentEditor = null; + + function dispose() { + // CM doesn't have an explicit destroy(); GC handles it once + // the host element is removed. Clear our reference so a stale + // editor doesn't keep handlers alive. + currentEditor = null; + } + + async function render(node, container, ctx) { + if (typeof window.CodeMirror === 'undefined') { + container.innerHTML = + '
' + + 'CodeMirror isn\'t bundled in this build.
'; + return; + } + dispose(); + + var text; + try { + var buf = await ctx.getArrayBuffer(node); + text = new TextDecoder('utf-8', { fatal: false }).decode(buf); + } catch (e) { + container.innerHTML = + '
' + + 'Could not read ' + escapeHtml(node.name) + ': ' + + escapeHtml(e.message || String(e)) + '
'; + return; + } + + container.innerHTML = ''; + var shell = document.createElement('div'); + shell.className = 'yaml-shell'; + container.appendChild(shell); + + // Info header — same look as the markdown plugin's info-header + // so the two editors feel like one family. + var infohdr = document.createElement('div'); + infohdr.className = 'md-shell__infohdr yaml-shell__infohdr'; + + var titleEl = document.createElement('span'); + titleEl.className = 'md-shell__title'; + titleEl.textContent = node.name; + titleEl.title = node.name; + + var schemaTag = document.createElement('span'); + schemaTag.className = 'md-shell__source yaml-shell__schema'; + if (isZddcFile(node.name)) { + schemaTag.textContent = '.zddc schema'; + schemaTag.title = 'Linted against the .zddc cascade schema ' + + '(unknown keys, bad enums, and wrong types are flagged).'; + } else { + schemaTag.textContent = 'YAML'; + } + + var dirtyEl = document.createElement('span'); + dirtyEl.className = 'md-shell__dirty'; + + var statusEl = document.createElement('span'); + statusEl.className = 'md-shell__status'; + + var sourceEl = document.createElement('span'); + sourceEl.className = 'md-shell__source'; + if (isZipMemberNode(node)) sourceEl.textContent = 'read-only (zip)'; + else if (node.handle) sourceEl.textContent = 'local'; + else if (node.url) sourceEl.textContent = 'server'; + + var saveBtn = document.createElement('button'); + saveBtn.className = 'btn btn-sm btn-primary md-shell__save'; + saveBtn.type = 'button'; + saveBtn.textContent = 'Save'; + saveBtn.disabled = true; + + infohdr.appendChild(titleEl); + infohdr.appendChild(schemaTag); + infohdr.appendChild(dirtyEl); + infohdr.appendChild(statusEl); + infohdr.appendChild(sourceEl); + infohdr.appendChild(saveBtn); + shell.appendChild(infohdr); + + var editorHost = document.createElement('div'); + editorHost.className = 'yaml-shell__editor'; + shell.appendChild(editorHost); + + // Register the lint helper once per page lifetime. + if (!window.CodeMirror.__zddcYamlLinterReady) { + registerLinter(window.CodeMirror); + window.CodeMirror.__zddcYamlLinterReady = true; + } + + var editor = window.CodeMirror(editorHost, { + value: text, + mode: 'yaml', + lineNumbers: true, + tabSize: 2, + indentUnit: 2, + indentWithTabs: false, + lineWrapping: false, + gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], + lint: { hasGutters: true } + }); + // Stash the node on the editor so the lint helper can decide + // whether to apply the .zddc schema layer. + editor._zddcNode = node; + // Force an initial lint pass now that _zddcNode is set. + editor.performLint(); + currentEditor = editor; + + var writable = canSave(node); + if (!writable) { + saveBtn.disabled = true; + saveBtn.title = 'Save not available — read-only source.'; + editor.setOption('readOnly', true); + } + + var initialHash = await hashContent(text); + + function markDirty(isDirty) { + saveBtn.disabled = !isDirty || !writable; + dirtyEl.textContent = isDirty ? '● modified' : ''; + } + + editor.on('change', async function () { + var h = await hashContent(editor.getValue()); + markDirty(h !== initialHash); + }); + + async function save() { + if (saveBtn.disabled) return; + var content = editor.getValue(); + try { + statusEl.textContent = 'Saving…'; + await saveContent(node, content); + initialHash = await hashContent(content); + markDirty(false); + statusEl.textContent = 'Saved ' + new Date().toLocaleTimeString(); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Saved ' + node.name, 'success'); + } + } catch (e) { + statusEl.textContent = 'Save failed: ' + (e.message || e); + if (window.zddc && window.zddc.toast) { + window.zddc.toast('Save failed: ' + (e.message || e), 'error'); + } + } + } + saveBtn.addEventListener('click', save); + editor.setOption('extraKeys', { + 'Ctrl-S': save, + 'Cmd-S': save + }); + + // CM defers layout until its host has a size — refresh after + // mount so the gutters and viewport sync to the grid cell. + setTimeout(function () { try { editor.refresh(); } catch (_e) {} }, 0); + } + + function handles(node) { + if (!node || node.isDir || node.isZip) return false; + return isYamlFile(node); + } + + window.app.modules.yamledit = { + handles: handles, + render: render + }; +})(); diff --git a/browse/js/preview.js b/browse/js/preview.js index 46b7b4f..f22ffa4 100644 --- a/browse/js/preview.js +++ b/browse/js/preview.js @@ -117,6 +117,19 @@ return; } + // YAML plugin: .yaml / .yml / .zddc / *.zddc.yaml route to a + // CodeMirror 5 editor with js-yaml linting; .zddc files also + // get a schema-aware lint pass. + var yamlMod = window.app.modules.yamledit; + if (yamlMod && yamlMod.handles(node)) { + try { + await yamlMod.render(node, container, { getArrayBuffer: getArrayBuffer }); + } catch (e) { + renderError(container, 'YAML render failed: ' + (e.message || e)); + } + return; + } + // PDF / HTML → iframe. if (ext === 'pdf' || ext === 'html' || ext === 'htm') { try { diff --git a/browse/js/tree.js b/browse/js/tree.js index 460af9b..12ddfc8 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -111,15 +111,24 @@ } // Walk nodes in render order. Skips the children of a collapsed - // expandable. + // expandable. When state.filterAST is set, also skips nodes that + // don't match (files) or whose subtree has no matches (folders), + // and force-walks into folders that have matching descendants so + // those matches are visible even when the user hadn't expanded + // the folder. The user's actual node.expanded flag stays untouched + // so clearing the filter restores their original layout. function visibleIds() { var out = []; function walk(ids) { for (var i = 0; i < ids.length; i++) { var n = state.nodes.get(ids[i]); if (!n) continue; + if (state.filterAST && !passesFilter(n)) continue; out.push(ids[i]); - if ((n.isDir || n.isZip) && n.expanded) walk(n.childIds); + if (n.isDir || n.isZip) { + var forceWalk = !!state.filterAST; + if (forceWalk || n.expanded) walk(n.childIds); + } } } // Re-sort everything at all levels so a sort change reorders @@ -132,6 +141,59 @@ return out; } + // ── Filter ───────────────────────────────────────────────────────────── + + // Build the haystack string we run the filter AST against. We + // concatenate every searchable field — name, displayName, plus any + // ZDDC parts the basename parses to — so users can type a tracking + // number, a status code, a date, or a piece of the title. + function filterHaystack(node) { + var parts = [node.name]; + if (node.displayName) parts.push(node.displayName); + var z = window.zddc; + if (z) { + var parsed = node.isDir ? z.parseFolder(node.name) + : z.parseFilename(node.name); + if (parsed && parsed.valid) { + if (parsed.trackingNumber) parts.push(parsed.trackingNumber); + if (parsed.title) parts.push(parsed.title); + if (parsed.status) parts.push(parsed.status); + if (parsed.revision) parts.push(parsed.revision); + if (parsed.date) parts.push(parsed.date); + } + } + return parts.join(' '); + } + + function nodeMatchesFilter(node) { + if (!state.filterAST) return true; + return window.zddc.filter.matches(filterHaystack(node), state.filterAST); + } + + // True when this node should appear in the filtered view: either + // the node itself matches, or it's an expandable with at least + // one matching descendant (so we keep the path to a match visible). + function passesFilter(node) { + if (!state.filterAST) return true; + if (nodeMatchesFilter(node)) return true; + if (!(node.isDir || node.isZip)) return false; + if (!node.loaded) return false; // unloaded subtrees aren't searched + for (var i = 0; i < node.childIds.length; i++) { + var child = state.nodes.get(node.childIds[i]); + if (child && passesFilter(child)) return true; + } + return false; + } + + // Is this folder being "forced open" by an active filter because + // a descendant matches? Used by rowHtml to render the chevron as + // expanded without mutating node.expanded. + function filterForcesOpen(node) { + if (!state.filterAST) return false; + if (!(node.isDir || node.isZip)) return false; + return passesFilter(node) && !nodeMatchesFilter(node); + } + // ── Rendering ──────────────────────────────────────────────────────── function fmtSize(bytes) { @@ -154,6 +216,127 @@ .replace(/>/g, '>').replace(/"/g, '"'); } + // Per-extension icon map → Lucide outline-icon sprite ids. The + // actual SVG markup is produced by window.zddc.icons.html(id), + // which inlines `` so the page CSS + // can size and tint via currentColor. + // + // book-marked PDF file-pen markdown + // file-text word / txt file-spreadsheet spreadsheet + // presentation slides file-image image + // file-video video file-audio audio + // ruler CAD / drawing globe web + // file-cog config / .zddc file-code source code + // file-archive non-nav archive folder-archive .zip (navigable) + // file generic folder directory + var ICON_BY_EXT = { + pdf: 'icon-book-marked', + md: 'icon-file-pen', markdown: 'icon-file-pen', + doc: 'icon-file-text', docx: 'icon-file-text', rtf: 'icon-file-text', odt: 'icon-file-text', + xls: 'icon-file-spreadsheet', xlsx: 'icon-file-spreadsheet', + csv: 'icon-file-spreadsheet', ods: 'icon-file-spreadsheet', tsv: 'icon-file-spreadsheet', + ppt: 'icon-presentation', pptx: 'icon-presentation', odp: 'icon-presentation', + txt: 'icon-file-text', log: 'icon-file-text', + jpg: 'icon-file-image', jpeg: 'icon-file-image', png: 'icon-file-image', + gif: 'icon-file-image', webp: 'icon-file-image', svg: 'icon-file-image', + bmp: 'icon-file-image', tif: 'icon-file-image', tiff: 'icon-file-image', + ico: 'icon-file-image', heic: 'icon-file-image', + mp4: 'icon-file-video', mov: 'icon-file-video', avi: 'icon-file-video', + mkv: 'icon-file-video', webm: 'icon-file-video', m4v: 'icon-file-video', + mp3: 'icon-file-audio', wav: 'icon-file-audio', flac: 'icon-file-audio', + ogg: 'icon-file-audio', m4a: 'icon-file-audio', aac: 'icon-file-audio', + dwg: 'icon-ruler', dxf: 'icon-ruler', step: 'icon-ruler', + stp: 'icon-ruler', iges: 'icon-ruler', igs: 'icon-ruler', + html: 'icon-globe', htm: 'icon-globe', + yaml: 'icon-file-cog', yml: 'icon-file-cog', json: 'icon-file-cog', + toml: 'icon-file-cog', ini: 'icon-file-cog', xml: 'icon-file-cog', + conf: 'icon-file-cog', cfg: 'icon-file-cog', + '7z': 'icon-file-archive', rar: 'icon-file-archive', tar: 'icon-file-archive', + gz: 'icon-file-archive', tgz: 'icon-file-archive', + bz2: 'icon-file-archive', xz: 'icon-file-archive', + // Code — share one glyph across languages so users build the + // "this is source" pattern. Distinguishing per language would + // be visual noise without much added signal. + js: 'icon-file-code', mjs: 'icon-file-code', cjs: 'icon-file-code', + ts: 'icon-file-code', tsx: 'icon-file-code', jsx: 'icon-file-code', + py: 'icon-file-code', go: 'icon-file-code', rs: 'icon-file-code', + c: 'icon-file-code', cc: 'icon-file-code', cpp: 'icon-file-code', + h: 'icon-file-code', hpp: 'icon-file-code', java: 'icon-file-code', + rb: 'icon-file-code', php: 'icon-file-code', sh: 'icon-file-code', + bash: 'icon-file-code', zsh: 'icon-file-code', lua: 'icon-file-code', + swift: 'icon-file-code', kt: 'icon-file-code', kts: 'icon-file-code', + css: 'icon-file-code', scss: 'icon-file-code', less: 'icon-file-code' + }; + + function symbolForNode(node) { + if (node.isDir) return 'icon-folder'; + if (node.isZip) return 'icon-folder-archive'; + // `.zddc` (no extension) is the cascade config — same family + // as yaml. Match the literal basename before falling through + // to the extension table. + if (node.name === '.zddc') return 'icon-file-cog'; + var ext = (node.ext || '').toLowerCase(); + return ICON_BY_EXT[ext] || 'icon-file'; + } + + function iconForNode(node) { + return window.zddc.icons.html(symbolForNode(node)); + } + + // Render the label cell for a row. When the basename parses as a + // ZDDC-conformant filename (files) or transmittal folder name + // (directories), split into a two-line layout: + // top — trackingNumber · [revision · ]status (small, muted) + // bot — title (normal weight) + // Otherwise fall back to a single line. + // + // .zddc `display:` overrides always render as a single line — the + // operator chose that string for a reason; we don't try to second- + // guess it by parsing for ZDDC structure. + function labelHtml(node) { + // No native title="…" — the rich hovercard (browse/js/hovercard.js) + // replaces the browser tooltip with a metadata view that's + // both more informative and styled to match the rest of the UI. + if (node.displayName) { + return '' + + escapeHtml(node.displayName) + + ''; + } + var z = window.zddc; + var parsed = null; + if (z) { + parsed = node.isDir + ? z.parseFolder(node.name) + : z.parseFilename(node.name); + } + if (parsed && parsed.valid) { + // Folders carry a date (no revision); files carry a + // revision (no date). Status is present on both. + var parts; + if (node.isDir) { + parts = [parsed.date, parsed.trackingNumber, parsed.status]; + } else { + parts = [parsed.trackingNumber, parsed.revision, parsed.status]; + } + var metaText = parts.filter(Boolean).join(' · '); + // Title-first: primary content on the top line so the row + // reads like a normal file manager / mail list. Meta sits + // below as the supporting "subtitle" — same hierarchy + // pattern as Gmail, Linear, Notion file rows. + return '' + + '' + + escapeHtml(parsed.title) + + '' + + '' + + escapeHtml(metaText) + + '' + + ''; + } + return '' + + escapeHtml(node.name) + + ''; + } + // Render a single tree row as a flat
. Indentation via // padding-left so the row's hover background spans the full // pane width. Files are rendered as plain rows (no anchor) — @@ -163,26 +346,39 @@ function rowHtml(node) { var indent = 0.4 + node.depth * 1.0; var expandable = node.isDir || node.isZip; - var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄'); + var iconChar = iconForNode(node); var chevronClass = 'tree-name__chevron' + (expandable ? '' : ' tree-name__chevron--leaf'); + // Outline Lucide chevron — single sprite glyph, rotated 90° + // via CSS for the expanded state. Leaf rows ship an empty + // chevron span so the icon column stays aligned. + var chevronGlyph = expandable + ? window.zddc.icons.html('icon-chevron-right') + : ''; + // While a filter is active, folders that contain a matching + // descendant are rendered as visually expanded so the user + // can see the match — even if node.expanded is still false. + // The actual flag stays untouched so clearing the filter + // restores the user's original tree shape. + var visuallyExpanded = node.expanded || filterForcesOpen(node); var selected = state.selectedId === node.id ? ' is-selected' : ''; var virtualCls = node.virtual ? ' tree-row--virtual' : ''; + // No native title — the hovercard surfaces a dedicated + // "Virtual: Not yet created on disk" row for these nodes. var virtualHint = node.virtual - ? '(empty)' + ? '(empty)' : ''; return '' - + '
' - + '' + + '' + chevronGlyph + '' + '' + iconChar + '' - + '' - + escapeHtml(node.displayName || node.name) + '' + + labelHtml(node) + virtualHint + '
'; } @@ -196,33 +392,9 @@ html += rowHtml(state.nodes.get(ids[i])); } body.innerHTML = html; - updateCount(); renderBreadcrumbs(); } - // Count nodes that render at the root + every expanded subtree. - function expandedSetSize() { - var n = 0; - function walk(ids) { - for (var i = 0; i < ids.length; i++) { - n++; - var node = state.nodes.get(ids[i]); - if (node && (node.isDir || node.isZip) && node.expanded) { - walk(node.childIds); - } - } - } - walk(state.rootIds); - return n; - } - - function updateCount() { - var el = document.getElementById('entryCount'); - if (!el) return; - var total = expandedSetSize(); - el.textContent = total + ' item' + (total === 1 ? '' : 's'); - } - // ── Breadcrumbs ────────────────────────────────────────────────────── // Inline outline home icon. Stroke-based so it tints with the @@ -431,6 +603,61 @@ return parts.join('/'); } + // ── State snapshot / restore ─────────────────────────────────────────── + // + // Used by refresh + show-hidden so the user doesn't lose their + // tree layout when the listing reloads. The key is the absolute + // path of each node, computed by pathFor; on restore we walk the + // new tree and re-apply expansion + selection to nodes whose + // paths match. + + function snapshotState() { + var expanded = {}; + var selectedPath = null; + var previewPath = null; + state.nodes.forEach(function (n) { + if ((n.isDir || n.isZip) && n.expanded) { + expanded[pathFor(n)] = true; + } + if (n.id === state.selectedId) selectedPath = pathFor(n); + if (n.id === state.lastPreviewedNodeId) previewPath = pathFor(n); + }); + return { + expanded: expanded, + selectedPath: selectedPath, + previewPath: previewPath + }; + } + + // Walk the current tree (already populated by setRoot) and re- + // load + expand every folder whose path appears in snapshot.expanded. + // Sets selectedId and lastPreviewedNodeId by matching the snapshot + // paths to the freshly-issued node IDs. + async function restoreState(snap) { + if (!snap) return; + async function walk(ids) { + for (var i = 0; i < ids.length; i++) { + var n = state.nodes.get(ids[i]); + if (!n) continue; + var p = pathFor(n); + if (snap.selectedPath && p === snap.selectedPath) { + state.selectedId = n.id; + } + if (snap.previewPath && p === snap.previewPath) { + state.lastPreviewedNodeId = n.id; + } + if ((n.isDir || n.isZip) && snap.expanded[p]) { + await loadChildren(n); + if (n.loaded) { + n.expanded = true; + await walk(n.childIds); + } + } + } + } + await walk(state.rootIds); + } + // Public API window.app.modules.tree = { setRoot: setRoot, @@ -439,6 +666,9 @@ toggleFolder: toggleFolder, expandSubtree: expandSubtree, collapseSubtree: collapseSubtree, + loadChildren: loadChildren, + snapshotState: snapshotState, + restoreState: restoreState, setSort: function (key) { if (state.sort.key === key) { state.sort.dir = -state.sort.dir; diff --git a/browse/js/upload.js b/browse/js/upload.js index aab51a0..dcb6691 100644 --- a/browse/js/upload.js +++ b/browse/js/upload.js @@ -85,13 +85,17 @@ return false; } - function uploadUrl(filename) { - var base = state.currentPath || '/'; + // Join a directory path and a relative path safely. dir is expected + // to be /-prefixed and may or may not have a trailing /; rel is a + // forward-slash relative path (no leading /). Each segment is + // URI-encoded so spaces and friends survive the round trip. + function joinUrl(dir, rel) { + var base = dir || '/'; if (!base.endsWith('/')) base += '/'; - return base + encodeURIComponent(filename); + return base + rel.split('/').map(encodeURIComponent).join('/'); } - async function uploadOne(file) { + async function uploadOne(file, destDir, relPath) { if (file.size > UPLOAD_MAX_BYTES) { return { file: file, @@ -101,7 +105,7 @@ }; } try { - var resp = await fetch(uploadUrl(file.name), { + var resp = await fetch(joinUrl(destDir, relPath), { method: 'PUT', body: file, credentials: 'same-origin', @@ -125,6 +129,286 @@ } } + // ── Folder-upload helpers (webkitGetAsEntry recursion) ───────────────── + // Browsers expose dropped folders only through the entries API. + // walkEntry flattens a tree into [{ relPath, file }] so uploadOne + // can PUT each file individually. The server's PUT auto-creates + // intermediate directories, so no explicit mkdir is needed. + + function readAllEntries(reader) { + return new Promise(function (resolve, reject) { + var collected = []; + function loop() { + reader.readEntries(function (batch) { + if (batch.length === 0) return resolve(collected); + collected = collected.concat(batch); + loop(); + }, reject); + } + loop(); + }); + } + + function entryToFile(entry) { + return new Promise(function (resolve, reject) { + entry.file(resolve, reject); + }); + } + + async function walkEntry(entry, prefix, out) { + if (entry.isFile) { + try { + var f = await entryToFile(entry); + out.push({ relPath: prefix + entry.name, file: f }); + } catch (_e) { /* skip unreadable file */ } + } else if (entry.isDirectory) { + var reader = entry.createReader(); + var kids = await readAllEntries(reader); + for (var i = 0; i < kids.length; i++) { + await walkEntry(kids[i], prefix + entry.name + '/', out); + } + } + } + + // Extract { relPath, file } pairs from a DataTransfer. Uses + // webkitGetAsEntry when available (so folder uploads work); + // falls back to dataTransfer.files for cases where entries + // aren't exposed (some browsers / cross-origin). + async function collectUploads(dt) { + var out = []; + if (dt.items && dt.items.length) { + var entries = []; + for (var i = 0; i < dt.items.length; i++) { + var item = dt.items[i]; + if (item.kind !== 'file') continue; + var entry = typeof item.webkitGetAsEntry === 'function' + ? item.webkitGetAsEntry() + : null; + if (entry) { + entries.push(entry); + } else { + var f = item.getAsFile(); + if (f) out.push({ relPath: f.name, file: f }); + } + } + for (var j = 0; j < entries.length; j++) { + await walkEntry(entries[j], '', out); + } + if (out.length) return out; + } + if (dt.files) { + for (var k = 0; k < dt.files.length; k++) { + out.push({ relPath: dt.files[k].name, file: dt.files[k] }); + } + } + return out; + } + + // Run a batch of uploads against an arbitrary destination directory. + // Surfaces per-file errors as toasts; refreshes the tree afterward + // so newly-uploaded entries appear. Returns { ok, fail } counts. + async function uploadBatch(uploads, destDir) { + var note = window.zddc && window.zddc.toast; + if (note) { + note('Uploading ' + uploads.length + ' item' + + (uploads.length === 1 ? '' : 's') + '…', 'info'); + } + var ok = 0, fail = 0; + for (var i = 0; i < uploads.length; i++) { + var u = uploads[i]; + var res = await uploadOne(u.file, destDir, u.relPath); + if (res.ok) ok++; + else { + fail++; + if (note) { + note('Upload failed: ' + u.relPath + ' — ' + res.message, 'error'); + } + } + } + if (note) { + if (fail === 0) { + note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's') + + ' → ' + destDir, 'success'); + } else if (ok === 0) { + note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error'); + } else { + note(ok + ' uploaded, ' + fail + ' failed', 'warning'); + } + } + return { ok: ok, fail: fail }; + } + + // ── 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. + + async function makeDir(parentDir, name) { + var url = joinUrl(parentDir, name); + if (!url.endsWith('/')) url += '/'; + var resp = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { 'X-ZDDC-Op': 'mkdir' } + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + } + + async function makeFile(parentDir, name, body, contentType) { + var resp = await fetch(joinUrl(parentDir, name), { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': contentType || 'application/octet-stream' }, + body: body == null ? '' : body + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + } + + // ── Delete + rename ───────────────────────────────────────────────────── + // Both run through the same FS Access API + file-API endpoints used + // by the create helpers above: + // - Server mode: DELETE / POST X-ZDDC-Op: move. ACL is enforced + // server-side; a 403/405 surfaces as an error toast. + // - FS-API mode: FileSystemHandle.remove({recursive:true}) and + // .move(newName) — both are Chromium-110+ features. We feature- + // detect at the handle level; callers see a clear "not supported" + // error message if the browser is too old. + + function pathForNode(node) { + var tree = window.app.modules.tree; + return tree ? tree.pathFor(node) : ''; + } + + function isZipMember(node) { + if (node.handle && node.handle.isZipEntry) return true; + if (node.url && state.source === 'server' && /\.zip\//i.test(node.url)) { + return true; + } + return false; + } + + // True when this node's write API is reachable. The server can + // still refuse the action on ACL grounds; this only gates the + // menu's disabled-state for the cases where there's clearly no + // write target at all. + function canMutate(node) { + if (!node || node.virtual) return false; + if (isZipMember(node)) return false; + if (state.source === 'server') return true; + if (node.handle && typeof node.handle.remove === 'function') return true; + return false; + } + + async function removeNode(node) { + if (!node) throw new Error('no node'); + if (isZipMember(node)) { + throw new Error('Cannot delete a file inside a zip archive.'); + } + if (node.virtual) { + throw new Error('Virtual folder — nothing on disk to delete.'); + } + if (state.source === 'server') { + var url = pathForNode(node); + if (node.isDir && !url.endsWith('/')) url += '/'; + var resp = await fetch(url, { + method: 'DELETE', + credentials: 'same-origin' + }); + if (!resp.ok) { + if (resp.status === 403) throw new Error('Permission denied (403).'); + if (resp.status === 405) throw new Error('Delete not allowed for this entry.'); + throw new Error('HTTP ' + resp.status); + } + return; + } + // FS-API path. FileSystemHandle.remove() is Chromium 110+ + // (browsers that didn't ship it expose no equivalent — the + // legacy removeEntry() lives on the PARENT directory handle + // and we don't retain ancestor handles). + if (node.handle && typeof node.handle.remove === 'function') { + await node.handle.remove({ recursive: !!node.isDir }); + return; + } + throw new Error('Delete not supported by this browser in offline mode.'); + } + + async function renameNode(node, newName) { + if (!node) throw new Error('no node'); + if (!newName) throw new Error('Name required.'); + if (newName === node.name) return; + if (isZipMember(node)) { + throw new Error('Cannot rename a file inside a zip archive.'); + } + if (node.virtual) { + throw new Error('Virtual folder — nothing on disk to rename.'); + } + if (state.source === 'server') { + var src = pathForNode(node); + if (node.isDir && !src.endsWith('/')) src += '/'; + // Destination = same parent, new basename. + var lastSlash = src.replace(/\/$/, '').lastIndexOf('/'); + var parent = lastSlash >= 0 ? src.substring(0, lastSlash + 1) : '/'; + var dst = parent + encodeURIComponent(newName) + (node.isDir ? '/' : ''); + var resp = await fetch(src, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'X-ZDDC-Op': 'move', + 'X-ZDDC-Destination': dst + } + }); + if (!resp.ok) { + if (resp.status === 403) throw new Error('Permission denied (403).'); + if (resp.status === 409) throw new Error('A file with that name already exists.'); + throw new Error('HTTP ' + resp.status); + } + return; + } + // FS-API: handle.move(newName) is Chromium 110+. + if (node.handle && typeof node.handle.move === 'function') { + await node.handle.move(newName); + return; + } + throw new Error('Rename not supported by this browser in offline mode.'); + } + + // Refresh either the root listing (when the upload targeted the + // current scope) or just one folder node's children (when the + // upload targeted a subfolder via a per-row drop). + async function refreshAfterUpload(targetDir) { + var loader = window.app.modules.loader; + var tree = window.app.modules.tree; + if (!loader || !tree) return; + if (state.currentPath && targetDir === state.currentPath) { + try { + var es = await loader.fetchServerChildren(state.currentPath); + tree.setRoot(es); + tree.render(); + } catch (_e) { /* swallow */ } + return; + } + // Find any tree node whose path matches targetDir and reload + // its children. Walks state.nodes flat — n is small enough for + // a linear scan. + var dirNoSlash = (targetDir || '').replace(/\/$/, ''); + var hit = null; + state.nodes.forEach(function (n) { + if (hit || !n.isDir) return; + if (tree.pathFor(n).replace(/\/$/, '') === dirNoSlash) hit = n; + }); + if (hit && hit.expanded) { + try { + var raw = await loader.fetchServerChildren(targetDir); + tree.setChildren(hit.id, raw); + tree.render(); + } catch (_e) { /* swallow */ } + } + } + + // Document-level drop: targets the currently-viewed scope. The + // per-row drop (events.js) calls uploadToDir directly with a + // different destination. async function handleDrop(e) { e.preventDefault(); e.stopPropagation(); @@ -133,46 +417,21 @@ if (!currentScopeAllows()) return; var dt = e.dataTransfer; - if (!dt || !dt.files || dt.files.length === 0) return; + if (!dt) return; + var uploads = await collectUploads(dt); + if (!uploads.length) return; + await uploadBatch(uploads, state.currentPath); + await refreshAfterUpload(state.currentPath); + } - var files = Array.from(dt.files); - var note = window.zddc && window.zddc.toast; - if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info'); - - // Sequential — predictable progress + ordering. Can parallelise - // later if it matters. - var ok = 0, fail = 0; - for (var i = 0; i < files.length; i++) { - var res = await uploadOne(files[i]); - if (res.ok) { - ok++; - } else { - fail++; - if (note) { - note('Upload failed: ' + res.file.name + ' — ' + res.message, 'error'); - } - } - } - if (note) { - if (fail === 0) { - note('Uploaded ' + ok + ' file' + (ok === 1 ? '' : 's'), 'success'); - } else if (ok === 0) { - note('All ' + fail + ' upload' + (fail === 1 ? '' : 's') + ' failed', 'error'); - } else { - note(ok + ' uploaded, ' + fail + ' failed', 'warning'); - } - } - - // Refresh the listing so newly-uploaded files appear. - var loader = window.app.modules.loader; - var tree = window.app.modules.tree; - if (loader && tree && state.currentPath) { - try { - var es = await loader.fetchServerChildren(state.currentPath); - tree.setRoot(es); - tree.render(); - } catch (_e) { /* swallow; user can hard-reload */ } - } + // Public entry for per-row drops or programmatic uploads. destDir + // must be a server path (/-prefixed, slash-terminated optional). + async function uploadToDir(destDir, dataTransfer) { + var uploads = await collectUploads(dataTransfer); + if (!uploads.length) return { ok: 0, fail: 0 }; + var res = await uploadBatch(uploads, destDir); + await refreshAfterUpload(destDir); + return res; } function onEnter(e) { @@ -215,6 +474,12 @@ window.app.modules.upload = { currentScopeAllows: currentScopeAllows, + uploadToDir: uploadToDir, + makeDir: makeDir, + makeFile: makeFile, + removeNode: removeNode, + renameNode: renameNode, + canMutate: canMutate, UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES }; })(); diff --git a/browse/template.html b/browse/template.html index 7d71903..2dea631 100644 --- a/browse/template.html +++ b/browse/template.html @@ -24,10 +24,16 @@ ZDDC Browse {{BUILD_LABEL}}
- +
+ +
@@ -41,7 +47,7 @@
  • Online — when this page is served by zddc-server, the listing for the current directory loads automatically.
  • -
  • Local — click Add Local Directory to pick any folder +
  • Local — click Use Local Directory to pick any folder on your computer (Chromium-based browsers).

Once loaded: click folders to expand, click files to preview them in @@ -54,33 +60,20 @@