diff --git a/.claude/agents/beardrive-cto.md b/.claude/agents/beardrive-cto.md new file mode 100644 index 0000000..8555e4e --- /dev/null +++ b/.claude/agents/beardrive-cto.md @@ -0,0 +1,111 @@ +--- +name: beardrive-cto +description: CTO-level engineering reviewer for BearDrive — audits architecture, reusability, and scalability across the Go backend (sync engine, hub, storage) and the React/TS frontend. Reads the real code, checks changes against the repo's invariants and seams, and returns prioritized findings with concrete refactor plans, effort estimates, and per-category scores. Use before merging large features, when planning refactors, or for a periodic architecture health check — not for style nits or one-line bug hunts. +tools: Bash, Read, Write, Glob, Grep +model: opus +--- + +You are the CTO doing an engineering review of BearDrive (repo root: +/Users/snow/workspace/runbear/sfs). You think in systems: boundaries, +seams, failure modes, and what this code will look like with 100× the +tenants, files, and contributors. You are pragmatic — this is a small +team shipping fast — so every recommendation is weighed against its cost +and sequenced. You never hand-wave: every finding names files and lines, +every proposal has a first commit. + +## Ground rules of this codebase (violations are findings) + +Read `CLAUDE.md` first — it is the constitution. In particular: + +- **Sync invariants**: each device writes only its own journal; blobs + push before journals; scan before pull; deterministic `Replay`; + materialize never clobbers dirty files; atomic state writes; cycles + under the flock; degrade-to-offline, never fail a cycle. +- **Seams are sacred**: `AuthProvider`, `QuotaProvider`, `MetaStore`, + `remote.Backend` are the extension points a closed managed deployment + builds on. Logic creeping to the wrong side of a seam (provider + specifics in OSS, hub logic in providers) is an architecture bug. +- **One binary, no Node at build**: frontend output is committed at + `internal/webapp/static` (go:embed). Runtime frontend deps are + deliberately minimal (react, react-dom, @tanstack/react-query, + lucide-react). +- **Every user-facing page owns a URL** (`VIEW_ROUTES` in + `frontend/src/router.ts`); no new URL-less panel state. +- **Clients are storage-blind**; credentials never reach the frontend or + the CLI. + +If `cloud/` exists in the checkout it is the private managed layer +(separate repo). Review it only when the task says so; otherwise treat +its existence as context for seam decisions. + +## What to examine + +**Backend (Go, `internal/`, `cmd/bdrive`)** +- Package boundaries and dependency direction: does `journal` stay pure, + does `syncer` remain the only orchestrator, do `webapp` services keep + their in-memory-map + repo persistence discipline? +- Scalability ceilings, named concretely: in-memory maps that grow with + users/orgs/files, whole-file JSON rewrites, O(n) journal replays, + `List`-the-world storage walks, per-request allocations on hot paths + (`/store/*`, heat recording), the hub's single-writer journal identity + (max-instances=1), polling intervals vs. tenant count. +- Concurrency: lock scope and ordering, what the flock actually protects, + races between daemon and CLI, context propagation and timeouts on + remote calls. +- Error posture: is the "degrade, log once, retry next cycle" rule + applied consistently, or do some paths fail loud/silent inconsistently? +- API surface: handler-to-service layering in `webapp`, route/permission + duplication, whether new endpoints reuse `proj()`-style resolvers or + reinvent them. +- Test architecture: does new sync behavior come with multi-device + `syncer_test.go` coverage? Do webapp features land in the e2e harness? + Is `db_conformance_test.go` still exercising every backend? + +**Frontend (`internal/webapp/frontend/src`)** +- Component structure and reuse: shared primitives vs. copy-paste + (buttons, menus, tooltips, panels); props drilling vs. the small + in-repo emitter patterns (`nav.ts`, `search.ts`) — used consistently? +- State: react-query cache keys and invalidation discipline, polling + cost, derived-state recomputation on large trees (thousands of files), + memoization where it matters and not where it doesn't. +- Routing: everything through `router.ts`/`nav.ts`, no drift back toward + panel state; deep-link + reload behavior for new surfaces. +- Bundle and rendering: dependency creep, list virtualization needs, + `dangerouslySetInnerHTML` handling rules (transform-before-mount only). +- The Playwright suite: does it cover the surfaces that matter, is it + one-hub-shared-state aware, are selectors resilient? + +## How to work + +1. Map the change or area under review (`git log`/`git diff` for a + branch review; `Glob`/`Grep`/`Read` sweeps for a health check). Read + the actual code — never review from file names. +2. Check it against the ground rules above, then against general + architecture judgment (coupling, cohesion, single-responsibility, + YAGNI vs. known roadmap: multi-tenant cloud, GCS/Postgres at scale). +3. Where you suspect a scalability ceiling, estimate it with numbers + (e.g. "orgs.json rewrites whole-file per membership change: at 10k + orgs × 20 members that is ~X MB per write, Y writes/s ceiling"). +4. Verify claims empirically where cheap: `go build ./...`, + `go vet ./...`, targeted `go test`, `npm run build`, grep for the + pattern you assert is duplicated. Do not run destructive commands, + long benchmarks, or anything that mutates repos or running servers. + +## Report format + +1. **Verdict** — one paragraph: overall architecture health and the one + thing to fix first. +2. **Findings** — ordered by severity (`blocker` / `high` / `medium` / + `low`), each with: claim, evidence (`file:line`), blast radius (what + breaks or ossifies if ignored), and a concrete fix with a first step + and effort (S/M/L). +3. **Reuse map** — duplication worth consolidating and, equally, + consolidations NOT worth doing yet (say why). +4. **Scale outlook** — the three nearest ceilings with rough numbers and + the cheapest raise for each. +5. **Scores (0–10)** — architecture & boundaries, reusability, backend + scalability, frontend scalability, test architecture — each with a + one-line justification. + +Be direct. A finding that survives your own steelman of the current +design is worth reporting; anything else, cut. diff --git a/CLAUDE.md b/CLAUDE.md index 5899449..0c964ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,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 a React + TypeScript app (`webapp/frontend/`, Vite; runtime deps only react, react-dom, @tanstack/react-query) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). 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) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. +- **`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 a React + TypeScript app (`webapp/frontend/`, Vite; runtime deps only react, react-dom, @tanstack/react-query, lucide-react) whose **built output is committed** at `webapp/static/` — the `go:embed static` target — so plain `go build` needs no Node; after any `frontend/src` change run `npm run build` there and commit the new `static/` (`frontend/check-dist.sh` verifies freshness; e2e suite: `npm run e2e` — Playwright against the seeded harness in `e2e_serve_test.go`, port 8993). 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). **Every user-facing page owns a URL path**: new surfaces are view routes (`//{insights|history|install|settings}[/]`, `VIEW_ROUTES` in `router.ts`) so deep links, reload, and back/forward always work — never URL-less panel state (the org/hub admin panels are the legacy exceptions; don't add more) implemented by the in-repo synchronous router `frontend/src/nav.ts` + `router.ts` (deliberately NOT a router library: react-router v7's startTransition navigation left stale views on screen); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve (hashed `assets/*` are cached immutable, everything else no-cache), and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution. Rendered markdown is transformed as a string before mounting and link clicks are delegated on the container — never patch the `dangerouslySetInnerHTML` subtree after commit (React re-applies the markup on unrelated updates and discards DOM patches). **Read heat** (`reads.go`): a `ReadLedger` (hub-only, nil = off, config `reads` block) aggregates read telemetry into daily per-actor buckets, debounced to 10-minute visits, folded into all-time rows past `retention_days` — viewer file/render/download = human (recorded via the project id the `proj()` resolver stashes in the request context), `/s/*` hits = share, device-reported reads (`POST /api/p//reads`) = agent; `/store/*` replication and history `/blob` views are NEVER reads. `GET /api/p//heat?prefix=&days=` returns counts/distinct-readers/last-read only — actor identities (the email/device/token in the buckets) must never appear in an API response. Recording and flushing degrade silently (log once); telemetry must never fail a request or a sync cycle. The frontend shows heat dots on folder listings for members and an admin/org-owner Insights quadrant (reads × staleness). **Hub metadata persistence** (accounts, projects, orgs+invites, shares, devices, read buckets — never blobs or journals) sits behind a pluggable `MetaStore` of typed repos (`db.go`): the service structs (`BuiltinAuth`, `OrgDB`, `ProjectDB`, `ShareDB`, `DeviceRegistry`, `ReadLedger`) keep their in-memory maps + logic and persist each change as one record through a repo (the `ReadRepo` alone is batch-oriented — one flush, one write). Two backends — `db_file.go` (the historical JSON files, still the zero-dep default, reached via the `Open*(path)` constructors) and `db_sql.go` (one `database/sql` impl over pure-Go drivers: `modernc.org/sqlite` locally, `jackc/pgx` for Postgres/Supabase, portable schema + idempotent migrations + transactional multi-row writes). `web.go`'s `database` config (`{driver:file|sqlite|postgres, dsn}`) selects it; file is default and untouched. `db_conformance_test.go` runs the same service ops against every backend. `cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `logout`, `init`, `stop`, `sync`, `status`, `log`, `url`, `web`, `whoami`, `daemon`, `version` — `mnt`/`umnt`/`remote` 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 logout` clears the saved token+account (keeps the remembered server unless `--forget`). Switching hubs is `bdrive login ` then re-`init` — `init` is the only thing that writes a folder's remote (always a hub, `server + "/p/" + id`); there is no client command to point a folder at a raw bucket. `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 1aa02b5..9b6e36c 100644 --- a/README.md +++ b/README.md @@ -494,8 +494,9 @@ conflicts. Set `BDRIVE_HOME` to relocate all beardrive state (used heavily in te ### Web frontend The hub's web UI is a React + TypeScript app in `internal/webapp/frontend` -(Vite; runtime dependencies are just react, react-dom, and -@tanstack/react-query — routing is a small in-repo history router). Its +(Vite; runtime dependencies are just react, react-dom, +@tanstack/react-query, and lucide-react for icons — routing is a small +in-repo history router). Its **built output is committed** at `internal/webapp/static`, the `go:embed` target, so building or `go install`-ing the binary never needs Node. diff --git a/internal/webapp/frontend/e2e/admin.spec.ts b/internal/webapp/frontend/e2e/admin.spec.ts index f6a5c76..8bd557e 100644 --- a/internal/webapp/frontend/e2e/admin.spec.ts +++ b/internal/webapp/frontend/e2e/admin.spec.ts @@ -6,9 +6,15 @@ import { login, wikiId, ADMIN, MEMBER } from "./helpers"; // navigation closes them. Mutating specs revert their changes: the suite // shares one hub per run. +// The org panel opens from the account menu (sidebar footer). +async function openOrgSettings(page: import("@playwright/test").Page) { + await page.click("#account-btn"); + await page.click("#menu-org-settings"); +} + test("org admin: members with roles, self marked, rename round-trip", async ({ page }) => { await login(page); - await page.click("#invite-btn"); // owner's Manage button + await openOrgSettings(page); await expect(page.locator("#org-title")).toHaveText("default"); await expect(page.locator("#crumb")).toHaveText("default"); await expect(page.locator(".admin-item", { hasText: ADMIN })).toContainText("(you)"); @@ -19,15 +25,19 @@ test("org admin: members with roles, self marked, rename round-trip", async ({ p await page.fill("#org-rename", "renamed-org"); await page.click("#org-rename-btn"); await expect(page.locator("#toast")).toContainText("Renamed"); - await expect(page.locator("#orgbar #org-name")).toHaveText("renamed-org"); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("renamed-org"); + await page.keyboard.press("Escape"); await page.fill("#org-rename", "default"); await page.click("#org-rename-btn"); - await expect(page.locator("#orgbar #org-name")).toHaveText("default"); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("default"); + await page.keyboard.press("Escape"); }); test("org admin: member role change round-trip", async ({ page }) => { await login(page); - await page.click("#invite-btn"); + await openOrgSettings(page); const sel = page.locator(".admin-item", { hasText: MEMBER }).locator("select"); await sel.selectOption("owner"); await expect(page.locator("#toast")).toContainText("Role updated"); @@ -38,7 +48,7 @@ test("org admin: member role change round-trip", async ({ page }) => { test("org admin: invite create shows in list, revoke removes it", async ({ page }) => { await login(page); - await page.click("#invite-btn"); + await openOrgSettings(page); await page.click(".admin-h .pbtn"); // New invite await expect(page.locator("#toast")).toContainText("Invite"); const row = page.locator(".admin-item", { hasText: "/join/" }).first(); @@ -54,7 +64,7 @@ test("org admin: public share audit lists and revokes", async ({ page }) => { await login(page); const pid = await wikiId(page); await page.request.post(`/api/p/${pid}/shares`, { data: { path: "index.md" } }); - await page.click("#invite-btn"); + await openOrgSettings(page); const row = page.locator(".admin-item", { hasText: "index.md" }); await expect(row).toBeVisible(); await expect(row.locator(".ai-tag")).toContainText("wiki"); @@ -68,7 +78,7 @@ test("org admin: project rename and delete", async ({ page }) => { await login(page); await page.request.post("/api/projects", { data: { name: "doomed" } }); await page.reload(); // pick up the new project - await page.click("#invite-btn"); + await openOrgSettings(page); const row = page.locator(".admin-item", { hasText: "doomed" }); await row.locator(".ai-btn", { hasText: "Rename" }).click(); await page.fill(".modal-input", "doomed-2"); @@ -85,7 +95,7 @@ test("org admin: project rename and delete", async ({ page }) => { test("member sees the org panel read-only", async ({ page }) => { await login(page, MEMBER); - await page.click("#orgbar #org-name"); + await openOrgSettings(page); await expect(page.locator("#org-title")).toContainText("member"); await expect(page.locator("#org-rename")).toHaveCount(0); await expect(page.locator(".admin-item select")).toHaveCount(0); @@ -94,7 +104,8 @@ test("member sees the org panel read-only", async ({ page }) => { test("hub settings: policy view, save round-trip, pending queue empty", async ({ page }) => { await login(page); - await page.click("#adminbar"); + await page.click("#account-btn"); + await page.click("#menu-hub-admin"); await expect(page.locator("#crumb")).toHaveText("Signup & access"); await expect(page.locator(".admin h1")).toHaveText("Signup & access"); // Server has no SMTP: verification toggle disabled @@ -115,7 +126,8 @@ test("hub settings: policy view, save round-trip, pending queue empty", async ({ test("navigating away closes an open admin panel", async ({ page }) => { await login(page); - await page.click("#adminbar"); + await page.click("#account-btn"); + await page.click("#menu-hub-admin"); await expect(page.locator(".admin h1")).toBeVisible(); await page.click('#tree .row[data-path="index.md"]'); await expect(page.locator("#content h1")).toHaveText("Wiki"); diff --git a/internal/webapp/frontend/e2e/browse.spec.ts b/internal/webapp/frontend/e2e/browse.spec.ts index 3fe7fd7..23e0f5d 100644 --- a/internal/webapp/frontend/e2e/browse.spec.ts +++ b/internal/webapp/frontend/e2e/browse.spec.ts @@ -19,7 +19,9 @@ test("markdown file: rendered content, crumb, meta, download + share buttons", a await expect(page.locator("#content h1")).toHaveText("Wiki"); await expect(page.locator("#crumb")).toContainText("index.md"); await expect(page.locator("#meta")).toContainText("alice@x.io"); - await expect(page.locator("#download")).toBeVisible(); + // Download lives in the ⋯ menu now; the hidden anchor powers it. + await expect(page.locator("#download")).toHaveCount(1); + await expect(page.locator("#more-btn")).toBeVisible(); await expect(page.locator("#share-btn")).toBeVisible(); }); @@ -86,6 +88,14 @@ test("back/forward walks file → folder → file", async ({ page }) => { await expect(page.locator(".dl-title")).toContainText("notes"); }); +test("header search button opens the palette", async ({ page }) => { + await login(page); + await wikiId(page); + await page.click("#search-btn"); + await expect(page.locator("#palette")).toBeVisible(); + await page.keyboard.press("Escape"); +}); + test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => { await login(page); const pid = await wikiId(page); @@ -113,17 +123,20 @@ test("share mints a public link that serves the file, revoke kills it", async ({ expect(gone.status()).toBe(404); }); -test("upload into the selected folder, then the file opens", async ({ page }) => { +test("no browser upload: content arrives via sync; the tree picks it up", async ({ page }) => { await login(page); const pid = await wikiId(page); await page.goto(`/${pid}/notes`); - await page.locator("#upload-btn").waitFor(); - await page.setInputFiles('input[type="file"]', { - name: "dropped.md", - mimeType: "text/markdown", - buffer: Buffer.from("# Dropped\n\nUploaded through the browser.\n"), - }); - await page.waitForURL(`/${pid}/notes/dropped.md`); + // The upload affordance is gone everywhere — content enters via local sync. + await expect(page.locator("#upload-btn")).toHaveCount(0); + await expect(page.locator('input[type="file"]')).toHaveCount(0); + // A file lands through the device/store path (simulated via the API)… + await page.request.put( + `/api/p/${pid}/upload/content?path=${encodeURIComponent("notes/dropped.md")}`, + { data: "# Dropped\n\nArrived through sync.\n" }, + ); + // …and the polling tree shows it; opening renders it. + await page.goto(`/${pid}/notes/dropped.md`); await expect(page.locator("#content h1")).toHaveText("Dropped"); await expect(page.locator('#tree .row[data-path="notes/dropped.md"]')).toBeVisible(); }); diff --git a/internal/webapp/frontend/e2e/home.spec.ts b/internal/webapp/frontend/e2e/home.spec.ts index 2b291c6..a33d617 100644 --- a/internal/webapp/frontend/e2e/home.spec.ts +++ b/internal/webapp/frontend/e2e/home.spec.ts @@ -151,3 +151,42 @@ test("folder listing's Full history goes to the subtree feed", async ({ page }) const paths = await page.$$eval(".history .hpath", (els) => els.map((e) => e.textContent)); for (const p of paths) expect(p).toContain("notes/"); }); + +test("insights scopes to the selected folder via the ⋯ menu", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.goto(`/${pid}/notes`); + await page.click("#more-btn"); + await page.click("#more-menu .more-item:has-text('Insights')"); + await page.waitForURL(`/${pid}/insights/notes`); + await expect(page.locator(".in-title .in-scope")).toContainText("notes"); + // Scope note in the subtitle is the stable assertion. + await expect(page.locator(".insights .dl-sub")).toContainText("notes and everything in it"); +}); + +test("project menu pages each own a URL: Dashboard, Installation, Settings", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.click("#nav-dashboard"); + await page.waitForURL(`/${pid}/insights`); + await expect(page.locator(".insights .in-title")).toContainText("Knowledge insights"); + await expect(page.locator("#nav-dashboard")).toHaveClass(/active/); + await page.click("#nav-install"); + await page.waitForURL(`/${pid}/install`); + await expect(page.locator("#crumb")).toHaveText("Installation"); + await expect(page.locator("#nav-install")).toHaveClass(/active/); + await page.click("#nav-history"); + await page.waitForURL(`/${pid}/history`); + await expect(page.locator("#nav-history")).toHaveClass(/active/); + await page.click("#nav-settings"); + await page.waitForURL(`/${pid}/settings`); + await expect(page.locator("#crumb")).toHaveText("Project settings"); + await expect(page.locator(".project-settings h2")).toHaveText("wiki"); + await page.click("#nav-dashboard"); + await page.waitForURL(`/${pid}/insights`); + await expect(page.locator("#nav-dashboard")).toHaveClass(/active/); + // Deep link + reload land on the page, like any URL. + await page.goto(`/${pid}/settings`); + await expect(page.locator(".project-settings h2")).toHaveText("wiki"); + await expect(page.locator("#nav-settings")).toHaveClass(/active/); +}); diff --git a/internal/webapp/frontend/e2e/hub.spec.ts b/internal/webapp/frontend/e2e/hub.spec.ts index 0c350f0..9fec06b 100644 --- a/internal/webapp/frontend/e2e/hub.spec.ts +++ b/internal/webapp/frontend/e2e/hub.spec.ts @@ -9,16 +9,16 @@ test("landing selects the first project and rewrites the URL", async ({ page }) await login(page); const pid = await wikiId(page); await page.waitForURL("/" + pid); - await expect(page.locator("#vault-name")).toHaveText("wiki"); + await expect(page.locator("#project-select option:checked")).toHaveText("wiki"); await expect(page).toHaveTitle("wiki — BearDrive"); - await expect(page.locator("#projects .row.active .label")).toHaveText("wiki"); + await expect(page.locator("#vault-name")).toHaveText("BearDrive"); }); test("deep link to a project resolves after reload", async ({ page }) => { await login(page); const pid = await wikiId(page); await page.goto("/" + pid); - await expect(page.locator("#vault-name")).toHaveText("wiki"); + await expect(page.locator("#project-select option:checked")).toHaveText("wiki"); await expect(page).toHaveURL("/" + pid); }); @@ -26,22 +26,23 @@ test("unknown project id falls back to a real project", async ({ page }) => { await login(page); await page.goto("/p-00000000"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).not.toHaveText("…"); + await expect(page.locator("#project-select option:checked")).toHaveText(/.+/); }); -test("admin sees admin bar and org Manage; member does not", async ({ page, browser }) => { +test("account menu: admin gets hub admin entry; member does not", async ({ page, browser }) => { await login(page); // admin, owner of "default" - await expect(page.locator("#adminbar")).toBeVisible(); - await expect(page.locator("#orgbar #org-name")).toHaveText("default"); - await expect(page.locator("#invite-btn")).toBeVisible(); + await page.click("#account-btn"); + await expect(page.locator("#menu-org-settings")).toContainText("default"); + await expect(page.locator("#menu-hub-admin")).toBeVisible(); await expect(page.locator("#signout")).toBeVisible(); + await page.keyboard.press("Escape"); const ctx = await browser.newContext(); const p2 = await ctx.newPage(); await login(p2, MEMBER); - await expect(p2.locator("#orgbar #org-name")).toHaveText("default"); - await expect(p2.locator("#adminbar")).toHaveCount(0); - await expect(p2.locator("#invite-btn")).toHaveCount(0); + await p2.click("#account-btn"); + await expect(p2.locator("#menu-org-settings")).toContainText("default"); + await expect(p2.locator("#menu-hub-admin")).toHaveCount(0); await ctx.close(); }); @@ -77,8 +78,8 @@ test("no-org account gets the onboarding empty state and can create a project", await page.fill("#ob-name", "solo-notes"); await page.click("#ob-create"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).toHaveText("solo-notes"); - await expect(page.locator("#orgbar")).toBeVisible(); // fresh org, owner + await expect(page.locator("#project-select option:checked")).toHaveText("solo-notes"); + await expect(page.locator("#accountbar")).toBeVisible(); // fresh org, owner }); test("new project via the sidebar + modal", async ({ page }) => { @@ -87,7 +88,7 @@ test("new project via the sidebar + modal", async ({ page }) => { await page.fill(".modal-input", "scratch"); await page.click(".modal .pbtn"); await page.waitForURL(/\/p-[0-9a-f]{8}$/); - await expect(page.locator("#vault-name")).toHaveText("scratch"); - await expect(page.locator("#projects .row .label")).toContainText(["scratch", "wiki"]); + await expect(page.locator("#project-select option:checked")).toHaveText("scratch"); + await expect(page.locator("#project-select option")).toContainText(["scratch", "wiki"]); await expect(page.locator("#toast")).toContainText("Created"); }); diff --git a/internal/webapp/frontend/e2e/scope.spec.ts b/internal/webapp/frontend/e2e/scope.spec.ts new file mode 100644 index 0000000..145c42b --- /dev/null +++ b/internal/webapp/frontend/e2e/scope.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from "@playwright/test"; +import { login, wikiId } from "./helpers"; + +test("insights via ⋯ scopes to the open file", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.goto(`/${pid}/notes/readme.md`); + await page.click("#more-btn"); + await page.click("#more-menu .more-item:has-text('Insights')"); + await expect(page).toHaveURL(`/${pid}/insights/notes/readme.md`); + await expect(page.locator(".in-title .in-scope")).toContainText("notes/readme.md"); + // The subject stays selected in the tree; Dashboard does NOT light up. + await expect(page.locator('#tree .row[data-path="notes/readme.md"]')).toHaveClass(/active/); + await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/); +}); + +test("insights via ⋯ scopes to the selected folder", async ({ page }) => { + await login(page); + const pid = await wikiId(page); + await page.goto(`/${pid}/notes`); + await page.click("#more-btn"); + await page.click("#more-menu .more-item:has-text('Insights')"); + await expect(page).toHaveURL(`/${pid}/insights/notes`); + await expect(page.locator(".in-title .in-scope")).toContainText("notes"); + await expect(page.locator('#tree .row[data-path="notes"]')).toHaveClass(/active/); + await expect(page.locator("#nav-dashboard")).not.toHaveClass(/active/); +}); + +test("root Dashboard still lights the menu, not the tree", async ({ page }) => { + await login(page); + await wikiId(page); + await page.click("#nav-dashboard"); + await expect(page.locator("#nav-dashboard")).toHaveClass(/active/); + await expect(page.locator("#tree .row.active")).toHaveCount(0); +}); diff --git a/internal/webapp/frontend/index.html b/internal/webapp/frontend/index.html index 50e2df7..eab9b66 100644 --- a/internal/webapp/frontend/index.html +++ b/internal/webapp/frontend/index.html @@ -7,36 +7,6 @@ -
diff --git a/internal/webapp/frontend/package-lock.json b/internal/webapp/frontend/package-lock.json index ca61e98..ead7df4 100644 --- a/internal/webapp/frontend/package-lock.json +++ b/internal/webapp/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.25.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1532,6 +1533,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", + "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/internal/webapp/frontend/package.json b/internal/webapp/frontend/package.json index 19b3c55..3cf5804 100644 --- a/internal/webapp/frontend/package.json +++ b/internal/webapp/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tanstack/react-query": "^5.90.0", + "lucide-react": "^1.25.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index 1d5adf6..3315eee 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -10,9 +10,9 @@ import type { Project, ServerConfig } from "../api/types"; import { useHeat, useTree } from "../hooks/useBrowse"; import { urlForPath, urlForView, type Route } from "../router"; import { currentNavType, navigate, useLocationPath } from "../nav"; -import { uploadFile } from "../upload"; import { copyText } from "../util"; import { toast } from "../toast"; +import { onSearchRequest } from "../search"; import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell"; import { FileTree, ancestorsOf } from "../components/FileTree"; import { Breadcrumbs } from "../components/Breadcrumbs"; @@ -41,6 +41,7 @@ export default function Browser(props: { // touching the URL — matching the classic app, where they were never // routes. Any navigation closes them (the caller owns that state). panel?: { crumb: string; body: ReactNode } | null; + onClosePanel?: () => void; // panels are not routes: same-path navigation needs an explicit close }) { const { config, apiBase, route, hub, project } = props; const routeKey = useLocationPath(); // scroll memo key, one slot per URL @@ -58,6 +59,9 @@ export default function Browser(props: { }, [insightsOpen, apiBase, qc]); const path = route.path; + // On scoped view routes (/insights/

, /history/

) the subject of the + // page is the target — the tree highlights it, not a menu item. + const treePath = path || (route.view === "insights" || route.view === "history" ? route.viewTarget || "" : ""); const isDir = !!path && dirIndex.has(path); // A file only counts as one when the tree actually contains it — a // missing path gets the not-found view, not a broken file view. @@ -77,18 +81,19 @@ export default function Browser(props: { if (rootDirs.length === 1) setExpanded((s) => new Set(s).add(rootDirs[0].path)); }, [tree]); useEffect(() => { - // Opening any path (tree click, palette, wikilink, deep link) unfolds - // the way to it; a selected folder itself opens too. - if (!path || !loaded) return; + // Opening any path (tree click, palette, wikilink, deep link — or a + // scoped insights/history view of it) unfolds the way to it; a selected + // folder itself opens too. + if (!treePath || !loaded) return; setExpanded((s) => { const next = new Set(s); - for (const a of ancestorsOf(path)) next.add(a); - if (dirIndex.has(path)) next.add(path); + for (const a of ancestorsOf(treePath)) next.add(a); + if (dirIndex.has(treePath)) next.add(treePath); return next; }); - const row = document.querySelector(`#tree .row[data-path="${CSS.escape(path)}"]`); + const row = document.querySelector(`#tree .row[data-path="${CSS.escape(treePath)}"]`); if (row) row.scrollIntoView({ block: "nearest" }); - }, [path, loaded, dirIndex]); + }, [treePath, loaded, dirIndex]); const onToggle = useCallback((p: string) => { setExpanded((s) => { const next = new Set(s); @@ -138,17 +143,17 @@ export default function Browser(props: { /* ---- topbar state + actions ---- */ const [meta, setMeta] = useState(""); - const [uploadStatus, setUploadStatus] = useState(""); const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null); const [moreOpen, setMoreOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); - const uploadInput = useRef(null); + useEffect(() => onSearchRequest(() => setPaletteOpen(true)), []); const downloadRef = useRef(null); const panel = props.panel ?? null; const canShare = !panel && hub && !!project && isFile; const canHistory = !panel && hub && !!project; - const canUpload = !!config.upload?.enabled && (!hub || !!project); + // Browser upload is deliberately absent (for now): content enters through + // local sync only; the web app is a read/share/history surface. const canDownload = !panel && isFile; const canMore = !panel && (isFile || (hub && !!project && isDir)); const downloadURL = apiBase + "download?path=" + encodeURIComponent(path); @@ -175,32 +180,6 @@ export default function Browser(props: { openHistory(isDir ? path + "/" : path); }, [path, isDir, openHistory]); - const uploadNow = useCallback(() => uploadInput.current?.click(), []); - const onUploadPick = async () => { - const input = uploadInput.current!; - const file = input.files?.[0]; - input.value = ""; - if (!file) return; - // A selected folder receives the upload; a selected file means "next - // to it". - const dir = !path ? "" : isDir ? path : path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : ""; - const dest = dir ? dir + "/" + file.name : file.name; - try { - setUploadStatus(`Uploading ${dest}…`); - await uploadFile(apiBase, dest, file); - setUploadStatus(`Uploaded ${dest}`); - await qc.invalidateQueries({ queryKey: ["tree", apiBase] }); - openPath(dest); - } catch (err) { - setUploadStatus("Upload failed: " + (err as Error).message); - } - }; - - useEffect(() => { - // Any navigation clears a stale upload status from the meta slot. - setUploadStatus(""); - }, [routeKey]); - /* ---- ⌘K palette ---- */ useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -223,7 +202,6 @@ export default function Browser(props: { if (isFile) add("download", "Download: " + path, "action", () => downloadRef.current?.click()); } if (hub && project) add("hist", "History: whole project", "action", () => openHistory("")); - if (canUpload) add("upload", "Upload a file…", "action", uploadNow); if (hub) { for (const p of props.projects || []) { if (!project || p.id !== project.id) { @@ -237,7 +215,7 @@ export default function Browser(props: { for (const d of dirIndex.keys()) add("folder", d, "folder", () => openPath(d)); for (const f of flatFiles) add("doc", f.path, "file", () => openPath(f.path)); return items; - }, [hub, project, path, isFile, canUpload, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, uploadNow, openHistory, openPath]); + }, [hub, project, path, isFile, config.auth?.enabled, dirIndex, flatFiles, props.projects, shareNow, historyNow, openHistory, openPath]); /* ---- "⋯ More" menu (secondary actions on narrow screens) ---- */ useEffect(() => { @@ -261,6 +239,7 @@ export default function Browser(props: { flatFiles={flatFiles} heatMap={heatMap} devices={devices} + scope={route.viewTarget || ""} onOpenFile={openPath} onOpenFolder={openPath} isFolder={isFolderFn} @@ -362,7 +341,7 @@ export default function Browser(props: { ) : path ? ( ) : route.view === "insights" ? ( - "Insights — " + (project?.name ?? "") + "Insights — " + (route.viewTarget || project?.name || "") ) : route.view === "history" ? ( "History — " + historyTitle(route.viewTarget || "", isFolderFn) ) : isHome ? ( @@ -372,31 +351,22 @@ export default function Browser(props: { const topbar = ( - {canShare && ( - )} - {canHistory && ( + {canHistory && !path && !route.view && ( )} - {canUpload && ( - - )} - {canDownload && ( - - Download + )} {canMore && ( @@ -420,11 +390,6 @@ export default function Browser(props: { History )} - {canUpload && ( - - )} {canDownload && ( @@ -456,7 +424,7 @@ export default function Browser(props: { root={tree} expanded={expanded} onToggle={onToggle} - currentPath={path} + currentPath={treePath} listingShowing={listingShowing} onOpen={openPath} /> diff --git a/internal/webapp/frontend/src/apps/HubApp.tsx b/internal/webapp/frontend/src/apps/HubApp.tsx index c97e724..8cbc50d 100644 --- a/internal/webapp/frontend/src/apps/HubApp.tsx +++ b/internal/webapp/frontend/src/apps/HubApp.tsx @@ -2,13 +2,15 @@ import { useEffect, useMemo, useState } from "react"; import { postJSON } from "../api/http"; import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types"; import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub"; -import { parseRoute } from "../router"; +import { parseRoute, urlForView } from "../router"; import { navigate, Redirect, useLocationPath } from "../nav"; import { AppShell, Topbar, VaultHeader, closeSidebarOnMobile } from "../components/shell"; import { OrgAdmin } from "../components/OrgAdmin"; import { HubSettings } from "../components/HubSettings"; import { ProjectNav } from "../components/ProjectNav"; -import { OrgBar } from "../components/OrgBar"; +import { AccountBar } from "../components/AccountBar"; +import { ProjectSettings } from "../components/ProjectSettings"; +import { ConnectGuide } from "../components/ConnectGuide"; import { EmptyState } from "../components/EmptyState"; import { toast } from "../toast"; import Browser from "./Browser"; @@ -67,20 +69,18 @@ export default function HubApp({ config }: { config: ServerConfig }) { const brand = config.brand || config.volume || "BearDrive"; const org = (current && orgs?.find((o) => o.id === current.org)) || null; - const ownedOrg = orgs?.find((o) => o.role === "owner") || null; - // The top-of-sidebar gear is the always-visible admin entry point: any - // account that owns an org (or is a hub admin) gets it, whatever project - // is open. The panels it opens arrive in Phase 4. - const gearTarget = org && org.role === "owner" ? org : ownedOrg; // Insights (embedded on the project home and behind the ⋯ menu) is for // hub admins and owners of the project's org. const canInsights = isAdmin || (org ? org.role === "owner" : false); - const vault = ( - navigate("/" + current.id) : undefined} - showSignout={config.auth.enabled} + // Top of the sidebar is the brand; project and account actions live in + // their own sections below (PropelAuth-style layout). + const vault = navigate("/")} search={!!current} />; + + const accountBar = config.me ? ( + { - setPanel({ kind: "org", orgId: gearTarget.id }); - closeSidebarOnMobile(); - }, - } - : undefined - } + onOrgSettings={(o) => { + setPanel({ kind: "org", orgId: o.id }); + closeSidebarOnMobile(); + }} /> - ); + ) : undefined; if (!projects || !orgs) { return ( @@ -118,6 +112,7 @@ export default function HubApp({ config }: { config: ServerConfig }) { } + orgBar={accountBar} topbar={} contentClass="view" > @@ -160,6 +155,20 @@ export default function HubApp({ config }: { config: ServerConfig }) { } : null; + const routePage = + route.view === "settings" + ? { crumb: "Project settings", body: } + : route.view === "install" + ? { + crumb: "Installation", + body: ( +

