mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Opus 5
parent
9a52aed3bb
commit
820978cd76
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+30
-30
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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user