Compare commits

...
9 Commits
Author SHA1 Message Date
luxickandClaude Opus 5.5 aa90a04f91 Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead
of loading a new document: the server answers boosted requests and
history restores with a partial (title, page actions out-of-band, shell
content). The header stays, browsers without cross-document view
transitions (Firefox) no longer flash between pages, and the swap runs
as a same-document view transition. Files, the editor, internal
endpoints and POST forms still navigate normally.

Back/Forward refetch pages (no snapshots that could predate a move or
delete); the tree rail keeps its scroll position and the content column
returns to where it was. Mutation redirects swap their result in the
same way. Page scripts that assumed a fresh document now also run after
a swap (toc, contents, companion, scroll-sync, search actions) or
register once (fitness).

The e2e suite now also runs in Firefox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:29:08 +02:00
luxickandClaude Opus 5.5 fa90b3baee Revert "Declare a dark color scheme"
It did not remove the navigation flicker in Firefox; boosted navigation
will address it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:15:43 +02:00
luxickandClaude Opus 5.5 20f0ae5111 Declare a dark color scheme
Firefox has no cross-document view transitions and paints its default
white canvas between pages; color-scheme makes that frame dark. Native
controls (checkboxes, scrollbars) now use their dark variants too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:14:00 +02:00
luxickandClaude Opus 5.5 14fadf096a Make e2e tests independent and run them in parallel
Each test builds its pages in its own sandbox folder (search tests use a
random word), so tests no longer rely on earlier tests' leftovers and
run alone, in any order, or in parallel. The seed is just the home page.

