feat(browse): SPA overhaul — context menu, YAML editor, icons, hovercard, deep links, autofilter

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=<path>` 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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-14 12:12:42 -05:00
parent e5ba2b6168
commit 94b2e29448
20 changed files with 3301 additions and 270 deletions

View file

@ -27,8 +27,12 @@ concat_files \
"../shared/nav.css" \ "../shared/nav.css" \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \ "../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \
"../shared/context-menu.css" \
"../shared/elevation.css" \
"css/base.css" \ "css/base.css" \
"css/tree.css" \ "css/tree.css" \
"css/preview-yaml.css" \
> "$css_temp" > "$css_temp"
# JS files: shared canonical helpers, then browse modules. # JS files: shared canonical helpers, then browse modules.
@ -39,6 +43,8 @@ concat_files \
concat_files \ concat_files \
"../shared/vendor/jszip.min.js" \ "../shared/vendor/jszip.min.js" \
"../shared/vendor/utif.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/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \
@ -49,12 +55,17 @@ concat_files \
"../shared/logo.js" \ "../shared/logo.js" \
"../shared/help.js" \ "../shared/help.js" \
"../shared/preview-lib.js" \ "../shared/preview-lib.js" \
"../shared/context-menu.js" \
"../shared/elevation.js" \
"../shared/icons.js" \
"../shared/zddc-source.js" \ "../shared/zddc-source.js" \
"js/init.js" \ "js/init.js" \
"js/loader.js" \ "js/loader.js" \
"js/tree.js" \ "js/tree.js" \
"js/preview.js" \ "js/preview.js" \
"js/preview-markdown.js" \ "js/preview-markdown.js" \
"js/preview-yaml.js" \
"js/hovercard.js" \
"js/grid.js" \ "js/grid.js" \
"js/upload.js" \ "js/upload.js" \
"js/download.js" \ "js/download.js" \

110
browse/css/preview-yaml.css Normal file
View file

@ -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;
}

View file

@ -4,15 +4,33 @@ html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden;
font-family: var(--font); font-family: var(--font);
color: var(--text); color: var(--text);
background-color: var(--bg); 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 { #appMain {
position: relative; 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; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
@ -109,12 +127,6 @@ html, body {
vertical-align: -0.15em; vertical-align: -0.15em;
} }
.toolbar__count {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
/* ── Two-pane browse view ────────────────────────────────────────────────── */ /* ── Two-pane browse view ────────────────────────────────────────────────── */
.browse-view { .browse-view {
@ -139,6 +151,42 @@ html, body {
flex-shrink: 0; 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 { .tree-pane__body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
@ -250,9 +298,12 @@ html, body {
.tree-row { .tree-row {
display: flex; 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; gap: 0.25rem;
padding: 0.15rem 0.5rem; padding: 0.2rem 0.5rem;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
border-radius: 0; border-radius: 0;
@ -268,37 +319,76 @@ html, body {
color: var(--text); 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 { .tree-row.is-selected .tree-name__label {
color: var(--text); color: var(--text);
} }
.tree-name__chevron { .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; width: 1rem;
text-align: center; height: 1.2em;
color: var(--text-muted);
flex-shrink: 0; flex-shrink: 0;
font-family: monospace; color: var(--text-muted);
font-size: 0.65rem;
} }
.tree-row[data-isdir="true"] .tree-name__chevron::before, .tree-name__chevron svg {
.tree-row[data-iszip="true"] .tree-name__chevron::before { width: 0.85em;
content: "▸"; height: 0.85em;
transition: transform 0.12s ease;
} }
.tree-row[data-isdir="true"].expanded .tree-name__chevron::before, /* Expanded state rotate the same chevron 90° rather than swapping
.tree-row[data-iszip="true"].expanded .tree-name__chevron::before { to a second glyph. Smooth, single-sprite, and consistent with the
content: "▾"; way most modern file trees indicate expand state. */
} .tree-row.expanded .tree-name__chevron svg {
transform: rotate(90deg);
.tree-name__chevron--leaf::before {
content: "";
} }
.tree-name__icon { .tree-name__icon {
flex-shrink: 0; 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 { .tree-name__label {
@ -306,6 +396,48 @@ html, body {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--text); 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, .tree-row[data-isdir="true"] .tree-name__label,
@ -427,12 +559,15 @@ html, body {
overflow: hidden; overflow: hidden;
} }
/* Sidebar (col 1): two stacked sections Front matter (top, fixed /* Sidebar (col 1): three stacked items Front matter (fixed height,
default 180 px, drag-resizable) and TOC (bottom, takes the rest). */ 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 { .md-shell__sidebar {
grid-area: sidebar; grid-area: sidebar;
display: grid; display: flex;
grid-template-rows: 180px 1fr; /* JS overrides on resize */ flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
@ -460,20 +595,17 @@ html, body {
outline: none; outline: none;
} }
/* Horizontal resizer between front-matter and TOC inside the sidebar. /* Horizontal resizer a real flex item between FM and TOC. Drag
Spans both rows by placement, then absolutely positioned to overlay it up/down to change the front-matter pane's height; the JS
the grid-row boundary. */ handler updates fmSection.style.height directly. */
.md-shell__fmresizer { .md-shell__fmresizer {
grid-column: 1; flex: 0 0 6px;
grid-row: 1;
align-self: end;
justify-self: stretch;
height: 6px; height: 6px;
margin-bottom: -3px;
cursor: row-resize; cursor: row-resize;
background: transparent; background: var(--border);
z-index: 2;
transition: background 0.12s; 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:hover,
.md-shell__fmresizer.is-dragging, .md-shell__fmresizer.is-dragging,
@ -558,15 +690,30 @@ html, body {
} }
.md-side { .md-side {
display: grid; display: flex;
grid-template-rows: auto 1fr; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden; 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 { .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; padding: 0.35rem 0.75rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
@ -576,8 +723,13 @@ html, body {
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: var(--text-muted); color: var(--text-muted);
} }
.md-side__body { .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; min-height: 0;
padding: 0.3rem 0; padding: 0.3rem 0;
font-size: 0.85rem; font-size: 0.85rem;
@ -604,10 +756,11 @@ html, body {
cursor: pointer; cursor: pointer;
border-left: 2px solid transparent; border-left: 2px solid transparent;
transition: background 0.1s, border-color 0.1s, color 0.1s; transition: background 0.1s, border-color 0.1s, color 0.1s;
/* Truncate long headings rather than wrap; the title attribute /* Single-line items but no ellipsis long headings extend the
carries the full text. */ item's intrinsic width, and the parent .md-side__body has
overflow: hidden; overflow:auto, so they create a horizontal scrollbar instead
text-overflow: ellipsis; of getting clipped. The title attribute still carries the
full text for SR users. */
white-space: nowrap; white-space: nowrap;
} }
.md-toc__item:hover { .md-toc__item:hover {
@ -670,44 +823,105 @@ html, body {
cursor: not-allowed; 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 /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */ by the .md-shell BEM block above. */
/* ── Hover info card ────────────────────────────────────────────────────── */
/* Singleton element appended to <body> 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;
}

View file

@ -19,9 +19,76 @@
// Expose for events.js's client-side rescope on dblclick. // Expose for events.js's client-side rescope on dblclick.
window.app.modules.augmentRoot = passThroughEntries; 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() { async function bootstrap() {
events.init(); events.init();
// Honor ?file=<path> deep links: external clients (the profile
// page's "edit your .zddc files" list, future bookmarks, etc.)
// can link directly to "open browse at <dir>, 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 // Try server auto-detect. If this page is served by zddc-server
// (or any server with a Caddy-shaped JSON listing), load the // (or any server with a Caddy-shaped JSON listing), load the
// current directory automatically. Otherwise show the empty // current directory automatically. Otherwise show the empty
@ -40,6 +107,14 @@
// response, re-resolve so an /incoming URL auto-activates // response, re-resolve so an /incoming URL auto-activates
// grid mode. // grid mode.
if (events.applyResolvedViewMode) events.applyResolvedViewMode(); 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. // Else: empty state stays visible; user can click Select Directory.

View file

@ -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 <a download> at "<currentPath>?zip=1" — // downloadFile: a single file. Server mode lets the browser pull
// zddc-server streams an ACL-filtered .zip of the subtree, so nothing // node.url (zddc-server emits Content-Disposition); FS-API mode
// is held in the browser. // reads bytes through the file handle and blob-downloads.
// //
// FS-API (offline) mode: there's no server, so we walk the picked // downloadFolder: an arbitrary directory node as a .zip. Server
// folder ourselves, bundle every file with JSZip, and download the // mode points an <a download> at "<node-path>/?zip=1" so zddc-server
// blob. A two-pass walk (metadata first, then bytes) lets us warn // streams an ACL-filtered archive without buffering on the client.
// before loading a very large tree into memory. // 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 () { (function () {
'use strict'; 'use strict';
@ -103,39 +106,75 @@
ev.statusInfo('Downloaded ' + rootHandle.name + '.zip (' + files.length + ' files)'); 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; 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; if (busy) return;
var btn = document.getElementById('downloadZipBtn'); if (!node || node.isDir) {
events().statusError('Not a file: ' + (node && node.name));
return;
}
busy = true; busy = true;
if (btn) btn.disabled = true;
try { try {
if (state.source === 'server') { if (node.url) {
downloadServerSubtree(); events().statusInfo('Downloading ' + node.name + '…');
} else if (state.source === 'fs' && state.rootHandle) { downloadUrl(node.name, node.url);
await downloadFsSubtree(state.rootHandle); 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 { } 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
// "<node-path>/?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) { } catch (e) {
events().statusError('Download failed: ' + (e && e.message ? e.message : e)); events().statusError('Download failed: ' + (e && e.message ? e.message : e));
} finally { } finally {
busy = false; busy = false;
if (btn) btn.disabled = false;
} }
} }
window.app.modules.download = { window.app.modules.download = {
downloadCurrentSubtree: downloadCurrentSubtree downloadFile: downloadFile,
downloadFolder: downloadFolder
}; };
})(); })();

View file

@ -69,7 +69,6 @@
function applySourceUI() { function applySourceUI() {
var add = document.getElementById('addDirectoryBtn'); var add = document.getElementById('addDirectoryBtn');
var refresh = document.getElementById('refreshHeaderBtn'); var refresh = document.getElementById('refreshHeaderBtn');
var dlZip = document.getElementById('downloadZipBtn');
if (add) { if (add) {
if (state.source === 'server') { if (state.source === 'server') {
add.classList.remove('btn-primary'); add.classList.remove('btn-primary');
@ -86,18 +85,16 @@
refresh.classList.add('hidden'); 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() { 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') { if (state.source === 'server') {
var raw; var raw;
try { try {
@ -107,6 +104,7 @@
return; return;
} }
tree.setRoot(raw); tree.setRoot(raw);
await tree.restoreState(snap);
tree.render(); tree.render();
statusInfo('Refreshed (' + raw.length + ' item' statusInfo('Refreshed (' + raw.length + ' item'
+ (raw.length === 1 ? '' : 's') + ')'); + (raw.length === 1 ? '' : 's') + ')');
@ -119,6 +117,7 @@
return; return;
} }
tree.setRoot(raw2); tree.setRoot(raw2);
await tree.restoreState(snap);
tree.render(); tree.render();
statusInfo('Refreshed'); statusInfo('Refreshed');
} }
@ -132,38 +131,31 @@
var refresh = document.getElementById('refreshHeaderBtn'); var refresh = document.getElementById('refreshHeaderBtn');
if (refresh) refresh.addEventListener('click', refreshListing); if (refresh) refresh.addEventListener('click', refreshListing);
var dlZip = document.getElementById('downloadZipBtn'); // Tree autofilter — parses input through zddc.filter.parse so
if (dlZip) dlZip.addEventListener('click', function () { // the same query grammar that the archive app uses (terms,
var d = window.app.modules.download; // quotes, !negation, multi-word AND) works here. The AST is
if (d) d.downloadCurrentSubtree(); // cached on state.filterAST; tree.render reads it and skips
}); // non-matching rows. Escape clears.
var filterInput = document.getElementById('treeFilter');
// Sort dropdown — change → tree re-renders with the new sort. if (filterInput) {
// Format of option value: "<key>:<asc|desc>". Defaults match var filterDebounce = null;
// state.sort initial values (name:asc). var applyFilter = function () {
var sortSel = document.getElementById('sortBy'); var raw = filterInput.value || '';
if (sortSel) { state.filterText = raw;
sortSel.value = state.sort.key + ':' + (state.sort.dir > 0 ? 'asc' : 'desc'); state.filterAST = raw ? window.zddc.filter.parse(raw) : null;
sortSel.addEventListener('change', function () { filterInput.classList.toggle('filter-active', !!raw);
var parts = sortSel.value.split(':'); tree.render();
var key = parts[0]; };
var dir = parts[1] === 'desc' ? -1 : 1; filterInput.addEventListener('input', function () {
tree.setSortExplicit(key, dir); if (filterDebounce) clearTimeout(filterDebounce);
filterDebounce = setTimeout(applyFilter, 80);
}); });
} filterInput.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && filterInput.value) {
// "Show hidden" checkbox — toggles state.showHidden, which the e.preventDefault();
// loader reads to append ?hidden=1 to listing requests. Re-uses filterInput.value = '';
// the existing refreshListing flow so the tree pulls a fresh applyFilter();
// 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();
}); });
} }
@ -314,9 +306,520 @@
} }
navigateIntoFolder(node); 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 =
'<div class="preview-empty">Click a file in the tree to preview it.</div>';
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 mode is URL-driven, not UI-driven.
// //
// ?view=grid → grid mode (only honored where classifier is // ?view=grid → grid mode (only honored where classifier is

258
browse/js/hovercard.js Normal file
View file

@ -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 <body>; 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 += '<div class="tree-hovercard__sep"></div>';
} 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 '<span class="tree-hovercard__key">' + escapeHtml(key) + '</span>'
+ '<span class="tree-hovercard__val'
+ (mono ? ' tree-hovercard__val--mono' : '')
+ '">' + escapeHtml(val) + '</span>';
}
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 = ''
+ '<div class="tree-hovercard__header">'
+ '<div class="tree-hovercard__title">' + escapeHtml(primary) + '</div>'
+ (secondary
? '<div class="tree-hovercard__sub">' + escapeHtml(secondary) + '</div>'
: '')
+ '</div>'
+ '<div class="tree-hovercard__list">' + buildRowsHtml(node) + '</div>';
}
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 };
})();

View file

@ -8,6 +8,15 @@
window.app = { modules: {}, state: {} }; window.app = { modules: {}, state: {} };
} }
// Mount the shared Lucide outline-icon sprite into <body> before
// the tree first renders. The sprite is hidden (display:none on
// the outer <svg>) — it only exists so per-row <use href="#…"/>
// refs resolve. Falls back to deferring until DOMContentLoaded
// when <body> isn't ready yet.
if (window.zddc && window.zddc.icons) {
window.zddc.icons.inject();
}
window.app.state = { window.app.state = {
// Source: 'server' | 'fs' | null. Determines how the loader // Source: 'server' | 'fs' | null. Determines how the loader
// resolves entries. // resolves entries.
@ -61,6 +70,13 @@
// scopeDefaultTool: cascade's default_tool at currentPath // scopeDefaultTool: cascade's default_tool at currentPath
// (empty when no default declared) // (empty when no default declared)
scopeDropTarget: false, 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
}; };
})(); })();

View file

@ -346,13 +346,18 @@
container.appendChild(shell); container.appendChild(shell);
// ── Sidebar (col 1): front matter (top) + TOC (bottom) ────────────── // ── 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'); var sidebar = document.createElement('div');
sidebar.className = 'md-shell__sidebar'; sidebar.className = 'md-shell__sidebar';
sidebar.style.gridTemplateRows = lastFmHeight + 'px 1fr';
shell.appendChild(sidebar); shell.appendChild(sidebar);
var fmSection = document.createElement('section'); var fmSection = document.createElement('section');
fmSection.className = 'md-side md-side--fm'; 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'); var fmHeader = document.createElement('div');
fmHeader.className = 'md-side__header'; fmHeader.className = 'md-side__header';
fmHeader.textContent = 'YAML front matter'; fmHeader.textContent = 'YAML front matter';
@ -502,7 +507,10 @@
var editor = new window.toastui.Editor({ var editor = new window.toastui.Editor({
el: editorHost, el: editorHost,
height: '100%', 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', previewStyle: 'vertical',
initialValue: bodyText, initialValue: bodyText,
usageStatistics: false, usageStatistics: false,
@ -592,7 +600,7 @@
var dy = e.clientY - startY; var dy = e.clientY - startY;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy)); var h = Math.max(FM_MIN, Math.min(maxFmHeight(), startH + dy));
lastFmHeight = h; lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr'; fmSection.style.height = h + 'px';
e.preventDefault(); e.preventDefault();
} }
function onUp() { function onUp() {
@ -616,7 +624,7 @@
var step = e.key === 'ArrowUp' ? -24 : 24; var step = e.key === 'ArrowUp' ? -24 : 24;
var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step)); var h = Math.max(FM_MIN, Math.min(maxFmHeight(), lastFmHeight + step));
lastFmHeight = h; lastFmHeight = h;
sidebar.style.gridTemplateRows = h + 'px 1fr'; fmSection.style.height = h + 'px';
}); });
})(); })();

533
browse/js/preview-yaml.js Normal file
View file

@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
// and `<anything>.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<semver> | 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 "<indent><key>:" 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 =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'CodeMirror isn\'t bundled in this build.</div>';
return;
}
dispose();
var text;
try {
var buf = await ctx.getArrayBuffer(node);
text = new TextDecoder('utf-8', { fatal: false }).decode(buf);
} catch (e) {
container.innerHTML =
'<div class="preview-empty" style="color:var(--danger)">'
+ 'Could not read ' + escapeHtml(node.name) + ': '
+ escapeHtml(e.message || String(e)) + '</div>';
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
};
})();

