Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions architecture/webapp-frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/webapp/e2e_serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions internal/webapp/frontend/e2e/history-filters.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
3 changes: 3 additions & 0 deletions internal/webapp/frontend/src/apps/Browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
108 changes: 108 additions & 0 deletions internal/webapp/frontend/src/components/HistoryFilters.tsx
Original file line number Diff line number Diff line change
@@ -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();
}
47 changes: 40 additions & 7 deletions internal/webapp/frontend/src/components/HistoryView.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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";
import { HistoryRow, NoteText, type RemoveAction, type RestoreAction } from "./HistoryRow";
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,
Expand All @@ -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,
Expand All @@ -59,15 +66,29 @@ 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]);
useEffect(() => {
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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading