diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index c787ec8..1698248 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -160,6 +160,31 @@ committed). Re-running `bdrive init --yes` later is always safe. To change the scope later use `bdrive scope add/rm `, never hand-edit `.bdrive/config.json`. +**If the folder is empty after init — ask once.** Init has already pulled the +project, so an empty folder *now* means there is genuinely nothing to build on. +(If the project was created from a template in the browser, its files are +already here — say nothing.) Ask: start from a structure, or from scratch? + +> On Claude Code use `AskUserQuestion`, header "Starting point", in this +> order: **Docs + decision records** — `docs/`, `decisions/` — labelled +> "(Recommended)"; **LLM wiki** — you curate sources, the agent writes and +> maintains every page; **PARA** — `projects/`, `areas/`, `resources/`, +> `archives/`; then **Start from scratch**. Every other agent: prose. + +Each template is a directory skeleton plus an `AGENTS.md` saying where a new +note goes, when something is archived, and what a good filename looks like — +follow that file from then on, the same way you would one the user wrote. On a +pick, run one command in the same folder: + +```sh +bdrive init --template docs --yes +``` + +**A folder with files in it skips this entirely.** Never offer to restructure +someone's existing notes, and never ask before init — before init you cannot +know whether the project already has a structure, which is how you end up with +two copies. + ## 4. Confirm the sync hooks `bdrive init` already did this — do not run a separate hooks command. It diff --git a/README.md b/README.md index c3f0747..59f7886 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ hub's own storage, never something a syncing client points at directly: |---|---| | `bdrive login [server-url]` | Sign this device in (browser flow — the page names the account this terminal would act as and lets you switch before approving; `--device` forces the approval-link flow, and shells without a TTY fall back to it automatically; default server beardrive.ai — the managed cloud, free personal workspace on signup; pass your hub URL to self-host). Switch hubs with `bdrive login ` | | `bdrive logout` | Sign this device out — clear the saved token/account (`--forget` also drops the remembered server) | -| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--yes`) for scripts; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | +| `bdrive init [folder]` | Create/connect a project and start syncing — the mount is always exactly the folder named. Interactive on a TTY, flags (`--name/--project/--server/--only/--template/--yes`) for scripts; `--template docs\|wiki\|para` starts the project from a structure (directories plus the `AGENTS.md` that explains them) instead of an empty folder; registers agent sync hooks and the login autostart in each platform's user config (`--no-hooks` skips the hooks), prints the project link; re-run to resume | | `bdrive resume` | Restart the sync daemon for every project on this device that isn't paused — after a reboot, a crash, or a manual kill. Idempotent; this is what the login agent runs | | `bdrive autostart [install\|uninstall]` | Show, add, or remove the login registration that runs `bdrive resume` after a reboot — a launchd user agent on macOS, a systemd user unit on Linux, an HKCU Run entry on Windows. `bdrive init` installs it; `--no-autostart` skips it | | `bdrive stop [folder]` | Stop syncing, including agent sync hooks (files stay; `bdrive init` resumes) | @@ -400,11 +400,24 @@ hub**, run `bdrive login ` and then re-run `bdrive init` in each folder to connect it to a project there; `bdrive logout` signs out entirely. `bdrive init` then, per project, walks you through it on a terminal: **create a new project or -connect an existing one** (picked from the server's list), and **sync the -whole folder or only some of its subfolders** (e.g. `./wiki`). Every question -has a flag (`--name`, `--project`, `--only`, `--yes`), and without a TTY -init never prompts — it creates-or-joins a project named after the folder -and syncs everything. It writes `.bdrive/config.json`, seeds a starter +connect an existing one** (picked from the server's list), **start from a +structure or from scratch**, and **sync the whole folder or only some of its +subfolders** (e.g. `./wiki`). Every question has a flag (`--name`, +`--project`, `--template`, `--only`, `--yes`), and without a TTY init never +prompts — it creates-or-joins a project named after the folder, empty, and +syncs everything. + +`--template docs`, `wiki` or `para` starts a **new** project from a +structure rather than an empty folder: a small directory skeleton plus the +`AGENTS.md` that tells an agent where a new note goes, when something is +archived, and what a good filename looks like — which is the part that keeps +a shared folder from rotting into a pile. The hub seeds it at creation, so it +is already there for the browser and for every device that connects later; +joining a project that already exists never restructures it, and +`--template` is refused together with `--only` (scope rules live in the +synced `.bdriveignore`, so a scope that left out the template's folders would +hide them for the whole team). Creating a project in the web UI offers the +same three starting points. It writes `.bdrive/config.json`, seeds a starter `.bdriveignore` (node_modules, build dirs, caches, `.env*`), and starts the daemon — local changes are detected within seconds, and the agent sync hooks sync at every turn boundary. Not signed in yet? init runs the login diff --git a/architecture/cli-sync.md b/architecture/cli-sync.md index 4a89660..c8de29b 100644 --- a/architecture/cli-sync.md +++ b/architecture/cli-sync.md @@ -123,6 +123,14 @@ classDiagram } note for Commands "cmd/bdrive — thin cobra layer; init is the front door (one command: login + hooks + sync + link), stop pauses" + class Templates { + <> + go:embed files/docs, files/wiki, files/para + List / Get / Names + WriteTo(dir) skips existing paths + } + note for Templates "init --template : refused with --only and on an unknown name BEFORE any write. The hub seeds at creation (the CLI creates through POST /api/projects), so WriteTo runs only as the fallback for a hub too old to know the field, and in an already-initialized folder — the agent's post-init path. Skipping existing paths is what makes a double-seed a no-op" + class syncBlocked { <> enrolled in mounts.json? @@ -198,6 +206,7 @@ classDiagram syncBlocked --> PausedMarker : Paused check Commands --> openSession : after the gate openSession --> MountRegistry : path self-heal (enrolled only) + Commands --> Templates : init --template (seed) / init resume (agent's post-init path) Commands --> startSync : init startSync --> MountRegistry : enrolls startSync --> PausedMarker : clears diff --git a/architecture/overview.md b/architecture/overview.md index f9f3ea0..82ccfeb 100644 --- a/architecture/overview.md +++ b/architecture/overview.md @@ -30,6 +30,8 @@ flowchart LR store["object store (hub-owned)
internal/remote: file:// s3:// gs://
blobs + per-device journals"] + tpl["internal/templates
go:embed'ed starting structures
(docs, wiki, para: skeleton + AGENTS.md)"] + docs["web/docs — docs.beardrive.ai
Astro/Starlight, deploys separately"] cloud["cloud/ (PRIVATE nested repo, gitignored)
managed beardrive.ai: swaps AuthProvider,
QuotaProvider, MetaStore seams"] @@ -42,6 +44,8 @@ flowchart LR hooks -->|"bdrive sync --hook / --note, read-log
gated: enrolled + not paused"| cli srv --> store srv --> meta + cli -->|"init --template: seed locally"| tpl + srv -->|"POST /api/projects template:
seed as ops under the hub's device"| tpl fe -->|/api/config, /api/projects, viewer APIs| srv cloud -.->|imports OSS packages,
replaces providers| srv docs -.->|documents| cli diff --git a/architecture/webapp-frontend.md b/architecture/webapp-frontend.md index 9debef5..10552a0 100644 --- a/architecture/webapp-frontend.md +++ b/architecture/webapp-frontend.md @@ -69,11 +69,12 @@ classDiagram class components { FileView FolderListing FileTree HistoryView HistoryRow DiffView VersionBanner - Insights ShareDialog + Insights ShareDialog NewProjectDialog ShareBanner SharesTable AdminTable OrgAdmin HubSettings ProjectSettings Palette shell AccountBar ... } + note for components "NewProjectDialog replaced ProjectNav's name-only modalPrompt: name + starting point, POSTing {name, template}. Its options come from useConfig()'s `templates`, never a hardcoded list, so a hub shipping another template needs no frontend change; \"Empty project\" (value \"\") stays preselected so an unpicked create behaves exactly as it did before templates. modal.tsx keeps its one-field API — teaching it about choices would tax every other caller" note for components "components/ui — shadcn/ui primitives (Radix, copied in), themed from BearDrive tokens in tw.css; rendered markdown is transformed as a string before mounting, link clicks delegated on the container — never patch the dangerouslySetInnerHTML subtree" class lib { diff --git a/architecture/webapp-server.md b/architecture/webapp-server.md index 66cddc5..2b99b82 100644 --- a/architecture/webapp-server.md +++ b/architecture/webapp-server.md @@ -135,16 +135,25 @@ classDiagram -repo ProjectRepo -byID +Get +Create +Update +Rename +List - +SetCreator +SetDefault + +SetCreator +SetDefault +SetTemplate +SetPerm +ClearPerm } class Project { +ID +Name +Org +Created +Description +Icon +Creator string + +Template string +Default string +Perms map email→level } + class seedTemplate { + <> + POST /api/projects `template` + templates.Get before GetOrCreate → 400 + Upload() per file, hub's own device + skips paths that already exist + CheckWrite / RecordUsage + } note for Project "Default == "" means write — the historical behavior, so an upgraded hub needs no migration. SetPerm/ClearPerm refuse to drop the last explicit admin." class projectPerm { @@ -236,6 +245,9 @@ classDiagram BuiltinAuth ..> OrgDB : InviteValid wiring ProjectDB ..> Project + Server *-- seedTemplate : on create, when `template` is set + seedTemplate ..> Uploader : RemoteSource.Upload (blob, then journal) + seedTemplate ..> ProjectDB : SetTemplate records it once Server *-- projectPerm : gates every per-project route projectPerm ..> Project : Perms + Default projectPerm ..> Directory : org role diff --git a/cmd/bdrive/init.go b/cmd/bdrive/init.go index eca1292..2530b39 100644 --- a/cmd/bdrive/init.go +++ b/cmd/bdrive/init.go @@ -20,6 +20,7 @@ import ( "github.com/runbear-io/beardrive/internal/agenthooks" "github.com/runbear-io/beardrive/internal/autostart" "github.com/runbear-io/beardrive/internal/config" + "github.com/runbear-io/beardrive/internal/templates" ) // starterIgnore is seeded into new projects so build artifacts and @@ -50,7 +51,7 @@ venv/ // folder just resumes syncing (which is also how a moved/renamed folder // picks up where it left off). func initCmd() *cobra.Command { - var projectID, projectName, serverURL string + var projectID, projectName, serverURL, template string var only []string var yes, foreground, noHooks, noAutostart bool c := &cobra.Command{ @@ -92,6 +93,21 @@ the folder was renamed or moved.`, if projectID != "" && projectName != "" { return fmt.Errorf("--project and --name are mutually exclusive") } + // Both refusals land before any network call or file write: a + // scope that excludes a template's top level would hide it from + // the whole team (scope rules live in the synced .bdriveignore), + // and an unknown name should cost nothing. + var tpl templates.Template + if template != "" { + if len(only) > 0 { + return fmt.Errorf("--template and --only are mutually exclusive: " + + "scope rules live in the synced .bdriveignore, so a scope that leaves out " + + "the template's folders would hide them for everyone") + } + if tpl, err = templates.Get(template); err != nil { + return err + } + } // Already initialized → resume (also self-heals after a move). if proj, ok, err := config.ResolveMount(folder); err != nil { @@ -120,6 +136,16 @@ the folder was renamed or moved.`, fmt.Printf(" syncing: ./%s only (rules written to .bdriveignore)\n", strings.Join(scope, ", ./")) } } + // --template in an already-initialized folder is the agent's + // path: init pulled the project, the folder turned out to be + // empty, so the structure is written here and the usual cycle + // pushes it. Existing paths are never overwritten, which is + // what makes re-running this safe. + if tpl.Name != "" { + if err := seedLocally(folder, tpl); err != nil { + return err + } + } if !noHooks { installAgentHooks(folder) } @@ -138,21 +164,32 @@ the folder was renamed or moved.`, interactive := stdinIsTTY() && !yes - // Which project? + // Which project — and, when creating one, what does it start from? var p serverProject + var created bool switch { case projectID != "": p, err = getProject(server, settings.Token, projectID) case projectName != "": - p, _, err = createProject(server, settings.Token, projectName) + p, created, err = createProject(server, settings.Token, projectName, template) case interactive: - p, err = chooseProject(server, settings.Token, filepath.Base(folder)) + p, created, err = chooseProject(server, settings.Token, filepath.Base(folder), &template, &tpl) default: - p, _, err = createProject(server, settings.Token, filepath.Base(folder)) + p, created, err = createProject(server, settings.Token, filepath.Base(folder), template) } if err != nil { return fmt.Errorf("cannot set up project on %s: %w", server, err) } + // A template is applied when a project is created, and only then: + // joining one that already exists must never restructure it. + if tpl.Name != "" && !created && p.Template != tpl.Name { + from := "an empty project" + if p.Template != "" { + from = "the " + p.Template + " template" + } + return fmt.Errorf("project %q already exists and was created from %s\n"+ + "a template only applies to a new project; connect to this one without --template", p.Name, from) + } if err := checkNotAlreadyMounted(server+"/p/"+p.ID, folder, p.Name); err != nil { return err } @@ -197,6 +234,22 @@ the folder was renamed or moved.`, } } fmt.Printf("initialized %s\n server: %s\n project: %s (%s)\n", folder, server, p.Name, p.ID) + if tpl.Name != "" { + switch { + case p.Template == tpl.Name: + // The hub seeded it at creation; the initial cycle below + // is blocking and pulls, so the files land on disk before + // this command returns. + fmt.Printf(" start: %s template (seeded on the hub)\n", tpl.Name) + default: + // An older hub silently ignored the template field, which + // would make --template a quiet no-op. Seed from here and + // let the first cycle push it. + if err := seedLocally(folder, tpl); err != nil { + return err + } + } + } if !noHooks { installAgentHooks(folder) } @@ -232,6 +285,7 @@ next steps: c.Flags().StringVar(&serverURL, "server", "", "hub to connect to (default: the remembered one); signs in there if this device has no session") c.Flags().StringVar(&projectID, "project", "", "connect an existing project by id (p-xxxxxxxx)") c.Flags().StringVar(&projectName, "name", "", "project name to create or join (default: folder name)") + c.Flags().StringVar(&template, "template", "", "start from a structure ("+strings.Join(templates.Names(), ", ")+"); default: an empty project") c.Flags().StringSliceVar(&only, "only", nil, "sync only these subfolders of the mount (comma-separated, e.g. wiki,docs) — written as .bdriveignore rules") c.Flags().BoolVarP(&yes, "yes", "y", false, "accept defaults, never prompt") c.Flags().BoolVarP(&foreground, "foreground", "f", false, "run the sync daemon in the foreground") @@ -240,6 +294,23 @@ next steps: return c } +// seedLocally writes a template into the folder and says what it wrote. +// Paths that already exist are never touched, so seeding twice — an agent +// re-running init, a hub that already seeded — is a no-op rather than a +// conflict. +func seedLocally(folder string, tpl templates.Template) error { + wrote, err := tpl.WriteTo(folder) + if err != nil { + return fmt.Errorf("seed the %s template: %w", tpl.Name, err) + } + if len(wrote) == 0 { + fmt.Printf(" start: %s template (already present)\n", tpl.Name) + return nil + } + fmt.Printf(" start: %s template (%d files — read AGENTS.md)\n", tpl.Name, len(wrote)) + return nil +} + // installAgentHooks registers turn-boundary sync hooks as part of init, so // the one command a user (or their agent) already ran covers hooks too — a // separate `bdrive hooks install` is one more permission prompt, and it is @@ -398,31 +469,48 @@ func normalizeServer(raw string) string { return "https://" + raw } -func chooseProject(server, token, defaultName string) (serverProject, error) { +// chooseProject asks what to do and does it. The starting point is asked +// only on the create-a-new-project branch — connecting to an existing +// project never restructures it — and the picked template is handed back +// through name/tpl so the caller's refusals and summary see it too. +func chooseProject(server, token, defaultName string, name *string, tpl *templates.Template) (serverProject, bool, error) { var mode string if err := survey.AskOne(&survey.Select{ Message: "What would you like to do?", Options: []string{"Create a new project", "Connect an existing project"}, }, &mode); err != nil { - return serverProject{}, err + return serverProject{}, false, err } if mode == "Create a new project" { - name := defaultName - if err := survey.AskOne(&survey.Input{Message: "Project name:", Default: defaultName}, &name); err != nil { - return serverProject{}, err + projName := defaultName + if err := survey.AskOne(&survey.Input{Message: "Project name:", Default: defaultName}, &projName); err != nil { + return serverProject{}, false, err } - p, created, err := createProject(server, token, name) + if *name == "" { + picked, err := chooseTemplate() + if err != nil { + return serverProject{}, false, err + } + if picked != "" { + t, err := templates.Get(picked) + if err != nil { + return serverProject{}, false, err + } + *name, *tpl = picked, t + } + } + p, created, err := createProject(server, token, projName, *name) if err == nil && !created { fmt.Printf("project %q already exists — connecting to it\n", p.Name) } - return p, err + return p, created, err } projects, err := listProjects(server, token) if err != nil { - return serverProject{}, err + return serverProject{}, false, err } if len(projects) == 0 { - return serverProject{}, fmt.Errorf("the server has no projects yet; create one instead") + return serverProject{}, false, fmt.Errorf("the server has no projects yet; create one instead") } labels := make([]string, len(projects)) for i, p := range projects { @@ -430,9 +518,38 @@ func chooseProject(server, token, defaultName string) (serverProject, error) { } var idx int if err := survey.AskOne(&survey.Select{Message: "Connect to which project?", Options: labels}, &idx); err != nil { - return serverProject{}, err + return serverProject{}, false, err } - return projects[idx], nil + return projects[idx], false, nil +} + +// chooseTemplate offers the three starting points in the same words the web +// dialog uses, recommended first and "empty" as a real option rather than a +// footnote. Returns "" for an empty project. +func chooseTemplate() (string, error) { + list := templates.List() + options := make([]string, 0, len(list)+1) + for i, t := range list { + label := fmt.Sprintf("%s — %s", t.Title, t.Blurb) + if i == 0 { + label += " (recommended)" + } + options = append(options, label) + } + options = append(options, "Empty project — just the folder") + + var idx int + if err := survey.AskOne(&survey.Select{ + Message: "Start from a structure?", + Options: options, + Default: options[len(options)-1], + }, &idx); err != nil { + return "", err + } + if idx == len(list) { + return "", nil + } + return list[idx].Name, nil } // chooseScope returns nil for whole-folder sync, or the subfolders to narrow @@ -458,6 +575,10 @@ func chooseScope() ([]string, error) { type serverProject struct { ID string `json:"id"` Name string `json:"name"` + // Template is the structure the hub seeded the project from, "" for an + // empty project — and also for a hub too old to know the field, which is + // why init falls back to seeding locally rather than trusting it blindly. + Template string `json:"template"` } var initClient = &http.Client{Timeout: 10 * time.Second} @@ -527,8 +648,8 @@ func listProjects(server, token string) ([]serverProject, error) { return out.Projects, nil } -func createProject(server, token, name string) (serverProject, bool, error) { - body, err := json.Marshal(map[string]string{"name": name}) +func createProject(server, token, name, template string) (serverProject, bool, error) { + body, err := json.Marshal(map[string]string{"name": name, "template": template}) if err != nil { return serverProject{}, false, err } diff --git a/cmd/bdrive/migrate.go b/cmd/bdrive/migrate.go index 795d743..37a0de7 100644 --- a/cmd/bdrive/migrate.go +++ b/cmd/bdrive/migrate.go @@ -128,7 +128,7 @@ bdrive init --project .`, if name == "" { return fmt.Errorf("archive has no manifest; pass --name") } - p, created, err := createProject(settings.Server, settings.Token, name) + p, created, err := createProject(settings.Server, settings.Token, name, "") if err != nil { return fmt.Errorf("cannot create project on %s: %w", settings.Server, err) } diff --git a/internal/syncer/flows_test.go b/internal/syncer/flows_test.go index 2517023..6e0a1e1 100644 --- a/internal/syncer/flows_test.go +++ b/internal/syncer/flows_test.go @@ -11,6 +11,7 @@ import ( "github.com/runbear-io/beardrive/internal/journal" "github.com/runbear-io/beardrive/internal/remote" "github.com/runbear-io/beardrive/internal/store" + "github.com/runbear-io/beardrive/internal/templates" ) // End-to-end scenarios for the init knowledge flows documented in @@ -275,3 +276,64 @@ func TestSessionNoteStampsOps(t *testing.T) { t.Fatalf("cleared note = %q", got) } } + +// A template is inert files, so it has to converge like any other content — +// and the double-seed case has to be a no-op rather than a divergence. One +// device seeds a structure, a teammate connects and gets it byte-for-byte; +// then the teammate seeds the same template again (an agent that asked when +// it shouldn't have) and nothing forks: no second copy, no conflict copies. +func TestTemplateSeedConverges(t *testing.T) { + be := sharedRemote(t) + a := newDevice(t, "deva", be) + b := newDevice(t, "devb", be) + + tpl, err := templates.Get("docs") + if err != nil { + t.Fatal(err) + } + if _, err := tpl.WriteTo(a.Folder); err != nil { + t.Fatal(err) + } + cycle(t, a) + cycle(t, b) + + for _, f := range tpl.Files { + if got := read(t, b.Folder, f.Path); got != f.Content { + t.Fatalf("%s did not reach devb intact:\n%q", f.Path, got) + } + } + + // devb seeds the same template on top of what it just pulled. WriteTo + // skips every existing path, so this writes nothing at all. + wrote, err := tpl.WriteTo(b.Folder) + if err != nil { + t.Fatal(err) + } + if len(wrote) != 0 { + t.Fatalf("re-seeding an already-seeded folder wrote %v", wrote) + } + cycle(t, b) + cycle(t, a) + + for _, d := range []*Session{a, b} { + if got := conflictFiles(t, d.Folder); len(got) != 0 { + t.Fatalf("%s: double-seed made conflict copies: %v", d.Device.ID, got) + } + for _, f := range tpl.Files { + if read(t, d.Folder, f.Path) != f.Content { + t.Fatalf("%s: %s diverged after the second seed", d.Device.ID, f.Path) + } + } + } + // One op per file per device that wrote it: no path was journaled twice. + ops, err := a.Store.DeviceOps("deva") + if err != nil { + t.Fatal(err) + } + if len(ops) != len(tpl.Files) { + t.Fatalf("deva journaled %d ops for a %d-file template: %+v", len(ops), len(tpl.Files), ops) + } + if ops, err := b.Store.DeviceOps("devb"); err != nil || len(ops) != 0 { + t.Fatalf("devb journaled %d ops for files it only pulled (err %v)", len(ops), err) + } +} diff --git a/internal/templates/files/docs/AGENTS.md b/internal/templates/files/docs/AGENTS.md new file mode 100644 index 0000000..90af85c --- /dev/null +++ b/internal/templates/files/docs/AGENTS.md @@ -0,0 +1,58 @@ +# How this folder is organized + +This project uses the **docs + decision records** structure. Everything here +syncs to every teammate and every one of their agents, so the filing rules below +are not a preference — they are what keeps the folder readable for people who +did not write the file. + +## Where a new file goes + +| What you are writing | Where it goes | +| -- | -- | +| Anything explaining how something works, or how to do something | `docs/` | +| A decision, with its context and its consequences | `decisions/` | +| A quick note that has no home yet | `docs/` — a wrong-but-findable file beats a right-but-invisible one | + +Read the directory before you add to it. If a file already covers the topic, +append to that file instead of creating a near-duplicate beside it — two files +saying almost the same thing is the failure mode this structure exists to +prevent. + +Never create a new top-level directory. If nothing fits, put it in `docs/` and +say so in the file. + +## Decisions + +A decision record is written **once, when the decision is made**, and is not +edited afterwards — that is the whole point of the format. Number it with the +next free number: `decisions/0002-....md`. See `decisions/0001-record-decisions.md` +for the shape. + +## When something is no longer true + +Do not delete it and do not silently rewrite it. + +- **A doc that is out of date**: fix it in place, and say what changed at the + bottom under `## Changes`. +- **A decision that has been reversed**: leave the old record alone, write a new + one, and add one line to the old one — `Superseded by 0007-....md`. The reason + a decision was reversed is usually more useful than the decision itself. +- **A doc for something that no longer exists**: delete the file. There is no + archive directory here on purpose — BearDrive keeps every version of every + file forever, so `bdrive log ` is the archive, and a folder of dead docs + is just noise for the next person searching. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `docs/deploy-to-staging.md` +- name the subject, not the format: `docs/auth.md`, never `docs/notes.md`, + `docs/new.md`, `docs/misc.md`, or a date-stamped file +- decisions are `NNNN-imperative-phrase.md`: `decisions/0002-use-postgres.md` +- one topic per file; if a file grows past a screen or two of scrolling, split it + and link the parts + +## Every file starts with an H1 + +The first line is `# Title`, and the paragraph under it says who the file is for +and why they would open it. Both the search box and an agent reading in a hurry +use that line. diff --git a/internal/templates/files/docs/decisions/0001-record-decisions.md b/internal/templates/files/docs/decisions/0001-record-decisions.md new file mode 100644 index 0000000..423c34e --- /dev/null +++ b/internal/templates/files/docs/decisions/0001-record-decisions.md @@ -0,0 +1,41 @@ +# 0001 — Record decisions + +**Status:** accepted + +## Context + +Decisions get made in a call, a thread, or an agent session, and the reasoning +evaporates within a week. Six months later someone re-opens the same question, +or — worse — quietly reverses it without knowing what it cost the first time. + +## Decision + +Anything with a consequence someone could reasonably want to undo gets a file in +this directory. Copy the shape of this one: + +``` +# NNNN — Imperative phrase + +**Status:** accepted | superseded by NNNN + +## Context what was true, and what forced a choice +## Decision what we are doing, in the present tense +## Consequences what this costs, including what it makes harder +``` + +Number sequentially, never reuse a number, and write the record when the +decision is made rather than when someone remembers. + +(The format has a name — an **architecture decision record**, or ADR — if you +want to read more about it. Nothing here depends on knowing that.) + +A record is written once and not edited afterwards. Reversing a decision means a +new record, plus one line here — `Superseded by NNNN-....md` — so the trail stays +readable in both directions. + +## Consequences + +A small tax on every real decision, and a directory that answers "why is it like +this?" without anyone having to remember. The failure mode to watch for is +recording everything: a decision with no alternative and no cost is just a doc, +and belongs in `docs/`. diff --git a/internal/templates/files/docs/docs/README.md b/internal/templates/files/docs/docs/README.md new file mode 100644 index 0000000..1b02b90 --- /dev/null +++ b/internal/templates/files/docs/docs/README.md @@ -0,0 +1,10 @@ +# Docs + +How things work, and how to do them. One topic per file, named after the +subject: `deploy-to-staging.md`, `auth.md`, `onboarding.md`. + +Nothing here is precious. If a doc is wrong, fix it — the version you replaced +is still in the project's history, and `bdrive log ` will show you who +changed it and when. + +Delete this file once there are real docs beside it. diff --git a/internal/templates/files/para/AGENTS.md b/internal/templates/files/para/AGENTS.md new file mode 100644 index 0000000..cf8ac0f --- /dev/null +++ b/internal/templates/files/para/AGENTS.md @@ -0,0 +1,61 @@ +# How this folder is organized + +This project uses **PARA**: four top-level directories, sorted by how +*actionable* something is rather than by what topic it belongs to. Everything +here syncs to every teammate and every one of their agents, so the filing rules +below are what keep the folder usable by someone who did not write the file. + +## Where a new file goes + +Ask one question — *what is this for?* — and take the first row that fits: + +| The file is about… | It goes in | +| -- | -- | +| Something with a finish line and a deadline | `projects/` | +| A responsibility with no finish line — a system, a team, a customer you keep tending | `areas/` | +| A topic you are collecting material on, useful but not owned | `resources/` | +| Something already finished, dropped, or superseded | `archives/` | + +The ordering matters: a thing with a deadline is a project even if it is also a +topic. When two rows both fit, take the higher one. + +One directory per project or area, named after the thing, with a `README.md` +inside stating its goal and its current state. Notes live inside that directory, +not loose at the top level. + +Never create a fifth top-level directory. The four are the whole structure; if +something does not fit, it belongs in `resources/`. + +## When something is archived + +Archiving is an explicit move, and it is the habit that keeps PARA from becoming +a pile. Move the whole directory into `archives/`, unchanged, when: + +- a project is shipped, cancelled, or has not been touched in about a month and + nobody can say what the next step is; +- an area is no longer anyone's responsibility; +- a resource has been superseded by a better one — move it, do not delete it. + +Add one line at the top of its `README.md` saying what happened and when +(`Archived 2026-07-30 — shipped in v2.1.`). Nothing is deleted: `archives/` is +searchable, and it is what makes "we tried this before" answerable. Past work +that is still being cited is not archived — pull it back out into `resources/` +instead of copying it. + +Un-archiving is the same move backwards, and is completely fine. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `projects/q3-launch/press-plan.md` +- name the subject, not the format: never `notes.md`, `new.md`, `misc.md`, or a + bare date +- every project and area directory has a `README.md`; that is the file someone + opens first +- one topic per file — if a file grows past a screen or two of scrolling, split + it and link the parts + +## Every file starts with an H1 + +The first line is `# Title`, and the paragraph under it says who the file is for +and why they would open it. Both the search box and an agent reading in a hurry +use that line. diff --git a/internal/templates/files/para/archives/README.md b/internal/templates/files/para/archives/README.md new file mode 100644 index 0000000..46a8eee --- /dev/null +++ b/internal/templates/files/para/archives/README.md @@ -0,0 +1,10 @@ +# Archives + +Everything finished, cancelled, or superseded — moved here whole, unchanged, +with one line at the top of its README saying what happened and when. + +This is not a wastebasket. It is what makes "have we tried this before?" +answerable, and it stays searchable like the rest of the project. Nothing here +is deleted, and anything still being cited belongs back in `resources/`. + +Delete this file once there is something real beside it. diff --git a/internal/templates/files/para/areas/README.md b/internal/templates/files/para/areas/README.md new file mode 100644 index 0000000..0885021 --- /dev/null +++ b/internal/templates/files/para/areas/README.md @@ -0,0 +1,12 @@ +# Areas + +Standing responsibilities with no finish line: a service you run, a team you +lead, a customer you look after, hiring, security. + +One directory per area, named after the responsibility (`infrastructure/`), with +its own `README.md` saying what "healthy" looks like and who tends it. + +An area is not a project — if it has a deadline, it belongs in `projects/`. An +area nobody owns any more is archived. + +Delete this file once there are real areas beside it. diff --git a/internal/templates/files/para/projects/README.md b/internal/templates/files/para/projects/README.md new file mode 100644 index 0000000..b97d84d --- /dev/null +++ b/internal/templates/files/para/projects/README.md @@ -0,0 +1,12 @@ +# Projects + +Work with a finish line and a deadline: a launch, a migration, a hire, a +customer deliverable. + +One directory per project, named after the thing (`q3-launch/`), with its own +`README.md` stating the goal, the deadline, and where it currently stands. + +When a project ships, is cancelled, or goes quiet with no next step, move the +whole directory to `archives/` and note what happened at the top of its README. + +Delete this file once there are real projects beside it. diff --git a/internal/templates/files/para/resources/README.md b/internal/templates/files/para/resources/README.md new file mode 100644 index 0000000..0ced1a3 --- /dev/null +++ b/internal/templates/files/para/resources/README.md @@ -0,0 +1,12 @@ +# Resources + +Topics worth collecting material on, without owning them: reference notes, +research, competitor teardowns, saved patterns. + +Group by topic (`pricing/`, `postgres/`). If a resource turns out to have a +deadline it is a project; if you find yourself responsible for it, it is an +area. + +Superseded material moves to `archives/` — it is never deleted. + +Delete this file once there are real resources beside it. diff --git a/internal/templates/files/wiki/AGENTS.md b/internal/templates/files/wiki/AGENTS.md new file mode 100644 index 0000000..2df21ef --- /dev/null +++ b/internal/templates/files/wiki/AGENTS.md @@ -0,0 +1,107 @@ +# How this folder is organized + +This project is an **LLM wiki**: you curate sources and ask questions, and the +agent writes and maintains the pages. You rarely write the wiki yourself. + +It instantiates the LLM Wiki pattern described by Andrej Karpathy +() — *one* +instantiation of it, not the canonical one. That document is deliberately +abstract and says the specifics are yours to work out. So change anything here +that does not fit your material; this file is meant to be edited as you learn +what your domain needs. + +## The three layers + +| Layer | Who writes it | +| -- | -- | +| `sources/` | **you** — clippings, papers, transcripts, notes, exports | +| `wiki/` | **the agent** — every page here is generated and maintained by it | +| `AGENTS.md` (this file) | both of you, over time | + +**Nothing in `sources/` is ever edited.** It is the record of what was actually +said, and every claim in the wiki is only worth as much as that record. Add to +it freely; correct it never. Nothing enforces this — BearDrive syncs `sources/` +like any other folder — so it holds only because everyone honors it. + +## If there is nothing here yet + +Do not build scaffolding before there is material. With no sources there is no +wiki to maintain, and empty category pages are ceremony that will go stale +before anyone reads them. + +The first move is a source: put something in `sources/`, then ingest it. The +structure grows out of the material, never ahead of it. + +## Where a new page goes + +Everything the agent writes goes in `wiki/`. There are no fixed subdirectories — +add one when a category has earned it (a dozen people, a dozen papers), not +before. Until then flat is fine, because `index.md` is the navigation. + +Before creating a page, read `index.md` for one that already covers the subject. +Extending a page beats adding a near-duplicate beside it: two pages on one +subject is how a wiki starts contradicting itself. + +## The three operations + +### Ingest — a new source arrived + +1. Read it. +2. Say what you took from it, and stop. The human steers what matters here. +3. Write or update the pages it touches in `wiki/`. One source usually touches + several: a summary, the entities it names, the concepts it bears on. +4. **Update `index.md` in the same turn.** +5. Append one entry to `log.md`. + +**A page write that has not updated the index is an incomplete write.** The +index is what every future session reads *first* to find anything. A page +missing from it is invisible; a wrong line in it is worse than a missing one, +because it will be believed and acted on. If you can only do one thing +properly, do the index. + +### Query — a question was asked + +Read `index.md`, pick the pages that look relevant, read those, and answer with +links to the ones you used. + +Then ask whether the answer is worth keeping. A comparison, a synthesis, a +connection nobody had written down — file it as a page and index it. Answers +left in the conversation are answers you will pay to derive again. + +### Lint — periodically, or on request + +Walk the wiki and look for: + +- pages that contradict each other; +- claims a newer source has superseded; +- orphans — pages nothing links to; +- concepts referred to everywhere with no page of their own; +- gaps worth going and finding a source for. + +Report what you find. Fix the mechanical parts yourself — missing links, index +drift, broken names. Ask before rewriting anything that is a judgment call. + +## When something stops being true + +Revise the page. Do not append a correction underneath the old text and do not +leave both versions standing: a page should read as current truth from top to +bottom, because that is how it will be quoted. + +Nothing is lost by rewriting. BearDrive keeps every version of every file, so +`bdrive log ` still shows what the page said before and who changed it. +When a source is what changed your mind, cite it in the revision and note it in +`log.md`. + +## Filenames + +- lowercase, words joined by hyphens, `.md`: `wiki/margin-compression.md` +- name the subject: `wiki/anthropic.md`, never `wiki/notes-3.md` or a bare date +- one subject per page +- link pages to each other with `[[wikilinks]]` — the hub renders them, and they + are what lets a lint pass find orphans at all + +## Every page starts with an H1 and one line + +The first line is `# Title`. The line under it is a one-sentence summary of the +whole page — and it is the same sentence that goes in `index.md`. Writing it +once and using it in both places is what keeps the two agreeing. diff --git a/internal/templates/files/wiki/index.md b/internal/templates/files/wiki/index.md new file mode 100644 index 0000000..ba3f9c9 --- /dev/null +++ b/internal/templates/files/wiki/index.md @@ -0,0 +1,24 @@ +# Index + +The catalog of this wiki: every page in `wiki/`, listed once, with a one-line +summary. The agent updates it in the same turn it writes a page — see +[[AGENTS]]. + +This is the file a session reads *first* to find anything, so an entry that is +missing or out of date costs more than a page that is. + +Nothing here yet. It fills in as sources are ingested. + + diff --git a/internal/templates/files/wiki/log.md b/internal/templates/files/wiki/log.md new file mode 100644 index 0000000..c032124 --- /dev/null +++ b/internal/templates/files/wiki/log.md @@ -0,0 +1,27 @@ +# Log + +Append-only, oldest first. One entry per ingest, per query worth remembering, +and per lint pass. An entry that is already here is never edited — this file is +the timeline, not the current state. + +Keep the heading format exactly as below. The consistent prefix is what makes +the log readable with ordinary tools: + +```sh +grep "^## \[" log.md | tail -5 # what happened recently +``` + + diff --git a/internal/templates/files/wiki/sources/README.md b/internal/templates/files/wiki/sources/README.md new file mode 100644 index 0000000..76e64bf --- /dev/null +++ b/internal/templates/files/wiki/sources/README.md @@ -0,0 +1,16 @@ +# Sources + +Raw material, exactly as it arrived: article clippings, papers, transcripts, +meeting notes, exports, images. This is the record the whole wiki rests on. + +**Nothing in here is ever edited.** Not to fix a typo, not to trim it down, not +to summarize it in place. If something is wrong or misleading, that belongs in +the wiki page that cites it — the source keeps saying what it actually said. + +Add freely. One file per source, named so you can tell what it is a year from +now: `2026-07-30-earnings-call.md`, `hbr-switching-costs.md`. + +Nothing enforces the read-only rule — this folder syncs like any other — so it +holds only because everyone honors it, agents included. + +Delete this file once there are real sources beside it. diff --git a/internal/templates/files/wiki/wiki/README.md b/internal/templates/files/wiki/wiki/README.md new file mode 100644 index 0000000..5cbdee9 --- /dev/null +++ b/internal/templates/files/wiki/wiki/README.md @@ -0,0 +1,15 @@ +# Wiki + +The synthesized layer: summaries, entity pages, concept pages, comparisons — +everything the agent writes and keeps current. Humans read this; the agent +maintains it. + +Flat until it isn't. Add subdirectories when a category has genuinely earned +one, not in advance — `../index.md` is the navigation, so depth buys you very +little and costs you a filing decision on every write. + +Each page: an H1, a one-sentence summary under it (the same sentence that goes +in the index), and `[[wikilinks]]` to the pages it relates to. Pages are +revised in place when they stop being true, never appended to with corrections. + +Delete this file once there are real pages beside it. diff --git a/internal/templates/templates.go b/internal/templates/templates.go new file mode 100644 index 0000000..0d78413 --- /dev/null +++ b/internal/templates/templates.go @@ -0,0 +1,156 @@ +// Package templates holds the starting structures a new project can be +// created from: a directory skeleton plus the AGENTS.md that explains it. +// +// The files are go:embed'ed rather than fetched, because `cmd/bdrive` is one +// binary serving the CLI, the daemon and the hub — so the hub that seeds a +// project at creation and the client that seeds one with `bdrive init +// --template` read the identical set, with no gallery and no drift. +// +// The AGENTS.md is the deliverable. Directories are the easy half and the +// half that decays; what makes a structure stick is the instruction file +// telling an agent where a new note goes, when something is archived, and +// what a good filename looks like. +// +// One hard rule for the content: BearDrive syncs paths, not directories +// (internal/syncer/walk.go only journals regular files), so an empty +// directory never reaches a teammate. Every directory in a template holds at +// least one real file — asserted in the tests. +package templates + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" +) + +//go:embed files +var content embed.FS + +// File is one file of a template: a slash-separated path relative to the +// project root, and its literal content. +type File struct { + Path string `json:"path"` + Content string `json:"-"` +} + +// Template is one starting structure. +type Template struct { + Name string `json:"name"` // the flag/API value, e.g. "docs" + Title string `json:"title"` // what a menu shows, e.g. "Docs + decision records" + Blurb string `json:"blurb"` // the one-line shape, e.g. "docs/, decisions/" + Files []File `json:"-"` +} + +// shipped is the registry, in the order both surfaces render: the +// recommended one first. Adding a template is a directory under files/ plus +// a row here — no other code. +var shipped = []struct{ Name, Title, Blurb string }{ + {"docs", "Docs + decision records", "docs/, decisions/"}, + {"wiki", "LLM wiki", "sources/, wiki/, index.md, log.md"}, + {"para", "PARA", "projects/, areas/, resources/, archives/"}, +} + +// List returns every shipped template, recommended first. +func List() []Template { + out := make([]Template, 0, len(shipped)) + for _, s := range shipped { + t, err := Get(s.Name) + if err != nil { + // Unreachable: the files are embedded in this binary. Panicking + // here would take a hub down for a packaging mistake. + continue + } + out = append(out, t) + } + return out +} + +// Names lists the valid template names, for error messages. +func Names() []string { + out := make([]string, len(shipped)) + for i, s := range shipped { + out[i] = s.Name + } + return out +} + +// Get returns the named template. An unknown name errors, naming the set. +func Get(name string) (Template, error) { + for _, s := range shipped { + if s.Name != name { + continue + } + files, err := load(name) + if err != nil { + return Template{}, err + } + return Template{Name: s.Name, Title: s.Title, Blurb: s.Blurb, Files: files}, nil + } + return Template{}, fmt.Errorf("unknown template %q (valid: %s)", name, strings.Join(Names(), ", ")) +} + +// load reads one template's files out of the embedded set, sorted by path so +// seeding order is stable (and so a journal's op order is reproducible). +func load(name string) ([]File, error) { + root := "files/" + name + var out []File + err := fs.WalkDir(content, root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + b, err := content.ReadFile(p) + if err != nil { + return err + } + out = append(out, File{Path: strings.TrimPrefix(p, root+"/"), Content: string(b)}) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, nil +} + +// WriteTo writes the template into dir and returns the paths it wrote. A path +// that already exists is never overwritten and is left out of the result — +// which is what makes seeding twice a no-op rather than a divergence. +func (t Template) WriteTo(dir string) ([]string, error) { + var wrote []string + for _, f := range t.Files { + abs := filepath.Join(dir, filepath.FromSlash(f.Path)) + if _, err := os.Stat(abs); err == nil { + continue + } + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + return wrote, err + } + if err := os.WriteFile(abs, []byte(f.Content), 0o644); err != nil { + return wrote, err + } + wrote = append(wrote, f.Path) + } + return wrote, nil +} + +// Dirs lists every directory a template's paths imply, for the +// every-directory-holds-a-file check. +func (t Template) Dirs() []string { + seen := map[string]bool{} + for _, f := range t.Files { + for d := path.Dir(f.Path); d != "." && d != "/"; d = path.Dir(d) { + seen[d] = true + } + } + out := make([]string, 0, len(seen)) + for d := range seen { + out = append(out, d) + } + sort.Strings(out) + return out +} diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go new file mode 100644 index 0000000..4157856 --- /dev/null +++ b/internal/templates/templates_test.go @@ -0,0 +1,128 @@ +package templates + +import ( + "os" + "path" + "path/filepath" + "strings" + "testing" +) + +// Every shipped template has to satisfy the two things that make it work at +// all: the hub must be able to journal every path (so the paths obey the same +// rules cleanUploadPath enforces on an upload), and every directory must hold +// a file — BearDrive syncs paths, not directories, so an empty directory +// never reaches a teammate. +func TestShippedTemplates(t *testing.T) { + list := List() + if len(list) != len(shipped) { + t.Fatalf("List() returned %d templates, want %d", len(list), len(shipped)) + } + for _, tpl := range list { + if tpl.Title == "" || tpl.Blurb == "" { + t.Errorf("%s: needs a title and a blurb for the menus", tpl.Name) + } + var agents string + haveFileIn := map[string]bool{} + for _, f := range tpl.Files { + if f.Path == "AGENTS.md" { + agents = f.Content + } + haveFileIn[path.Dir(f.Path)] = true + + // The same rules cleanUploadPath applies, so the hub can never + // reject its own content. + if f.Path == "" || strings.HasPrefix(f.Path, "/") || strings.HasSuffix(f.Path, "/") || + path.Clean(f.Path) != f.Path || strings.HasPrefix(f.Path, "../") || + strings.Contains(f.Path, "/../") { + t.Errorf("%s: path %q is not a clean relative path", tpl.Name, f.Path) + } + if strings.HasPrefix(path.Base(f.Path), ".bdrive") { + t.Errorf("%s: path %q uses a reserved name", tpl.Name, f.Path) + } + if strings.TrimSpace(f.Content) == "" { + t.Errorf("%s: %s is empty", tpl.Name, f.Path) + } + } + if strings.TrimSpace(agents) == "" { + t.Errorf("%s: no AGENTS.md at the root — the instructions are the deliverable", tpl.Name) + } + // The three questions an AGENTS.md exists to answer. Each is checked + // through a set of alternatives, because the honest vocabulary + // differs by structure: PARA archives, a wiki supersedes and revises + // stale claims. What must not vary is that the question is answered. + for question, words := range map[string][]string{ + "where a new file goes": {"goes"}, + "what happens when something stops being true": {"rchiv", "supersede", "stale"}, + "what a good filename looks like": {"ilename"}, + } { + answered := false + for _, w := range words { + answered = answered || strings.Contains(agents, w) + } + if !answered { + t.Errorf("%s: AGENTS.md does not answer %s", tpl.Name, question) + } + } + for _, dir := range tpl.Dirs() { + if !haveFileIn[dir] { + t.Errorf("%s: directory %q holds no file, so it never syncs", tpl.Name, dir) + } + } + } +} + +func TestGetUnknownNamesTheSet(t *testing.T) { + _, err := Get("karpathy-wiki") + if err == nil { + t.Fatal("unknown template should error") + } + for _, name := range Names() { + if !strings.Contains(err.Error(), name) { + t.Fatalf("error %q does not name the valid template %q", err, name) + } + } +} + +// Seeding twice must be a no-op, not a divergence: WriteTo never overwrites, +// which is what makes `bdrive init --template` re-runnable and a hub-seeded +// project safe for an agent to seed again by mistake. +func TestWriteToSkipsExisting(t *testing.T) { + tpl, err := Get("docs") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + const sentinel = "# mine, not yours\n" + if err := os.WriteFile(filepath.Join(dir, "AGENTS.md"), []byte(sentinel), 0o644); err != nil { + t.Fatal(err) + } + + wrote, err := tpl.WriteTo(dir) + if err != nil { + t.Fatal(err) + } + for _, p := range wrote { + if p == "AGENTS.md" { + t.Fatal("WriteTo reported writing a path that already existed") + } + } + if len(wrote) != len(tpl.Files)-1 { + t.Fatalf("wrote %d paths, want %d", len(wrote), len(tpl.Files)-1) + } + if got, _ := os.ReadFile(filepath.Join(dir, "AGENTS.md")); string(got) != sentinel { + t.Fatalf("WriteTo clobbered an existing file: %q", got) + } + if _, err := os.Stat(filepath.Join(dir, "decisions", "0001-record-decisions.md")); err != nil { + t.Fatalf("WriteTo did not create the rest of the template: %v", err) + } + + // Second pass: nothing left to write. + again, err := tpl.WriteTo(dir) + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Fatalf("re-seeding wrote %v, want nothing", again) + } +} diff --git a/internal/webapp/cli_e2e_test.go b/internal/webapp/cli_e2e_test.go index 8bb74f2..1277c0e 100644 --- a/internal/webapp/cli_e2e_test.go +++ b/internal/webapp/cli_e2e_test.go @@ -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") + } +} diff --git a/internal/webapp/db_conformance_test.go b/internal/webapp/db_conformance_test.go index d131652..5659fcc 100644 --- a/internal/webapp/db_conformance_test.go +++ b/internal/webapp/db_conformance_test.go @@ -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) diff --git a/internal/webapp/db_sql.go b/internal/webapp/db_sql.go index 011f792..aa3378f 100644 --- a/internal/webapp/db_sql.go +++ b/internal/webapp/db_sql.go @@ -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 { diff --git a/internal/webapp/frontend/e2e/hub.spec.ts b/internal/webapp/frontend/e2e/hub.spec.ts index fbe68d4..68d2bee 100644 --- a/internal/webapp/frontend/e2e/hub.spec.ts +++ b/internal/webapp/frontend/e2e/hub.spec.ts @@ -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"); diff --git a/internal/webapp/frontend/shot3.mjs b/internal/webapp/frontend/shot3.mjs new file mode 100644 index 0000000..c9c4ecc --- /dev/null +++ b/internal/webapp/frontend/shot3.mjs @@ -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(); diff --git a/internal/webapp/frontend/src/api/types.ts b/internal/webapp/frontend/src/api/types.ts index 58929f5..ec4c628 100644 --- a/internal/webapp/frontend/src/api/types.ts +++ b/internal/webapp/frontend/src/api/types.ts @@ -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. diff --git a/internal/webapp/frontend/src/apps/Browser.tsx b/internal/webapp/frontend/src/apps/Browser.tsx index 01ce7e9..fb59b06 100644 --- a/internal/webapp/frontend/src/apps/Browser.tsx +++ b/internal/webapp/frontend/src/apps/Browser.tsx @@ -418,7 +418,7 @@ export default function Browser(props: { // dashboard below it. view = ( <> - +
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("/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 ? ( + 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 ( } + projectsNav={ setCreating(true)} />} orgBar={accountBar} topbar={} > - + setCreating(true)} canCreate={canCreate} /> + {newProjectDialog} ); } @@ -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: , + body: , } : null; @@ -223,7 +271,8 @@ export default function HubApp({ config }: { config: ServerConfig }) { } return ( - + setCreating(true)} menu={{ // Scoped views (/dashboard/, /history/) 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} + ); } diff --git a/internal/webapp/frontend/src/components/ConnectGuide.tsx b/internal/webapp/frontend/src/components/ConnectGuide.tsx index abc0800..23019b8 100644 --- a/internal/webapp/frontend/src/components/ConnectGuide.tsx +++ b/internal/webapp/frontend/src/components/ConnectGuide.tsx @@ -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 &&

{project.description}

}

