From 0254c8d4649aca66c8ad14089107ad12ec138738 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Thu, 9 Jul 2026 14:19:07 -0700 Subject: [PATCH] feat(web): native URL path routing (no hash, no %2F) Replace the frontend's hash-based routing with the History API and real `/` paths, so URLs read like native file paths: /#p-4e61c7d4/shared%2Fidea.md -> /p-4e61c7d4/shared/idea.md /#shared%2Fnotes.md -> /shared/notes.md (volume mode) /#join/ -> /join/ Client (app.js): - parseRoute() reads location.pathname; pushURL/syncURL push native paths (segments percent-encoded, "/" kept literal); a popstate handler restores back/forward. Invites read from /join/. - All api/ fetches and the app.js/style.css refs are now root-absolute so a deep path doesn't break relative URL resolution. Server (server.go): - New Server.frontend handler: real assets serve directly; every other non-API/auth/share GET returns index.html (SPA fallback), so deep links and refreshes resolve instead of 404ing. Reserved prefixes stay 404s. - Invite links minted as /join/ (orgs.go). Tests: TestFrontendSPAFallback covers the fallback + reserved-prefix 404s; existing invite test updated. Verified end to end in the running hub (deep-link reload, back/forward, no %2F/# in the bar). Docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs --- CLAUDE.md | 2 +- README.md | 2 +- internal/webapp/dir_test.go | 36 ++++++++ internal/webapp/orgs.go | 4 +- internal/webapp/orgs_test.go | 2 +- internal/webapp/server.go | 32 ++++++- internal/webapp/static/app.js | 140 +++++++++++++++++------------- internal/webapp/static/index.html | 4 +- plugin/skills/beardrive/SKILL.md | 2 +- 9 files changed, 155 insertions(+), 69 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6de48fa..dbf6e0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Package roles (`internal/`): - **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely. - **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch). - **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes//`. `.bdrive/` is never synced and holds no credentials. -- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/#join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. +- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `//` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p//store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`//` in hub mode, `/` in volume mode, `/join/` for invites — no `#`, slashes stay literal); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve, and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. `cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared `, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning. diff --git a/README.md b/README.md index 2343b0d..69732e6 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ Projects are walled by **organization**: every project belongs to one org `owner` or `member` role — can see, browse, or sync it. Your first `bdrive init` creates an org for you automatically; an owner invites teammates from the web UI (the org name in the sidebar footer — Invite -mints an expiring join link, `/#join/`, that any signed-in account +mints an expiring join link, `/join/`, that any signed-in account can open to become a member). A hub upgraded from an earlier version sweeps its existing projects into a `default` org that all existing accounts join, so nothing breaks. Public share links stay outside the diff --git a/internal/webapp/dir_test.go b/internal/webapp/dir_test.go index e7c59e5..4cb15bf 100644 --- a/internal/webapp/dir_test.go +++ b/internal/webapp/dir_test.go @@ -65,3 +65,39 @@ func TestDirSourceServesFolder(t *testing.T) { t.Fatalf("path traversal must 404, got %d", rec.Code) } } + +// The frontend serves real assets directly but returns the app shell for any +// client-side route (a deep file path, /join/), so a deep link or +// refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s. +func TestFrontendSPAFallback(t *testing.T) { + h := dirServer(t, map[string]string{"notes/plan.md": "content"}) + + shell := func(url string) { + t.Helper() + rec := get(t, h, url) + if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") { + t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type")) + } + if !strings.Contains(rec.Body.String(), `id="sidebar"`) { + t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String()) + } + } + // Client routes all resolve to the shell, not a 404 or file content. + shell("/") + shell("/notes/plan.md") // a deep file route (not the raw file) + shell("/p-deadbeef/notes/plan.md") // hub-style route + shell("/join/abc123") // invite route + + // Real assets are served as themselves. + if rec := get(t, h, "/app.js"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") { + t.Fatalf("/app.js: %d %s", rec.Code, rec.Header().Get("Content-Type")) + } + if rec := get(t, h, "/style.css"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "css") { + t.Fatalf("/style.css: %d %s", rec.Code, rec.Header().Get("Content-Type")) + } + + // A mistyped API path is a genuine 404, not the shell. + if rec := get(t, h, "/api/bogus"); rec.Code != 404 { + t.Fatalf("/api/bogus: want 404, got %d", rec.Code) + } +} diff --git a/internal/webapp/orgs.go b/internal/webapp/orgs.go index 222ba67..c6f4818 100644 --- a/internal/webapp/orgs.go +++ b/internal/webapp/orgs.go @@ -511,7 +511,7 @@ func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) { out := make([]map[string]any, 0, len(invs)) for _, inv := range invs { out = append(out, map[string]any{ - "token": inv.Token, "url": requestBaseURL(r) + "/#join/" + inv.Token, + "token": inv.Token, "url": requestBaseURL(r) + "/join/" + inv.Token, "creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses, }) } @@ -565,7 +565,7 @@ func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) { } writeJSON(w, map[string]any{ "token": inv.Token, - "url": requestBaseURL(r) + "/#join/" + inv.Token, + "url": requestBaseURL(r) + "/join/" + inv.Token, "expires": inv.Expires, }) } diff --git a/internal/webapp/orgs_test.go b/internal/webapp/orgs_test.go index 978a902..8b6f481 100644 --- a/internal/webapp/orgs_test.go +++ b/internal/webapp/orgs_test.go @@ -239,7 +239,7 @@ func TestOrgInviteFlow(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil { t.Fatal(err) } - if !strings.Contains(inv.URL, "/#join/"+inv.Token) { + if !strings.Contains(inv.URL, "/join/"+inv.Token) { t.Fatalf("invite URL = %q", inv.URL) } diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 546b728..c9b316d 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -361,13 +361,43 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/p/{project}/store/sign", proj(s.handleStoreSign)) mux.HandleFunc("PUT /api/p/{project}/store/object", proj(s.handleStorePut)) - mux.Handle("GET /", http.FileServerFS(static)) + mux.Handle("GET /", s.frontend(static)) if s.Auth != nil { s.Auth.Register(mux) } return s.rateLimitAuth(s.authGate(mux)) } +// frontend serves the embedded single-page app. Real asset files (app.js, +// style.css) are served directly; every other GET that isn't an API, auth, +// or share route returns index.html, so client-side routes like +// // and /join/ survive a deep link or refresh. +func (s *Server) frontend(static fs.FS) http.HandlerFunc { + files := http.FileServerFS(static) + index, _ := fs.ReadFile(static, "index.html") + return func(w http.ResponseWriter, r *http.Request) { + upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + // Reserved prefixes that fell through to the catch-all are genuine + // 404s — don't mask a mistyped API/auth/share URL with the app shell. + if strings.HasPrefix(upath, "api/") || strings.HasPrefix(upath, "auth/") || strings.HasPrefix(upath, "s/") { + http.NotFound(w, r) + return + } + if upath != "" && upath != "index.html" { + if f, err := static.Open(upath); err == nil { + fi, statErr := f.Stat() + f.Close() + if statErr == nil && !fi.IsDir() { + files.ServeHTTP(w, r) // a real asset + return + } + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(index) + } +} + // handleConfig tells the client how this server is configured. Deliberately // nothing about the storage backend. func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index 90aa27f..d2ae91d 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -17,7 +17,7 @@ const TEXT_EXT = /\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh let serverConfig = { mode: "volume", upload: { enabled: false } }; let projects = []; let currentProject = null; // hub mode: the selected project -let apiBase = "api/"; // volume-scoped endpoint prefix +let apiBase = "/api/"; // volume-scoped endpoint prefix let orgs = []; // hub mode: the orgs this account belongs to let joinedOrgId = null; // org just joined via an invite this page-load @@ -38,7 +38,7 @@ function projColor(s) { async function getJSON(url) { const r = await fetch(url); if (r.status === 401) { // auth required: sign in, then come back here - location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash); + location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search); throw new Error("signing in…"); } if (!r.ok) throw new Error(await r.text()); @@ -52,7 +52,7 @@ async function postJSON(url, body) { body: JSON.stringify(body || {}), }); if (r.status === 401) { - location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash); + location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search); throw new Error("signing in…"); } if (!r.ok) throw new Error(await r.text()); @@ -62,23 +62,23 @@ async function postJSON(url, body) { /* ---- boot ---- */ async function boot() { try { - serverConfig = await getJSON("api/config"); + serverConfig = await getJSON("/api/config"); } catch { /* non-fatal */ } document.title = serverConfig.brand || serverConfig.volume || "BearDrive"; // If auth is on and we're not signed in, go straight to the login page // rather than firing authed API calls that 401 (noisy console, and the // redirect happens anyway). /api/config reports `me` only when signed in. if (serverConfig.auth && serverConfig.auth.enabled && !serverConfig.me) { - location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash); + location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search); return; } if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false; if (serverConfig.mode === "hub") { - await acceptInviteFromHash(); + await acceptInviteFromURL(); await loadOrgs(); await loadProjects(); updateAdminBar(); - const { project, path } = parseHash(); + const { project, path } = parseRoute(); // After accepting an invite, open a project in the org you just joined // rather than whatever happened to be first. const proj = projects.find((x) => x.id === project) @@ -91,7 +91,7 @@ async function boot() { $("vault-name").textContent = serverConfig.volume || "BearDrive"; initUpload(); await refreshTree(); - const { path } = parseHash(); + const { path } = parseRoute(); if (path) openFile(path); } setInterval(refreshTree, 15000); // pick up synced changes @@ -101,7 +101,7 @@ async function boot() { async function loadProjects() { let out; try { - out = await getJSON("api/projects"); + out = await getJSON("/api/projects"); } catch { return; } projects = out.projects || []; const nav = $("projects"); @@ -147,7 +147,7 @@ function selectProject(p, path) { currentProject = p; expanded = new Set(); // fresh collapse state for the new project's tree treeFirstLoad = true; - apiBase = "api/p/" + p.id + "/"; + apiBase = "/api/p/" + p.id + "/"; $("vault-name").textContent = p.name; document.title = p.name + " — BearDrive"; currentPath = null; @@ -162,26 +162,26 @@ function selectProject(p, path) { initHistory(); updateShareButton(); refreshTree().then(() => { if (path) openFile(path); }); - if (!path) location.hash = p.id; + if (!path) pushURL("/" + p.id); } /* ---- hub: organizations ---- */ -/* Opening "#join/" joins the invite's org. If the visitor isn't +/* Opening "/join/" joins the invite's org. If the visitor isn't signed in yet, postJSON's 401 handler sends them to /auth/login with the - #join hash intact in `next`, so after signing in they land right back - here and the join completes — the token is never lost. */ -async function acceptInviteFromHash() { - const m = location.hash.match(/^#join\/([0-9a-f]+)$/); + /join path intact in `next`, so after signing in the server re-serves the + app there and the join completes — the token is never lost. */ +async function acceptInviteFromURL() { + const m = location.pathname.match(/^\/join\/([0-9a-f]+)\/?$/); if (!m) return; try { - const out = await postJSON("api/invites/" + m[1]); // may redirect to login (401) - location.hash = ""; + const out = await postJSON("/api/invites/" + m[1]); // may redirect to login (401) + history.replaceState(null, "", "/"); joinedOrgId = out.org && out.org.id; toast("Welcome — you joined the “" + out.org.name + "” team. Opening its projects…"); } catch (e) { if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot - location.hash = ""; + history.replaceState(null, "", "/"); toast("Could not accept the invite: " + e.message, true); } } @@ -202,7 +202,7 @@ function showEmptyState() {

Have an invite link?

A teammate can send you a join link. Paste it here:

- +
` : ``} @@ -218,10 +218,9 @@ function showEmptyState() { const join = $("ob-join"); if (join) join.onclick = () => { const v = $("ob-invite").value.trim(); - const m = v.match(/#join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/); + const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/); if (!m) { toast("That doesn't look like an invite link.", true); return; } - location.hash = "join/" + m[1]; - location.reload(); + location.href = "/join/" + m[1]; }; $("ob-create").onclick = () => createProject($("ob-name").value.trim()); } @@ -229,7 +228,7 @@ function showEmptyState() { async function createProject(name) { if (!name) { toast("Give the project a name.", true); return; } try { - const out = await postJSON("api/projects", { name }); + const out = await postJSON("/api/projects", { name }); await loadOrgs(); await loadProjects(); selectProject(out.project, null); @@ -241,7 +240,7 @@ async function createProject(name) { async function loadOrgs() { try { - orgs = (await getJSON("api/orgs")).orgs || []; + orgs = (await getJSON("/api/orgs")).orgs || []; } catch { orgs = []; } } @@ -299,7 +298,7 @@ async function showOrgAdmin(org) { rn.querySelector("#org-rename").value = org.name; rn.querySelector("#org-rename-btn").onclick = async () => { const name = rn.querySelector("#org-rename").value.trim(); - try { await api("PATCH", "api/orgs/" + org.id, { name }); toast("Renamed."); await loadOrgs(); refreshAll(); } + try { await api("PATCH", "/api/orgs/" + org.id, { name }); toast("Renamed."); await loadOrgs(); refreshAll(); } catch (e) { toast(e.message, true); } }; } @@ -319,14 +318,14 @@ async function showOrgAdmin(org) { if (m.role === r) o.selected = true; sel.appendChild(o); } sel.onchange = async () => { - try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); } + try { await api("PATCH", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); } }; row.appendChild(sel); const rm = el(row, "button", "ai-del", "Remove"); rm.onclick = async () => { if (!(await modalConfirm("Remove member", "Remove " + m.email + " from " + org.name + "?", "Remove", true))) return; - try { await api("DELETE", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); } + try { await api("DELETE", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); } }; } else { @@ -348,14 +347,14 @@ async function showOrgAdmin(org) { rn.onclick = async () => { const name = await modalPrompt("Rename project", "New name", p.name, "Rename"); if (!name || name === p.name) return; - try { await api("PATCH", "api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); } + try { await api("PATCH", "/api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); } }; const del = el(row, "button", "ai-del", "Delete"); del.onclick = async () => { if (!(await modalConfirm("Delete project", "Delete “" + p.name + "”? Its files stay in storage, but it's removed from the hub.", "Delete", true))) return; try { - await api("DELETE", "api/projects/" + p.id); + await api("DELETE", "/api/projects/" + p.id); toast("Deleted “" + p.name + "”."); if (currentProject && currentProject.id === p.id) currentProject = null; await loadProjects(); @@ -371,7 +370,7 @@ async function showOrgAdmin(org) { const mk = el(ih, "button", "pbtn", "New invite"); mk.onclick = async () => { try { - const out = await postJSON("api/orgs/" + org.id + "/invites"); + const out = await postJSON("/api/orgs/" + org.id + "/invites"); const ok = await copyText(out.url); toast(ok ? "Invite link copied to clipboard." : "Invite created — copy it from the list below."); showOrgAdmin(currentOrg()); @@ -379,7 +378,7 @@ async function showOrgAdmin(org) { }; const ilist = el(panel, "div", "admin-list"); try { - const invs = (await getJSON("api/orgs/" + org.id + "/invites")).invites || []; + const invs = (await getJSON("/api/orgs/" + org.id + "/invites")).invites || []; if (!invs.length) el(ilist, "div", "admin-empty", "No active invite links."); for (const inv of invs) { const row = el(ilist, "div", "admin-item"); @@ -394,7 +393,7 @@ async function showOrgAdmin(org) { const rv = el(row, "button", "ai-del", "Revoke"); rv.onclick = async () => { if (!(await modalConfirm("Revoke invite", "Revoke this invite link? Anyone still holding it won't be able to join.", "Revoke", true))) return; - try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); } + try { await api("DELETE", "/api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); } }; } @@ -404,7 +403,7 @@ async function showOrgAdmin(org) { el(panel, "h3", null, "Public share links"); const slist = el(panel, "div", "admin-list"); try { - const shs = (await getJSON("api/orgs/" + org.id + "/shares")).shares || []; + const shs = (await getJSON("/api/orgs/" + org.id + "/shares")).shares || []; if (!shs.length) el(slist, "div", "admin-empty", "No public shares."); for (const sh of shs) { const row = el(slist, "div", "admin-item"); @@ -418,7 +417,7 @@ async function showOrgAdmin(org) { const rv = el(row, "button", "ai-del", "Revoke"); rv.onclick = async () => { if (!(await modalConfirm("Revoke share link", "Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.", "Revoke", true))) return; - try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); } + try { await api("DELETE", "/api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); } catch (e) { toast(e.message, true); } }; } @@ -527,7 +526,7 @@ async function updateAdminBar() { if (!bar) return; if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; } let pending = []; - try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { } bar.hidden = false; bar.innerHTML = svgIcon("shield") + `Admin${pending.length ? " · " + pending.length : ""}`; bar.title = "Hub administration — signup policy" + (pending.length ? " and pending approvals" : ""); @@ -539,7 +538,7 @@ async function updateAdminBar() { (they're server-config owned, deliberately not browser-editable). */ async function showHubSettings() { let pol = {}; - try { pol = await getJSON("api/admin/policy"); } catch (e) { toast(e.message, true); return; } + try { pol = await getJSON("/api/admin/policy"); } catch (e) { toast(e.message, true); return; } currentPath = null; markActive(); closeSidebarOnMobile(); $("crumb").textContent = "Signup & access"; $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true; @@ -572,7 +571,7 @@ async function showHubSettings() { save.style.marginTop = "14px"; save.onclick = async () => { try { - await postJSON("api/admin/policy", { require_verification: ver.checked, require_approval: app.checked }); + await postJSON("/api/admin/policy", { require_verification: ver.checked, require_approval: app.checked }); toast("Signup policy saved."); } catch (e) { toast(e.message, true); } }; @@ -594,21 +593,21 @@ async function showHubSettings() { el(panel, "h3", null, "Pending signups"); const plist = el(panel, "div", "admin-list"); let pending = []; - try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { } if (!pending.length) el(plist, "div", "admin-empty", "No one is waiting for approval."); for (const u of pending) { const row = el(plist, "div", "admin-item"); el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email); const ok = el(row, "button", "pbtn", "Approve"); - ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; + ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; const no = el(row, "button", "ai-del", "Deny"); - no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; + no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } }; } } async function showPending() { let pending = []; - try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { } currentPath = null; markActive(); $("crumb").textContent = "Pending signups"; $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true; @@ -622,9 +621,9 @@ async function showPending() { const row = el(list, "div", "admin-item"); el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email); const ok = el(row, "button", "pbtn", "Approve"); - ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } }; + ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } }; const no = el(row, "button", "ai-del", "Deny"); - no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } }; + no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } }; } } @@ -634,21 +633,40 @@ function refreshAll() { if (currentProject) refreshTree(); } -/* Hash routing: "#" in volume mode, "#/" in hub mode. */ -function parseHash() { - const h = decodeURIComponent(location.hash.slice(1)); - if (serverConfig.mode !== "hub") return { path: h }; - const slash = h.indexOf("/"); - if (slash === -1) return { project: h, path: "" }; - return { project: h.slice(0, slash), path: h.slice(slash + 1) }; +/* Native path routing (no hash, no %2F): + volume mode: / + hub mode: // + invite: /join/ + Each path segment is percent-encoded for odd characters, but the "/" + separators stay literal so the URL reads like a real file path. */ +function encodePath(p) { return p.split("/").map(encodeURIComponent).join("/"); } +function decodePath(p) { return p.split("/").map(decodeURIComponent).join("/"); } + +function parseRoute() { + const raw = location.pathname.replace(/^\/+/, ""); + if (serverConfig.mode !== "hub") return { path: raw ? decodePath(raw) : "" }; + const slash = raw.indexOf("/"); + if (slash === -1) return { project: raw, path: "" }; + return { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) }; } -function setHash(path) { - location.hash = serverConfig.mode === "hub" && currentProject - ? currentProject.id + "/" + encodeURIComponent(path) - : encodeURIComponent(path); +/* The URL for a file within the current context. */ +function urlForPath(path) { + const enc = encodePath(path); + if (serverConfig.mode === "hub" && currentProject) { + return "/" + currentProject.id + (enc ? "/" + enc : ""); + } + return "/" + enc; } +/* Push a route without reloading, skipping a no-op that would just stack a + duplicate history entry (e.g. when boot opens the file already in the URL). */ +function pushURL(url) { + if (location.pathname === url) return; + history.pushState(null, "", url); +} +function syncURL(path) { pushURL(urlForPath(path)); } + /* ---- tree ---- */ async function refreshTree() { if (serverConfig.mode === "hub" && !currentProject) return; @@ -764,7 +782,7 @@ function revealInTree(p) { /* ---- file pane ---- */ async function openFile(p) { currentPath = p; - setHash(p); + syncURL(p); markActive(); revealInTree(p); closeSidebarOnMobile(); @@ -875,7 +893,7 @@ function showShareDialog(url, copied) { back.querySelector('[data-a="open"]').onclick = () => window.open(url, "_blank"); back.querySelector('[data-a="close"]').onclick = close; back.querySelector('[data-a="revoke"]').onclick = async () => { - try { await api("DELETE", "api/shares/" + token); toast("Link revoked — it no longer works."); close(); } + try { await api("DELETE", "/api/shares/" + token); toast("Link revoked — it no longer works."); close(); } catch (e) { toast(e.message, true); } }; document.body.appendChild(back); @@ -1316,11 +1334,13 @@ function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); } $("menu-btn").addEventListener("click", toggleSidebar); $("sb-backdrop").addEventListener("click", closeSidebarOnMobile); -window.addEventListener("hashchange", () => { - const { project, path } = parseHash(); +/* Back/forward: re-resolve the route from the URL. selectProject/openFile + dedup against the current URL, so replaying it here never stacks history. */ +window.addEventListener("popstate", () => { + const { project, path } = parseRoute(); if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) { const proj = projects.find((x) => x.id === project); - if (proj) { selectProject(proj, path); return; } + if (proj) { selectProject(proj, path || null); return; } } if (path && path !== currentPath) openFile(path); }); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 4ca254f..74972d3 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -4,7 +4,7 @@ BearDrive - + @@ -84,6 +84,6 @@
↑↓ navigate · ⏎ select · esc close
- + diff --git a/plugin/skills/beardrive/SKILL.md b/plugin/skills/beardrive/SKILL.md index e4565f8..833ef54 100644 --- a/plugin/skills/beardrive/SKILL.md +++ b/plugin/skills/beardrive/SKILL.md @@ -93,7 +93,7 @@ cd ~/agent-workspace && bdrive init --name agent-workspace Devices connecting the same project (by name or id) converge through the hub. Direct-to-bucket setups (no hub) remain possible via `bdrive remote set s3://…` after an offline init. -Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/#join/`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project ` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall. +Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project ` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall. ### Renames and moves