diff --git a/internal/webapp/history.go b/internal/webapp/history.go index 04f84c8..1350460 100644 --- a/internal/webapp/history.go +++ b/internal/webapp/history.go @@ -20,7 +20,7 @@ import ( // HistoryEntry is one change as the history API reports it. type HistoryEntry struct { Time string `json:"time"` - Kind string `json:"kind"` // put | delete + Kind string `json:"kind"` // add | edit | delete Path string `json:"path"` Size int64 `json:"size,omitempty"` Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint @@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request return } journal.Sort(all) + // A put is an "add" when the path didn't exist just before it (first + // version, or first after a delete), an "edit" otherwise. Existence is + // replayed over ALL ops in journal order, before any path/prefix filter, + // so a filtered view classifies the same as the full feed. + kinds := make([]string, len(all)) + exists := make(map[string]bool, len(all)) + for i, op := range all { + switch { + case op.Kind == journal.KindDelete: + kinds[i] = "delete" + exists[op.Path] = false + case exists[op.Path]: + kinds[i] = "edit" + default: + kinds[i] = "add" + exists[op.Path] = true + } + } entries := make([]HistoryEntry, 0, n) for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first op := all[i] @@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request dev = DeviceInfo{ID: op.Device, Name: op.DeviceName} } entries = append(entries, HistoryEntry{ - Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind, + Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i], Path: op.Path, Size: op.Size, Blob: op.Blob, User: op.User, UserName: op.UserName, Author: op.Author, Device: dev, Note: op.Note, diff --git a/internal/webapp/history_test.go b/internal/webapp/history_test.go index b52265e..570997d 100644 --- a/internal/webapp/history_test.go +++ b/internal/webapp/history_test.go @@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) { if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) { t.Fatalf("order wrong: %+v", out.Entries) } + // puts are classified: first version = add, later versions = edit + if oldest.Kind != "add" || newest.Kind != "edit" { + t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind) + } if newest.User != "alice@x.io" || newest.UserName != "Alice" { t.Fatalf("user = %+v", newest) } @@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) { if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" { t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0]) } + // the put that created other.md is an add, even in the filtered view + if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" { + t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1]) + } // a device the registry never saw falls back to the op's own info if out.Entries[0].Device.Name != "dev2" { t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device) diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 3e3c1d7..58c9797 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -3,6 +3,7 @@ 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 @@ -92,7 +93,7 @@ async function boot() { initUpload(); await refreshTree(); const { path } = parseRoute(); - if (path) openFile(path); + if (path) openPath(path); } setInterval(refreshTree, 15000); // pick up synced changes } @@ -161,7 +162,7 @@ function selectProject(p, path) { initUpload(); initHistory(); updateShareButton(); - refreshTree().then(() => { if (path) openFile(path); }); + refreshTree().then(() => { if (path) openPath(path); }); if (!path) pushURL("/" + p.id); } @@ -676,6 +677,7 @@ async function refreshTree() { root = await getJSON(apiBase + "tree"); } catch { return; } // keep the last good tree 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. @@ -688,6 +690,10 @@ async function refreshTree() { 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); + } } function renderChildren(children) { @@ -719,6 +725,7 @@ function renderNode(n) { row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; li.appendChild(row); if (n.dir) { + dirIndex.set(n.path, n); if (serverConfig.mode === "hub") { const hist = document.createElement("span"); hist.className = "dir-history"; @@ -731,12 +738,21 @@ function renderNode(n) { const open = expanded.has(n.path); if (!open) li.classList.add("collapsed"); row.setAttribute("aria-expanded", String(open)); - row.onclick = () => { + 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 }); row.onclick = () => openFile(n.path); @@ -780,6 +796,122 @@ function revealInTree(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(); + renderFolderListing(p); +} + +function renderFolderListing(p) { + const node = dirIndex.get(p); + if (!node) return; + const content = $("content"); + 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")); + 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); + 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(" · "); + } + el(row, "span", "dl-meta", meta); + row.onclick = () => openPath(c.path); + row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } }; + } + renderFolderHistory(wrap, p); +} + +/* 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) { + 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 + "/" }); + }).catch(() => sec.remove()); +} + /* ---- file pane ---- */ async function openFile(p) { currentPath = p; @@ -787,7 +919,7 @@ async function openFile(p) { markActive(); revealInTree(p); closeSidebarOnMobile(); - $("crumb").textContent = p.split("/").join(" / "); + setCrumb(p); updateShareButton(); const dl = $("download"); dl.href = apiBase + "download?path=" + encodeURIComponent(p); @@ -916,7 +1048,8 @@ function join(dir, rel) { (rendered, sandboxed), no account needed. Always the latest content. */ function updateShareButton() { const btn = $("share-btn"); - btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath); + // 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", { @@ -941,7 +1074,11 @@ function updateShareButton() { function initHistory() { const btn = $("history-btn"); btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull()); - btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" }); + btn.onclick = () => { + if (!currentPath) return showHistory({ prefix: "" }); + if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" }); + showHistory({ path: currentPath }); + }; } function currentProjectOrNull() { @@ -972,39 +1109,63 @@ async function showHistory(q) { if (!out.entries || out.entries.length === 0) { wrap.innerHTML = `
No history yet.
`; } - for (const e of out.entries || []) { - const row = document.createElement("div"); - row.className = "hentry " + e.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 = - `
` + - `
`; - row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot"); - row.querySelector(".hpath").textContent = e.path; - 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.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); - } - const p = e.path; - row.querySelector(".hpath").onclick = () => showHistory({ path: p }); - wrap.appendChild(row); - } + for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e)); content.appendChild(wrap); } +/* 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 = + `
` + + `
`; + 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 (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"; + // 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); + } + } + row.appendChild(note); + } + const p = e.path; + row.querySelector(".hpath").onclick = () => showHistory({ path: p }); + return row; +} + function humanSize(n) { if (n < 1024) return n + " B"; const units = ["KB", "MB", "GB", "TB"]; @@ -1029,8 +1190,10 @@ function initUpload() { const file = input.files[0]; input.value = ""; if (!file) return; - const dir = currentPath && currentPath.includes("/") - ? currentPath.slice(0, currentPath.lastIndexOf("/")) : ""; + // 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 { @@ -1204,6 +1367,9 @@ function paletteCandidates() { 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)); } @@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => { const proj = projects.find((x) => x.id === project); if (proj) { selectProject(proj, path || null); return; } } - if (path && path !== currentPath) openFile(path); + if (path && path !== currentPath) openPath(path); }); boot(); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 74972d3..84b2736 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -33,6 +33,7 @@ + diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 7bd48c1..5e7eee5 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; } .icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; } .icon-btn:hover { color: var(--text); background: var(--hover); } #crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; } +#crumb .crumb-seg:hover { color: var(--accent-bright); } +#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; } #meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .btn { @@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; } .tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; } .admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; } +/* ---- folder listing ---- */ +.dirlist { max-width: 704px; margin: 0 auto; } +.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; } +.dl-title-icon { display: flex; color: var(--accent); } +.dl-title-icon .ico { width: 20px; height: 20px; } +.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; } +.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); } +.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; } +.dl-row:last-child { border-bottom: none; } +.dl-row:hover { background: var(--hover); } +.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); } +.dl-row .ticon .ico { width: 16px; height: 16px; } +.dl-row:hover .ticon { color: var(--text-faint); } +.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); } +.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; } +.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; } +.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; } +.dl-more { margin-top: 10px; } + /* ---- history ---- */ .history { max-width: 860px; } .hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); } @@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; } .hline { display: flex; gap: 10px; align-items: center; } .hkind { display: inline-flex; color: var(--add); } .hkind .ico { width: 13px; height: 13px; } +.hentry.edit .hkind { color: var(--accent); } .hentry.delete .hkind { color: var(--del); } +.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; } +.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); } +.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); } +.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); } .hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; } .hpath:hover { color: var(--accent-bright); } .htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; } @@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; } .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; } +.hnote::before { content: "› "; color: var(--text-ghost); } +.hnote a { color: var(--accent-bright); text-decoration: none; } +.hnote a:hover { text-decoration: underline; } /* ---- command palette ---- */ #palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; } @@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; } #invite-btn { min-height: 40px; padding: 0 14px; } #org-name { min-height: 44px; } .nav-add { min-width: 44px; min-height: 44px; } - .markdown, .admin, .onboard, .history { max-width: 100%; } + .markdown, .admin, .onboard, .history, .dirlist { max-width: 100%; } .markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; } .ob-row { flex-direction: column; } }