Files
beardrive/internal/webapp/static/app.js
T
Snow LeeandClaude Fable 5 46f48e4c82 feat(web): project home page — connect-an-agent guide + embedded Insights
Clicking a project now lands on an index page (/<project-id>) instead of
redirecting to Insights: a tabbed guide for mounting the project locally
and wiring it to a coding agent, with the Insights dashboard embedded
below for admins/org-owners.

- Claude Code & Cowork share one tab guiding through the plugin
  (/plugin marketplace add + /beardrive:install with this hub's URL and
  the project id pre-filled); Hermes and Codex get the four-step CLI flow
  (install, login, init, hooks install) with real copyable commands.
- /<project-id>/insights stays a dedicated deep-linkable route; the
  sidebar project name is now a home link.
- Copy buttons, localStorage-persisted tab choice, scroll/back-forward
  behavior consistent with the other views.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-12 16:53:47 -07:00

2157 lines
89 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* beardrive web viewer: file tree + obsidian-like markdown pane. No dependencies. */
"use strict";
const $ = (id) => document.getElementById(id);
let flatFiles = []; // [{path, name}] for wikilink resolution
let dirIndex = new Map(); // dir path → tree node, for folder listings
let currentPath = null;
let expanded = new Set(); // dir paths that are open (folders start closed)
let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project
const MD_EXT = /\.(md|markdown)$/i;
const IMG_EXT = /\.(png|jpe?g|gif|svg|webp|ico|bmp|avif)$/i;
const TEXT_EXT = /\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh|rb|rs|c|h|cpp|java|kt|swift|sql|html|css|xml|ini|conf|env|mod|sum|jsonl)$/i;
/* The client is storage-blind: where files live and how uploads reach
storage is the server's business, learned from /api/config. A hub server
hosts many projects; volume-scoped calls go under api/p/<project-id>/. */
let serverConfig = { mode: "volume", upload: { enabled: false } };
let projects = [];
let currentProject = null; // hub mode: the selected project
let apiBase = "/api/"; // volume-scoped endpoint prefix
let orgs = []; // hub mode: the orgs this account belongs to
let joinedOrgId = null; // org just joined via an invite this page-load
let heatMap = null; // hub: path → 30-day read counts, from the heat API
let heatAt = 0; // when heatMap was last fetched (ms)
const fileURL = (p) => apiBase + "file?path=" + encodeURIComponent(p);
/* inline SVG icon, pulled from the <symbol> sprite in index.html */
function svgIcon(name) { return `<svg class="ico" aria-hidden="true"><use href="#i-${name}"/></svg>`; }
/* Deterministic accent for a project's letter-mark, so each project keeps a
stable color across reloads without any server state. */
const PROJ_COLORS = ["#5b8def", "#f5a623", "#4cc38a", "#e0679b", "#8b7bf0", "#3ec8c8", "#e6934a"];
function projColor(s) {
let h = 0;
for (const c of s) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PROJ_COLORS[h % PROJ_COLORS.length];
}
async function getJSON(url) {
const r = await fetch(url);
if (r.status === 401) { // auth required: sign in, then come back here
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("signing in…");
}
if (!r.ok) throw new Error(await r.text());
return r.json();
}
async function postJSON(url, body) {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
});
if (r.status === 401) {
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("signing in…");
}
if (!r.ok) throw new Error(await r.text());
return r.json();
}
/* ---- 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 */ }
document.title = serverConfig.brand || serverConfig.volume || "BearDrive";
// If auth is on and we're not signed in, go straight to the login page
// rather than firing authed API calls that 401 (noisy console, and the
// redirect happens anyway). /api/config reports `me` only when signed in.
if (serverConfig.auth && serverConfig.auth.enabled && !serverConfig.me) {
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
return;
}
if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false;
if (serverConfig.mode === "hub") {
await acceptInviteFromURL();
await loadOrgs();
await loadProjects();
updateAdminBar();
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 === route.project)
|| (joinedOrgId && projects.find((x) => x.org === joinedOrgId))
|| projects[0];
if (proj) selectProject(proj, route);
else { $("vault-name").textContent = serverConfig.brand || serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
setInterval(loadProjects, 30000); // pick up new projects
} else {
$("vault-name").textContent = serverConfig.volume || "BearDrive";
initUpload();
await refreshTree();
const { path } = parseRoute();
if (path) openPath(path);
}
setInterval(refreshTree, 15000); // pick up synced changes
}
/* ---- hub: projects ---- */
async function loadProjects() {
let out;
try {
out = await getJSON("/api/projects");
} catch { return; }
projects = out.projects || [];
const nav = $("projects");
nav.hidden = false;
nav.innerHTML = "";
const head = document.createElement("div");
head.className = "nav-head";
head.innerHTML = `<span>Projects</span>`;
const add = document.createElement("button");
add.className = "nav-add";
add.title = "New project";
add.textContent = "+";
add.onclick = async () => {
const name = await modalPrompt("New project", "Project name", "", "Create");
if (name) createProject(name);
};
head.appendChild(add);
nav.appendChild(head);
const ul = document.createElement("ul");
for (const p of projects) {
const li = document.createElement("li");
const row = document.createElement("div");
row.className = "row" + (currentProject && currentProject.id === p.id ? " active" : "");
const mark = document.createElement("span");
mark.className = "proj-mark";
mark.style.background = projColor(p.name);
mark.textContent = (p.name.trim()[0] || "?");
const lab = document.createElement("span");
lab.className = "label";
lab.textContent = p.name;
row.append(mark, lab);
row.title = p.name;
row.tabIndex = 0;
row.setAttribute("role", "button");
row.onclick = () => selectProject(p, null);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
ul.appendChild(li).appendChild(row);
}
nav.appendChild(ul);
}
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;
// The project name doubles as a "home" link back to the index page.
$("vault-name").classList.add("vault-link");
$("vault-name").onclick = () => { showProjectHome(); closeSidebarOnMobile(); };
document.title = p.name + " — BearDrive";
currentPath = null;
$("crumb").textContent = "";
$("meta").textContent = "";
$("download").hidden = true;
$("content").className = "view";
$("content").innerHTML = `<div class="empty">Loading…</div>`;
loadProjects(); // refresh active highlight
updateOrgBar();
initUpload();
initHistory();
updateShareButton();
// Landing view (applyRoute with an empty route): the project home page —
// the connect-an-agent guide, with Insights below for admins/owners.
refreshTree().then(() => applyRoute(route || {}));
if (!route || (!route.path && !route.view)) pushURL("/" + p.id);
}
/* ---- hub: organizations ---- */
/* Opening "/join/<token>" joins the invite's org. If the visitor isn't
signed in yet, postJSON's 401 handler sends them to /auth/login with the
/join path intact in `next`, so after signing in the server re-serves the
app there and the join completes — the token is never lost. */
async function acceptInviteFromURL() {
const m = location.pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
if (!m) return;
try {
const out = await postJSON("/api/invites/" + m[1]); // may redirect to login (401)
history.replaceState(null, "", "/");
joinedOrgId = out.org && out.org.id;
toast("Welcome — you joined the “" + out.org.name + "” team. Opening its projects…");
} catch (e) {
if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot
history.replaceState(null, "", "/");
toast("Could not accept the invite: " + e.message, true);
}
}
/* Onboarding: a signed-in account with no projects shouldn't hit a blank
sidebar. Explain that access comes from an invite, let them paste one,
and — since any member can — offer to start a new project. */
function showEmptyState() {
$("orgbar").hidden = true;
$("content").className = "view";
const auth = serverConfig.auth && serverConfig.auth.enabled;
$("content").innerHTML = `
<div class="onboard">
<h1>Welcome to BearDrive</h1>
<p>You're signed in, but you're not part of any project yet.</p>
${auth ? `
<div class="ob-card">
<h3>Have an invite link?</h3>
<p>A teammate can send you a join link. Paste it here:</p>
<div class="ob-row">
<input id="ob-invite" type="text" placeholder="https://…/join/…" autocomplete="off">
<button id="ob-join" class="pbtn">Join</button>
</div>
</div>` : ``}
<div class="ob-card">
<h3>Or start a new project</h3>
<p>Create a shared space for your team's files.</p>
<div class="ob-row">
<input id="ob-name" type="text" placeholder="Project name, e.g. wiki" autocomplete="off">
<button id="ob-create" class="pbtn">Create</button>
</div>
</div>
</div>`;
const join = $("ob-join");
if (join) join.onclick = () => {
const v = $("ob-invite").value.trim();
const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
if (!m) { toast("That doesn't look like an invite link.", true); return; }
location.href = "/join/" + m[1];
};
$("ob-create").onclick = () => createProject($("ob-name").value.trim());
}
async function createProject(name) {
if (!name) { toast("Give the project a name.", true); return; }
try {
const out = await postJSON("/api/projects", { name });
await loadOrgs();
await loadProjects();
selectProject(out.project, null);
toast("Created “" + out.project.name + "”.");
} catch (e) {
toast("Could not create the project: " + e.message, true);
}
}
async function loadOrgs() {
try {
orgs = (await getJSON("/api/orgs")).orgs || [];
} catch { orgs = []; }
}
function currentOrg() {
if (!currentProject || !currentProject.org) return null;
return orgs.find((o) => o.id === currentProject.org) || null;
}
/* The sidebar footer names the project's org; clicking it lists members,
and owners get an Invite button that mints a join link. */
function updateOrgBar() {
const bar = $("orgbar"), org = currentOrg();
// The top-of-sidebar gear is the always-visible admin entry point: any
// account that owns an org (or is a hub admin) gets it, whatever project
// is open.
const owned = orgs.find((o) => o.role === "owner");
const gear = $("settings-btn");
if (gear) {
const target = (org && org.role === "owner") ? org : owned;
gear.hidden = !target;
gear.onclick = () => showOrgAdmin(target);
}
if (!org) { bar.hidden = true; return; }
bar.hidden = false;
const nm = $("org-name");
nm.textContent = org.name;
nm.title = "Manage organization";
nm.onclick = () => showOrgAdmin(org);
nm.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); nm.click(); } };
const btn = $("invite-btn");
btn.hidden = org.role !== "owner";
btn.textContent = "Manage";
btn.onclick = () => showOrgAdmin(org);
}
/* The org admin panel: members (owners can change roles / remove), rename,
invite links (create / revoke), and an org-wide audit of public shares. */
async function showOrgAdmin(org) {
currentPath = null;
markActive();
closeSidebarOnMobile();
$("crumb").textContent = org.name;
$("meta").textContent = "";
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true;
const owner = org.role === "owner";
const box = $("content");
box.className = "view";
box.innerHTML = `<div class="admin"><h1 id="org-title"></h1></div>`;
box.querySelector("#org-title").textContent = org.name + (owner ? "" : " · member");
const panel = box.querySelector(".admin");
if (owner) {
const rn = el(panel, "div", "admin-row");
rn.innerHTML = `<input id="org-rename" type="text" value=""><button class="pbtn" id="org-rename-btn">Rename org</button>`;
rn.querySelector("#org-rename").value = org.name;
rn.querySelector("#org-rename-btn").onclick = async () => {
const name = rn.querySelector("#org-rename").value.trim();
try { await api("PATCH", "/api/orgs/" + org.id, { name }); toast("Renamed."); await loadOrgs(); refreshAll(); }
catch (e) { toast(e.message, true); }
};
}
// Members
el(panel, "h3", null, "Members");
const mlist = el(panel, "div", "admin-list");
const myEmail = (serverConfig.me && serverConfig.me.email) || "";
for (const m of org.members) {
const row = el(mlist, "div", "admin-item");
const isSelf = myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
el(row, "span", "ai-main", m.email + (isSelf ? " (you)" : ""));
if (owner && !isSelf) {
const sel = document.createElement("select");
for (const r of ["owner", "member"]) {
const o = document.createElement("option"); o.value = r; o.textContent = r;
if (m.role === r) o.selected = true; sel.appendChild(o);
}
sel.onchange = async () => {
try { await api("PATCH", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); }
};
row.appendChild(sel);
const rm = el(row, "button", "ai-del", "Remove");
rm.onclick = async () => {
if (!(await modalConfirm("Remove member", "Remove " + m.email + " from " + org.name + "?", "Remove", true))) return;
try { await api("DELETE", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
} else {
el(row, "span", "ai-tag", m.role);
}
}
if (!owner) return;
// Projects in this org (rename / delete)
el(panel, "h3", null, "Projects");
const plist = el(panel, "div", "admin-list");
const orgProjects = projects.filter((p) => p.org === org.id);
if (!orgProjects.length) el(plist, "div", "admin-empty", "No projects yet.");
for (const p of orgProjects) {
const row = el(plist, "div", "admin-item");
el(row, "span", "ai-main", p.name);
const rn = el(row, "button", "ai-btn", "Rename");
rn.onclick = async () => {
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
if (!name || name === p.name) return;
try { await api("PATCH", "/api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
const del = el(row, "button", "ai-del", "Delete");
del.onclick = async () => {
if (!(await modalConfirm("Delete project", "Delete “" + p.name + "”? Its files stay in storage, but it's removed from the hub.", "Delete", true))) return;
try {
await api("DELETE", "/api/projects/" + p.id);
toast("Deleted “" + p.name + "”.");
if (currentProject && currentProject.id === p.id) currentProject = null;
await loadProjects();
const next = currentOrg();
if (next) showOrgAdmin(next); else showEmptyState();
} catch (e) { toast(e.message, true); }
};
}
// Invites
const ih = el(panel, "div", "admin-h");
el(ih, "h3", null, "Invite links");
const mk = el(ih, "button", "pbtn", "New invite");
mk.onclick = async () => {
try {
const out = await postJSON("/api/orgs/" + org.id + "/invites");
const ok = await copyText(out.url);
toast(ok ? "Invite link copied to clipboard." : "Invite created — copy it from the list below.");
showOrgAdmin(currentOrg());
} catch (e) { toast(e.message, true); }
};
const ilist = el(panel, "div", "admin-list");
try {
const invs = (await getJSON("/api/orgs/" + org.id + "/invites")).invites || [];
if (!invs.length) el(ilist, "div", "admin-empty", "No active invite links.");
for (const inv of invs) {
const row = el(ilist, "div", "admin-item");
const main = el(row, "span", "ai-main mono", inv.url);
main.style.cursor = "pointer";
main.title = "Copy";
main.onclick = () => copyText(inv.url).then((ok) => toast(ok ? "Copied." : "Select and copy the link."));
const meta = (inv.creator ? "by " + inv.creator + " · " : "") +
(inv.uses ? inv.uses + " joined · " : "unused · ") +
"expires " + new Date(inv.expires).toLocaleDateString();
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
if (!(await modalConfirm("Revoke invite", "Revoke this invite link? Anyone still holding it won't be able to join.", "Revoke", true))) return;
try { await api("DELETE", "/api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
}
} catch { /* ignore */ }
// Org-wide share audit
el(panel, "h3", null, "Public share links");
const slist = el(panel, "div", "admin-list");
try {
const shs = (await getJSON("/api/orgs/" + org.id + "/shares")).shares || [];
if (!shs.length) el(slist, "div", "admin-empty", "No public shares.");
for (const sh of shs) {
const row = el(slist, "div", "admin-item");
const main = el(row, "span", "ai-main mono", sh.path);
main.title = sh.url; main.style.cursor = "pointer";
main.onclick = () => window.open(sh.url, "_blank");
const meta = (sh.project_name || "") +
(sh.creator ? " · by " + sh.creator : "") +
(sh.created ? " · " + new Date(sh.created).toLocaleDateString() : "");
el(row, "span", "ai-tag", meta);
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
if (!(await modalConfirm("Revoke share link", "Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.", "Revoke", true))) return;
try { await api("DELETE", "/api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
}
} catch { /* ignore */ }
}
/* small DOM helper */
function el(parent, tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
parent.appendChild(n);
return n;
}
/* fetch wrapper for methods without a body-returning helper */
async function api(method, url, body) {
const opt = { method };
if (body !== undefined) { opt.headers = { "Content-Type": "application/json" }; opt.body = JSON.stringify(body); }
const r = await fetch(url, opt);
if (!r.ok) throw new Error(await r.text());
return r.status === 204 ? {} : r.json();
}
/* In-app modal prompt (text input) — replaces native prompt(). Resolves to
the trimmed string, or null if cancelled. */
function modalPrompt(title, label, value, okLabel) {
return new Promise((resolve) => {
const back = document.createElement("div");
back.className = "modal-back";
back.innerHTML = `<div class="modal">
<h3></h3>
<label class="modal-label"></label>
<input class="modal-input" type="text" autocomplete="off">
<div class="modal-actions">
<button class="ai-btn" data-a="cancel">Cancel</button>
<button class="pbtn" data-a="ok"></button>
</div></div>`;
back.querySelector("h3").textContent = title;
back.querySelector(".modal-label").textContent = label || "";
const input = back.querySelector(".modal-input");
input.value = value || "";
back.querySelector('[data-a="ok"]').textContent = okLabel || "OK";
const done = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
const onKey = (e) => { if (e.key === "Escape") done(null); if (e.key === "Enter") done(input.value.trim() || null); };
back.querySelector('[data-a="cancel"]').onclick = () => done(null);
back.querySelector('[data-a="ok"]').onclick = () => done(input.value.trim() || null);
back.onclick = (e) => { if (e.target === back) done(null); };
document.addEventListener("keydown", onKey);
document.body.appendChild(back);
input.focus();
input.select();
});
}
/* In-app confirm — replaces native confirm(). `danger` styles the confirm
button as destructive. Resolves true/false. */
function modalConfirm(title, message, confirmLabel, danger) {
return new Promise((resolve) => {
const back = document.createElement("div");
back.className = "modal-back";
back.innerHTML = `<div class="modal">
<h3></h3>
<p class="modal-msg"></p>
<div class="modal-actions">
<button class="ai-btn" data-a="cancel">Cancel</button>
<button class="${danger ? "danger-btn" : "pbtn"}" data-a="ok"></button>
</div></div>`;
back.querySelector("h3").textContent = title;
back.querySelector(".modal-msg").textContent = message || "";
back.querySelector('[data-a="ok"]').textContent = confirmLabel || "Confirm";
const done = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
const onKey = (e) => { if (e.key === "Escape") done(false); if (e.key === "Enter") done(true); };
back.querySelector('[data-a="cancel"]').onclick = () => done(false);
back.querySelector('[data-a="ok"]').onclick = () => done(true);
back.onclick = (e) => { if (e.target === back) done(false); };
document.addEventListener("keydown", onKey);
document.body.appendChild(back);
back.querySelector('[data-a="ok"]').focus();
});
}
/* clipboard copy that never throws on a non-HTTPS origin (where
navigator.clipboard is undefined). Returns true on success. */
async function copyText(text) {
try {
if (navigator.clipboard) { await navigator.clipboard.writeText(text); return true; }
} catch { /* fall through */ }
return false;
}
/* transient toast, replacing blocking alert() */
let toastTimer = null;
function toast(msg, isErr) {
let t = $("toast");
if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.appendChild(t); }
t.textContent = msg;
t.className = "show" + (isErr ? " err" : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.className = ""; }, 3200);
}
/* Admin approval bar: a hub admin sees pending signups to approve/deny. */
async function updateAdminBar() {
const bar = $("adminbar");
if (!bar) return;
if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; }
let pending = [];
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
bar.hidden = false;
bar.innerHTML = svgIcon("shield") + `<span>Admin${pending.length ? " · " + pending.length : ""}</span>`;
bar.title = "Hub administration — signup policy" + (pending.length ? " and pending approvals" : "");
bar.onclick = () => showHubSettings();
}
/* Hub-admin settings: signup/access policy. Verification & approval are
live toggles; the domain allowlist and admin list are shown read-only
(they're server-config owned, deliberately not browser-editable). */
async function showHubSettings() {
let pol = {};
try { pol = await getJSON("/api/admin/policy"); } catch (e) { toast(e.message, true); return; }
currentPath = null; markActive(); closeSidebarOnMobile();
$("crumb").textContent = "Signup & access";
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true;
const box = $("content");
box.className = "view";
box.innerHTML = `<div class="admin"><h1>Signup &amp; access</h1>
<p class="admin-sub">Who can create an account on this hub, and how new accounts are vetted.</p></div>`;
const panel = box.querySelector(".admin");
el(panel, "h3", null, "New-account vetting");
const toggles = el(panel, "div", "admin-list");
const mkToggle = (label, desc, key, on, disabled) => {
const row = el(toggles, "label", "admin-item toggle");
const left = el(row, "span", "ai-main");
el(left, "div", "tg-label", label);
el(left, "div", "tg-desc", desc);
const cb = document.createElement("input");
cb.type = "checkbox"; cb.checked = !!on; cb.dataset.key = key;
if (disabled) { cb.disabled = true; row.style.opacity = ".55"; }
row.appendChild(cb);
return cb;
};
const ver = mkToggle("Require email verification",
pol.mailer ? "New accounts must click an emailed link before they can sign in — proves they control the address."
: "Configure SMTP on the server (auth.smtp) to enable email verification.",
"require_verification", pol.require_verification && pol.mailer, !pol.mailer);
const app = mkToggle("Require admin approval",
"New accounts wait for a hub admin to approve them before they gain access.",
"require_approval", pol.require_approval);
const save = el(panel, "button", "pbtn", "Save policy");
save.style.marginTop = "14px";
save.onclick = async () => {
try {
await postJSON("/api/admin/policy", { require_verification: ver.checked, require_approval: app.checked });
toast("Signup policy saved.");
} catch (e) { toast(e.message, true); }
};
el(panel, "h3", null, "Who can sign up");
const info = el(panel, "div", "admin-list");
const dom = el(info, "div", "admin-item");
el(dom, "span", "ai-main", "Allowed email domains");
el(dom, "span", "ai-tag", (pol.allowed_domains && pol.allowed_domains.length) ? pol.allowed_domains.map((d) => "@" + d).join(", ") : "any");
const sg = el(info, "div", "admin-item");
el(sg, "span", "ai-main", "Self-signup");
el(sg, "span", "ai-tag", pol.allow_signup ? "open" : "invite-only");
const ad = el(info, "div", "admin-item");
el(ad, "span", "ai-main", "Hub admins");
el(ad, "span", "ai-tag", (pol.admins && pol.admins.length) ? pol.admins.join(", ") : "none");
el(panel, "p", "admin-sub", "Domains and admins are set in the server config file (they can't be widened from the browser).");
// Pending approvals live here too, so this is the single admin home.
el(panel, "h3", null, "Pending signups");
const plist = el(panel, "div", "admin-list");
let pending = [];
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
if (!pending.length) el(plist, "div", "admin-empty", "No one is waiting for approval.");
for (const u of pending) {
const row = el(plist, "div", "admin-item");
el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email);
const ok = el(row, "button", "pbtn", "Approve");
ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
const no = el(row, "button", "ai-del", "Deny");
no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
}
}
async function showPending() {
let pending = [];
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
currentPath = null; markActive();
$("crumb").textContent = "Pending signups";
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true;
const box = $("content");
box.className = "view";
box.innerHTML = `<div class="admin"><h1>Pending signups</h1></div>`;
const panel = box.querySelector(".admin");
const list = el(panel, "div", "admin-list");
if (!pending.length) el(list, "div", "admin-empty", "No one is waiting for approval.");
for (const u of pending) {
const row = el(list, "div", "admin-item");
el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email);
const ok = el(row, "button", "pbtn", "Approve");
ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
const no = el(row, "button", "ai-del", "Deny");
no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
}
}
function refreshAll() {
loadProjects();
updateOrgBar();
if (currentProject) refreshTree();
}
/* Native path routing (no hash, no %2F):
volume mode: /<path>
hub mode: /<project-id>/<path>
invite: /join/<token>
Each path segment is percent-encoded for odd characters, but the "/"
separators stay literal so the URL reads like a real file path. */
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:
/<project-id>/insights the Insights dashboard
/<project-id>/history[/<path>] 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: "" };
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. */
function urlForPath(path) {
const enc = encodePath(path);
if (serverConfig.mode === "hub" && currentProject) {
return "/" + currentProject.id + (enc ? "/" + enc : "");
}
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 (serverConfig.mode === "hub" && currentProject) showProjectHome("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 + 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 || [];
// First render of a project's tree: every folder starts closed, except a
// lone root folder — opening it spares the user a single shut folder.
if (treeFirstLoad) {
treeFirstLoad = false;
const rootDirs = kids.filter((c) => c.dir);
if (rootDirs.length === 1) expanded.add(rootDirs[0].path);
}
const nav = $("tree");
nav.innerHTML = "";
nav.appendChild(renderChildren(kids));
markActive();
// A folder listing shows live tree data — keep it in step with the tree.
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
renderFolderListing(currentPath);
}
refreshHeat();
}
/* ---- 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;
if (!force && Date.now() - heatAt < 60000) return;
heatAt = Date.now(); // set before the fetch so failures don't hammer
let out;
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);
}
}
/* Heat for one listing entry: a file's own bucket, or the subtree sum for a
folder. Null when there is nothing to show. */
function heatFor(path, isDir) {
if (!heatMap) return null;
if (!isDir) return heatMap[path] || null;
const agg = { human: 0, agent: 0, share: 0 };
for (const [p, e] of Object.entries(heatMap)) {
if (!p.startsWith(path + "/")) continue;
agg.human += e.human || 0;
agg.agent += e.agent || 0;
agg.share += e.share || 0;
}
return agg.human || agg.agent || agg.share ? agg : null;
}
function heatTotal(e) { return (e.human || 0) + (e.agent || 0) + (e.share || 0); }
function heatText(e) {
const total = heatTotal(e);
if (!total) return "";
let s = total + (total === 1 ? " read" : " reads");
if (e.agent) s += " (" + e.agent + " agent)";
return s;
}
/* Dot intensity 1–4, log-ish steps: 1–2, 3–9, 10–29, 30+ reads. */
function heatLevel(e) {
const total = heatTotal(e);
if (!total) return 0;
if (total < 3) return 1;
if (total < 10) return 2;
if (total < 30) return 3;
return 4;
}
function renderChildren(children) {
const ul = document.createElement("ul");
for (const n of children) ul.appendChild(renderNode(n));
return ul;
}
function renderNode(n) {
const li = document.createElement("li");
li.className = n.dir ? "dir" : "file";
const row = document.createElement("div");
row.className = "row";
row.dataset.path = n.path;
const chev = document.createElement("span");
chev.className = "chev";
chev.innerHTML = svgIcon("chevd");
const ticon = document.createElement("span");
ticon.className = "ticon";
ticon.innerHTML = svgIcon(n.dir ? "folder" : "doc");
const label = document.createElement("span");
label.className = "label";
label.textContent = n.name;
row.append(chev, ticon, label);
// Keyboard-operable: the row behaves as a button.
row.tabIndex = 0;
row.setAttribute("role", "button");
row.title = n.name;
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
li.appendChild(row);
if (n.dir) {
dirIndex.set(n.path, n);
li.appendChild(renderChildren(n.children || []));
const open = expanded.has(n.path);
if (!open) li.classList.add("collapsed");
row.setAttribute("aria-expanded", String(open));
const toggle = () => {
li.classList.toggle("collapsed");
const isCollapsed = li.classList.contains("collapsed");
isCollapsed ? expanded.delete(n.path) : expanded.add(n.path);
row.setAttribute("aria-expanded", String(!isCollapsed));
};
// The chevron only folds; the row selects the folder (opens it in the
// tree and lists its contents in the main pane). Clicking the folder
// whose listing is already showing folds/unfolds it, like a plain tree;
// from any other view (file, history) it brings the listing back.
chev.onclick = (e) => { e.stopPropagation(); toggle(); };
row.onclick = () => {
if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; }
openFolder(n.path);
};
} else {
flatFiles.push({ path: n.path, name: n.name, time: n.time });
row.onclick = () => openFile(n.path);
}
return li;
}
function markActive() {
for (const el of document.querySelectorAll("#tree .row.active")) el.classList.remove("active");
if (!currentPath) return;
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(currentPath)}"]`);
if (row) row.classList.add("active");
}
/* Mark every ancestor folder of a path as open. */
function expandTo(filePath) {
const parts = filePath.split("/");
let acc = "";
for (let i = 0; i < parts.length - 1; i++) {
acc = acc ? acc + "/" + parts[i] : parts[i];
expanded.add(acc);
}
}
/* Reflect the `expanded` set onto the already-rendered tree DOM. */
function applyTreeExpansion() {
for (const li of document.querySelectorAll("#tree li.dir")) {
const row = li.querySelector(":scope > .row");
const open = row && expanded.has(row.dataset.path);
li.classList.toggle("collapsed", !open);
if (row) row.setAttribute("aria-expanded", String(!!open));
}
}
/* Opening a file (search, wikilink, deep link) unfolds the path to it and
scrolls the row into view so the selection is never hidden in a shut folder. */
function revealInTree(p) {
expandTo(p);
applyTreeExpansion();
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
if (row) row.scrollIntoView({ block: "center" });
}
/* ---- breadcrumb ----
Every ancestor segment is a link to that folder's listing; the last
segment is the current page. */
function setCrumb(p) {
const c = $("crumb");
c.innerHTML = "";
const parts = p.split("/");
let acc = "";
parts.forEach((seg, i) => {
acc = acc ? acc + "/" + seg : seg;
if (i) el(c, "span", "crumb-sep", "/");
const last = i === parts.length - 1;
const s = el(c, "span", last ? null : "crumb-seg", seg);
if (!last) {
const target = acc;
s.title = target;
s.onclick = () => { if (dirIndex.has(target)) openFolder(target); };
}
});
}
/* ---- folder pane ---- */
/* Open a path of either kind — a route or link doesn't know which it is
until the tree has loaded. */
function openPath(p) {
if (dirIndex.has(p)) openFolder(p);
else openFile(p);
}
/* Selecting a folder: unfold it in the tree, highlight it, and list what's
inside it in the main pane. */
function openFolder(p) {
if (!dirIndex.has(p)) return;
currentPath = p;
syncURL(p);
expandTo(p);
expanded.add(p); // the selected folder itself opens, not just its ancestors
applyTreeExpansion();
markActive();
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
if (row) row.scrollIntoView({ block: "nearest" });
closeSidebarOnMobile();
setCrumb(p);
$("meta").textContent = "";
$("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);
}
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");
const head = el(wrap, "h1", "dl-title");
const hicon = el(head, "span", "dl-title-icon");
hicon.innerHTML = svgIcon("folder");
el(head, "span", null, node.name);
const kids = (node.children || []).slice()
.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
const dirs = kids.filter((c) => c.dir).length;
const files = kids.length - dirs;
const counts = [];
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
if (files) counts.push(files + (files === 1 ? " file" : " files"));
const folderHeat = heatFor(p, true);
if (folderHeat) counts.push(heatText(folderHeat) + " in 30 days");
el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder");
if (!kids.length) {
el(wrap, "div", "dl-empty", "Nothing in this folder yet.");
if (keepTop) content.scrollTo({ top: keepTop, behavior: "instant" });
renderFolderHistory(wrap, p, keepTop);
return;
}
const list = el(wrap, "div", "dl-items");
for (const c of kids) {
const row = el(list, "div", "dl-row");
row.tabIndex = 0;
row.setAttribute("role", "button");
row.title = c.path;
const icon = el(row, "span", "ticon");
icon.innerHTML = svgIcon(c.dir ? "folder" : "doc");
el(row, "span", "dl-name", c.name);
let meta = "";
if (c.dir) {
const n = (c.children || []).length;
meta = n + (n === 1 ? " item" : " items");
} else {
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
.filter(Boolean).join(" · ");
}
const he = heatFor(c.path, !!c.dir);
if (he) {
const dot = el(row, "span", "heatdot lvl" + heatLevel(he));
dot.title = heatText(he) + " in 30 days";
meta = heatText(he) + (meta ? " · " + meta : "");
}
el(row, "span", "dl-meta", meta);
row.onclick = () => openPath(c.path);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
}
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, restoreTop) {
if (!(serverConfig.mode === "hub" && currentProject)) return;
const sec = el(wrap, "div", "dl-history");
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
const entries = out.entries || [];
if (!entries.length) return sec.remove();
el(sec, "h3", "dl-h3", "Recent changes");
const list = el(sec, "div", "history dl-hlist");
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();
revealInTree(p);
closeSidebarOnMobile();
setCrumb(p);
updateShareButton();
const dl = $("download");
dl.href = apiBase + "download?path=" + encodeURIComponent(p);
dl.hidden = false;
$("more-btn").hidden = false;
const content = $("content");
content.className = "markdown"; // document view: markdown type rules apply
content.innerHTML = "";
$("meta").textContent = "";
try {
if (MD_EXT.test(p)) {
const doc = await getJSON(apiBase + "render?path=" + encodeURIComponent(p));
content.innerHTML = doc.html;
fixLinks(content, p);
showMeta(doc);
} else if (IMG_EXT.test(p)) {
const img = document.createElement("img");
img.src = fileURL(p);
img.alt = p;
content.appendChild(img);
} else if (TEXT_EXT.test(p)) {
const r = await fetch(fileURL(p));
if (!r.ok) throw new Error(await r.text());
const pre = document.createElement("pre");
pre.className = "plain";
pre.textContent = await r.text();
content.appendChild(pre);
} else {
content.innerHTML =
`<div class="filecard"><div class="name"></div>` +
`<p>No preview for this file type.</p>` +
`<a class="btn" download href="${dl.href}">Download</a></div>`;
content.querySelector(".name").textContent = p.split("/").pop();
}
} catch (err) {
content.innerHTML = `<div class="empty"></div>`;
content.querySelector(".empty").textContent = "Could not load file: " + err.message;
}
content.scrollTo({ top: restoreTop, behavior: "instant" });
}
function showMeta(doc) {
const parts = [];
if (doc.author) parts.push(doc.author + (doc.device ? " on " + doc.device : ""));
if (doc.time) parts.push(new Date(doc.time).toLocaleString());
const he = heatMap && heatMap[doc.path];
if (he && heatTotal(he)) parts.push(heatText(he) + " / 30d");
$("meta").textContent = parts.join(" · ");
}
/* Rewrite rendered-markdown links: wiki: targets resolve by basename, and
relative links/images resolve against the current file's folder. */
function fixLinks(scope, p) {
const dir = p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "";
for (const img of scope.querySelectorAll("img")) {
const src = img.getAttribute("src") || "";
if (!/^([a-z]+:|\/)/i.test(src)) img.src = fileURL(join(dir, src));
}
for (const a of scope.querySelectorAll("a")) {
const href = a.getAttribute("href") || "";
if (href.startsWith("wiki:")) {
const target = decodeURIComponent(href.slice(5));
a.onclick = (e) => { e.preventDefault(); openWikilink(target); };
} else if (!/^([a-z]+:|\/|#)/i.test(href)) {
const target = join(dir, decodeURIComponent(href));
a.onclick = (e) => { e.preventDefault(); openFile(target); };
} else if (/^https?:/i.test(href)) {
a.target = "_blank";
a.rel = "noopener";
}
}
}
function openWikilink(target) {
const want = target.toLowerCase();
const hit =
flatFiles.find((f) => f.path.toLowerCase() === want || f.path.toLowerCase() === want + ".md") ||
flatFiles.find((f) => {
const n = f.name.toLowerCase();
return n === want || n === want + ".md";
});
if (hit) openFile(hit.path);
}
/* A clear, explicitly-public share confirmation: warns that anyone with the
link can view, and offers copy / open / revoke. */
function showShareDialog(url, copied) {
const back = document.createElement("div");
back.className = "modal-back";
back.innerHTML = `
<div class="modal">
<h3>Public link created</h3>
<p><b>Anyone with this link can view this file</b> — no account needed. It always shows the latest version until you revoke it.</p>
<div class="modal-url"></div>
<div class="modal-actions">
<button class="pbtn" data-a="copy">${copied ? "Copied ✓" : "Copy link"}</button>
<button class="ai-btn" data-a="open">Open</button>
<button class="ai-del" data-a="revoke">Revoke</button>
<button class="ai-btn" data-a="close">Done</button>
</div>
</div>`;
back.querySelector(".modal-url").textContent = url;
const close = () => back.remove();
back.onclick = (e) => { if (e.target === back) close(); };
const token = url.split("/s/")[1];
back.querySelector('[data-a="copy"]').onclick = () => copyText(url).then((ok) => toast(ok ? "Copied." : "Select and copy the link above."));
back.querySelector('[data-a="open"]').onclick = () => window.open(url, "_blank");
back.querySelector('[data-a="close"]').onclick = close;
back.querySelector('[data-a="revoke"]').onclick = async () => {
try { await api("DELETE", "/api/shares/" + token); toast("Link revoked — it no longer works."); close(); }
catch (e) { toast(e.message, true); }
};
document.body.appendChild(back);
}
function join(dir, rel) {
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
const out = [];
for (const s of parts) {
if (s === "" || s === ".") continue;
if (s === "..") out.pop();
else out.push(s);
}
return out.join("/");
}
/* ---- share ----
Mint a public URL for the open file: anyone with the link can view it
(rendered, sandboxed), no account needed. Always the latest content. */
function updateShareButton() {
const btn = $("share-btn");
// Shares are per-file; a selected folder has nothing to mint.
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath));
btn.onclick = async () => {
try {
const r = await fetch(apiBase + "shares", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: currentPath }),
});
if (!r.ok) throw new Error(await r.text());
const share = await r.json();
const copied = await copyText(share.url);
showShareDialog(share.url, copied);
} catch (err) {
toast("Share failed: " + err.message, true);
}
};
}
/* ---- project home ----
The index page of a project (/<project-id>): how to mount the project as a
local folder and connect a coding agent, with the Insights dashboard
embedded below for admins/org-owners. The guide shows real, copyable
commands with this hub's URL and this project's id filled in. */
const GUIDE_AGENTS = [
{ key: "claude", label: "Claude Code & Cowork" },
{ key: "hermes", label: "Hermes", hook: "hermes",
note: "Registers BearDrive's hooks in Hermes's config: pull before every turn, push after edits " +
"with a session note, and report file reads to Insights." },
{ key: "codex", label: "Codex", hook: "codex",
note: "Registers hooks in .codex/hooks.json.",
extra: "Run /hooks inside Codex once to trust the project's .codex layer — after that every turn " +
"pulls, edits push automatically, and reads are reported to Insights." },
];
let guideAgent = "claude";
try { guideAgent = localStorage.getItem("bdrive-guide-agent") || "claude"; } catch { /* private mode */ }
function guideSteps(agent) {
const origin = location.origin, pid = currentProject.id;
// Claude Code and Claude Cowork share one plugin: install it once, then
// /beardrive:install does the whole setup conversationally.
if (agent.key === "claude") {
return [
{ title: "Add the BearDrive plugin",
desc: "One time, in any Claude Code session. The plugin ships the beardrive skill, the /beardrive " +
"commands, and turn-boundary sync hooks — and Claude Cowork shares the same plugins, so " +
"installing it once covers both.",
code: "/plugin marketplace add runbear-io/beardrive\n/plugin install beardrive@beardrive" },
{ title: "Set up this project conversationally",
desc: "In a Claude Code or Cowork session in the folder where you want the files, run:",
code: "/beardrive:install connect to " + origin + ", project " + pid,
extra: "Claude installs the CLI, signs this machine in, mounts the project, and registers the " +
"sync hooks — pull the latest before every turn, push after edits (stamped with the session " +
"that made them), and report file reads to Insights. It asks before anything it changes." },
];
}
const slug = (currentProject.name || "project").toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "project";
return [
{ title: "Install the BearDrive CLI",
desc: "One static binary. Homebrew on macOS and Linux; releases and `go install` also work.",
code: "brew install runbear-io/tap/beardrive" },
{ title: "Sign in to this hub",
desc: "Opens the browser once and stores a device token on this machine — the synced folder itself never holds credentials.",
code: "bdrive login " + origin },
{ title: "Mount the project into a local folder",
desc: "Run it where you want the files. An existing folder works too — contents merge, and re-running init later (or after moving the folder) just resumes.",
code: "mkdir -p ~/" + slug + " && cd ~/" + slug + "\nbdrive init --project " + pid },
{ title: "Connect " + agent.label,
desc: agent.note,
code: "bdrive hooks install --agent " + agent.hook,
extra: agent.extra },
];
}
function renderConnectGuide(content) {
const wrap = el(content, "div", "guide");
el(wrap, "h1", "in-title", currentProject.name);
el(wrap, "p", "dl-sub",
"Mount this project as a folder on any machine and connect your coding agent: files sync both " +
"ways in the background, every change is journaled with who made it, and agent reads feed Insights.");
const tabs = el(wrap, "div", "gd-tabs");
const body = el(wrap, "div", "gd-body");
const render = () => {
const agent = GUIDE_AGENTS.find((a) => a.key === guideAgent) || GUIDE_AGENTS[0];
for (const b of tabs.children) b.classList.toggle("active", b.dataset.key === agent.key);
body.innerHTML = "";
guideSteps(agent).forEach((s, i) => {
const step = el(body, "div", "gd-step");
const head = el(step, "div", "gd-step-head");
el(head, "span", "gd-num", String(i + 1));
el(head, "span", "gd-step-title", s.title);
if (s.desc) el(step, "p", "gd-desc", s.desc);
if (s.code) {
const pre = el(step, "pre", "gd-code");
el(pre, "code", null, s.code);
const btn = el(pre, "button", "gd-copy", "Copy");
btn.onclick = async () => {
btn.textContent = (await copyText(s.code)) ? "Copied" : "Copy failed";
setTimeout(() => { btn.textContent = "Copy"; }, 1400);
};
}
if (s.extra) el(step, "p", "gd-desc gd-extra", s.extra);
});
el(body, "p", "gd-done",
"That's it — the folder now syncs on its own. Every agent turn starts from the latest state, " +
"edits appear here (and on every teammate's mount) within seconds, and what your agents read " +
"shows up in Insights.");
};
for (const a of GUIDE_AGENTS) {
const b = el(tabs, "button", "gd-tab", a.label);
b.dataset.key = a.key;
b.onclick = () => {
guideAgent = a.key;
try { localStorage.setItem("bdrive-guide-agent", a.key); } catch { /* private mode */ }
render();
};
}
render();
}
async function showProjectHome(push = true) {
if (!(serverConfig.mode === "hub" && currentProject)) return;
if (push === "replace") replaceURL("/" + currentProject.id);
else if (push) pushURL("/" + currentProject.id);
const restoreTop = pendingScroll != null ? pendingScroll : 0;
pendingScroll = null;
currentPath = null;
markActive();
$("crumb").textContent = currentProject.name;
$("meta").textContent = "";
$("download").hidden = true;
$("more-btn").hidden = true;
const content = $("content");
content.className = "view";
content.innerHTML = "";
renderConnectGuide(content);
if (canSeeInsights()) {
const host = el(content, "div", "home-insights");
await refreshHeat(true);
insightsDevices = null;
try {
insightsDevices = (await getJSON(apiBase + "heat?by=device&days=30")).devices || [];
} catch { /* older server: the coverage section simply doesn't render */ }
if (!host.isConnected) return; // user navigated away during the fetches
renderInsights(host, "all");
}
content.scrollTo({ top: restoreTop, behavior: "instant" });
}
/* ---- insights: the read×write matrix ----
Every file plotted by how much it is read (30 days, from the heat API)
against how long since it last changed (from the tree). The hot-but-stale
quadrant is the danger zone: knowledge people still rely on that nobody
maintains. Admin/org-owner only — members get the ambient heat dots. */
const HOT_READS = 3; // ≥ this many reads/30d = hot
const STALE_DAYS = 30; // ≥ this many days since last write = stale
function canSeeInsights() {
if (!(serverConfig.mode === "hub" && currentProject)) return false;
if (!(serverConfig.reads && serverConfig.reads.enabled)) return false;
if (serverConfig.auth && serverConfig.auth.admin) return true;
const org = currentOrg();
return !!(org && org.role === "owner");
}
let insightsDevices = null; // /heat?by=device breakdown, fetched per Insights open
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 {
insightsDevices = (await getJSON(apiBase + "heat?by=device&days=30")).devices || [];
} catch { /* older server: the coverage section simply doesn't render */ }
currentPath = null;
markActive();
$("crumb").textContent = "Insights — " + currentProject.name;
$("meta").textContent = "";
$("download").hidden = true;
$("more-btn").hidden = true;
const content = $("content");
content.className = "view";
renderInsights(content, "all");
content.scrollTo({ top: restoreTop, behavior: "instant" });
}
/* SVG element helper for the insights charts. */
function svgEl(parent, tag, attrs, text) {
const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, v);
if (text != null) n.textContent = text;
if (parent) parent.appendChild(n);
return n;
}
/* Staleness color: fresh green → amber → red over 0..300 days. */
function staleColor(days) {
const stops = [[76, 195, 138], [232, 196, 84], [224, 93, 93]];
const t = Math.min(1, Math.max(0, days / 300)) * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(t)), f = t - i;
const c = stops[i].map((v, k) => Math.round(v + (stops[i + 1][k] - v) * f));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
function renderInsights(content, lens) {
content.innerHTML = "";
const wrap = el(content, "div", "insights");
el(wrap, "h1", "in-title", "Knowledge insights");
el(wrap, "p", "dl-sub",
"Reads over the last 30 days × how long since each file changed. " +
"Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone.");
const bar = el(wrap, "div", "in-lens");
for (const l of ["all", "human", "agent"]) {
const label = l === "all" ? "All reads" : l === "human" ? "Human reads" : "Agent reads";
const b = el(bar, "button", "in-lens-btn" + (l === lens ? " active" : ""), label);
b.onclick = () => renderInsights(content, l);
}
const readsOf = (e) => (lens === "all" ? heatTotal(e) : e[lens] || 0);
const now = Date.now();
const pts = flatFiles.map((f) => {
const e = (heatMap && heatMap[f.path]) || {};
const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0;
const reads = readsOf(e);
return { path: f.path, reads, agent: e.agent || 0, total: heatTotal(e), days,
danger: reads >= HOT_READS && days >= STALE_DAYS };
});
el(wrap, "h3", "dl-h3", "Map — cell size = reads, color = freshness");
wrap.appendChild(insightsTreemap(pts));
el(wrap, "h3", "dl-h3", "Reads × freshness");
wrap.appendChild(insightsChart(pts));
el(wrap, "h3", "dl-h3", "Hot path — top files by reads");
renderHotPath(wrap, pts, lens);
if (insightsDevices && insightsDevices.length) {
el(wrap, "h3", "dl-h3", "Agent coverage — which agents read which areas");
wrap.appendChild(insightsMatrix(insightsDevices));
}
}
/* ---- treemap (the landing chart) ----
Squarified treemap (Bruls et al.), dependency-free: items sorted by value
fill a rect in rows along the shorter side, keeping cells near-square.
Returns [{item, x, y, w, h}]. */
function squarify(items, x, y, w, h) {
const total = items.reduce((s, it) => s + it.value, 0);
if (!total || w <= 0 || h <= 0) return [];
let rest = items.slice().sort((a, b) => b.value - a.value)
.map((it) => ({ it, a: (it.value / total) * w * h }));
const worst = (row, side) => {
const sum = row.reduce((t, r) => t + r.a, 0);
const d = sum / side;
let m = 0;
for (const r of row) {
const l = r.a / d;
m = Math.max(m, l / d, d / l);
}
return m;
};
const out = [];
while (rest.length) {
const horiz = w >= h; // row = a strip along the shorter side
const side = horiz ? h : w;
const row = [rest.shift()];
while (rest.length && worst(row.concat(rest[0]), side) <= worst(row, side)) {
row.push(rest.shift());
}
const d = row.reduce((t, r) => t + r.a, 0) / side;
let off = 0;
for (const r of row) {
const l = r.a / d;
if (horiz) out.push({ item: r.it, x, y: y + off, w: d, h: l });
else out.push({ item: r.it, x: x + off, y, w: l, h: d });
off += l;
}
if (horiz) { x += d; w -= d; } else { y += d; h -= d; }
}
return out;
}
const TM_HEADER = 15; // group label strip height
function insightsTreemap(pts) {
const W = 720, H = 480;
const svg = svgEl(null, "svg", { viewBox: `0 0 ${W} ${H}`, class: "in-chart in-treemap" });
// Two levels: top-level folder groups, files within each.
const groups = new Map();
for (const p of pts) {
const top = p.path.includes("/") ? p.path.split("/")[0] : "/";
let g = groups.get(top);
if (!g) groups.set(top, g = { name: top, files: [], value: 0 });
g.files.push(p);
g.value += p.reads + 1; // +1: unread files still occupy a sliver
}
for (const gc of squarify([...groups.values()], 0, 0, W, H)) {
const g = gc.item;
const dir = g.name === "/" ? "" : g.name;
svgEl(svg, "rect", {
x: gc.x + 1, y: gc.y + 1, width: Math.max(0, gc.w - 2), height: Math.max(0, gc.h - 2),
rx: 3, class: "in-tm-group", "data-dir": dir,
});
if (gc.w > 46 && gc.h > TM_HEADER + 10) {
let label = g.name === "/" ? "(root)" : g.name;
const fit = Math.floor((gc.w - 8) / 6);
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
svgEl(svg, "text", { x: gc.x + 5, y: gc.y + 12, class: "in-tm-glabel", "data-dir": dir }, label);
}
const cells = squarify(
g.files.map((f) => ({ ...f, name: f.path.split("/").pop(), value: f.reads + 1 })),
gc.x + 2, gc.y + TM_HEADER, Math.max(0, gc.w - 4), Math.max(0, gc.h - TM_HEADER - 2));
for (const c of cells) {
const cell = svgEl(svg, "rect", {
x: c.x + 0.6, y: c.y + 0.6, width: Math.max(0.4, c.w - 1.2), height: Math.max(0.4, c.h - 1.2),
rx: 1.5, fill: staleColor(c.item.days), class: "in-tm-cell", "data-path": c.item.path,
});
svgEl(cell, "title", {},
`${c.item.path} — ${c.item.reads} read${c.item.reads === 1 ? "" : "s"}/30d · changed ${Math.round(c.item.days)}d ago`);
if (c.w > 54 && c.h > 16) {
const fit = Math.floor((c.w - 8) / 6);
let label = (c.item.danger ? "⚠ " : "") + c.item.name;
if (label.length > fit) label = label.slice(0, Math.max(1, fit - 1)) + "…";
if (fit >= 5) svgEl(svg, "text", { x: c.x + 4.5, y: c.y + 12.5, class: "in-tm-label", "data-path": c.item.path }, label);
}
}
}
// One delegated click handler for thousands of cells.
svg.addEventListener("click", (e) => {
const t = e.target.closest("[data-path], [data-dir]");
if (!t) return;
const path = t.getAttribute("data-path");
if (path) return openFile(path);
const dir = t.getAttribute("data-dir");
if (dir && dirIndex.has(dir)) openFolder(dir);
});
return svg;
}
/* ---- hot path: top-20 files by reads, agent/human split ---- */
function renderHotPath(wrap, pts, lens) {
const top = pts.filter((p) => p.reads > 0)
.sort((a, b) => b.reads - a.reads || b.days - a.days).slice(0, 20);
if (!top.length) {
el(wrap, "div", "dl-empty", "No reads in the window yet.");
return;
}
const max = top[0].reads;
const list = el(wrap, "div", "in-hotpath");
for (const p of top) {
const row = el(list, "div", "in-hp-row");
row.tabIndex = 0;
row.setAttribute("role", "button");
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);
const pct = (p.reads / max) * 100;
el(barw, "span", "in-hp-agent").style.width = (pct * aFrac).toFixed(1) + "%";
el(barw, "span", "in-hp-human").style.width = (pct * (1 - aFrac)).toFixed(1) + "%";
el(row, "span", "in-hp-count", p.reads);
row.onclick = () => openFile(p.path);
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
}
const lg = el(wrap, "p", "in-legend");
el(lg, "span", "in-sw agent");
lg.append(" agent reads ");
el(lg, "span", "in-sw human");
lg.append(" human reads");
}
/* ---- agent coverage matrix: devices × top-level folders ---- */
function insightsMatrix(devices) {
const totals = new Map();
for (const d of devices) {
for (const [f, n] of Object.entries(d.folders || {})) totals.set(f, (totals.get(f) || 0) + n);
}
const cols = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12).map((e) => e[0]);
const rows = devices.slice(0, 12); // server sorts by total desc
const left = 140, top = 6, cw = Math.min(76, Math.max(34, (720 - left - 8) / cols.length)), ch = 26;
const W = 720, H = top + rows.length * ch + 58;
const svg = svgEl(null, "svg", { viewBox: `0 0 ${W} ${H}`, class: "in-chart in-matrix" });
const max = Math.max(1, ...rows.flatMap((d) => cols.map((c) => (d.folders || {})[c] || 0)));
const shade = (t) => { // #17191f → amber by intensity
const a = [23, 25, 31], b = [245, 166, 35];
const c = a.map((v, i) => Math.round(v + (b[i] - v) * t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
};
rows.forEach((d, i) => {
let label = d.name || d.id;
if (label.length > 20) label = label.slice(0, 19) + "…";
svgEl(svg, "text", { x: left - 8, y: top + i * ch + 17, "text-anchor": "end", class: "in-label" }, label);
cols.forEach((c, j) => {
const v = (d.folders || {})[c] || 0;
const cell = svgEl(svg, "rect", {
x: left + j * cw, y: top + i * ch, width: cw - 4, height: ch - 4, rx: 3,
fill: shade(Math.sqrt(v / max)),
});
svgEl(cell, "title", {}, `${d.name || d.id} × ${c || "(root)"}: ${v} read${v === 1 ? "" : "s"}/30d`);
});
});
cols.forEach((c, j) => {
const cx = left + j * cw + (cw - 4) / 2, cy = top + rows.length * ch + 14;
svgEl(svg, "text", {
x: cx, y: cy, class: "in-label", "text-anchor": "end",
transform: `rotate(-28 ${cx} ${cy})`,
}, c || "(root)");
});
return svg;
}
/* Dependency-free SVG scatter: x = days since last write, y = reads, both
log-scaled; threshold lines split the quadrants. */
function insightsChart(pts) {
const W = 720, H = 360, M = { l: 44, r: 16, t: 20, b: 34 };
const maxDays = Math.max(STALE_DAYS * 2, ...pts.map((p) => p.days));
const maxReads = Math.max(HOT_READS * 2, ...pts.map((p) => p.reads));
const lx = (d) => Math.log10(d + 1) / Math.log10(maxDays + 1);
const ly = (r) => Math.log10(r + 1) / Math.log10(maxReads + 1);
const X = (d) => M.l + lx(d) * (W - M.l - M.r);
const Y = (r) => H - M.b - ly(r) * (H - M.t - M.b);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.setAttribute("class", "in-chart");
const add = (tag, attrs, text) => {
const n = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
if (text != null) n.textContent = text;
svg.appendChild(n);
return n;
};
// danger quadrant shading + threshold lines
add("rect", { x: X(STALE_DAYS), y: M.t, width: W - M.r - X(STALE_DAYS), height: Y(HOT_READS) - M.t, class: "in-danger-zone" });
add("line", { x1: X(STALE_DAYS), y1: M.t, x2: X(STALE_DAYS), y2: H - M.b, class: "in-threshold" });
add("line", { x1: M.l, y1: Y(HOT_READS), x2: W - M.r, y2: Y(HOT_READS), class: "in-threshold" });
// axes
add("line", { x1: M.l, y1: H - M.b, x2: W - M.r, y2: H - M.b, class: "in-axis" });
add("line", { x1: M.l, y1: M.t, x2: M.l, y2: H - M.b, class: "in-axis" });
add("text", { x: (M.l + W - M.r) / 2, y: H - 8, class: "in-label" }, "days since last change →");
add("text", { x: 12, y: (M.t + H - M.b) / 2, class: "in-label", transform: `rotate(-90 12 ${(M.t + H - M.b) / 2})` }, "reads / 30d →");
add("text", { x: W - M.r - 6, y: M.t + 14, class: "in-quad in-quad-danger", "text-anchor": "end" }, "hot + stale");
add("text", { x: M.l + 6, y: M.t + 14, class: "in-quad" }, "hot + fresh");
add("text", { x: W - M.r - 6, y: H - M.b - 8, class: "in-quad", "text-anchor": "end" }, "cold + stale");
add("text", { x: W - M.r - 6, y: M.t + 28, class: "in-label", "text-anchor": "end" }, "dot size = agent share of reads");
for (const p of pts) {
// Radius encodes the agent share of the file's reads; translucent dots
// keep the cloud readable at hundreds of files.
const share = p.total ? (p.agent || 0) / p.total : 0;
const c = add("circle", {
cx: X(p.days).toFixed(1), cy: Y(p.reads).toFixed(1), r: (3 + 4 * share).toFixed(1),
class: "in-pt" + (p.danger ? " danger" : p.reads ? "" : " cold"),
});
const tip = document.createElementNS("http://www.w3.org/2000/svg", "title");
tip.textContent = `${p.path} — ${p.reads} read${p.reads === 1 ? "" : "s"} / 30d · changed ${Math.round(p.days)}d ago`;
c.appendChild(tip);
c.onclick = () => openFile(p.path);
}
return svg;
}
/* ---- history ----
Every change ever made, straight from the journals: who (account), when,
from which device (name, OS, IP as the server saw it), with view/download
of that exact version. Groundwork for revert/rollback. */
function initHistory() {
const btn = $("history-btn");
btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull());
btn.onclick = () => {
if (!currentPath) return showHistory({ prefix: "" });
if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" });
showHistory({ path: currentPath });
};
}
function currentProjectOrNull() {
return serverConfig.mode === "hub" ? currentProject : null;
}
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)
: "prefix=" + encodeURIComponent(q.prefix);
let out;
try {
out = await getJSON(apiBase + "history?" + qs + "&n=200");
} catch (err) {
$("meta").textContent = "History unavailable: " + err.message;
return;
}
const title = "path" in q ? q.path : (q.prefix ? q.prefix + " (folder)" : "all changes");
$("crumb").textContent = "History — " + title;
$("meta").textContent = "";
$("download").hidden = true;
content.className = "view";
$("more-btn").hidden = true;
content.innerHTML = "";
const wrap = document.createElement("div");
wrap.className = "history";
if (!out.entries || out.entries.length === 0) {
wrap.innerHTML = `<div class="empty">No history yet.</div>`;
}
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
file, by whom, from where — with view/download of that exact version. */
const KIND_ICON = { add: "plus", edit: "edit", delete: "x" };
const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" };
function historyEntryRow(e) {
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
const row = document.createElement("div");
row.className = "hentry " + kind;
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
const when = new Date(e.time).toLocaleString();
row.innerHTML =
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htag"></span><span class="htime"></span></div>` +
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span></div>`;
row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot");
row.querySelector(".hpath").textContent = e.path;
row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind;
row.querySelector(".htime").textContent = when;
row.querySelector(".hwho").textContent = who;
row.querySelector(".hdev").textContent = dev;
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
if (e.note) {
const note = document.createElement("div");
note.className = "hnote";
// Linkify http(s) URLs (e.g. a Claude session link); everything else
// stays plain text — notes are user/agent input, never markup.
for (const tok of e.note.split(/(https?:\/\/\S+)/)) {
if (/^https?:\/\//.test(tok)) {
const a = document.createElement("a");
a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener";
note.appendChild(a);
} else if (tok) {
note.append(tok);
}
}
// Long notes clamp to one line; clicking the note (links still work)
// expands to the full text and back.
note.tabIndex = 0;
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";
note.setAttribute("aria-expanded", String(open));
};
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;
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;
}
function humanSize(n) {
if (n < 1024) return n + " B";
const units = ["KB", "MB", "GB", "TB"];
let i = -1;
do { n /= 1024; i++; } while (n >= 1024 && i < units.length - 1);
return n.toFixed(1) + " " + units[i];
}
/* ---- upload ----
The client asks the server how to upload (upload/init): "direct" hands
back a short-lived presigned URL and the bytes go straight to the object
store; "server" means relay the bytes through the bdrive server. */
function initUpload() {
const btn = $("upload-btn");
const enabled = serverConfig.upload && serverConfig.upload.enabled &&
(serverConfig.mode !== "hub" || currentProject);
if (!enabled) { btn.hidden = true; return; }
btn.hidden = false;
const input = $("upload-input");
btn.onclick = () => input.click();
input.onchange = async () => {
const file = input.files[0];
input.value = "";
if (!file) return;
// A selected folder receives the upload; a selected file means "next to it".
const dir = !currentPath ? ""
: dirIndex.has(currentPath) ? currentPath
: currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
const dest = dir ? dir + "/" + file.name : file.name;
const status = $("meta");
try {
status.textContent = `Uploading ${dest}…`;
await uploadFile(dest, file);
status.textContent = `Uploaded ${dest}`;
await refreshTree();
openFile(dest);
} catch (err) {
status.textContent = "Upload failed: " + err.message;
}
};
}
async function uploadFile(dest, file) {
const buf = await file.arrayBuffer();
const sha = await sha256Hex(buf);
const post = async (url, body) => {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(await r.text());
return r.json();
};
const req = { path: dest, sha256: sha, size: file.size };
const plan = await post(apiBase + "upload/init", req);
if (plan.mode === "direct") {
if (!plan.exists) { // identical content already in the store? skip the PUT
const r = await fetch(plan.url, { method: plan.method || "PUT", headers: plan.headers || {}, body: buf });
if (!r.ok) throw new Error("storage upload failed: " + r.status);
}
await post(apiBase + "upload/commit", req);
} else {
const r = await fetch(apiBase + "upload/content?path=" + encodeURIComponent(dest), {
method: "PUT", body: buf,
});
if (!r.ok) throw new Error(await r.text());
}
}
async function sha256Hex(buf) {
if (crypto.subtle) {
const d = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
return sha256Fallback(new Uint8Array(buf)); // plain-http origins have no crypto.subtle
}
/* Minimal SHA-256 (FIPS 180-4) for non-secure contexts. */
function sha256Fallback(bytes) {
const K = new Uint32Array([
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]);
const H = new Uint32Array([
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
const rr = (x, n) => (x >>> n) | (x << (32 - n));
const len = bytes.length;
const padded = new Uint8Array((((len + 8) >> 6) + 1) << 6);
padded.set(bytes);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
dv.setUint32(padded.length - 8, Math.floor((len * 8) / 0x100000000));
dv.setUint32(padded.length - 4, (len * 8) >>> 0);
const w = new Uint32Array(64);
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
for (let i = 16; i < 64; i++) {
const s0 = rr(w[i-15],7) ^ rr(w[i-15],18) ^ (w[i-15] >>> 3);
const s1 = rr(w[i-2],17) ^ rr(w[i-2],19) ^ (w[i-2] >>> 10);
w[i] = (w[i-16] + s0 + w[i-7] + s1) >>> 0;
}
let [a,b,c,d,e,f,g,h] = H;
for (let i = 0; i < 64; i++) {
const S1 = rr(e,6) ^ rr(e,11) ^ rr(e,25);
const t1 = (h + S1 + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
const S0 = rr(a,2) ^ rr(a,13) ^ rr(a,22);
const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
h=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0;
}
H[0]+=a; H[1]+=b; H[2]+=c; H[3]+=d; H[4]+=e; H[5]+=f; H[6]+=g; H[7]+=h;
}
return [...H].map((x) => (x >>> 0).toString(16).padStart(8, "0")).join("");
}
/* ---- command palette (⌘K / Ctrl+K) ----
One box for everything: fuzzy-jump to any file, switch projects, and run
quick actions (share, history, upload, download, sign out). */
let paletteItems = [];
let paletteSel = 0;
function paletteOpen() {
buildPaletteItems("");
$("palette-overlay").hidden = false;
const input = $("palette-input");
input.value = "";
input.focus();
}
function paletteClose() {
$("palette-overlay").hidden = true;
}
/* Subsequence fuzzy match. Returns a score (higher = better) or -1, plus
the matched positions for highlighting. */
function fuzzy(query, text) {
if (!query) return { score: 0, hits: [] };
const q = query.toLowerCase();
const t = text.toLowerCase();
let ti = 0, score = 0, streak = 0;
const hits = [];
for (let qi = 0; qi < q.length; qi++) {
const found = t.indexOf(q[qi], ti);
if (found === -1) return null;
streak = found === ti ? streak + 1 : 1;
score += streak * 3; // consecutive runs
if (found === 0 || "/ -_.".includes(t[found - 1])) score += 8; // word starts
hits.push(found);
ti = found + 1;
}
score -= Math.floor(t.length / 8); // mild preference for short targets
return { score, hits };
}
function highlight(text, hits) {
const span = document.createElement("span");
span.className = "plabel";
let last = 0;
for (const h of hits) {
if (h > last) span.append(text.slice(last, h));
const b = document.createElement("b");
b.textContent = text[h];
span.append(b);
last = h + 1;
}
span.append(text.slice(last));
return span;
}
/* Candidate sources: context-aware actions, then projects, then files. */
function paletteCandidates() {
const items = [];
const add = (icon, label, kind, run) => items.push({ icon, label, kind, run });
const hub = serverConfig.mode === "hub";
if (hub && currentProject && currentPath) {
add("share", "Share: " + currentPath, "action", () => $("share-btn").click());
add("hist", "History: " + currentPath, "action", () => showHistory({ path: currentPath }));
add("download", "Download: " + currentPath, "action", () => $("download").click());
}
if (hub && currentProject) {
add("hist", "History: whole project", "action", () => showHistory({ prefix: "" }));
}
if (!$("upload-btn").hidden) {
add("upload", "Upload a file…", "action", () => $("upload-btn").click());
}
if (hub) {
for (const p of projects) {
if (!currentProject || p.id !== currentProject.id) {
add("folder", "Switch to project: " + p.name, "project", () => selectProject(p, null));
}
}
}
if (serverConfig.auth && serverConfig.auth.enabled) {
add("power", "Sign out", "action", () => { location.href = "/auth/logout"; });
}
for (const d of dirIndex.keys()) {
add("folder", d, "folder", () => openFolder(d));
}
for (const f of flatFiles) {
add("doc", f.path, "file", () => openFile(f.path));
}
return items;
}
/* Match a query against a label, tolerating a simple English plural so
"ideas" still finds idea.md. Tries the raw query first, then a lightly
de-pluralized form (…ies→…y, …es→…, …s→…). */
function fuzzyStemmed(query, label) {
const m = fuzzy(query, label);
if (m) return m;
const q = query.toLowerCase();
let stem = null;
if (q.length > 3 && q.endsWith("ies")) stem = q.slice(0, -3) + "y";
else if (q.length > 3 && q.endsWith("es")) stem = q.slice(0, -2);
else if (q.length > 2 && q.endsWith("s")) stem = q.slice(0, -1);
return stem ? fuzzy(stem, label) : null;
}
function buildPaletteItems(query) {
const scored = [];
for (const c of paletteCandidates()) {
const m = fuzzyStemmed(query, c.label);
if (m) scored.push({ ...c, score: m.score, hits: m.hits });
}
scored.sort((a, b) => b.score - a.score);
paletteItems = scored.slice(0, 40);
paletteSel = 0;
renderPalette();
}
function renderPalette() {
const ul = $("palette-results");
ul.innerHTML = "";
if (paletteItems.length === 0) {
const li = document.createElement("li");
li.className = "pempty";
li.textContent = "No matches — search covers file names, projects, and actions";
ul.appendChild(li);
return;
}
paletteItems.forEach((item, i) => {
const li = document.createElement("li");
if (i === paletteSel) li.classList.add("selected");
const pic = document.createElement("span");
pic.className = "picon";
pic.innerHTML = svgIcon(item.icon);
const kind = document.createElement("span");
kind.className = "pkind";
kind.textContent = item.kind;
li.append(pic, highlight(item.label, item.hits), kind);
li.onclick = () => runPaletteItem(item);
li.onmousemove = () => { if (paletteSel !== i) { paletteSel = i; renderPalette(); } };
ul.appendChild(li);
});
const sel = ul.children[paletteSel];
if (sel) sel.scrollIntoView({ block: "nearest" });
}
function runPaletteItem(item) {
paletteClose();
item.run();
}
window.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
$("palette-overlay").hidden ? paletteOpen() : paletteClose();
return;
}
if ($("palette-overlay").hidden) return;
if (e.key === "Escape") {
e.preventDefault();
paletteClose();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const n = paletteItems.length;
if (n) {
paletteSel = (paletteSel + (e.key === "ArrowDown" ? 1 : n - 1)) % n;
renderPalette();
}
} else if (e.key === "Enter") {
e.preventDefault();
if (paletteItems[paletteSel]) runPaletteItem(paletteItems[paletteSel]);
}
});
$("palette-input").addEventListener("input", (e) => buildPaletteItems(e.target.value));
$("palette-overlay").addEventListener("click", (e) => {
if (e.target.id === "palette-overlay") paletteClose();
});
/* Visible search affordance in the top bar → opens the palette. */
$("search-btn").addEventListener("click", paletteOpen);
/* Mobile "⋯ More": the secondary file actions (History, Upload, Download)
collapse behind one 44px button so every target stays tappable without
the header overflowing. The menu proxies to the real buttons, so their
behavior and visibility rules are the single source of truth. */
function buildMoreMenu() {
const menu = $("more-menu");
menu.innerHTML = "";
const items = [
["History", $("history-btn")],
["Upload", $("upload-btn")],
["Download", $("download")],
].filter(([, el]) => el && !el.hidden);
for (const [label, el] of items) {
const b = document.createElement("button");
b.className = "more-item";
b.textContent = label;
b.onclick = () => { $("more-menu").hidden = true; el.click(); };
menu.appendChild(b);
}
if (canSeeInsights()) {
const b = document.createElement("button");
b.className = "more-item";
b.textContent = "Insights";
b.onclick = () => { $("more-menu").hidden = true; showInsights(); };
menu.appendChild(b);
return items.length + 1;
}
return items.length;
}
$("more-btn").addEventListener("click", (e) => {
e.stopPropagation();
const menu = $("more-menu");
if (menu.hidden) { buildMoreMenu(); menu.hidden = false; }
else menu.hidden = true;
});
document.addEventListener("click", () => { $("more-menu").hidden = true; });
/* Mobile: the sidebar is off-canvas; a hamburger toggles it. */
function toggleSidebar() { document.body.classList.toggle("sb-open"); }
function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); }
$("menu-btn").addEventListener("click", toggleSidebar);
$("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", () => {
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; }
}
applyRoute(r);
});
boot();