+ +
+ ), + } + : null; + // Landing ("/") and unknown project ids both resolve to a real project // URL; replace so back/forward never bounces through the redirect. if (route.project !== current.id) { @@ -178,18 +187,54 @@ export default function HubApp({ config }: { config: ServerConfig }) { canInsights={canInsights} sidebar={{ vault, - projectsNav: , - orgBar: ( - { - setPanel({ kind: "org", orgId: o.id }); - closeSidebarOnMobile(); + projectsNav: ( + , /history/) belong to + // the file/folder — the tree carries the selection, no menu + // item lights up. + active: panel + ? null + : route.view === "insights" && !route.viewTarget + ? "dashboard" + : route.view === "install" + ? "install" + : route.view === "history" && !route.viewTarget + ? "history" + : route.view === "settings" + ? "settings" + : null, + // Each page is a URL; explicitly close overlay panels because + // same-path navigation doesn't change pathname. + onDashboard: () => { + setPanel(null); + navigate(urlForView("insights", current.id)); + closeSidebarOnMobile(); + }, + onInstall: () => { + setPanel(null); + navigate(urlForView("install", current.id)); + closeSidebarOnMobile(); + }, + onHistory: () => { + setPanel(null); + navigate(urlForView("history", current.id)); + closeSidebarOnMobile(); + }, + onSettings: () => { + setPanel(null); + navigate(urlForView("settings", current.id)); + closeSidebarOnMobile(); + }, }} /> ), + orgBar: accountBar, }} - panel={activePanel} + panel={activePanel || routePage} + onClosePanel={() => setPanel(null)} /> ); } @@ -218,7 +263,7 @@ function JoinInvite({ token, onDone }: { token: string; onDone: (orgId: string | // eslint-disable-next-line react-hooks/exhaustive-deps }, [token]); return ( - } topbar={}> + } topbar={}>
Joining…
); diff --git a/internal/webapp/frontend/src/apps/VolumeApp.tsx b/internal/webapp/frontend/src/apps/VolumeApp.tsx index 37f257a..bb1223c 100644 --- a/internal/webapp/frontend/src/apps/VolumeApp.tsx +++ b/internal/webapp/frontend/src/apps/VolumeApp.tsx @@ -21,7 +21,7 @@ export default function VolumeApp({ config }: { config: ServerConfig }) { apiBase="/api/" route={route} hub={false} - sidebar={{ vault: }} + sidebar={{ vault: }} /> ); } diff --git a/internal/webapp/frontend/src/components/AccountBar.tsx b/internal/webapp/frontend/src/components/AccountBar.tsx new file mode 100644 index 0000000..e08deed --- /dev/null +++ b/internal/webapp/frontend/src/components/AccountBar.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; +import type { Org } from "../api/types"; +import { Icon } from "./shell"; +import { projColor } from "./ProjectNav"; + +// The sidebar footer is the account row: avatar, name, email. Clicking it +// opens a popover with the workspace (org) and account actions — settings, +// hub administration for admins, and sign-out. +export function AccountBar({ + me, + org, + admin, + onOrgSettings, +}: { + me: { email: string; name: string }; + org: Org | null; + admin?: { pending: number; onClick: () => void }; // hub admins only + onOrgSettings: (org: Org) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const display = me.name || me.email; + return ( +
+ {open && ( + + )} + +
+ ); +} diff --git a/internal/webapp/frontend/src/components/Insights.tsx b/internal/webapp/frontend/src/components/Insights.tsx index bd00cc6..2926044 100644 --- a/internal/webapp/frontend/src/components/Insights.tsx +++ b/internal/webapp/frontend/src/components/Insights.tsx @@ -48,15 +48,29 @@ export function Insights(props: { flatFiles: Node[]; heatMap: HeatMap | null; devices: DeviceHeat[] | null; + scope?: string; // "" = whole project; a folder scopes to its subtree, a file to itself onOpenFile: (path: string) => void; onOpenFolder: (path: string) => void; isFolder: (path: string) => boolean; }) { const [lens, setLens] = useState("all"); - const { flatFiles, heatMap, devices } = props; + const { flatFiles, heatMap, devices, scope } = props; + + const inScope = (p: string) => !scope || p === scope || p.startsWith(scope + "/"); + const scoped = scope ? flatFiles.filter((f) => inScope(f.path)) : flatFiles; + const scopedDevices = + devices && scope + ? devices + .map((d) => { + const folders: Record = {}; + for (const [f, n] of Object.entries(d.folders || {})) if (inScope(f)) folders[f] = n; + return { ...d, folders }; + }) + .filter((d) => Object.keys(d.folders).length > 0) + : devices; const now = Date.now(); - const pts: Pt[] = flatFiles.map((f) => { + const pts: Pt[] = scoped.map((f) => { const e = (heatMap && heatMap[f.path]) || {}; const days = f.time ? Math.max(0, (now - new Date(f.time).getTime()) / 86400000) : 0; const reads = lens === "all" ? heatTotal(e) : e[lens] || 0; @@ -72,10 +86,11 @@ export function Insights(props: { return (
-

Knowledge insights

+

Knowledge insights{scope ? · {scope} : null}

- Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — - read a lot, maintained by nobody — is the danger zone. + {scope + ? `Reads over the last 30 days × freshness, for ${scope} and everything in it.` + : "Reads over the last 30 days × how long since each file changed. Hot but stale knowledge — read a lot, maintained by nobody — is the danger zone."}

{(["all", "human", "agent"] as const).map((l) => ( @@ -98,10 +113,10 @@ export function Insights(props: {

Hot path — top files by reads

- {devices && devices.length > 0 && ( + {scopedDevices && scopedDevices.length > 0 && ( <>

Agent coverage — which agents read which areas

- + )}
diff --git a/internal/webapp/frontend/src/components/OrgBar.tsx b/internal/webapp/frontend/src/components/OrgBar.tsx deleted file mode 100644 index 91bdfda..0000000 --- a/internal/webapp/frontend/src/components/OrgBar.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { Org } from "../api/types"; - -// The sidebar footer names the project's org; clicking it opens the org -// admin panel, and owners get a Manage button that does the same. The -// panel itself arrives with the admin surfaces (Phase 4). -export function OrgBar({ org, onManage }: { org: Org | null; onManage: (org: Org) => void }) { - if (!org) return null; - return ( -
- onManage(org)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onManage(org); - } - }} - > - {org.name} - - {org.role === "owner" && ( - - )} -
- ); -} diff --git a/internal/webapp/frontend/src/components/ProjectNav.tsx b/internal/webapp/frontend/src/components/ProjectNav.tsx index fdd44c5..5b8fbe3 100644 --- a/internal/webapp/frontend/src/components/ProjectNav.tsx +++ b/internal/webapp/frontend/src/components/ProjectNav.tsx @@ -1,4 +1,5 @@ import { navigate } from "../nav"; +import { Icon } from "./shell"; import { postJSON } from "../api/http"; import type { Project, ProjectCreated } from "../api/types"; import { modalPrompt } from "../modal"; @@ -15,7 +16,23 @@ export function projColor(s: string): string { return PROJ_COLORS[h % PROJ_COLORS.length]; } -export function ProjectNav({ projects, currentId }: { projects: Project[]; currentId?: string }) { +export interface ProjectMenu { + active: "dashboard" | "install" | "history" | "settings" | null; + onDashboard: () => void; + onInstall: () => void; + onHistory: () => void; + onSettings: () => void; +} + +export function ProjectNav({ + projects, + currentId, + menu, +}: { + projects: Project[]; + currentId?: string; + menu?: ProjectMenu; +}) { const refresh = useHubRefresh(); const create = async () => { @@ -39,33 +56,67 @@ export function ProjectNav({ projects, currentId }: { projects: Project[]; curre +
-
    - {projects.map((p) => ( -
  • -
    { - navigate("/" + p.id); +
    + + {currentId && ( + +
    + {menu && ( +
      + {( + [ + ["dashboard", "Dashboard", "dashboard", menu.onDashboard], + ["install", "Installation", "terminal", menu.onInstall], + ["history", "History", "hist", menu.onHistory], + ["settings", "Settings", "gear", menu.onSettings], + ] as const + ).map(([key, label, icon, onClick]) => ( +
    • +
      { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onClick(); + } + }} + > + + {label} +
      +
    • + ))} +
    + )} ); } diff --git a/internal/webapp/frontend/src/components/ProjectSettings.tsx b/internal/webapp/frontend/src/components/ProjectSettings.tsx new file mode 100644 index 0000000..6bd7d6b --- /dev/null +++ b/internal/webapp/frontend/src/components/ProjectSettings.tsx @@ -0,0 +1,30 @@ +import type { Org, Project } from "../api/types"; + +// Settings for the open project (sidebar menu). Today: identity facts; +// per-project knobs land here as they grow. Install/connect lives on the +// Installation page. +export function ProjectSettings({ project, org }: { project: Project; org: Org | null }) { + return ( +
    +

    {project.name}

    +
    +
    Project id
    +
    + {project.id} +
    + {org && ( + <> +
    Workspace
    +
    {org.name}
    + + )} + {project.created && ( + <> +
    Created
    +
    {new Date(project.created).toLocaleDateString()}
    + + )} +
    +
    + ); +} diff --git a/internal/webapp/frontend/src/components/shell.tsx b/internal/webapp/frontend/src/components/shell.tsx index 5b268e4..2d26246 100644 --- a/internal/webapp/frontend/src/components/shell.tsx +++ b/internal/webapp/frontend/src/components/shell.tsx @@ -1,4 +1,35 @@ import type { ReactNode } from "react"; +import { requestSearch } from "../search"; +import { + Check, + ChevronDown, + ChevronRight, + Clock, + Copy, + Download, + Ellipsis, + FileText, + Folder, + LayoutDashboard, + Globe, + History, + Link, + Lock, + LogOut, + Menu, + Plus, + Search, + Settings, + Share2, + Shield, + SquareTerminal, + Trash2, + TriangleAlert, + Upload, + Users, + X, + type LucideIcon, +} from "lucide-react"; // The app's fixed layout: off-canvas sidebar (mobile: body.sb-open toggles // it), topbar, and the content pane. Ids and classes match the classic app @@ -11,12 +42,42 @@ export function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); } +// Icons are lucide (lucide.dev) components behind the historical sprite +// names, so call sites keep the tiny `` API and style.css's +// `.ico` sizing/stroke rules apply unchanged. +const ICONS: Record = { + alert: TriangleAlert, + check: Check, + chev: ChevronRight, + chevd: ChevronDown, + clock: Clock, + copy: Copy, + doc: FileText, + dots: Ellipsis, + download: Download, + folder: Folder, + dashboard: LayoutDashboard, + gear: Settings, + globe: Globe, + hist: History, + link: Link, + lock: Lock, + menu: Menu, + plus: Plus, + power: LogOut, + search: Search, + share: Share2, + shield: Shield, + terminal: SquareTerminal, + trash: Trash2, + upload: Upload, + users: Users, + x: X, +}; + export function Icon({ name }: { name: string }) { - return ( - - ); + const C = ICONS[name]; + return C ?
    @@ -291,9 +313,10 @@ footer a:hover { color: var(--text); }

    Google Drive for AI agents.Your agent knows what their agent knows.

    One synced folder for your whole team — humans and agents. Share any file with people as a public URL; give every agent the same - memory, synced across machines in seconds. Real files. Self-host the whole thing in one Go binary.

    + memory, synced across machines in seconds. Real files. Use the managed cloud, or self-host the whole thing in one Go binary.

    - Start syncing + Start free → + Self-host it
    +
    +
    +

    SHARE WITHOUT A SHARE STEP

    +

    The link is born with the file.

    +

    The moment your agent writes a file, the path in its reply is + already a clickable, members-only hub link. You didn't run a command, you didn't + copy a path — any teammate who's signed in opens it in one click, rendered and at + the latest version. It's gated to your project, so it's safe to paste in any + internal channel.

    +
    +
    + + Deal page written to shared/wiki/deals/motive.md 🔗 +
    +
    ↑ your agent's own reply — the 🔗 is a live hub link it added automatically. No bdrive share, no copy-paste, nobody asking you to resend it.
    +
    +
    +
    +

    WHY BEARDRIVE

    @@ -399,10 +441,16 @@ every tool works content-addressed blobs append-only, no locks, offl which device — every past version viewable and downloadable. An agent overwriting your doc is an annoyance, not a loss.

    +
    🔒
    +

    Gated links your agents hand you. When Claude writes or updates a + file, it drops a link right beside the path — a hub link that only opens for + signed-in teammates on the project. Safe to paste in any internal channel; + the turn-start hook teaches every agent to do it automatically.

    +
    🔗
    -

    Public share links. Any synced file becomes a rendered page at an - unguessable URL: HTML as a page, markdown Obsidian-style, PDFs inline. - Sandboxed, rate-limited, living until you revoke it.

    +

    Public share links. Need it truly public? Any synced file becomes a + rendered page at an unguessable URL: HTML as a page, markdown Obsidian-style, + PDFs inline. Sandboxed, rate-limited, living until you revoke it.

    👥

    Teams & organizations. Projects belong to your org; only @@ -427,17 +475,19 @@ every tool works content-addressed blobs append-only, no locks, offl

    -

    CLAUDE CODE

    +

    CLAUDE CODE & COWORK

    Your agents become fluent in it.

    • /beardrive:install sets a whole team project up - conversationally: CLI, sign-in, project, and sync hooks.
    • + conversationally: CLI, sign-in, project, and sync hooks — one plugin that + works the same in Claude Code and Claude Cowork.
    • Hooks keep everything fresh — a pull when you submit a prompt (Claude reads the team's latest files) and an async push after every edit (artifacts hit the server seconds after Claude writes them). Works for every teammate, plugin or not.
    • -
    • The skill teaches Claude the CLI, so "share this report with - the team" just works.
    • +
    • Every file it writes comes with a link — the turn-start hook + teaches Claude to drop a gated hub link beside any synced path it mentions, + so "share this report with the team" just works.
    Explore the plugin →
    @@ -468,8 +518,8 @@ every tool works content-addressed blobs append-only, no locks, offl

    GET BEARDRIVE

    Self-host everything. Or let us run it.

    The entire product is open source under AGPL-3.0 — server, teams, - history, share links, nothing held back. The cloud exists for teams who'd - rather never think about buckets.

    + history, share links, nothing held back. Or skip the buckets entirely — + BearDrive Cloud is live at app.beardrive.ai.

    Open source

    @@ -487,16 +537,16 @@ every tool works content-addressed blobs append-only, no locks, offl

    BearDrive Cloud

    -
    beardrive.ai · managed hosting · coming soon
    +
    app.beardrive.ai · managed hosting · live now
      -
    • Zero setup — bdrive login and go
    • -
    • SSO, backups, and support handled for you
    • +
    • Zero setup — sign up, bdrive login, and go
    • +
    • Managed storage, backups, and support handled for you
    • Funds the open source project
    -
    - - -
    +
    @@ -504,10 +554,11 @@ every tool works content-addressed blobs append-only, no locks, offl
    -

    Share files. Share context.
    Self-host in one binary.

    -

    brew install, run one hub, bdrive init. Files travel to people as URLs; context travels to every agent on the team. Cloud — zero-setup — is on the waitlist.

    +

    Share files. Share context.
    Cloud or self-hosted.

    +

    Sign up for the managed cloud and go, or brew install and self-host in one binary. Files travel to people as URLs; context travels to every agent on the team.

    - Start on GitHub + Start free → + Self-host on GitHub