From 87539e1ee709f2fd060c81c84bb0ec12f40b691c Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 12 Jul 2026 15:38:48 -0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20UX=20polish=20=E2=80=94=20stable?= =?UTF-8?q?=20rerenders,=20RESTful=20view=20routes,=20unified=20history=20?= =?UTF-8?q?rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - No-flicker polling: tree and heat refreshes compare fetched data and skip DOM rebuilds when nothing changed; in-place listing re-renders preserve scroll. - RESTful routes: //insights and //history[/] replace query-param views; the reserved first segments survive refresh, deep links, and back/forward. Per-route scroll restore (manual scrollRestoration) returns the reader to where they were. - History rows unified: every entry (recent changes and full history) is a clickable row that opens the file — view/download version links removed; old versions remain reachable via the blob API until the time-travel viewer lands. Folder tree loses the per-dir history icon. - Hot path: danger rows use the amber accent, ⚠ trails the filename, and a tooltip explains the flag (reads/30d, days unchanged). - Frontend responses send Cache-Control: no-cache so upgrades never leave browsers on a stale embedded UI. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- internal/webapp/server.go | 3 + internal/webapp/static/app.js | 187 +++++++++++++++++++++++-------- internal/webapp/static/style.css | 15 +-- 3 files changed, 147 insertions(+), 58 deletions(-) diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 59c10a5..543d791 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -383,6 +383,9 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc { files := http.FileServerFS(static) index, _ := fs.ReadFile(static, "index.html") return func(w http.ResponseWriter, r *http.Request) { + // Embedded assets carry no modtime, so without this browsers cache + // them heuristically and users see a stale frontend after upgrades. + w.Header().Set("Cache-Control", "no-cache") upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") // Reserved prefixes that fell through to the catch-all are genuine // 404s — don't mask a mistyped API/auth/share URL with the app shell. diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 6b3de33..e89d256 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -64,6 +64,9 @@ async function postJSON(url, body) { /* ---- boot ---- */ async function boot() { + // The app restores per-route scroll itself (scrollMemo); the browser's own + // restoration would fight it on back/forward. + if ("scrollRestoration" in history) history.scrollRestoration = "manual"; try { serverConfig = await getJSON("/api/config"); } catch { /* non-fatal */ } @@ -81,13 +84,13 @@ async function boot() { await loadOrgs(); await loadProjects(); updateAdminBar(); - const { project, path } = parseRoute(); + const route = parseRoute(); // After accepting an invite, open a project in the org you just joined // rather than whatever happened to be first. - const proj = projects.find((x) => x.id === project) + const proj = projects.find((x) => x.id === route.project) || (joinedOrgId && projects.find((x) => x.org === joinedOrgId)) || projects[0]; - if (proj) selectProject(proj, path); + if (proj) selectProject(proj, route); else { $("vault-name").textContent = serverConfig.brand || serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); } setInterval(loadProjects, 30000); // pick up new projects } else { @@ -146,12 +149,14 @@ async function loadProjects() { nav.appendChild(ul); } -function selectProject(p, path) { +function selectProject(p, route) { currentProject = p; expanded = new Set(); // fresh collapse state for the new project's tree treeFirstLoad = true; heatMap = null; // the old project's heat means nothing here heatAt = 0; + lastTreeJSON = ""; + lastHeatJSON = ""; apiBase = "/api/p/" + p.id + "/"; $("vault-name").textContent = p.name; document.title = p.name + " — BearDrive"; @@ -166,13 +171,10 @@ function selectProject(p, path) { initUpload(); initHistory(); updateShareButton(); - refreshTree().then(() => { - if (path) openPath(path); - // Landing view: admins/owners get the Insights dashboard instead of an - // empty "select a file" pane; members keep the placeholder. - else if (canSeeInsights()) showInsights(); - }); - if (!path) pushURL("/" + p.id); + // Landing view (applyRoute with an empty route): admins/owners get the + // Insights dashboard instead of an empty "select a file" pane. + refreshTree().then(() => applyRoute(route || {})); + if (!route || (!route.path && !route.view)) pushURL("/" + p.id); } /* ---- hub: organizations ---- */ @@ -653,12 +655,28 @@ function refreshAll() { function encodePath(p) { return p.split("/").map(encodeURIComponent).join("/"); } function decodePath(p) { return p.split("/").map(decodeURIComponent).join("/"); } +/* Special views are RESTful routes under the project — the first segment + after the project id is reserved when it names a view: + //insights the Insights dashboard + //history[/] change feed (whole project / subtree / file) + (Root-level files literally named "insights" or "history" lose the URL + shortcut and remain reachable through the tree.) */ +const VIEW_ROUTES = new Set(["insights", "history"]); + function parseRoute() { const raw = location.pathname.replace(/^\/+/, ""); if (serverConfig.mode !== "hub") return { path: raw ? decodePath(raw) : "" }; const slash = raw.indexOf("/"); if (slash === -1) return { project: raw, path: "" }; - return { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) }; + const r = { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) }; + const seg = r.path.indexOf("/"); + const head = seg === -1 ? r.path : r.path.slice(0, seg); + if (VIEW_ROUTES.has(head)) { + r.view = head; + r.viewTarget = seg === -1 ? "" : r.path.slice(seg + 1).replace(/\/+$/, ""); + r.path = ""; + } + return r; } /* The URL for a file within the current context. */ @@ -670,21 +688,73 @@ function urlForPath(path) { return "/" + enc; } +/* The URL for a special view of the current project. */ +function urlForView(view, q) { + let s = (serverConfig.mode === "hub" && currentProject ? "/" + currentProject.id : "") + "/" + view; + if (view === "history" && q) { + const target = "path" in q ? q.path : (q.prefix || "").replace(/\/+$/, ""); + if (target) s += "/" + encodePath(target); + } + return s; +} + +/* Dispatch a parsed route to the right view (after the tree has loaded). */ +function applyRoute(r) { + if (r.view === "insights") return showInsights(false); + if (r.view === "history") { + // The route stores one target; the tree says whether it is a folder + // (subtree feed) or a file (version list). + const t = r.viewTarget || ""; + const hq = !t ? { prefix: "" } : dirIndex.has(t) ? { prefix: t + "/" } : { path: t }; + return showHistory(hq, false); + } + if (r.path) return openPath(r.path); + if (canSeeInsights()) showInsights("replace"); +} + +/* Per-route scroll positions of the content pane, so back/forward returns + to where the user was (e.g. deep in a folder's change feed). */ +const scrollMemo = new Map(); +let routeKey = location.pathname + location.search; +let pendingScroll = null; // set by popstate, consumed by the next render +function rememberScroll() { scrollMemo.set(routeKey, $("content").scrollTop); } +function applyPendingScroll(final) { + if (pendingScroll == null) return; + $("content").scrollTo({ top: pendingScroll, behavior: "instant" }); + if (final) pendingScroll = null; +} + /* Push a route without reloading, skipping a no-op that would just stack a duplicate history entry (e.g. when boot opens the file already in the URL). */ function pushURL(url) { - if (location.pathname === url) return; + if (location.pathname + location.search === url) return; + rememberScroll(); history.pushState(null, "", url); + routeKey = url; +} +/* Replace the current entry (landing views that shouldn't stack). */ +function replaceURL(url) { + history.replaceState(null, "", url); + routeKey = url; } function syncURL(path) { pushURL(urlForPath(path)); } /* ---- tree ---- */ +let lastTreeJSON = ""; // skip re-rendering when nothing changed (no flicker) async function refreshTree() { if (serverConfig.mode === "hub" && !currentProject) return; let root; try { root = await getJSON(apiBase + "tree"); } catch { return; } // keep the last good tree + // The 15s poll usually returns the same tree — rebuilding the DOM anyway + // makes the sidebar (and any open folder listing) visibly flicker. + const json = JSON.stringify(root); + if (json === lastTreeJSON) { + refreshHeat(); + return; + } + lastTreeJSON = json; flatFiles = []; dirIndex = new Map(); const kids = root.children || []; @@ -709,6 +779,7 @@ async function refreshTree() { /* ---- read heat ---- 30-day read counts per path from the heat API (hub only). Counts only — the server never says who read what. Fetched lazily alongside the tree. */ +let lastHeatJSON = ""; async function refreshHeat(force) { if (!(serverConfig.mode === "hub" && currentProject)) return; if (!(serverConfig.reads && serverConfig.reads.enabled)) return; @@ -718,6 +789,9 @@ async function refreshHeat(force) { try { out = await getJSON(apiBase + "heat?days=30"); } catch { return; } // keep the last good heat + const json = JSON.stringify(out.entries || {}); + if (json === lastHeatJSON) return; // unchanged: no re-render, no flicker + lastHeatJSON = json; heatMap = out.entries || {}; if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) { renderFolderListing(currentPath); @@ -789,14 +863,6 @@ function renderNode(n) { li.appendChild(row); if (n.dir) { dirIndex.set(n.path, n); - if (serverConfig.mode === "hub") { - const hist = document.createElement("span"); - hist.className = "dir-history"; - hist.innerHTML = svgIcon("hist"); - hist.title = "Folder history"; - hist.onclick = (e) => { e.stopPropagation(); showHistory({ prefix: n.path + "/" }); }; - row.appendChild(hist); - } li.appendChild(renderChildren(n.children || [])); const open = expanded.has(n.path); if (!open) li.classList.add("collapsed"); @@ -907,6 +973,7 @@ function openFolder(p) { $("download").hidden = true; $("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject); updateShareButton(); + if (pendingScroll == null) $("content").scrollTop = 0; // fresh navigation starts at the top renderFolderListing(p); } @@ -914,6 +981,11 @@ function renderFolderListing(p) { const node = dirIndex.get(p); if (!node) return; const content = $("content"); + // Scroll intent: a back/forward restore wins; an in-place refresh (tree or + // heat poll re-render) keeps the reader where they are; openFolder resets + // to the top before calling us. + const keepTop = pendingScroll != null ? pendingScroll : content.scrollTop; + pendingScroll = null; content.className = "view"; content.innerHTML = ""; const wrap = el(content, "div", "dirlist"); @@ -933,7 +1005,8 @@ function renderFolderListing(p) { el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder"); if (!kids.length) { el(wrap, "div", "dl-empty", "Nothing in this folder yet."); - renderFolderHistory(wrap, p); + if (keepTop) content.scrollTo({ top: keepTop, behavior: "instant" }); + renderFolderHistory(wrap, p, keepTop); return; } const list = el(wrap, "div", "dl-items"); @@ -963,13 +1036,14 @@ function renderFolderListing(p) { row.onclick = () => openPath(c.path); row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; } - renderFolderHistory(wrap, p); + if (keepTop) content.scrollTo({ top: keepTop, behavior: "instant" }); + renderFolderHistory(wrap, p, keepTop); } /* The folder's change feed, straight from the journals: files added, edited, and deleted anywhere under it, newest first. Hub-only — a plain-folder viewer has no journals to read. */ -function renderFolderHistory(wrap, p) { +function renderFolderHistory(wrap, p, restoreTop) { if (!(serverConfig.mode === "hub" && currentProject)) return; const sec = el(wrap, "div", "dl-history"); getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => { @@ -980,11 +1054,16 @@ function renderFolderHistory(wrap, p) { for (const e of entries) list.appendChild(historyEntryRow(e)); const more = el(sec, "button", "ai-btn dl-more", "Full history"); more.onclick = () => showHistory({ prefix: p + "/" }); + // The feed adds height after the listing rendered; a restored scroll + // position (back/forward) may only fit now. + if (restoreTop) $("content").scrollTo({ top: restoreTop, behavior: "instant" }); }).catch(() => sec.remove()); } /* ---- file pane ---- */ async function openFile(p) { + const restoreTop = pendingScroll != null ? pendingScroll : 0; + pendingScroll = null; currentPath = p; syncURL(p); markActive(); @@ -1029,6 +1108,7 @@ async function openFile(p) { content.innerHTML = `
`; content.querySelector(".empty").textContent = "Could not load file: " + err.message; } + content.scrollTo({ top: restoreTop, behavior: "instant" }); } function showMeta(doc) { @@ -1159,8 +1239,13 @@ function canSeeInsights() { let insightsDevices = null; // /heat?by=device breakdown, fetched per Insights open -async function showInsights() { +async function showInsights(push = true) { if (!canSeeInsights()) return; + // A real route (?v=insights): refresh, deep links, and back/forward work. + if (push === "replace") replaceURL(urlForView("insights")); + else if (push) pushURL(urlForView("insights")); + const restoreTop = pendingScroll != null ? pendingScroll : 0; + pendingScroll = null; await refreshHeat(true); insightsDevices = null; try { @@ -1175,6 +1260,7 @@ async function showInsights() { const content = $("content"); content.className = "view"; renderInsights(content, "all"); + content.scrollTo({ top: restoreTop, behavior: "instant" }); } /* SVG element helper for the insights charts. */ @@ -1345,8 +1431,10 @@ function renderHotPath(wrap, pts, lens) { const row = el(list, "div", "in-hp-row"); row.tabIndex = 0; row.setAttribute("role", "button"); - row.title = p.path + (p.danger ? " — hot + stale" : ""); - el(row, "span", "in-hp-name" + (p.danger ? " danger" : ""), (p.danger ? "⚠ " : "") + p.path); + row.title = p.danger + ? `${p.reads} read${p.reads === 1 ? "" : "s"}/30d · unchanged ${Math.round(p.days)}d — review this file` + : p.path; + el(row, "span", "in-hp-name" + (p.danger ? " danger" : ""), p.path + (p.danger ? " ⚠" : "")); const barw = el(row, "span", "in-hp-bar"); // Split of the lens reads: pure lenses are single-color by definition. const aFrac = lens === "agent" ? 1 : lens === "human" ? 0 : (p.total ? p.agent / p.total : 0); @@ -1474,7 +1562,11 @@ function currentProjectOrNull() { return serverConfig.mode === "hub" ? currentProject : null; } -async function showHistory(q) { +async function showHistory(q, push = true) { + // A real route (?v=history&path=|prefix=): refresh and back/forward work. + if (push) pushURL(urlForView("history", q)); + const restoreTop = pendingScroll != null ? pendingScroll : 0; + pendingScroll = null; const content = $("content"); const qs = "path" in q ? "path=" + encodeURIComponent(q.path) @@ -1500,6 +1592,7 @@ async function showHistory(q) { } for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e)); content.appendChild(wrap); + content.scrollTo({ top: restoreTop, behavior: "instant" }); } /* One change as a row: what happened (added / edited / deleted), to which @@ -1515,7 +1608,7 @@ function historyEntryRow(e) { const when = new Date(e.time).toLocaleString(); row.innerHTML = `
` + - `
`; + `
`; row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot"); row.querySelector(".hpath").textContent = e.path; row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind; @@ -1523,17 +1616,6 @@ function historyEntryRow(e) { row.querySelector(".hwho").textContent = who; row.querySelector(".hdev").textContent = dev; row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : ""; - if (kind !== "delete" && e.blob) { - const view = document.createElement("a"); - view.textContent = "view"; - view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path); - view.target = "_blank"; - const dl = document.createElement("a"); - dl.textContent = "download"; - dl.href = view.href + "&download=1"; - dl.setAttribute("download", e.path.split("/").pop()); - row.querySelector(".hact").append(view, " ", dl); - } if (e.note) { const note = document.createElement("div"); note.className = "hnote"; @@ -1554,6 +1636,7 @@ function historyEntryRow(e) { note.setAttribute("role", "button"); note.title = "Show full note"; note.onclick = (ev) => { + ev.stopPropagation(); // expanding a note is not a navigation if (ev.target.tagName === "A") return; const open = note.classList.toggle("open"); note.title = open ? "Collapse note" : "Show full note"; @@ -1562,8 +1645,17 @@ function historyEntryRow(e) { note.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); note.click(); } }; row.appendChild(note); } + // The whole row opens the file it names (deleted files have nothing to + // open). Old versions stay retrievable via the blob API; a proper version + // viewer is the planned time-travel phase. const p = e.path; - row.querySelector(".hpath").onclick = () => showHistory({ path: p }); + if (kind !== "delete") { + row.classList.add("clickable"); + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.onclick = (ev) => { if (ev.target.tagName !== "A") openFile(p); }; + row.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); openFile(p); } }; + } return row; } @@ -1913,12 +2005,15 @@ $("sb-backdrop").addEventListener("click", closeSidebarOnMobile); /* Back/forward: re-resolve the route from the URL. selectProject/openFile dedup against the current URL, so replaying it here never stacks history. */ window.addEventListener("popstate", () => { - const { project, path } = parseRoute(); - if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) { - const proj = projects.find((x) => x.id === project); - if (proj) { selectProject(proj, path || null); return; } + rememberScroll(); // routeKey still names the route we are leaving + routeKey = location.pathname + location.search; + pendingScroll = scrollMemo.has(routeKey) ? scrollMemo.get(routeKey) : null; + const r = parseRoute(); + if (serverConfig.mode === "hub" && r.project && (!currentProject || currentProject.id !== r.project)) { + const proj = projects.find((x) => x.id === r.project); + if (proj) { selectProject(proj, r); return; } } - if (path && path !== currentPath) openPath(path); + applyRoute(r); }); boot(); diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 7ccaf05..1fa24c5 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -137,14 +137,6 @@ button, input, a.btn { font-family: inherit; } #tree .file .chev { visibility: hidden; } #tree li.collapsed > ul { display: none; } #tree li.collapsed > .row .chev { transform: rotate(-90deg); } -.dir-history { - margin-left: auto; flex: none; visibility: hidden; - width: 24px; height: 24px; border-radius: 5px; - display: inline-flex; align-items: center; justify-content: center; color: var(--text-ghost); -} -.dir-history .ico { width: 14px; height: 14px; } -#tree li > .row:hover .dir-history { visibility: visible; } -.dir-history:hover { color: var(--text); background: var(--hover); } /* org bar */ #orgbar { display: flex; align-items: center; gap: 9px; padding: 9px 12px 9px 14px; border-top: 1px solid var(--border); } @@ -279,6 +271,8 @@ button, input, a.btn { font-family: inherit; } .dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; } .dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; } .dl-hlist .hentry:last-child { border-bottom: none; } +.hentry.clickable { cursor: pointer; } +.hentry.clickable:hover { background: var(--hover); } .dl-more { margin-top: 10px; } /* ---- insights (read×write matrix) ---- */ @@ -312,7 +306,7 @@ button, input, a.btn { font-family: inherit; } .in-hp-row:last-child { border-bottom: none; } .in-hp-row:hover { background: var(--hover); } .in-hp-name { flex: 0 0 300px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12.5px; color: var(--text); } -.in-hp-name.danger { color: #e07070; } +.in-hp-name.danger { color: var(--accent); } .in-hp-bar { flex: 1; display: flex; height: 10px; border-radius: 3px; overflow: hidden; } .in-hp-agent { background: var(--accent); } .in-hp-human { background: #5b8def; } @@ -344,9 +338,6 @@ button, input, a.btn { font-family: inherit; } .hmeta { display: flex; gap: 14px; margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-dim); } .hdev, .hsize { color: var(--text-faint); } .hsize { font-variant-numeric: tabular-nums; } -.hact { margin-left: auto; } -.hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; } -.hact a:hover { text-decoration: underline; } .hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } .hnote:hover { color: var(--text); } .hnote.open { white-space: normal; overflow-wrap: anywhere; }