feat(webapp): history can be narrowed by path, author and date (BEA-67) (#113)

* feat(webapp): history can be narrowed by path, author and date (BEA-67)

The project History view was a flat scroll with no controls but per-row
restore/open/download. Fine at eleven rows; unreadable after a month of
agent writes, and agents write far more than people do.

Four reader filters on GET /api/p/<id>/history — q= (case-insensitive
substring of the path), user= (exact account), since=/until= (UTC bounds,
inclusive at both ends, RFC3339 or a bare YYYY-MM-DD). They compose with
each other and with the existing path=/prefix= scoping, and they are
applied in the same walk as path/prefix — BEFORE the sort and the cursor
skip — so next_cursor keeps meaning "the next matching entry" and paging
under a filter needed no new machinery. kinds[] is still computed over
every op, so a filtered view classifies edits the same as the full feed.
A malformed since/until is a 400, not a silently unfiltered feed.

The filter bar drives those params through the URL rather than component
state: a narrowed feed is a link you can send, it survives reload, and
Back undoes it. The author list accumulates across fetches — rebuilding
it from the current feed would strand a reader who filtered by one
author with only their own name to pick from. Zero matches gets its own
empty state with a Clear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(architecture): the frontend diagram parses again

The escaped quotes inside the NewProjectDialog note made the whole
classDiagram fail to parse — it has been rendering as an error box, not a
diagram, since that note landed. Same breakage on origin/main; noticed
while adding HistoryFilters to the same file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee (Sungwon)
2026-08-03 14:11:01 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 7861b755ac
commit c8ab5504f7
19 changed files with 703 additions and 140 deletions
+6 -2
View File
@@ -97,7 +97,8 @@ actually read (and which hot ones nobody maintains).
- **Change tracking** — `bdrive log` and the web UI's History view show
which account changed which file, when, from which device (name, OS).
Content is stored content-addressed, so every version is retained — view
or download any point in a file's history.
or download any point in a file's history. The feed can be narrowed by
path, author and date range, and the narrowed view has its own URL.
- **Cloud-provider agnostic** — a hub can store on Amazon S3 (`s3://`),
Google Cloud Storage (`gs://`), any S3-compatible store (MinIO, Cloudflare
R2 via `AWS_ENDPOINT_URL`), or a plain shared directory (`file://`, e.g. a
@@ -493,7 +494,10 @@ IP), with view/download of any past version (content is
content-addressed and retained forever; reverting to a version is the next
phase and the API is already shaped for it). Folder rows have a history
shortcut for a subtree feed; the topbar button shows the current file's
versions or the whole project feed.
versions or the whole project feed. A filter bar above the feed narrows it
by path substring, author and date range (UTC) — the filters ride in the
URL, so a narrowed feed is a link you can send, and they are applied
server-side, so paging through a filtered feed stays correct.
Hubs also track **read heat**: viewer opens and downloads count as human
reads, share-link hits as share reads, and agent tool reads (reported by
+6 -3
View File
@@ -33,8 +33,10 @@ classDiagram
+parseRoute(url, mode) Route
+Route.version ?v= sha, one past version
+Route.trailingSlash notes/ resolves, then replaces to notes
+Route.filters q user since until, history feed
+historyFilterQuery(filters) / hasHistoryFilters
+urlForPath(path, projectId, version)
+urlForView / encodePath / decodePath
+urlForView(view, projectId, target, filters) / encodePath / decodePath
}
class nav {
+navigate(url)
@@ -68,13 +70,14 @@ classDiagram
class components {
FileView FolderListing FileTree
HistoryView HistoryRow DiffView VersionBanner
HistoryView HistoryRow HistoryFilters DiffView VersionBanner
Insights ShareDialog NewProjectDialog
ShareBanner SharesTable AdminTable
OrgAdmin HubSettings ProjectSettings
Palette shell AccountBar ...
}
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; \"Empty project\" (value \"\") stays preselected so an unpicked create behaves exactly as it did before templates. modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; 'Empty project' (value: the empty string) stays preselected so an unpicked create behaves exactly as it did before templates. modal.tsx keeps its one-field API — teaching it about choices would tax every other caller"
note for components "HistoryFilters drives the SERVER (?q=/?user=/?since=/?until= on the history API), never the loaded page — filtering what is on screen would lie about everything below the fold and break next_cursor. Its state is Route.filters, so a narrowed feed is linkable, survives reload, and Back undoes it; the author list accumulates across fetches, because filtering by one author leaves only their rows loaded"
note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree"
class lib {
+3
View File
@@ -238,6 +238,9 @@ func seedE2E(t *testing.T, state, prefix, projectID string) {
put("archive/old-runbook.md", "# Old runbook\n\nStill read, never maintained.\n", 150*24*time.Hour)
put("archive/legacy-notes.md", "# Legacy notes\n\nStale, still consulted.\n", 95*24*time.Hour)
ops[2].Note = "expanded the guide — https://claude.ai/session/e2e" // the one row with a note expander
// A second account, so the history filter bar has more than one name to
// offer — and something to exclude when a reader picks one.
ops[4].User, ops[4].UserName, ops[4].Author = "bob@x.io", "Bob", "bob@x.io"
// One agent run that touched two files — the history feed groups it into
// a single card. One file it edited and one it created (whose undo is a
// removal, since restore cannot un-create). Both of these ops are the head
@@ -0,0 +1,107 @@
import { test, expect } from "@playwright/test";
import { login, wikiId } from "./helpers";
// BEA-67: History was a flat scroll with no way to narrow it. The filter bar
// drives the API (not the loaded page) and lives in the URL, so a narrowed
// feed is linkable, survives reload, and Back undoes it.
// Every row currently on screen, run cards included.
const rows = ".history .hentry";
test("the path filter narrows the feed and lands in the URL", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
await expect(page.locator(rows).first()).toBeVisible();
const before = await page.locator(rows).count();
expect(before).toBeGreaterThan(3);
await page.fill(".hfilters input[type=search]", "runbook");
await page.waitForURL(`/${pid}/history?q=runbook`);
// Every row matches, and there are strictly fewer of them. (Exact counts
// would be a lie: the suite shares one mutable hub, and earlier specs
// upload and restore into this feed.)
await expect(page.locator(`${rows} .hpath`).first()).toBeVisible();
const paths = await page.locator(`${rows} .hpath`).allTextContents();
expect(paths.length).toBeLessThan(before);
for (const p of paths) expect(p.toLowerCase()).toContain("runbook");
// A reload gets the same narrowed feed — the filter is not component state.
await page.reload();
await expect(page.locator(".hfilters input[type=search]")).toHaveValue("runbook");
await expect(page.locator(rows)).toHaveCount(paths.length);
// Back undoes the filter like any other navigation.
await page.goBack();
await expect(page).toHaveURL(`/${pid}/history`);
await expect(page.locator(rows)).toHaveCount(before);
});
test("the author filter offers the accounts in the feed and narrows to one", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history`);
const sel = page.locator(".hfilters select.hf-user");
await expect(sel.locator("option")).toContainText(["Anyone", "alice@x.io", "bob@x.io"]);
await sel.selectOption("bob@x.io");
await page.waitForURL(`/${pid}/history?user=bob%40x.io`);
// the seed gives bob exactly one change, and nothing else in the suite
// ever writes as him
await expect(page.locator(rows)).toHaveCount(1);
await expect(page.locator(`${rows} .hpath`)).toHaveText("notes/deep/topic.md");
// and the other author is still selectable — filtering by one must not
// strand the reader with a list rebuilt from their rows alone
await expect(sel.locator("option")).toContainText(["Anyone", "alice@x.io", "bob@x.io"]);
});
test("the date range filters server-side, and filters compose", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// Everything the seed writes is within the last few days, so a window that
// ends long ago must empty the feed rather than quietly return all of it.
await page.goto(`/${pid}/history?since=2020-01-01&until=2020-01-02`);
await expect(page.locator(rows)).toHaveCount(0);
await expect(page.locator(".history .empty")).toContainText("No changes match these filters.");
// Compose: an author who did write, plus a window that excludes everyone.
await page.goto(`/${pid}/history?user=alice%40x.io&until=2020-01-02`);
await expect(page.locator(rows)).toHaveCount(0);
await expect(page.locator(".hfilters select.hf-user")).toHaveValue("alice@x.io");
await expect(page.locator(".hf-date").nth(1)).toHaveValue("2020-01-02");
});
test("no match offers a way out, and Clear empties the query string", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
await page.goto(`/${pid}/history?q=no-such-file`);
await expect(page.locator(rows)).toHaveCount(0);
const empty = page.locator(".history .empty");
await expect(empty).toContainText("No changes match these filters.");
await empty.getByRole("button", { name: "Clear filters" }).click();
await page.waitForURL(`/${pid}/history`);
await expect(page.locator(rows).first()).toBeVisible();
// The bar's own Clear does the same, and only shows while something is set.
await expect(page.locator(".hf-clear")).toHaveCount(0);
await page.goto(`/${pid}/history?q=runbook`);
await page.locator(".hf-clear").click();
await page.waitForURL(`/${pid}/history`);
});
test("the folder feed and the per-file version list filter too", async ({ page }) => {
await login(page);
const pid = await wikiId(page);
// folder subtree: the filter composes with the prefix scoping
await page.goto(`/${pid}/history/notes?q=readme`);
await expect(page.locator(".hfilters")).toBeVisible();
for (const p of await page.locator(`${rows} .hpath`).allTextContents()) {
expect(p).toContain("notes/readme.md");
}
// per-file version list: guide.md has two versions, one of them Alice's
await page.goto(`/${pid}/history/guide.md`);
await expect(page.locator(rows).first()).toBeVisible();
expect(await page.locator(rows).count()).toBeGreaterThanOrEqual(2);
await page.goto(`/${pid}/history/guide.md?user=bob%40x.io`);
await expect(page.locator(rows)).toHaveCount(0);
});
@@ -368,6 +368,9 @@ export default function Browser(props: {
onRendered={onRendered}
restore={canRestore ? { onRestore, busy: restoring } : undefined}
remove={canRestore ? { onRemove, busy: removing } : undefined}
filters={route.filters}
/* push, not replace: a filter is a navigation, and Back undoes it */
onFilters={(f) => navigate(urlForView("history", project?.id, route.viewTarget || "", f))}
/>
);
} else if (path) {
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import { Input } from "@/components/ui/input";
import { Icon } from "./shell";
import { hasHistoryFilters, type HistoryFilters as Filters } from "../router";
/* ---- history filters ----
The whole feed is one flat scroll, and agents write far more than people
do — so a month-old project is unreadable without a way to narrow it.
Every filter is applied SERVER-side (?q=/?user=/?since=/?until=), never
over the loaded page: filtering what happens to be on screen would lie
about everything below the fold and break paging. State lives in the URL,
so a narrowed feed is linkable and Back undoes a filter like any other
navigation.
Dates are bare YYYY-MM-DD and the server reads them as UTC days — the
label says so, because a native date input speaks the reader's local
calendar and a silent conversion would quietly drop an evening's changes
for anyone east of UTC. */
export function HistoryFilters(props: {
filters?: Filters;
authors: string[]; // accounts seen in the loaded window
onChange: (f: Filters) => void;
}) {
const { filters, authors, onChange } = props;
const set = (k: keyof Filters, v: string) => onChange({ ...filters, [k]: v || undefined });
// The path box is typed into, so it keeps its own state and pushes a URL
// only once typing pauses — a navigation per keystroke would stack a
// history entry per letter and refetch on each one.
const [q, setQ] = useState(filters?.q ?? "");
const typed = useRef(false);
useEffect(() => {
if (!typed.current) setQ(filters?.q ?? ""); // external change (Back, Clear, deep link)
}, [filters?.q]);
useEffect(() => {
if (!typed.current) return;
const t = setTimeout(() => {
typed.current = false;
if (q !== (filters?.q ?? "")) set("q", q);
}, 250);
return () => clearTimeout(t);
}, [q]); // eslint-disable-line react-hooks/exhaustive-deps
// The URL is authoritative: an author filtered from a page that is no
// longer loaded still has to show as the current selection.
const options = filters?.user && !authors.includes(filters.user) ? [filters.user, ...authors] : authors;
const active = hasHistoryFilters(filters);
return (
<div className="hfilters">
<label className="hf-search">
<Icon name="search" />
<Input
type="search"
value={q}
placeholder="path contains…"
aria-label="Filter by path"
onChange={(e) => {
typed.current = true;
setQ(e.target.value);
}}
/>
</label>
<select
className="hf-user"
value={filters?.user ?? ""}
aria-label="Filter by author"
onChange={(e) => set("user", e.target.value)}
>
<option value="">Anyone</option>
{options.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
<span className="hf-dates">
<span className="hf-lbl">UTC</span>
<Input
type="date"
className="hf-date"
value={filters?.since ?? ""}
aria-label="From date (UTC)"
onChange={(e) => set("since", e.target.value)}
/>
<span className="hf-dash">–</span>
<Input
type="date"
className="hf-date"
value={filters?.until ?? ""}
aria-label="To date (UTC)"
onChange={(e) => set("until", e.target.value)}
/>
</span>
{active && (
<button type="button" className="hf-clear" onClick={() => onChange({})}>
Clear
</button>
)}
</div>
);
}
// The accounts present in a loaded feed, in first-seen order.
export function authorsOf(entries: { user?: string }[]): string[] {
const seen = new Set<string>();
for (const e of entries) if (e.user) seen.add(e.user);
return [...seen].sort();
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { getJSON } from "../api/http";
import type { HistoryEntry } from "../api/types";
@@ -6,6 +6,8 @@ import { HistoryRow, NoteText, type RemoveAction, type RestoreAction } from "./H
import { Icon } from "./shell";
import { whoChanged } from "../util";
import { groupRuns, runFileCount, type Run } from "../lib/runs";
import { HistoryFilters, authorsOf } from "./HistoryFilters";
import { historyFilterQuery, hasHistoryFilters, type HistoryFilters as Filters } from "../router";
/* ---- history ----
Every change ever made, straight from the journals: who (account), when,
@@ -28,17 +30,22 @@ export function HistoryView(props: {
onRendered?: () => void;
restore?: RestoreAction;
remove?: RemoveAction;
// Reader filters, straight from the URL. Applied server-side, so they
// narrow the whole feed and not just the loaded page.
filters?: Filters;
onFilters?: (f: Filters) => void;
}) {
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove } = props;
const { apiBase, target, isFolder, onMeta, onRendered, restore, remove, filters } = props;
const q = !target
? { prefix: "" }
: isFolder(target)
? { prefix: target + "/" }
: { path: target };
const qs =
"path" in q && q.path !== undefined
("path" in q && q.path !== undefined
? "path=" + encodeURIComponent(q.path)
: "prefix=" + encodeURIComponent(q.prefix ?? "");
: "prefix=" + encodeURIComponent(q.prefix ?? "")) +
historyFilterQuery(filters).replace("?", "&");
// Paged: the server hands back a cursor while entries remain, so a project
// with thousands of changes is reachable to its first one. Pages accumulate
// into one array — groupRuns and prevBlob both work over the whole window,
@@ -59,6 +66,8 @@ export function HistoryView(props: {
staleTime: 15_000,
});
const seenAuthors = useRef(new Set<string>());
useEffect(() => {
if (error) onMeta("History unavailable: " + (error as Error).message);
}, [error, onMeta]);
@@ -66,8 +75,20 @@ export function HistoryView(props: {
if (data) onRendered?.();
}, [data, onRendered]);
if (!data) return null;
const entries = data.pages.flatMap((p) => p.entries || []);
const entries = data ? data.pages.flatMap((p) => p.entries || []) : [];
// The author list accumulates and never shrinks: filtering BY an author
// leaves only their rows loaded, so a list rebuilt from the current feed
// would drop every other name and strand the reader on "Anyone" as the
// only way out.
for (const a of authorsOf(entries)) seenAuthors.current.add(a);
const bar = props.onFilters && (
<HistoryFilters
filters={filters}
authors={[...seenAuthors.current].sort()}
onChange={props.onFilters}
/>
);
if (!data) return bar ? <div className="history">{bar}</div> : null;
// Diffs are a per-file affair: the subtree feed mixes paths, and each row
// there would need its own predecessor lookup for no review benefit.
const perFile = !!target && !isFolder(target);
@@ -103,7 +124,19 @@ export function HistoryView(props: {
};
return (
<div className="history">
{entries.length === 0 && <div className="empty">No history yet.</div>}
{bar}
{entries.length === 0 &&
(hasHistoryFilters(filters) ? (
<div className="empty">
No changes match these filters.
<br />
<button type="button" className="btn hf-clear-empty" onClick={() => props.onFilters?.({})}>
Clear filters
</button>
</div>
) : (
<div className="empty">No history yet.</div>
))}
{groupRuns(entries).map((item, n) =>
item.run ? (
<RunGroup
+29 -1
View File
@@ -1,7 +1,7 @@
// Run with `npm test` (node's built-in runner; node ≥ 23 strips the types).
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseRoute } from "./router.ts";
import { parseRoute, urlForView, historyFilterQuery } from "./router.ts";
// A trailing slash is what a browser hands you when you copy a folder URL,
// so /notes/ has to be the same page as /notes.
@@ -68,3 +68,31 @@ test("odd characters round-trip", () => {
assert.equal(r.path, "notes/a b");
assert.equal(r.trailingSlash, true);
});
// History filters live in the URL, so a narrowed feed is linkable and comes
// back on reload and Back. Round-trip: parse → build → same URL.
test("history filters round-trip through the URL", () => {
const r = parseRoute("/p-1/history/notes?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31", "hub");
assert.equal(r.view, "history");
assert.equal(r.viewTarget, "notes");
assert.deepEqual(r.filters, {
q: "runbook",
user: "mira@acme.io",
since: "2026-07-01",
until: "2026-07-31",
});
assert.equal(
urlForView("history", "p-1", "notes", r.filters),
"/p-1/history/notes?q=runbook&user=mira%40acme.io&since=2026-07-01&until=2026-07-31",
);
});
// An unfiltered feed keeps the bare URL it has always had — no empty ?q=.
test("no filters means no query string", () => {
assert.equal(parseRoute("/p-1/history", "hub").filters, undefined);
assert.equal(historyFilterQuery(undefined), "");
assert.equal(historyFilterQuery({ q: "" }), "");
assert.equal(urlForView("history", "p-1"), "/p-1/history");
// other views ignore them entirely
assert.equal(urlForView("dashboard", "p-1", "", { q: "x" }), "/p-1/dashboard");
});
+43 -3
View File
@@ -32,6 +32,31 @@ const LEGACY_VIEWS: Record<string, ViewName> = { insights: "dashboard" };
export type ViewName = "dashboard" | "history" | "install" | "settings";
// How a reader has narrowed the history feed. Server-side filters, so they
// ride to the API verbatim — and they live in the URL rather than component
// state so a filtered feed is linkable, survives reload, and comes back on
// Back like any other page (see CLAUDE.md: every surface owns a URL).
export interface HistoryFilters {
q?: string; // substring of the path
user?: string; // exact account
since?: string; // YYYY-MM-DD (UTC), inclusive
until?: string; // YYYY-MM-DD (UTC), inclusive
}
export const HISTORY_FILTER_KEYS = ["q", "user", "since", "until"] as const;
export function hasHistoryFilters(f?: HistoryFilters): boolean {
return !!f && HISTORY_FILTER_KEYS.some((k) => !!f[k]);
}
// The query string for a filter set — "" when nothing is set, so an
// unfiltered view keeps the bare URL it has always had.
export function historyFilterQuery(f?: HistoryFilters): string {
const p = new URLSearchParams();
for (const k of HISTORY_FILTER_KEYS) if (f?.[k]) p.set(k, f[k]!);
const s = p.toString();
return s ? "?" + s : "";
}
export interface Route {
// Org administration is not project-scoped, so it is a top-level route
// rather than a view under a project. The server hands out this URL (see
@@ -63,6 +88,9 @@ export interface Route {
// not a property of the project — a teammate connecting next week has their
// own answer, and would be told the wrong thing by a persisted flag.
connect?: string;
// History feed filters (?q=&user=&since=&until=). Only ever set on the
// history view; absent when nothing is filtered.
filters?: HistoryFilters;
}
// `url` is pathname + search (what useLocationPath hands back).
@@ -74,6 +102,12 @@ export function parseRoute(url: string, mode: "volume" | "hub"): Route {
const r = parsePath(qi === -1 ? url : url.slice(0, qi), mode);
if (version) r.version = version;
if (connect) r.connect = connect;
const filters: HistoryFilters = {};
for (const k of HISTORY_FILTER_KEYS) {
const v = q?.get(k);
if (v) filters[k] = v;
}
if (hasHistoryFilters(filters)) r.filters = filters;
return r;
}
@@ -120,9 +154,15 @@ export function urlForPath(path: string, projectId?: string, version?: string):
return "/" + enc + q;
}
// The URL for a special view of a project.
export function urlForView(view: ViewName, projectId?: string, target?: string): string {
// The URL for a special view of a project, optionally carrying the history
// filter set (ignored by every other view).
export function urlForView(
view: ViewName,
projectId?: string,
target?: string,
filters?: HistoryFilters,
): string {
let s = (projectId ? "/" + projectId : "") + "/" + view;
if (target) s += "/" + encodePath(target.replace(/\/+$/, ""));
return s;
return s + (view === "history" ? historyFilterQuery(filters) : "");
}
+22
View File
@@ -668,6 +668,23 @@ a.ai-main:hover { color: var(--accent); }
.in-matrix rect:hover { opacity: .85; }
/* ---- history ---- */
/* The filter bar. Wraps rather than scrolls: every control has to stay
reachable on a phone, and a horizontally scrolled toolbar hides the
Clear that gets you back out. */
.hfilters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 0 12px; border-bottom: 1px solid var(--border); margin-bottom: 4px; }
.hf-search { position: relative; display: flex; align-items: center; flex: 1 1 200px; min-width: 160px; }
.hf-search .ico { position: absolute; left: 9px; width: 14px; height: 14px; color: var(--text-ghost); pointer-events: none; }
.hf-search input { height: 30px; padding-left: 29px; font-size: 12.5px; border-radius: var(--r-ctl); background: var(--surface); }
.hf-search input::-webkit-search-cancel-button { filter: invert(.6); }
.hf-user { height: 30px; max-width: 190px; padding: 0 8px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 12.5px; cursor: pointer; }
.hf-dates { display: flex; align-items: center; gap: 6px; }
.hf-lbl { font-size: 10px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-ghost); }
.hf-date { width: 140px; height: 30px; font-size: 12.5px; border-radius: var(--r-ctl); background: var(--surface); }
.hf-date::-webkit-calendar-picker-indicator { filter: invert(.6); cursor: pointer; }
.hf-dash { color: var(--text-ghost); }
.hf-clear { height: 30px; padding: 0 10px; border: none; border-radius: var(--r-ctl); background: none; color: var(--text-dim); font: inherit; font-size: 12.5px; cursor: pointer; }
.hf-clear:hover { color: var(--text); background: var(--hover); }
.hf-clear-empty { margin-top: 12px; }
/* .history width comes from .page (app) */
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); --hindent: 72px; }
.hentry:hover { background: rgba(255,255,255,.015); }
@@ -858,6 +875,11 @@ a.ai-main:hover { color: var(--accent); }
wrap so the controls keep room next to long names/URLs. */
.admin-item { flex-wrap: wrap; row-gap: 8px; padding: 12px 14px; }
.admin-item select { height: 44px; }
/* 30px controls are too small to tap; the bar already wraps, so growing
them costs nothing but a second row. */
.hf-search input, .hf-user, .hf-date, .hf-clear { height: 44px; }
.hf-dates { flex: 1 1 100%; }
.hf-date { flex: 1; width: auto; min-width: 0; }
.ai-btn, .ai-del { height: auto; min-height: 44px; padding: 0 12px; }
/* react-table renders rows as `display: table-row`, which makes every
flex rule above inert and lets the last column (Remove) fall outside
+56
View File
@@ -96,10 +96,38 @@ func decodeCursor(s string) (journal.Op, error) {
return journal.Op{Time: time.Unix(0, c.T).UTC(), Lamport: c.L, Seq: c.S, Device: c.D}, nil
}
// parseHistTime accepts RFC3339 or a bare YYYY-MM-DD (UTC). A bare date is
// the start of that day; `end` bumps it by 24h so ?until=<day> includes the
// whole day — the bound is then exclusive on both parses, which is what an
// inclusive "until this date" means once you stop thinking in instants.
func parseHistTime(s string, end bool) (time.Time, bool) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
if end {
t = t.Add(time.Nanosecond) // RFC3339 bound is inclusive to the second
}
return t.UTC(), true
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false
}
if end {
t = t.AddDate(0, 0, 1)
}
return t.UTC(), true
}
// handleHistory serves ?path=<file> (one file's versions) or
// ?prefix=<folder/> (everything underneath, "" = the whole project),
// newest first by wall-clock time, at most ?n= entries (default 100).
//
// Reader filters — ?q= (case-insensitive substring of the path), ?user=
// (exact account), ?since=/?until= (UTC bounds, inclusive at both ends) —
// compose with each other and with path/prefix. They are applied in the same
// walk as path/prefix, i.e. BEFORE the sort and the cursor skip, so
// next_cursor keeps meaning "the next matching entry" and paging under a
// filter needs no new machinery.
//
// Paging: the response carries next_cursor when more entries exist, and
// ?cursor= resumes just past the entry it was minted from — so history older
// than one page is reachable, and the UI can tell whether it is hiding
@@ -129,6 +157,26 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
return
}
}
needle := strings.ToLower(q.Get("q")) // lowered once, not per op
user := q.Get("user")
var since, until time.Time
// since > until is not an error: it means "nothing", which is what it returns.
for _, b := range []struct {
name string
end bool
into *time.Time
}{{"since", false, &since}, {"until", true, &until}} {
raw := q.Get(b.name)
if raw == "" {
continue
}
t, ok := parseHistTime(raw, b.end)
if !ok {
http.Error(w, "invalid "+b.name, http.StatusBadRequest)
return
}
*b.into = t
}
all, err := rs.loadOps(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
@@ -164,6 +212,14 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
continue
case path == "" && prefix != "" && !strings.HasPrefix(op.Path, strings.TrimSuffix(prefix, "/")+"/"):
continue
case needle != "" && !strings.Contains(strings.ToLower(op.Path), needle):
continue
case user != "" && op.User != user:
continue
case !since.IsZero() && op.Time.Before(since):
continue
case !until.IsZero() && !op.Time.Before(until):
continue
}
// Unregistered device (or volume mode, where Devices is nil): fall back
// to the op's own id + self-reported name.
+149
View File
@@ -54,6 +54,24 @@ func (f *fakeRemote) putAt(dev, path, content string, at time.Time) {
writeFileT(f.t, p, data)
}
// putFull writes a put carrying both an account and a wall-clock time — what
// the filter tests need to slice a feed by author and by day at once.
func (f *fakeRemote) putFull(dev, user, path, content string, at time.Time) {
f.t.Helper()
f.putAs(dev, user, strings.ToUpper(user[:1])+user[1:], path, content)
p := filepath.Join(f.dir, "journal", dev+".jsonl")
ops, err := journal.ReadFile(p)
if err != nil || len(ops) == 0 {
f.t.Fatal(err)
}
ops[len(ops)-1].Time = at
data, err := journal.Marshal(ops)
if err != nil {
f.t.Fatal(err)
}
writeFileT(f.t, p, data)
}
func writeFileT(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o644); err != nil {
@@ -400,6 +418,137 @@ func TestHistoryPagingAcrossLamportAndTime(t *testing.T) {
}
}
// seedFiltered builds a feed with two authors, three days and paths that
// differ in case, so every filter has something to include and something to
// leave out.
func seedFiltered(t *testing.T) (http.Handler, string) {
t.Helper()
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
day := func(d, h int) time.Time { return time.Date(2026, 7, d, h, 30, 0, 0, time.UTC) }
f.putFull("dev1", "mira@acme.io", "docs/Runbook.md", "v1", day(1, 9))
f.putFull("dev1", "mira@acme.io", "docs/runbook-old.md", "v1", day(15, 12))
f.putFull("dev2", "ken@acme.io", "docs/plan.md", "p1", day(31, 23))
f.putFull("dev2", "ken@acme.io", "notes/runbook.md", "n1", day(15, 0))
return srv.Handler(), "/api/p/" + p.ID + "/"
}
// histPaths runs one history request and returns the paths it yielded, in
// order, plus the next cursor.
func histPaths(t *testing.T, h http.Handler, u string) ([]string, string) {
t.Helper()
rec := do(t, h, "GET", u, nil)
if rec.Code != 200 {
t.Fatalf("history %s: %d %s", u, rec.Code, rec.Body)
}
var out struct {
Entries []HistoryEntry `json:"entries"`
Next string `json:"next_cursor"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
got := []string{}
for _, e := range out.Entries {
got = append(got, e.Path)
}
return got, out.Next
}
// The reader filters: substring, author, date window, and every combination
// of them — including with the existing prefix scoping.
func TestHistoryFilters(t *testing.T) {
h, base := seedFiltered(t)
for _, c := range []struct {
q string
want []string
}{
// substring, case-insensitive, matching a path the prefix scoping couldn't express
{"q=runbook", []string{"docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
{"q=RUNBOOK", []string{"docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
{"q=.md", []string{"docs/plan.md", "docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
{"q=nothing-here", []string{}},
// author, exact
{"user=mira@acme.io", []string{"docs/runbook-old.md", "docs/Runbook.md"}},
{"user=ken@acme.io", []string{"docs/plan.md", "notes/runbook.md"}},
{"user=nobody@acme.io", []string{}},
// bare dates are UTC days, inclusive at BOTH ends: the 1st 09:30 and
// the 31st 23:30 both survive a 07-01..07-31 window.
{"since=2026-07-01&until=2026-07-31", []string{"docs/plan.md", "docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
{"since=2026-07-02", []string{"docs/plan.md", "docs/runbook-old.md", "notes/runbook.md"}},
{"until=2026-07-15", []string{"docs/runbook-old.md", "notes/runbook.md", "docs/Runbook.md"}},
{"since=2026-07-15&until=2026-07-15", []string{"docs/runbook-old.md", "notes/runbook.md"}},
// an RFC3339 bound is inclusive to the second it names
{"since=2026-07-31T23:30:00Z", []string{"docs/plan.md"}},
{"until=2026-07-01T09:30:00Z", []string{"docs/Runbook.md"}},
// since > until means nothing, not an error
{"since=2026-07-31&until=2026-07-01", []string{}},
// composed with each other…
{"q=runbook&user=mira@acme.io", []string{"docs/runbook-old.md", "docs/Runbook.md"}},
{"q=runbook&user=mira@acme.io&since=2026-07-10&until=2026-07-20", []string{"docs/runbook-old.md"}},
// …and with the existing prefix/path scoping
{"prefix=docs/&q=runbook", []string{"docs/runbook-old.md", "docs/Runbook.md"}},
{"prefix=notes/&user=mira@acme.io", []string{}},
{"path=docs/Runbook.md&q=runbook", []string{"docs/Runbook.md"}},
{"path=docs/Runbook.md&user=ken@acme.io", []string{}},
} {
if got, _ := histPaths(t, h, base+"history?"+c.q); !slices.Equal(got, c.want) {
t.Errorf("?%s = %v, want %v", c.q, got, c.want)
}
}
}
// Filtering happens before the cursor skip, so paging a filtered feed walks
// exactly the unpaged filtered set — no repeats, no gaps, same order. Filter
// after the skip and this test loses entries.
func TestHistoryFilterPaging(t *testing.T) {
h, base := seedFiltered(t)
filter := "q=runbook"
want, next := histPaths(t, h, base+"history?"+filter)
if len(want) != 3 || next != "" {
t.Fatalf("unpaged filtered feed = %v (next %q)", want, next)
}
var got []string
cursor := ""
for pages := 0; ; pages++ {
if pages > len(want) {
t.Fatalf("paging did not terminate: %v", got)
}
page, next := histPaths(t, h, base+"history?"+filter+"&n=2"+cursorArg(cursor))
if len(page) == 0 {
t.Fatalf("empty page %d", pages)
}
got = append(got, page...)
if next == "" {
break
}
cursor = next
}
if !slices.Equal(got, want) {
t.Fatalf("paged filtered = %v, want %v", got, want)
}
}
// A date we can't parse is a 400, not a silently unfiltered feed.
func TestHistoryBadDateRange(t *testing.T) {
h, base := seedFiltered(t)
for _, q := range []string{"since=yesterday", "until=2026-13-45", "since=2026-07-01&until=soon", "since="} {
rec := do(t, h, "GET", base+"history?"+q, nil)
if q == "since=" { // an empty value is "no filter", like every other param
if rec.Code != 200 {
t.Errorf("?%s = %d, want 200", q, rec.Code)
}
continue
}
if rec.Code != http.StatusBadRequest {
t.Errorf("?%s = %d, want 400", q, rec.Code)
}
if strings.Contains(rec.Body.String(), "\"entries\"") {
t.Errorf("?%s returned a feed body: %s", q, rec.Body)
}
}
}
// BenchmarkHistoryPage measures what a deep page costs: every request
// re-lists and re-parses every journal (loadOps), so page 20 should cost
// about what page 1 costs — the ceiling is gone, the per-page work is not.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
<script type="module" crossorigin src="/assets/index-C2ypx3jZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CO632k9V.css">
<script type="module" crossorigin src="/assets/index-B9U5WcW1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cm-vCJRy.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -57,7 +57,7 @@ page.
any object store suffices.
- **Change tracking** — `bdrive log` and the web UI's History view show which
account changed which file, when, from which device. Every version is
retained.
retained, and the feed narrows by path, author and date range.
- **Read analytics** — the hub records what humans, share links, and *agents*
actually read, and surfaces the knowledge nobody maintains.
- **Cloud-provider agnostic** — a hub stores on Amazon S3, Google Cloud Storage,
@@ -106,6 +106,13 @@ versions in between stay in the history, the restore itself shows up in
teammate like any other edit — so you can restore away from a restore. The hub
has the same button on every history row.
The hub's History view narrows the feed by path substring, author and date
range (dates are UTC days, inclusive at both ends). The filters live in the
URL — `<project>/history?q=runbook&user=mira@acme.io&since=2026-07-01&until=2026-07-31`
— so a narrowed feed is a link you can send, and it survives reload and Back.
Filtering happens on the server, so paging through a filtered feed shows every
match, not just the ones on the first page.
**Restore puts content back; it does not delete.** To un-create a file an agent
run *created*, open that run in the hub's History view and use the row's
**undo — remove file** button (it asks first: the file leaves every synced