View file

@ -117,6 +117,19 @@
return; 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. // PDF / HTML → iframe.
if (ext === 'pdf' || ext === 'html' || ext === 'htm') { if (ext === 'pdf' || ext === 'html' || ext === 'htm') {
try { try {

View file

@ -111,15 +111,24 @@
} }
// Walk nodes in render order. Skips the children of a collapsed // 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() { function visibleIds() {
var out = []; var out = [];
function walk(ids) { function walk(ids) {
for (var i = 0; i < ids.length; i++) { for (var i = 0; i < ids.length; i++) {
var n = state.nodes.get(ids[i]); var n = state.nodes.get(ids[i]);
if (!n) continue; if (!n) continue;
if (state.filterAST && !passesFilter(n)) continue;
out.push(ids[i]); 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 // Re-sort everything at all levels so a sort change reorders
@ -132,6 +141,59 @@
return out; 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 ──────────────────────────────────────────────────────── // ── Rendering ────────────────────────────────────────────────────────
function fmtSize(bytes) { function fmtSize(bytes) {
@ -154,6 +216,127 @@
.replace(/>/g, '&gt;').replace(/"/g, '&quot;'); .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
} }
// Per-extension icon map → Lucide outline-icon sprite ids. The
// actual SVG markup is produced by window.zddc.icons.html(id),
// which inlines `<svg><use href="#id"/></svg>` 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 '<span class="tree-name__label">'
+ escapeHtml(node.displayName)
+ '</span>';
}
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 '<span class="tree-name__label tree-name__label--zddc">'
+ '<span class="tree-name__title">'
+ escapeHtml(parsed.title)
+ '</span>'
+ '<span class="tree-name__meta">'
+ escapeHtml(metaText)
+ '</span>'
+ '</span>';
}
return '<span class="tree-name__label">'
+ escapeHtml(node.name)
+ '</span>';
}
// Render a single tree row as a flat <div>. Indentation via // Render a single tree row as a flat <div>. Indentation via
// padding-left so the row's hover background spans the full // padding-left so the row's hover background spans the full
// pane width. Files are rendered as plain rows (no anchor) — // pane width. Files are rendered as plain rows (no anchor) —
@ -163,26 +346,39 @@
function rowHtml(node) { function rowHtml(node) {
var indent = 0.4 + node.depth * 1.0; var indent = 0.4 + node.depth * 1.0;
var expandable = node.isDir || node.isZip; var expandable = node.isDir || node.isZip;
var iconChar = node.isDir ? '📁' : (node.isZip ? '🗜️' : '📄'); var iconChar = iconForNode(node);
var chevronClass = 'tree-name__chevron' var chevronClass = 'tree-name__chevron'
+ (expandable ? '' : ' tree-name__chevron--leaf'); + (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 selected = state.selectedId === node.id ? ' is-selected' : '';
var virtualCls = node.virtual ? ' tree-row--virtual' : ''; 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 var virtualHint = node.virtual
? '<span class="tree-name__hint" title="Folder not yet created on disk — opens an empty workspace">(empty)</span>' ? '<span class="tree-name__hint">(empty)</span>'
: ''; : '';
return '' return ''
+ '<div class="tree-row ' + (node.expanded ? 'expanded' : '') + selected + virtualCls + '<div class="tree-row ' + (visuallyExpanded ? 'expanded' : '') + selected + virtualCls
+ '" data-id="' + node.id + '" data-id="' + node.id
+ '" data-isdir="' + node.isDir + '" data-isdir="' + node.isDir
+ '" data-iszip="' + node.isZip + '"' + '" data-iszip="' + node.isZip + '"'
+ (node.virtual ? ' data-virtual="true"' : '') + (node.virtual ? ' data-virtual="true"' : '')
+ ' style="padding-left:' + indent + 'rem"' + ' style="padding-left:' + indent + 'rem"'
+ ' role="treeitem" tabindex="-1">' + ' role="treeitem" tabindex="-1">'
+ '<span class="' + chevronClass + '"></span>' + '<span class="' + chevronClass + '">' + chevronGlyph + '</span>'
+ '<span class="tree-name__icon">' + iconChar + '</span>' + '<span class="tree-name__icon">' + iconChar + '</span>'
+ '<span class="tree-name__label" title="' + escapeHtml(node.name) + '">' + labelHtml(node)
+ escapeHtml(node.displayName || node.name) + '</span>'
+ virtualHint + virtualHint
+ '</div>'; + '</div>';
} }
@ -196,33 +392,9 @@
html += rowHtml(state.nodes.get(ids[i])); html += rowHtml(state.nodes.get(ids[i]));
} }
body.innerHTML = html; body.innerHTML = html;
updateCount();
renderBreadcrumbs(); 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 ────────────────────────────────────────────────────── // ── Breadcrumbs ──────────────────────────────────────────────────────
// Inline outline home icon. Stroke-based so it tints with the // Inline outline home icon. Stroke-based so it tints with the
@ -431,6 +603,61 @@
return parts.join('/'); 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 // Public API
window.app.modules.tree = { window.app.modules.tree = {
setRoot: setRoot, setRoot: setRoot,
@ -439,6 +666,9 @@
toggleFolder: toggleFolder, toggleFolder: toggleFolder,
expandSubtree: expandSubtree, expandSubtree: expandSubtree,
collapseSubtree: collapseSubtree, collapseSubtree: collapseSubtree,
loadChildren: loadChildren,
snapshotState: snapshotState,
restoreState: restoreState,
setSort: function (key) { setSort: function (key) {
if (state.sort.key === key) { if (state.sort.key === key) {
state.sort.dir = -state.sort.dir; state.sort.dir = -state.sort.dir;

View file

@ -85,13 +85,17 @@
return false; return false;
} }
function uploadUrl(filename) { // Join a directory path and a relative path safely. dir is expected
var base = state.currentPath || '/'; // 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 += '/'; 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) { if (file.size > UPLOAD_MAX_BYTES) {
return { return {
file: file, file: file,
@ -101,7 +105,7 @@
}; };
} }
try { try {
var resp = await fetch(uploadUrl(file.name), { var resp = await fetch(joinUrl(destDir, relPath), {
method: 'PUT', method: 'PUT',
body: file, body: file,
credentials: 'same-origin', 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) { async function handleDrop(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -133,46 +417,21 @@
if (!currentScopeAllows()) return; if (!currentScopeAllows()) return;
var dt = e.dataTransfer; 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); // Public entry for per-row drops or programmatic uploads. destDir
var note = window.zddc && window.zddc.toast; // must be a server path (/-prefixed, slash-terminated optional).
if (note) note('Uploading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '…', 'info'); async function uploadToDir(destDir, dataTransfer) {
var uploads = await collectUploads(dataTransfer);
// Sequential — predictable progress + ordering. Can parallelise if (!uploads.length) return { ok: 0, fail: 0 };
// later if it matters. var res = await uploadBatch(uploads, destDir);
var ok = 0, fail = 0; await refreshAfterUpload(destDir);
for (var i = 0; i < files.length; i++) { return res;
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 */ }
}
} }
function onEnter(e) { function onEnter(e) {
@ -215,6 +474,12 @@
window.app.modules.upload = { window.app.modules.upload = {
currentScopeAllows: currentScopeAllows, currentScopeAllows: currentScopeAllows,
uploadToDir: uploadToDir,
makeDir: makeDir,
makeFile: makeFile,
removeNode: removeNode,
renameNode: renameNode,
canMutate: canMutate,
UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES UPLOAD_MAX_BYTES: UPLOAD_MAX_BYTES
}; };
})(); })();

View file

@ -24,10 +24,16 @@
<span class="app-header__title">ZDDC Browse</span> <span class="app-header__title">ZDDC Browse</span>
<span class="build-timestamp">{{BUILD_LABEL}}</span> <span class="build-timestamp">{{BUILD_LABEL}}</span>
</div> </div>
<button id="addDirectoryBtn" class="btn btn-primary">Add Local Directory</button> <button id="addDirectoryBtn" class="btn btn-primary">Use Local Directory</button>
<button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button> <button id="refreshHeaderBtn" class="btn btn-secondary hidden" title="Refresh listing" aria-label="Refresh listing"></button>
</div> </div>
<div class="header-right"> <div class="header-right">
<!-- Elevation toggle slot. shared/elevation.js fills it
when /.profile/access reports the user has admin
authority; stays empty + hidden for non-admins so
the chrome is quiet for the common case. -->
<span id="elevation-toggle" class="elevation-toggle hidden"
title="Opt into your admin powers for the next 30 minutes (sudo-style)."></span>
<button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button> <button id="theme-btn" class="btn btn-secondary" title="Theme: auto (follows OS)" aria-label="Theme: auto (follows OS)"></button>
<button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button> <button id="help-btn" class="btn btn-secondary" title="Help" aria-label="Help">?</button>
</div> </div>
@ -41,7 +47,7 @@
<ul class="welcome-list"> <ul class="welcome-list">
<li><b>Online</b> — when this page is served by zddc-server, the <li><b>Online</b> — when this page is served by zddc-server, the
listing for the current directory loads automatically.</li> listing for the current directory loads automatically.</li>
<li><b>Local</b> — click <i>Add Local Directory</i> to pick any folder <li><b>Local</b> — click <i>Use Local Directory</i> to pick any folder
on your computer (Chromium-based browsers).</li> on your computer (Chromium-based browsers).</li>
</ul> </ul>
<p>Once loaded: click folders to expand, click files to preview them in <p>Once loaded: click folders to expand, click files to preview them in
@ -54,33 +60,20 @@
<div id="browseRoot" class="browse-root hidden"> <div id="browseRoot" class="browse-root hidden">
<div class="browse-toolbar"> <div class="browse-toolbar">
<nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav> <nav class="breadcrumbs" id="breadcrumbs" aria-label="Path"></nav>
<span class="toolbar__count" id="entryCount"></span>
<button id="downloadZipBtn" class="btn btn-sm btn-secondary hidden"
title="Download this folder (and everything under it you can access) as a .zip"
aria-label="Download this folder as a zip">⤓ Download (zip)</button>
<label class="sort-control" for="sortBy" title="Sort tree entries">
<span class="sort-control__label">Sort:</span>
<select id="sortBy" class="sort-control__select" aria-label="Sort tree entries">
<option value="name:asc">Name (A→Z)</option>
<option value="name:desc">Name (Z→A)</option>
<option value="date:desc">Modified (new→old)</option>
<option value="date:asc">Modified (old→new)</option>
<option value="size:desc">Size (large→small)</option>
<option value="size:asc">Size (small→large)</option>
<option value="ext:asc">Type (A→Z)</option>
</select>
</label>
<label class="sort-control" for="showHidden"
title="Surface .-prefixed and _-prefixed entries (.zddc, .converted/, _app/, …). ACL still applies — you only see what you'd already be allowed to read.">
<input type="checkbox" id="showHidden" class="sort-control__checkbox"
aria-label="Show hidden files">
<span class="sort-control__label">Show hidden</span>
</label>
</div> </div>
<!-- Browse mode (default): two-pane tree + preview --> <!-- Browse mode (default): two-pane tree + preview -->
<div id="browseView" class="browse-view"> <div id="browseView" class="browse-view">
<div class="pane tree-pane" id="treePane"> <div class="pane tree-pane" id="treePane">
<div class="tree-pane__toolbar">
<input type="search"
id="treeFilter"
class="tree-filter"
placeholder="Filter files…"
aria-label="Filter the tree by name, tracking number, status, revision, or title"
autocomplete="off"
spellcheck="false">
</div>
<div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div> <div class="tree-pane__body" id="treeBody" role="tree" aria-label="Files"></div>
</div> </div>
<div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div> <div class="pane-resizer" data-resizer-for="tree-pane" aria-hidden="true"></div>
@ -135,18 +128,22 @@
<dd>Recursive expand or collapse — the whole subtree.</dd> <dd>Recursive expand or collapse — the whole subtree.</dd>
<dt>Click a file</dt> <dt>Click a file</dt>
<dd>Preview it in the right pane.</dd> <dd>Preview it in the right pane.</dd>
<dt>Right-click any row</dt>
<dd>Opens a context menu with Open, Download, Copy path, Sort, and
folder-specific actions. Toggle items show a ✓ when active; submenus
open on hover.</dd>
<dt>⤴ Pop out</dt> <dt>⤴ Pop out</dt>
<dd>Open the current preview in a separate window — useful for a second <dd>Open the current preview in a separate window — useful for a second
monitor.</dd> monitor.</dd>
<dt>ZIP files</dt> <dt>ZIP files</dt>
<dd>Behave as folders — click to inspect contents inline. JSZip is <dd>Behave as folders — click to inspect contents inline. JSZip is
bundled, so this works offline.</dd> bundled, so this works offline.</dd>
<dt>⤓ Download (zip)</dt> <dt>Download / Download ZIP</dt>
<dd>Downloads the directory you're currently viewing — and everything <dd>Right-click a file for <b>Download</b>, or a folder for
under it that you're allowed to see — as a single <code>.zip</code>. <b>Download ZIP</b> (everything under it that you're allowed to see,
Navigate into a subfolder first to download just that subtree. Online, bundled into one archive). Online, the server streams it; locally,
the server streams it; locally, the browser bundles the picked folder the browser bundles the picked folder (a confirmation appears if it's
(a confirmation appears if it's very large).</dd> very large).</dd>
<dt>Refresh</dt> <dt>Refresh</dt>
<dd>Re-fetches the current directory listing — works for both <dd>Re-fetches the current directory listing — works for both
local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd> local (re-enumerates the FS handle) and online (re-fetches the JSON).</dd>
@ -154,7 +151,7 @@
<h3>Header buttons</h3> <h3>Header buttons</h3>
<dl> <dl>
<dt>Add Local Directory</dt> <dt>Use Local Directory</dt>
<dd>Pick a folder from your computer. Works in both modes; in online <dd>Pick a folder from your computer. Works in both modes; in online
mode it's de-emphasized but still available.</dd> mode it's de-emphasized but still available.</dd>
<dt>⟳ Refresh</dt> <dt>⟳ Refresh</dt>

View file

@ -230,7 +230,7 @@ a:hover {
} }
/* Subdued / de-emphasized variant. /* Subdued / de-emphasized variant.
Used on the "Add Local Directory" button when a tool is operating Used on the "Use Local Directory" button when a tool is operating
in server (online) mode the local-dir affordance is still in server (online) mode the local-dir affordance is still
available but visually quieter, since the typical user already available but visually quieter, since the typical user already
has the directory loaded from the server. */ has the directory loaded from the server. */
@ -292,6 +292,11 @@ a:hover {
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
/* Let the left / right groups wrap to a second row at narrow
viewports rather than overflowing the viewport edge. row-gap
gives a small breathing strip when wrapped. */
flex-wrap: wrap;
row-gap: 0.3rem;
} }
/* Left and right groups inside .app-header. Both flex-row so their /* Left and right groups inside .app-header. Both flex-row so their
@ -303,16 +308,35 @@ a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
/* Allow the title to shrink (and ellipsize) before the action
buttons get pushed off-screen at narrow viewports. */
min-width: 0;
flex-wrap: wrap;
row-gap: 0.3rem;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-shrink: 0;
}
/* Title group (title + build label). Made shrinkable so narrow
viewports don't push the action buttons out of view; the title
itself ellipsizes via the rule below. */
.header-title-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 0;
flex-shrink: 1;
} }
/* Tool name inside the header. Renders in the display serif so the /* Tool name inside the header. Renders in the display serif so the
tool's identity reads as a document title, not a UI label. */ tool's identity reads as a document title, not a UI label.
overflow + ellipsis on min-width:0 lets the title compress
gracefully when there's no room. */
.app-header__title { .app-header__title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 18px; font-size: 18px;
@ -320,6 +344,9 @@ a:hover {
color: var(--text); color: var(--text);
letter-spacing: 0; letter-spacing: 0;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
} }
/* Brand logo sits left of the title in every tool's app-header. /* Brand logo sits left of the title in every tool's app-header.

109
shared/context-menu.css Normal file
View file

@ -0,0 +1,109 @@
/* shared/context-menu.css generic styles for window.zddc.menu.
Mirrors the look-and-feel of native context menus: tight rows,
five-column grid (check | icon | label | accel | arrow), subtle
border + shadow, hover background from the shared --bg-hover token,
danger items tinted with --danger. */
.zddc-menu {
position: fixed;
z-index: 10000;
min-width: 12rem;
max-width: 22rem;
padding: 0.25rem 0;
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.85rem;
line-height: 1.2;
user-select: none;
/* Allow focus styles inside without leaking to the menu itself. */
outline: none;
}
.zddc-menu__sep {
height: 1px;
margin: 0.25rem 0;
background: var(--border);
}
.zddc-menu__item {
display: grid;
grid-template-columns: 1.1rem 1.25rem 1fr auto 0.9rem;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.7rem;
cursor: pointer;
color: var(--text);
/* Suppress the focus ring on the row itself hover/focus
background handles the cue. */
outline: none;
}
.zddc-menu__item:hover,
.zddc-menu__item:focus,
.zddc-menu__item:focus-visible {
background: var(--bg-hover);
}
.zddc-menu__item.is-disabled {
color: var(--text-muted);
cursor: default;
}
.zddc-menu__item.is-disabled:hover,
.zddc-menu__item.is-disabled:focus {
background: transparent;
}
.zddc-menu__item--danger {
color: var(--danger);
}
.zddc-menu__item--danger:hover,
.zddc-menu__item--danger:focus {
background: var(--danger);
color: var(--text-light);
}
.zddc-menu__check {
font-size: 0.9rem;
text-align: center;
color: var(--primary);
}
.zddc-menu__icon {
font-size: 0.95rem;
text-align: center;
}
.zddc-menu__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.zddc-menu__accel {
color: var(--text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
padding-left: 0.5rem;
}
.zddc-menu__item--danger .zddc-menu__accel {
color: inherit;
opacity: 0.85;
}
.zddc-menu__arrow {
color: var(--text-muted);
font-size: 0.7rem;
text-align: center;
}
.zddc-menu__item--has-sub .zddc-menu__arrow {
color: var(--text);
}

381
shared/context-menu.js Normal file
View file

@ -0,0 +1,381 @@
// shared/context-menu.js — generic context-menu framework exposed on
// window.zddc.menu. Built so every ZDDC tool can drop a right-click
// menu (or any programmatically-opened menu) onto its UI without
// shipping its own implementation.
//
// API:
// window.zddc.menu.open({ x, y, items, context })
// window.zddc.menu.close()
//
// `items` is an array (or a function returning an array, evaluated
// against `context` at open-time). Each entry is one of:
// { label, action, icon?, accel?, disabled?, visible?, danger? }
// — a normal menu item; `action(ctx)` fires on click/Enter.
// { label, checked, action, ... }
// — toggle item; `checked` may be a bool or a fn(ctx). Renders
// a ✓ in the gutter when truthy.
// { label, items, ... }
// — submenu; `items` may itself be an array or fn(ctx).
// { separator: true }
// — horizontal divider. Leading/trailing/duplicate separators
// are collapsed automatically so callers can build items
// conditionally without managing dividers.
//
// Any of `label`, `checked`, `visible`, `disabled`, and `items` may
// be a function — each is invoked with the context object so callers
// can render fully context-aware menus from a single declarative
// config.
//
// Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a
// submenu, ArrowLeft / Escape backs up one level (or closes if
// already at the root), Enter / Space activates. Click-outside,
// window blur, scroll, and resize all dismiss.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.menu) return;
var SUBMENU_HOVER_MS = 180;
// Open menu stack — index 0 is the root, deeper entries are
// nested submenus. Each frame: { el, depth, parentRow? }.
var stack = [];
var rootContext = null;
var submenuTimer = null;
function resolve(val, ctx) {
return typeof val === 'function' ? val(ctx) : val;
}
function close() {
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
for (var i = 0; i < stack.length; i++) {
var fr = stack[i];
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
stack = [];
rootContext = null;
document.removeEventListener('mousedown', onDocMouseDown, true);
document.removeEventListener('keydown', onDocKeyDown, true);
// blur is bound WITHOUT capture so we only react to the window
// itself losing focus — capturing would also fire when any
// inner element blurs (which happens every time the user moves
// the mouse between menu rows, since hover focuses the row).
window.removeEventListener('blur', close);
window.removeEventListener('resize', close, true);
window.removeEventListener('scroll', onDocScroll, true);
}
function open(opts) {
opts = opts || {};
close();
rootContext = opts.context || {};
var items = resolve(opts.items, rootContext) || [];
var el = buildMenu(items, rootContext, 0);
document.body.appendChild(el);
position(el, opts.x || 0, opts.y || 0, null);
stack.push({ el: el, depth: 0 });
document.addEventListener('mousedown', onDocMouseDown, true);
document.addEventListener('keydown', onDocKeyDown, true);
window.addEventListener('blur', close);
window.addEventListener('resize', close, true);
window.addEventListener('scroll', onDocScroll, true);
focusFirst(el);
}
// ── Building ─────────────────────────────────────────────────────────
function collapseSeparators(items) {
var out = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (it && it.separator) {
if (out.length === 0) continue;
if (out[out.length - 1].separator) continue;
out.push(it);
} else if (it) {
out.push(it);
}
}
while (out.length && out[out.length - 1].separator) out.pop();
return out;
}
function buildMenu(items, ctx, depth) {
var menu = document.createElement('div');
menu.className = 'zddc-menu';
menu.setAttribute('role', 'menu');
menu.dataset.depth = String(depth);
// Suppress the native context menu over our own menu.
menu.addEventListener('contextmenu', function (e) { e.preventDefault(); });
var filtered = items.filter(function (it) {
if (!it) return false;
if (it.separator) return true;
if ('visible' in it && !resolve(it.visible, ctx)) return false;
return true;
});
var pruned = collapseSeparators(filtered);
for (var i = 0; i < pruned.length; i++) {
menu.appendChild(buildRow(pruned[i], ctx, depth));
}
return menu;
}
function buildRow(item, ctx, depth) {
if (item.separator) {
var sep = document.createElement('div');
sep.className = 'zddc-menu__sep';
sep.setAttribute('role', 'separator');
return sep;
}
var hasSub = !!item.items;
var isToggle = ('checked' in item);
var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false;
var row = document.createElement('div');
row.className = 'zddc-menu__item';
if (item.danger) row.classList.add('zddc-menu__item--danger');
if (hasSub) row.classList.add('zddc-menu__item--has-sub');
if (disabled) {
row.classList.add('is-disabled');
row.setAttribute('aria-disabled', 'true');
}
row.setAttribute('role',
hasSub ? 'menuitem'
: (isToggle ? 'menuitemcheckbox' : 'menuitem'));
row.tabIndex = -1;
// Check gutter — present on every row so columns align.
var check = document.createElement('span');
check.className = 'zddc-menu__check';
if (isToggle) {
var on = !!resolve(item.checked, ctx);
if (on) {
check.textContent = '✓';
row.classList.add('is-checked');
row.setAttribute('aria-checked', 'true');
} else {
row.setAttribute('aria-checked', 'false');
}
}
row.appendChild(check);
// Icon column.
var icon = document.createElement('span');
icon.className = 'zddc-menu__icon';
if (item.icon) icon.textContent = item.icon;
row.appendChild(icon);
// Label.
var label = document.createElement('span');
label.className = 'zddc-menu__label';
label.textContent = String(resolve(item.label, ctx) || '');
row.appendChild(label);
// Accelerator hint (visual only; no binding).
var accel = document.createElement('span');
accel.className = 'zddc-menu__accel';
if (item.accel) accel.textContent = item.accel;
row.appendChild(accel);
// Submenu arrow.
var arrow = document.createElement('span');
arrow.className = 'zddc-menu__arrow';
if (hasSub) arrow.textContent = '▸';
row.appendChild(arrow);
if (!disabled) {
row.addEventListener('mouseenter', function () {
// Hovering any row in a menu collapses deeper menus
// (so traversing siblings closes a previously-opened
// submenu) and re-focuses this row for keyboard nav.
closeBelow(depth);
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
submenuTimer = setTimeout(function () {
openSubmenu(row, item, ctx, depth + 1, false);
}, SUBMENU_HOVER_MS);
}
try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); }
});
row.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; }
if (hasSub) {
openSubmenu(row, item, ctx, depth + 1, true);
return;
}
activate(item, ctx);
});
}
return row;
}
function activate(item, ctx) {
try {
if (typeof item.action === 'function') item.action(ctx);
} finally {
close();
}
}
function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) {
closeBelow(depth - 1);
var items = resolve(parentItem.items, ctx) || [];
var el = buildMenu(items, ctx, depth);
document.body.appendChild(el);
var rect = parentRow.getBoundingClientRect();
// Slight overlap so pointer-cross feels continuous.
position(el, rect.right - 2, rect.top - 4, parentRow);
stack.push({ el: el, depth: depth, parentRow: parentRow });
if (takeFocus) focusFirst(el);
}
function closeBelow(depth) {
while (stack.length && stack[stack.length - 1].depth > depth) {
var fr = stack.pop();
if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
}
}
// ── Positioning ──────────────────────────────────────────────────────
function position(el, x, y, parentRow) {
// Fixed so we ignore document scroll; measure after layout.
el.style.position = 'fixed';
el.style.left = '0px';
el.style.top = '0px';
el.style.visibility = 'hidden';
var rect = el.getBoundingClientRect();
var w = rect.width;
var h = rect.height;
var vw = window.innerWidth;
var vh = window.innerHeight;
var leftX = x;
if (leftX + w > vw - 4) {
if (parentRow) {
var pr = parentRow.getBoundingClientRect();
leftX = pr.left - w + 2; // flip submenu to the left
} else {
leftX = Math.max(4, x - w); // flip root menu left of cursor
}
}
if (leftX < 4) leftX = 4;
var topY = y;
if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4);
if (topY < 4) topY = 4;
el.style.left = leftX + 'px';
el.style.top = topY + 'px';
el.style.visibility = '';
}
// ── Focus + keyboard ─────────────────────────────────────────────────
function focusable(menuEl) {
return Array.prototype.slice.call(
menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)'));
}
function focusFirst(menuEl) {
var items = focusable(menuEl);
if (items.length) {
try { items[0].focus({ preventScroll: true }); }
catch (_e) { items[0].focus(); }
}
}
function onDocMouseDown(e) {
for (var i = 0; i < stack.length; i++) {
if (stack[i].el.contains(e.target)) return;
}
close();
}
// Scroll listener uses capture so scrolls inside any element (the
// tree pane, the document, etc.) dismiss the menu — its position
// is fixed and would otherwise hang over stale content. Scrolls
// that originate inside the menu itself (a future tall submenu)
// are ignored.
function onDocScroll(e) {
var t = e.target;
for (var i = 0; i < stack.length; i++) {
if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) {
return;
}
}
close();
}
function onDocKeyDown(e) {
if (!stack.length) return;
var top = stack[stack.length - 1];
var items = focusable(top.el);
var active = document.activeElement;
var idx = items.indexOf(active);
switch (e.key) {
case 'Escape':
e.preventDefault();
if (stack.length > 1) {
var fr = stack.pop();
if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el);
if (fr.parentRow) fr.parentRow.focus();
} else {
close();
}
return;
case 'ArrowDown':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? 0 : (idx + 1) % items.length].focus();
return;
case 'ArrowUp':
e.preventDefault();
if (!items.length) return;
items[idx < 0 ? items.length - 1
: (idx - 1 + items.length) % items.length].focus();
return;
case 'Home':
e.preventDefault();
if (items.length) items[0].focus();
return;
case 'End':
e.preventDefault();
if (items.length) items[items.length - 1].focus();
return;
case 'ArrowRight':
if (active && active.classList.contains('zddc-menu__item--has-sub')) {
e.preventDefault();
active.click();
}
return;
case 'ArrowLeft':
if (stack.length > 1) {
e.preventDefault();
var fr2 = stack.pop();
if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el);
if (fr2.parentRow) fr2.parentRow.focus();
}
return;
case 'Enter':
case ' ':
if (active) {
e.preventDefault();
active.click();
}
return;
}
}
window.zddc.menu = { open: open, close: close };
})();