Parallel load hid the htmx settle race from the timing-based tests, so
a dedicated test now submits a dialog in the task right after it is
inserted, which fails deterministically without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:01:16 +02:00
luxickandClaude Opus 5.5 a55a1c8ed4 Render search suggestions on the server
The typeahead requests /_search via htmx (debounced, newer requests
replace in-flight ones) and gets rendered rows with the match
highlighting done in Go. Rows are links, so picking in the header is
plain navigation; the editor's link dialog reads data-name instead. The
editor's [[ autocomplete keeps the JSON response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:00:08 +02:00
luxickandClaude Opus 5.5 4365fa2567 Render the folder tree on the server
The sidebar tree is part of the page (chain to the current page
expanded, active row marked), so it no longer paints late and the
sessionStorage snapshot is gone; only the rail's scroll position is
kept. Folders are native <details> that lazy-load their children as
HTML via htmx. The tree picker hosts the same server-rendered rows with
radio inputs, so ?tree returns HTML and the JSON listing is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:51:20 +02:00
luxickandClaude Opus 5.5 353df29ee6 Toggle tasks and edit view settings via htmx
Task checkboxes post themselves (hx-post="?toggle=N") and flip back when
the save fails, replacing tasks.js. View settings are a server-rendered
form dialog with real radios and a select, replacing view-settings.js;
with that, postReplace has no callers left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:41:59 +02:00
luxickandClaude Opus 5.5 3a3fb3b51f Serve delete, move and merge dialogs as htmx fragments
The server renders the dialogs (?dialog=delete|move) and answers a move
that needs confirmation with the merge dialog itself, so the client no
longer builds action URLs or interprets X-Merge-Available. Successful
mutations send HX-Redirect, which hx.js turns into a history replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:37:46 +02:00
luxick 465aed47b3 Add tests 2026-09-30 16:16:00 +02:00
40 changed files with 1806 additions and 1301 deletions
+6 -1
View File
@@ -11,7 +11,7 @@ RELEASE_FLAGS := -trimpath -ldflags="-s -w"
EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js EDITOR_BUNDLE := assets/editor/vendor/codemirror.bundle.js
EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json EDITOR_SRCS := $(wildcard editor-build/*.js) editor-build/package.json editor-build/package-lock.json
.PHONY: deploy companion companion-windows companion-linux companion-release editor .PHONY: deploy companion companion-windows companion-linux companion-release editor e2e
# Cross-compiled companion artifacts the wiki binary embeds. Both must exist # Cross-compiled companion artifacts the wiki binary embeds. Both must exist
# before `go build .` so embed.FS picks them up. # before `go build .` so embed.FS picks them up.
@@ -46,3 +46,8 @@ deploy: companion-release
scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape scp datascape-arm $(NAS):/share/homes/luxick/.local/bin/datascape
ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh ssh $(NAS) /share/homes/luxick/.local/bin/datascape-start.sh
rm datascape-arm rm datascape-arm
# Browser end-to-end tests (Playwright driving the system Chrome). Builds the
# wiki binary and runs it against a throwaway wiki in e2e/.tmp.
e2e:
cd e2e && npm install --no-audit --no-fund && npx playwright test
+9 -2
View File
@@ -200,11 +200,18 @@
// leaves the status icon and reveal buttons invisible on mobile. // leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return; if (isMobileDevice()) return;
probeStatus().then(function () { probeStatus().then(function () {
wireFileLinks(); // delegated to document: once
applyToPage();
// Boosted navigation swaps in a new page without reloading this
// script: re-apply to its footer icon and reveal buttons.
document.addEventListener('page:swapped', applyToPage);
});
}
function applyToPage() {
updateFooterIcon(); updateFooterIcon();
wireFileLinks();
wireRevealButton(); wireRevealButton();
wireFileRevealButtons(); wireFileRevealButtons();
});
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+6
View File
@@ -2,6 +2,11 @@
// reloads the page with that chart's query parameter updated. Plain GET // reloads the page with that chart's query parameter updated. Plain GET
// navigation — each view is distinct and bookmarkable, so no // navigation — each view is distinct and bookmarkable, so no
// postReplace/history handling is needed. // postReplace/history handling is needed.
// Boosted navigation re-runs this script on every visit; the listeners
// are delegated to document, so register them once.
if (!window.fitnessWired) {
window.fitnessWired = true;
document.addEventListener('change', function (e) { document.addEventListener('change', function (e) {
var url = new URL(window.location.href); var url = new URL(window.location.href);
var sel = e.target.closest('[data-fitness-range]'); var sel = e.target.closest('[data-fitness-range]');
@@ -72,3 +77,4 @@ document.addEventListener('pointermove', function (e) {
tip.style.left = left + 'px'; tip.style.left = left + 'px';
tip.style.top = top + 'px'; tip.style.top = top + 'px';
}); });
}
+140
View File
@@ -0,0 +1,140 @@
// Site-wide htmx policy. Loaded right after htmx itself.
(function () {
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
// settle step, by default 20ms after the swap: a click or Enter in that
// window would submit the form natively. No settle transitions are used,
// so settle in the same tick.
htmx.config.defaultSettleDelay = 0;
function boosting() {
return document.body.getAttribute('hx-boost') === 'true';
}
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
// target into the current history entry rather than pushing a new one, so
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
// navigation; elsewhere replaceState + reload (not location.replace: a
// target differing only by #fragment would otherwise not re-fetch).
htmx.location = {
set href(target) {
if (boosting()) {
if (window.closeModal) closeModal();
htmx.ajax('GET', target, {
target: '.shell',
swap: 'innerHTML transition:true',
headers: { 'HX-Boosted': 'true' },
replace: target
});
return;
}
window.history.replaceState(null, '', target);
window.location.reload();
},
reload: function () { window.location.reload(); }
};
// Failed requests answer with a plain-text reason; show it. A failed
// boosted navigation falls back to loading the page normally instead.
function fallBack(e) {
if (!e.detail.boosted) return false;
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
return true;
}
document.addEventListener('htmx:responseError', function (e) {
if (fallBack(e)) return;
var xhr = e.detail.xhr;
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
});
document.addEventListener('htmx:sendError', function (e) {
if (fallBack(e)) return;
alert('Network error');
});
// --- Boosted navigation ---------------------------------------------
// Outside the editor, links and GET forms to wiki pages load the next page
// into .shell instead of replacing the document: the server answers with
// the "partial" (title, page actions out-of-band, shell content). The
// header stays put, nothing flashes in browsers without cross-document
// view transitions, and the swap runs as a same-document view transition.
// Back/Forward refetch the partial (hx-history="false" on .shell: no
// snapshots that could show a page from before a move or delete).
htmx.config.scrollIntoViewOnBoost = false;
// Only wiki pages are boosted: same origin, a slash-terminated path, no
// query except a search (?q=). Files, the editor (?edit), internal
// endpoints (/_…) and POST forms navigate normally.
function isPageURL(url) {
if (url.origin !== location.origin) return false;
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
var ok = true;
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
return ok;
}
// htmx decides per element whether it is boosted, right after this event.
document.addEventListener('htmx:beforeProcessNode', function (e) {
var elt = e.target;
if (elt.hasAttribute('hx-boost')) return;
var page = false;
if (elt.tagName === 'A') {
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
} else if (elt.tagName === 'FORM') {
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
} else {
return;
}
if (!page) elt.setAttribute('hx-boost', 'false');
});
function isShell(elt) {
return elt && elt.classList && elt.classList.contains('shell');
}
// Scroll positions: the tree rail keeps its position across navigation;
// the content column (.center) starts at the top, or where it was when
// coming back via Back/Forward.
var treeTop = 0;
var restoring = false;
var centerTops = {};
function pageKey() {
return location.pathname + location.search;
}
function rememberTree() {
var tree = document.querySelector('.tree-scroll');
treeTop = tree ? tree.scrollTop : 0;
}
document.addEventListener('scroll', function (e) {
var t = e.target;
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
}, { capture: true, passive: true });
document.addEventListener('htmx:beforeSwap', function (e) {
if (e.detail.boosted) {
e.detail.target = document.querySelector('.shell');
e.detail.swapOverride = 'innerHTML transition:true';
}
if (isShell(e.detail.target)) rememberTree();
});
document.addEventListener('htmx:historyCacheMissLoad', function () {
restoring = true;
rememberTree();
});
// After a new page is in .shell: restore scroll positions, then tell page
// scripts (companion.js, …) to apply themselves to the new content.
document.addEventListener('htmx:afterSettle', function (e) {
if (!isShell(e.target)) return;
var tree = document.querySelector('.tree-scroll');
if (tree) {
tree.scrollTop = treeTop;
var active = tree.querySelector('.tree-row.is-active');
if (active) active.scrollIntoView({ block: 'nearest' });
}
if (restoring) {
restoring = false;
var center = document.querySelector('.center');
if (center) center.scrollTop = centerTops[pageKey()] || 0;
}
document.dispatchEvent(new CustomEvent('page:swapped'));
});
})();
+45 -21
View File
@@ -8,6 +8,8 @@
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="{{asset "style.css"}}" /> <link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "hx.js"}}" defer></script>
<script src="{{asset "modal.js"}}"></script> <script src="{{asset "modal.js"}}"></script>
<script src="{{asset "global-shortcuts.js"}}"></script> <script src="{{asset "global-shortcuts.js"}}"></script>
<script src="{{asset "history-nav.js"}}"></script> <script src="{{asset "history-nav.js"}}"></script>
@@ -16,10 +18,13 @@
<script src="{{asset "tree-picker.js"}}"></script> <script src="{{asset "tree-picker.js"}}"></script>
<script src="{{asset "companion.js"}}" defer></script> <script src="{{asset "companion.js"}}" defer></script>
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script> {{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
<script src="{{asset "tree-sidebar.js"}}" defer></script>{{end}} <script src="{{asset "page/actions.js"}}"></script>
{{block "headScripts" .}}{{end}} <script src="{{asset "search/actions.js"}}"></script>{{end}}
</head> </head>
<body> {{/* Outside the editor, links to pages are boosted: htmx swaps .shell
(plus title and page actions, see "partial") instead of loading a new
document. hx.js decides which links count as pages. */}}
<body{{if not .EditMode}} hx-boost="true"{{end}}>
<header> <header>
<nav class="breadcrumb row"> <nav class="breadcrumb row">
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a> <a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
@@ -35,29 +40,46 @@
<span class="dropdown actions"> <span class="dropdown actions">
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button> <button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
<div class="dropdown-menu align-right actions-menu"> <div class="dropdown-menu align-right actions-menu">
<div class="actions-special"></div> {{template "page-actions" .}}
{{template "headerActions" . }}
</div> </div>
</span> </span>
{{end}} {{end}}
</div> </div>
</header> </header>
<div class="shell"> <div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
{{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body>
</html>
{{end}}
{{/* The page-specific header actions; the partial swaps them out-of-band
(the attribute is inert in a full page). */}}
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
<div class="actions-special">
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
</div>
{{template "headerActions" .}}
</div>{{end}}
{{define "shell"}}
{{if not .EditMode}}<aside class="tree-sidebar col"> {{if not .EditMode}}<aside class="tree-sidebar col">
<div class="tree-scroll"></div> <nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
<script> <script>
// Paint the previous page's tree before first render so the rail // Full page loads: keep the rail's scroll position (before first
// doesn't flash empty; tree-sidebar.js swaps in the live tree. // paint, so it doesn't jump), then reveal the active row. Boosted
// navigation keeps it itself (hx.js); this script re-runs then and
// bails out.
(function () { (function () {
try { if (document.readyState !== "loading") return;
var s = JSON.parse(sessionStorage.getItem("tree-snapshot"));
if (!s) return;
var el = document.currentScript.previousElementSibling; var el = document.currentScript.previousElementSibling;
el.innerHTML = s.html; try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
var a = el.querySelector('a.tree-name[href="' + CSS.escape(location.pathname.replace(/\/*$/, "/")) + '"]'); var active = el.querySelector(".tree-row.is-active");
if (a) a.parentNode.classList.add("is-active"); if (active) active.scrollIntoView({ block: "nearest" });
el.scrollTop = s.top; addEventListener("pagehide", function () {
} catch (e) {} var cur = document.querySelector(".tree-scroll");
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
});
})(); })();
</script> </script>
</aside>{{end}} </aside>{{end}}
@@ -75,8 +97,10 @@
</footer> </footer>
</div> </div>
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}} {{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}}
</body>
</html>
{{end}} {{end}}
{{/* Response to boosted navigation and history restores (hx.js swaps it into
.shell): the title, the page actions out-of-band, and the shell's content. */}}
{{define "partial"}}<title>{{.Title}}</title>
{{template "page-actions" .}}
{{template "shell" .}}{{end}}
+121 -113
View File
@@ -1,65 +1,44 @@
// One modal at a time, from either of two sources sharing the same chrome
// (backdrop click, Escape, drag by the header, focus restore):
// - openModal(opts) builds a modal from JS;
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
// #modal-host. Their forms post themselves; a [data-modal-close] element
// closes them.
(function () { (function () {
var backdrop = null; var current = null; // { backdrop, prevFocus, onKeydown }
var modal = null; var drag = null; // { modal, startX, startY, originX, originY }
var titleEl = null;
var bodyEl = null;
var cancelBtn = null;
var confirmBtn = null;
var footerEl = null;
var prevFocus = null;
var currentOpts = null;
var onKeydown = null;
function build() {
backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
modal = document.createElement('div');
modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div');
header.className = 'panel-header';
titleEl = document.createElement('span');
header.appendChild(titleEl);
bodyEl = document.createElement('div');
bodyEl.className = 'panel-body';
footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
// show mounts backdrop (whose child is the .modal) into parent and makes
// it the current modal. onKeydown handles keys beyond Escape.
function show(backdrop, parent, prevFocus, onKeydown) {
var modal = backdrop.querySelector('.modal');
var header = modal.querySelector('.panel-header');
function onDown(e) { startDrag(e, modal); }
header.addEventListener('mousedown', onDown);
header.addEventListener('touchstart', onDown, { passive: false });
backdrop.addEventListener('mousedown', function (e) { backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close(); if (e.target === backdrop) close();
}); });
wireDrag(header); backdrop.addEventListener('click', function (e) {
cancelBtn.addEventListener('click', close); if (e.target.closest('[data-modal-close]')) close();
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
currentOpts.confirm.onConfirm();
}
}); });
var keydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (onKeydown) onKeydown(e);
};
document.addEventListener('keydown', keydown);
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
} }
// wireDrag makes the modal draggable by `handle`. Dragging switches the // Dragging switches the modal to fixed positioning so flexbox alignment
// modal to fixed positioning so flexbox alignment doesn't fight us. // doesn't fight us.
function wireDrag(handle) { function startDrag(e, modal) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return; if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e; var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect(); var rect = modal.getBoundingClientRect();
@@ -67,54 +46,66 @@
modal.style.position = 'fixed'; modal.style.position = 'fixed';
modal.style.left = rect.left + 'px'; modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px'; modal.style.top = rect.top + 'px';
dragging = true; drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault(); e.preventDefault();
} }
function onMove(e) { function onMove(e) {
if (!dragging) return; if (!drag) return;
var pt = e.touches ? e.touches[0] : e; var pt = e.touches ? e.touches[0] : e;
var dx = pt.clientX - startX; var m = drag.modal;
var dy = pt.clientY - startY; var maxX = window.innerWidth - m.offsetWidth;
var w = modal.offsetWidth; var maxY = window.innerHeight - m.offsetHeight;
var h = modal.offsetHeight; m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
var maxX = window.innerWidth - w; m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
var maxY = window.innerHeight - h;
var x = Math.max(0, Math.min(maxX, originX + dx));
var y = Math.max(0, Math.min(maxY, originY + dy));
modal.style.left = x + 'px';
modal.style.top = y + 'px';
} }
function onUp() { dragging = false; } function onUp() { drag = null; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove); document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp); document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp); document.addEventListener('touchend', onUp);
// detach tears down the current modal and returns the element that had
// focus before it opened. keepDOM leaves the backdrop in place for a swap
// about to replace it: htmx must still find the element that issued the
// request in the document.
function detach(keepDOM) {
if (!current) return null;
var c = current;
current = null;
drag = null;
document.removeEventListener('keydown', c.onKeydown);
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
return c.prevFocus;
}
function close() {
var toRestore = detach();
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
} }
function open(opts) { function open(opts) {
if (backdrop && backdrop.parentNode) close(); var prevFocus = current ? detach() : document.activeElement;
if (!backdrop) build();
currentOpts = opts; var backdrop = document.createElement('div');
prevFocus = document.activeElement; backdrop.className = 'modal-backdrop';
var modal = document.createElement('div');
modal.classList.remove('is-dragged'); modal.className = 'modal panel panel-floating';
modal.style.position = ''; modal.setAttribute('role', 'dialog');
modal.style.left = ''; modal.setAttribute('aria-modal', 'true');
modal.style.top = '';
var header = document.createElement('div');
header.className = 'panel-header';
var titleEl = document.createElement('span');
titleEl.textContent = opts.title || ''; titleEl.textContent = opts.title || '';
header.appendChild(titleEl);
bodyEl.textContent = ''; var bodyEl = document.createElement('div');
bodyEl.className = 'panel-body';
if (opts.body instanceof Node) { if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body); bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') { } else if (typeof opts.body === 'string') {
@@ -124,14 +115,24 @@
var confirmOpts = opts.confirm || {}; var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {}; var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK'; confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : ''); confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
confirmBtn.disabled = !!confirmOpts.initiallyDisabled; confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = cancelOpts.label || 'CANCEL'; cancelBtn.textContent = cancelOpts.label || 'CANCEL';
cancelBtn.className = 'btn'; cancelBtn.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
footerEl.textContent = ''; var footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
if (opts.swapButtons) { if (opts.swapButtons) {
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn); footerEl.appendChild(cancelBtn);
@@ -140,7 +141,20 @@
footerEl.appendChild(confirmBtn); footerEl.appendChild(confirmBtn);
} }
document.body.appendChild(backdrop); modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
var enterConfirms = confirmOpts.enterConfirms !== false;
show(backdrop, document.body, prevFocus, function (e) {
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
});
setTimeout(function () { setTimeout(function () {
if (cancelOpts.autofocus) { if (cancelOpts.autofocus) {
@@ -156,22 +170,6 @@
} }
}, 0); }, 0);
var enterConfirms = confirmOpts.enterConfirms !== false;
onKeydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
};
document.addEventListener('keydown', onKeydown);
return { return {
close: close, close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; }, setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
@@ -179,20 +177,30 @@
}; };
} }
function close() { // Server-rendered dialogs. A swap into #modal-host replaces whatever modal
if (!backdrop || !backdrop.parentNode) return; // is open (e.g. the merge step replacing the move dialog); focus returns
if (onKeydown) { // to where it was before the first one opened.
document.removeEventListener('keydown', onKeydown); var hostPrevFocus = null;
onKeydown = null; // History restores swap without event details; they never target the host.
} function isHost(e) {
backdrop.parentNode.removeChild(backdrop); return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
} }
document.addEventListener('htmx:beforeSwap', function (e) {
if (!isHost(e)) return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
if (!isHost(e)) return;
var host = e.detail.target;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
if (f) {
f.focus();
if (f.select) f.select();
} }
});
window.openModal = open; window.openModal = open;
window.closeModal = close; window.closeModal = close;
+6 -115
View File
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/'); return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
} }
// postReplace POSTs to action with the optional form body, then loads target
// into the current history entry — so the action and its result occupy one
// entry instead of two, and back-navigation skips past the stale pre-mutation
// snapshot in bfcache. body may be null for empty POSTs.
//
// We can't just call window.location.replace(target): when target differs from
// the current URL only by fragment, the browser updates the URL bar without
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' };
if (body) {
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = body;
}
fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function promptPageName(title, initial, confirmLabel, onName) { function promptPageName(title, initial, confirmLabel, onName) {
var input = document.createElement('input'); var input = document.createElement('input');
input.type = 'text'; input.type = 'text';
@@ -75,45 +41,10 @@ function newPage() {
}); });
} }
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() { function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, ''); var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return; if (!current) return;
var segs = current.split('/').filter(Boolean); var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
var currentName = segs[segs.length - 1] || '';
var parent = '/' + segs.slice(0, -1).join('/');
if (parent === '/') parent = '/';
openTreePicker({ openTreePicker({
title: 'Move — new parent?', title: 'Move — new parent?',
@@ -123,53 +54,13 @@ function movePage() {
hideFiles: true, hideFiles: true,
confirmLabel: 'NEXT', confirmLabel: 'NEXT',
onSelect: function (newParent) { onSelect: function (newParent) {
var input = document.createElement('input'); // The server renders the name step and handles what follows
input.type = 'text'; // (merge confirmation, errors, redirect).
input.className = 'input';
input.placeholder = 'Page name';
input.value = currentName;
openModal({
title: 'Move — new name?',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
var target = encodePickedPath(dest) + '/';
closeModal(); closeModal();
submitMove(action, target); htmx.ajax('GET', window.location.pathname + '?dialog=move', {
} target: '#modal-host',
} values: { parent: newParent }
}); });
} }
}); });
} }
function deletePage() {
var decodedPath = decodeURIComponent(window.location.pathname);
openModal({
title: 'Delete page',
body: 'Delete ' + decodedPath + ' and everything inside it?',
confirm: {
label: 'DELETE',
danger: true,
enterConfirms: false,
onConfirm: function () {
var p = window.location.pathname.replace(/\/+$/, '');
var idx = p.lastIndexOf('/');
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
closeModal();
postReplace(window.location.pathname + '?delete=1', null, parent);
}
},
cancel: { autofocus: true },
swapButtons: true
});
}
+8 -2
View File
@@ -1,4 +1,7 @@
document.addEventListener("DOMContentLoaded", function () { // Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var aside = document.querySelector("aside.sidebar"); var aside = document.querySelector("aside.sidebar");
var specials = document.querySelector(".actions-special"); var specials = document.querySelector(".actions-special");
if (!aside || !aside.children.length || !specials) return; if (!aside || !aside.children.length || !specials) return;
@@ -12,4 +15,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (typeof openOverlay === "function") openOverlay(aside); if (typeof openOverlay === "function") openOverlay(aside);
}); });
specials.appendChild(item); specials.appendChild(item);
}); }
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
+80
View File
@@ -0,0 +1,80 @@
{{/* Server-rendered dialogs, fetched with ?dialog=<name> and swapped into
#modal-host by htmx; modal.js supplies the chrome (backdrop, Escape, drag,
focus). Each dialog is a form that posts the action it offers, so the client
never builds action URLs itself. Responses to those posts either redirect
(HX-Redirect), replace the dialog with the next step (e.g. the merge
confirmation), or fail with a plain-text error. */}}
{{define "delete"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?delete=1" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Delete page</span></div>
<div class="panel-body">Delete {{.Path}} and everything inside it?</div>
<div class="panel-footer">
<button class="btn danger" type="submit">DELETE</button>
<button class="btn" type="button" data-modal-close autofocus>CANCEL</button>
</div>
</form>
</div>{{end}}
{{define "move"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
<div class="panel-body">
<input type="hidden" name="parent" value="{{.Parent}}" />
<input class="input" type="text" name="name" value="{{.Name}}" placeholder="Page name" required autofocus />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit">MOVE</button>
</div>
</form>
</div>{{end}}
{{define "merge"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Merge folders?</span></div>
<div class="panel-body">
The destination folder already exists but has no page of its own. Merge this page and its contents into it?
<input type="hidden" name="move" value="{{.Dest}}" />
<input type="hidden" name="merge" value="1" />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>MERGE</button>
</div>
</form>
</div>{{end}}
{{define "view"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">View settings</span></div>
<div class="panel-body col">
<fieldset class="col gap-1">
<legend class="caption">View style</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
</div>
</fieldset>
<label class="col gap-1">
<span class="caption">Sort by</span>
<select class="input" name="sort">
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
</select>
</label>
<fieldset class="col gap-1">
<legend class="caption">Order</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
</div>
</fieldset>
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>SAVE</button>
</div>
</form>
</div>{{end}}
+2 -6
View File
@@ -1,11 +1,9 @@
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
{{define "headerActions"}}{{if .CanEdit}} {{define "headerActions"}}{{if .CanEdit}}
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button> <button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button> <button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}} {{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button> <button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button> <button class="btn btn-block danger" hx-get="?dialog=delete" hx-target="#modal-host" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
{{end}} {{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a> <a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
{{end}}{{end}} {{end}}{{end}}
@@ -21,7 +19,7 @@
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p> <p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
{{end}} {{end}}
{{if .Entries}} {{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2> <h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" hx-get="?dialog=view" hx-target="#modal-host" title="View &amp; sorting">view</button>{{end}}</h2>
{{if eq .View "thumbnail"}} {{if eq .View "thumbnail"}}
<div class="thumb-grid"> <div class="thumb-grid">
{{range .Entries}} {{range .Entries}}
@@ -44,12 +42,10 @@
</tbody> </tbody>
</table> </table>
{{end}} {{end}}
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
{{end}} {{end}}
{{if or .Content .SpecialContent}} {{if or .Content .SpecialContent}}
<script src="{{asset "page/content.js"}}"></script> <script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}} {{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
<script src="{{asset "page/tasks.js"}}"></script>
{{end}} {{end}}
{{if .Content}} {{if .Content}}
<script src="{{asset "page/sections.js"}}"></script> <script src="{{asset "page/sections.js"}}"></script>
-24
View File
@@ -1,24 +0,0 @@
(function () {
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
cb.addEventListener('change', function () {
var idx = cb.dataset.taskIndex;
var checked = cb.checked;
cb.disabled = true;
fetch(window.location.pathname + '?toggle=' + idx, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'checked=' + checked
}).then(function (res) {
if (!res.ok) {
cb.checked = !checked;
alert('Failed to save task state (' + res.status + ')');
}
}).catch(function () {
cb.checked = !checked;
alert('Failed to save task state');
}).finally(function () {
cb.disabled = false;
});
});
});
})();
+8 -2
View File
@@ -1,4 +1,7 @@
document.addEventListener("DOMContentLoaded", function () { // Runs on DOMContentLoaded for a full page load, and right away when this
// script arrives with a boosted navigation (the document is loaded then).
(function () {
function run() {
var content = document.querySelector("main"); var content = document.querySelector("main");
if (!content) return; if (!content) return;
@@ -29,4 +32,7 @@ document.addEventListener("DOMContentLoaded", function () {
nav.appendChild(list); nav.appendChild(list);
content.insertBefore(nav, content.firstChild); content.insertBefore(nav, content.firstChild);
}); }
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();
})();
-85
View File
@@ -1,85 +0,0 @@
// View-settings modal: lets the user pick the folder listing's view style,
// sort key, and order, then persists them by POSTing to the folder with
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
function openViewSettings() {
var btn = document.getElementById('view-settings-btn');
var state = {
view: (btn && btn.dataset.view) || 'list',
sort: (btn && btn.dataset.sort) || 'name',
order: (btn && btn.dataset.order) || 'asc'
};
// segmented builds a row of mutually-exclusive .btn toggles bound to a
// single state key, marking the current choice with .is-active.
function segmented(key, options) {
var wrap = document.createElement('div');
wrap.className = 'row gap-1';
options.forEach(function (opt) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () {
state[key] = opt.value;
wrap.querySelectorAll('button').forEach(function (x) {
x.classList.remove('is-active');
});
b.classList.add('is-active');
});
wrap.appendChild(b);
});
return wrap;
}
function field(labelText, control) {
var row = document.createElement('div');
row.className = 'col gap-1';
var label = document.createElement('span');
label.className = 'caption';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
var sortSelect = document.createElement('select');
sortSelect.className = 'input';
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
if (state.sort === o[0]) opt.selected = true;
sortSelect.appendChild(opt);
});
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
var body = document.createElement('div');
body.className = 'col';
body.appendChild(field('View style', segmented('view', [
{ value: 'list', label: 'List' },
{ value: 'thumbnail', label: 'Thumbnail' }
])));
body.appendChild(field('Sort by', sortSelect));
body.appendChild(field('Order', segmented('order', [
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' }
])));
openModal({
title: 'View settings',
body: body,
confirm: {
label: 'SAVE',
onConfirm: function () {
var action = window.location.pathname + '?settings';
var formBody = 'view=' + encodeURIComponent(state.view) +
'&sort=' + encodeURIComponent(state.sort) +
'&order=' + encodeURIComponent(state.order);
var target = window.location.pathname;
closeModal();
postReplace(action, formBody, target);
}
}
});
}
+10 -5
View File
@@ -45,17 +45,22 @@
} }
// Reading mode: keep the latest scroll fraction recorded (throttled to one // Reading mode: keep the latest scroll fraction recorded (throttled to one
// write per frame) so it is ready the moment the editor opens. // write per frame) so it is ready the moment the editor opens. Boosted
// navigation replaces .center and changes the page without a reload, so
// listen on document and resolve both when writing.
var pending = false; var pending = false;
center.addEventListener('scroll', function () { document.addEventListener('scroll', function (e) {
if (pending) return; if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
pending = true; pending = true;
requestAnimationFrame(function () { requestAnimationFrame(function () {
pending = false; pending = false;
center = document.querySelector('.center');
if (!center) return;
var m = maxScroll(); var m = maxScroll();
try { try {
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() })); sessionStorage.setItem('scrollsync:' + location.pathname,
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
} catch (e) {} } catch (e) {}
}); });
}, { passive: true }); }, { capture: true, passive: true });
})(); })();
+73 -246
View File
@@ -1,63 +1,18 @@
// search-suggest.js — instant typeahead dropdown. // search-suggest.js — instant typeahead dropdown.
// //
// Exposes window.attachSuggestions(inputEl, opts) used by both the header // Exposes window.attachSuggestions(inputEl, opts) used by both the header
// search box and the editor's "Insert link" modal. Owns: debounced fetching, // search box and the editor's "Insert link" modal. Fetching is htmx: the input
// request ordering, DOM creation, keyboard handling, open/close lifecycle. // requests /_search as you type (debounced; a newer request replaces an
// in-flight one) and the server renders the rows (assets/search/suggest.html).
// This file owns the dropdown's open/close lifecycle and keyboard handling.
// //
// opts: // opts:
// onPick(result) — called when the user selects a row // onPick({name}) — optional; called when the user selects a row.
// onShowAll(query) — optional; called when the footer row activates // Without it rows are plain links and navigate.
// showFooter (bool) — show the "Show all N matches" footer row // showFooter (bool) — show the "Show all N matches" footer row
// container (Element) — optional parent (defaults to inputEl.parentNode) // container (Element) — optional parent (defaults to inputEl.parentNode)
(function () { (function () {
var DEBOUNCE_MS = 100; var seq = 0;
var MIN_QUERY_LEN = 2;
function escapeHTML(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function tokenize(s) {
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
}
// highlight bolds the substring spans in `name` that match any of the
// query tokens (case-insensitive). Overlapping/adjacent spans merge.
// Returns a safe HTML string.
function highlight(name, tokens) {
if (!tokens.length) return escapeHTML(name);
var lower = name.toLowerCase();
var spans = [];
tokens.forEach(function (t) {
if (!t) return;
var idx = lower.indexOf(t);
if (idx >= 0) spans.push([idx, idx + t.length]);
});
if (!spans.length) return escapeHTML(name);
spans.sort(function (a, b) { return a[0] - b[0]; });
var merged = [spans[0].slice()];
for (var i = 1; i < spans.length; i++) {
var last = merged[merged.length - 1];
if (spans[i][0] <= last[1]) {
last[1] = Math.max(last[1], spans[i][1]);
} else {
merged.push(spans[i].slice());
}
}
var out = '';
var cursor = 0;
merged.forEach(function (sp) {
out += escapeHTML(name.slice(cursor, sp[0]));
out += '<strong>' + escapeHTML(name.slice(sp[0], sp[1])) + '</strong>';
cursor = sp[1];
});
out += escapeHTML(name.slice(cursor));
return out;
}
function attachSuggestions(inputEl, opts) { function attachSuggestions(inputEl, opts) {
if (!inputEl) return; if (!inputEl) return;
@@ -68,237 +23,116 @@
var dropdown = document.createElement('div'); var dropdown = document.createElement('div');
dropdown.className = 'suggest-dropdown'; dropdown.className = 'suggest-dropdown';
dropdown.id = 'suggest-' + (++seq);
host.appendChild(dropdown); host.appendChild(dropdown);
function makeRow(cls, tabbable) { // htmx sends the input's value under its name.
var tr = document.createElement('tr'); if (!inputEl.name) inputEl.name = 'q';
tr.className = cls; inputEl.setAttribute('hx-get', '/_search' + (opts.showFooter ? '?footer' : ''));
if (tabbable) tr.setAttribute('tabindex', '0'); inputEl.setAttribute('hx-trigger', 'input changed delay:100ms');
var td = document.createElement('td'); inputEl.setAttribute('hx-sync', 'this:replace');
tr.appendChild(td); inputEl.setAttribute('hx-target', '#' + dropdown.id);
return { tr: tr, td: td }; htmx.process(inputEl);
var activeIdx = -1;
function rows() {
return dropdown.querySelectorAll('.suggest-row');
} }
var state = { function isOpen() {
results: [], return dropdown.classList.contains('is-open');
total: 0,
query: '',
activeIdx: -1,
open: false,
reqSeq: 0,
debounceTimer: null,
blurTimer: null,
};
function rowCount() {
var n = state.results.length;
if (state.results.length === 0 && state.query.length >= MIN_QUERY_LEN) {
return 0; // "no matches" row is non-interactive
}
if (opts.showFooter && state.total > state.results.length) n += 1;
return n;
}
function isFooterIdx(idx) {
return opts.showFooter
&& state.total > state.results.length
&& idx === state.results.length;
}
function render() {
dropdown.textContent = '';
if (!state.open) {
dropdown.classList.remove('is-open');
return;
}
var table = document.createElement('table');
table.className = 'data-table';
var tbody = document.createElement('tbody');
table.appendChild(tbody);
var tokens = tokenize(state.query);
if (state.results.length === 0) {
var empty = makeRow('is-empty', false);
empty.td.textContent = 'No matches';
tbody.appendChild(empty.tr);
} else {
state.results.forEach(function (r, i) {
var row = makeRow('suggest-row', true);
row.tr.setAttribute('data-idx', String(i));
var nameEl = document.createElement('span');
nameEl.className = 'suggest-name';
nameEl.innerHTML = highlight(r.name, tokens);
var pathEl = document.createElement('span');
pathEl.className = 'suggest-path';
pathEl.textContent = '/' + r.path;
row.td.appendChild(nameEl);
row.td.appendChild(pathEl);
if (i === state.activeIdx) row.tr.classList.add('is-active');
row.tr.addEventListener('mousedown', function (e) {
// mousedown (not click) so the input doesn't blur-close
// the dropdown before the pick handler fires.
e.preventDefault();
pick(i);
});
tbody.appendChild(row.tr);
});
if (opts.showFooter && state.total > state.results.length) {
var footer = makeRow('suggest-row suggest-footer', true);
footer.td.textContent = 'Show all ' + state.total + ' matches';
var footerIdx = state.results.length;
if (state.activeIdx === footerIdx) footer.tr.classList.add('is-active');
footer.tr.addEventListener('mousedown', function (e) {
e.preventDefault();
pickFooter();
});
tbody.appendChild(footer.tr);
}
}
dropdown.appendChild(table);
dropdown.classList.add('is-open');
}
function pick(idx) {
var r = state.results[idx];
if (!r) return;
close();
if (opts.onPick) opts.onPick(r);
}
function pickFooter() {
close();
if (opts.onShowAll) {
opts.onShowAll(state.query);
} else if (inputEl.form) {
inputEl.form.submit();
} else {
window.location.href = '/?q=' + encodeURIComponent(state.query);
}
} }
function open() { function open() {
state.open = true; if (dropdown.firstElementChild) dropdown.classList.add('is-open');
render();
} }
function close() { function close() {
state.open = false; dropdown.classList.remove('is-open');
state.activeIdx = -1; setActive(-1);
render();
} }
function fetchResults(query) { function setActive(idx) {
var seq = ++state.reqSeq; var rs = rows();
fetch('/_search?q=' + encodeURIComponent(query), { if (rs[activeIdx]) rs[activeIdx].classList.remove('is-active');
credentials: 'same-origin', activeIdx = idx;
headers: { 'Accept': 'application/json' }, if (rs[idx]) {
}).then(function (r) { rs[idx].classList.add('is-active');
if (!r.ok) throw new Error('HTTP ' + r.status); try { rs[idx].scrollIntoView({ block: 'nearest' }); } catch (e) {}
return r.json();
}).then(function (resp) {
if (seq !== state.reqSeq) return; // stale
state.results = resp.results || [];
state.total = resp.total || 0;
state.query = resp.query || query;
state.activeIdx = -1;
open();
}).catch(function () {
if (seq !== state.reqSeq) return;
state.results = [];
state.total = 0;
close();
});
} }
function onInput() {
var q = inputEl.value.trim();
state.query = q;
if (state.debounceTimer) clearTimeout(state.debounceTimer);
if (q.length < MIN_QUERY_LEN) {
state.reqSeq++; // invalidate any in-flight response
state.results = [];
state.total = 0;
close();
return;
}
state.debounceTimer = setTimeout(function () {
fetchResults(q);
}, DEBOUNCE_MS);
} }
function moveActive(delta) { function moveActive(delta) {
var n = rowCount(); var n = rows().length;
if (n === 0) return; if (n === 0) return;
var next = state.activeIdx + delta; var next = activeIdx + delta;
if (next < 0) next = n - 1; if (next < 0) next = n - 1;
if (next >= n) next = 0; if (next >= n) next = 0;
state.activeIdx = next; setActive(next);
render(); }
// Keep the active row in view.
var active = dropdown.querySelector('tr.is-active'); // pick activates a row: onPick for data rows when given, otherwise
if (active && active.scrollIntoView) { // (and always for the footer) the row's link.
try { active.scrollIntoView({ block: 'nearest' }); } catch (e) {} function pick(row) {
close();
if (opts.onPick && !row.classList.contains('suggest-footer')) {
opts.onPick({ name: row.dataset.name });
} else {
row.querySelector('a').click();
} }
} }
function activateCurrent() { dropdown.addEventListener('htmx:afterSwap', function () {
if (state.activeIdx < 0) return false; activeIdx = -1;
if (isFooterIdx(state.activeIdx)) { if (document.activeElement === inputEl && dropdown.firstElementChild) {
pickFooter();
return true;
}
pick(state.activeIdx);
return true;
}
inputEl.addEventListener('input', onInput);
inputEl.addEventListener('focus', function () {
if (state.blurTimer) {
clearTimeout(state.blurTimer);
state.blurTimer = null;
}
if (inputEl.value.trim().length >= MIN_QUERY_LEN
&& (state.results.length || state.query)) {
open(); open();
} else {
close();
} }
}); });
inputEl.addEventListener('blur', function () {
// Delay so click/mousedown on a row still resolves. // mousedown keeps focus in the input so blur doesn't close the
state.blurTimer = setTimeout(close, 150); // dropdown before the click lands.
dropdown.addEventListener('mousedown', function (e) { e.preventDefault(); });
dropdown.addEventListener('click', function (e) {
var row = e.target.closest('.suggest-row');
if (!row) return;
if (opts.onPick && !row.classList.contains('suggest-footer')) {
e.preventDefault();
pick(row);
} else {
close(); // the link navigates
}
}); });
inputEl.addEventListener('focus', open);
inputEl.addEventListener('blur', close);
inputEl.addEventListener('keydown', function (e) { inputEl.addEventListener('keydown', function (e) {
if (!isOpen()) return;
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(1); moveActive(1);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
moveActive(-1); moveActive(-1);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
if (!state.open) return;
e.preventDefault(); e.preventDefault();
close(); close();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (state.open && state.activeIdx >= 0) { // Without an active row: native submit (full results page).
var row = rows()[activeIdx];
if (row) {
e.preventDefault(); e.preventDefault();
activateCurrent(); pick(row);
} }
// else: native form submit behaviour (full results page)
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
if (!state.open || rowCount() === 0) return; if (rows().length === 0) return;
e.preventDefault(); e.preventDefault();
moveActive(e.shiftKey ? -1 : 1); moveActive(e.shiftKey ? -1 : 1);
} }
}); });
// Click outside the host closes the dropdown.
document.addEventListener('mousedown', function (e) {
if (!state.open) return;
if (host.contains(e.target)) return;
close();
});
return { return {
close: close, close: close,
destroy: function () { destroy: function () {
@@ -310,14 +144,7 @@
window.attachSuggestions = attachSuggestions; window.attachSuggestions = attachSuggestions;
// Auto-bind to the header search input. Header search submits the form
// for the "show all" action; we route to a navigate-on-pick handler.
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('header .search-input'); attachSuggestions(document.querySelector('header .search-input'), { showFooter: true });
if (!input) return;
attachSuggestions(input, {
showFooter: true,
onPick: function (r) { window.location.href = r.url; },
});
}); });
})(); })();
+17 -8
View File
@@ -52,19 +52,19 @@ function createSearchPage(name) {
}); });
} }
document.addEventListener('DOMContentLoaded', function () { // The "create page" link on results pages; delegated, since boosted
// navigation swaps the results in without a new DOMContentLoaded.
var createLink = document.querySelector('[data-create-page]'); document.addEventListener('click', function (e) {
if (createLink) { var createLink = e.target.closest && e.target.closest('[data-create-page]');
createLink.addEventListener('click', function (e) { if (!createLink) return;
e.preventDefault(); e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page')); createSearchPage(createLink.getAttribute('data-create-page'));
}); });
}
// Focus the search input on results pages so Tab steps directly into the // Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM // first match — the input sits immediately before the results in DOM order,
// order, so the natural tab sequence is input → first result → next, … // so the natural tab sequence is input → first result → next, …
document.addEventListener('DOMContentLoaded', function () {
var input = document.querySelector('.search-input'); var input = document.querySelector('.search-input');
if (input && input.value) { if (input && input.value) {
input.focus(); input.focus();
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
try { input.setSelectionRange(end, end); } catch (e) {} try { input.setSelectionRange(end, end); } catch (e) {}
} }
}); });
// The header (and its search box) survives boosted navigation: show the
// current page's query, as a full load would.
document.addEventListener('page:swapped', function () {
var input = document.querySelector('.search-input');
if (input && document.activeElement !== input) {
input.value = new URLSearchParams(location.search).get('q') || '';
}
});
-2
View File
@@ -1,5 +1,3 @@
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}} {{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}} {{define "content"}}
+8
View File
@@ -0,0 +1,8 @@
{{/* Search typeahead dropdown (/_search, htmx requests). Rows are links, so a
pick in the header is plain navigation; the editor's link dialog intercepts
the click and reads data-name. Empty output closes the dropdown. */}}
{{define "suggest"}}{{if .Show}}<table class="data-table"><tbody>
{{range .Results}}<tr class="suggest-row" data-name="{{.Name}}"><td><a class="suggest-link" href="{{.URL}}" tabindex="-1"><span class="suggest-name">{{.Highlighted}}</span><span class="suggest-path">/{{.Path}}</span></a></td></tr>
{{else}}<tr class="is-empty"><td>No matches</td></tr>
{{end}}{{if .AllURL}}<tr class="suggest-row suggest-footer"><td><a class="suggest-link" href="{{.AllURL}}" tabindex="-1">Show all {{.Total}} matches</a></td></tr>
{{end}}</tbody></table>{{end}}{{end}}
+41 -7
View File
@@ -241,6 +241,20 @@ footer {
.danger { color: var(--danger); } .danger { color: var(--danger); }
.danger:hover { color: var(--danger-hover); } .danger:hover { color: var(--danger-hover); }
.btn.is-active { color: var(--primary-hover); } .btn.is-active { color: var(--primary-hover); }
/* Segmented choice: radios styled as .btn toggles. The label is the button;
the input stays in the accessibility tree and keyboard order. */
.segmented input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.segmented .btn { position: relative; }
.segmented .btn:has(input:checked) { color: var(--primary-hover); }
.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); }
fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */
/* === Form controls === /* === Form controls ===
.input baseline is shared by search-input, modal inputs, and the editor .input baseline is shared by search-input, modal inputs, and the editor
@@ -411,6 +425,8 @@ main > h2 {
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; } .dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
.actions-special > .btn {text-align: left;} .actions-special > .btn {text-align: left;}
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
.page-actions { display: contents; }
/* Header actions carry an icon and a label: the mobile dropdown shows the /* Header actions carry an icon and a label: the mobile dropdown shows the
label, the inline desktop bar (see responsive block) the icon. */ label, the inline desktop bar (see responsive block) the icon. */
.btn-icon { display: none; } .btn-icon { display: none; }
@@ -433,11 +449,13 @@ main > h2 {
} }
.suggest-dropdown.is-open { display: block; } .suggest-dropdown.is-open { display: block; }
.suggest-row { cursor: pointer; } .suggest-row { cursor: pointer; }
.suggest-row > td { padding: 0.4rem 0.6rem; } .suggest-row > td { padding: 0; }
/* The link fills the row, so every part of it is the click target. */
.suggest-link { display: block; padding: 0.4rem 0.6rem; color: inherit; text-decoration: none; }
.suggest-name, .suggest-path { display: block; } .suggest-name, .suggest-path { display: block; }
.suggest-name { color: var(--text); } .suggest-name { color: var(--text); }
.suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; } .suggest-path { color: var(--text-muted); font-size: 0.8rem; margin-top: 0.1rem; }
.suggest-footer > td { color: var(--link); font-size: var(--font-sm); } .suggest-footer .suggest-link { color: var(--link); font-size: var(--font-sm); }
/* === Editor toolbar === /* === Editor toolbar ===
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
@@ -733,17 +751,33 @@ aside.sidebar:empty { display: none; }
padding: 0.25rem var(--space-1); padding: 0.25rem var(--space-1);
cursor: pointer; cursor: pointer;
} }
.tree-row:hover, .tree-row.is-selected { background: var(--bg-panel-hover); } .tree-row:hover { background: var(--bg-panel-hover); }
.tree-row.is-selected {
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row.is-active > .tree-name { color: var(--secondary); } .tree-row.is-active > .tree-name { color: var(--secondary); }
.tree-row.is-disabled { color: var(--text-muted); cursor: default; } .tree-row.is-disabled { color: var(--text-muted); cursor: default; }
.tree-row.is-disabled:hover { background: none; } .tree-row.is-disabled:hover { background: none; }
.tree-chevron { text-align: center; flex-shrink: 0; } .tree-chevron { text-align: center; flex-shrink: 0; }
.tree-chevron { width: 1.25rem; color: var(--secondary); } .tree-chevron { width: 1.25rem; color: var(--secondary); }
.tree-chevron.is-leaf { visibility: hidden; } .tree-chevron.is-leaf { visibility: hidden; }
/* Expandable folders are <details>; the chevron follows its open state. */
.tree-node > summary { list-style: none; }
.tree-node > summary::-webkit-details-marker { display: none; }
.tree-node > summary > .tree-chevron::before { content: "\25b8"; }
.tree-node[open] > summary > .tree-chevron::before { content: "\25be"; }
/* Picker rows select through a visually hidden radio in the name label. */
.tree-name input[type="radio"] {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
}
.tree-row:has(input:checked) {
background: var(--bg-panel-hover);
border-left: 3px solid var(--primary);
padding-left: calc(var(--space-2) - 3px);
}
.tree-row:has(input:focus-visible) { outline: 1px solid var(--secondary); }
label.tree-name { flex: 1; cursor: pointer; position: relative; }
.tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tree-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tree-children { .tree-children {
margin-left: var(--space-2); margin-left: var(--space-2);
+31 -247
View File
@@ -1,43 +1,29 @@
// openTreePicker shows a modal to pick a wiki path. The tree is server-rendered
// (?tree&pick=…, see tree.go and tree.html): each selectable row carries a
// radio named "pick", folders expand natively and lazy-load via htmx. This
// file only hosts the tree in a modal and reports the checked row.
//
// opts: title, confirmLabel, mode ('folder' | 'file' | 'any'), initialPath
// (expanded on open), preselect (checked on open), hideFiles, allowRoot
// (default true: offer "/" in folder modes), onSelect(path, kind).
(function () { (function () {
function joinPath(parent, name) {
if (parent === '/' || parent === '') return '/' + name;
return parent.replace(/\/+$/, '') + '/' + name;
}
function encodePath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/';
}
function fetchFolder(path) {
return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function openTreePicker(opts) { function openTreePicker(opts) {
opts = opts || {}; opts = opts || {};
var mode = opts.mode || 'folder'; var mode = opts.mode || 'folder';
var initialPath = opts.initialPath || '/';
var allowRoot = opts.allowRoot !== false;
var hideFiles = !!opts.hideFiles;
var preselect = opts.preselect || null;
var container = document.createElement('div'); var container = document.createElement('div');
var treeEl = document.createElement('div'); var treeEl = document.createElement('div');
treeEl.className = 'tree-picker panel'; treeEl.className = 'tree-picker panel';
treeEl.textContent = '…';
var selectedPathEl = document.createElement('div'); var selectedPathEl = document.createElement('div');
selectedPathEl.className = 'tree-selected-path muted'; selectedPathEl.className = 'tree-selected-path muted';
selectedPathEl.textContent = '\u00a0'; selectedPathEl.textContent = ' ';
container.appendChild(treeEl); container.appendChild(treeEl);
container.appendChild(selectedPathEl); container.appendChild(selectedPathEl);
var selected = null; // { path, kind, rowEl } function checked() {
return treeEl.querySelector('input[name="pick"]:checked');
}
var handle = openModal({ var handle = openModal({
title: opts.title || 'Pick', title: opts.title || 'Pick',
@@ -46,236 +32,34 @@
label: opts.confirmLabel || 'SELECT', label: opts.confirmLabel || 'SELECT',
initiallyDisabled: true, initiallyDisabled: true,
onConfirm: function () { onConfirm: function () {
if (!selected) return; var pick = checked();
if (!pick) return;
handle.close(); handle.close();
if (opts.onSelect) opts.onSelect(selected.path, selected.kind); if (opts.onSelect) opts.onSelect(pick.value, pick.dataset.kind);
} }
} }
}); });
function setSelected(path, kind, rowEl) { function sync() {
if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected'); var pick = checked();
selected = { path: path, kind: kind, rowEl: rowEl }; selectedPathEl.textContent = pick ? pick.value : ' ';
rowEl.classList.add('is-selected'); handle.setConfirmDisabled(!pick);
selectedPathEl.textContent = path;
handle.setConfirmDisabled(false);
} }
treeEl.addEventListener('change', sync);
function isSelectable(kind) { var url = '/?tree&pick=' + mode +
if (mode === 'any') return true; (opts.hideFiles ? '' : '&files=1') +
if (mode === 'folder') return kind === 'folder'; (opts.allowRoot !== false ? '&root=1' : '') +
if (mode === 'file') return kind === 'file'; '&expandTo=' + encodeURIComponent(opts.initialPath || '/') +
return false; (opts.preselect ? '&select=' + encodeURIComponent(opts.preselect) : '');
} htmx.ajax('GET', url, { target: treeEl, swap: 'innerHTML' }).then(function () {
sync();
// buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op var pick = checked();
// for files and idempotent for folders (resolves with the already- if (pick) {
// loaded children on repeat calls). try { pick.closest('.tree-row').scrollIntoView({ block: 'nearest' }); } catch (e) {}
function buildRow(parentPath, name, kind) {
var row = document.createElement('div');
row.className = 'tree-row';
if (!isSelectable(kind)) row.classList.add('is-disabled');
var chevron = document.createElement('span');
chevron.className = 'tree-chevron';
if (kind === 'folder') {
chevron.textContent = '\u25b8'; // ▸
} else {
chevron.classList.add('is-leaf');
}
row.appendChild(chevron);
var label = document.createElement('span');
label.className = 'tree-name';
label.textContent = name;
row.appendChild(label);
var fullPath = joinPath(parentPath, name);
var childrenEl = null;
var loadPromise = null;
var isOpen = false;
function expand() {
if (kind !== 'folder') return Promise.resolve(null);
if (isOpen) return loadPromise || Promise.resolve(childrenEl);
if (!childrenEl) {
childrenEl = document.createElement('div');
childrenEl.className = 'tree-children';
}
row.parentNode.insertBefore(childrenEl, row.nextSibling);
chevron.textContent = '\u25be'; // ▾
isOpen = true;
if (!loadPromise) {
loadPromise = loadInto(fullPath, childrenEl);
}
return loadPromise;
}
function collapse() {
if (kind !== 'folder' || !isOpen) return;
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl);
}
chevron.textContent = '\u25b8';
isOpen = false;
}
chevron.addEventListener('click', function (e) {
e.stopPropagation();
if (isOpen) collapse(); else expand();
});
row.addEventListener('click', function () {
if (isSelectable(kind)) {
setSelected(fullPath, kind, row);
} else if (kind === 'folder') {
if (isOpen) collapse(); else expand();
} }
}); });
if (kind === 'folder') {
row.addEventListener('dblclick', function (e) {
e.preventDefault();
if (isOpen) collapse(); else expand();
});
}
return {
rowEl: row,
name: name,
kind: kind,
childrenEl: function () { return childrenEl; },
expand: expand
};
}
// loadInto fetches folderPath and populates `target` with rows. Returns
// an array of the row objects on success, or [] on failure.
function loadInto(folderPath, target) {
target.textContent = '';
var loading = document.createElement('div');
loading.className = 'tree-row is-disabled';
loading.textContent = '\u2026';
target.appendChild(loading);
return fetchFolder(folderPath).then(function (resp) {
target.textContent = '';
var rows = [];
(resp.entries || []).forEach(function (e) {
if (hideFiles && e.kind !== 'folder') return;
var r = buildRow(resp.path, e.name, e.kind);
target.appendChild(r.rowEl);
rows.push(r);
});
if (rows.length === 0) {
var empty = document.createElement('div');
empty.className = 'tree-row is-disabled';
empty.textContent = '(empty)';
target.appendChild(empty);
}
return rows;
}).catch(function () {
target.textContent = '';
var err = document.createElement('div');
err.className = 'tree-row';
err.textContent = '(failed — tap to retry)';
err.addEventListener('click', function () {
loadInto(folderPath, target);
});
target.appendChild(err);
return [];
});
}
// Root selection row — visible when allowRoot and mode accepts folders.
if (allowRoot && isSelectable('folder')) {
var rootRow = document.createElement('div');
rootRow.className = 'tree-row';
var rootChev = document.createElement('span');
rootChev.className = 'tree-chevron is-leaf';
rootRow.appendChild(rootChev);
var rootLabel = document.createElement('span');
rootLabel.className = 'tree-name';
rootLabel.textContent = '/';
rootRow.appendChild(rootLabel);
rootRow.addEventListener('click', function () {
setSelected('/', 'folder', rootRow);
});
treeEl.appendChild(rootRow);
}
var rootChildren = document.createElement('div');
treeEl.appendChild(rootChildren);
// Expand the ancestor chain of initialPath so the user lands in
// context. For root, just load root children.
var segs = (initialPath || '/').split('/').filter(Boolean);
var preselectSegs = preselect
? preselect.split('/').filter(Boolean)
: null;
loadInto('/', rootChildren).then(function (rows) {
return expandChain(rows, segs, '/');
}).then(function () {
if (preselectSegs === null) return;
if (preselectSegs.length === 0) {
// Preselect root itself (if allowed).
if (allowRoot && isSelectable('folder')) {
var root = treeEl.querySelector('.tree-row');
if (root) setSelected('/', 'folder', root);
}
return;
}
selectByPath(preselectSegs);
});
// expandChain walks `segments`, looking up each by name in the current
// row list, expanding it, and recursing into its children.
function expandChain(rows, segments, basePath) {
if (segments.length === 0) return Promise.resolve();
var seg = segments[0];
var match = null;
for (var i = 0; i < rows.length; i++) {
if (rows[i].kind === 'folder' && rows[i].name === seg) {
match = rows[i];
break;
}
}
if (!match) return Promise.resolve();
return match.expand().then(function (childRows) {
return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg));
});
}
// selectByPath walks the visible tree rows to locate the row matching
// `segments` and marks it selected. Assumes its ancestors are already
// expanded (expandChain ran first).
function selectByPath(segments) {
var container = rootChildren;
var path = '/';
for (var i = 0; i < segments.length; i++) {
var seg = segments[i];
var kids = container.children;
var found = null;
for (var j = 0; j < kids.length; j++) {
var row = kids[j];
if (!row.classList || !row.classList.contains('tree-row')) continue;
var nm = row.querySelector('.tree-name');
if (nm && nm.textContent === seg) { found = row; break; }
}
if (!found) return;
path = joinPath(path, seg);
if (i === segments.length - 1) {
if (isSelectable('folder')) setSelected(path, 'folder', found);
try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {}
return;
}
var next = found.nextSibling;
if (!next || !next.classList || !next.classList.contains('tree-children')) return;
container = next;
}
}
return handle; return handle;
} }
-245
View File
@@ -1,245 +0,0 @@
;(function () {
// This script owns the .tree-scroll child of the aside, not the aside itself.
var container = document.querySelector("aside.tree-sidebar .tree-scroll")
if (!container) return
function joinPath(parent, name) {
if (parent === "/" || parent === "") return "/" + name
return parent.replace(/\/+$/, "") + "/" + name
}
function encodeSegments(p) {
return p.replace(/^\/+/, "").split("/").map(encodeURIComponent).join("/")
}
function folderHref(p) {
if (p === "/" || p === "") return "/"
return "/" + encodeSegments(p) + "/"
}
function fetchFolder(path, expandTo) {
var url = folderHref(path) + "?tree=1&foldersOnly=1"
if (expandTo) url += "&expandTo=" + encodeURIComponent(expandTo)
return fetch(url, { credentials: "same-origin" }).then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status)
return r.json()
})
}
// Current page as a canonical, DECODED wiki path ("/" for root, no trailing
// slash otherwise). pathname is percent-encoded; the tree's entry names (and
// thus fullPath) are decoded, so we must decode here too or paths containing
// spaces / non-ASCII never match a row and the chain never expands.
var activePath = (function () {
var p = window.location.pathname.replace(/\/+$/, "")
if (p === "") return "/"
try {
return decodeURIComponent(p)
} catch (e) {
return p
}
})()
var activeRow = null
var live = false // set once the fetched tree is on screen
function disabledRow(text) {
var row = document.createElement("div")
row.className = "tree-row is-disabled"
row.textContent = text
return row
}
// renderInto fills containerEl with rows for `entries` (children of
// parentPath). Folders carrying a `children` array (the pre-expanded
// ancestor chain from ?expandTo) are opened immediately and recurse.
function renderInto(containerEl, parentPath, entries) {
entries.forEach(function (entry) {
var built = buildRow(parentPath, entry)
containerEl.appendChild(built.row)
if (built.preExpand) built.open()
})
}
function buildRow(parentPath, entry) {
var fullPath = joinPath(parentPath, entry.name)
var row = document.createElement("div")
row.className = "tree-row"
var chevron = document.createElement("span")
chevron.className = "tree-chevron"
chevron.textContent = "▸"
row.appendChild(chevron)
var link = document.createElement("a")
link.className = "tree-name tree-folder"
link.textContent = entry.name
link.href = folderHref(fullPath)
row.appendChild(link)
if (fullPath === activePath) {
row.classList.add("is-active")
activeRow = row
}
var preChildren = entry.children ? entry.children : null
var childrenEl = null
var loaded = false
var isOpen = false
var isLeaf = !entry.hasChildren
if (isLeaf) chevron.classList.add("is-leaf")
function ensureChildrenEl() {
if (!childrenEl) {
childrenEl = document.createElement("div")
childrenEl.className = "tree-children"
}
return childrenEl
}
function markLeaf() {
isLeaf = true
isOpen = false
chevron.textContent = "▸"
chevron.classList.add("is-leaf")
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
}
function loadChildren() {
var el = ensureChildrenEl()
el.textContent = ""
el.appendChild(disabledRow("…"))
fetchFolder(fullPath)
.then(function (resp) {
el.textContent = ""
loaded = true
if (!resp.entries || resp.entries.length === 0) {
markLeaf()
return
}
renderInto(el, fullPath, resp.entries)
})
.catch(function () {
el.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function (e) {
e.stopPropagation()
loadChildren()
})
el.appendChild(err)
})
}
function open() {
if (isOpen || isLeaf) return
var el = ensureChildrenEl()
row.parentNode.insertBefore(el, row.nextSibling)
chevron.textContent = "▾" // ▾
isOpen = true
if (!loaded) {
if (preChildren) {
renderInto(el, fullPath, preChildren)
loaded = true
} else {
loadChildren()
}
}
}
function close() {
if (!isOpen) return
if (childrenEl && childrenEl.parentNode) {
childrenEl.parentNode.removeChild(childrenEl)
}
chevron.textContent = "▸"
isOpen = false
}
// Clicking the name link navigates (folder) or opens the file; clicking
// anywhere else on the row toggles expansion for folders.
row.addEventListener("click", function (e) {
if (e.target.closest("a.tree-name")) return
if (isOpen) close()
else open()
})
return { row: row, open: open, preExpand: !!preChildren }
}
function render() {
// If the layout pre-painted a snapshot of the previous page's tree, keep it
// on screen and build the live tree off-DOM, swapping it in once ready —
// no "…" placeholder, no scroll jump.
var listing = document.createElement("div")
var hasSnapshot = container.firstChild !== null
if (!hasSnapshot) {
container.appendChild(listing)
listing.appendChild(disabledRow("…"))
}
fetchFolder("/", activePath === "/" ? "" : activePath)
.then(function (resp) {
listing.textContent = ""
renderInto(listing, "/", resp.entries)
if (hasSnapshot) {
var top = container.scrollTop
container.replaceChildren(listing)
container.scrollTop = top
}
live = true
if (activeRow) {
try {
activeRow.scrollIntoView({ block: "nearest" })
} catch (e) {}
}
})
.catch(function () {
live = false
container.replaceChildren(listing)
listing.textContent = ""
var err = document.createElement("div")
err.className = "tree-row"
err.textContent = "(failed — tap to retry)"
err.addEventListener("click", function () {
container.textContent = ""
render()
})
listing.appendChild(err)
})
}
// Snapshot the tree (markup + scroll) on the way out so the next page's
// layout can paint it immediately. Skipped until the live tree has rendered,
// so a placeholder or error row never becomes the snapshot. The active
// highlight is left out; the next page marks its own row.
window.addEventListener("pagehide", function () {
if (!live) return
if (activeRow) activeRow.classList.remove("is-active")
var html = container.innerHTML
if (activeRow) activeRow.classList.add("is-active") // bfcache may restore this page
try {
sessionStorage.setItem("tree-snapshot", JSON.stringify({ html: html, top: container.scrollTop }))
} catch (e) {}
})
function setupMenuItem() {
var specials = document.querySelector(".actions-special")
if (!specials) return
var item = document.createElement("button")
item.type = "button"
item.className = "btn btn-block"
item.title = "Folders"
item.textContent = "FOLDERS"
item.addEventListener("click", function () {
if (typeof openOverlay === "function") openOverlay(container)
})
specials.appendChild(item)
}
render()
setupMenuItem()
})()
+18
View File
@@ -0,0 +1,18 @@
{{/* Folder tree rows (tree.go), shared by the sidebar and the tree picker.
Expandable folders are native <details>: open/close needs no script, and a
folder's children load via htmx the first time it opens, unless the server
pre-expanded them. A label inside <summary> selects its radio without
toggling the folder; the rest of the row toggles. */}}
{{define "rows"}}{{range .}}{{if .Expandable}}<details class="tree-node"{{if .Children}} open{{end}}>
<summary class="{{template "row-class" .}}"><span class="tree-chevron" aria-hidden="true"></span>{{template "label" .}}</summary>
<div class="tree-children"{{if not .Children}} hx-get="{{.ChildrenURL}}" hx-trigger="toggle once from:closest details"{{end}}>{{template "rows" .Children}}</div>
</details>
{{else}}<div class="{{template "row-class" .}}"><span class="tree-chevron is-leaf" aria-hidden="true"></span>{{template "label" .}}</div>
{{end}}{{end}}{{end}}
{{define "row-class"}}tree-row{{if .Active}} is-active{{end}}{{if and (not .Link) (not .Selectable)}} is-disabled{{end}}{{end}}
{{define "label"}}{{if .Link}}<a class="tree-name tree-{{.Kind}}" href="{{.URL}}">{{.Name}}</a>
{{- else if .Selectable}}<label class="tree-name"><input type="radio" name="pick" value="{{.Path}}" data-kind="{{.Kind}}"{{if .Checked}} checked{{end}} />{{.Name}}</label>
{{- else}}<span class="tree-name">{{.Name}}</span>{{end}}{{end}}
+1
View File
File diff suppressed because one or more lines are too long
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"html/template"
"log"
"net/http"
"path"
"strings"
)
var dialogTmpl = template.Must(template.New("dialogs").ParseFS(assets, "assets/page/dialogs.html"))
// isHX reports whether r was issued by htmx.
func isHX(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
// layoutTemplate names the template that renders a layout page for r: the
// whole document, or for boosted navigation and htmx history restores only
// the "partial" that hx.js swaps into the open page. The editor is never
// boosted.
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
return "partial"
}
return "layout"
}
// redirectAfter sends the client to target once a mutation succeeded. htmx
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
// Back skips the pre-mutation page); plain form posts get a 303.
func redirectAfter(w http.ResponseWriter, r *http.Request, target string) {
if isHX(r) {
w.Header().Set("HX-Redirect", target)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// pageURL returns the escaped, slash-terminated browser URL of the page at
// the wiki path p ("/" or "/a/b", trailing slash optional).
func pageURL(p string) string {
p = strings.Trim(p, "/")
if p == "" {
return "/"
}
return fileURL(p) + "/"
}
// renderDialog writes the named dialog from dialogs.html.
func renderDialog(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := dialogTmpl.ExecuteTemplate(w, name, data); err != nil {
log.Printf("dialog template error: %v", err)
}
}
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
page := normalizeMovePath(urlPath)
name := r.URL.Query().Get("dialog")
if page == "/" && (name == "delete" || name == "move") {
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
return
}
switch name {
case "view":
view, sortKey, order := readPageSettings(fsPath).viewSettings()
renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order})
case "delete":
renderDialog(w, "delete", struct{ Path string }{urlPath})
case "move":
// parent comes from the folder picker; default to the current one.
parent := r.URL.Query().Get("parent")
if parent == "" {
parent = path.Dir(page)
}
renderDialog(w, "move", struct{ Parent, Name string }{parent, path.Base(page)})
default:
http.NotFound(w, r)
}
}
+4
View File
@@ -0,0 +1,4 @@
node_modules/
.tmp/
test-results/
playwright-report/
+58
View File
@@ -0,0 +1,58 @@
{
"name": "datascape-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "datascape-e2e",
"devDependencies": {
"@playwright/test": "^1"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "datascape-e2e",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig, devices } from "@playwright/test";
const port = process.env.E2E_PORT || "8765";
export default defineConfig({
testDir: "tests",
// Tests share one server and wiki but each works in its own sandbox folder,
// so they can run in parallel.
workers: 4,
fullyParallel: true,
reporter: "list",
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
// The system Chrome; no browser download needed.
{ name: "chrome", use: { channel: "chrome" } },
// Playwright's Firefox build: npx playwright install firefox
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
webServer: {
command: "node server.mjs",
url: `http://127.0.0.1:${port}/`,
timeout: 120_000,
reuseExistingServer: false,
stdout: "pipe",
},
});
+27
View File
@@ -0,0 +1,27 @@
// Builds datascape, seeds a throwaway wiki and runs the server in the
// foreground. Playwright's webServer starts this and kills it after the run.
import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tmp = join(here, ".tmp");
const wiki = join(tmp, "wiki");
const bin = join(tmp, process.platform === "win32" ? "datascape.exe" : "datascape");
const port = process.env.E2E_PORT || "8765";
rmSync(tmp, { recursive: true, force: true });
mkdirSync(wiki, { recursive: true });
execFileSync("go", ["build", "-o", bin, "."], { cwd: join(here, ".."), stdio: "inherit" });
// Tests create their own pages (see sandbox in tests/pages.spec.js); the seed
// is only the home page.
writeFileSync(join(wiki, "index.md"), "# Home\n\nWelcome.\n");
const srv = spawn(bin, ["-dir", wiki, "-cache", join(tmp, "cache"), "-addr", "127.0.0.1:" + port, "-calendars", ""], {
stdio: "inherit",
});
srv.on("exit", (code) => process.exit(code ?? 0));
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => srv.kill());
+155
View File
@@ -0,0 +1,155 @@
// Boosted navigation: outside the editor, page links swap the next page into
// the open document instead of loading a new one. A marker on window survives
// a boosted navigation and disappears with a full page load.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
// Chrome aborts the cross-document view transition (style.css) when the
// next document can't take part, e.g. a plain-text file, and reports it
// as an unhandled rejection that no page script can catch.
if (err.message.includes("ViewTransition opt-in disabled")) return;
throw err;
});
});
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
function sandbox(pages) {
const root = "t" + randomWord(7);
for (const [rel, body] of Object.entries(pages)) {
const dir = join(wiki, root, rel);
mkdirSync(dir, { recursive: true });
if (body !== null) writeFileSync(join(dir, "index.md"), body);
}
return root;
}
const sidebar = (page) => page.locator("aside.tree-sidebar");
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
const mark = (page) => page.evaluate(() => (window.__marker = 1));
const marked = (page) => page.evaluate(() => window.__marker === 1);
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
const root = sandbox({
"": "# Parent\n",
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
});
await page.goto(`/${root}/`);
await mark(page);
await link(sidebar(page), "Child").click();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
await expect(page).toHaveTitle("Child");
expect(await marked(page)).toBe(true);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
// Page scripts ran for the new content: TOC and section edit links.
await expect(page.locator("nav.toc")).toContainText("Second");
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
// htmx attributes in the new content work: the task toggles.
await page.locator("main input.task-checkbox").check();
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
});
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
const root = sandbox({ "": long("Parent"), Child: long("Child") });
await page.goto(`/${root}/`);
await mark(page);
const center = page.locator(".center");
await center.evaluate((el) => (el.scrollTop = 600));
await link(sidebar(page), "Child").click();
await expect(page.locator("main h1")).toContainText("Child");
// A new page starts at the top.
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page).toHaveTitle("Parent");
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
await page.goForward();
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("Back to the first, fully loaded page works", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto(`/${root}/Child/`);
await mark(page);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
await expect(page.locator("main h1")).toContainText("Child");
expect(await marked(page)).toBe(true);
});
test("files and the editor load as full pages", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
writeFileSync(join(wiki, root, "note.txt"), "plain file");
await page.goto(`/${root}/`);
await mark(page);
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
await expect(page.locator("body")).toContainText("plain file");
expect(await marked(page)).toBe(false);
await page.goto(`/${root}/`);
await mark(page);
await page.locator("header").getByTitle("Edit page (E)").click();
await expect(page).toHaveURL(/\?edit/);
await expect(page.locator(".cm-content")).toBeVisible();
expect(await marked(page)).toBe(false);
});
test("header actions follow the page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n" });
await page.goto("/");
await mark(page);
const move = page.locator("header").getByTitle("Move page (M)");
await expect(move).toHaveCount(0);
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(move).toBeVisible();
await page.goBack();
await expect(page.locator("main h1")).toContainText("Home");
await expect(move).toHaveCount(0);
expect(await marked(page)).toBe(true);
});
test("search results and the search box follow boosted navigation", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
expect((await page.request.post("/_reindex")).status()).toBe(204);
await page.goto(`/${root}/`);
await mark(page);
const input = page.locator("header .search-input");
await input.fill(root);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
await expect(page).toHaveTitle(`Search: ${root}`);
await expect(page.locator("main .search-card").first()).toBeVisible();
await link(sidebar(page), root).click();
await expect(page.locator("main h1")).toContainText("Parent");
await expect(input).toHaveValue("");
expect(await marked(page)).toBe(true);
});
test("a mutation swaps in its result without a reload", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await mark(page);
await page.locator("header").getByTitle("Delete page").click();
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
await expect(page.locator(".modal")).toHaveCount(0);
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
expect(await marked(page)).toBe(true);
});
+392
View File
@@ -0,0 +1,392 @@
// End-to-end coverage of the page UI. Tests drive the app through visible
// labels, not implementation details, so they survive changes to the
// client-side mechanics.
//
// Every test builds the pages it needs under its own fresh top-level folder
// (sandbox), so tests don't depend on each other's leftovers and can run alone
// or in any order. Names are matched exactly: other tests' folders share the
// sidebar and the search index.
import { test, expect } from "@playwright/test";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
// Fail any test that logs a JS error.
test.beforeEach(({ page }) => {
page.on("pageerror", (err) => {
throw err;
});
});
// randomWord returns n random lowercase letters.
function randomWord(n) {
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
}
// sandbox creates a fresh top-level folder holding pages, keyed by path
// relative to it ("" is the folder's own page); a null body makes an empty
// folder. Returns the folder name.
function sandbox(pages) {
const root = "t" + randomWord(7);
for (const [rel, body] of Object.entries(pages)) {
const dir = join(wiki, root, rel);
mkdirSync(dir, { recursive: true });
if (body !== null) writeFileSync(join(dir, "index.md"), body);
}
return root;
}
const readPage = (root, rel = "") => readFileSync(join(wiki, root, rel, "index.md"), "utf8");
const exactly = (name) => new RegExp(`^${name}$`);
const sidebar = (page) => page.locator("aside.tree-sidebar");
const modal = (page) => page.locator(".modal");
const treeRow = (scope, name) => scope.locator(".tree-row", { hasText: exactly(name) });
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
// Page actions sit in a dropdown on narrow screens and inline (icon-only) on
// wide ones; address them by title and open the dropdown only when present.
async function pageAction(page, title) {
const toggle = page.getByRole("button", { name: "ACTIONS" });
if (await toggle.isVisible()) await toggle.click();
await page.locator("header").getByTitle(title).click();
}
// movePage runs the move flow from the current page: keep the preselected
// parent, then enter name.
async function movePage(page, name) {
await pageAction(page, "Move page (M)");
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(name);
await modal(page).getByRole("button", { name: "MOVE" }).click();
}
test("renders a page and navigates via the sidebar", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
await page.goto("/");
await expect(page.locator("main h1")).toContainText("Home");
await link(sidebar(page), root).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main h1")).toContainText("Parent");
// The sidebar expands the active folder's children.
await expect(link(sidebar(page), "Child")).toBeVisible();
});
test("task checkbox toggle persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await expect(box).not.toBeChecked();
await box.check();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [x] first task");
await page.reload();
await expect(page.locator("main input.task-checkbox").first()).toBeChecked();
});
test("unchecking a task persists", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [ ] first task\n- [x] second task\n" });
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").nth(1);
await expect(box).toBeChecked();
await box.uncheck();
await expect(box).toBeEnabled();
await expect.poll(() => readPage(root)).toContain("- [ ] second task");
});
test("a failed task save reverts the checkbox", async ({ page }) => {
const root = sandbox({ "": "# Tasks\n\n- [x] done task\n" });
page.on("dialog", (d) => d.dismiss());
await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" }));
await page.goto(`/${root}/`);
const box = page.locator("main input.task-checkbox").first();
await box.click();
await expect(box).toBeEnabled();
await expect(box).toBeChecked();
expect(readPage(root)).toContain("- [x] done task");
});
test("view settings switch the listing to thumbnails", async ({ page }) => {
const root = sandbox({ "": "# Listing\n", Child: "# Child\n" });
await page.goto(`/${root}/`);
await expect(page.locator("main table.data-table")).toBeVisible();
await page.getByTitle("View & sorting").click();
await modal(page).getByText("Thumbnail").click();
await modal(page).getByLabel("Sort by").selectOption("size");
await modal(page).getByText("Desc").click();
await modal(page).getByRole("button", { name: "SAVE" }).click();
await expect(page.locator("main .thumb-grid")).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
// Reopening shows the saved state.
await page.getByTitle("View & sorting").click();
await expect(modal(page).getByLabel("Thumbnail")).toBeChecked();
await expect(modal(page).getByLabel("Sort by")).toHaveValue("size");
await expect(modal(page).getByLabel("Desc")).toBeChecked();
});
test("editor saves a page", async ({ page }) => {
const root = sandbox({ "": "# Editable\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Edit page (E)");
await expect(page).toHaveURL(/\?edit/);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type("\nAdded by e2e.\n");
await page.getByRole("button", { name: "SAVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(page.locator("main")).toContainText("Added by e2e.");
expect(readPage(root)).toContain("Added by e2e.");
// Save replaces the editor's history entry: Back leaves the page entirely.
await page.goBack();
await expect(page).not.toHaveURL(/\?edit/);
});
test("move renames a page", async ({ page }) => {
const root = sandbox({ Beta: "# Beta\n" });
await page.goto(`/${root}/Beta/`);
await movePage(page, "Gamma");
await expect(page).toHaveURL(new RegExp(`/${root}/Gamma/$`));
await expect(page.locator("main h1")).toContainText("Beta");
});
test("move onto an empty folder offers a merge", async ({ page }) => {
const root = sandbox({ Alpha: "# Alpha\n", Empty: null });
await page.goto(`/${root}/Alpha/`);
await movePage(page, "Empty");
await expect(modal(page)).toContainText("Merge folders?");
await modal(page).getByRole("button", { name: "MERGE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Empty/$`));
await expect(page.locator("main h1")).toContainText("Alpha");
});
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", Taken: "# Taken\n" });
await page.goto(`/${root}/Mover/`);
// Dismiss the alert from the handler: an open alert blocks the click.
const messages = [];
page.on("dialog", (d) => {
messages.push(d.message());
d.dismiss();
});
await movePage(page, "Taken");
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
await expect(modal(page)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/${root}/Mover/$`));
});
test("delete removes a page and returns to the parent", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Notes: "# Notes\n" });
await page.goto(`/${root}/Notes/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toContainText(`Delete /${root}/Notes/`);
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await expect(link(sidebar(page), "Notes")).toHaveCount(0);
});
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/`);
await page.goto(`/${root}/Gone/`);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "DELETE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
await page.goBack();
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("a server dialog works from the first moment it is shown", async ({ page }) => {
// htmx wires up swapped-in forms only when it settles. Submit in the very
// next task after the dialog is inserted: if the form isn't wired yet, the
// browser submits it natively (a GET) and the page is not deleted.
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
await page.goto(`/${root}/Gone/`);
await page.evaluate(() => {
document.addEventListener("htmx:afterSwap", (e) => {
if (e.detail.target.id !== "modal-host") return;
const submit = e.detail.target.querySelector('button[type="submit"]');
setTimeout(() => submit.click(), 0);
}, { once: true });
});
await pageAction(page, "Delete page");
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
});
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
const root = sandbox({ "": "# Keep me\n" });
await page.goto(`/${root}/`);
await pageAction(page, "Delete page");
await expect(modal(page)).toBeVisible();
// CANCEL is focused, so Enter must not delete.
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(modal(page)).toHaveCount(0);
await pageAction(page, "Delete page");
await modal(page).getByRole("button", { name: "CANCEL" }).click();
await expect(modal(page)).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
expect(readPage(root)).toContain("Keep me");
});
test("sidebar tree is in the page itself, no fetch on load", async ({ page }) => {
const root = sandbox({ "": "# Here\n" });
const treeRequests = [];
page.on("request", (r) => {
if (r.url().includes("?tree")) treeRequests.push(r.url());
});
const res = await page.request.get(`/${root}/`);
expect(await res.text()).toContain(`class="tree-name tree-folder" href="/${root}/"`);
await page.goto(`/${root}/`);
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText(root);
expect(treeRequests).toEqual([]);
});
test("sidebar folders expand lazily and collapse", async ({ page }) => {
const root = sandbox({ "One/Deep": "# Deep\n", Two: "# Two\n" });
await page.goto("/");
const chevron = treeRow(sidebar(page), root).locator(".tree-chevron");
const glyph = () => chevron.evaluate((el) => getComputedStyle(el, "::before").content);
await expect(link(sidebar(page), "One")).toHaveCount(0);
expect(await glyph()).toBe('"▸"');
await chevron.click();
await expect(link(sidebar(page), "One")).toBeVisible();
expect(await glyph()).toBe('"▾"');
// One has a subfolder, Two has none: only One gets a chevron.
await expect(sidebar(page).locator("summary.tree-row", { hasText: exactly("One") })).toHaveCount(1);
await expect(treeRow(sidebar(page), "Two").locator(".tree-chevron")).toHaveClass(/is-leaf/);
await chevron.click();
await expect(link(sidebar(page), "One")).toBeHidden();
});
test("tree picker selects inside a lazily expanded folder", async ({ page }) => {
const root = sandbox({ Mover: "# Mover\n", "Tree/One/Deep": "# Deep\n" });
await page.goto(`/${root}/Mover/`);
await pageAction(page, "Move page (M)");
// The picker opens with the current parent expanded; Tree is still closed.
const picker = modal(page).locator(".tree-picker");
await treeRow(picker, "Tree").locator(".tree-chevron").click();
await treeRow(picker, "One").locator(".tree-chevron").click();
await picker.getByText("Deep", { exact: true }).click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/Tree/One/Deep`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/Tree/One/Deep/Mover/$`));
});
test("tree picker can pick the root", async ({ page }) => {
const root = sandbox({ Two: "# Two\n" });
await page.goto(`/${root}/Two/`);
await pageAction(page, "Move page (M)");
// The current parent is preselected and its chain expanded.
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}`);
await modal(page).locator(".tree-picker").getByText("/", { exact: true }).click();
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByPlaceholder("Page name").fill(`${root}-top`);
await modal(page).getByRole("button", { name: "MOVE" }).click();
await expect(page).toHaveURL(new RegExp(`127\\.0\\.0\\.1:\\d+/${root}-top/$`));
});
test("editor embed picker selects a file", async ({ page }) => {
const root = sandbox({ "": "# Embeds\n" });
writeFileSync(join(wiki, root, "photo.jpg"), "x");
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+G");
const picker = modal(page).locator(".tree-picker");
// Folder rows aren't selectable in file mode; the file is.
await picker.getByText("photo.jpg").click();
await expect(modal(page).locator(".tree-selected-path")).toHaveText(`/${root}/photo.jpg`);
await modal(page).getByRole("button", { name: "NEXT" }).click();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText("photo.jpg");
});
test("mobile: FOLDERS opens the tree in an overlay", async ({ page }) => {
const root = sandbox({ "": "# Mobile\n" });
await page.setViewportSize({ width: 400, height: 800 });
await page.goto(`/${root}/`);
await expect(sidebar(page)).toBeHidden();
await page.getByRole("button", { name: "ACTIONS" }).click();
await page.getByTitle("Folders").click();
const overlay = page.locator(".overlay");
await expect(link(overlay, root)).toBeVisible();
await link(overlay, root).click();
await expect(overlay).toHaveCount(0);
});
// seedSearch creates pages whose names share a random word (so no other
// test's pages match it) and reindexes. Returns the sandbox, the word and its
// capitalized form used in the names.
async function seedSearch(page) {
const word = randomWord(8);
const Word = word[0].toUpperCase() + word.slice(1);
const pages = { "": "# Search\n" };
for (const n of ["Zebra Crossing", "Zebra Finch", "1", "2", "3", "4", "5", "6"]) {
pages[`${Word} ${n}`] = `# ${Word} ${n}\n`;
}
const root = sandbox(pages);
expect((await page.request.post("/_reindex")).status()).toBe(204);
return { root, word, Word };
}
const suggest = (page) => page.locator("header .suggest-dropdown");
test("header typeahead: highlight, keyboard pick", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(`${word} finch`);
const first = suggest(page).locator(".suggest-row").first();
await expect(first.locator(".suggest-name")).toHaveText(`${Word} Zebra Finch`);
await expect(first.locator("strong")).toHaveText([Word, "Finch"]);
await input.press("ArrowDown");
await expect(suggest(page).locator(".suggest-row.is-active")).toContainText(`${Word} Zebra Finch`);
await input.press("Enter");
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Finch/$`));
});
test("header typeahead: click, footer, escape, short query", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto("/");
const input = page.locator("header .search-input");
await input.fill(word[0]);
await page.waitForTimeout(300);
await expect(suggest(page)).toBeHidden();
await input.fill(word);
const { total } = await (await page.request.get(`/_search?q=${word}`)).json();
expect(total).toBeGreaterThan(5);
await expect(suggest(page).locator(".suggest-footer")).toHaveText(`Show all ${total} matches`);
await expect(suggest(page).locator(".suggest-row:not(.suggest-footer)")).toHaveCount(5);
await input.press("Escape");
await expect(suggest(page)).toBeHidden();
await input.fill(`${word} crossing`);
await suggest(page).locator(".suggest-name", { hasText: `${Word} Zebra Crossing` }).click();
await expect(page).toHaveURL(new RegExp(`/${root}/${Word}%20Zebra%20Crossing/$`));
await page.goto("/");
await page.locator("header .search-input").fill(word);
await suggest(page).locator(".suggest-footer").click();
await expect(page).toHaveURL(new RegExp(`/\\?q=${word}$`));
});
test("editor link dialog fills the query from a suggestion", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Alt+Shift+P");
const query = modal(page).getByPlaceholder("Search query");
await query.fill(`${word} crossing`);
await modal(page).locator(".suggest-row", { hasText: `${Word} Zebra Crossing` }).click();
await expect(query).toHaveValue(`${Word} Zebra Crossing`);
await expect(modal(page).getByPlaceholder("Display text (optional)")).toBeFocused();
await modal(page).getByRole("button", { name: "INSERT" }).click();
await expect(page.locator(".cm-content")).toContainText(`[[${Word} Zebra Crossing]]`);
});
test("editor [[ autocomplete lists matching pages", async ({ page }) => {
const { root, word, Word } = await seedSearch(page);
await page.goto(`/${root}/?edit`);
await page.locator(".cm-content").click();
await page.keyboard.press("Control+End");
await page.keyboard.type(`\n[[${word} finc`);
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText(`${Word} Zebra Finch`);
});
+20 -4
View File
@@ -301,6 +301,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
h.handlePost(w, r, urlPath, fsPath) h.handlePost(w, r, urlPath, fsPath)
return return
} }
if r.URL.Query().Has("dialog") {
h.serveDialog(w, r, urlPath, fsPath)
return
}
for _, ph := range pageTypeHandlers { for _, ph := range pageTypeHandlers {
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok { if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
@@ -402,12 +406,13 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
Content: rendered, Content: rendered,
Entries: entries, Entries: entries,
View: view, View: view,
Sort: sortKey,
Order: order,
SpecialContent: specialContent, SpecialContent: specialContent,
SidebarWidget: sidebarWidget, SidebarWidget: sidebarWidget,
SuppressTOC: suppressTOC, SuppressTOC: suppressTOC,
} }
if !editMode {
data.Tree = sidebarTree(h.root, urlPath)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
t := pageTmpl t := pageTmpl
@@ -415,7 +420,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
t = editTmpl t = editTmpl
} }
data.renderTimer = renderTimer{requestStart(r)} data.renderTimer = renderTimer{requestStart(r)}
if err := t.ExecuteTemplate(w, "layout", data); err != nil { if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
} }
} }
@@ -427,7 +432,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge")) // The destination comes either whole (?move=/dst, or the merge
// dialog's move field) or as the move dialog's parent + name.
// Body fields take precedence over the bare ?move query key.
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
dst := r.FormValue("move")
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
}
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
+9 -7
View File
@@ -14,8 +14,7 @@ import (
// The one exception is a merge: when the source carries a page (index.md) and // The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty // the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge // container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the // is true; otherwise the client is asked to confirm it.
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) { func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
@@ -51,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
return return
} }
if !merge { if !merge {
// Ask the client to confirm the merge. The header lets it tell // Ask the user to confirm: htmx clients get the merge dialog as
// this apart from an ordinary conflict. // the next step; others a conflict they can retry with &merge=1.
w.Header().Set("X-Merge-Available", "1") if isHX(r) {
renderDialog(w, "merge", struct{ Dest string }{newPath})
return
}
http.Error(w, "destination already exists — merge folders?", http.StatusConflict) http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
return return
} }
@@ -94,7 +96,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} }
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther) redirectAfter(w, r, pageURL(newPath))
} }
// canMergeMove reports whether moving onto an existing destination should // canMergeMove reports whether moving onto an existing destination should
@@ -169,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
return return
} }
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/")) folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther) redirectAfter(w, r, pageURL(parentURL(urlPath)))
} }
// normalizeMovePath returns the absolute path with any trailing slash removed, // normalizeMovePath returns the absolute path with any trailing slash removed,
+1 -1
View File
@@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
http.Redirect(w, r, urlPath, http.StatusSeeOther) redirectAfter(w, r, pageURL(urlPath))
} }
// writePageSettings performs a read-modify-write of <dir>/.page-settings, // writePageSettings performs a read-modify-write of <dir>/.page-settings,
+1 -2
View File
@@ -58,11 +58,10 @@ type pageData struct {
Content template.HTML Content template.HTML
Entries []entry Entries []entry
View string // listing view style: "list" or "thumbnail" View string // listing view style: "list" or "thumbnail"
Sort string // listing sort key: "name" / "modified" / "size"
Order string // listing sort order: "asc" / "desc"
SpecialContent template.HTML SpecialContent template.HTML
SidebarWidget template.HTML SidebarWidget template.HTML
SuppressTOC bool SuppressTOC bool
Tree template.HTML // sidebar folder tree; empty in edit mode
renderTimer renderTimer
} }
+96 -10
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"html/template"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
@@ -47,6 +48,7 @@ type searchPageData struct {
PageTotal int PageTotal int
FileTotal int FileTotal int
IndexBuiltAt time.Time IndexBuiltAt time.Time
Tree template.HTML // sidebar folder tree
renderTimer renderTimer
} }
@@ -131,10 +133,11 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
PageTotal: len(pages), PageTotal: len(pages),
FileTotal: len(files), FileTotal: len(files),
IndexBuiltAt: builtAt, IndexBuiltAt: builtAt,
Tree: sidebarTree(h.root, "/"),
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
data.renderTimer = renderTimer{requestStart(r)} data.renderTimer = renderTimer{requestStart(r)}
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil { if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
log.Printf("search template error: %v", err) log.Printf("search template error: %v", err)
} }
} }
@@ -290,20 +293,23 @@ func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []s
return score return score
} }
// handleSearchSuggest serves the JSON typeahead for the header dropdown and
// the editor's link picker. Caps results at 5; reports total so the UI can
// surface a "show all" footer when more matches exist. Empty/whitespace query
// is a no-op (200 with empty results), not a 400 — every keystroke fires this.
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
type suggestResult struct { type suggestResult struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
URL string `json:"url"` URL string `json:"url"`
} }
// handleSearchSuggest serves the typeahead: rendered rows for the dropdown
// (header search, editor link dialog; htmx requests) and JSON for the
// editor's [[ autocomplete. Caps results at 5; reports total so the dropdown
// can offer a "show all" footer (?footer) when more matches exist. An
// empty/whitespace query is a no-op (200, no results), not a 400 — every
// keystroke fires this.
func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
if !h.checkAuth(w, r) {
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
type suggestResp struct { type suggestResp struct {
Query string `json:"query"` Query string `json:"query"`
Results []suggestResult `json:"results"` Results []suggestResult `json:"results"`
@@ -325,12 +331,92 @@ func (h *handler) handleSearchSuggest(w http.ResponseWriter, r *http.Request) {
}) })
} }
} }
// htmx (the typeahead dropdown) gets rendered rows; other callers (the
// editor's [[ autocomplete) keep the JSON.
w.Header().Set("Vary", "HX-Request")
if isHX(r) {
renderSuggest(w, resp.Query, resp.Results, resp.Total, r.URL.Query().Has("footer"))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("search suggest encode error: %v", err) log.Printf("search suggest encode error: %v", err)
} }
} }
var suggestTmpl = template.Must(template.ParseFS(assets, "assets/search/suggest.html"))
// suggestMinLen is the shortest query (in characters) the typeahead answers;
// shorter ones render nothing, which closes the dropdown.
const suggestMinLen = 2
type suggestRow struct {
Name, Path, URL string
Highlighted template.HTML
}
// renderSuggest writes the typeahead dropdown for query. footer adds a
// "Show all" row linking to the full results page when some were cut off.
func renderSuggest(w http.ResponseWriter, query string, results []suggestResult, total int, footer bool) {
data := struct {
Show bool
Results []suggestRow
Total int
AllURL string
}{Show: utf8.RuneCountInString(query) >= suggestMinLen, Total: total}
tokens := suggestTokens(query)
for _, res := range results {
data.Results = append(data.Results, suggestRow{res.Name, res.Path, res.URL, highlightMatches(res.Name, tokens)})
}
if footer && total > len(results) {
data.AllURL = "/?q=" + url.QueryEscape(query)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := suggestTmpl.ExecuteTemplate(w, "suggest", data); err != nil {
log.Printf("suggest template error: %v", err)
}
}
// suggestTokens lowercases query and splits it on anything that is not a
// letter or digit.
func suggestTokens(query string) []string {
return strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
})
}
// highlightMatches bolds the first occurrence of each token in name
// (case-insensitive), merging overlapping spans, and escapes the rest.
func highlightMatches(name string, tokens []string) template.HTML {
lower := strings.ToLower(name)
if len(lower) != len(name) {
// Lowercasing changed byte lengths, so offsets would not line up.
return template.HTML(template.HTMLEscapeString(name))
}
type span struct{ from, to int }
var spans []span
for _, t := range tokens {
if i := strings.Index(lower, t); i >= 0 {
spans = append(spans, span{i, i + len(t)})
}
}
sort.Slice(spans, func(i, j int) bool { return spans[i].from < spans[j].from })
var b strings.Builder
cursor := 0
for i := 0; i < len(spans); i++ {
sp := spans[i]
for i+1 < len(spans) && spans[i+1].from <= sp.to {
i++
sp.to = max(sp.to, spans[i].to)
}
b.WriteString(template.HTMLEscapeString(name[cursor:sp.from]))
b.WriteString("<strong>" + template.HTMLEscapeString(name[sp.from:sp.to]) + "</strong>")
cursor = sp.to
}
b.WriteString(template.HTMLEscapeString(name[cursor:]))
return template.HTML(b.String())
}
// handleReindex rebuilds the folder index synchronously and returns 204. // handleReindex rebuilds the folder index synchronously and returns 204.
// The frontend reloads the page on success. Serialized via buildMu so a // The frontend reloads the page on success. Serialized via buildMu so a
// double-click waits rather than running two walks in parallel. // double-click waits rather than running two walks in parallel.
+21
View File
@@ -0,0 +1,21 @@
package main
import "testing"
func TestHighlightMatches(t *testing.T) {
cases := []struct{ name, query, want string }{
{"Project Alpha", "alpha", "Project <strong>Alpha</strong>"},
// Overlapping and adjacent spans merge.
{"Project Alpha", "pro ject", "<strong>Project</strong> Alpha"},
{"Project Alpha", "roj proj", "<strong>Proj</strong>ect Alpha"},
{"a<b>&c", "b", "a&lt;<strong>b</strong>&gt;&amp;c"},
{"Notes", "zzz", "Notes"},
// Lowercasing changes the byte length: no highlight, still escaped.
{"İstanbul <x>", "stan", "İstanbul &lt;x&gt;"},
}
for _, c := range cases {
if got := string(highlightMatches(c.name, suggestTokens(c.query))); got != c.want {
t.Errorf("highlightMatches(%q, %q) = %q, want %q", c.name, c.query, got, c.want)
}
}
}
+7 -6
View File
@@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type
// then a `[ ]` / `[x]` / `[X]` checkbox marker. // then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`) var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered // rewriteTaskCheckboxes makes the task checkboxes in rendered HTML toggle
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching // their source line. The disabled attribute is removed and each checkbox
// its position among task list items in source order; the disabled attribute // posts itself to ?toggle=N (htmx), N being its position among task list
// is removed so the user can toggle them. // items in source order. An unchecked box sends no value, i.e. checked=false;
// a failed save flips the box back.
func rewriteTaskCheckboxes(in []byte) []byte { func rewriteTaskCheckboxes(in []byte) []byte {
idx := 0 idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte { return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked")) checked := bytes.Contains(match, []byte("checked"))
var out bytes.Buffer var out bytes.Buffer
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`) out.WriteString(`<input type="checkbox" class="task-checkbox" name="checked" value="true" hx-post="?toggle=`)
out.WriteString(strconv.Itoa(idx)) out.WriteString(strconv.Itoa(idx))
out.WriteByte('"') out.WriteString(`" hx-swap="none" hx-disabled-elt="this" hx-on::response-error="this.checked = !this.checked" hx-on::send-error="this.checked = !this.checked"`)
if checked { if checked {
out.WriteString(` checked=""`) out.WriteString(` checked=""`)
} }
+134 -54
View File
@@ -1,34 +1,71 @@
package main package main
import ( import (
"encoding/json" "bytes"
"html/template"
"log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
) )
var treeTmpl = template.Must(template.New("tree").ParseFS(assets, "assets/tree.html"))
// treeOpts selects what a folder tree renders. The same markup serves the
// sidebar (links) and the tree picker (radio inputs named "pick").
type treeOpts struct {
Files bool // list files, not just folders
Pick string // "" renders links; "folder", "file" or "any" renders a picker for that kind
Active string // wiki path of the current page, highlighted (sidebar)
Select string // wiki path of the initially checked row (picker)
}
// query returns the ?tree query that lazy-loads a folder's children with the
// same options.
func (o treeOpts) query() string {
q := "?tree"
if o.Files {
q += "&files=1"
}
if o.Pick != "" {
q += "&pick=" + o.Pick
}
return q
}
type treeEntry struct { type treeEntry struct {
Name string `json:"name"` Name string
Kind string `json:"kind"` Kind string // "folder" or "file"
// HasChildren reports whether a folder has any visible entries (under the // HasChildren reports whether a folder has any visible entries (under the
// same foldersOnly filter), so the client can hide the expand chevron on // same files filter), so empty folders render without a chevron.
// empty folders without a round-trip. Always false for files. HasChildren bool
HasChildren bool `json:"hasChildren"`
// Children is populated only along the expandTo chain (see handleTree);
// omitted otherwise so the flat picker listing keeps its original shape.
Children []treeEntry `json:"children,omitempty"`
} }
type treeResponse struct { // treeRow is one rendered row. Folders with children are expandable: either
Path string `json:"path"` // pre-expanded (Children set, along the requested path) or lazy-loaded from
Entries []treeEntry `json:"entries"` // ChildrenURL when first opened.
type treeRow struct {
Name, Path, Kind string
URL string // page URL (sidebar links)
Link bool // render the name as a link
Selectable bool // render a picker radio
Active, Checked bool
Expandable bool
Children []treeRow
ChildrenURL string
} }
// handleTree responds with a JSON listing of the immediate children of the // handleTree responds with the rendered rows of the folder at fsPath:
// folder at fsPath. Hidden entries and `index.md` are filtered. Files are not //
// descended — the client lazy-loads children on expand. // ?tree sidebar rows (folders only, links)
// &files=1 include files
// &pick=folder|file|any picker rows selecting that kind
// &expandTo=/a/b pre-expand the chain down to /a/b
// &select=/a/b check that row
// &root=1 lead with a row for "/" itself (picker)
func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
@@ -44,30 +81,98 @@ func (h *handler) handleTree(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
foldersOnly := r.URL.Query().Has("foldersOnly") q := r.URL.Query()
o := treeOpts{Files: q.Has("files"), Select: q.Get("select")}
switch p := q.Get("pick"); p {
case "", "folder", "file", "any":
o.Pick = p
default:
http.Error(w, "bad pick mode", http.StatusBadRequest)
return
}
entries, err := listTreeEntries(fsPath, foldersOnly) dir := normalizeMovePath(urlPath)
var expand []string
if segs, base := treePathSegments(q.Get("expandTo")), treePathSegments(dir); len(segs) >= len(base) &&
strings.Join(segs[:len(base)], "/") == strings.Join(base, "/") {
expand = segs[len(base):]
}
rows, err := buildTreeRows(fsPath, dir, expand, o)
if err != nil { if err != nil {
http.Error(w, "read failed", http.StatusInternalServerError) http.Error(w, "read failed", http.StatusInternalServerError)
return return
} }
if dir == "/" && q.Has("root") && o.Pick != "" {
if expandTo := r.URL.Query().Get("expandTo"); expandTo != "" { root := newTreeRow(treeEntry{Name: "/", Kind: "folder"}, "/", o)
expandTreeChain(fsPath, entries, treePathSegments(expandTo), foldersOnly) rows = append([]treeRow{root}, rows...)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := treeTmpl.ExecuteTemplate(w, "rows", rows); err != nil {
log.Printf("tree template error: %v", err)
}
} }
resp := treeResponse{Path: canonicalTreePath(urlPath), Entries: entries} // sidebarTree renders the navigation tree for the page at urlPath: the root
w.Header().Set("Content-Type", "application/json; charset=utf-8") // listing with the chain down to the page expanded and the page highlighted.
_ = json.NewEncoder(w).Encode(resp) // The tree is part of the page itself, so the rail never paints empty.
func sidebarTree(root, urlPath string) template.HTML {
page := normalizeMovePath(urlPath)
rows, err := buildTreeRows(root, "/", treePathSegments(page), treeOpts{Active: page})
if err != nil {
return ""
}
var buf bytes.Buffer
if err := treeTmpl.ExecuteTemplate(&buf, "rows", rows); err != nil {
log.Printf("tree template error: %v", err)
return ""
}
return template.HTML(buf.String())
} }
// canonicalTreePath returns the URL path in the form used by the picker: // buildTreeRows lists the folder fsPath (wiki path dir) and expands the
// "/" for root, otherwise stripped of any trailing slash. // folder named by expand[0], recursing down the chain. The walk stops at the
func canonicalTreePath(urlPath string) string { // deepest segment that still exists. Segments only ever match real directory
if urlPath == "" || urlPath == "/" { // names from listTreeEntries, so this cannot traverse outside the tree.
return "/" func buildTreeRows(fsPath, dir string, expand []string, o treeOpts) ([]treeRow, error) {
entries, err := listTreeEntries(fsPath, !o.Files)
if err != nil {
return nil, err
} }
return "/" + strings.Trim(urlPath, "/") rows := make([]treeRow, len(entries))
for i, e := range entries {
p := path.Join(dir, e.Name)
rows[i] = newTreeRow(e, p, o)
if len(expand) > 0 && e.Kind == "folder" && e.Name == expand[0] && e.HasChildren {
kids, err := buildTreeRows(filepath.Join(fsPath, e.Name), p, expand[1:], o)
if err == nil {
rows[i].Children = kids
}
}
}
return rows, nil
}
func newTreeRow(e treeEntry, p string, o treeOpts) treeRow {
row := treeRow{
Name: e.Name,
Path: p,
Kind: e.Kind,
URL: pageURL(p),
Link: o.Pick == "",
Active: o.Pick == "" && p == o.Active,
Expandable: e.HasChildren,
}
if e.Kind == "file" {
row.URL = fileURL(strings.TrimPrefix(p, "/"))
}
if o.Pick != "" {
row.Selectable = o.Pick == "any" || o.Pick == e.Kind
row.Checked = row.Selectable && p == o.Select
}
if row.Expandable {
row.ChildrenURL = row.URL + o.query()
}
return row
} }
// listTreeEntries returns the immediate children of fsPath, filtering hidden // listTreeEntries returns the immediate children of fsPath, filtering hidden
@@ -139,28 +244,3 @@ func treePathSegments(p string) []string {
} }
return segs return segs
} }
// expandTreeChain walks segs, matching each against a folder in entries by
// name, loading that folder's children in place, and recursing. The walk stops
// at the deepest matching segment, so a stale or deleted path simply expands as
// far as it still exists. Segments only ever match real directory names from
// listTreeEntries (no "." or ".." entries), so this cannot traverse outside the
// listed tree.
func expandTreeChain(fsPath string, entries []treeEntry, segs []string, foldersOnly bool) {
if len(segs) == 0 {
return
}
for i := range entries {
if entries[i].Kind != "folder" || entries[i].Name != segs[0] {
continue
}
childFs := filepath.Join(fsPath, segs[0])
kids, err := listTreeEntries(childFs, foldersOnly)
if err != nil {
return
}
entries[i].Children = kids
expandTreeChain(childFs, entries[i].Children, segs[1:], foldersOnly)
return
}
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestHandleTreeExpandTo(t *testing.T) {
root := t.TempDir()
for _, d := range []string{"Projects/A/Deep", "Other/B"} {
if err := os.MkdirAll(filepath.Join(root, filepath.FromSlash(d)), 0755); err != nil {
t.Fatal(err)
}
}
h := &handler{root: root}
get := func(urlPath, query string) string {
r := httptest.NewRequest("GET", urlPath+"?"+query, nil)
w := httptest.NewRecorder()
h.handleTree(w, r, urlPath, filepath.Join(root, filepath.FromSlash(strings.Trim(urlPath, "/"))))
return w.Body.String()
}
// A chain below the listed folder is pre-expanded.
if body := get("/Projects/", "tree&expandTo=/Projects/A"); !strings.Contains(body, `href="/Projects/A/Deep/"`) {
t.Errorf("expandTo below the folder did not expand:\n%s", body)
}
// Lazy children keep the picker options.
if body := get("/", "tree&pick=folder&root=1"); !strings.Contains(body, `hx-get="/Projects/?tree&amp;pick=folder"`) ||
!strings.Contains(body, `value="/"`) {
t.Errorf("picker rows missing lazy URL or root row:\n%s", body)
}
w := httptest.NewRecorder()
h.handleTree(w, httptest.NewRequest("GET", "/?tree&pick=bogus", nil), "/", root)
if w.Code != 400 {
t.Errorf("bad pick mode: got %d, want 400", w.Code)
}
}