control: repository screens (CLI views stage 3a) !556
37 files changed, +1040 −73
Layout: unified · split
CHANGELOG.org +6
| @@ -25,6 +25,12 @@ anything beyond "replace the binary and restart" is needed. | ||
| 25 | 25 | dashboard drops the server build and queue tables (=admin stats= has |
| 26 | 26 | them; =--json= is unchanged) and shows a job's failed build only |
| 27 | 27 | while it is that job's latest. |
| 28 | - Every repository command at a terminal is a screen (#319): =repo | |
| 29 | log=, =tree=, =refs=, =symbols=, =list=, =search=, =bookmarks=, | |
| 30 | =topics=, the secret, deploy-key, domain, access, mirror and runner | |
| 31 | lists, =repo settings show= (now with approvals, resolved threads, | |
| 32 | code owners and website), =repo deps status=, and =repo commit=, whose | |
| 33 | diff follows the screen. | |
| 28 | 34 | |
| 29 | 35 | * v1.41.0 — 2026-10-01 |
| 30 | 36 | |
docs/plans/2026-10-01-cli-views-stage3.md added +326
| @@ -0,0 +1,326 @@ | ||
| 1 | # CLI views implementation plan (stage 3) | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Every command that still draws its terminal output through `table` or `view` draws a screen, and `view` keeps only its piped behaviour. | |
| 6 | ||
| 7 | **Architecture:** Stages 1 and 2 built `screen` (`internal/control/screen.go`), `emitView`/`emitPageView`, and eleven screens. This stage adds one helper, `listScreen`, and migrates the remaining 70 commands noun by noun: a builder per command (or per shared emitter), the command switched to `emitView`/`emitPageView`, its piped output pinned before and after. The last task strips `view`'s terminal branches, so a caller missed here falls back to the plain layout rather than a half-styled one. | |
| 8 | ||
| 9 | **Tech Stack:** Go, `internal/control`. | |
| 10 | ||
| 11 | **Spec:** `docs/specs/2026-10-01-cli-views-design.md`. Stage 1–2 plan: `docs/plans/2026-10-01-cli-views.md` (its "Corrections" section applies here too). | |
| 12 | ||
| 13 | **Issues:** #316 (tracking), #319 (this stage). | |
| 14 | ||
| 15 | ## Global Constraints | |
| 16 | ||
| 17 | - Piped output and `--json` byte-identical for every migrated command; prove it with `pinPlain` before touching the command. | |
| 18 | - Rendering stays in `internal/control`; nothing in `cmd/gitbay` changes. | |
| 19 | - Colour is never the only signal (`stripSGR(colour) == colourless` holds for every screen; the renderer guarantees it if builders use cells, never raw SGR). | |
| 20 | - Every legend and `more` argv passes `checkActions` (registered path, declared flags). | |
| 21 | - Never put in a cell or legend: webhook secrets, API token values, push device tokens beyond `ShortToken`, mirror tokens, mailbox credentials, `web login` URLs, base64 page content. A legend command that reads a secret takes it from stdin and carries no value (`repo secret set <path> <NAME>`, never `--secret`). | |
| 22 | - Destructive commands appear in a legend without their confirmation flag (`release delete <repo> <tag>`, never `--yes`). | |
| 23 | - Commit messages end `Ref #319`; the last commit of the stage `Closes #319`. No attribution lines. Signed commits, `--strategy ff`. | |
| 24 | - Locally: `go build ./... && go vet ./... && go test ./internal/control/`. CI runs e2e, including `e2e/readonly_test.go`, which runs every read command at 60 columns and fails on a line wider than 60 (suggested commands are exempt only in unit tests; keep legend argv short). | |
| 25 | ||
| 26 | ## Reference | |
| 27 | ||
| 28 | ### Helpers that exist | |
| 29 | ||
| 30 | | Name | Where | Use | | |
| 31 | |---|---|---| | |
| 32 | | `screen`, `field`, `section{title,n,note,rows,more,empty}`, `row`, `rowOf`, `action{group,argv}` | `screen.go` | the model | | |
| 33 | | `c.emitView(data, plain, build)` / `c.emitPageView(p, items, next, plain, build)` | `control.go`, `cursor.go` | route terminal to `build` | | |
| 34 | | `discussion(cs)`, `events(cs)` | `screen.go` | comment and system-event sections | | |
| 35 | | `cRef cGlyph cYou cFlex cText cMeta cAge cNum cSize cSwatch cState cLink cMark` | `table.go` | cells | | |
| 36 | | `glyph(state)` | `term.go` | `✓` success/ok/approved/verified/passed/merged, `✗` failure/failed/error/bad sig, `◐` pending/running/queued, `○` closed/draft/canceled/skipped | | |
| 37 | | `labelsMark`, `checksMark`, `reviewMark` | `marks.go` | row marks | | |
| 38 | | `keyValues`, `c.usedText`, `c.expiresText` | `audit.go`, `identity.go` | reuse in builders | | |
| 39 | | `checkActions`, `pinPlain`, `screenCtx`, `renderString`, `sectionCounts`, `actionArgvs`, `actionVerbs` | `*_test.go` | tests | | |
| 40 | ||
| 41 | ### Migration recipe (every command) | |
| 42 | ||
| 43 | 1. **Pin.** In the task's test file, add `Test<Cmd>PlainPinned`: build the fixture, `Dispatch` the command with no `Term`, `pinPlain(t, "<cmd-slug>", out.String())`. Run with `-update-plain`, then without. Commit nothing yet. If the screen tests in the same file do not compile yet, keep them out of the file until the pin exists (stage 2 lost a file juggling this; copy the full file aside with `cp` and check `grep -c 'func Test'` after restoring). | |
| 44 | 2. **Screen test.** Construct the command's data value directly (no store), call the builder, assert section titles and counts, the lead glyph of a representative row, the action argv, and `checkActions(t, s)`. | |
| 45 | 3. **Builder.** Write `<cmd>Screen(c *Ctx, ...) screen` next to the command, from the spec row below. | |
| 46 | 4. **Wire.** Replace `c.emit(d, plain)` with `c.emitView(d, plain, func() screen { return <cmd>Screen(...) })` (`emitPage` → `emitPageView`). Inside `plain`, delete every `c.Term.Cols > 0` branch and keep the `== 0` path unconditionally; a terminal no longer reaches `plain`. Move terminal-only data loads out of `plain` to a `c.Term.Cols > 0 && !c.JSON` block before the emit. | |
| 47 | 5. **Orphans.** `grep` every helper the deleted branches called; delete those now unused, with their tests. | |
| 48 | 6. **Run** `go test ./internal/control/ -run '<Cmd>'`, then the package. Pins must still match. | |
| 49 | ||
| 50 | ### Worked example: a list with a shared emitter (`repo topics`, `repo topics add`, `repo topics remove`) | |
| 51 | ||
| 52 | `runRepoTopics` (`repo.go:963`) and `editTopics` (`repo.go:987`) both print `[]string` through a one-column table. | |
| 53 | ||
| 54 | Test (`internal/control/stage3repo_test.go`): | |
| 55 | ||
| 56 | ```go | |
| 57 | func TestRepoTopicsPlainPinned(t *testing.T) { | |
| 58 | st, repo, uid := newQueueTestRepo(t) | |
| 59 | owner := store.User{ID: uid, Username: "alice"} | |
| 60 | c, out, errOut := mrTestCtx(st, owner) | |
| 61 | if code := Dispatch(c, []string{"repo", "topics", "add", repo.Path(), "cli", "forge"}); code != protocol.ExitOK { | |
| 62 | t.Fatalf("exit %d: %s", code, errOut) | |
| 63 | } | |
| 64 | pinPlain(t, "repo-topics-add", out.String()) | |
| 65 | c, out, errOut = mrTestCtx(st, owner) | |
| 66 | if code := Dispatch(c, []string{"repo", "topics", repo.Path()}); code != protocol.ExitOK { | |
| 67 | t.Fatalf("exit %d: %s", code, errOut) | |
| 68 | } | |
| 69 | pinPlain(t, "repo-topics", out.String()) | |
| 70 | } | |
| 71 | ||
| 72 | func TestTopicsScreen(t *testing.T) { | |
| 73 | s := topicsScreen(screenCtx(80, false), screenRepo, []string{"cli", "forge"}) | |
| 74 | if n := sectionCounts(s); n["Topics"] != 2 { | |
| 75 | t.Errorf("sections = %v", n) | |
| 76 | } | |
| 77 | if got := actionVerbs(s); !slices.Equal(got, []string{"topics", "topics"}) { | |
| 78 | t.Errorf("actions = %q", got) | |
| 79 | } | |
| 80 | checkActions(t, s) | |
| 81 | } | |
| 82 | ``` | |
| 83 | ||
| 84 | Helper and builder (`screen.go` and `repo.go`): | |
| 85 | ||
| 86 | ```go | |
| 87 | // listScreen is a list command's terminal screen: one section of rows | |
| 88 | // and the commands that apply. | |
| 89 | func listScreen(title string, rows []row, actions ...action) screen { | |
| 90 | return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions} | |
| 91 | } | |
| 92 | ``` | |
| 93 | ||
| 94 | ```go | |
| 95 | // topicsScreen is a repository's topics at a terminal, after a read or | |
| 96 | // an edit. | |
| 97 | func topicsScreen(c *Ctx, repo store.Repo, topics []string) screen { | |
| 98 | rows := make([]row, len(topics)) | |
| 99 | for i, t := range topics { | |
| 100 | rows[i] = rowOf(cRef(t)) | |
| 101 | } | |
| 102 | return listScreen("Topics", rows, | |
| 103 | action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}}, | |
| 104 | action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}}, | |
| 105 | ) | |
| 106 | } | |
| 107 | ``` | |
| 108 | ||
| 109 | Both `runRepoTopics` and `editTopics` call `c.emitView(topics, plain, func() screen { return topicsScreen(c, repo, topics) })` (`now` in `editTopics`). An empty list still prints "nothing to list" on stderr: `emit` handles that before `build` runs. | |
| 110 | ||
| 111 | ### Worked example: a show (`org show`, also serving `org members list`) | |
| 112 | ||
| 113 | ```go | |
| 114 | // orgShowScreen is org show at a terminal: the organisation and its | |
| 115 | // members, and where to go from here. | |
| 116 | func orgShowScreen(c *Ctx, d orgShowOut) screen { | |
| 117 | members := section{title: "Members", n: len(d.Members)} | |
| 118 | for _, m := range d.Members { | |
| 119 | members.rows = append(members.rows, rowOf(cRef(m.User), cState(m.Role))) | |
| 120 | } | |
| 121 | return screen{ | |
| 122 | fields: []field{{"Org", []cell{cLink(d.Org, c.siteURL(d.Org))}}}, | |
| 123 | sections: []section{members}, | |
| 124 | actions: []action{ | |
| 125 | {"Members", []string{"org", "members", "add", d.Org, "<user>"}}, | |
| 126 | {"Org", []string{"org", "team", "list", d.Org}}, | |
| 127 | {"Org", []string{"org", "label", "list", d.Org}}, | |
| 128 | {"Org", []string{"org", "milestone", "list", d.Org}}, | |
| 129 | }, | |
| 130 | } | |
| 131 | } | |
| 132 | ``` | |
| 133 | ||
| 134 | If `runOrgShow`'s output type is a local `type out struct`, hoist it to package level as `orgShowOut` (same JSON tags) as stage 2 did for `repoShowOut`. `org members list` delegates to `runOrgShow`, so it migrates with it. | |
| 135 | ||
| 136 | ### Worked example: a stream with a header (`repo commit`) | |
| 137 | ||
| 138 | The screen has no diff part. `runRepoCommit` (`sig.go:250`) keeps `c.emit` and branches itself: | |
| 139 | ||
| 140 | ```go | |
| 141 | return c.emit(d, func(w io.Writer) { | |
| 142 | if c.Term.Cols == 0 { | |
| 143 | // existing plain header and diff, unchanged | |
| 144 | return | |
| 145 | } | |
| 146 | c.render(w, commitScreen(c, repo, d, body)) | |
| 147 | fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff)) | |
| 148 | }) | |
| 149 | ``` | |
| 150 | ||
| 151 | `commitScreen` fields: `Commit` (`cRef(sha10)`, `cText(subject)`), `Author` (`cText(name+" <"+email+">")`, `cAge(date)`), `Signature` (`cGlyph(state)`, `cState(state)`, `cMeta(signer, fingerprint)`), `URL` when `!c.Term.Links`; body = message without subject, format `"text"`; section `Checks` (`cGlyph(state)`, `cFlex(context)`); actions `Read: repo log <path> --ref <sha>`, `Read: repo tree <path> --ref <sha>`. The legend prints before the diff, which is what a reader scrolling up from the end of a long patch expects to find above it. | |
| 152 | ||
| 153 | ## Spec rows | |
| 154 | ||
| 155 | Columns: **title** of the section (or "fields" for a show), **lead** (the glyph cell second in the row, after the ref, or none), **row** cells after the lead, **actions** (`Group: argv`), **notes**. `<p>` is `repo.Path()`. | |
| 156 | ||
| 157 | --- | |
| 158 | ||
| 159 | ## Task 1: `listScreen` and the repository lists (MR 3a) | |
| 160 | ||
| 161 | **Files:** `screen.go` (`listScreen`), `repo.go`, `pagescmd.go`, `deploykey.go`, `build.go` (secrets), `mirrorcmd.go`, `runnerrepo.go`, test `stage3repo_test.go`, pins under `testdata/plain/`. | |
| 162 | ||
| 163 | | Command | Title | Lead | Row | Actions | Notes | | |
| 164 | |---|---|---|---|---|---| | |
| 165 | | `repo topics` / `topics add` / `topics remove` | Topics | none | `cRef(t)` | worked example | shared `topicsScreen` | | |
| 166 | | `repo domain list` (`pagescmd.go:196`) | Pages domains | `cGlyph(state)` (`expired` → `"failed"`) | `cRef(domain)`, `cState(state)` | `Domains: repo domain add <p> <domain>`, `repo domain verify <p> <domain>` | | | |
| 167 | | `repo deploy-key list` (`deploykey.go:93`) | Deploy keys | none | `cRef(fp)`, `cState(mode)`, `cFlex(label)`, `cMeta(algo, "used "+usedText, expiresText)` | `Keys: repo deploy-key add <p>`, `repo deploy-key remove <p> <fingerprint>` | key arrives on stdin | | |
| 168 | | `repo secret list` (`build.go:555`) | Build secrets | none | `cRef(name)` | `Secrets: repo secret set <p> <NAME>`, `repo secret remove <p> <NAME>` | names only, never values | | |
| 169 | | `repo access list` (`repo.go:753`) | Access | none | `cRef(user)`, `cState(role)`, `cMeta("via "+source)` | `Access: repo access grant <p> <user> write`, `repo access revoke <p> <user>` | | | |
| 170 | | `repo mirror list` (`mirrorcmd.go:99`) | Mirrors | `cGlyph("error"/"pending"/"ok")` from LastError/Pending | `cRef(id)`, `cFlex(url)`, `cMeta(direction, "synced "+relAge(LastSync))`, `cMark(LastError, sgrRed)` | `Mirrors: repo mirror sync <p>`, `repo mirror remove <p> <id>` | never the token | | |
| 171 | | `repo runner list` (`runnerrepo.go:102`) | Runners | `cGlyph("running")` when BuildNumber≠0 | `cRef(fp)`, `cFlex(username)`, `cMeta(algo, seen, "building "+repo+" #n "+job)` | `Runners: repo runner remove <p> <fingerprint>` | | | |
| 172 | | `repo bookmarks` (`repo.go:1225`) | Bookmarks | none | `cLink(path, url)`, `cState(vis)`, `cFlex(desc)`, `cMeta(n+" bookmarks")` | `Read: repo show <first path>` | drop `cNum` so no header row | | |
| 173 | | `repo search` (`repo.go:1045`) | Repositories matching "q" | none | `cLink`, `cState(vis)`, `cFlex(desc)`, `cMeta(topics joined)` | `Read: repo show <first path>` | | | |
| 174 | | `repo list` (`repo.go:339`) | Repositories | none | `cLink`, `cState(vis[, archived])`, `cFlex(desc)` | `Read: repo show <first path>`, `New: repo create <owner>/<name>` | `emitPageView` | | |
| 175 | ||
| 176 | Steps: recipe per command; one commit for `listScreen` + topics, one per row group of three or four, each `Ref #319`. | |
| 177 | ||
| 178 | ## Task 2: Repository browsing (MR 3a) | |
| 179 | ||
| 180 | **Files:** `sig.go` (log), `read.go` (tree, refs), `symbols.go`, test `stage3repo_test.go`. | |
| 181 | ||
| 182 | | Command | Title | Lead | Row | Actions | Notes | | |
| 183 | |---|---|---|---|---|---| | |
| 184 | | `repo log` (`sig.go:144`) | Commits on `<ref>` (+ " touching `<path>`") | `cGlyph(sig state)` | `cRef(sha10)`, `cFlex(subject)`, `cMeta(author, relAge(date))` | `Read: repo commit <p> <first sha>`, `repo tree <p> --ref <ref>` | delete the in-closure terminal table | | |
| 185 | | `repo tree` (`read.go:281`) | `<p>[/dir] at <ref>` | none | dirs `cMark(name+"/", sgrBlue)`, files `cText(name)`; `cSize`; `cMeta(sha10)` | `Read: repo cat <p> <dir/first file> --ref <ref>`, `repo log <p> --ref <ref> --path <dir>` | `sgrBlue` replaces the last `sgrCyan` in `read.go` | | |
| 186 | | `repo refs` (`read.go:~85`) | two sections, Branches and Tags | none | `cRef(name)`, `cMeta(sha10[, "default"])` | `Read: repo log <p> --ref <default>` | default branch first; tags from `sorted` | | |
| 187 | | `repo symbols` (`symbols.go:50`) | Symbols matching "q" | none | `cRef(name)`, `cText(kind)`, `cFlex(path:line)` | `Read: repo cat <p> <first path> --ref <ref>` | keep the two stderr notes; `emitPageView` | | |
| 188 | ||
| 189 | ## Task 3: Repository shows (MR 3a, then open it) | |
| 190 | ||
| 191 | **Files:** `repo.go` (settings), `deps.go`, `sig.go` (commit), test `stage3repo_test.go`. | |
| 192 | ||
| 193 | | Command | Fields | Sections | Actions | Notes | | |
| 194 | |---|---|---|---|---| | |
| 195 | | `repo settings show` (`repo.go:783`) | `Repo` (`cRef(p)`), then one field per setting, `on`/`off` via `cState`; add the four not shown today (approvals, resolved, codeowners, website) to the **screen only** | none | `Protect: repo settings protect <p> <branch>`, `repo settings require-mr <p> on` (check the real arg form) | piped output unchanged | | |
| 196 | | `repo deps status` (`deps.go:85`) | `Checks` (`cGlyph("ok")`/`cGlyph("failed")` when LastError, `cText("on")`), `Last check` (`cAge`), `Error` (`cMark(…, sgrRed)`), `Tracked in` (`cLink("#n", …"issues"…)`) | Behind: `cRef(name)`, `cText(ecosystem)`, `cMeta(current+" → "+latest)` | `Deps: repo deps disable <p>` | the disabled branch is its own `emitView` with one field `Checks: off` and `Deps: repo deps enable <p>` | | |
| 197 | | `repo commit` (`sig.go:250`) | worked example | Checks | worked example | stream: keep `emit` | | |
| 198 | ||
| 199 | End of task: CHANGELOG line under Unreleased, push `cli-views-3a`, `gitbay mr create --source cli-views-3a --target main --title "control: repository screens (CLI views stage 3a)"`. | |
| 200 | ||
| 201 | ## Task 4: Account (MR 3b) | |
| 202 | ||
| 203 | **Files:** `identity.go`, `register.go`, `sig.go` (pgp), `token.go`, `web.go`, `profile.go`, test `stage3account_test.go`. | |
| 204 | ||
| 205 | | Command | Title / fields | Lead | Row | Actions | Notes | | |
| 206 | |---|---|---|---|---|---| | |
| 207 | | `keys list` (`identity.go:117`) | SSH keys | `cYou()` on `c.Source`'s key | `cRef(fp)`, `cState(scope)`, `cFlex(label)`, `cMeta(algo, usedText, expiresText)` | `Keys: keys label <fingerprint> <text>`, `keys remove <fingerprint>` | `●` replaces the "this session" mark; add `"this session"` to its meta so the glyph is not the only signal | | |
| 208 | | `email list` (`register.go:58`) | Emails | `cYou()` when unverified | `cRef(addr)`, `cState(verified\|unverified)`, `cMeta("primary"?, VerifiedBy)` | `Email: email add <address>`, `email primary <address>` | | | |
| 209 | | `pgp list` (`sig.go:80`) | OpenPGP keys | `cGlyph("failed")` when revoked or expired | `cRef(fp)`, `cFlex(emails parsed from UIDsJSON)`, `cMeta("revoked"/"expires …")` | `Keys: pgp remove <fingerprint>` | parse UIDs for the screen only | | |
| 210 | | `token list` (`token.go:103`) | API tokens | `cGlyph("failed")` when expired | `cRef(name)`, `cState(scope)`, `cMeta("by "+createdBy, expiresText)` | `Tokens: token revoke <name>` | never a value | | |
| 211 | | `web sessions list` (`web.go:34`) | Browser sessions | none | `cRef(id)`, `cMeta("since "+relAge, "until "+relAge, usedText)` | `Sessions: web sessions revoke <id>` | never `web login` | | |
| 212 | | `whoami` (`identity.go:75`) | fields `User`, `Role` (admin only), `Instance`, `Key` (label or fp prefix), `Scope` | | | `Account: keys list`, `token list`, `email list` | move the `SSHKeyByFingerprint` lookup before emit | | |
| 213 | | `profile show`, `profile set`, `org profile` (shared `emitProfile`, `profile.go:254`) | fields `Profile` (`cText(name)`, `cMeta(kind, description)`), `Website`, `Activity`; body About | Links, Orgs, Members, Repos (`cLink`, `cState`, `cFlex`) | `Edit: profile set --description <text>` (own user profile only); `Edit: org profile <org> --description <text>` (org) | one builder; empty sections drop out after `set` | | |
| 214 | ||
| 215 | ## Task 5: Notifications and webhooks (MR 3b) | |
| 216 | ||
| 217 | **Files:** `notifications.go`, `webhook.go`, test `stage3notify_test.go`. | |
| 218 | ||
| 219 | | Command | Title / fields | Lead | Row | Actions | Notes | | |
| 220 | |---|---|---|---|---|---| | |
| 221 | | `notifications list` (`notifications.go:452`) | Notifications | `cYou()` when unread | `cRef(repo)`, `cFlex(actor+" "+summary)`, `cMeta(kind, relAge)` | `Inbox: notifications read --all`; `Read: issue show`/`mr show <repo> <n>` for the first issue/MR row (number from the path's last segment) | keep the early empty-page exit; `emitPageView` | | |
| 222 | | settings `show`/`mail`/`reply`/`watch`/`push` (shared `emitNotificationSettings`, `:254`) | fields Mail, Reply, Watch, Push, each `cState("on"/"off")` | | | one action per setting offering the opposite value: `Settings: notifications settings mail off`, …; `Devices: notifications device list` | one builder serves five commands | | |
| 223 | | `notifications device list` (`:387`) | Push devices | none | `cRef(id)`, `cFlex(label)`, `cMeta(ShortToken, relAge(added))` | `Devices: notifications device remove <id>` | `ShortToken` only | | |
| 224 | | `webhook list` (`webhook.go:104`) | Webhooks | `cGlyph("ok")` active / `cGlyph("closed")` inactive | `cRef(id)`, `cFlex(url clipped to scheme+host+"/…")`, `cMeta(events, "signed"?)` | `Hooks: webhook deliveries <p>` | elide the URL path on the screen; piped unchanged | | |
| 225 | | `webhook deliveries` (`webhook.go:159`) | Deliveries | `cGlyph` with `delivered`→`"ok"` | `cRef(id)`, `cFlex(event)`, `cMeta(n+" attempts", "HTTP "+status)`, `cMark(LastError, sgrRed)` | `Retry: webhook redeliver <p> <first failed id>` | | | |
| 226 | ||
| 227 | ## Task 6: Organisations, labels and milestones (MR 3b) | |
| 228 | ||
| 229 | **Files:** `org.go`, `orglabel.go`, `teams.go`, `label.go`, `milestone.go`, test `stage3org_test.go`. | |
| 230 | ||
| 231 | | Command | Title / fields | Lead | Row | Actions | Notes | | |
| 232 | |---|---|---|---|---|---| | |
| 233 | | `org list` (`org.go:91`) | Organizations | none | `cLink(org)`, `cState(role)` | `Read: org show <first org>` | | | |
| 234 | | `org show` + `org members list` | worked example | | | | | | |
| 235 | | `org label list` (`orglabel.go:142`) and `label list` (`label.go:41`) | Labels | none | `cSwatch(color)`, `cRef(name)`, `cMeta(n+" issues", m+" MRs", "org"?)` | org: `Labels: org label set <org> <label> --color rrggbb`; repo: `Labels: label set <p> <label> --color rrggbb`, `Read: issue list <p> --label <first>` | one `labelsScreen(c, labels, actions...)`; dropping `cNum` drops the header row | | |
| 236 | | `milestone list` + `org milestone list` (shared `emitMilestones`, `milestone.go:123`) | Milestones | `cGlyph(state)`; `cYou()` when open and overdue | `cRef(title)`, `cMeta(due or "overdue …", "c/t closed", "org"?)` | caller passes its actions: repo `Milestones: milestone create <p> <title>`, `milestone list <p> --state all`; org the `org milestone` equivalents | give `emitMilestones` an `actions []action` parameter | | |
| 237 | | `org team list` (`teams.go:141`) | Teams | none | `cRef(name)` | `Read: org team show <org> <first team>` | | | |
| 238 | | `org team show` (`teams.go:166`) | fields `Team` (`cLink(org/team)`), `Members` | Grants: `cLink(repo)`, `cState(role)` | `Team: org team add <org> <team> <user>`, `org team grant <org> <team> <owner/name> write` | | | |
| 239 | ||
| 240 | End of task: CHANGELOG line, push `cli-views-3b`, MR "control: account, notification and organisation screens (CLI views stage 3b)". | |
| 241 | ||
| 242 | ## Task 7: Work items and content (MR 3c) | |
| 243 | ||
| 244 | **Files:** `query.go`, `search.go`, `dashboard.go` (feed), `mr.go` (revisions), `milestone.go` (templates), `build.go` (jobs), `status.go`, `release.go`, `snippet.go`, `wiki.go`, `explore.go`, `audit.go`, test `stage3work_test.go`. | |
| 245 | ||
| 246 | | Command | Title / fields | Lead | Row | Actions | Notes | | |
| 247 | |---|---|---|---|---|---| | |
| 248 | | `query run`, `issue list --query`, `mr list --query` (shared `runItemQuery`, `query.go:196`) | query name, or "Results" from `listByQuery` | `cGlyph(state or "draft")` | `cLink(ref)`, `cFlex(title)`, `cMeta(author, milestone, relAge(updated))` | `Read: issue show`/`mr show <repo> <n>` for the first row by kind | one builder, title passed in | | |
| 249 | | `query list` (`query.go:326`) | Saved queries | none | `cRef(name)`, `cFlex(query)`, `cMeta("pinned"?)` | `Queries: query run <first>`, `query pin <first>` | | | |
| 250 | | `query show` (`query.go:351`) | fields Query, Matches, Pinned | | | `Queries: query run <name>`, `query pin`/`unpin <name>` | | | |
| 251 | | `search` (`search.go:92`) | sections Repositories, Issues, Merge requests | `cGlyph(state)` for issue/MR rows | repo `cLink`, `cFlex(title)`; item `cLink(ref)`, `cFlex(title)`, `cMeta(author)` | `Narrow: search <q> --kind issue` | `writeSearchTable` stays for piped | | |
| 252 | | `feed` (`dashboard.go:340`) | Activity | none | `l.termCells(c)` | none | `emitPageView`; "Next page" covers paging | | |
| 253 | | `mr revisions` (`mr.go:2080`) | Revisions | `cMark("●", "")` for the current one, plain dot, not `cYou` | `cRef("v"+n)`, `cRef(sha10)`, `cAge` | `Compare: mr range-diff <p> <n>` | keep the stderr note | | |
| 254 | | `issue templates` (`milestone.go:288`) | Issue templates | none | `cRef(name)` | `New: issue create <p> --title <title>` | never the body | | |
| 255 | | `build jobs` (`build.go:443`) | Jobs | none | `cRef(name)`, `cMeta(when)` | `Run: build trigger <p> <first job>` | | | |
| 256 | | `status list` (`status.go:107`) | fields Commit, Combined (`cGlyph`+`cState`) | Statuses: `cGlyph(state)`, `cText(context)`, `cFlex(desc)`, `cMeta(creator)` | `Report: status set <p> <sha> --context <context> --state success` | drop the pseudo-row on the screen | | |
| 257 | | `release list` (`release.go:253`) | Releases | none | `cLink(tag)`, `cFlex(subtitle)`, `cMeta(n+" assets", relAge)` | `Read: release show <p> <first tag>` | `emitPageView` | | |
| 258 | | `release show` (`release.go:317`) | fields Release (`cRef(tag)`, `cText(title)`), Author, Released; body Notes | Assets: `cRef(name)`, `cSize`, `cMeta(sha12)` | `Get: release asset get <p> <tag> <first asset>`, `Edit: release edit <p> <tag> --title <title>` | rebind `repo` (it is `_` today) | | |
| 259 | | `snippet list` (`snippet.go:244`) | Snippets | none | `cRef(id)`, `cState(vis)`, `cFlex(desc)`, `cMeta(files, relAge)` | `Read: snippet show <first id>` | `emitPageView` | | |
| 260 | | `snippet show` (`snippet.go:212`) | fields Snippet, Owner, Visibility, Updated | Files: `cRef(name)`, `cSize` | `Get: snippet file get <id> <first file>`, `Edit: snippet edit <id> --visibility public` | never file content | | |
| 261 | | `wiki list` (`wiki.go:83`) | Wiki | none | `cLink(page)`, `cMeta("home"?)` | `Read: wiki show <p> <home>` | | | |
| 262 | | `wiki show` (`wiki.go:126`) | fields Page, File, URL; body Content (format md/org); binary pages get a `Binary` field and no body | | `Read: wiki list <p>` | never Base64 | | |
| 263 | | `explore` (`explore.go:60`) | Explore | none | `cLink(path)`, `cFlex(desc)`, `cMeta(topics, "archived"?)` | `Read: repo show <first path>` | `emitPageView` | | |
| 264 | | `audit` (`audit.go:35`) | Audit | none | `cAge`, `cText(actor)`, `cText(action)`, `cFlex(keyValues(data))` | `Filter: audit --since 24h` | | | |
| 265 | ||
| 266 | ## Task 8: Administration (MR 3c) | |
| 267 | ||
| 268 | **Files:** `admin.go`, `adminhost.go`, `adminmail.go`, test `stage3admin_test.go`. | |
| 269 | ||
| 270 | | Command | Title / fields | Lead | Row | Actions | Notes | | |
| 271 | |---|---|---|---|---|---| | |
| 272 | | `admin user list` (`admin.go:134`) | Accounts | `cYou()` when pending | `cRef(name)`, `cState(state)`, `cMeta("admin"?, "seen "+relAge)` | `Filter: admin user list --state pending` | `emitPageView` | | |
| 273 | | `admin user show` (`admin.go:175`) | fields User (`cRef`, `cState`, `cMeta("admin"?)`), Seen, Repos (+limit), Sessions | Keys, Emails, PGP keys, Orgs, API tokens | `Manage: admin user disable <name>` or `enable`, `admin user promote`/`demote <name>` by current state | token names and scopes only | | |
| 274 | | `admin runners` (`admin.go:540`) | fields Queue (`cText(n+" pending")`, `cMeta(claimed, wait avg, wait max, reaped)`) | Runners: lead `cGlyph("running")` when holding; `cRef(user)`, `cMeta(scope, seen, held)` | `Prune: admin runners remove <fingerprint>` (first idle runner) | never `forget` | | |
| 275 | | `admin repo list` (`admin.go:407`) | Repositories | none | `cLink(path)`, `cState(vis[, archived])`, `cSize(bytes)`, `cMeta("pushed "+relAge)` | `Filter: admin repo list --visibility private` | keeps the header (size column) | | |
| 276 | | `admin mr prune` (`admin.go:616`) | Pruned | `cGlyph("ok")` / `cGlyph("skipped")` for "already gone" | `cLink("!n")`, `cRef(head)` or `cMeta("already gone")` | none | | | |
| 277 | | `admin stats` (`adminhost.go:291`) | fields Users, Orgs, Repos, Issues (+open), MRs (+open), Disk (`cSize(db)`, `cMeta(repos, lfs)`) | Repositories: top 20 by bytes, `cLink(path)`, `cSize`; `more: admin repo list` | `Admin: admin user list`, `admin runners` | the dashboard's Problems line points here | | |
| 278 | | `admin mail inbound check` (`adminmail.go:25`) | fields Inbound (server / mailbox), Messages (`cMeta(unseen)`), Auth (`cMark("✗ …", sgrRed)` on warning, else `cMark("✓ dkim", sgrGreen)`) | | none | off: one field `Inbound: off` | | |
| 279 | ||
| 280 | ## Task 9: `view` becomes piped-only; docs; MR 3c | |
| 281 | ||
| 282 | **Files:** `view.go`, `view_test.go`, `.gitbay/wiki/Users.org`, `CHANGELOG.org`. | |
| 283 | ||
| 284 | - [ ] **Step 1: Check nothing is left.** Run the stage-2 inventory again: | |
| 285 | ||
| 286 | ```bash | |
| 287 | cd internal/control && grep -lE 'c\.table\(|c\.view\(' *.go | grep -v _test | |
| 288 | ``` | |
| 289 | ||
| 290 | and, for each file, confirm every `c.view(`/`c.table(` sits inside a `plain` closure passed to `emitView`/`emitPageView`, or behind `c.Term.Cols == 0`. List any exception in the MR body. | |
| 291 | ||
| 292 | - [ ] **Step 2: Failing test.** | |
| 293 | ||
| 294 | ```go | |
| 295 | // view is the piped writer: at a terminal it writes what it writes | |
| 296 | // piped, so a caller that never migrated falls back to the plain layout. | |
| 297 | func TestViewIgnoresTerminal(t *testing.T) { | |
| 298 | render := func(term Term) string { | |
| 299 | var b bytes.Buffer | |
| 300 | c := &Ctx{Term: term} | |
| 301 | v := c.view(&b) | |
| 302 | v.title("#1", "A title", "open") | |
| 303 | v.fields("author", "alice") | |
| 304 | v.section("files") | |
| 305 | return b.String() | |
| 306 | } | |
| 307 | if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want { | |
| 308 | t.Errorf("terminal view differs from piped:\n%q\n%q", got, want) | |
| 309 | } | |
| 310 | } | |
| 311 | ``` | |
| 312 | ||
| 313 | - [ ] **Step 3: Strip `view`'s terminal branches.** In `view.go`: `title`, `section`, `fields`, `body`, `event`, `comment` keep only their `Cols == 0` paths; `opts` returns `termtext.Options{Base: …}` without width or colour; `when` keeps its terminal format only if a screen still calls it (`grep -n 'c.when(' *.go`), otherwise its piped form only. `safe()` calls stay: they are no-ops piped. | |
| 314 | ||
| 315 | - [ ] **Step 4: Run** the package; fix any test that asserted a terminal `view` (each is now a screen test or a piped test). | |
| 316 | ||
| 317 | - [ ] **Step 5: Docs.** Users wiki: replace the "other `show` views" bullet with "every list and show is a screen; `--json` and piped output are unchanged", and drop the per-command lists added in stage 2. CHANGELOG: one line for 3c. | |
| 318 | ||
| 319 | - [ ] **Step 6: Verify, commit `Closes #319`, push `cli-views-3c`, MR** "control: work item and admin screens; view is piped-only (CLI views stage 3c)". | |
| 320 | ||
| 321 | ## Self-review notes | |
| 322 | ||
| 323 | - Coverage: the 70 commands from the inventory (25 nouns) appear in tasks 1–8, the eleven `view` callers outside the original list included (`admin mail inbound check`, `repo deps status`, `org show`, `admin stats`, `repo commit`, `org team show`, `emitProfile`, notification settings, `repo settings show`, `admin user show`, `admin runners`); task 9 removes `view`'s terminal branches. | |
| 324 | - Shared emitters each get one builder: `editTopics`, `emitProfile`, `emitNotificationSettings`, `emitMilestones`, `runOrgShow`, `runItemQuery`, label lists. | |
| 325 | - Rows give exact cells and actions; code is written out in full for the helper and the three patterns (list with shared emitter, show, stream with header). The per-command builders follow those patterns from their table rows; that is a deliberate trade against 70 near-identical code blocks. | |
| 326 | - Argument forms marked "check the real arg form" (`repo settings require-mr`) are verified by `checkActions` in the screen test; adjust the argv to whatever passes. | |
e2e/term_test.go +3 −3
| @@ -25,7 +25,7 @@ func TestTermEnvSelectsTerminalOutput(t *testing.T) { | ||
| 25 | 25 | t.Errorf("plain repo list: %q", plain) |
| 26 | 26 | } |
| 27 | 27 | term, _, _ := inst.sshTerm(t, key, "80,color", "repo", "list") |
| 28 | if !strings.HasPrefix(term, "\x1b[2malice/app\x1b[0m ") { | |
| 28 | if !strings.HasPrefix(term, "\x1b[1m\x1b[34mRepositories (1)") { | |
| 29 | 29 | t.Errorf("terminal repo list: %q", term) |
| 30 | 30 | } |
| 31 | 31 | } |
| @@ -75,10 +75,10 @@ func TestTermEnvOverMultiplexedSession(t *testing.T) { | ||
| 75 | 75 | exec.Command("ssh", "-o", "ControlPath="+sock, "-O", "exit", "git@127.0.0.1").Run() |
| 76 | 76 | }) |
| 77 | 77 | |
| 78 | if out := mux("80"); !strings.HasPrefix(out, "alice/app ") { | |
| 78 | if out := mux("80"); !strings.HasPrefix(out, "Repositories (1)\nalice/app public") { | |
| 79 | 79 | t.Fatalf("master session: %q", out) |
| 80 | 80 | } |
| 81 | if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[2malice/app") { | |
| 81 | if out := mux("80,color"); !strings.HasPrefix(out, "\x1b[1m\x1b[34mRepositories") { | |
| 82 | 82 | t.Errorf("second session kept the master's GITBAY_TERM: %q", out) |
| 83 | 83 | } |
| 84 | 84 | if out := mux(""); !strings.HasPrefix(out, "alice/app\t") { |
internal/control/build.go +10 −1
| @@ -564,12 +564,21 @@ func runSecretList(c *Ctx, args []string) int { | ||
| 564 | 564 | if err != nil { |
| 565 | 565 | return c.fail(protocol.ExitFailure, "%v", err) |
| 566 | 566 | } |
| 567 | return c.emit(names, func(w io.Writer) { | |
| 567 | return c.emitView(names, func(w io.Writer) { | |
| 568 | 568 | tb := c.table(w, "NAME") |
| 569 | 569 | for _, n := range names { |
| 570 | 570 | tb.row(cRef(n)) |
| 571 | 571 | } |
| 572 | 572 | tb.flush() |
| 573 | }, func() screen { | |
| 574 | rows := make([]row, len(names)) | |
| 575 | for i, n := range names { | |
| 576 | rows[i] = rowOf(cRef(n)) | |
| 577 | } | |
| 578 | return listScreen("Build secrets", rows, | |
| 579 | action{"Secrets", []string{"repo", "secret", "set", repo.Path(), "<NAME>"}}, | |
| 580 | action{"Secrets", []string{"repo", "secret", "remove", repo.Path(), "<NAME>"}}, | |
| 581 | ) | |
| 573 | 582 | }) |
| 574 | 583 | } |
| 575 | 584 | |
internal/control/deploykey.go +11 −1
| @@ -119,13 +119,23 @@ func runDeployKeyList(c *Ctx, args []string) int { | ||
| 119 | 119 | ds = append(ds, out{k.Fingerprint, k.Algo, mode, k.Label, k.LastUsedAt, k.ExpiresAt}) |
| 120 | 120 | } |
| 121 | 121 | now := time.Now() |
| 122 | return c.emit(ds, func(w io.Writer) { | |
| 122 | return c.emitView(ds, func(w io.Writer) { | |
| 123 | 123 | tb := c.table(w, "FINGERPRINT", "ALGO", "MODE", "LABEL", "USED", "EXPIRES") |
| 124 | 124 | for _, d := range ds { |
| 125 | 125 | tb.row(cFlex(d.Fingerprint), cText(d.Algo), cState(d.Mode), cText(d.Label), |
| 126 | 126 | cText(c.usedText(d.LastUsedAt)), cText(c.expiresText(d.ExpiresAt, now))) |
| 127 | 127 | } |
| 128 | 128 | tb.flush() |
| 129 | }, func() screen { | |
| 130 | rows := make([]row, len(ds)) | |
| 131 | for i, d := range ds { | |
| 132 | rows[i] = rowOf(cFlexRef(d.Fingerprint), cState(d.Mode), cText(d.Label), | |
| 133 | cMeta(d.Algo, "used "+c.usedText(d.LastUsedAt), c.expiresText(d.ExpiresAt, now))) | |
| 134 | } | |
| 135 | return listScreen("Deploy keys", rows, | |
| 136 | action{"Keys", []string{"repo", "deploy-key", "add", repo.Path()}}, | |
| 137 | action{"Keys", []string{"repo", "deploy-key", "remove", repo.Path(), "<fingerprint>"}}, | |
| 138 | ) | |
| 129 | 139 | }) |
| 130 | 140 | } |
| 131 | 141 | |
internal/control/deps.go +29 −2
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "errors" |
| 5 | 5 | "fmt" |
| 6 | 6 | "io" |
| 7 | "strconv" | |
| 7 | 8 | |
| 8 | 9 | "gitbay.org/gitbay/internal/policy" |
| 9 | 10 | "gitbay.org/gitbay/internal/protocol" |
| @@ -92,9 +93,14 @@ func runDepsStatus(c *Ctx, args []string) int { | ||
| 92 | 93 | } |
| 93 | 94 | check, err := c.Store.DepCheckFor(repo.ID) |
| 94 | 95 | if errors.Is(err, store.ErrNotFound) { |
| 95 | return c.emit(map[string]any{"enabled": false}, func(w io.Writer) { | |
| 96 | return c.emitView(map[string]any{"enabled": false}, func(w io.Writer) { | |
| 96 | 97 | v := c.view(w) |
| 97 | 98 | v.fields("checks", fmt.Sprintf("off (repo deps enable %s)", repo.Path())) |
| 99 | }, func() screen { | |
| 100 | return screen{ | |
| 101 | fields: []field{{"Checks", []cell{cText("off")}}}, | |
| 102 | actions: []action{{"Deps", []string{"repo", "deps", "enable", repo.Path()}}}, | |
| 103 | } | |
| 98 | 104 | }) |
| 99 | 105 | } |
| 100 | 106 | if err != nil { |
| @@ -109,7 +115,7 @@ func runDepsStatus(c *Ctx, args []string) int { | ||
| 109 | 115 | for _, r := range reports { |
| 110 | 116 | out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest}) |
| 111 | 117 | } |
| 112 | return c.emit(out, func(w io.Writer) { | |
| 118 | return c.emitView(out, func(w io.Writer) { | |
| 113 | 119 | tracked := "" |
| 114 | 120 | if check.IssueNumber != 0 { |
| 115 | 121 | tracked = fmt.Sprintf("#%d", check.IssueNumber) |
| @@ -129,5 +135,26 @@ func runDepsStatus(c *Ctx, args []string) int { | ||
| 129 | 135 | } |
| 130 | 136 | tb.flush() |
| 131 | 137 | } |
| 138 | }, func() screen { | |
| 139 | state := []cell{cGlyph("ok"), cText("on")} | |
| 140 | if check.LastError != "" { | |
| 141 | state = []cell{cGlyph("failed"), cText("on")} | |
| 142 | } | |
| 143 | s := screen{fields: []field{ | |
| 144 | {"Checks", state}, | |
| 145 | {"Last check", []cell{cAge(check.LastCheck)}}, | |
| 146 | {"Error", []cell{cMark(check.LastError, sgrRed)}}, | |
| 147 | }} | |
| 148 | if check.IssueNumber != 0 { | |
| 149 | n := strconv.FormatInt(check.IssueNumber, 10) | |
| 150 | s.fields = append(s.fields, field{"Tracked in", []cell{cLink("#"+n, c.siteURL(repo.Path(), "issues", n))}}) | |
| 151 | } | |
| 152 | behind := section{title: "Behind", n: len(out.Behind)} | |
| 153 | for _, b := range out.Behind { | |
| 154 | behind.rows = append(behind.rows, rowOf(cRef(b.Name), cText(b.Ecosystem), cMeta(b.Current+" → "+b.Latest))) | |
| 155 | } | |
| 156 | s.sections = []section{behind} | |
| 157 | s.actions = []action{{"Deps", []string{"repo", "deps", "disable", repo.Path()}}} | |
| 158 | return s | |
| 132 | 159 | }) |
| 133 | 160 | } |
internal/control/mirrorcmd.go +21 −1
| @@ -122,7 +122,7 @@ func runMirrorList(c *Ctx, args []string) int { | ||
| 122 | 122 | // The token never leaves the server, in any encoding. |
| 123 | 123 | ds = append(ds, out{m.ID, m.Direction, m.URL, m.Username, m.Dirty, m.LastSync, m.LastError}) |
| 124 | 124 | } |
| 125 | return c.emit(ds, func(w io.Writer) { | |
| 125 | return c.emitView(ds, func(w io.Writer) { | |
| 126 | 126 | tb := c.table(w, "ID", "DIRECTION", "URL", "LAST", "STATUS") |
| 127 | 127 | for _, d := range ds { |
| 128 | 128 | status := "ok" |
| @@ -135,6 +135,26 @@ func runMirrorList(c *Ctx, args []string) int { | ||
| 135 | 135 | tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.Direction), cText(d.URL), cText("last "+orDash(d.LastSync)), cState(status)) |
| 136 | 136 | } |
| 137 | 137 | tb.flush() |
| 138 | }, func() screen { | |
| 139 | rows := make([]row, len(ds)) | |
| 140 | for i, d := range ds { | |
| 141 | state := "ok" | |
| 142 | if d.Pending { | |
| 143 | state = "pending" | |
| 144 | } | |
| 145 | if d.LastError != "" { | |
| 146 | state = "error" | |
| 147 | } | |
| 148 | synced := "never synced" | |
| 149 | if d.LastSync != "" { | |
| 150 | synced = "synced " + relAge(d.LastSync, termNow()) | |
| 151 | } | |
| 152 | rows[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), cGlyph(state), cFlex(d.URL), cMeta(d.Direction, synced), cMark(d.LastError, sgrRed)) | |
| 153 | } | |
| 154 | return listScreen("Mirrors", rows, | |
| 155 | action{"Mirrors", []string{"repo", "mirror", "sync", repo.Path()}}, | |
| 156 | action{"Mirrors", []string{"repo", "mirror", "remove", repo.Path(), "<id>"}}, | |
| 157 | ) | |
| 138 | 158 | }) |
| 139 | 159 | } |
| 140 | 160 | |
internal/control/pagescmd.go +14 −1
| @@ -220,11 +220,24 @@ func runDomainList(c *Ctx, args []string) int { | ||
| 220 | 220 | } |
| 221 | 221 | list = append(list, out{d.Domain, state, d.VerifiedAt}) |
| 222 | 222 | } |
| 223 | return c.emit(list, func(w io.Writer) { | |
| 223 | return c.emitView(list, func(w io.Writer) { | |
| 224 | 224 | tb := c.table(w, "DOMAIN", "STATE") |
| 225 | 225 | for _, d := range list { |
| 226 | 226 | tb.row(cRef(d.Domain), cState(d.State)) |
| 227 | 227 | } |
| 228 | 228 | tb.flush() |
| 229 | }, func() screen { | |
| 230 | rows := make([]row, len(list)) | |
| 231 | for i, d := range list { | |
| 232 | state := d.State | |
| 233 | if state == "expired" { | |
| 234 | state = "failed" | |
| 235 | } | |
| 236 | rows[i] = rowOf(cGlyph(state), cRef(d.Domain), cState(d.State)) | |
| 237 | } | |
| 238 | return listScreen("Pages domains", rows, | |
| 239 | action{"Domains", []string{"repo", "domain", "add", repo.Path(), "<domain>"}}, | |
| 240 | action{"Domains", []string{"repo", "domain", "verify", repo.Path(), "<domain>"}}, | |
| 241 | ) | |
| 229 | 242 | }) |
| 230 | 243 | } |
internal/control/read.go +51 −26
| @@ -108,24 +108,34 @@ func runRepoRefs(c *Ctx, args []string) int { | ||
| 108 | 108 | // Tags read newest version first; the JSON keeps git's order. |
| 109 | 109 | sorted := slices.Clone(tags) |
| 110 | 110 | gitutil.SortVersions(sorted) |
| 111 | return c.emit(d, func(w io.Writer) { | |
| 111 | return c.emitView(d, func(w io.Writer) { | |
| 112 | 112 | tb := c.table(w, "KIND", "NAME", "SHA") |
| 113 | if c.Term.Cols > 0 { | |
| 114 | for _, ref := range d.Branches { | |
| 115 | if ref.Name == repo.DefaultBranch { | |
| 116 | tb.row(cText("default"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
| 120 | 113 | for _, ref := range d.Branches { |
| 121 | if c.Term.Cols == 0 || ref.Name != repo.DefaultBranch { | |
| 122 | tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 123 | } | |
| 114 | tb.row(cText("branch"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) | |
| 124 | 115 | } |
| 125 | 116 | for _, ref := range sorted { |
| 126 | 117 | tb.row(cText("tag"), cRef(ref.Name), cRef(fmt.Sprintf("%.10s", ref.SHA))) |
| 127 | 118 | } |
| 128 | 119 | tb.flush() |
| 120 | }, func() screen { | |
| 121 | branches := section{title: "Branches", n: len(d.Branches)} | |
| 122 | for _, r := range d.Branches { | |
| 123 | if r.Name == repo.DefaultBranch { | |
| 124 | branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA), "default"))) | |
| 125 | } | |
| 126 | } | |
| 127 | for _, r := range d.Branches { | |
| 128 | if r.Name != repo.DefaultBranch { | |
| 129 | branches.rows = append(branches.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA)))) | |
| 130 | } | |
| 131 | } | |
| 132 | tags := section{title: "Tags", n: len(sorted)} | |
| 133 | for _, r := range sorted { | |
| 134 | tags.rows = append(tags.rows, rowOf(cRef(r.Name), cMeta(fmt.Sprintf("%.10s", r.SHA)))) | |
| 135 | } | |
| 136 | return screen{sections: []section{branches, tags}, actions: []action{ | |
| 137 | {"Read", []string{"repo", "log", repo.Path(), "--ref", repo.DefaultBranch}}, | |
| 138 | }} | |
| 129 | 139 | }) |
| 130 | 140 | } |
| 131 | 141 | |
| @@ -323,21 +333,7 @@ func runRepoTree(c *Ctx, args []string) int { | ||
| 323 | 333 | } |
| 324 | 334 | d.Entries = append(d.Entries, eo) |
| 325 | 335 | } |
| 326 | return c.emit(d, func(w io.Writer) { | |
| 327 | if c.Term.Cols > 0 { | |
| 328 | tb := c.table(w, "NAME", "SIZE", "SHA") | |
| 329 | for _, e := range d.Entries { | |
| 330 | name, size := cText(e.Name), cText("") | |
| 331 | if e.Type == "tree" { | |
| 332 | name = cMark(e.Name+"/", sgrCyan) | |
| 333 | } else { | |
| 334 | size = cSize(e.Size) | |
| 335 | } | |
| 336 | tb.row(name, size, cMark(e.SHA[:min(10, len(e.SHA))], sgrDim)) | |
| 337 | } | |
| 338 | tb.flush() | |
| 339 | return | |
| 340 | } | |
| 336 | return c.emitView(d, func(w io.Writer) { | |
| 341 | 337 | tb := c.table(w, "SHA", "SIZE", "NAME") |
| 342 | 338 | for _, e := range d.Entries { |
| 343 | 339 | name := e.Name |
| @@ -351,6 +347,35 @@ func runRepoTree(c *Ctx, args []string) int { | ||
| 351 | 347 | tb.row(cRef(e.SHA[:min(10, len(e.SHA))]), size, cFlex(name)) |
| 352 | 348 | } |
| 353 | 349 | tb.flush() |
| 350 | }, func() screen { | |
| 351 | title := repo.Path() | |
| 352 | if dirPath != "" { | |
| 353 | title += "/" + dirPath | |
| 354 | } | |
| 355 | rows := make([]row, len(d.Entries)) | |
| 356 | firstFile := "" | |
| 357 | for i, e := range d.Entries { | |
| 358 | name, size := cText(e.Name), cText("") | |
| 359 | if e.Type == "tree" { | |
| 360 | name = cMark(e.Name+"/", sgrBlue) | |
| 361 | } else { | |
| 362 | size = cSize(e.Size) | |
| 363 | if firstFile == "" { | |
| 364 | firstFile = path.Join(dirPath, e.Name) | |
| 365 | } | |
| 366 | } | |
| 367 | rows[i] = rowOf(name, size, cMeta(e.SHA[:min(10, len(e.SHA))])) | |
| 368 | } | |
| 369 | s := listScreen(title+" at "+ref, rows) | |
| 370 | if firstFile != "" { | |
| 371 | s.actions = append(s.actions, action{"Read", []string{"repo", "cat", repo.Path(), firstFile, "--ref", ref}}) | |
| 372 | } | |
| 373 | logArgs := []string{"repo", "log", repo.Path(), "--ref", ref} | |
| 374 | if dirPath != "" { | |
| 375 | logArgs = append(logArgs, "--path", dirPath) | |
| 376 | } | |
| 377 | s.actions = append(s.actions, action{"Read", logArgs}) | |
| 378 | return s | |
| 354 | 379 | }) |
| 355 | 380 | } |
| 356 | 381 | |
internal/control/repo.go +114 −7
| @@ -360,7 +360,7 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 360 | 360 | desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) |
| 361 | 361 | ds = append(ds, out{r.Path(), r.Visibility, desc, r.Settings.Archived}) |
| 362 | 362 | } |
| 363 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 363 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 364 | 364 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") |
| 365 | 365 | for _, d := range ds { |
| 366 | 366 | cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)} |
| @@ -370,6 +370,20 @@ func runRepoList(c *Ctx, args []string) int { | ||
| 370 | 370 | tb.row(cells...) |
| 371 | 371 | } |
| 372 | 372 | tb.flush() |
| 373 | }, func() screen { | |
| 374 | rows := make([]row, len(ds)) | |
| 375 | for i, d := range ds { | |
| 376 | state := d.Visibility | |
| 377 | if d.Archived { | |
| 378 | state += ", archived" | |
| 379 | } | |
| 380 | rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cFlex(d.Description)) | |
| 381 | } | |
| 382 | s := listScreen("Repositories", rows) | |
| 383 | if len(ds) > 0 { | |
| 384 | s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}} | |
| 385 | } | |
| 386 | return s | |
| 373 | 387 | }) |
| 374 | 388 | } |
| 375 | 389 | |
| @@ -771,12 +785,21 @@ func runAccessList(c *Ctx, args []string) int { | ||
| 771 | 785 | for _, e := range entries { |
| 772 | 786 | ds = append(ds, out{e.Username, e.Role, e.Source}) |
| 773 | 787 | } |
| 774 | return c.emit(ds, func(w io.Writer) { | |
| 788 | return c.emitView(ds, func(w io.Writer) { | |
| 775 | 789 | tb := c.table(w, "USER", "ROLE", "SOURCE") |
| 776 | 790 | for _, d := range ds { |
| 777 | 791 | tb.row(cRef(d.User), cState(d.Role), cText("via "+d.Source)) |
| 778 | 792 | } |
| 779 | 793 | tb.flush() |
| 794 | }, func() screen { | |
| 795 | rows := make([]row, len(ds)) | |
| 796 | for i, d := range ds { | |
| 797 | rows[i] = rowOf(cRef(d.User), cState(d.Role), cMeta("via "+d.Source)) | |
| 798 | } | |
| 799 | return listScreen("Access", rows, | |
| 800 | action{"Access", []string{"repo", "access", "grant", repo.Path(), "<user>", "write"}}, | |
| 801 | action{"Access", []string{"repo", "access", "revoke", repo.Path(), "<user>"}}, | |
| 802 | ) | |
| 780 | 803 | }) |
| 781 | 804 | } |
| 782 | 805 | |
| @@ -788,7 +811,7 @@ func runSettingsShow(c *Ctx, args []string) int { | ||
| 788 | 811 | if code >= 0 { |
| 789 | 812 | return code |
| 790 | 813 | } |
| 791 | return c.emit(repo.Settings, func(w io.Writer) { | |
| 814 | return c.emitView(repo.Settings, func(w io.Writer) { | |
| 792 | 815 | v := c.view(w) |
| 793 | 816 | v.title(repo.Path(), "settings", "") |
| 794 | 817 | v.fields( |
| @@ -801,6 +824,49 @@ func runSettingsShow(c *Ctx, args []string) int { | ||
| 801 | 824 | "git daemon", strconv.FormatBool(repo.Settings.GitDaemon), |
| 802 | 825 | "archived", strconv.FormatBool(repo.Settings.Archived), |
| 803 | 826 | ) |
| 827 | }, func() screen { | |
| 828 | set := repo.Settings | |
| 829 | onOff := func(b bool) cell { | |
| 830 | if b { | |
| 831 | return cText("on") | |
| 832 | } | |
| 833 | return cMeta("off") | |
| 834 | } | |
| 835 | approvals := cMeta("off") | |
| 836 | if set.RequireApprovals > 0 { | |
| 837 | approvals = cText(strconv.Itoa(set.RequireApprovals)) | |
| 838 | } | |
| 839 | list := func(xs []string) cell { | |
| 840 | if len(xs) == 0 { | |
| 841 | return cMeta("none") | |
| 842 | } | |
| 843 | return cText(strings.Join(xs, ", ")) | |
| 844 | } | |
| 845 | mr := "on" | |
| 846 | if set.RequireMR { | |
| 847 | mr = "off" | |
| 848 | } | |
| 849 | s := screen{fields: []field{ | |
| 850 | {"Repo", []cell{cLink(repo.Path(), c.siteURL(repo.Path())), cMeta("settings")}}, | |
| 851 | {"Protected", []cell{list(set.ProtectedBranches)}}, | |
| 852 | {"Protected tags", []cell{list(set.ProtectedTags)}}, | |
| 853 | {"Require MR", []cell{onOff(set.RequireMR)}}, | |
| 854 | {"Require checks", []cell{onOff(set.RequireChecks)}}, | |
| 855 | {"Contexts", []cell{list(set.RequiredContexts)}}, | |
| 856 | {"Approvals", []cell{approvals}}, | |
| 857 | {"Resolved threads", []cell{onOff(set.RequireResolved)}}, | |
| 858 | {"Code owners", []cell{onOff(set.RequireCodeowners)}}, | |
| 859 | {"Signed commits", []cell{onOff(set.RequireSignedCommits)}}, | |
| 860 | {"Git daemon", []cell{onOff(set.GitDaemon)}}, | |
| 861 | {"Archived", []cell{onOff(set.Archived)}}, | |
| 862 | }, actions: []action{ | |
| 863 | {"Settings", []string{"repo", "settings", "protect", repo.Path(), "<branch>"}}, | |
| 864 | {"Settings", []string{"repo", "settings", "require-mr", repo.Path(), mr}}, | |
| 865 | }} | |
| 866 | if set.Website != "" { | |
| 867 | s.fields = append(s.fields, field{"Website", []cell{cText(set.Website)}}) | |
| 868 | } | |
| 869 | return s | |
| 804 | 870 | }) |
| 805 | 871 | } |
| 806 | 872 | |
| @@ -960,6 +1026,19 @@ func archiveRepo(c *Ctx, repo store.Repo, archived bool) int { | ||
| 960 | 1026 | return c.emit(s, func(w io.Writer) { fmt.Fprintf(w, "%sd %s\n", verb, repo.Path()) }) |
| 961 | 1027 | } |
| 962 | 1028 | |
| 1029 | // topicsScreen is a repository's topics at a terminal, after a read or | |
| 1030 | // an edit. | |
| 1031 | func topicsScreen(repo store.Repo, topics []string) screen { | |
| 1032 | rows := make([]row, len(topics)) | |
| 1033 | for i, t := range topics { | |
| 1034 | rows[i] = rowOf(cRef(t)) | |
| 1035 | } | |
| 1036 | return listScreen("Topics", rows, | |
| 1037 | action{"Edit", []string{"repo", "topics", "add", repo.Path(), "<topic>"}}, | |
| 1038 | action{"Edit", []string{"repo", "topics", "remove", repo.Path(), "<topic>"}}, | |
| 1039 | ) | |
| 1040 | } | |
| 1041 | ||
| 963 | 1042 | func runTopicsList(c *Ctx, args []string) int { |
| 964 | 1043 | if len(args) != 1 { |
| 965 | 1044 | return c.usage() |
| @@ -972,12 +1051,14 @@ func runTopicsList(c *Ctx, args []string) int { | ||
| 972 | 1051 | if err != nil { |
| 973 | 1052 | return c.fail(protocol.ExitFailure, "%v", err) |
| 974 | 1053 | } |
| 975 | return c.emit(topics, func(w io.Writer) { | |
| 1054 | return c.emitView(topics, func(w io.Writer) { | |
| 976 | 1055 | tb := c.table(w, "TOPIC") |
| 977 | 1056 | for _, t := range topics { |
| 978 | 1057 | tb.row(cRef(t)) |
| 979 | 1058 | } |
| 980 | 1059 | tb.flush() |
| 1060 | }, func() screen { | |
| 1061 | return topicsScreen(repo, topics) | |
| 981 | 1062 | }) |
| 982 | 1063 | } |
| 983 | 1064 | |
| @@ -1031,12 +1112,14 @@ func editTopics(c *Ctx, args []string, add bool) int { | ||
| 1031 | 1112 | if err != nil { |
| 1032 | 1113 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1033 | 1114 | } |
| 1034 | return c.emit(now, func(w io.Writer) { | |
| 1115 | return c.emitView(now, func(w io.Writer) { | |
| 1035 | 1116 | tb := c.table(w, "TOPIC") |
| 1036 | 1117 | for _, t := range now { |
| 1037 | 1118 | tb.row(cRef(t)) |
| 1038 | 1119 | } |
| 1039 | 1120 | tb.flush() |
| 1121 | }, func() screen { | |
| 1122 | return topicsScreen(repo, now) | |
| 1040 | 1123 | }) |
| 1041 | 1124 | } |
| 1042 | 1125 | |
| @@ -1079,12 +1162,22 @@ func runRepoSearch(c *Ctx, args []string) int { | ||
| 1079 | 1162 | } |
| 1080 | 1163 | ds = append(ds, out{r.Path(), r.Visibility, desc, topics}) |
| 1081 | 1164 | } |
| 1082 | return c.emit(ds, func(w io.Writer) { | |
| 1165 | return c.emitView(ds, func(w io.Writer) { | |
| 1083 | 1166 | tb := c.table(w, "PATH", "VISIBILITY", "DESCRIPTION") |
| 1084 | 1167 | for _, d := range ds { |
| 1085 | 1168 | tb.row(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description)) |
| 1086 | 1169 | } |
| 1087 | 1170 | tb.flush() |
| 1171 | }, func() screen { | |
| 1172 | rows := make([]row, len(ds)) | |
| 1173 | for i, d := range ds { | |
| 1174 | rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", "))) | |
| 1175 | } | |
| 1176 | s := listScreen(fmt.Sprintf("Repositories matching %q", args[0]), rows) | |
| 1177 | if len(ds) > 0 { | |
| 1178 | s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}} | |
| 1179 | } | |
| 1180 | return s | |
| 1088 | 1181 | }) |
| 1089 | 1182 | } |
| 1090 | 1183 | |
| @@ -1249,12 +1342,26 @@ func runRepoBookmarks(c *Ctx, args []string) int { | ||
| 1249 | 1342 | Bookmarks: c.Store.BookmarkCount(r.ID), |
| 1250 | 1343 | }) |
| 1251 | 1344 | } |
| 1252 | return c.emit(out, func(w io.Writer) { | |
| 1345 | return c.emitView(out, func(w io.Writer) { | |
| 1253 | 1346 | tb := c.table(w, "PATH", "COUNT", "DESCRIPTION") |
| 1254 | 1347 | for _, b := range out { |
| 1255 | 1348 | tb.row(cRef(b.Path), cNum(int64(b.Bookmarks)), cFlex(b.Description)) |
| 1256 | 1349 | } |
| 1257 | 1350 | tb.flush() |
| 1351 | }, func() screen { | |
| 1352 | rows := make([]row, len(out)) | |
| 1353 | for i, b := range out { | |
| 1354 | n := fmt.Sprintf("%d bookmarks", b.Bookmarks) | |
| 1355 | if b.Bookmarks == 1 { | |
| 1356 | n = "1 bookmark" | |
| 1357 | } | |
| 1358 | rows[i] = rowOf(cLink(b.Path, c.siteURL(b.Path)), cState(b.Visibility), cFlex(b.Description), cMeta(n)) | |
| 1359 | } | |
| 1360 | s := listScreen("Bookmarks", rows) | |
| 1361 | if len(out) > 0 { | |
| 1362 | s.actions = []action{{"Read", []string{"repo", "show", out[0].Path}}} | |
| 1363 | } | |
| 1364 | return s | |
| 1258 | 1365 | }) |
| 1259 | 1366 | } |
| 1260 | 1367 | |
internal/control/runnerrepo.go +17 −1
| @@ -114,7 +114,7 @@ func runRepoRunnerList(c *Ctx, args []string) int { | ||
| 114 | 114 | if runners == nil { |
| 115 | 115 | runners = []store.RepoRunner{} |
| 116 | 116 | } |
| 117 | return c.emit(runners, func(w io.Writer) { | |
| 117 | return c.emitView(runners, func(w io.Writer) { | |
| 118 | 118 | tb := c.table(w, "FINGERPRINT", "ALGO", "USER", "SEEN", "HELD") |
| 119 | 119 | for _, r := range runners { |
| 120 | 120 | seen := r.LastSeen |
| @@ -128,6 +128,22 @@ func runRepoRunnerList(c *Ctx, args []string) int { | ||
| 128 | 128 | tb.row(cRef(r.Fingerprint), cText(r.Algo), cText(r.Username), cAge(seen), cText(held)) |
| 129 | 129 | } |
| 130 | 130 | tb.flush() |
| 131 | }, func() screen { | |
| 132 | rows := make([]row, len(runners)) | |
| 133 | for i, r := range runners { | |
| 134 | lead, held := cGlyph(""), "" | |
| 135 | if r.BuildNumber != 0 { | |
| 136 | lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob) | |
| 137 | } | |
| 138 | seen := "never seen" | |
| 139 | if r.LastSeen != "" { | |
| 140 | seen = "seen " + relAge(r.LastSeen, termNow()) | |
| 141 | } | |
| 142 | rows[i] = rowOf(cFlexRef(r.Fingerprint), lead, cText(r.Username), cMeta(r.Algo, seen, held)) | |
| 143 | } | |
| 144 | return listScreen("Runners", rows, | |
| 145 | action{"Runners", []string{"repo", "runner", "remove", repo.Path(), "<fingerprint>"}}, | |
| 146 | ) | |
| 131 | 147 | }) |
| 132 | 148 | } |
| 133 | 149 | |
internal/control/screen.go +6
| @@ -57,6 +57,12 @@ type action struct { | ||
| 57 | 57 | argv []string |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | // listScreen is a list command's terminal screen: one section of rows | |
| 61 | // and the commands that apply. | |
| 62 | func listScreen(title string, rows []row, actions ...action) screen { | |
| 63 | return screen{sections: []section{{title: title, n: len(rows), rows: rows}}, actions: actions} | |
| 64 | } | |
| 65 | ||
| 60 | 66 | // render draws s: the parts in order, one blank line between parts and |
| 61 | 67 | // between sections. |
| 62 | 68 | func (c *Ctx) render(w io.Writer, s screen) { |
internal/control/sig.go +36 −29
| @@ -225,22 +225,28 @@ func runRepoLog(c *Ctx, args []string) int { | ||
| 225 | 225 | } |
| 226 | 226 | ds = append(ds, d) |
| 227 | 227 | } |
| 228 | return c.emit(ds, func(w io.Writer) { | |
| 229 | if c.Term.Cols > 0 { | |
| 230 | tb := c.table(w, "SHA", "SUBJECT", "AUTHOR", "WHEN", "SIGNATURE") | |
| 231 | for _, d := range ds { | |
| 232 | tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cFlex(d.Subject), cText(d.AuthorName), | |
| 233 | cAge(d.Date), cState(d.Signature.State)) | |
| 234 | } | |
| 235 | tb.flush() | |
| 236 | return | |
| 237 | } | |
| 228 | return c.emitView(ds, func(w io.Writer) { | |
| 238 | 229 | tb := c.table(w, "SHA", "STATE", "SUBJECT", "AUTHOR") |
| 239 | 230 | for _, d := range ds { |
| 240 | 231 | tb.row(cRef(fmt.Sprintf("%.10s", d.SHA)), cState(d.Signature.State), cFlex(d.Subject), |
| 241 | 232 | cText(fmt.Sprintf("(%s <%s>)", d.AuthorName, d.AuthorEmail))) |
| 242 | 233 | } |
| 243 | 234 | tb.flush() |
| 235 | }, func() screen { | |
| 236 | title := "Commits on " + ref | |
| 237 | if filePath != "" { | |
| 238 | title += " touching " + filePath | |
| 239 | } | |
| 240 | rows := make([]row, len(ds)) | |
| 241 | for i, d := range ds { | |
| 242 | rows[i] = rowOf(cRef(fmt.Sprintf("%.10s", d.SHA)), cGlyph(d.Signature.State), cFlex(d.Subject), cMeta(d.AuthorName, relAge(d.Date, termNow()))) | |
| 243 | } | |
| 244 | s := listScreen(title, rows) | |
| 245 | if len(ds) > 0 { | |
| 246 | s.actions = append(s.actions, action{"Read", []string{"repo", "commit", repo.Path(), ds[0].SHA[:min(12, len(ds[0].SHA))]}}) | |
| 247 | } | |
| 248 | s.actions = append(s.actions, action{"Read", []string{"repo", "tree", repo.Path(), "--ref", ref}}) | |
| 249 | return s | |
| 244 | 250 | }) |
| 245 | 251 | } |
| 246 | 252 | |
| @@ -348,27 +354,28 @@ func runRepoCommit(c *Ctx, args []string) int { | ||
| 348 | 354 | fmt.Fprintf(w, "\n%s", d.Diff) |
| 349 | 355 | return |
| 350 | 356 | } |
| 351 | v := c.view(w) | |
| 352 | v.title(d.SHA[:10], d.Subject, d.Signature.State) | |
| 353 | signer := d.Signature.Signer | |
| 354 | if signer != "" && d.Signature.Fingerprint != "" { | |
| 355 | signer += ", key " + d.Signature.Fingerprint | |
| 357 | short, url := d.SHA[:min(10, len(d.SHA))], c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:min(12, len(d.SHA))]) | |
| 358 | s := screen{body: body, format: "text", fields: []field{ | |
| 359 | {"Commit", []cell{cLink(short, url), cText(d.Subject)}}, | |
| 360 | {"Author", []cell{cText(fmt.Sprintf("%s <%s>", d.AuthorName, d.AuthorEmail)), cAge(d.Date)}}, | |
| 361 | }} | |
| 362 | if d.CommitterEmail != "" && d.CommitterEmail != d.AuthorEmail { | |
| 363 | s.fields = append(s.fields, field{"Committer", []cell{cText(d.CommitterEmail)}}) | |
| 356 | 364 | } |
| 357 | v.fields( | |
| 358 | "author", fmt.Sprintf("%s <%s>, %s", d.AuthorName, d.AuthorEmail, c.when(d.Date)), | |
| 359 | "committer", d.CommitterEmail, | |
| 360 | "signer", signer, | |
| 361 | "url", c.siteURL(repo.OwnerName, repo.Name, "commit", d.SHA[:12]), | |
| 362 | ) | |
| 363 | v.text(body) | |
| 364 | if len(d.Checks) > 0 { | |
| 365 | v.section("checks") | |
| 366 | tb := c.table(w, "CHECK", "STATE") | |
| 367 | for _, ch := range d.Checks { | |
| 368 | tb.row(cText(ch.Context), cState(ch.State)) | |
| 369 | } | |
| 370 | tb.flush() | |
| 365 | s.fields = append(s.fields, field{"Signed", []cell{cGlyph(d.Signature.State), cState(d.Signature.State), cMeta(d.Signature.Signer, d.Signature.Fingerprint)}}) | |
| 366 | if !c.Term.Links { | |
| 367 | s.fields = append(s.fields, field{"URL", []cell{cText(url)}}) | |
| 368 | } | |
| 369 | checks := section{title: "Checks", n: len(d.Checks)} | |
| 370 | for _, ch := range d.Checks { | |
| 371 | checks.rows = append(checks.rows, rowOf(cGlyph(ch.State), cFlex(ch.Context))) | |
| 372 | } | |
| 373 | s.sections = []section{checks} | |
| 374 | s.actions = []action{ | |
| 375 | {"Read", []string{"repo", "log", repo.Path(), "--ref", short}}, | |
| 376 | {"Read", []string{"repo", "tree", repo.Path(), "--ref", short}}, | |
| 371 | 377 | } |
| 378 | c.render(w, s) | |
| 372 | 379 | fmt.Fprintf(w, "\n%s", c.Term.diff(d.Diff)) |
| 373 | 380 | }) |
| 374 | 381 | } |
internal/control/stage3repo_test.go added +316
| @@ -0,0 +1,316 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "crypto/ed25519" | |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | "golang.org/x/crypto/ssh" | |
| 15 | ) | |
| 16 | ||
| 17 | // dispatchAs runs argv as u with stdin, piped, and returns stdout; a | |
| 18 | // non-zero exit fails t. | |
| 19 | func dispatchAs(t *testing.T, st *store.Store, u store.User, stdin string, argv ...string) string { | |
| 20 | t.Helper() | |
| 21 | return dispatchIn(t, st, u, "", stdin, argv...) | |
| 22 | } | |
| 23 | ||
| 24 | // dispatchIn is dispatchAs with repositories under root. | |
| 25 | func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, argv ...string) string { | |
| 26 | t.Helper() | |
| 27 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} | |
| 28 | c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(stdin)} | |
| 29 | c.Cfg.Server.Root = root | |
| 30 | // Fixtures write a lot as user 1; the write limiter is global to | |
| 31 | // the package, so leave its budget to the tests that exercise it. | |
| 32 | c.Cfg.Limits.WriteRate = -1 | |
| 33 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 34 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) | |
| 35 | } | |
| 36 | return out.String() | |
| 37 | } | |
| 38 | ||
| 39 | // atTerminal runs argv as u at a 100-column terminal without colour | |
| 40 | // and returns stdout; a non-zero exit fails t. | |
| 41 | func atTerminal(t *testing.T, st *store.Store, u store.User, argv ...string) string { | |
| 42 | t.Helper() | |
| 43 | return atTerminalIn(t, st, u, "", argv...) | |
| 44 | } | |
| 45 | ||
| 46 | // atTerminalIn is atTerminal with repositories under root. | |
| 47 | func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv ...string) string { | |
| 48 | t.Helper() | |
| 49 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} | |
| 50 | c := &Ctx{User: u, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Stdin: strings.NewReader(""), Term: Term{Cols: 100}} | |
| 51 | c.Cfg.Server.Root = root | |
| 52 | // Fixtures write a lot as user 1; the write limiter is global to | |
| 53 | // the package, so leave its budget to the tests that exercise it. | |
| 54 | c.Cfg.Limits.WriteRate = -1 | |
| 55 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 56 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) | |
| 57 | } | |
| 58 | return out.String() | |
| 59 | } | |
| 60 | ||
| 61 | // checkLegend fails t for any suggested command in a rendered screen | |
| 62 | // (a line starting "gitbay ", or after "+n more ") that the registry | |
| 63 | // would not dispatch with the flags it names. | |
| 64 | func checkLegend(t *testing.T, out string) { | |
| 65 | t.Helper() | |
| 66 | var s screen | |
| 67 | for _, line := range strings.Split(out, "\n") { | |
| 68 | if i := strings.Index(line, "more gitbay "); i >= 0 { | |
| 69 | line = line[i+len("more "):] | |
| 70 | } | |
| 71 | for _, cmd := range strings.Split(line, " ") { | |
| 72 | cmd = strings.TrimSpace(cmd) | |
| 73 | if !strings.HasPrefix(cmd, "gitbay ") { | |
| 74 | continue | |
| 75 | } | |
| 76 | argv, err := protocol.Tokenize(strings.TrimPrefix(cmd, "gitbay ")) | |
| 77 | if err != nil { | |
| 78 | t.Errorf("%q: %v", cmd, err) | |
| 79 | continue | |
| 80 | } | |
| 81 | s.actions = append(s.actions, action{"", argv}) | |
| 82 | } | |
| 83 | } | |
| 84 | if len(s.actions) == 0 { | |
| 85 | t.Errorf("no suggested commands in:\n%s", out) | |
| 86 | } | |
| 87 | checkActions(t, s) | |
| 88 | } | |
| 89 | ||
| 90 | // newPubKey is an ed25519 public key in authorized_keys form, the same | |
| 91 | // on every run so its fingerprint can be pinned. | |
| 92 | func newPubKey(t *testing.T) string { | |
| 93 | t.Helper() | |
| 94 | pub := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{7}, ed25519.SeedSize)).Public().(ed25519.PublicKey) | |
| 95 | sp, err := ssh.NewPublicKey(pub) | |
| 96 | if err != nil { | |
| 97 | t.Fatal(err) | |
| 98 | } | |
| 99 | return string(ssh.MarshalAuthorizedKey(sp)) | |
| 100 | } | |
| 101 | ||
| 102 | // repoListsFixture is a repository with something in every list task 1 | |
| 103 | // migrates. | |
| 104 | func repoListsFixture(t *testing.T) (*store.Store, store.Repo, store.User) { | |
| 105 | t.Helper() | |
| 106 | st, repo, uid := newQueueTestRepo(t) | |
| 107 | owner := store.User{ID: uid, Username: "alice", IsAdmin: true} | |
| 108 | p := repo.Path() | |
| 109 | dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "cli", "forge") | |
| 110 | dispatchAs(t, st, owner, "s3cret\n", "repo", "secret", "set", p, "DEPLOY_TOKEN") | |
| 111 | dispatchAs(t, st, owner, testRunnerPub, "repo", "deploy-key", "add", p) | |
| 112 | dispatchAs(t, st, owner, "", "repo", "domain", "add", p, "docs.example.test") | |
| 113 | dispatchAs(t, st, owner, "", "repo", "bookmark", p) | |
| 114 | // mirror add resolves the host, so the fixture goes to the store. | |
| 115 | if _, err := st.AddMirror(repo.ID, "push", "https://mirror.example.test/app.git", "", "tok"); err != nil { | |
| 116 | t.Fatal(err) | |
| 117 | } | |
| 118 | dispatchAs(t, st, owner, newPubKey(t), "repo", "runner", "add", p) | |
| 119 | return st, repo, owner | |
| 120 | } | |
| 121 | ||
| 122 | func TestRepoListsPlainPinned(t *testing.T) { | |
| 123 | st, repo, owner := repoListsFixture(t) | |
| 124 | p := repo.Path() | |
| 125 | for name, argv := range map[string][]string{ | |
| 126 | "repo-topics": {"repo", "topics", p}, | |
| 127 | "repo-secret-list": {"repo", "secret", "list", p}, | |
| 128 | "repo-deploy-key-list": {"repo", "deploy-key", "list", p}, | |
| 129 | "repo-domain-list": {"repo", "domain", "list", p}, | |
| 130 | "repo-access-list": {"repo", "access", "list", p}, | |
| 131 | "repo-bookmarks": {"repo", "bookmarks"}, | |
| 132 | "repo-search": {"repo", "search", "app"}, | |
| 133 | "repo-list": {"repo", "list"}, | |
| 134 | "repo-mirror-list": {"repo", "mirror", "list", p}, | |
| 135 | "repo-runner-list": {"repo", "runner", "list", p}, | |
| 136 | } { | |
| 137 | pinPlain(t, name, dispatchAs(t, st, owner, "", argv...)) | |
| 138 | } | |
| 139 | pinPlain(t, "repo-topics-add", dispatchAs(t, st, owner, "", "repo", "topics", "add", p, "git")) | |
| 140 | } | |
| 141 | ||
| 142 | func TestRepoListScreens(t *testing.T) { | |
| 143 | st, repo, owner := repoListsFixture(t) | |
| 144 | p := repo.Path() | |
| 145 | for _, tc := range []struct { | |
| 146 | argv []string | |
| 147 | want []string | |
| 148 | }{ | |
| 149 | {[]string{"repo", "topics", p}, []string{"Topics (2)\ncli\nforge\n"}}, | |
| 150 | {[]string{"repo", "topics", "add", p, "git"}, []string{"Topics (3)\n"}}, | |
| 151 | {[]string{"repo", "secret", "list", p}, []string{"Build secrets (1)\nDEPLOY_TOKEN\n"}}, | |
| 152 | {[]string{"repo", "deploy-key", "list", p}, []string{"Deploy keys (1)\n", " ro runner@test ssh-ed25519 · used never"}}, | |
| 153 | {[]string{"repo", "domain", "list", p}, []string{"Pages domains (1)\n", "◐ docs.example.test pending"}}, | |
| 154 | {[]string{"repo", "access", "list", p}, []string{"Access (1)\nalice admin via owner\n"}}, | |
| 155 | {[]string{"repo", "mirror", "list", p}, []string{"Mirrors (1)\n1 ◐ https://mirror.example.test/app.git push · never synced"}}, | |
| 156 | {[]string{"repo", "runner", "list", p}, []string{"Runners (1)\n", " alice ssh-ed25519 · never seen"}}, | |
| 157 | {[]string{"repo", "bookmarks"}, []string{"Bookmarks (1)\nalice/app public 1 bookmark\n"}}, | |
| 158 | {[]string{"repo", "search", "app"}, []string{"Repositories matching \"app\" (1)\nalice/app public cli, forge"}}, | |
| 159 | {[]string{"repo", "list"}, []string{"Repositories (1)\nalice/app public\n"}}, | |
| 160 | } { | |
| 161 | out := atTerminal(t, st, owner, tc.argv...) | |
| 162 | for _, w := range tc.want { | |
| 163 | if !strings.Contains(out, w) { | |
| 164 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 165 | } | |
| 166 | } | |
| 167 | if strings.Contains(out, "s3cret") || strings.Contains(out, "tok\n") { | |
| 168 | t.Errorf("%v: secret on screen:\n%s", tc.argv, out) | |
| 169 | } | |
| 170 | checkLegend(t, out) | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | // browseFixture is a repository on disk under root: two commits on main | |
| 175 | // (the second touching docs/), a branch, and a tag, with fixed dates so | |
| 176 | // the shas are the same on every run. | |
| 177 | func browseFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) { | |
| 178 | t.Helper() | |
| 179 | st, repo, uid := newQueueTestRepo(t) | |
| 180 | root := t.TempDir() | |
| 181 | env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z") | |
| 182 | git := func(dir string, args ...string) { | |
| 183 | t.Helper() | |
| 184 | cmd := exec.Command("git", args...) | |
| 185 | cmd.Dir, cmd.Env = dir, env | |
| 186 | if out, err := cmd.CombinedOutput(); err != nil { | |
| 187 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 188 | } | |
| 189 | } | |
| 190 | src := filepath.Join(root, "src") | |
| 191 | if err := os.MkdirAll(filepath.Join(src, "docs"), 0o755); err != nil { | |
| 192 | t.Fatal(err) | |
| 193 | } | |
| 194 | os.WriteFile(filepath.Join(src, "README.md"), []byte("# app\n"), 0o644) | |
| 195 | git(root, "init", "-q", "-b", "main", "src") | |
| 196 | git(src, "add", ".") | |
| 197 | git(src, "commit", "-q", "-m", "first") | |
| 198 | os.WriteFile(filepath.Join(src, "docs", "guide.md"), []byte("# guide\n"), 0o644) | |
| 199 | git(src, "add", ".") | |
| 200 | git(src, "commit", "-q", "-m", "docs: guide") | |
| 201 | git(src, "tag", "v1.0.0") | |
| 202 | git(src, "branch", "feature") | |
| 203 | dir := RepoDir(root, repo.OwnerName, repo.Name) | |
| 204 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 205 | git(root, "clone", "-q", "--bare", src, dir) | |
| 206 | return st, repo, store.User{ID: uid, Username: "alice"}, root | |
| 207 | } | |
| 208 | ||
| 209 | func TestRepoBrowsePlainPinned(t *testing.T) { | |
| 210 | st, repo, u, root := browseFixture(t) | |
| 211 | p := repo.Path() | |
| 212 | for name, argv := range map[string][]string{ | |
| 213 | "repo-log": {"repo", "log", p}, | |
| 214 | "repo-log-path": {"repo", "log", p, "--path", "docs/guide.md"}, | |
| 215 | "repo-tree": {"repo", "tree", p}, | |
| 216 | "repo-tree-docs": {"repo", "tree", p, "docs"}, | |
| 217 | "repo-refs": {"repo", "refs", p}, | |
| 218 | } { | |
| 219 | pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...)) | |
| 220 | } | |
| 221 | sst, srepo, alice, _ := symbolsFixture(t) | |
| 222 | pinPlain(t, "repo-symbols", dispatchAs(t, sst, alice, "", "repo", "symbols", srepo.Path(), "Pars")) | |
| 223 | } | |
| 224 | ||
| 225 | func TestRepoBrowseScreens(t *testing.T) { | |
| 226 | st, repo, u, root := browseFixture(t) | |
| 227 | p := repo.Path() | |
| 228 | for _, tc := range []struct { | |
| 229 | argv []string | |
| 230 | want []string | |
| 231 | }{ | |
| 232 | {[]string{"repo", "log", p}, []string{"Commits on main (2)\n", " docs: guide t · "}}, | |
| 233 | {[]string{"repo", "log", p, "--path", "docs/guide.md"}, []string{"Commits on main touching docs/guide.md (1)\n"}}, | |
| 234 | {[]string{"repo", "tree", p}, []string{"alice/app at main (2)\n", "docs/", "README.md"}}, | |
| 235 | {[]string{"repo", "tree", p, "docs"}, []string{"alice/app/docs at main (1)\n", "guide.md"}}, | |
| 236 | {[]string{"repo", "refs", p}, []string{"Branches (2)\nmain", "default", "Tags (1)\nv1.0.0"}}, | |
| 237 | } { | |
| 238 | out := atTerminalIn(t, st, u, root, tc.argv...) | |
| 239 | for _, w := range tc.want { | |
| 240 | if !strings.Contains(out, w) { | |
| 241 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 242 | } | |
| 243 | } | |
| 244 | checkLegend(t, out) | |
| 245 | } | |
| 246 | sst, srepo, alice, _ := symbolsFixture(t) | |
| 247 | out := atTerminal(t, sst, alice, "repo", "symbols", srepo.Path(), "Pars") | |
| 248 | if !strings.Contains(out, "Symbols matching \"Pars\" (") || !strings.Contains(out, "Parse function p.go:1") { | |
| 249 | t.Errorf("symbols:\n%s", out) | |
| 250 | } | |
| 251 | checkLegend(t, out) | |
| 252 | } | |
| 253 | ||
| 254 | func TestRepoShowsPlainPinned(t *testing.T) { | |
| 255 | st, repo, u, root := browseFixture(t) | |
| 256 | p := repo.Path() | |
| 257 | pinPlain(t, "repo-settings-show", dispatchIn(t, st, u, root, "", "repo", "settings", "show", p)) | |
| 258 | pinPlain(t, "repo-deps-status-off", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p)) | |
| 259 | pinPlain(t, "repo-commit", dispatchIn(t, st, u, root, "", "repo", "commit", p, "aee4475ad9")) | |
| 260 | dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p) | |
| 261 | pinPlain(t, "repo-deps-status", dispatchIn(t, st, u, root, "", "repo", "deps", "status", p)) | |
| 262 | } | |
| 263 | ||
| 264 | func TestRepoShowScreens(t *testing.T) { | |
| 265 | st, repo, u, root := browseFixture(t) | |
| 266 | p := repo.Path() | |
| 267 | for _, tc := range []struct { | |
| 268 | argv []string | |
| 269 | want []string | |
| 270 | }{ | |
| 271 | {[]string{"repo", "settings", "show", p}, []string{"Repo:", "alice/app", "Require MR:", "off", "Approvals:"}}, | |
| 272 | {[]string{"repo", "deps", "status", p}, []string{"Checks: off\n", "gitbay repo deps enable alice/app"}}, | |
| 273 | {[]string{"repo", "commit", p, "aee4475ad9"}, []string{"Commit:", "aee4475ad9 docs: guide", "Author:", "t <t@example.test>", "gitbay repo log alice/app --ref aee4475ad9", "+# guide"}}, | |
| 274 | } { | |
| 275 | out := atTerminalIn(t, st, u, root, tc.argv...) | |
| 276 | for _, w := range tc.want { | |
| 277 | if !strings.Contains(out, w) { | |
| 278 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 279 | } | |
| 280 | } | |
| 281 | checkLegend(t, out) | |
| 282 | } | |
| 283 | dispatchIn(t, st, u, root, "", "repo", "deps", "enable", p) | |
| 284 | out := atTerminalIn(t, st, u, root, "repo", "deps", "status", p) | |
| 285 | if !strings.Contains(out, "Checks:") || !strings.Contains(out, "gitbay repo deps disable alice/app") { | |
| 286 | t.Errorf("deps on:\n%s", out) | |
| 287 | } | |
| 288 | checkLegend(t, out) | |
| 289 | if i, j := strings.Index(out, "gitbay repo deps"), strings.Index(out, "Checks:"); i < j { | |
| 290 | t.Errorf("legend before the header:\n%s", out) | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | // At 60 columns no screen line is wider than the terminal, apart from a | |
| 295 | // suggested command: e2e's readonly test holds every read command to it. | |
| 296 | func TestRepoScreensFitSixtyColumns(t *testing.T) { | |
| 297 | st, repo, owner := repoListsFixture(t) | |
| 298 | p := repo.Path() | |
| 299 | for _, argv := range [][]string{ | |
| 300 | {"repo", "deploy-key", "list", p}, | |
| 301 | {"repo", "runner", "list", p}, | |
| 302 | {"repo", "mirror", "list", p}, | |
| 303 | } { | |
| 304 | out, errOut := &bytes.Buffer{}, &bytes.Buffer{} | |
| 305 | c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut, Term: Term{Cols: 60}} | |
| 306 | c.Cfg.Limits.WriteRate = -1 | |
| 307 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 308 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) | |
| 309 | } | |
| 310 | for _, line := range strings.Split(out.String(), "\n") { | |
| 311 | if cells(line) > 60 && !strings.HasPrefix(line, "gitbay ") { | |
| 312 | t.Errorf("%v: %d cells: %q", argv, cells(line), line) | |
| 313 | } | |
| 314 | } | |
| 315 | } | |
| 316 | } | |
internal/control/symbols.go +21 −1
| @@ -115,7 +115,7 @@ func runRepoSymbols(c *Ctx, args []string) int { | ||
| 115 | 115 | for _, r := range rows { |
| 116 | 116 | ds = append(ds, symbolOut{r.Name, r.Kind, r.Path, r.Line}) |
| 117 | 117 | } |
| 118 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 118 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 119 | 119 | tb := c.table(w, "NAME", "KIND", "LOCATION") |
| 120 | 120 | for _, d := range ds { |
| 121 | 121 | tb.row(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line))) |
| @@ -127,6 +127,26 @@ func runRepoSymbols(c *Ctx, args []string) int { | ||
| 127 | 127 | if idx.State == "partial" { |
| 128 | 128 | fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note) |
| 129 | 129 | } |
| 130 | }, func() screen { | |
| 131 | rows := make([]row, len(ds)) | |
| 132 | for i, d := range ds { | |
| 133 | rows[i] = rowOf(cRef(d.Name), cText(d.Kind), cFlex(fmt.Sprintf("%s:%d", d.Path, d.Line))) | |
| 134 | } | |
| 135 | if capped { | |
| 136 | fmt.Fprintf(c.Stderr, "first %d matches; page with --limit and --cursor\n", symbolsUnpaged) | |
| 137 | } | |
| 138 | if idx.State == "partial" { | |
| 139 | fmt.Fprintf(c.Stderr, "the index is partial: %s\n", idx.Note) | |
| 140 | } | |
| 141 | s := listScreen(fmt.Sprintf("Symbols matching %q", query), rows) | |
| 142 | if len(ds) > 0 { | |
| 143 | cat := []string{"repo", "cat", repo.Path(), ds[0].Path} | |
| 144 | if ref != "" { | |
| 145 | cat = append(cat, "--ref", ref) | |
| 146 | } | |
| 147 | s.actions = []action{{"Read", cat}} | |
| 148 | } | |
| 149 | return s | |
| 130 | 150 | }) |
| 131 | 151 | } |
| 132 | 152 | |
internal/control/table.go +5
| @@ -57,6 +57,11 @@ func cGlyph(state string) cell { | ||
| 57 | 57 | return cell{kind: kindGlyph, s: g, sgr: sgr} |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | // cFlexRef is an identifier too long to keep whole at every width, a | |
| 61 | // key fingerprint: dim like a reference, but cut to fit like a title. | |
| 62 | // Piped and --json output carry it whole. | |
| 63 | func cFlexRef(s string) cell { return cell{kind: kindFlex, s: s, sgr: sgrDim} } | |
| 64 | ||
| 60 | 65 | // cYou is the mark for a row that waits on the viewer. |
| 61 | 66 | func cYou() cell { return cell{kind: kindGlyph, s: "●", sgr: sgrYellow} } |
| 62 | 67 | |
internal/control/testdata/plain/repo-access-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice admin via owner | |
internal/control/testdata/plain/repo-bookmarks.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app 1 | |
internal/control/testdata/plain/repo-commit.txt added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | commit aee4475ad9033773a17e148aafa3105212ac9c10 | |
| 2 | Author: t <t@example.test> | |
| 3 | Date: <time> | |
| 4 | ||
| 5 | docs: guide | |
| 6 | ||
| 7 | docs/guide.md | 1 + | |
| 8 | 1 file changed, 1 insertion(+) | |
| 9 | ||
| 10 | diff --git a/docs/guide.md b/docs/guide.md | |
| 11 | new file mode 100644 | |
| 12 | index 0000000..cd51204 | |
| 13 | --- /dev/null | |
| 14 | +++ b/docs/guide.md | |
| 15 | @@ -0,0 +1 @@ | |
| 16 | +# guide | |
internal/control/testdata/plain/repo-deploy-key-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | SHA256:re738AN1/nwapcQTqXzvkuuJNj+U6StCED66ZUJNCdE ssh-ed25519 ro runner@test never never | |
internal/control/testdata/plain/repo-deps-status-off.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | checks off (repo deps enable alice/app) | |
internal/control/testdata/plain/repo-deps-status.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | checks on | |
internal/control/testdata/plain/repo-domain-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | docs.example.test pending | |
internal/control/testdata/plain/repo-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app public | |
internal/control/testdata/plain/repo-log-path.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | aee4475ad9 unsigned docs: guide (t <t@example.test>) | |
internal/control/testdata/plain/repo-log.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | aee4475ad9 unsigned docs: guide (t <t@example.test>) | |
| 2 | 75e07603f5 unsigned first (t <t@example.test>) | |
internal/control/testdata/plain/repo-mirror-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | 1 push https://mirror.example.test/app.git last - pending | |
internal/control/testdata/plain/repo-refs.txt added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | branch feature aee4475ad9 | |
| 2 | branch main aee4475ad9 | |
| 3 | tag v1.0.0 aee4475ad9 | |
internal/control/testdata/plain/repo-runner-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | SHA256:z/fSv0Z0RZS+Lccbc6ZoOWwt/fbj1VFJGSBKQbo4icE ssh-ed25519 alice never idle | |
internal/control/testdata/plain/repo-search.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app public | |
internal/control/testdata/plain/repo-secret-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | DEPLOY_TOKEN | |
internal/control/testdata/plain/repo-settings-show.txt added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | alice/app settings | |
| 2 | ||
| 3 | require mr false | |
| 4 | require checks false | |
| 5 | require signed commits false | |
| 6 | git daemon false | |
| 7 | archived false | |
internal/control/testdata/plain/repo-symbols.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | Parse function p.go:1 | |
| 2 | ParseAll function p.go:2 | |
| 3 | ParseArgs function p.go:3 | |
| 4 | Parser function p.go:4 | |
| 5 | Parser.Run method p.go:40 | |
| 6 | parse function p.go:5 | |
internal/control/testdata/plain/repo-topics-add.txt added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | cli | |
| 2 | forge | |
| 3 | git | |
internal/control/testdata/plain/repo-topics.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | cli | |
| 2 | forge | |
internal/control/testdata/plain/repo-tree-docs.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | cd51204800 8 guide.md | |
internal/control/testdata/plain/repo-tree.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | e2bd1550b1 6 README.md | |
| 2 | 604e844ed3 - docs/ | |