fix(history): page the History API and view so old changes are reachable (BEA-46) (#99)

* fix(history): page the API so old changes stop being unreachable (BEA-46)

GET /api/p/<id>/history capped at ?n= and said nothing about what it was
hiding, so every change older than the cap was unreachable — a project's
early history sat in the journals and blob store with no way to display it.

The display order used to come from two mechanisms: a stable time sort over
a slice built in reverse-journal order, so the tie-break was implicit in the
construction and a cursor could not re-derive it. histLess makes it one
function — newest wall-clock first, ties in reverse journal.Less — used for
both the sort and the skip-past-cursor step, so paging cannot disagree with
the feed. The cursor is server-minted and opaque because it has to be:
HistoryEntry.time is formatted to whole seconds and carries no lamport/seq,
so a client-computed cursor would be lossy across same-second ops.

?n= alone returns exactly the entries it always did (the tie-break IS
reverse-Less); it just gains a next_cursor key when more exist. A cursor is
a position in an ordering, not a snapshot: an offline device pushing
mid-scroll lands ops mid-feed by timestamp and the reader sees them on
refresh — pinning would mean server state for the life of a scroll.

BenchmarkHistoryPage over 5000 ops: page 1 14.4ms, page 20 15.8ms — every
page re-lists and re-parses the journals, so the ceiling is gone but the
per-page work is not. No cache needed at this scale.

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

* fix(history): follow the cursor in the History view, with a Load more (BEA-46)

The view hard-coded n=200 and rendered whatever came back, so a project past
200 changes showed a list that simply stopped. useInfiniteQuery now follows
next_cursor at 100 a page, and the foot of the list says "Load more" while
older changes exist — a button, not an IntersectionObserver, so it is
keyboard-reachable and states out loud that there is more.

Pages accumulate into one array, which is what makes the rest free:
groupRuns already groups across the whole window (a run straddling a page
boundary becomes one card when its second page lands — verified live: 7
files on page 1, 12 after Load more) and prevBlob already returns undefined
past the end, so the oldest loaded row shows no diff base rather than
diffing against the wrong predecessor.

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-07-31 18:09:30 +09:00
committed by GitHub
co-authored by Claude Opus 5
parent 9a52aed3bb
commit 820978cd76
10 changed files with 332 additions and 50 deletions
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
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";
@@ -39,9 +39,23 @@ export function HistoryView(props: {
"path" in q && q.path !== undefined
? "path=" + encodeURIComponent(q.path)
: "prefix=" + encodeURIComponent(q.prefix ?? "");
const { data, error } = useQuery({
queryKey: ["history", apiBase, qs, 200],
queryFn: () => getJSON<{ entries: HistoryEntry[] }>(apiBase + "history?" + qs + "&n=200"),
// 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,
// so a run straddling a page boundary becomes one card when its second page
// lands, and the oldest loaded row shows no diff base rather than a wrong one.
const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["history", apiBase, qs],
queryFn: ({ pageParam }) =>
getJSON<{ entries: HistoryEntry[]; next_cursor?: string }>(
apiBase +
"history?" +
qs +
"&n=100" +
(pageParam ? "&cursor=" + encodeURIComponent(pageParam) : ""),
),
initialPageParam: "",
getNextPageParam: (last) => last.next_cursor,
staleTime: 15_000,
});
@@ -53,7 +67,7 @@ export function HistoryView(props: {
}, [data, onRendered]);
if (!data) return null;
const entries = data.entries || [];
const entries = data.pages.flatMap((p) => p.entries || []);
// 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);
@@ -101,6 +115,19 @@ export function HistoryView(props: {
/>
),
)}
{/* A button, not an IntersectionObserver: keyboard-reachable, and it
says out loud that there is more rather than hiding it behind a
scroll gesture. */}
{hasNextPage && (
<button
type="button"
className="btn hmore"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading…" : "Load more"}
</button>
)}
</div>
);
}
@@ -113,3 +113,19 @@ test("note-less changes stay bare rows, cards sit where their newest op did", ()
assert.equal(items[1].run?.entries.length, 2);
assert.deepEqual(items[2], { i: 2 });
});
// Pagination leans on this: the History view accumulates pages into one
// array, so a run whose ops straddle a page boundary must become ONE card
// when the next page lands — and a run that looked single-file on page 1
// must grow into a card, not sit beside a second copy of itself.
test("a run split across two pages groups into one card", () => {
const note = "session-1";
const page1 = [e("plain.md"), e("a.md", note)];
const page2 = [e("b.md", note), e("c.md", note)];
assert.deepEqual(groupRuns(page1), [{ i: 0 }, { i: 1 }]); // page 1 alone: bare rows
const items = groupRuns(page1.concat(page2));
assert.equal(items.length, 2);
assert.deepEqual(items[0], { i: 0 });
assert.deepEqual(items[1].run?.entries.map((x) => x.path), ["a.md", "b.md", "c.md"]);
assert.deepEqual(items[1].run?.idx, [1, 2, 3]); // idx still addresses the flat feed
});
+4
View File
@@ -658,6 +658,10 @@ a.ai-main:hover { color: var(--accent); }
.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; }
/* Foot of a paged feed: says out loud that older changes exist, which is
the whole point — the list used to just stop. */
.hmore { display: flex; margin: 14px auto; }
.hmore:disabled { opacity: .6; cursor: default; }
.hmeta { display: flex; align-items: center; gap: 14px; margin-top: 4px; padding-left: var(--hindent); font-size: 12px; color: var(--text-dim); }
.hdev, .hsize { color: var(--text-faint); }
.hsize { font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; }
+89 -11
View File
@@ -1,6 +1,8 @@
package webapp
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
@@ -44,9 +46,70 @@ type HistoryEntry struct {
Note string `json:"note,omitempty"`
}
// histLess is the display order of the history feed: newest wall-clock time
// first, ties in reverse journal order. Journal order is causal, not
// chronological (see journal.Less) — this is the one place that reconciles
// the two, and the cursor skips with the same function the feed sorts with,
// so paging can never disagree with what a reader sees. journal.Less is a
// total order and (device, seq) is unique per op, so histLess is total too:
// no stable sort needed, and equal timestamps come back in the same order
// on every request.
func histLess(a, b journal.Op) bool {
if !a.Time.Equal(b.Time) {
return a.Time.After(b.Time)
}
return journal.Less(b, a)
}
// histCursor is the ordering tuple of the last entry of a page — everything
// histLess reads, and nothing else. It rides the wire base64'd so a client
// treats it as opaque: HistoryEntry.time is formatted to whole seconds and
// carries no lamport/seq, so a client-computed cursor would be lossy across
// same-second ops.
type histCursor struct {
T int64 `json:"t"` // op time, unix nanoseconds
L int64 `json:"l"` // lamport
S int64 `json:"s"` // per-device seq
D string `json:"d"` // device
}
func encodeCursor(op journal.Op) string {
b, err := json.Marshal(histCursor{T: op.Time.UnixNano(), L: op.Lamport, S: op.Seq, D: op.Device})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(b)
}
// decodeCursor rebuilds the four ordering fields into a bare Op — JSON
// rather than a delimited string, so a device id can never collide with a
// separator.
func decodeCursor(s string) (journal.Op, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return journal.Op{}, err
}
var c histCursor
if err := json.Unmarshal(raw, &c); err != nil {
return journal.Op{}, err
}
return journal.Op{Time: time.Unix(0, c.T).UTC(), Lamport: c.L, Seq: c.S, Device: c.D}, nil
}
// 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).
//
// 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
// anything. ?n= alone returns exactly the entries it always did.
//
// A cursor is a position in an ordering, not a snapshot: an offline device
// that pushes mid-scroll lands ops in the middle of the feed by timestamp,
// and the reader sees them on refresh rather than mid-page. Deliberate —
// pinning the feed to a read time means server-side state for the life of a
// scroll.
func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request) {
rs := storeSource(v, w)
if rs == nil {
@@ -92,11 +155,10 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
}
type timed struct {
entry HistoryEntry
at time.Time
op journal.Op
}
matched := make([]timed, 0, len(all))
for i := len(all) - 1; i >= 0; i-- { // descending journal (Lamport) order
op := all[i]
for i, op := range all {
switch {
case path != "" && op.Path != path:
continue
@@ -114,23 +176,39 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
Path: op.Path, Size: op.Size, Blob: op.Blob,
User: op.User, UserName: op.UserName, Author: op.Author,
Device: dev, Note: op.Note,
}, op.Time})
}, op})
}
// Journal order is causal, not chronological: a device that was offline can
// write at a later wall-clock time yet carry a lower Lamport clock, so
// reverse-journal order is not "newest first". Sort the response by time,
// and truncate to ?n= AFTER that — truncating during the walk above would
// Truncation happens AFTER the sort: cutting during the walk above would
// pick the n highest-Lamport entries and merely display them in time order.
// Stable + strict After keeps equal timestamps in descending-Lamport order.
sort.SliceStable(matched, func(a, b int) bool { return matched[a].at.After(matched[b].at) })
sort.Slice(matched, func(a, b int) bool { return histLess(matched[a].op, matched[b].op) })
if raw := q.Get("cursor"); raw != "" {
cur, err := decodeCursor(raw)
if err != nil {
http.Error(w, "invalid cursor", http.StatusBadRequest)
return
}
i := 0
for i < len(matched) && !histLess(cur, matched[i].op) { // skip to just past it
i++
}
matched = matched[i:]
}
// Exact, with no over-fetch probe: every op is already in memory, so
// "there is more" is a length check and the last page simply omits the key.
var next string
if len(matched) > n {
next = encodeCursor(matched[n-1].op)
matched = matched[:n]
}
entries := make([]HistoryEntry, len(matched))
for i, m := range matched {
entries[i] = m.entry
}
writeJSON(w, map[string]any{"entries": entries})
out := map[string]any{"entries": entries}
if next != "" {
out["next_cursor"] = next
}
writeJSON(w, out)
}
// handleBlob streams one exact version by content hash — view or download
+157
View File
@@ -2,8 +2,10 @@ package webapp
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
@@ -302,6 +304,161 @@ func TestHistoryOrderedByTimeNotLamport(t *testing.T) {
}
}
// Paging walks the same order the feed displays: each entry exactly once, in
// time order, across boundaries that fall between ops whose Lamport order and
// wall-clock order disagree — and between two ops sharing one timestamp,
// which the whole-second `time` field could not have expressed, so only a
// server-minted cursor can resume there.
func TestHistoryPagingAcrossLamportAndTime(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
early := time.Date(2026, 7, 26, 0, 9, 17, 0, time.UTC)
late := time.Date(2026, 7, 26, 22, 9, 17, 0, time.UTC)
f.putAt("offline", "notes/late.md", "written offline", late) // lamport 1, newest by the clock
f.putAt("online", "notes/a.md", "a", early) // lamport 2..4, one shared timestamp
f.putAt("online", "notes/b.md", "b", early)
f.putAt("online", "notes/c.md", "c", early)
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
// page returns one response's paths (path@time) and its next cursor.
page := func(u string) ([]string, string) {
t.Helper()
rec := do(t, h, "GET", u, nil)
if rec.Code != 200 {
t.Fatalf("history: %d %s", 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)
}
var got []string
for _, e := range out.Entries {
got = append(got, e.Path+"@"+e.Time)
}
return got, out.Next
}
want := []string{
"notes/late.md@2026-07-26T22:09:17Z",
"notes/c.md@2026-07-26T00:09:17Z",
"notes/b.md@2026-07-26T00:09:17Z",
"notes/a.md@2026-07-26T00:09:17Z",
}
// one entry at a time, following the cursor to the end
var got []string
cursor, pages := "", 0
for {
entries, next := page(base + "history?n=1" + cursorArg(cursor))
if len(entries) != 1 {
t.Fatalf("page %d = %v, want exactly 1 entry", pages, entries)
}
got = append(got, entries...)
pages++
if pages > len(want)+2 {
t.Fatalf("paging did not terminate: %v", got)
}
if next == "" {
break
}
cursor = next
}
if !slices.Equal(got, want) {
t.Fatalf("paged = %v, want %v (each entry once, in time order)", got, want)
}
if pages != len(want) {
t.Fatalf("pages = %d, want %d", pages, len(want))
}
// mid-list cursors: page 2 of 2 picks up exactly where page 1 stopped
first, next := page(base + "history?n=2")
if !slices.Equal(first, want[:2]) || next == "" {
t.Fatalf("page 1 = %v (next %q)", first, next)
}
second, next := page(base + "history?n=2&cursor=" + url.QueryEscape(next))
if !slices.Equal(second, want[2:]) {
t.Fatalf("page 2 = %v, want %v", second, want[2:])
}
if next != "" {
t.Fatalf("last page carries next_cursor %q", next)
}
// a request that fits in one page never claims there is more
if all, next := page(base + "history?n=100"); !slices.Equal(all, want) || next != "" {
t.Fatalf("single page = %v (next %q)", all, next)
}
// the prefix feed pages the same way
if got, _ := page(base + "history?prefix=notes/&n=2"); !slices.Equal(got, want[:2]) {
t.Fatalf("prefix page 1 = %v", got)
}
// a garbage cursor is an error, not a silent full page
if rec := do(t, h, "GET", base+"history?cursor=not-a-cursor", nil); rec.Code != http.StatusBadRequest {
t.Fatalf("bad cursor: %d, want 400", rec.Code)
}
}
// 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.
func BenchmarkHistoryPage(b *testing.B) {
srv, p, root := newHub(b, false, nil)
dir := filepath.Join(root, p.ID)
os.MkdirAll(filepath.Join(dir, "journal"), 0o755)
os.MkdirAll(filepath.Join(dir, "blobs"), 0o755)
now := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
ops := make([]journal.Op, 0, 5000)
for i := range 5000 {
ops = append(ops, journal.Op{
Seq: int64(i + 1), Lamport: int64(i + 1),
Time: now.Add(-time.Duration(i) * time.Minute), Device: "bench",
Kind: journal.KindPut, Path: fmt.Sprintf("docs/%03d.md", i%50),
Blob: strings.Repeat("a", 64), Size: 12, Mode: 0o644,
})
}
if err := journal.Append(filepath.Join(dir, "journal", "bench.jsonl"), ops); err != nil {
b.Fatal(err)
}
h := srv.Handler()
base := "/api/p/" + p.ID + "/history?n=100"
get := func(u string) string {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", u, nil))
if rec.Code != 200 {
b.Fatalf("history: %d %s", rec.Code, rec.Body)
}
var out struct {
Next string `json:"next_cursor"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
return out.Next
}
// the cursor that opens page 20, paid for once outside the timed loop
deep := ""
for range 19 {
deep = get(base + cursorArg(deep))
}
b.Run("page1", func(b *testing.B) {
for b.Loop() {
get(base)
}
})
b.Run("page20", func(b *testing.B) {
for b.Loop() {
get(base + cursorArg(deep))
}
})
}
func cursorArg(c string) string {
if c == "" {
return ""
}
return "&cursor=" + url.QueryEscape(c)
}
func TestDeviceRegistryObserve(t *testing.T) {
path := filepath.Join(t.TempDir(), "devices.json")
r, err := OpenDeviceRegistry(path)
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-gtPH5RQg.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qQY6rBYF.css">
<script type="module" crossorigin src="/assets/index-B02ab_uT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-tRbQn818.css">
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
// newHub builds a hub server over a fresh storage root with one project.
func newHub(t *testing.T, upload bool, wrap func(remote.Backend) remote.Backend) (*Server, Project, string) {
func newHub(t testing.TB, upload bool, wrap func(remote.Backend) remote.Backend) (*Server, Project, string) {
t.Helper()
root := t.TempDir()
be, err := remote.Open(context.Background(), "file://"+root)