Onboarding, Settings screen and host picker #24
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The first launch should walk the user through setup, and settings should be changeable later without editing a file. Split from #5, which now covers only the config layer underneath these screens.
Plan
internal/ui(docs/design.md→ Onboarding, steps 1–7), shown when no config exists. The connection test uses the adapter's "current user" and "list repos" calls. Renovate bot detection looks for open PRs fromrenovate/*branches and suggests their author.S): hosts (add using the same flow, edit, remove, set default), the update check toggle, and the "only merge when CI is green" setting per host with a per-repo override. Changes save immediately.Depends on
Context
docs/design.md→ Onboarding, Settings, Navigation model level 0.🤖 Building now. design.md doesn't spell out a few details, so these are the defaults going in. Each is cheap to change, so tell me if any should be different:
j/kmove,enteropens Edit · Set as default · Remove,spacetoggles,escgoes back.hfrom the repo list: always opens the host picker, even when it was skipped at startup. The picker includes + Add host.Sduring onboarding: does nothing, because keys type into the fields.The onboarding sign-in step asks for a token with
read:user,write:repositoryandwrite:issue.🤖 Handoff: stopped for the day. Pick up here on any machine.
State: branch
feature/24-onboarding-settingsis pushed at8ea83f7, rebased onto develop0b2c232(after #9), and every package passesgo test -race ./.... There's no MR yet. The delivery ran through/labs:composer: planning, building all five parts, consolidation, and both Phase 6 reviews are done. The full plan is in the next comment.What's built:
hback to it,Sfor Settings, session switching that drops results from the old host, saves written in order, the last-used host rememberedOpen Phase 6 findings. All go to one fresh
composer-fixer, followed by a targeted re-review of each axis:make lintis red.contextcheckatinternal/ui/app.go:83and:85: NewApp calls openOnboarding and openSession, and that chain reaches calls that use a stored ctx. Pass ctx explicitly; no//nolint.ineffassignatinternal/ui/onboarding_test.go:205reviveunused-parametertinobRun,onboarding_test.go:60connect to http://127.0.0.1:1: GET /version: Get "http://127.0.0.1:1/ap. Fix: wrap the onboarding error line to the frame width, and make the picker messageCan't connect to NAME: <innermost cause>.keyMap/help.Modelparams ononboarding.view,settings.viewandsettings.hints.newSettings()returns the zero value. Usesettings{}.stepTest, turn the empty-case switch into an early return.cmd/lazyforge/main.gorun()repeatstea.NewProgram(...).Run()in both branches. Run it once.appDepscallsconfig.DefaultPaths()even with--config. Only the state path needs it.write:repositoryshould also say "close PRs".Accepted, no change:
model.gowas touched by a behaviour-preserving extraction.atotto/clipboardindirect dependency comes with the approved text input.gitea.Newnow returns "not found" for a/versionreply that isn't a valid version.Phase 8 notes:
TestLiveGreenCIcovers the host-level toggle only. Add the per-repo override case and the Settings path.Remaining after the fix:
Closes #24)Decisions so far:
--demonever touches your config, and itsh/Shints are hidden.🤖 #24 plan (composer Phase 1, with the lead rulings at the end). It is posted here so a session on another machine can continue without re-planning.
Plan: #24 onboarding, Settings screen, host picker
Working dir
/Users/bobparsons/Development/personal/lazyforge-24, branchfeature/24-onboarding-settings, fork_pointb1beb5c(stacked on #9).Conventions: skills
go-developmentandcomment-standard; repoCLAUDE.md. Verify withmake check.Goal
Replace cmd's two dead ends ("no config yet: onboarding is not built yet", "pick a host with --host") with real screens:
onboarding (design.md steps 1-7), the host picker (level 0, with + Add host), and Settings (
S) that saves on every change.Task type / size / parallelism
Feature. Size L (≈1,300-1,700 lines incl. tests across 4 packages).
Three waves, five subtasks:
onboarding.go/settings.gowith the exact seam signatures below.Decisions (engineering, made here; confirm under QUESTIONS in the report)
D1. New root model
ui.App(internal/ui/app.go) owns the screen (onboarding | picker | settings | session), the config, and the one live session. The existingui.Modelstays the session model unchanged;ui.New(ctx, svc)keeps working (--demoand every existing test use it).model.go,load.go,actions.go,dialog.goare not edited.D2. App intercepts two keys before the session instead of editing
model.go:S(open Settings) andh/leftatlevelRepos(open the picker), both only whensession.dialog == nil && !session.showHelp. Everything else goes to the session.D3. Stale session messages are dropped by generation stamping. Switching host cancels the old session's context, bumps
App.gen, and wraps every command the session returns withstamp(gen, cmd).stampruns the inner cmd; atea.BatchMsgis re-stamped element-wise; a msg implementing the unexported markerinterface{ fromSession() }is wrapped instampedMsg{gen, msg}; anything else (the runtime's exec message for$EDITOR,tea.QuitMsg) passes through untouched. App delivers astampedMsgonly whengen == a.gen. The marker methods forreposLoadedMsg, changeRequestsLoadedMsg, issuesLoadedMsg, runsLoadedMsg, refreshTickMsg, recheckedMsg, mergeDoneMsg, actionDoneMsgare declared inapp.gonext to the interface (so load.go/actions.go stay untouched). Unstamped non-App messages (e.g.editorDoneMsgfrom the ExecProcess callback) go to the current session. Why: two hosts can carry the sameowner/name(mirrors), so the existingcore.Keyrepo check doesn't protect a new session from an old host's results.D4. UI never imports
forge/gitea. cmd injectsConnect func(context.Context, config.Host) (forge.Forge, error)(type check,config.ResolveToken,gitea.New) andProbe func(context.Context, string) (forge.Kind, error)(gitea.Probe).uimay importconfig(leaf package) andcore.D5. Live "only merge when green". App holds
live *atomic.Pointer[config.Config](allocated inNewApp, shared by value copies). A session for hostnameis built withcore.New(f, core.Options{RequireGreenCI: func(r domain.RepoRef) bool { return live.Load().Hosts[name].RequiresGreenCI(r.String()) }}). Every applied config change doeslive.Store(&next). Race-free (core.Merge reads it off the UI goroutine); a Settings toggle takes effect on the next merge dialog without reconnecting. Configs are never mutated in place: every change starts fromcfg.Clone()(newconfig.Config.Clone, deep-copiesHostsand eachReposmap).D6. Saves are tea.Cmds, ordered by sequence number.
saver(inapp.go) =struct{ mu sync.Mutex; path string; written int }held by pointer.a.save(next) tea.Cmdincrementsa.saveSeqand returns a cmd that locksmu, skips the write ifseq < written(a newer snapshot already landed), elseconfig.Save(path, next)and returnsconfigSavedMsg{seq, err}. Settings changes apply optimistically (a.cfg = next; live.Store) and show a status error if the save fails. Onboarding's final save is not optimistic: App appliesnextonly onconfigSavedMsg{seq: thatSeq, err: nil}; on failure onboarding stays on step 7 with the error. The file seam for tests is the path (Deps.ConfigPath/StatePathintot.TempDir()), so tests never touch the user's files and still prove the real 0600 write.D7. State.LastHost is written fire-and-forget by a cmd (
LoadState→ setLastHost→SaveState, errors ignored: losing state is harmless) whenever a session starts from the picker or from onboarding.D8. Startup connect stays in cmd. When
SelectHostreturns a name, cmd connects before the UI as today (errors still exit with the existing messages). Only picker picks, onboarding and edits connect from inside the UI.D9. Onboarding async results are checked against their inputs, not an id. A probe result carries the URL it probed, a connect result carries the
config.Hostit tried, a Renovate result carries nothing but is accepted only on step 6 with the field still untouched. A result is used only if the step and the current input still match; esc cancels the step's context.D10. Renovate suggestion lives in core (
core.(*Service).SuggestRenovateUser): scan the most recently active repos (cap 10,ponytail:comment naming the ceiling), first open CR whoseSourceBranchstarts withrenovate/wins, return itsAuthor. Per-repo errors other than ctx errors are skipped. ReusesRepos/ChangeRequests(cached, semaphore-bounded); no adapter change.D11. Permissions shown at step 4 (Forgejo/Gitea token scope names), derived from the adapter's calls:
read:user: sign-in check (GET /user)write:repository: list repos, PRs, CI status and runs, merge, approve, close PRswrite:issue: issues, comments, closing issues, ticking dashboard entriesToken page link:
<url>/user/settings/applications. Verify the list against the live server once it's back (see report RISKS); addread:misconly if/versionturns out to need it.D12. In-screen keys reuse existing bindings only (no new letters):
j/kmove,l/enterin/activate,spacetoggle/cycle,escback/cancel,y/enterconfirm,tabswitch sign-in mode,qquit (picker only). keys.go gains exactly two bindings:Settings(S, help "settings") andEnter(enteronly, so text inputs can receivey,l,q,j,k,h,S). Text-input screens match onlyEnter,Close(esc),NextBox(tab, step 4),Up/Downrestricted to arrow keys on list steps, andInterrupt.D13. Host name rules (step 7):
^[A-Za-z0-9._-]+$, unique among configured hosts (except the host being edited). Suggested from the URL hostname: strip a leadinggit./www./code., take the first label (git.bobparsons.dev→bobparsons,codeberg.org→codeberg); if taken, append-2,-3, ….D14. URL normalisation (step 3): trim spaces and trailing
/; prependhttps://when there's no scheme; must parse with a non-empty host, else "Enter a server address like https://git.example.com" without probing.Seams (public and package-internal interfaces the test-writers target)
Subtasks
S1: Probe and Renovate suggestion (wave 1, independent)
File domain:
internal/forge/gitea/gitea.go,internal/forge/gitea/probe_test.go(seam check),internal/core/renovate.go,internal/core/renovate_test.go(seam check).Steps:
gitea.Probe: unauthenticatedGET {base}/api/v1/version;+gitea-in the version →KindForgejo, elseKindGitea. Reuse the existing request/statusErrorpath (a forge with empty token; omit the Authorization header when the token is empty). Wrap errorsprobe %s: %w. Non-JSON or missingversion→ error wrappingforge.ErrNotFound("no Forgejo or Gitea API"). ErrUnauthorized →("", nil).Newmay call the same unexported version helper.SuggestRenovateUserper D10.AC:
{"version":"16.0.5+gitea-1.22.0"}→KindForgejo;{"version":"1.22.0"}→KindGitea; request carries no Authorization header.errors.Is(err, forge.ErrNotFound); 401 →("", nil); closed server/refused port → error that is neither (a*net.OpErrorreachable viaerrors.As).Newbehavior unchanged (existing gitea tests pass).SourceBranch "renovate/foo",Author "renovate-bot"→"renovate-bot"; no such CR →"", nil; a repo whose CR listing errors is skipped and a later repo still wins; cancelled ctx → error; only the first 10 repos by activity are scanned (11th repo's renovate CR not found).S2: App shell, picker, session switching, saves (wave 1)
File domain:
internal/ui/app.go,internal/ui/picker.go,internal/ui/keys.go,internal/ui/onboarding.go(stub only),internal/ui/settings.go(stub only),internal/ui/app_test.go(seam check),internal/config/config.go(addClone),docs/architecture.md(UI section: App root, D3, D4, D5),docs/adr/0012-ui-app-root-and-settings.md(D1-D7).Steps:
config.Clone.Settings(S, "settings") andEnter(enter) bindings;SinfullHelpand in the repos-level short help; repos-level hint forh= "hosts".Appper D1-D8: screens;NewApppicks the start screen (Fresh → onboarding withwelcome;Host != ""→ session fromForge; else picker);startSession(name, f)(cancel previous ctx, gen++,core.Newwith D5 closure,New(sctx, svc), replay lastWindowSizeMsg, returnstamp(gen, session.Init())batched with the D7 state write); key interception D2;stampD3; saver D6;WindowSizeMsgstored and forwarded.picker.go): rows = configured hosts sorted by name, then+ Add host; a row shows name, URL and(default)forDefaultHost; initial cursor onLastHostif configured, elseDefaultHost, else row 0.j/k/gg/Gmove;l/enteron a host: if it's the active session's host return to the session without reconnecting, elseConnectin a cmd (status "Connecting to NAME…"; resultconnectedMsg{name, f, err}used only ifname == a.connecting); error → status error, stay.l/enteron+ Add host→ onboarding from step 2.S→ Settings.qquits. Header breadcrumb:lazyforge › Hosts.onboardDoneMsg→ build next (Clone; on edit with renamed host delete the old key and moveDefaultHostif it pointed there; copyRequireGreenCIandReposfrom the old host) → D6 non-optimistic save → on success: add →startSession(name, f)(straight into the repo list); edit → back to Settings, and if the edited host is the active one,startSession(newName, f)while staying on Settings.onboardCancelMsg→ previous screen (quit when Fresh).settingsIntent: change → optimistic apply + save; add → onboarding step 2; edit → onboarding step 3 prefilled; close → previous screen (session or picker).onboarding.go/settings.gocompile with the seam signatures, render a placeholder, and the stub settings returnsintentCloseon esc / stub onboarding emitsonboardCancelMsgon esc.AC:
Clone: mutating the clone'sHosts, a host'sRepos, or adding a repo override leaves the original unchanged.Fresh→ onboarding;Hostset withForge→ session (repo list loads); neither → picker.b, a, c→ rowsa, b, c, + Add host;(default)on the default host; cursor starts onLastHost, falls back toDefaultHost, then row 0;LastHostnaming an unknown host falls back.Connectonce with that host'sconfig.Host; success → session for that host and the state file atStatePathhaslast_host = NAME(other state fields preserved);Connecterror → status shows it, screen stays picker, no session.+ Add host→ onboarding (not at step 1). Zero configured hosts → picker shows only+ Add host.h→ picker; picking the same host returns to the same session with noConnectcall;hinside an open dialog or the help overlay does not open the picker.Sfrom the session (repos, boxes, details) and from the picker opens Settings;Swith a dialog open or help shown does nothing; Settings close returns to the screen it came from.reposLoadedMsg(orrefreshTickMsg) produced by A's session is dropped; B's are applied. A non-marker msg returned by the session's cmd passes throughstampunwrapped.hwithrequire_green_ci = false; apply a Settings change setting it true;svc.RequiresGreenCI(repo)flips to true without a new session. Same for a per-repo override.ConfigPathwith mode 0600; two quick changes leave the file equal to the second; a save error (ConfigPath inside a read-only dir / path is a directory) shows a status error.saveFailedcalled, still on onboarding. Edit with rename → old key gone, default and overrides moved.go build ./...passes with the stubs; existing ui tests pass unchanged.S3: Onboarding flow (wave 2)
File domain:
internal/ui/onboarding.go(replaces stub),internal/ui/onboarding_test.go(seam check),docs/design.md(step 4 only: record the D11 permission list, per the ticket's "engineering fills it in").Steps: implement the flow with
bubbles/v2/textinput; one input per step; all I/O via cmds (D9).entercontinue;esc→onboardCancelMsg(App quits on Fresh).enteron them does nothing;enterselects.enter→ Probe cmd ("Checking…"). Success with matching kind (or kind"") → step 4. Kind mismatch → "That server runs Gitea, not Forgejo" (names swapped as appropriate), stay. ErrNotFound → "No Forgejo or Gitea API at that address", stay. Network error → "Can't reach the server: ", stay.tabtoggles "Paste a token" / "Command that prints one"; token mode usestextinput.EchoPassword; shows the D11 permission list and the token page URL. Empty input → inline error, stay.enter→ step 5.config.Host{Type, URL, Token|TokenCmd},connectthencore.New(f, core.Options{}).Reposin one cmd; success "Signed in as LOGIN · N repositories",enter→ step 6. Failure, stay on step 5 withenterretry,escback to step 4:errors.Is(err, forge.ErrUnauthorized)from connect → "The token was rejected. Check it's valid and has read:user."*net.OpError/*url.Errortimeout → "Can't reach the server: …"err.Error()(token_cmd errors are already output-free).SuggestRenovateUseron the step-5 service when it arrives ("Looking for Renovate PRs…" meanwhile, "No Renovate PRs found" when ""); typing before it arrives keeps the typed value.enteraccepts (blank = skip). Editing prefills fromhost.RenovateUserand skips the scan.entervalidates (D13 errors inline) → emitonboardDoneMsg.saveFailed(err)shows "Couldn't save: …" and stays.escon steps 3-7 goes back one step (cancelling that step's in-flight cmd ctx);escon the first shown step →onboardCancelMsg.AC:
GitHub/GitLabrender "coming soon"; Down from Gitea doesn't land on them; Forgejo and Gitea select.git.example.comprobeshttps://git.example.com; probe success → step 4; mismatch, ErrNotFound and network errors each show their message and stay on step 3;not a urlwith no host shows the format hint without calling Probe.read:user,write:repository,write:issue) and<url>/user/settings/applicationsare shown; empty input stays.enterretries (Connect called again). Token mode setsHost.Token, command mode setsHost.TokenCmd, never both.bobparsonsforhttps://git.bobparsons.dev; taken name →-2; invalid characters and duplicates rejected inline;enteremitsonboardDoneMsgwith type, URL, token/token_cmd, renovate_user and name.editname, and allows keeping the same name.escwalks back one step; on the first shown step it emitsonboardCancelMsg.q,S,h,j,yinto any input inserts the character (no quit, no Settings, no navigation).ConfigPathis mode 0600 and loads back withconfig.Load.S4: Settings screen (wave 2)
File domain:
internal/ui/settings.go(replaces stub),internal/ui/settings_test.go(seam check).Layout (one navigable list, header
lazyforge › Settings):(default),(connected)foractive; then+ Add host.Check for updates at startup on|off.Only merge when CI is green on|off, followed by its repo override rowsowner/name inherit|on|off— rows for the active host come fromv.reposmerged with existing overrides; for other hosts only existing overrides.Keys (D12):
j/k,gg/Gmove; on a host rowl/enteropens an inline menuEdit · Set as default · Remove(j/k,enter,esc);+ Add host→intentAdd;space/enteron the update or host-merging row toggles;space/enteron a repo row cycles inherit → on → off → inherit;esc/h→intentClose.Remove asks inline
Remove NAME? y/enter confirm · esc cancel; removing the default also clearsDefaultHost; removing the active host is refused: "Switch to another host before removing NAME". Set as default on the current default does nothing.Every change returns
intentChangewith aCloned config; pure helpers do the edits.AC:
(default)and(connected)markers correct.Update.Check; App saves it (file reflects it immediately).Hosts[h].RequireGreenCI; repo row cyclesRepos[r].RequireGreenCInil → true → false → nil, and at nil the repo key is removed fromRepos(the file has no empty[hosts.h.repos."o/r"]table).v.repos) plus overrides for repos not in that list; another host lists only its overrides.DefaultHost; Remove + confirm deletes the host and clearsDefaultHostwhen it pointed there;escat the confirm leaves config unchanged; removing the active host returns no change and shows the refusal.intentEdit{host};+ Add host→intentAdd;esc→intentClose.cfgis never mutated (compare against a pre-captured copy after each intent).S5: cmd wiring (wave 2)
File domain:
cmd/lazyforge/main.go,cmd/lazyforge/main_test.go.Steps: replace
realServicewithappDeps(signature above) andrunUIwithtea.NewProgram(ui.NewApp(ctx, d)).Run();--demopath unchanged. Missing config →maybeUpdate(ctx, !noUpdateCheck)thenDeps{Fresh: true, Config: config.Config{Update: config.Update{Check: true}}}.SelectHost→ErrNeedPicker→Deps{Host: ""}; other errors returned. Named host →connect(moves today's type check,ResolveToken,gitea.Newwith the 30 s client) and errors keep today'shost %q: …wrapping.StatePathfromDefaultPathseven when--configis given;LastHostfromLoadState(error → zero).Probe=gitea.Probewith a 10 s client.AC:
Deps.Fresh, no error,Update.Checktrue.--host→Deps.Host == "", no error,Deps.Forge == nil.type "github" has no adapter yet, token_cmd failure hides output, unknown--hostnames the host.Deps.Hostset,Deps.Forge.Info().Userfrom/user.connectfor agithubhost returns the "no adapter yet" error without runningtoken_cmd.TestRealServiceWiresGreenCIhere.)Shared constraints
uiimportsconfig,core,forge,domain; neverforge/giteaorforgetestoutside tests (depguard).fmt.Print/logging of tokens anywhere; error messages built here never includeTokenorTokenCmdoutput.tea.Cmds;Updatenever blocks.textinputis inbubbles/v2).Verification
make check(build, test, lint, format) on the integration branch; tmux check of: first run in an emptyXDG_CONFIG_HOMEthrough to the repo list (fake token against an httptest/fixture server is fine while the git server is down), picker with two hosts,Stoggles reflected inconfig.toml,hfrom repo list → picker → same host.Lead rulings (Opus, override anything above that conflicts)
🤖 resumed: running the Phase 6 fixer from the handoff list, then test backfill and MR.
🤖 MR #32 open: reviews passed, tests backfilled. Merging once CI is green.
🤖 merged in #32