- 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:"}

+ {existing && ( +

+ 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. +

+ )}

The agent installs the CLI, signs this machine in, and registers the sync hooks — asking diff --git a/internal/webapp/frontend/src/components/EmptyState.tsx b/internal/webapp/frontend/src/components/EmptyState.tsx index 49fe0d6..c66e954 100644 --- a/internal/webapp/frontend/src/components/EmptyState.tsx +++ b/internal/webapp/frontend/src/components/EmptyState.tsx @@ -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 (

Welcome to BearDrive

You're signed in, but you're not part of any project yet.

-
-

Connect a new drive to your project

+ {canCreate && ( +
+

Start a project

+

+ 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. +

+ +
+ )} +
+

{canCreate ? "Or let your agent do it" : "Connect a new drive to your project"}

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: diff --git a/internal/webapp/frontend/src/components/NewProjectDialog.tsx b/internal/webapp/frontend/src/components/NewProjectDialog.tsx new file mode 100644 index 0000000..c830611 --- /dev/null +++ b/internal/webapp/frontend/src/components/NewProjectDialog.tsx @@ -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; + 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 ( +

!open && onClose()}> + + +

New project

+
+ + { + setName(e.currentTarget.value); + if (err) setErr(""); + }} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> + {err && ( + + {err} + + )} + + {options.length > 1 && ( +
+ Starting point + {options.map((o, i) => ( + + ))} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/internal/webapp/frontend/src/components/ProjectNav.tsx b/internal/webapp/frontend/src/components/ProjectNav.tsx index fc71c45..41fde86 100644 --- a/internal/webapp/frontend/src/components/ProjectNav.tsx +++ b/internal/webapp/frontend/src/components/ProjectNav.tsx @@ -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("/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 (