162
shared/icons.js Normal file
View file

@ -0,0 +1,162 @@
// shared/icons.js — minimal outline SVG sprite for ZDDC tools.
//
// Vendored from Lucide (https://lucide.dev, ISC). Only the 16
// file-type glyphs the browse tree maps to are bundled; total weight
// is ~4.5 KB of SVG path data. Each symbol viewBox is 0 0 24 24 with
// no stroke/fill attributes — those are applied at the call site via
// CSS so the icons inherit `currentColor` and tint with the theme.
//
// API:
// window.zddc.icons.inject() // mount sprite into <body> once
// window.zddc.icons.html('icon-foo') // → '<svg viewBox="0 0 24 24"><use href="#icon-foo"/></svg>'
// window.zddc.icons.ID // string set of valid symbol ids
//
// Callers concat html() output into innerHTML the same way they
// previously concat'd emoji glyphs. The injected sprite is hidden
// (`display:none` on the outer <svg>) so it costs zero layout.
//
// Why a sprite (rather than per-row inline paths): a hundred tree
// rows × 300 bytes of duplicated path data is 30 KB of churn on
// every re-render. With <use>, each row carries only a ~60-byte
// reference. The sprite is parsed once.
(function () {
'use strict';
if (!window.zddc) window.zddc = {};
if (window.zddc.icons) return;
// ── Sprite (Lucide outline glyphs, viewBox 24×24) ──────────────────────
// Concatenated from upstream lucide-static@1.16.0 SVGs; class/style
// attributes stripped. Order matches the icons-mapped block below
// so a diff against Lucide's source stays readable.
var SYMBOLS = ''
+ '<symbol id="icon-folder" viewBox="0 0 24 24">'
+ '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'
+ '</symbol>'
+ '<symbol id="icon-folder-archive" viewBox="0 0 24 24">'
+ '<circle cx="15" cy="19" r="2"/>'
+ '<path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/>'
+ '<path d="M15 11v-1"/>'
+ '<path d="M15 17v-2"/>'
+ '</symbol>'
+ '<symbol id="icon-file" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-text" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>'
+ '</symbol>'
+ '<symbol id="icon-file-image" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<circle cx="10" cy="12" r="2"/>'
+ '<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22"/>'
+ '</symbol>'
+ '<symbol id="icon-file-video" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M15.033 13.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56v-4.704a.645.645 0 0 1 .967-.56z"/>'
+ '</symbol>'
+ '<symbol id="icon-file-audio" viewBox="0 0 24 24">'
+ '<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0"/>'
+ '</symbol>'
+ '<symbol id="icon-file-archive" viewBox="0 0 24 24">'
+ '<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 12v-1"/><path d="M8 18v-2"/><path d="M8 7V6"/>'
+ '<circle cx="8" cy="20" r="2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-spreadsheet" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M8 13h2"/><path d="M14 13h2"/><path d="M8 17h2"/><path d="M14 17h2"/>'
+ '</symbol>'
+ '<symbol id="icon-file-code" viewBox="0 0 24 24">'
+ '<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10 12.5 8 15l2 2.5"/>'
+ '<path d="m14 12.5 2 2.5-2 2.5"/>'
+ '</symbol>'
+ '<symbol id="icon-file-cog" viewBox="0 0 24 24">'
+ '<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z"/>'
+ '<path d="M20 8v12a2 2 0 0 1-2 2h-4.182"/>'
+ '<path d="m3.305 19.53.923-.382"/>'
+ '<path d="M4 10.592V4a2 2 0 0 1 2-2h8"/>'
+ '<path d="m4.228 16.852-.924-.383"/>'
+ '<path d="m5.852 15.228-.383-.923"/>'
+ '<path d="m5.852 20.772-.383.924"/>'
+ '<path d="m8.148 15.228.383-.923"/>'
+ '<path d="m8.53 21.696-.382-.924"/>'
+ '<path d="m9.773 16.852.922-.383"/>'
+ '<path d="m9.773 19.148.922.383"/>'
+ '<circle cx="7" cy="18" r="3"/>'
+ '</symbol>'
+ '<symbol id="icon-file-pen" viewBox="0 0 24 24">'
+ '<path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34"/>'
+ '<path d="M14 2v5a1 1 0 0 0 1 1h5"/>'
+ '<path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z"/>'
+ '</symbol>'
+ '<symbol id="icon-book-marked" viewBox="0 0 24 24">'
+ '<path d="M10 2v8l3-3 3 3V2"/>'
+ '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20"/>'
+ '</symbol>'
+ '<symbol id="icon-presentation" viewBox="0 0 24 24">'
+ '<path d="M2 3h20"/>'
+ '<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3"/>'
+ '<path d="m7 21 5-5 5 5"/>'
+ '</symbol>'
+ '<symbol id="icon-ruler" viewBox="0 0 24 24">'
+ '<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>'
+ '<path d="m14.5 12.5 2-2"/>'
+ '<path d="m11.5 9.5 2-2"/>'
+ '<path d="m8.5 6.5 2-2"/>'
+ '<path d="m17.5 15.5 2-2"/>'
+ '</symbol>'
+ '<symbol id="icon-globe" viewBox="0 0 24 24">'
+ '<circle cx="12" cy="12" r="10"/>'
+ '<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/>'
+ '<path d="M2 12h20"/>'
+ '</symbol>'
// Lightweight outline chevron — used by the tree as the
// expand/collapse affordance. The single glyph rotates 90°
// via CSS to indicate the expanded state, so we only ship
// one path instead of two.
+ '<symbol id="icon-chevron-right" viewBox="0 0 24 24">'
+ '<path d="m9 18 6-6-6-6"/>'
+ '</symbol>';
var injected = false;
function inject() {
if (injected) return;
// insertAdjacentHTML on body parses the SVG namespace correctly
// across all modern browsers (innerHTML on a <div> wrapper has
// historically tripped over <symbol> in some engines).
var sprite = '<svg xmlns="http://www.w3.org/2000/svg" '
+ 'aria-hidden="true" style="position:absolute;width:0;height:0;'
+ 'overflow:hidden" focusable="false">'
+ SYMBOLS
+ '</svg>';
if (document.body) {
document.body.insertAdjacentHTML('afterbegin', sprite);
injected = true;
} else {
document.addEventListener('DOMContentLoaded', inject, { once: true });
}
}
// Produces the per-row markup callers concat into innerHTML.
// Bundles the size + stroke defaults inline so the SVG renders
// correctly even before the page CSS runs (e.g. mid-paint).
function html(symbolId) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" '
+ 'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
+ 'aria-hidden="true"><use href="#' + symbolId + '"/></svg>';
}
window.zddc.icons = { inject: inject, html: html };
})();

79
shared/vendor/codemirror-yaml.min.css vendored Normal file

File diff suppressed because one or more lines are too long

1
shared/vendor/codemirror-yaml.min.js vendored Normal file

File diff suppressed because one or more lines are too long