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 = `