mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(templates): start a project from a structure, not an empty folder (#97)
* feat(templates): start a project from a structure, not an empty folder A new project was an empty folder with a .bdriveignore in it, so every agent session invented its own layout and the folder rotted into a pile. Both surfaces now offer the same three starting points — from a template, from scratch, from an existing folder (which is just a non-empty folder, and is never restructured). internal/templates holds the shipped set as literal go:embed'ed files: `docs` (docs/, decisions/) and `para` (projects/, areas/, resources/, archives/). cmd/bdrive is one binary for the CLI and the hub, so both read the identical set — no gallery, no drift. The AGENTS.md in each is the deliverable: where a new note goes, when something is archived, what a good filename looks like. Every directory holds a real file, because BearDrive syncs paths and an empty directory would never reach a teammate. The hub seeds at creation through the existing Upload+Commit path, journaled under its own device, and records the choice on the project record — so a user who picked PARA in a browser sees PARA in the browser, and a later init cannot seed a second copy. `bdrive init --template <name>` goes through the same endpoint, with a local-seed fallback for a hub too old to know the field, and seeds in place when re-run in an already-initialized folder (the agent's post-init path). Seeding never overwrites an existing path, which is what makes a double-seed a no-op rather than a divergence. Refusals cost nothing: an unknown name and --template with --only are both rejected before any network call or write. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(cli): joining a project that already has a template is refused by name The one acceptance case with no test behind it: connecting to an existing project with --template must say what the project was actually created from, and must not write the other skeleton on the way out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(templates): name the docs template in plain English, not an acronym "Plain docs + ADRs" was the recommended, first, preselected-adjacent option in a picker that non-engineers see — and it's the label people accept without reading further, so half of it not parsing is the worst place for jargon. The title also disagreed with its own blurb: "ADRs" over "docs/, decisions/", two words for the same folder one line apart. Now "Docs + decision records", which says the same thing to everyone and matches the folder names. The term itself moves into decisions/0001-record-decisions.md, where the reader is already inside the structure and the file can teach it in passing. One line in the registry drives both the web dialog and the CLI menu; the rest is prose echoing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(templates): add the LLM wiki template The third starting point from the issue title, unblocked: the spec parked it because shipping an approximation under someone's name needed a source, and there is now one — Karpathy's LLM Wiki gist. Worth noting the issue's own one-line description of it ("few large, append-heavy topic pages") does not match the source, which is the opposite: many interlinked pages, where a single ingest touches 10-15 of them. The pattern is three layers and three operations, not a folder shape. sources/ is yours and immutable; wiki/ is the agent's and it owns every page; AGENTS.md is the schema layer — which is exactly the file this template system already treats as the deliverable, so the fit is direct. index.md and log.md ship as the two navigation files the pattern turns on. Three of the things the gist tells you to go set up, BearDrive already is: version history and collaboration (per-file history, bdrive log), an Obsidian- style reader for [[wikilinks]] (the hub viewer), and a surface for the lint pass (the dashboard is literally reads x staleness). Two rules in the AGENTS.md are load-bearing and deliberate. A page write that has not updated the index is an incomplete write — a stale index is worse than a missing page, because it is read first and believed. And with no sources yet, build nothing: the structure grows out of the material rather than ahead of it. Shipped second, not first: docs stays the recommendation because a default is the option chosen by people not reading closely, and this pattern degrades badly when half-followed. Promoting it later is one line in the registry. The shipped-template test now checks the "what happens when something stops being true" question through a set of alternatives — PARA archives, a wiki supersedes and revises — since the vocabulary honestly differs by structure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): "I already have a folder" as a starting point The third way to start from the spec — adopt what you already have — had no presence in the browser. Templates and "empty" were the only visible answers, so someone with a folder of notes either hesitated or picked a template and got four directories merged into their material. The constraint that shapes it: the browser cannot reach your disk, so this cannot change what is created. It creates the same empty project "Empty project" does; what it changes is the next screen. Create therefore stays enabled — disabling it would leave the dialog a dead end AND produce no project id, which is the one thing the paste prompt actually needs. Landing on the project home with the intent, three things differ: the guide says "in the folder you already have", a note states plainly that connecting never moves, renames or overwrites anything, and the paste prompt tells the agent a folder already exists. That last one is the part that isn't cosmetic — without it an agent reads an empty project and proposes creating shared/, the one recommendation that is wrong here. It still asks which folder: that is the runbook's hard gate and nothing here weakens it. The intent rides in the URL (?connect=existing) rather than onto the project record, the same way ?v= pins a file version. It belongs to whoever is connecting right now — a teammate who connects next week has their own answer and would be told the wrong thing by a persisted flag. Five rows made the dialog tall enough to push Create off a short viewport, so .modal scrolls internally. A hairline divider between the seeding and non-seeding rows was tried and removed: --border is 7% white, which at 1px in a gap renders as literally nothing. The gap is the cue that reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(web): with no projects, open the create dialog and give the page a way in A signed-in account with no projects landed on a page whose only path forward was pasting a prompt into a coding agent. Now the create dialog opens itself — with nothing to browse there is nothing else on that page to do — and the page behind it leads with "Start a project" and a button, so closing the dialog is not a dead end. The dialog moves up to HubApp because three things ask for it now: the sidebar's +, the empty state's button, and the auto-open. ProjectNav keeps only an onNew callback; one owner beats three copies of the create handler. Two guards on the auto-open. It fires once per mount, keyed off a ref rather than the empty state, or closing it would immediately reopen it. And it never fires on a read-only hub, which refuses creation server-side with a 403 — opening a dialog that cannot succeed is worse than the page it covers. The agent paste-prompt stays, demoted to "Or let your agent do it": it is still the right path for someone who wants the folder connected in the same breath, and it is the only path on a hub where this account cannot create. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a05a1f8a52
commit
1c703c95f8
@@ -581,3 +581,126 @@ func mustParse(t *testing.T, raw string) *url.URL {
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// `bdrive init --template` is the CLI-first path to a structured project: the
|
||||
// hub seeds it at creation (the CLI creates through the same endpoint the
|
||||
// browser does), init's blocking first cycle pulls it, and re-running the
|
||||
// same command is a no-op rather than a second copy.
|
||||
func TestCLITemplateSeeding(t *testing.T) {
|
||||
e := newCLIEnv(t)
|
||||
run, hub, browser := e.run, e.hub, e.browser
|
||||
|
||||
work := filepath.Join(t.TempDir(), "brain")
|
||||
if err := os.MkdirAll(work, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer run(work, "stop", work)
|
||||
out, err := run(work, "init", "--name", "seeded", "--template", "docs", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("init --template: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "docs template") {
|
||||
t.Fatalf("init said nothing about the template:\n%s", out)
|
||||
}
|
||||
|
||||
want := []string{"AGENTS.md", filepath.Join("decisions", "0001-record-decisions.md"), filepath.Join("docs", "README.md")}
|
||||
for _, rel := range want {
|
||||
if !fileExists(filepath.Join(work, rel)) {
|
||||
t.Fatalf("%s is not on disk after init --template docs:\n%s", rel, out)
|
||||
}
|
||||
}
|
||||
id := projectIDByName(t, browser, hub.URL, "seeded")
|
||||
paths := hubPaths(t, browser, hub.URL, id)
|
||||
for _, rel := range []string{"AGENTS.md", "decisions/0001-record-decisions.md", "docs/README.md"} {
|
||||
if !paths[rel] {
|
||||
t.Fatalf("%s never reached the hub: %v", rel, paths)
|
||||
}
|
||||
}
|
||||
// Every directory of the template has a file in it — an empty directory
|
||||
// would never sync, so the structure would silently not exist for a
|
||||
// teammate.
|
||||
if !paths["docs/README.md"] || !paths["decisions/0001-record-decisions.md"] {
|
||||
t.Fatalf("a template directory reached the hub empty: %v", paths)
|
||||
}
|
||||
|
||||
// Re-running is safe: the runbook promises it, and agents pass --yes.
|
||||
before, err := os.ReadFile(filepath.Join(work, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := run(work, "init", "--template", "docs", "--yes"); err != nil {
|
||||
t.Fatalf("re-init --template: %v\n%s", err, out)
|
||||
}
|
||||
after, err := os.ReadFile(filepath.Join(work, "AGENTS.md"))
|
||||
if err != nil || string(after) != string(before) {
|
||||
t.Fatalf("re-running init --template rewrote AGENTS.md (err %v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The two refusals that must cost nothing: a scope that would hide the
|
||||
// template from the whole team, and a name that does not exist.
|
||||
func TestCLITemplateRefusals(t *testing.T) {
|
||||
e := newCLIEnv(t)
|
||||
run := e.run
|
||||
|
||||
scoped := filepath.Join(t.TempDir(), "scoped")
|
||||
if err := os.MkdirAll(scoped, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := run(scoped, "init", "--name", "scoped", "--template", "para", "--only", "docs", "--yes")
|
||||
if err == nil {
|
||||
t.Fatalf("--template with --only should be refused:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, ".bdriveignore") {
|
||||
t.Fatalf("the refusal should say why (scope lives in .bdriveignore):\n%s", out)
|
||||
}
|
||||
if fileExists(filepath.Join(scoped, ".bdrive", "config.json")) {
|
||||
t.Fatal("a refused init still initialized the folder")
|
||||
}
|
||||
if fileExists(filepath.Join(scoped, "AGENTS.md")) {
|
||||
t.Fatal("a refused init still seeded files")
|
||||
}
|
||||
|
||||
// Joining a project that already exists never restructures it: the
|
||||
// refusal has to name what it was actually created from.
|
||||
first := filepath.Join(t.TempDir(), "first")
|
||||
if err := os.MkdirAll(first, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := run(first, "init", "--name", "taken", "--template", "docs", "--yes"); err != nil {
|
||||
t.Fatalf("init first: %v\n%s", err, out)
|
||||
}
|
||||
defer run(first, "stop", first)
|
||||
second := filepath.Join(t.TempDir(), "second")
|
||||
if err := os.MkdirAll(second, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = run(second, "init", "--name", "taken", "--template", "para", "--yes")
|
||||
defer run(second, "stop", second)
|
||||
if err == nil {
|
||||
t.Fatalf("a template on an existing project should be refused:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "docs") {
|
||||
t.Fatalf("the refusal should name the project's existing template:\n%s", out)
|
||||
}
|
||||
if fileExists(filepath.Join(second, "projects", "README.md")) {
|
||||
t.Fatal("a refused init still wrote the para skeleton")
|
||||
}
|
||||
|
||||
bad := filepath.Join(t.TempDir(), "bad")
|
||||
if err := os.MkdirAll(bad, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = run(bad, "init", "--name", "bad", "--template", "karpathy-wiki", "--yes")
|
||||
if err == nil {
|
||||
t.Fatalf("an unknown template should be refused:\n%s", out)
|
||||
}
|
||||
for _, name := range []string{"docs", "para"} {
|
||||
if !strings.Contains(out, name) {
|
||||
t.Fatalf("the refusal should name the valid set (%s missing):\n%s", name, out)
|
||||
}
|
||||
}
|
||||
if fileExists(filepath.Join(bad, ".bdrive", "config.json")) {
|
||||
t.Fatal("a refused init still initialized the folder")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,9 @@ func TestMetaStoreConformance(t *testing.T) {
|
||||
if err := projects.SetDefault(p1.ID, PermNone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := projects.SetTemplate(p1.ID, "para"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for email, level := range map[string]string{
|
||||
"boss@x.io": PermAdmin, "reader@x.io": PermRead, "cutoff@x.io": PermNone,
|
||||
} {
|
||||
@@ -256,6 +259,9 @@ func TestMetaStoreConformance(t *testing.T) {
|
||||
if hb.Creator != "boss@x.io" || hb.Default != PermNone {
|
||||
t.Fatalf("creator/default lost across reload: %+v", hb)
|
||||
}
|
||||
if hb.Template != "para" {
|
||||
t.Fatalf("template lost across reload: %+v", hb)
|
||||
}
|
||||
if hb.Perms["boss@x.io"] != PermAdmin || hb.Perms["reader@x.io"] != PermRead ||
|
||||
hb.Perms["cutoff@x.io"] != PermNone || len(hb.Perms) != 3 {
|
||||
t.Fatalf("grants lost across reload: %+v", hb.Perms)
|
||||
|
||||
@@ -177,6 +177,7 @@ func (s *sqlMetaStore) migrate() error {
|
||||
"icon": `TEXT NOT NULL DEFAULT ''`,
|
||||
"creator": `TEXT NOT NULL DEFAULT ''`,
|
||||
"default_level": `TEXT NOT NULL DEFAULT ''`,
|
||||
"template": `TEXT NOT NULL DEFAULT ''`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -299,7 +300,7 @@ type sqlProjectRepo struct{ s *sqlMetaStore }
|
||||
|
||||
func (r *sqlProjectRepo) Load() ([]Project, error) {
|
||||
rows, err := r.s.db.Query(
|
||||
`SELECT id, name, org, created, description, icon, creator, default_level FROM projects`)
|
||||
`SELECT id, name, org, created, description, icon, creator, default_level, template FROM projects`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -309,7 +310,7 @@ func (r *sqlProjectRepo) Load() ([]Project, error) {
|
||||
var p Project
|
||||
var created string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created,
|
||||
&p.Description, &p.Icon, &p.Creator, &p.Default); err != nil {
|
||||
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -360,12 +361,12 @@ func (r *sqlProjectRepo) Put(p Project) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(r.s.q(
|
||||
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
|
||||
description=excluded.description, icon=excluded.icon,
|
||||
creator=excluded.creator, default_level=excluded.default_level`),
|
||||
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default); err != nil {
|
||||
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`),
|
||||
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil {
|
||||
|
||||
@@ -69,14 +69,27 @@ test("join link accepts an invite after sign-in", async ({ page, browser }) => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test("no-org account gets the onboarding empty state with the agent prompt", async ({
|
||||
test("no projects: the create dialog opens itself, and the page behind is no dead end", async ({
|
||||
page,
|
||||
}) => {
|
||||
await login(page, "solo@example.com");
|
||||
// With nothing to browse, the one useful action opens on arrival.
|
||||
await expect(page.locator(".modal .start-points")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".modal-input")).toHaveCount(0);
|
||||
|
||||
// Closing it leaves a page that says what to do — and a way back in.
|
||||
await expect(page.locator(".onboard h1")).toHaveText("Welcome to BearDrive");
|
||||
await expect(page.locator(".ob-card h3")).toHaveText("Connect a new drive to your project");
|
||||
// The agent paste-prompt is the one path, with this hub's real origin
|
||||
// filled in; the by-hand route is a docs link.
|
||||
await expect(page.locator(".ob-start h3")).toHaveText("Start a project");
|
||||
await page.click("#ob-new");
|
||||
await expect(page.locator(".modal-input")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
// Dismissed once, it stays dismissed until asked for again.
|
||||
await expect(page.locator(".modal-input")).toHaveCount(0);
|
||||
|
||||
// The agent paste-prompt is still the other path, with this hub's real
|
||||
// origin filled in; the by-hand route is a docs link.
|
||||
await expect(page.locator(".ob-agent h3")).toHaveText("Or let your agent do it");
|
||||
await expect(page.locator(".onboard .gd-code code")).toContainText(
|
||||
"to set up a new BearDrive project on http://localhost:8993. Ask me which folder to sync.",
|
||||
);
|
||||
@@ -86,10 +99,21 @@ test("no-org account gets the onboarding empty state with the agent prompt", asy
|
||||
);
|
||||
});
|
||||
|
||||
// An account that already has projects must not get the dialog thrown at it.
|
||||
test("the create dialog does not open itself when projects exist", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator("#project-select")).toBeVisible();
|
||||
await expect(page.locator(".modal-input")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("new project via the sidebar + modal", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#projects .nav-add");
|
||||
await page.fill(".modal-input", "scratch");
|
||||
// The starting point defaults to an empty project, so this path still
|
||||
// describes exactly what it did before templates existed.
|
||||
await expect(page.locator(".start-points")).toBeVisible();
|
||||
await expect(page.locator(".start-point.on")).toContainText("Empty project");
|
||||
await page.click(".modal .pbtn");
|
||||
await page.waitForURL(/\/[0-9a-f-]{36}$/);
|
||||
await expect(page.locator("#project-select")).toContainText("scratch");
|
||||
@@ -102,6 +126,49 @@ test("new project via the sidebar + modal", async ({ page }) => {
|
||||
await expectToast(page, "Created");
|
||||
});
|
||||
|
||||
// Picking a template seeds the project on the hub, so the folder listing
|
||||
// shows the structure before any device has ever connected.
|
||||
test("new project from a template", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#projects .nav-add");
|
||||
await page.fill(".modal-input", "from-template");
|
||||
await page.click('.start-point:has-text("Docs + decision records")');
|
||||
await expect(page.locator(".start-point.on")).toContainText("Docs + decision records");
|
||||
await page.click(".modal .pbtn");
|
||||
await page.waitForURL(/\/[0-9a-f-]{36}$/);
|
||||
await expect(page.locator("#project-select")).toContainText("from-template");
|
||||
// Asserted on the file tree, not #content: a brand-new project's dashboard
|
||||
// deliberately paints no treemap cells (#93), so #content is not where a
|
||||
// seeded file reliably shows up.
|
||||
for (const name of ["docs", "decisions", "AGENTS.md"]) {
|
||||
await expect(page.locator("#sidebar").getByText(name, { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
// "I already have a folder" creates the same empty project as "Empty
|
||||
// project" — the browser cannot touch your disk — so what it must change is
|
||||
// the next screen: the paste prompt stops telling the agent to make a new
|
||||
// folder, and the reassurance appears. The intent rides in the URL, so it
|
||||
// survives a reload.
|
||||
test("new project from an existing folder", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#projects .nav-add");
|
||||
await page.fill(".modal-input", "brought-my-own");
|
||||
await page.click('.start-point:has-text("I already have a folder")');
|
||||
await page.click(".modal .pbtn");
|
||||
await page.waitForURL(/connect=existing/);
|
||||
await expect(page.locator(".gd-note")).toContainText("never moves, renames or overwrites");
|
||||
await expect(page.locator(".gd-code code").first()).toContainText(
|
||||
"I already have a folder of notes — ask me which one to sync",
|
||||
);
|
||||
// Nothing was seeded: same artifact as an empty project. Checked on the
|
||||
// file tree, not #content — the paste prompt names INSTALL_FOR_AGENTS.md,
|
||||
// which a substring match on "AGENTS.md" happily finds.
|
||||
await expect(page.locator("#sidebar").getByText("AGENTS.md", { exact: true })).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.locator(".gd-note")).toBeVisible();
|
||||
});
|
||||
|
||||
test("account menu closes on Escape and outside click", async ({ page }) => {
|
||||
await login(page);
|
||||
await page.click("#account-btn");
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { chromium } from "@playwright/test";
|
||||
const dir = process.argv[2];
|
||||
const browser = await chromium.launch();
|
||||
|
||||
// desktop: the three picker states, cropped to the dialog
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto("http://localhost:8993/");
|
||||
await page.waitForURL(/auth\/login/);
|
||||
await page.fill('input[name="email"]', "e2e@example.com");
|
||||
await page.fill('input[name="password"]', "e2e-pass-1");
|
||||
await page.click("form button");
|
||||
await page.waitForSelector("#sidebar");
|
||||
await page.click("#projects .nav-add");
|
||||
await page.waitForSelector(".modal-input");
|
||||
await page.fill(".modal-input", "team-wiki");
|
||||
const modal = page.locator(".modal");
|
||||
for (const [file, label] of [
|
||||
["template-select-empty", "Empty project"],
|
||||
["template-select-docs", "Docs + decision records"],
|
||||
["template-select-para", "PARA"],
|
||||
]) {
|
||||
await page.click(`.start-point:has-text("${label}")`);
|
||||
await page.waitForTimeout(250);
|
||||
await modal.screenshot({ path: `${dir}/${file}.png` });
|
||||
}
|
||||
// full-window "after" shot of the dialog, default state
|
||||
await page.click('.start-point:has-text("Empty project")');
|
||||
await page.waitForTimeout(200);
|
||||
await page.fill(".modal-input", "");
|
||||
await page.screenshot({ path: `${dir}/new-project-dialog-after.png` });
|
||||
await page.close();
|
||||
|
||||
// mobile
|
||||
const m = await browser.newPage({ viewport: { width: 375, height: 812 } });
|
||||
await m.goto("http://localhost:8993/");
|
||||
await m.waitForSelector("#sidebar");
|
||||
const burger = m.locator("#sb-toggle, .sb-toggle, [aria-label='Menu']").first();
|
||||
if (await burger.count()) await burger.click();
|
||||
await m.waitForTimeout(300);
|
||||
await m.click("#projects .nav-add");
|
||||
await m.waitForSelector(".modal-input");
|
||||
await m.waitForTimeout(400);
|
||||
await m.screenshot({ path: `${dir}/new-project-dialog-mobile-after.png` });
|
||||
console.log("overflow:", await m.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth));
|
||||
await browser.close();
|
||||
@@ -16,6 +16,10 @@ export interface ServerConfig {
|
||||
admin?: boolean;
|
||||
};
|
||||
reads: { enabled: boolean };
|
||||
// Starting structures a new project can be created from (internal/templates,
|
||||
// go:embed'ed into the server). Served rather than hardcoded here so a hub
|
||||
// shipping another one needs no frontend change.
|
||||
templates?: StartTemplate[];
|
||||
me?: { email: string; name: string };
|
||||
// Managed deployments only: where billing lives + the user's current plan.
|
||||
billing?: { plan: string; url: string };
|
||||
@@ -40,6 +44,13 @@ export interface BillingInfo {
|
||||
portal_url: string;
|
||||
}
|
||||
|
||||
// One entry of /api/config's `templates` (templates.Template).
|
||||
export interface StartTemplate {
|
||||
name: string; // the API/flag value, e.g. "docs"
|
||||
title: string; // menu label, e.g. "Docs + decision records"
|
||||
blurb: string; // the one-line shape, e.g. "docs/, decisions/"
|
||||
}
|
||||
|
||||
// Per-project permission levels (perms.go). Ordered: each includes the ones
|
||||
// before it.
|
||||
export type PermLevel = "none" | "read" | "write" | "admin";
|
||||
@@ -60,6 +71,8 @@ export interface Project {
|
||||
/** lucide icon name (kebab-case); unknown or absent → the folder placeholder */
|
||||
icon?: string;
|
||||
creator?: string;
|
||||
/** the starting structure it was created from ("" / absent for an empty project) */
|
||||
template?: string;
|
||||
// The signed-in account's effective level on this project, resolved
|
||||
// server-side. A project you cannot read never appears in the list at all,
|
||||
// so this is always read or better here.
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function Browser(props: {
|
||||
// dashboard below it.
|
||||
view = (
|
||||
<>
|
||||
<ConnectGuide project={project!} />
|
||||
<ConnectGuide project={project!} existing={route.connect === "existing"} />
|
||||
<div className="home-insights">
|
||||
<Insights
|
||||
flatFiles={flatFiles}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { postJSON } from "../api/http";
|
||||
import type { InviteAccepted, Project, ServerConfig } from "../api/types";
|
||||
import type { InviteAccepted, Project, ProjectCreated, ServerConfig } from "../api/types";
|
||||
import { useOrgs, usePending, useProjects, useHubRefresh } from "../hooks/useHub";
|
||||
import { parseRoute, urlForPath, urlForView } from "../router";
|
||||
import { linkProps, navigate, Redirect, useLocationPath } from "../nav";
|
||||
@@ -13,6 +13,7 @@ import { BillingView } from "../components/BillingView";
|
||||
import { ProjectSettings } from "../components/ProjectSettings";
|
||||
import { ConnectGuide } from "../components/ConnectGuide";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { EXISTING, NewProjectDialog } from "../components/NewProjectDialog";
|
||||
import { toast } from "../toast";
|
||||
import Browser from "./Browser";
|
||||
|
||||
@@ -40,6 +41,52 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
|
||||
const route = useMemo(() => parseRoute(loc, "hub"), [loc]);
|
||||
|
||||
// Creating a project is asked for from three places — the sidebar's +, the
|
||||
// empty state's button, and the auto-open below — so the dialog and its one
|
||||
// handler live here rather than in any of them.
|
||||
const [creating, setCreating] = useState(false);
|
||||
// A read-only hub refuses creation server-side (403), so never offer it.
|
||||
const canCreate = config.upload.enabled;
|
||||
|
||||
const createProject = async (name: string, template: string) => {
|
||||
// "I already have a folder" is an empty project with a different next
|
||||
// screen: the server never hears the sentinel, and the intent rides in
|
||||
// the URL instead of onto the project record — it belongs to whoever is
|
||||
// connecting right now, not to the project forever.
|
||||
const existing = template === EXISTING;
|
||||
try {
|
||||
const out = await postJSON<ProjectCreated>("/api/projects", {
|
||||
name,
|
||||
template: existing ? "" : template,
|
||||
});
|
||||
setCreating(false);
|
||||
await refresh();
|
||||
navigate("/" + out.project.id + (existing ? "?connect=existing" : ""));
|
||||
toast(`Created “${out.project.name}”.`);
|
||||
} catch (e) {
|
||||
toast("Could not create the project: " + (e as Error).message, true);
|
||||
}
|
||||
};
|
||||
|
||||
// With no projects at all there is nothing else on the page to do, so the
|
||||
// dialog opens itself. Once per mount, keyed off a ref rather than the
|
||||
// empty state — otherwise closing it would immediately reopen it.
|
||||
const autoOpened = useRef(false);
|
||||
useEffect(() => {
|
||||
if (autoOpened.current || joinToken) return;
|
||||
if (!projects || projects.length > 0 || !canCreate) return;
|
||||
autoOpened.current = true;
|
||||
setCreating(true);
|
||||
}, [projects, canCreate, joinToken]);
|
||||
|
||||
const newProjectDialog = creating ? (
|
||||
<NewProjectDialog
|
||||
templates={config.templates ?? []}
|
||||
onCreate={createProject}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const current: Project | null = useMemo(() => {
|
||||
if (!projects) return null;
|
||||
return (
|
||||
@@ -110,13 +157,14 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
return (
|
||||
<AppShell
|
||||
vault={vault}
|
||||
projectsNav={<ProjectNav projects={projects} />}
|
||||
projectsNav={<ProjectNav projects={projects} onNew={() => setCreating(true)} />}
|
||||
orgBar={accountBar}
|
||||
topbar={<Topbar />}
|
||||
>
|
||||
<Page>
|
||||
<EmptyState />
|
||||
<EmptyState onNew={() => setCreating(true)} canCreate={canCreate} />
|
||||
</Page>
|
||||
{newProjectDialog}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -196,7 +244,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
// it used to sit in the .onboard card, 320px narrower and 90px
|
||||
// lower than home, two sidebar items apart.
|
||||
crumb: "Installation",
|
||||
body: <ConnectGuide project={current} />,
|
||||
body: <ConnectGuide project={current} existing={route.connect === "existing"} />,
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -223,7 +271,8 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Browser
|
||||
<>
|
||||
<Browser
|
||||
key={current.id} // fresh tree/fold state per project
|
||||
config={config}
|
||||
apiBase={"/api/p/" + current.id + "/"}
|
||||
@@ -237,6 +286,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
<ProjectNav
|
||||
projects={projects}
|
||||
currentId={current.id}
|
||||
onNew={() => setCreating(true)}
|
||||
menu={{
|
||||
// Scoped views (/dashboard/<path>, /history/<path>) belong to
|
||||
// the file/folder — the tree carries the selection, no menu
|
||||
@@ -281,7 +331,9 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
}}
|
||||
panel={activePanel || orgPage || billingPage || routePage}
|
||||
onClosePanel={() => setPanel(null)}
|
||||
/>
|
||||
/>
|
||||
{newProjectDialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,16 @@ import { projColor } from "./ProjectNav";
|
||||
|
||||
export const INSTALL_DOC = "https://raw.githubusercontent.com/runbear-io/beardrive/main/INSTALL_FOR_AGENTS.md";
|
||||
|
||||
export function ConnectGuide({ project }: { project: Project }) {
|
||||
export function ConnectGuide({ project, existing }: { project: Project; existing?: boolean }) {
|
||||
const origin = window.location.origin;
|
||||
// When the creator said they already have a folder, say so in the prompt.
|
||||
// Without it an agent reads an empty project and proposes creating a new
|
||||
// subfolder — the one recommendation that is wrong for this person. It
|
||||
// still asks which folder: that question is the runbook's hard gate and
|
||||
// nothing here may weaken it.
|
||||
const ask = existing
|
||||
? '. I already have a folder of notes — ask me which one to sync (the project is named "'
|
||||
: '. Ask me which folder to sync (the project is named "';
|
||||
const prompt =
|
||||
"Follow " +
|
||||
INSTALL_DOC +
|
||||
@@ -23,7 +31,7 @@ export function ConnectGuide({ project }: { project: Project }) {
|
||||
project.id +
|
||||
" on " +
|
||||
origin +
|
||||
'. Ask me which folder to sync (the project is named "' +
|
||||
ask +
|
||||
project.name +
|
||||
'").';
|
||||
const manual =
|
||||
@@ -48,9 +56,16 @@ export function ConnectGuide({ project }: { project: Project }) {
|
||||
{project.description && <p className="in-desc">{project.description}</p>}
|
||||
<div className="gd-body">
|
||||
<p className="gd-desc">
|
||||
Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the
|
||||
folder where you want the files:
|
||||
{existing
|
||||
? "Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the folder you already have:"
|
||||
: "Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the folder where you want the files:"}
|
||||
</p>
|
||||
{existing && (
|
||||
<p className="gd-note">
|
||||
Your files stay exactly where they are. Connecting a folder never moves, renames or
|
||||
overwrites anything in it — it uploads what is there and keeps it in sync.
|
||||
</p>
|
||||
)}
|
||||
<GuideCode code={prompt} />
|
||||
<p className="gd-desc">
|
||||
The agent installs the CLI, signs this machine in, and registers the sync hooks — asking
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GuideCode, INSTALL_DOC } from "./ConnectGuide";
|
||||
|
||||
// Onboarding: a signed-in account with no projects shouldn't hit a blank
|
||||
// sidebar. One path in — paste the canonical install prompt into a coding
|
||||
// agent — with the by-hand route a docs link away.
|
||||
// sidebar. Two paths in, in the order most people want them — create the
|
||||
// project here and pick what it starts from, or paste the install prompt into
|
||||
// a coding agent and let it do the whole thing. The by-hand route stays a
|
||||
// docs link away.
|
||||
//
|
||||
// The create dialog also opens itself on arrival (HubApp): with no projects
|
||||
// there is nothing else on this page to do. This page is what is left when
|
||||
// someone closes it, so it must not be a dead end — hence the button.
|
||||
|
||||
export function EmptyState() {
|
||||
export function EmptyState({ onNew, canCreate }: { onNew: () => void; canCreate: boolean }) {
|
||||
return (
|
||||
<div className="onboard">
|
||||
<h1>Welcome to BearDrive</h1>
|
||||
<p>You're signed in, but you're not part of any project yet.</p>
|
||||
<div className="ob-card">
|
||||
<h3>Connect a new drive to your project</h3>
|
||||
{canCreate && (
|
||||
<div className="ob-card ob-start">
|
||||
<h3>Start a project</h3>
|
||||
<p>
|
||||
Name it and pick what it starts from — a structure, or nothing at all. Then connect a
|
||||
folder on any machine and it stays in sync.
|
||||
</p>
|
||||
<Button variant="primary" id="ob-new" onClick={onNew}>
|
||||
New project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="ob-card ob-agent">
|
||||
<h3>{canCreate ? "Or let your agent do it" : "Connect a new drive to your project"}</h3>
|
||||
<p>
|
||||
Paste into your coding agent — Claude Code, Cowork, Codex, Gemini CLI, Hermes — in the
|
||||
folder where you want the files. It creates the project and starts syncing:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import type { StartTemplate } from "../api/types";
|
||||
|
||||
// The "I already have a folder" pick. It creates the same empty project the
|
||||
// "Empty project" pick does — the browser cannot reach your disk, so this can
|
||||
// only change what you are told next, never what is created. Create therefore
|
||||
// stays enabled: disabling it would leave the dialog a dead end AND produce no
|
||||
// project id, which is the one thing the paste prompt actually needs.
|
||||
export const EXISTING = "__existing__";
|
||||
|
||||
// The create-project dialog: a name, plus where the project starts from.
|
||||
//
|
||||
// Not a modalPrompt() variant on purpose — that API exists for one-field
|
||||
// prompts, and teaching it about choices makes every other caller pay for the
|
||||
// shape. This is a local useState over the Dialog we already have.
|
||||
//
|
||||
// The options come from /api/config, so a hub that ships another template
|
||||
// needs no change here. "Empty project" is the synthetic first-class option
|
||||
// (value "") and stays preselected: creating a project without picking
|
||||
// anything must behave exactly as it did before templates existed.
|
||||
export function NewProjectDialog({
|
||||
templates,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
templates: StartTemplate[];
|
||||
onCreate: (name: string, template: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
// "" is an empty project; EXISTING is also an empty project — same artifact,
|
||||
// different intent, and the intent is what the next screen needs to know.
|
||||
const [template, setTemplate] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
if (busy) return;
|
||||
if (!name.trim()) {
|
||||
setErr("Give it a name.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await onCreate(name.trim(), template);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Recommended first, then the rest, then the two that seed nothing. The
|
||||
// divider before them is doing real work: everything above answers "what
|
||||
// should we put in it", everything below answers "nothing".
|
||||
const options = [
|
||||
...templates.map((t) => ({ value: t.name, title: t.title, blurb: t.blurb, rule: false })),
|
||||
{
|
||||
value: EXISTING,
|
||||
title: "I already have a folder",
|
||||
blurb: "nothing is seeded — connect it and your files stay as they are",
|
||||
rule: true,
|
||||
},
|
||||
{ value: "", title: "Empty project", blurb: "just the folder", rule: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="modal" showCloseButton={false}>
|
||||
<DialogTitle asChild>
|
||||
<h3>New project</h3>
|
||||
</DialogTitle>
|
||||
<label className="modal-label" htmlFor="modal-input">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="modal-input"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
id="modal-input"
|
||||
autoFocus
|
||||
value={name}
|
||||
aria-invalid={!!err}
|
||||
aria-describedby={err ? "modal-input-err" : undefined}
|
||||
onChange={(e) => {
|
||||
setName(e.currentTarget.value);
|
||||
if (err) setErr("");
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
{err && (
|
||||
<span id="modal-input-err" role="alert" className="field-err">
|
||||
{err}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{options.length > 1 && (
|
||||
<fieldset className="start-points">
|
||||
<legend className="modal-label">Starting point</legend>
|
||||
{options.map((o, i) => (
|
||||
<label
|
||||
key={o.value}
|
||||
className={
|
||||
"start-point" + (template === o.value ? " on" : "") + (o.rule ? " sp-rule" : "")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="template"
|
||||
value={o.value}
|
||||
checked={template === o.value}
|
||||
onChange={() => setTemplate(o.value)}
|
||||
/>
|
||||
<span className="sp-text">
|
||||
<span className="sp-title">
|
||||
{o.title}
|
||||
{i === 0 && <span className="sp-rec">Recommended</span>}
|
||||
</span>
|
||||
<span className="sp-blurb">{o.blurb}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={submit} disabled={busy}>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { postJSON } from "../api/http";
|
||||
import type { Project, ProjectCreated } from "../api/types";
|
||||
import { modalPrompt } from "../modal";
|
||||
import { toast } from "../toast";
|
||||
import { useHubRefresh } from "../hooks/useHub";
|
||||
import type { Project } from "../api/types";
|
||||
import { closeSidebarOnMobile } from "./shell";
|
||||
|
||||
// Deterministic accent for a project's letter-mark, so each project keeps a
|
||||
@@ -35,32 +31,28 @@ export function ProjectNav({
|
||||
projects,
|
||||
currentId,
|
||||
menu,
|
||||
onNew,
|
||||
}: {
|
||||
projects: Project[];
|
||||
currentId?: string;
|
||||
menu?: ProjectMenu;
|
||||
// Opening the create dialog belongs to HubApp: three things ask for it now
|
||||
// (this button, the empty state's button, and the auto-open when a signed-in
|
||||
// account has no projects at all), and one owner beats three copies.
|
||||
onNew: () => void;
|
||||
}) {
|
||||
const refresh = useHubRefresh();
|
||||
const current = projects.find((p) => p.id === currentId);
|
||||
|
||||
const create = async () => {
|
||||
const name = await modalPrompt("New project", "Project name", "", "Create");
|
||||
if (name === null) return;
|
||||
try {
|
||||
const out = await postJSON<ProjectCreated>("/api/projects", { name });
|
||||
await refresh();
|
||||
navigate("/" + out.project.id);
|
||||
toast(`Created “${out.project.name}”.`);
|
||||
} catch (e) {
|
||||
toast("Could not create the project: " + (e as Error).message, true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav id="projects" aria-label="Projects">
|
||||
<div className="nav-head">
|
||||
<span>Projects</span>
|
||||
<button className="nav-add" title="New project" aria-label="New project" onClick={create}>
|
||||
<button
|
||||
className="nav-add"
|
||||
title="New project"
|
||||
aria-label="New project"
|
||||
onClick={onNew}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -57,14 +57,23 @@ export interface Route {
|
||||
// version is the same page pinned to older bytes, so it rides as a query
|
||||
// param on the file route.
|
||||
version?: string;
|
||||
// How the creator said they'd fill this project ("existing" = they already
|
||||
// have a folder). Rides as a query param rather than living on the project
|
||||
// record on purpose: it is one person's intent for their next five minutes,
|
||||
// not a property of the project — a teammate connecting next week has their
|
||||
// own answer, and would be told the wrong thing by a persisted flag.
|
||||
connect?: string;
|
||||
}
|
||||
|
||||
// `url` is pathname + search (what useLocationPath hands back).
|
||||
export function parseRoute(url: string, mode: "volume" | "hub"): Route {
|
||||
const qi = url.indexOf("?");
|
||||
const version = qi === -1 ? "" : new URLSearchParams(url.slice(qi)).get("v") || "";
|
||||
const q = qi === -1 ? null : new URLSearchParams(url.slice(qi));
|
||||
const version = q?.get("v") || "";
|
||||
const connect = q?.get("connect") || "";
|
||||
const r = parsePath(qi === -1 ? url : url.slice(0, qi), mode);
|
||||
if (version) r.version = version;
|
||||
if (connect) r.connect = connect;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,6 +394,10 @@ button, input, a.btn { font-family: inherit; }
|
||||
.ob-card { background: var(--bg-side); border: 1px solid var(--border); border-radius: var(--r-card); padding: 20px 22px; margin-bottom: 14px; }
|
||||
.ob-card h3 { margin: 0 0 6px; font-size: 14.5px; font-weight: 600; }
|
||||
.ob-card p { margin: 0 0 14px; font-size: 13px; color: var(--text-dim); }
|
||||
/* The create-here card leads; the agent-paste card follows it. Accent edge
|
||||
marks which one is the primary path without a second button competing. */
|
||||
.ob-card.ob-start { border-color: var(--border-2); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
.ob-card.ob-start .pbtn { margin-top: 2px; }
|
||||
.ob-alt { margin: 12px 0 0; }
|
||||
.ob-alt a { color: var(--text-faint); font-size: 12.5px; font-weight: 600; text-decoration: none; }
|
||||
.ob-alt a:hover { color: var(--text); }
|
||||
@@ -757,6 +761,31 @@ a.ai-main:hover { color: var(--accent); }
|
||||
.modal-input { width: 100%; height: 36px; padding: 0 12px; border-radius: var(--r-ctl); border: 1px solid var(--border); background: var(--surface); color: var(--text); font: inherit; font-size: 14px; margin-bottom: 16px; outline: none; }
|
||||
.modal-input:focus { border-color: var(--accent); background: var(--hover); }
|
||||
|
||||
/* starting-point picker (new-project dialog) — native radios, styled with
|
||||
the accent token; no extra ui/* component for three options. */
|
||||
.start-points { border: 0; margin: 0 0 18px; padding: 0; }
|
||||
.start-points legend { padding: 0; }
|
||||
.start-point { display: flex; align-items: flex-start; gap: 10px; padding: 9px 11px; border: 1px solid var(--border); border-radius: var(--r-ctl); background: var(--surface); cursor: pointer; margin-bottom: 6px; }
|
||||
.start-point:hover { background: var(--hover); }
|
||||
.start-point.on { border-color: var(--accent); background: var(--hover); }
|
||||
.start-point input { accent-color: var(--accent); margin: 2px 0 0; flex: none; }
|
||||
.sp-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.sp-title { font-size: 13.5px; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.sp-rec { font-size: 10.5px; letter-spacing: .02em; text-transform: uppercase; color: var(--accent); border: 1px solid var(--accent); border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
||||
.sp-blurb { font-size: 12px; color: var(--text-dim); overflow-wrap: anywhere; }
|
||||
/* Extra space separates "seed something" from "seed nothing" — the two rows
|
||||
below it produce the same empty project and differ only in intent. A
|
||||
hairline was tried here first and dropped: --border is 7% white, which at
|
||||
1px in a gap renders as nothing. The gap is the cue that actually reads,
|
||||
and both rows say "nothing is seeded" in their own words anyway. */
|
||||
.start-point.sp-rule { margin-top: 16px; }
|
||||
/* Five rows can outgrow a short viewport; the dialog scrolls rather than
|
||||
pushing Create off-screen. */
|
||||
.modal { max-height: calc(100vh - 32px); overflow-y: auto; }
|
||||
/* The reassurance on the connect guide — the question people actually have
|
||||
when they point BearDrive at notes they already care about. */
|
||||
.gd-note { margin: -4px 0 16px; font-size: 13px; color: var(--text-dim); border-left: 2px solid var(--accent); padding-left: 11px; line-height: 1.55; }
|
||||
|
||||
/* ---- toast ---- */
|
||||
[data-sonner-toast] { background: var(--bg-raise) !important; color: var(--text) !important; border: 1px solid var(--border-2) !important; border-radius: 10px !important; font-size: 13.5px !important; box-shadow: 0 18px 44px -12px rgba(0,0,0,.7) !important; }
|
||||
[data-sonner-toast][data-type="error"] { border-color: rgba(242,109,109,.5) !important; color: #ffb0aa !important; }
|
||||
|
||||
@@ -26,6 +26,11 @@ type Project struct {
|
||||
// explicit admin grant at creation. Empty on projects that predate
|
||||
// per-project permissions — those are governed by org owners.
|
||||
Creator string `json:"creator,omitempty"`
|
||||
// Template is the starting structure the project was created from
|
||||
// (internal/templates), empty for an empty project. Set once, at
|
||||
// creation, by whoever seeded it — it is what stops a second surface
|
||||
// seeding a second copy.
|
||||
Template string `json:"template,omitempty"`
|
||||
// Default is the level every org member gets without an explicit grant.
|
||||
// Empty means write: the historical behavior, so no row needs migrating.
|
||||
Default string `json:"default,omitempty"`
|
||||
@@ -219,6 +224,19 @@ func (db *ProjectDB) SetCreator(id, email string) error {
|
||||
return db.repo.Put(p)
|
||||
}
|
||||
|
||||
// SetTemplate records the starting structure a project was seeded from.
|
||||
func (db *ProjectDB) SetTemplate(id, name string) error {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
p, ok := db.byID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("no such project %q", id)
|
||||
}
|
||||
p.Template = name
|
||||
db.byID[id] = p
|
||||
return db.repo.Put(p)
|
||||
}
|
||||
|
||||
// SetDefault sets the level org members get without an explicit grant.
|
||||
func (db *ProjectDB) SetDefault(id, level string) error {
|
||||
if !validLevel(level) || level == PermAdmin {
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
"github.com/runbear-io/beardrive/internal/remote"
|
||||
"github.com/runbear-io/beardrive/internal/templates"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
@@ -522,6 +523,10 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
},
|
||||
"auth": auth,
|
||||
"reads": map[string]any{"enabled": s.Reads != nil},
|
||||
// The starting structures the create dialog offers. Served rather
|
||||
// than hardcoded in the frontend so a hub that ships another one
|
||||
// needs no frontend change.
|
||||
"templates": templates.List(),
|
||||
}
|
||||
// Outside a managed deployment this block is absent and the frontend
|
||||
// never loads a tracker. Outside the `me` check on purpose: a hub with
|
||||
@@ -610,11 +615,24 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Org string `json:"org,omitempty"`
|
||||
// Template is the starting structure to seed, "" for an empty
|
||||
// project (the historical behavior).
|
||||
Template string `json:"template,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Resolve the template before anything is created, so an unknown name
|
||||
// leaves no project behind.
|
||||
var tpl templates.Template
|
||||
if req.Template != "" {
|
||||
var err error
|
||||
if tpl, err = templates.Get(req.Template); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
org, err := s.orgForCreate(r, req.Org)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrManagedElsewhere) {
|
||||
@@ -652,6 +670,22 @@ func (s *Server) handleProjectCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
p, _ = s.Projects.Get(p.ID)
|
||||
}
|
||||
if tpl.Name != "" {
|
||||
// Seeding failure leaves a real, usable project holding part of a
|
||||
// template. Say so rather than reporting success; there is no
|
||||
// rollback, and deleting a project over a storage hiccup is worse
|
||||
// than an honest error.
|
||||
if err := s.seedTemplate(r.Context(), p.ID, tpl, s.requestUser(r)); err != nil {
|
||||
http.Error(w, fmt.Sprintf("project %s was created, but seeding the %s template failed: %v",
|
||||
p.Name, tpl.Name, err), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if err := s.Projects.SetTemplate(p.ID, tpl.Name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p, _ = s.Projects.Get(p.ID)
|
||||
}
|
||||
} else if !atLeast(s.projectPerm(r, p.ID), PermRead) {
|
||||
// GetOrCreate is create-or-join by name: without this, POSTing the
|
||||
// name of a project you've been cut off from would hand back its id.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='%23f5a623'><rect x='4' y='4' width='5.6' height='24'/><rect x='11.2' y='4' width='14.4' height='11.2'/><rect x='11.2' y='16.8' width='16.8' height='11.2'/></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-344uOWlP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkrUL9C1.css">
|
||||
<script type="module" crossorigin src="/assets/index-BlfX6Osf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BZ355MrB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/templates"
|
||||
)
|
||||
|
||||
// seedTemplate writes a starting structure into a freshly created project,
|
||||
// journaled under this server's own device exactly like a browser upload —
|
||||
// so the files are simply there when the first device syncs, and a user who
|
||||
// picked PARA in a browser sees PARA in the browser.
|
||||
//
|
||||
// A path that already exists is skipped rather than overwritten: seeding is
|
||||
// only ever reached on a just-created project, but the same rule in
|
||||
// templates.WriteTo is what makes a double-seed a no-op, and this is the
|
||||
// other half of it.
|
||||
func (s *Server) seedTemplate(ctx context.Context, projectID string, t templates.Template, who User) error {
|
||||
v, err := s.projectVolume(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
up := v.uploader()
|
||||
if up == nil {
|
||||
return fmt.Errorf("this project's storage is read-only")
|
||||
}
|
||||
var total int64
|
||||
for _, f := range t.Files {
|
||||
total += int64(len(f.Content))
|
||||
}
|
||||
org := s.orgOf(projectID)
|
||||
if err := s.quota().CheckWrite(org, total); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing := map[string]bool{}
|
||||
if snap, err := v.snapshot(ctx); err == nil {
|
||||
for p := range snap.files {
|
||||
existing[p] = true
|
||||
}
|
||||
}
|
||||
for _, f := range t.Files {
|
||||
if existing[f.Path] {
|
||||
continue
|
||||
}
|
||||
if err := up.Upload(ctx, f.Path, strings.NewReader(f.Content), int64(len(f.Content)), who); err != nil {
|
||||
return fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
}
|
||||
s.quota().RecordUsage(org, total)
|
||||
v.invalidate()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/templates"
|
||||
)
|
||||
|
||||
// Creating with a template seeds the files through the hub's own write path,
|
||||
// so a browser-created project arrives structured and every device just
|
||||
// pulls it.
|
||||
func TestProjectCreateWithTemplate(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
h := srv.Handler()
|
||||
|
||||
rec := do(t, h, "POST", "/api/projects", map[string]string{"name": "brain", "template": "para"})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var out struct {
|
||||
Project Project `json:"project"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.Created || out.Project.Template != "para" {
|
||||
t.Fatalf("create response = %+v, want created with template para", out)
|
||||
}
|
||||
|
||||
// The record carries it, so a later init can't seed a second copy.
|
||||
rec = do(t, h, "GET", "/api/projects/"+out.Project.ID, nil)
|
||||
var got Project
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Template != "para" {
|
||||
t.Fatalf("GET /api/projects/{id} template = %q, want para", got.Template)
|
||||
}
|
||||
|
||||
// Every template path is in the tree, journaled under the hub's device.
|
||||
tpl, err := templates.Get("para")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = do(t, h, "GET", "/api/p/"+out.Project.ID+"/history", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("history: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var hist struct {
|
||||
Entries []struct {
|
||||
Path string `json:"path"`
|
||||
Device DeviceInfo `json:"device"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, e := range hist.Entries {
|
||||
seen[e.Path] = e.Device.ID
|
||||
}
|
||||
if len(hist.Entries) != len(tpl.Files) {
|
||||
t.Fatalf("history has %d entries, want one per template file (%d): %v",
|
||||
len(hist.Entries), len(tpl.Files), seen)
|
||||
}
|
||||
for _, f := range tpl.Files {
|
||||
if seen[f.Path] == "" {
|
||||
t.Fatalf("%s missing from the project: %v", f.Path, seen)
|
||||
}
|
||||
if seen[f.Path] != webDevice.ID {
|
||||
t.Fatalf("%s attributed to %q, want the hub's own device %q", f.Path, seen[f.Path], webDevice.ID)
|
||||
}
|
||||
}
|
||||
if !treePaths(t, h, out.Project.ID)["AGENTS.md"] {
|
||||
t.Fatal("the seeded AGENTS.md is not in the file tree")
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown template must be caught before anything is created.
|
||||
func TestProjectCreateUnknownTemplate(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
h := srv.Handler()
|
||||
before := len(srv.Projects.List())
|
||||
|
||||
rec := do(t, h, "POST", "/api/projects", map[string]string{"name": "nope", "template": "karpathy-wiki"})
|
||||
if rec.Code != 400 {
|
||||
t.Fatalf("unknown template: %d %s, want 400", rec.Code, rec.Body)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "docs") || !strings.Contains(rec.Body.String(), "para") {
|
||||
t.Fatalf("the 400 should name the valid set: %s", rec.Body)
|
||||
}
|
||||
if now := len(srv.Projects.List()); now != before {
|
||||
t.Fatalf("a rejected template still created a project (%d → %d)", before, now)
|
||||
}
|
||||
}
|
||||
|
||||
// No template key at all is exactly today's behavior: an empty project.
|
||||
func TestProjectCreateWithoutTemplateStaysEmpty(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
h := srv.Handler()
|
||||
|
||||
rec := do(t, h, "POST", "/api/projects", map[string]string{"name": "plain"})
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var out struct {
|
||||
Project Project `json:"project"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Project.Template != "" {
|
||||
t.Fatalf("template = %q on a plain create", out.Project.Template)
|
||||
}
|
||||
if paths := treePaths(t, h, out.Project.ID); len(paths) != 0 {
|
||||
t.Fatalf("a plain create is not empty any more: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// A read-only hub refuses creation before it can seed anything — the
|
||||
// existing Upload.Enabled guard, unchanged.
|
||||
func TestProjectCreateWithTemplateReadOnly(t *testing.T) {
|
||||
srv, _, _ := newHub(t, false, nil)
|
||||
rec := do(t, srv.Handler(), "POST", "/api/projects", map[string]string{"name": "ro", "template": "docs"})
|
||||
if rec.Code != 403 {
|
||||
t.Fatalf("read-only hub: %d %s, want 403", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The dialog's options come from the server, so a hub shipping another
|
||||
// template needs no frontend change.
|
||||
func TestConfigListsTemplates(t *testing.T) {
|
||||
srv, _, _ := newHub(t, true, nil)
|
||||
rec := do(t, srv.Handler(), "GET", "/api/config", nil)
|
||||
var cfg struct {
|
||||
Templates []struct{ Name, Title, Blurb string } `json:"templates"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cfg.Templates) != len(templates.List()) || cfg.Templates[0].Name != "docs" {
|
||||
t.Fatalf("/api/config templates = %+v", cfg.Templates)
|
||||
}
|
||||
for _, tpl := range cfg.Templates {
|
||||
if tpl.Title == "" || tpl.Blurb == "" {
|
||||
t.Fatalf("template %q has nothing to render: %+v", tpl.Name, tpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// treePaths is the set of file paths a project's tree lists.
|
||||
func treePaths(t *testing.T, h http.Handler, id string) map[string]bool {
|
||||
t.Helper()
|
||||
rec := do(t, h, "GET", "/api/p/"+id+"/tree", nil)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("tree: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var root Node
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
var walk func(*Node)
|
||||
walk = func(n *Node) {
|
||||
if !n.Dir && n.Path != "" {
|
||||
out[n.Path] = true
|
||||
}
|
||||
for _, c := range n.Children {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(&root)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package webapp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The LLM wiki template is the one with real workflow rules in it, so prove
|
||||
// the whole thing actually lands on a device and reaches the hub — including
|
||||
// the two index files the pattern depends on.
|
||||
func TestCLIWikiTemplate(t *testing.T) {
|
||||
e := newCLIEnv(t)
|
||||
run, hub, browser := e.run, e.hub, e.browser
|
||||
|
||||
work := filepath.Join(t.TempDir(), "brain")
|
||||
if err := os.MkdirAll(work, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer run(work, "stop", work)
|
||||
if out, err := run(work, "init", "--name", "llmwiki", "--template", "wiki", "--yes"); err != nil {
|
||||
t.Fatalf("init --template wiki: %v\n%s", err, out)
|
||||
}
|
||||
want := []string{"AGENTS.md", "index.md", "log.md", "sources/README.md", "wiki/README.md"}
|
||||
paths := hubPaths(t, browser, hub.URL, projectIDByName(t, browser, hub.URL, "llmwiki"))
|
||||
for _, rel := range want {
|
||||
if !fileExists(filepath.Join(work, filepath.FromSlash(rel))) {
|
||||
t.Errorf("%s missing on disk", rel)
|
||||
}
|
||||
if !paths[rel] {
|
||||
t.Errorf("%s never reached the hub: %v", rel, paths)
|
||||
}
|
||||
}
|
||||
// The index rule is the load-bearing instruction; if it ever drops out of
|
||||
// the AGENTS.md the pattern quietly stops working.
|
||||
agents, err := os.ReadFile(filepath.Join(work, "AGENTS.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(agents), "incomplete write") {
|
||||
t.Error("AGENTS.md lost the index-update-is-part-of-the-write rule")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user