control: remaining screens; view is piped-only (CLI views stage 3c) !558
51 files changed, +843 −268
Layout: unified · split
.gitbay/wiki/Users.org +18 −35
| @@ -1024,41 +1024,24 @@ then prints: | ||
| 1024 | 1024 | =krz/gitbay=, SHAs) dim. A marker piped output keeps as its own |
| 1025 | 1025 | trailing cell (=[archived]=, =primary=) joins the state at a terminal: |
| 1026 | 1026 | =public, archived=; |
| 1027 | - =mr list=, =issue list= and =build list= as one-section screens: | |
| 1028 | each row led by =●= when it waits on you (your review requested, an | |
| 1029 | issue assigned to you), otherwise by its checks' or state's glyph; | |
| 1030 | =mr list= with review state, branch and age, =issue list= with | |
| 1031 | labels, assignees, comments and age. A =dashboard= screen opens with | |
| 1032 | what waits on you, then open merge requests, failed builds (a job's | |
| 1033 | latest build only), five lines of activity with a count of builds | |
| 1034 | that passed, and pinned repositories; for admins a =Problems= line | |
| 1035 | counts background failures, and =admin stats= has the detail. A | |
| 1036 | column empty on every row is left out. These columns are | |
| 1037 | terminal-only; piped rows keep their own; | |
| 1038 | - diffs coloured (file headers bold, hunks cyan, added green, removed | |
| 1039 | red), and =build log= with its step lines bold, the failed one red; | |
| 1040 | both made safe like any user text, the log's own colour codes dropped; | |
| 1041 | - ages as =2h ago= and =in 5h=, sizes as =3.0 MiB=, durations as | |
| 1042 | =53m43s=, where piped output keeps RFC3339, bytes and seconds; | |
| 1043 | - =mr show=, =issue show=, =repo show= and =build show= drawn as | |
| 1044 | screens: a header block of =Label:= lines (labels dim), the | |
| 1045 | description, sections titled =Title (n)= in bold blue (=Commits=, | |
| 1046 | =Files=, =Discussion=, =Events=, =Steps=, open merge requests and | |
| 1047 | issues), and a legend under a rule of the commands that apply now, | |
| 1048 | grouped (=Unblock=, =Review=, =Merge=, =Read=, ...) and chosen from | |
| 1049 | the item's state and your access. Rows have no header and no indent: | |
| 1050 | a dim reference, a state glyph, the text, then dim facts joined by | |
| 1051 | =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐= still | |
| 1052 | going, =●= waiting on you, =○= closed or draft. A section cut short | |
| 1053 | ends with =+n more= and the command for the rest. Below 80 columns the | |
| 1054 | legend's groups stack; a suggested command is never cut, so it may run | |
| 1055 | past the width; | |
| 1056 | - other =show= views with a title line, aligned fields, the body rendered | |
| 1057 | from markdown or org, one line per event, and comments under a rule; | |
| 1058 | a sub-table (labels, revisions, and the like) carries a section label | |
| 1059 | line in both terminal and piped output; timestamps as | |
| 1060 | =2026-09-23 23:26 UTC=. =wiki show= piped prints the page source | |
| 1061 | verbatim; the rendered page is terminal-only; | |
| 1027 | - every list and show drawn as a screen: a header block of =Label:= | |
| 1028 | lines (labels dim), the description or notes, sections titled =Title | |
| 1029 | (n)= in bold blue, and a legend under a rule of the commands that | |
| 1030 | apply now, grouped and chosen from the item's state and your access. | |
| 1031 | Rows have no header (unless a column is a number or a size) and no | |
| 1032 | indent: a dim reference, a state glyph, the text, then dim facts | |
| 1033 | joined by =·=. The glyphs are =✓= passed, =✗= failed or blocked, =◐= | |
| 1034 | still going, =●= waiting on you (your review requested, an issue | |
| 1035 | assigned to you, an unread notification, a pending account, an | |
| 1036 | overdue milestone, the session's own key), =○= closed or draft. A | |
| 1037 | section cut short ends with =+n more= and the command for the rest. | |
| 1038 | Below 80 columns the legend's groups stack; a suggested command is | |
| 1039 | never cut, so it may run past the width. =dashboard= opens with what | |
| 1040 | waits on you; for admins a =Problems= line counts background | |
| 1041 | failures, and =admin stats= has the detail. =repo commit= prints its | |
| 1042 | screen, then the diff. Secrets never appear on a screen, and a | |
| 1043 | webhook URL shows its scheme and host only. =wiki show= piped prints | |
| 1044 | the page source verbatim; | |
| 1062 | 1045 | - errors on stderr after a red =error:=, a mistyped flag with the flag |
| 1063 | 1046 | it is closest to (=did you mean --state?=), and a usage line wrapped |
| 1064 | 1047 | to the width between its bracketed groups, one alternative per line; |
CHANGELOG.org +7
| @@ -39,6 +39,13 @@ anything beyond "replace the binary and restart" is needed. | ||
| 39 | 39 | milestone lists (repo and org), =org team list=/=show=. The session's |
| 40 | 40 | own key leads with =●=; =pgp list= shows user IDs rather than their |
| 41 | 41 | JSON; a webhook URL on screen is its scheme and host only. |
| 42 | - The remaining commands at a terminal are screens (#319): saved | |
| 43 | queries (and =issue list=/=mr list= with =--query=/=--q=), =search= | |
| 44 | (a section per kind), =feed=, =mr revisions=, =issue templates=, | |
| 45 | =build jobs=, =status list=, releases, snippets, wiki, =explore=, | |
| 46 | =audit=, and the admin commands (=admin stats= lists the 20 largest | |
| 47 | repositories). Every list and show is now a screen; the piped layout | |
| 48 | is the only other one. | |
| 42 | 49 | |
| 43 | 50 | * v1.41.0 — 2026-10-01 |
| 44 | 51 | |
internal/control/admin.go +142 −5
| @@ -159,7 +159,7 @@ func runAdminUserList(c *Ctx, args []string) int { | ||
| 159 | 159 | for _, u := range users { |
| 160 | 160 | ds = append(ds, adminUserRow(u)) |
| 161 | 161 | } |
| 162 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 162 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 163 | 163 | tb := c.table(w, "USERNAME", "STATE", "ADMIN", "CREATED", "LAST SEEN") |
| 164 | 164 | for _, d := range ds { |
| 165 | 165 | mark := "" |
| @@ -169,6 +169,25 @@ func runAdminUserList(c *Ctx, args []string) int { | ||
| 169 | 169 | tb.row(cRef(d.Username), cState(d.State), cText(mark), cAge(d.CreatedAt), cAge(d.LastSeen)) |
| 170 | 170 | } |
| 171 | 171 | tb.flush() |
| 172 | }, func() screen { | |
| 173 | rows := make([]row, len(ds)) | |
| 174 | for i, d := range ds { | |
| 175 | lead := cGlyph("") | |
| 176 | if d.State == "pending" { | |
| 177 | lead = cYou() | |
| 178 | } | |
| 179 | admin, seen := "", "" | |
| 180 | if d.Admin { | |
| 181 | admin = "admin" | |
| 182 | } | |
| 183 | if d.LastSeen != "" { | |
| 184 | seen = "seen " + relAge(d.LastSeen, termNow()) | |
| 185 | } | |
| 186 | rows[i] = rowOf(cRef(d.Username), lead, cState(d.State), cMeta(admin, seen)) | |
| 187 | } | |
| 188 | return listScreen("Accounts", rows, | |
| 189 | action{"Filter", []string{"admin", "user", "list", "--state", "pending"}}, | |
| 190 | ) | |
| 172 | 191 | }) |
| 173 | 192 | } |
| 174 | 193 | |
| @@ -280,7 +299,7 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 280 | 299 | return c.fail(protocol.ExitFailure, "%v", err) |
| 281 | 300 | } |
| 282 | 301 | |
| 283 | return c.emit(d, func(w io.Writer) { | |
| 302 | return c.emitView(d, func(w io.Writer) { | |
| 284 | 303 | admin := "" |
| 285 | 304 | if d.Admin { |
| 286 | 305 | admin = "yes" |
| @@ -346,6 +365,66 @@ func runAdminUserShow(c *Ctx, args []string) int { | ||
| 346 | 365 | } |
| 347 | 366 | tt.flush() |
| 348 | 367 | } |
| 368 | }, func() screen { | |
| 369 | s := screen{} | |
| 370 | user := []cell{cRef(d.Username), cState(d.State)} | |
| 371 | if d.Admin { | |
| 372 | user = append(user, cMeta("admin")) | |
| 373 | } | |
| 374 | seen := "never seen" | |
| 375 | if d.LastSeen != "" { | |
| 376 | seen = "seen " + relAge(d.LastSeen, termNow()) | |
| 377 | } | |
| 378 | s.fields = []field{ | |
| 379 | {"User", user}, | |
| 380 | {"Seen", []cell{cText(seen), cMeta("created " + relAge(d.CreatedAt, termNow()))}}, | |
| 381 | {"Repos", []cell{cText(strconv.FormatInt(d.Repos, 10))}}, | |
| 382 | {"Sessions", []cell{cText(strconv.FormatInt(d.WebSessions, 10))}}, | |
| 383 | } | |
| 384 | keys := section{title: "Keys", n: len(d.Keys)} | |
| 385 | for _, k := range d.Keys { | |
| 386 | keys.rows = append(keys.rows, rowOf(cFlexRef(k.Fingerprint), cState(k.Scope), cMeta(k.Algo, "used "+relAge(k.LastUsedAt, termNow())))) | |
| 387 | } | |
| 388 | emails := section{title: "Emails", n: len(d.Emails)} | |
| 389 | for _, e := range d.Emails { | |
| 390 | state, primary := "unverified", "" | |
| 391 | if e.Verified { | |
| 392 | state = "verified" | |
| 393 | } | |
| 394 | if e.Primary { | |
| 395 | primary = "primary" | |
| 396 | } | |
| 397 | emails.rows = append(emails.rows, rowOf(cRef(e.Address), cState(state), cMeta(primary, e.VerifiedBy))) | |
| 398 | } | |
| 399 | pgp := section{title: "PGP keys", n: len(d.PGPKeys)} | |
| 400 | for _, k := range d.PGPKeys { | |
| 401 | pgp.rows = append(pgp.rows, rowOf(cFlexRef(k.Fingerprint))) | |
| 402 | } | |
| 403 | orgs := section{title: "Orgs", n: len(d.Orgs)} | |
| 404 | for _, o := range d.Orgs { | |
| 405 | orgs.rows = append(orgs.rows, rowOf(cLink(o.Org, c.siteURL(o.Org)), cState(o.Role))) | |
| 406 | } | |
| 407 | tokens := section{title: "API tokens", n: len(d.APITokens)} | |
| 408 | for _, tk := range d.APITokens { | |
| 409 | used := "" | |
| 410 | if tk.LastUsedAt != nil { | |
| 411 | used = "used " + relAge(tk.LastUsedAt.UTC().Format(time.RFC3339Nano), termNow()) | |
| 412 | } | |
| 413 | tokens.rows = append(tokens.rows, rowOf(cRef(tk.Name), cState(tk.Scope), cMeta(used))) | |
| 414 | } | |
| 415 | s.sections = []section{keys, emails, pgp, orgs, tokens} | |
| 416 | role, state := "promote", "disable" | |
| 417 | if d.Admin { | |
| 418 | role = "demote" | |
| 419 | } | |
| 420 | if d.State == "disabled" { | |
| 421 | state = "enable" | |
| 422 | } | |
| 423 | s.actions = []action{ | |
| 424 | {"Manage", []string{"admin", "user", role, d.Username}}, | |
| 425 | {"Manage", []string{"admin", "user", state, d.Username}}, | |
| 426 | } | |
| 427 | return s | |
| 349 | 428 | }) |
| 350 | 429 | } |
| 351 | 430 | |
| @@ -439,7 +518,7 @@ func runAdminRepoList(c *Ctx, args []string) int { | ||
| 439 | 518 | size := gitutil.DirSize(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) |
| 440 | 519 | ds = append(ds, out{r.Path, r.Visibility, r.Archived, r.CreatedAt, r.LastPush, size}) |
| 441 | 520 | } |
| 442 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 521 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 443 | 522 | tb := c.table(w, "PATH", "VISIBILITY", "BYTES", "CREATED", "LAST PUSH") |
| 444 | 523 | for _, d := range ds { |
| 445 | 524 | cells := []cell{cLink(d.Path, c.siteURL(d.Path)), cState(d.Visibility), cSize(d.Bytes), cAge(d.CreatedAt), cAge(d.LastPush)} |
| @@ -449,6 +528,22 @@ func runAdminRepoList(c *Ctx, args []string) int { | ||
| 449 | 528 | tb.row(cells...) |
| 450 | 529 | } |
| 451 | 530 | tb.flush() |
| 531 | }, func() screen { | |
| 532 | rows := make([]row, len(ds)) | |
| 533 | for i, d := range ds { | |
| 534 | state := d.Visibility | |
| 535 | if d.Archived { | |
| 536 | state += ", archived" | |
| 537 | } | |
| 538 | pushed := "" | |
| 539 | if d.LastPush != "" { | |
| 540 | pushed = "pushed " + relAge(d.LastPush, termNow()) | |
| 541 | } | |
| 542 | rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cState(state), cSize(d.Bytes), cMeta(pushed)) | |
| 543 | } | |
| 544 | return listScreen("Repositories", rows, | |
| 545 | action{"Filter", []string{"admin", "repo", "list", "--visibility", "private"}}, | |
| 546 | ) | |
| 452 | 547 | }) |
| 453 | 548 | } |
| 454 | 549 | |
| @@ -574,7 +669,7 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 574 | 669 | } |
| 575 | 670 | } |
| 576 | 671 | d := map[string]any{"queue": queue, "runners": runners} |
| 577 | return c.emit(d, func(w io.Writer) { | |
| 672 | return c.emitView(d, func(w io.Writer) { | |
| 578 | 673 | v := c.view(w) |
| 579 | 674 | v.fields( |
| 580 | 675 | "pending", fmt.Sprintf("%d", queue.Pending), |
| @@ -599,6 +694,32 @@ func runAdminRunners(c *Ctx, args []string) int { | ||
| 599 | 694 | tb.row(cText(r.Username), cText(r.Fingerprint), cAge(r.LastSeen), cFlex(scope), cText(held)) |
| 600 | 695 | } |
| 601 | 696 | tb.flush() |
| 697 | }, func() screen { | |
| 698 | s := screen{fields: []field{{"Queue", []cell{ | |
| 699 | cText(fmt.Sprintf("%d pending", queue.Pending)), | |
| 700 | cMeta(fmt.Sprintf("%d claimed in 24h", queue.Claimed24h), "wait avg "+c.Term.dur(queue.ClaimWaitAvgS), | |
| 701 | "max "+c.Term.dur(queue.ClaimWaitMaxS), fmt.Sprintf("%d reaped", queue.Reaped24h)), | |
| 702 | }}}} | |
| 703 | runnersSec := section{title: "Runners", n: len(runners)} | |
| 704 | idle := "" | |
| 705 | for _, r := range runners { | |
| 706 | scope := r.Scope | |
| 707 | if scope == "" { | |
| 708 | scope = "any" | |
| 709 | } | |
| 710 | lead, held := cGlyph(""), "idle" | |
| 711 | if r.BuildNumber != 0 { | |
| 712 | lead, held = cGlyph("running"), fmt.Sprintf("building %s #%d %s", r.BuildRepo, r.BuildNumber, r.BuildJob) | |
| 713 | } else if idle == "" { | |
| 714 | idle = r.Fingerprint | |
| 715 | } | |
| 716 | runnersSec.rows = append(runnersSec.rows, rowOf(cRef(r.Username), lead, cFlex(scope), cMeta("seen "+relAge(r.LastSeen, termNow()), held))) | |
| 717 | } | |
| 718 | s.sections = []section{runnersSec} | |
| 719 | if idle != "" { | |
| 720 | s.actions = []action{{"Prune", []string{"admin", "runners", "remove", idle}}} | |
| 721 | } | |
| 722 | return s | |
| 602 | 723 | }) |
| 603 | 724 | } |
| 604 | 725 | |
| @@ -687,7 +808,7 @@ func runAdminMRPrune(c *Ctx, args []string) int { | ||
| 687 | 808 | if err := gitutil.PruneNow(dir); err != nil { |
| 688 | 809 | return c.fail(protocol.ExitFailure, "%v; the head refs are deleted but the objects are not yet pruned; re-run the same command", err) |
| 689 | 810 | } |
| 690 | return c.emit(rows, func(w io.Writer) { | |
| 811 | return c.emitView(rows, func(w io.Writer) { | |
| 691 | 812 | tb := c.table(w, "!", "HEAD") |
| 692 | 813 | for _, r := range rows { |
| 693 | 814 | if r.Head == "" { |
| @@ -697,5 +818,21 @@ func runAdminMRPrune(c *Ctx, args []string) int { | ||
| 697 | 818 | tb.row(cRef(fmt.Sprintf("!%d", r.Number)), cRef(r.Head)) |
| 698 | 819 | } |
| 699 | 820 | tb.flush() |
| 821 | }, func() screen { | |
| 822 | rs := make([]row, len(rows)) | |
| 823 | for i, r := range rows { | |
| 824 | n := strconv.FormatInt(r.Number, 10) | |
| 825 | ref := cLink("!"+n, c.siteURL(repo.Path(), "mrs", n)) | |
| 826 | if r.Head == "" { | |
| 827 | rs[i] = rowOf(ref, cGlyph("skipped"), cMeta("already gone")) | |
| 828 | continue | |
| 829 | } | |
| 830 | rs[i] = rowOf(ref, cGlyph("ok"), cRef(fmt.Sprintf("%.10s", r.Head))) | |
| 831 | } | |
| 832 | s := listScreen("Pruned", rs) | |
| 833 | if len(rows) > 0 { | |
| 834 | s.actions = []action{{"Read", []string{"mr", "show", repo.Path(), strconv.FormatInt(rows[0].Number, 10)}}} | |
| 835 | } | |
| 836 | return s | |
| 700 | 837 | }) |
| 701 | 838 | } |
internal/control/adminhost.go +27 −1
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "cmp" | |
| 4 | 5 | "errors" |
| 5 | 6 | "fmt" |
| 6 | 7 | "io" |
| 7 | 8 | "os" |
| 9 | "slices" | |
| 8 | 10 | |
| 9 | 11 | "golang.org/x/crypto/ssh" |
| 10 | 12 | |
| @@ -324,7 +326,7 @@ func runAdminStats(c *Ctx, args []string) int { | ||
| 324 | 326 | if fi, err := os.Stat(c.Cfg.Server.Root + "/gitbay.db"); err == nil { |
| 325 | 327 | d.DBBytes = fi.Size() |
| 326 | 328 | } |
| 327 | return c.emit(d, func(w io.Writer) { | |
| 329 | return c.emitView(d, func(w io.Writer) { | |
| 328 | 330 | v := c.view(w) |
| 329 | 331 | v.fields( |
| 330 | 332 | "users", fmt.Sprintf("%d", counts.Users), |
| @@ -344,6 +346,30 @@ func runAdminStats(c *Ctx, args []string) int { | ||
| 344 | 346 | } |
| 345 | 347 | tb.flush() |
| 346 | 348 | } |
| 349 | }, func() screen { | |
| 350 | count := func(all, open int64) []cell { | |
| 351 | return []cell{cText(fmt.Sprint(all)), cMeta(fmt.Sprintf("%d open", open))} | |
| 352 | } | |
| 353 | s := screen{fields: []field{ | |
| 354 | {"Users", []cell{cText(fmt.Sprint(counts.Users))}}, | |
| 355 | {"Orgs", []cell{cText(fmt.Sprint(counts.Orgs))}}, | |
| 356 | {"Repos", []cell{cText(fmt.Sprint(counts.Repos))}}, | |
| 357 | {"Issues", count(counts.Issues, counts.OpenIssues)}, | |
| 358 | {"MRs", count(counts.MRs, counts.OpenMRs)}, | |
| 359 | {"Disk", []cell{cSize(d.DBBytes), cMeta("database", "repositories "+humanBytes(d.RepoBytes), "lfs "+humanBytes(d.LFSBytes))}}, | |
| 360 | }} | |
| 361 | repos := slices.Clone(d.Repos) | |
| 362 | slices.SortStableFunc(repos, func(a, b repoDisk) int { return cmp.Compare(b.Bytes, a.Bytes) }) | |
| 363 | top := section{title: "Repositories", n: len(repos), more: []string{"admin", "repo", "list"}} | |
| 364 | for _, r := range repos[:min(20, len(repos))] { | |
| 365 | top.rows = append(top.rows, rowOf(cLink(r.Path, c.siteURL(r.Path)), cSize(r.Bytes))) | |
| 366 | } | |
| 367 | s.sections = []section{top} | |
| 368 | s.actions = []action{ | |
| 369 | {"Admin", []string{"admin", "user", "list"}}, | |
| 370 | {"Admin", []string{"admin", "runners"}}, | |
| 371 | } | |
| 372 | return s | |
| 347 | 373 | }) |
| 348 | 374 | } |
| 349 | 375 | |
internal/control/adminmail.go +20 −2
| @@ -43,8 +43,10 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 43 | 43 | Warning string `json:"warning,omitempty"` |
| 44 | 44 | } |
| 45 | 45 | if !in.Enabled { |
| 46 | return c.emit(out{}, func(w io.Writer) { | |
| 46 | return c.emitView(out{}, func(w io.Writer) { | |
| 47 | 47 | fmt.Fprintln(w, "inbound mail is off ([mail.inbound] enabled = false)") |
| 48 | }, func() screen { | |
| 49 | return screen{fields: []field{{"Inbound", []cell{cText("off")}}}} | |
| 48 | 50 | }) |
| 49 | 51 | } |
| 50 | 52 | cl, n, err := imapc.Open(in, true, 30*time.Second) |
| @@ -62,7 +64,7 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 62 | 64 | d.Warning = unauthenticatedWarning |
| 63 | 65 | fmt.Fprintln(c.Stderr, "warning: "+d.Warning) |
| 64 | 66 | } |
| 65 | return c.emit(d, func(w io.Writer) { | |
| 67 | return c.emitView(d, func(w io.Writer) { | |
| 66 | 68 | c.view(w).fields( |
| 67 | 69 | "server", d.Server, |
| 68 | 70 | "mailbox", d.Mailbox, |
| @@ -71,5 +73,21 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 71 | 73 | "require_dkim", fmt.Sprintf("%t", d.RequireDKIM), |
| 72 | 74 | "trusted_authserv_id", d.TrustedAuthservID, |
| 73 | 75 | ) |
| 76 | }, func() screen { | |
| 77 | auth := cMark("✓ dkim", sgrGreen) | |
| 78 | if d.Warning != "" { | |
| 79 | auth = cMark("✗ "+d.Warning, sgrRed) | |
| 80 | } else if !d.RequireDKIM { | |
| 81 | auth = cMeta("dkim not required") | |
| 82 | } | |
| 83 | s := screen{fields: []field{ | |
| 84 | {"Inbound", []cell{cText(d.Server), cMeta(d.Mailbox)}}, | |
| 85 | {"Messages", []cell{cText(fmt.Sprint(d.Messages)), cMeta(fmt.Sprintf("%d unseen", d.Unseen))}}, | |
| 86 | {"Auth", []cell{auth}}, | |
| 87 | }} | |
| 88 | if d.TrustedAuthservID != "" { | |
| 89 | s.fields = append(s.fields, field{"Authserv", []cell{cText(d.TrustedAuthservID)}}) | |
| 90 | } | |
| 91 | return s | |
| 74 | 92 | }) |
| 75 | 93 | } |
internal/control/audit.go +12 −6
| @@ -54,20 +54,26 @@ func runAudit(c *Ctx, args []string) int { | ||
| 54 | 54 | if err != nil { |
| 55 | 55 | return c.fail(protocol.ExitFailure, "%v", err) |
| 56 | 56 | } |
| 57 | return c.emit(entries, func(w io.Writer) { | |
| 57 | return c.emitView(entries, func(w io.Writer) { | |
| 58 | 58 | tb := c.table(w, "WHEN", "ACTOR", "ACTION", "DATA") |
| 59 | 59 | for _, e := range entries { |
| 60 | 60 | actor := e.Actor |
| 61 | 61 | if actor == "" { |
| 62 | 62 | actor = "-" |
| 63 | 63 | } |
| 64 | data := e.Data | |
| 65 | if c.Term.Cols > 0 { | |
| 66 | data = keyValues(data) | |
| 67 | } | |
| 68 | tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(data)) | |
| 64 | tb.row(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(e.Data)) | |
| 69 | 65 | } |
| 70 | 66 | tb.flush() |
| 67 | }, func() screen { | |
| 68 | rows := make([]row, len(entries)) | |
| 69 | for i, e := range entries { | |
| 70 | actor := e.Actor | |
| 71 | if actor == "" { | |
| 72 | actor = "-" | |
| 73 | } | |
| 74 | rows[i] = rowOf(cAge(e.CreatedAt), cText(actor), cText(e.Action), cFlex(keyValues(e.Data))) | |
| 75 | } | |
| 76 | return listScreen("Audit", rows, action{"Filter", []string{"audit", "--since", "24h"}}) | |
| 71 | 77 | }) |
| 72 | 78 | } |
| 73 | 79 | |
internal/control/build.go +18 −1
| @@ -456,7 +456,7 @@ func runBuildJobs(c *Ctx, args []string) int { | ||
| 456 | 456 | for _, j := range jobs { |
| 457 | 457 | out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) |
| 458 | 458 | } |
| 459 | return c.emit(out, func(w io.Writer) { | |
| 459 | return c.emitView(out, func(w io.Writer) { | |
| 460 | 460 | tb := c.table(w, "NAME", "WHEN") |
| 461 | 461 | for _, j := range out { |
| 462 | 462 | when := "on push" |
| @@ -469,6 +469,23 @@ func runBuildJobs(c *Ctx, args []string) int { | ||
| 469 | 469 | tb.row(cRef(j.Name), cText(when)) |
| 470 | 470 | } |
| 471 | 471 | tb.flush() |
| 472 | }, func() screen { | |
| 473 | rows := make([]row, len(out)) | |
| 474 | for i, j := range out { | |
| 475 | when := "on push" | |
| 476 | switch { | |
| 477 | case j.Schedule != "": | |
| 478 | when = "schedule " + j.Schedule | |
| 479 | case j.Tags != "": | |
| 480 | when = "tags " + j.Tags | |
| 481 | } | |
| 482 | rows[i] = rowOf(cRef(j.Name), cMeta(when)) | |
| 483 | } | |
| 484 | s := listScreen("Jobs", rows) | |
| 485 | if len(out) > 0 { | |
| 486 | s.actions = []action{{"Run", []string{"build", "trigger", repo.Path(), out[0].Name}}} | |
| 487 | } | |
| 488 | return s | |
| 472 | 489 | }) |
| 473 | 490 | } |
| 474 | 491 | |
internal/control/dashboard.go +7 −9
| @@ -357,20 +357,18 @@ func runFeed(c *Ctx, args []string) int { | ||
| 357 | 357 | }) |
| 358 | 358 | ds := feedOutputs(events) |
| 359 | 359 | lines := FeedLines(events) |
| 360 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 361 | if c.Term.Cols > 0 { | |
| 362 | tb := c.table(w, feedHeader...) | |
| 363 | for _, l := range lines { | |
| 364 | tb.row(l.termCells(c)...) | |
| 365 | } | |
| 366 | tb.flush() | |
| 367 | return | |
| 368 | } | |
| 360 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 369 | 361 | tb := c.table(w, "WHEN", "EVENT") |
| 370 | 362 | for _, l := range lines { |
| 371 | 363 | tb.row(cAge(l.When), cFlex(l.Sentence())) |
| 372 | 364 | } |
| 373 | 365 | tb.flush() |
| 366 | }, func() screen { | |
| 367 | rows := make([]row, len(lines)) | |
| 368 | for i, l := range lines { | |
| 369 | rows[i] = rowOf(l.termCells(c)...) | |
| 370 | } | |
| 371 | return listScreen("Activity", rows) | |
| 374 | 372 | }) |
| 375 | 373 | } |
| 376 | 374 | |
internal/control/explore.go +15 −1
| @@ -85,12 +85,26 @@ func runExplore(c *Ctx, args []string) int { | ||
| 85 | 85 | } |
| 86 | 86 | } |
| 87 | 87 | ds, next := trimPage(p, ds, "explore", func(o out) string { return o.Path }) |
| 88 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 88 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 89 | 89 | tb := c.table(w, "PATH", "DESCRIPTION") |
| 90 | 90 | for _, d := range ds { |
| 91 | 91 | tb.row(cRef(d.Path), cFlex(d.Description)) |
| 92 | 92 | } |
| 93 | 93 | tb.flush() |
| 94 | }, func() screen { | |
| 95 | rows := make([]row, len(ds)) | |
| 96 | for i, d := range ds { | |
| 97 | archived := "" | |
| 98 | if d.Archived { | |
| 99 | archived = "archived" | |
| 100 | } | |
| 101 | rows[i] = rowOf(cLink(d.Path, c.siteURL(d.Path)), cFlex(d.Description), cMeta(strings.Join(d.Topics, ", "), archived)) | |
| 102 | } | |
| 103 | s := listScreen("Explore", rows) | |
| 104 | if len(ds) > 0 { | |
| 105 | s.actions = []action{{"Read", []string{"repo", "show", ds[0].Path}}} | |
| 106 | } | |
| 107 | return s | |
| 94 | 108 | }) |
| 95 | 109 | } |
| 96 | 110 | |
internal/control/feedline.go +3 −5
| @@ -42,11 +42,9 @@ func (l FeedLine) Sentence() string { | ||
| 42 | 42 | return s |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | // feedHeader and termCells are a feed line as a terminal table shows | |
| 46 | // it: who, what, where, and how a build came out, so the outcome and the | |
| 47 | // reference can carry their colours. | |
| 48 | var feedHeader = []string{"WHEN", "WHO", "EVENT", "WHERE", "DETAIL"} | |
| 49 | ||
| 45 | // termCells is a feed line as a screen row shows it: when, who, what, | |
| 46 | // where, and how a build came out, so the outcome and the reference can | |
| 47 | // carry their colours. | |
| 50 | 48 | func (l FeedLine) termCells(c *Ctx) []cell { |
| 51 | 49 | ref := l.Ref |
| 52 | 50 | if ref != "" && ref[0] != '#' && ref[0] != '!' { |
internal/control/milestone.go +9 −1
| @@ -293,12 +293,20 @@ func runIssueTemplates(c *Ctx, args []string) int { | ||
| 293 | 293 | } |
| 294 | 294 | dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name) |
| 295 | 295 | ts := IssueTemplates(dir, repo.DefaultBranch) |
| 296 | return c.emit(ts, func(w io.Writer) { | |
| 296 | return c.emitView(ts, func(w io.Writer) { | |
| 297 | 297 | tb := c.table(w, "NAME") |
| 298 | 298 | for _, t := range ts { |
| 299 | 299 | tb.row(cRef(t.Name)) |
| 300 | 300 | } |
| 301 | 301 | tb.flush() |
| 302 | }, func() screen { | |
| 303 | rows := make([]row, len(ts)) | |
| 304 | for i, tpl := range ts { | |
| 305 | rows[i] = rowOf(cRef(tpl.Name)) | |
| 306 | } | |
| 307 | return listScreen("Issue templates", rows, | |
| 308 | action{"New", []string{"issue", "create", repo.Path(), "--title", "<title>"}}, | |
| 309 | ) | |
| 302 | 310 | }) |
| 303 | 311 | } |
| 304 | 312 | |
internal/control/mr.go +17 −1
| @@ -2089,7 +2089,7 @@ func runMRRevisions(c *Ctx, args []string) int { | ||
| 2089 | 2089 | if err != nil { |
| 2090 | 2090 | return c.fail(protocol.ExitFailure, "%v", err) |
| 2091 | 2091 | } |
| 2092 | return c.emit(revs, func(w io.Writer) { | |
| 2092 | return c.emitView(revs, func(w io.Writer) { | |
| 2093 | 2093 | tb := c.table(w, "REV", "SHA", "WHEN") |
| 2094 | 2094 | for _, r := range revs { |
| 2095 | 2095 | mark := " " |
| @@ -2103,6 +2103,22 @@ func runMRRevisions(c *Ctx, args []string) int { | ||
| 2103 | 2103 | fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n", |
| 2104 | 2104 | repo.Path(), mr.Number) |
| 2105 | 2105 | } |
| 2106 | }, func() screen { | |
| 2107 | rows := make([]row, len(revs)) | |
| 2108 | for i, r := range revs { | |
| 2109 | lead := cell{kind: kindGlyph} | |
| 2110 | if r.Current { | |
| 2111 | lead = cell{kind: kindGlyph, s: "●"} | |
| 2112 | } | |
| 2113 | rows[i] = rowOf(cRef(fmt.Sprintf("v%d", r.N)), lead, cRef(fmt.Sprintf("%.10s", r.SHA)), cAge(r.CreatedAt)) | |
| 2114 | } | |
| 2115 | if len(revs) < 2 { | |
| 2116 | fmt.Fprintf(c.Stderr, "only one revision; %s!%d has not been pushed to since it was opened\n", | |
| 2117 | repo.Path(), mr.Number) | |
| 2118 | } | |
| 2119 | return listScreen("Revisions", rows, | |
| 2120 | action{"Compare", []string{"mr", "range-diff", repo.Path(), strconv.FormatInt(mr.Number, 10)}}, | |
| 2121 | ) | |
| 2106 | 2122 | }) |
| 2107 | 2123 | } |
| 2108 | 2124 | |
internal/control/query.go +55 −6
| @@ -193,7 +193,7 @@ func savedQuery(c *Ctx, name string) (store.SavedQuery, ItemQuery, int) { | ||
| 193 | 193 | |
| 194 | 194 | // runItemQuery lists what q matches in the tables issues and mrs allow, |
| 195 | 195 | // one page at a time. The output is always the paged shape. |
| 196 | func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int { | |
| 196 | func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page, title string) int { | |
| 197 | 197 | var after *store.ItemCursor |
| 198 | 198 | if p.key != "" { |
| 199 | 199 | var err error |
| @@ -215,7 +215,7 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int { | ||
| 215 | 215 | next = encodeItemCursor(items[len(items)-1].Cursor()) |
| 216 | 216 | } |
| 217 | 217 | ds := queryItems(items) |
| 218 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 218 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 219 | 219 | tb := c.table(w, "REF", "STATE", "TITLE", "AUTHOR") |
| 220 | 220 | for _, d := range ds { |
| 221 | 221 | state := d.State |
| @@ -225,6 +225,25 @@ func runItemQuery(c *Ctx, q ItemQuery, issues, mrs bool, p page) int { | ||
| 225 | 225 | tb.row(cRef(d.Ref()), cState(state), cFlex(d.Title), cText(d.Author)) |
| 226 | 226 | } |
| 227 | 227 | tb.flush() |
| 228 | }, func() screen { | |
| 229 | rows := make([]row, len(ds)) | |
| 230 | for i, d := range ds { | |
| 231 | state := d.State | |
| 232 | if d.Draft { | |
| 233 | state = "draft" | |
| 234 | } | |
| 235 | page := "issues" | |
| 236 | if d.Kind == "mr" { | |
| 237 | page = "mrs" | |
| 238 | } | |
| 239 | rows[i] = rowOf(cLink(d.Ref(), c.siteURL(d.Repo, page, strconv.FormatInt(d.Number, 10))), cGlyph(state), cFlex(d.Title), | |
| 240 | cMeta(d.Author, d.Milestone, relAge(d.UpdatedAt, termNow()))) | |
| 241 | } | |
| 242 | s := listScreen(title, rows) | |
| 243 | if len(ds) > 0 { | |
| 244 | s.actions = []action{{"Read", []string{ds[0].Kind, "show", ds[0].Repo, strconv.FormatInt(ds[0].Number, 10)}}} | |
| 245 | } | |
| 246 | return s | |
| 228 | 247 | }) |
| 229 | 248 | } |
| 230 | 249 | |
| @@ -273,7 +292,7 @@ func listByQuery(c *Ctx, fl flags, kind string, p page) int { | ||
| 273 | 292 | if kind == "mr" && !mrs { |
| 274 | 293 | return c.usageWith("that query matches only issues; use issue list or query run") |
| 275 | 294 | } |
| 276 | return runItemQuery(c, q, kind == "issue", kind == "mr", p) | |
| 295 | return runItemQuery(c, q, kind == "issue", kind == "mr", p, "Results") | |
| 277 | 296 | } |
| 278 | 297 | |
| 279 | 298 | func savedQueryOut(sq store.SavedQuery) SavedQueryOut { |
| @@ -335,7 +354,7 @@ func runQueryList(c *Ctx, args []string) int { | ||
| 335 | 354 | for _, sq := range saved { |
| 336 | 355 | ds = append(ds, savedQueryOut(sq)) |
| 337 | 356 | } |
| 338 | return c.emit(ds, func(w io.Writer) { | |
| 357 | return c.emitView(ds, func(w io.Writer) { | |
| 339 | 358 | tb := c.table(w, "NAME", "PINNED", "QUERY") |
| 340 | 359 | for _, d := range ds { |
| 341 | 360 | pinned := "" |
| @@ -345,6 +364,23 @@ func runQueryList(c *Ctx, args []string) int { | ||
| 345 | 364 | tb.row(cRef(d.Name), cText(pinned), cFlex(d.Query)) |
| 346 | 365 | } |
| 347 | 366 | tb.flush() |
| 367 | }, func() screen { | |
| 368 | rows := make([]row, len(ds)) | |
| 369 | for i, d := range ds { | |
| 370 | pinned := "" | |
| 371 | if d.Pinned { | |
| 372 | pinned = "pinned" | |
| 373 | } | |
| 374 | rows[i] = rowOf(cRef(d.Name), cFlex(d.Query), cMeta(pinned)) | |
| 375 | } | |
| 376 | s := listScreen("Saved queries", rows) | |
| 377 | if len(ds) > 0 { | |
| 378 | s.actions = []action{ | |
| 379 | {"Queries", []string{"query", "run", ds[0].Name}}, | |
| 380 | {"Queries", []string{"query", "pin", ds[0].Name}}, | |
| 381 | } | |
| 382 | } | |
| 383 | return s | |
| 348 | 384 | }) |
| 349 | 385 | } |
| 350 | 386 | |
| @@ -362,7 +398,7 @@ func runQueryShow(c *Ctx, args []string) int { | ||
| 362 | 398 | } |
| 363 | 399 | d := savedQueryOut(sq) |
| 364 | 400 | d.Count = &n |
| 365 | return c.emit(d, func(w io.Writer) { | |
| 401 | return c.emitView(d, func(w io.Writer) { | |
| 366 | 402 | v := c.view(w) |
| 367 | 403 | v.title(d.Name, "", "") |
| 368 | 404 | pinned := "no" |
| @@ -370,6 +406,19 @@ func runQueryShow(c *Ctx, args []string) int { | ||
| 370 | 406 | pinned = "yes" |
| 371 | 407 | } |
| 372 | 408 | v.fields("query", d.Query, "matches", strconv.Itoa(n), "pinned", pinned) |
| 409 | }, func() screen { | |
| 410 | pin, pinned := "pin", "no" | |
| 411 | if d.Pinned { | |
| 412 | pin, pinned = "unpin", "yes" | |
| 413 | } | |
| 414 | return screen{fields: []field{ | |
| 415 | {"Query", []cell{cRef(d.Name), cText(d.Query)}}, | |
| 416 | {"Matches", []cell{cText(strconv.Itoa(n))}}, | |
| 417 | {"Pinned", []cell{cText(pinned)}}, | |
| 418 | }, actions: []action{ | |
| 419 | {"Queries", []string{"query", "run", d.Name}}, | |
| 420 | {"Queries", []string{"query", pin, d.Name}}, | |
| 421 | }} | |
| 373 | 422 | }) |
| 374 | 423 | } |
| 375 | 424 | |
| @@ -385,7 +434,7 @@ func runQueryRun(c *Ctx, args []string) int { | ||
| 385 | 434 | if code >= 0 { |
| 386 | 435 | return code |
| 387 | 436 | } |
| 388 | return runItemQuery(c, q, true, true, p) | |
| 437 | return runItemQuery(c, q, true, true, p, rest[0]) | |
| 389 | 438 | } |
| 390 | 439 | |
| 391 | 440 | func runQueryRemove(c *Ctx, args []string) int { |
internal/control/release.go +39 −27
| @@ -282,26 +282,7 @@ func runReleaseList(c *Ctx, args []string) int { | ||
| 282 | 282 | for _, r := range rels { |
| 283 | 283 | ds = append(ds, releaseToOut(r, false)) |
| 284 | 284 | } |
| 285 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 286 | if c.Term.Cols > 0 { | |
| 287 | tb := c.table(w, "TAG", "TITLE", "ASSETS", "RELEASED") | |
| 288 | for _, d := range ds { | |
| 289 | // "v1.2.0 — the forge speaks first" reads as its | |
| 290 | // subtitle beside the tag. | |
| 291 | title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ") | |
| 292 | assets := "" | |
| 293 | switch n := len(d.Assets); n { | |
| 294 | case 0: | |
| 295 | case 1: | |
| 296 | assets = "1 asset" | |
| 297 | default: | |
| 298 | assets = fmt.Sprintf("%d assets", n) | |
| 299 | } | |
| 300 | tb.row(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cText(assets), cAge(d.CreatedAt)) | |
| 301 | } | |
| 302 | tb.flush() | |
| 303 | return | |
| 304 | } | |
| 285 | return c.emitPageView(p, ds, next, func(w io.Writer) { | |
| 305 | 286 | tb := c.table(w, "TAG", "TITLE", "ASSETS") |
| 306 | 287 | for _, d := range ds { |
| 307 | 288 | title := d.Title |
| @@ -311,16 +292,37 @@ func runReleaseList(c *Ctx, args []string) int { | ||
| 311 | 292 | tb.row(cRef(d.Tag), cFlex(title), cText(fmt.Sprintf("%d asset(s)", len(d.Assets)))) |
| 312 | 293 | } |
| 313 | 294 | tb.flush() |
| 295 | }, func() screen { | |
| 296 | rows := make([]row, len(ds)) | |
| 297 | for i, d := range ds { | |
| 298 | // "v1.2.0 — the forge speaks first" reads as its subtitle | |
| 299 | // beside the tag. | |
| 300 | title := strings.TrimPrefix(strings.TrimPrefix(d.Title, d.Tag), " — ") | |
| 301 | assets := "" | |
| 302 | switch n := len(d.Assets); n { | |
| 303 | case 0: | |
| 304 | case 1: | |
| 305 | assets = "1 asset" | |
| 306 | default: | |
| 307 | assets = fmt.Sprintf("%d assets", n) | |
| 308 | } | |
| 309 | rows[i] = rowOf(cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cFlex(title), cMeta(assets, relAge(d.CreatedAt, termNow()))) | |
| 310 | } | |
| 311 | s := listScreen("Releases", rows) | |
| 312 | if len(ds) > 0 { | |
| 313 | s.actions = []action{{"Read", []string{"release", "show", repo.Path(), ds[0].Tag}}} | |
| 314 | } | |
| 315 | return s | |
| 314 | 316 | }) |
| 315 | 317 | } |
| 316 | 318 | |
| 317 | 319 | func runReleaseShow(c *Ctx, args []string) int { |
| 318 | _, rel, code := releaseRef(c, args, policy.CanRead) | |
| 320 | repo, rel, code := releaseRef(c, args, policy.CanRead) | |
| 319 | 321 | if code >= 0 { |
| 320 | 322 | return code |
| 321 | 323 | } |
| 322 | 324 | d := releaseToOut(rel, true) |
| 323 | return c.emit(d, func(w io.Writer) { | |
| 325 | return c.emitView(d, func(w io.Writer) { | |
| 324 | 326 | title := d.Title |
| 325 | 327 | if title == d.Tag { |
| 326 | 328 | title = "" |
| @@ -335,14 +337,24 @@ func runReleaseShow(c *Ctx, args []string) int { | ||
| 335 | 337 | v.section("assets") |
| 336 | 338 | tb := c.table(w, "NAME", "SIZE", "SHA256") |
| 337 | 339 | for _, a := range d.Assets { |
| 338 | sum := a.SHA256 | |
| 339 | if c.Term.Cols > 0 { | |
| 340 | sum = sum[:min(12, len(sum))] | |
| 341 | } | |
| 342 | tb.row(cRef(a.Name), cSize(a.Size), cFlex(sum)) | |
| 340 | tb.row(cRef(a.Name), cSize(a.Size), cFlex(a.SHA256)) | |
| 343 | 341 | } |
| 344 | 342 | tb.flush() |
| 345 | 343 | } |
| 344 | }, func() screen { | |
| 345 | assets := section{title: "Assets", n: len(d.Assets)} | |
| 346 | for _, a := range d.Assets { | |
| 347 | assets.rows = append(assets.rows, rowOf(cRef(a.Name), cSize(a.Size), cMeta(a.SHA256[:min(12, len(a.SHA256))]))) | |
| 348 | } | |
| 349 | s := screen{body: d.Notes, format: d.NotesFormat, sections: []section{assets}, fields: []field{ | |
| 350 | {"Release", []cell{cLink(d.Tag, c.siteURL(repo.Path(), "releases")), cText(d.Title)}}, | |
| 351 | {"Author", []cell{cText(d.Author), cAge(d.CreatedAt)}}, | |
| 352 | }} | |
| 353 | if len(d.Assets) > 0 { | |
| 354 | s.actions = append(s.actions, action{"Get", []string{"release", "asset", "get", repo.Path(), d.Tag, d.Assets[0].Name}}) | |
| 355 | } | |
| 356 | s.actions = append(s.actions, action{"Edit", []string{"release", "edit", repo.Path(), d.Tag, "--title", "<title>"}}) | |
| 357 | return s | |
| 346 | 358 | }) |
| 347 | 359 | } |
| 348 | 360 | |
internal/control/search.go +30 −6
| @@ -105,7 +105,9 @@ func runSearch(c *Ctx, args []string) int { | ||
| 105 | 105 | if err != nil { |
| 106 | 106 | return c.fail(protocol.ExitFailure, "%v", err) |
| 107 | 107 | } |
| 108 | return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) }) | |
| 108 | return c.emitView(results, func(w io.Writer) { writeSearchTable(c, w, results) }, func() screen { | |
| 109 | return searchScreen(c, f.Pos[0], results) | |
| 110 | }) | |
| 109 | 111 | } |
| 110 | 112 | |
| 111 | 113 | // writeSearchTable renders search results: a repo hit has no ref number or |
| @@ -118,11 +120,7 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) { | ||
| 118 | 120 | for _, r := range results { |
| 119 | 121 | switch r.Kind { |
| 120 | 122 | case "repo": |
| 121 | if c.Term.Cols > 0 { | |
| 122 | tb.row(cText("repo"), cLink(r.Repo, c.siteURL(r.Repo)), cState(""), cFlex(r.Title)) | |
| 123 | } else { | |
| 124 | tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title)) | |
| 125 | } | |
| 123 | tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title)) | |
| 126 | 124 | default: |
| 127 | 125 | page := "issues" |
| 128 | 126 | if r.Kind == "mr" { |
| @@ -134,6 +132,32 @@ func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) { | ||
| 134 | 132 | tb.flush() |
| 135 | 133 | } |
| 136 | 134 | |
| 135 | // searchScreen is search at a terminal: a section per kind of result. | |
| 136 | func searchScreen(c *Ctx, query string, results []SearchResult) screen { | |
| 137 | repos := section{title: "Repositories"} | |
| 138 | issues := section{title: "Issues"} | |
| 139 | mrs := section{title: "Merge requests"} | |
| 140 | for _, r := range results { | |
| 141 | switch r.Kind { | |
| 142 | case "repo": | |
| 143 | repos.n++ | |
| 144 | repos.rows = append(repos.rows, rowOf(cLink(r.Repo, c.siteURL(r.Repo)), cFlex(r.Title))) | |
| 145 | default: | |
| 146 | sec, page := &issues, "issues" | |
| 147 | if r.Kind == "mr" { | |
| 148 | sec, page = &mrs, "mrs" | |
| 149 | } | |
| 150 | sec.n++ | |
| 151 | ref := cLink(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number), c.siteURL(r.Repo, page, strconv.FormatInt(r.Number, 10))) | |
| 152 | sec.rows = append(sec.rows, rowOf(ref, cGlyph(r.State), cFlex(r.Title), cMeta(r.Author))) | |
| 153 | } | |
| 154 | } | |
| 155 | return screen{sections: []section{repos, issues, mrs}, actions: []action{ | |
| 156 | {"Narrow", []string{"search", query, "--kind", "issue"}}, | |
| 157 | {"Narrow", []string{"search", query, "--kind", "mr"}}, | |
| 158 | }} | |
| 159 | } | |
| 160 | ||
| 137 | 161 | // SearchMarker is the sigil a result's number carries, shared with the web |
| 138 | 162 | // so a hit reads the same in both places. |
| 139 | 163 | func SearchMarker(kind string) string { |
internal/control/search_test.go −27
| @@ -2,36 +2,9 @@ package control | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | "strings" | |
| 6 | 5 | "testing" |
| 7 | 6 | ) |
| 8 | 7 | |
| 9 | // A repo hit has no state, an issue/mr hit does. At a terminal both | |
| 10 | // titles must start in the same column, not have the repo's title slide | |
| 11 | // left into the state column. | |
| 12 | func TestSearchTableAlignsTitleAtTerminal(t *testing.T) { | |
| 13 | results := []SearchResult{ | |
| 14 | {Kind: "repo", Repo: "alice/webapp", Title: "a web application"}, | |
| 15 | {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"}, | |
| 16 | } | |
| 17 | var b bytes.Buffer | |
| 18 | writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results) | |
| 19 | ||
| 20 | lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n") | |
| 21 | if len(lines) != 2 { | |
| 22 | t.Fatalf("want 2 rows, got %d lines:\n%s", len(lines), b.String()) | |
| 23 | } | |
| 24 | repoRow, issueRow := lines[0], lines[1] | |
| 25 | repoTitleAt := strings.Index(repoRow, "a web application") | |
| 26 | issueTitleAt := strings.Index(issueRow, "memory leak") | |
| 27 | if repoTitleAt < 0 || issueTitleAt < 0 { | |
| 28 | t.Fatalf("columns not found:\n%s", b.String()) | |
| 29 | } | |
| 30 | if repoTitleAt != issueTitleAt { | |
| 31 | t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String()) | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | 8 | // Plain output has no header to align to, so a repo row stays 3 cells — |
| 36 | 9 | // bytes must not change from before the terminal fix. |
| 37 | 10 | func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) { |
internal/control/snippet.go +32 −2
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "fmt" |
| 8 | 8 | "io" |
| 9 | 9 | "strconv" |
| 10 | "strings" | |
| 10 | 11 | "unicode/utf8" |
| 11 | 12 | |
| 12 | 13 | "gitbay.org/gitbay/internal/policy" |
| @@ -222,7 +223,7 @@ func runSnippetShow(c *Ctx, args []string) int { | ||
| 222 | 223 | return c.fail(protocol.ExitFailure, "%v", err) |
| 223 | 224 | } |
| 224 | 225 | sn.Files = files |
| 225 | return c.emit(snippetOut(c, sn), func(w io.Writer) { | |
| 226 | return c.emitView(snippetOut(c, sn), func(w io.Writer) { | |
| 226 | 227 | v := c.view(w) |
| 227 | 228 | v.title(sn.PublicID, sn.Description, sn.Visibility) |
| 228 | 229 | v.fields( |
| @@ -238,6 +239,21 @@ func runSnippetShow(c *Ctx, args []string) int { | ||
| 238 | 239 | } |
| 239 | 240 | tb.flush() |
| 240 | 241 | } |
| 242 | }, func() screen { | |
| 243 | fs := section{title: "Files", n: len(files)} | |
| 244 | for _, f := range files { | |
| 245 | fs.rows = append(fs.rows, rowOf(cRef(f.Name), cSize(int64(f.Size)))) | |
| 246 | } | |
| 247 | s := screen{sections: []section{fs}, fields: []field{ | |
| 248 | {"Snippet", []cell{cLink(sn.PublicID, snippetURL(c, sn)), cText(sn.Description)}}, | |
| 249 | {"Owner", []cell{cText(sn.OwnerName), cState(sn.Visibility)}}, | |
| 250 | {"Updated", []cell{cAge(sn.UpdatedAt)}}, | |
| 251 | }} | |
| 252 | if len(files) > 0 { | |
| 253 | s.actions = append(s.actions, action{"Get", []string{"snippet", "file", "get", sn.PublicID, files[0].Name}}) | |
| 254 | } | |
| 255 | s.actions = append(s.actions, action{"Edit", []string{"snippet", "edit", sn.PublicID, "--description", "<text>"}}) | |
| 256 | return s | |
| 241 | 257 | }) |
| 242 | 258 | } |
| 243 | 259 | |
| @@ -270,7 +286,7 @@ func runSnippetList(c *Ctx, args []string) int { | ||
| 270 | 286 | for _, sn := range rows { |
| 271 | 287 | items = append(items, snippetOut(c, sn)) |
| 272 | 288 | } |
| 273 | return c.emitPage(p, items, next, func(w io.Writer) { | |
| 289 | return c.emitPageView(p, items, next, func(w io.Writer) { | |
| 274 | 290 | tb := c.table(w, "ID", "VISIBILITY", "FILES", "DESCRIPTION") |
| 275 | 291 | for _, sn := range rows { |
| 276 | 292 | names := "" |
| @@ -283,6 +299,20 @@ func runSnippetList(c *Ctx, args []string) int { | ||
| 283 | 299 | tb.row(cRef(sn.PublicID), cState(sn.Visibility), cText(names), cFlex(sn.Description)) |
| 284 | 300 | } |
| 285 | 301 | tb.flush() |
| 302 | }, func() screen { | |
| 303 | rs := make([]row, len(rows)) | |
| 304 | for i, sn := range rows { | |
| 305 | names := make([]string, len(sn.Files)) | |
| 306 | for j, f := range sn.Files { | |
| 307 | names[j] = f.Name | |
| 308 | } | |
| 309 | rs[i] = rowOf(cRef(sn.PublicID), cState(sn.Visibility), cFlex(sn.Description), cMeta(strings.Join(names, ", "), relAge(sn.UpdatedAt, termNow()))) | |
| 310 | } | |
| 311 | s := listScreen("Snippets", rs) | |
| 312 | if len(rows) > 0 { | |
| 313 | s.actions = []action{{"Read", []string{"snippet", "show", rows[0].PublicID}}} | |
| 314 | } | |
| 315 | return s | |
| 286 | 316 | }) |
| 287 | 317 | } |
| 288 | 318 | |
internal/control/stage3admin_test.go added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "regexp" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/store" | |
| 9 | ) | |
| 10 | ||
| 11 | var diskBytes = regexp.MustCompile(`\t\d{4,}\t|\d+\.\d KiB`) | |
| 12 | ||
| 13 | // adminFixture is the work fixture with alice as an instance admin, a | |
| 14 | // second account, and a closed merge request to prune. | |
| 15 | func adminFixture(t *testing.T) (*store.Store, store.Repo, store.User, string) { | |
| 16 | t.Helper() | |
| 17 | st, repo, u, root, _ := workFixture(t) | |
| 18 | if err := st.SetUserAdmin(u.ID, true); err != nil { | |
| 19 | t.Fatal(err) | |
| 20 | } | |
| 21 | u.IsAdmin = true | |
| 22 | dispatchIn(t, st, u, root, "", "admin", "user", "create", "bob") | |
| 23 | dispatchIn(t, st, u, root, "", "mr", "close", repo.Path(), "1") | |
| 24 | return st, repo, u, root | |
| 25 | } | |
| 26 | ||
| 27 | func TestAdminPlainPinned(t *testing.T) { | |
| 28 | st, repo, u, root := adminFixture(t) | |
| 29 | for name, argv := range map[string][]string{ | |
| 30 | "admin-user-list": {"admin", "user", "list"}, | |
| 31 | "admin-user-show": {"admin", "user", "show", "alice"}, | |
| 32 | "admin-runners": {"admin", "runners"}, | |
| 33 | "admin-repo-list": {"admin", "repo", "list"}, | |
| 34 | "admin-stats": {"admin", "stats"}, | |
| 35 | "admin-mail-inbound-check": {"admin", "mail", "inbound", "check"}, | |
| 36 | } { | |
| 37 | got := dispatchIn(t, st, u, root, "", argv...) | |
| 38 | // A repository's size on disk moves by a few bytes between runs. | |
| 39 | got = diskBytes.ReplaceAllString(got, "<size>") | |
| 40 | pinPlain(t, name, got) | |
| 41 | } | |
| 42 | pinPlain(t, "admin-mr-prune", dispatchIn(t, st, u, root, "", "admin", "mr", "prune", repo.Path(), "1", "--yes")) | |
| 43 | } | |
| 44 | ||
| 45 | func TestAdminScreens(t *testing.T) { | |
| 46 | st, repo, u, root := adminFixture(t) | |
| 47 | for _, tc := range []struct { | |
| 48 | argv []string | |
| 49 | want []string | |
| 50 | }{ | |
| 51 | {[]string{"admin", "user", "list"}, []string{"Accounts (3)\n", "alice", "bob", "admin"}}, | |
| 52 | {[]string{"admin", "user", "show", "alice"}, []string{"User:", "alice", "active", "gitbay admin user demote alice"}}, | |
| 53 | {[]string{"admin", "runners"}, []string{"Queue:", "0 pending"}}, | |
| 54 | {[]string{"admin", "repo", "list"}, []string{"Repositories (1)\n", "alice/app"}}, | |
| 55 | {[]string{"admin", "stats"}, []string{"Users:", "2", "Repos:", "Disk:"}}, | |
| 56 | {[]string{"admin", "mail", "inbound", "check"}, []string{"Inbound: off\n"}}, | |
| 57 | {[]string{"admin", "mr", "prune", repo.Path(), "1", "--yes"}, []string{"Pruned (1)\n", "!1", "already gone"}}, | |
| 58 | } { | |
| 59 | out := atTerminalIn(t, st, u, root, tc.argv...) | |
| 60 | for _, w := range tc.want { | |
| 61 | if !strings.Contains(out, w) { | |
| 62 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 63 | } | |
| 64 | } | |
| 65 | if strings.Contains(tc.argv[1], "mail") || tc.argv[1] == "runners" { | |
| 66 | continue | |
| 67 | } | |
| 68 | checkLegend(t, out) | |
| 69 | } | |
| 70 | } | |
internal/control/stage3repo_test.go +4
| @@ -30,6 +30,8 @@ func dispatchIn(t *testing.T, st *store.Store, u store.User, root, stdin string, | ||
| 30 | 30 | // Fixtures write a lot as user 1; the write limiter is global to |
| 31 | 31 | // the package, so leave its budget to the tests that exercise it. |
| 32 | 32 | c.Cfg.Limits.WriteRate = -1 |
| 33 | c.Cfg.Limits.MaxSnippetBytes = 1 << 20 | |
| 34 | c.Cfg.Limits.MaxBlobBytes = 1 << 20 | |
| 33 | 35 | if code := Dispatch(c, argv); code != protocol.ExitOK { |
| 34 | 36 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) |
| 35 | 37 | } |
| @@ -52,6 +54,8 @@ func atTerminalIn(t *testing.T, st *store.Store, u store.User, root string, argv | ||
| 52 | 54 | // Fixtures write a lot as user 1; the write limiter is global to |
| 53 | 55 | // the package, so leave its budget to the tests that exercise it. |
| 54 | 56 | c.Cfg.Limits.WriteRate = -1 |
| 57 | c.Cfg.Limits.MaxSnippetBytes = 1 << 20 | |
| 58 | c.Cfg.Limits.MaxBlobBytes = 1 << 20 | |
| 55 | 59 | if code := Dispatch(c, argv); code != protocol.ExitOK { |
| 56 | 60 | t.Fatalf("%v: exit %d: %s", argv, code, errOut) |
| 57 | 61 | } |
internal/control/stage3work_test.go added +168
| @@ -0,0 +1,168 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "os/exec" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/store" | |
| 11 | ) | |
| 12 | ||
| 13 | // workFixture is a repository on disk with a CI job and an issue | |
| 14 | // template, an issue, a merge request, a commit status and a saved | |
| 15 | // query, all as alice. It returns the head sha. | |
| 16 | func workFixture(t *testing.T) (*store.Store, store.Repo, store.User, string, string) { | |
| 17 | t.Helper() | |
| 18 | st, repo, uid := newQueueTestRepo(t) | |
| 19 | u := store.User{ID: uid, Username: "alice"} | |
| 20 | root := t.TempDir() | |
| 21 | env := append(gitTestEnv(), "GIT_AUTHOR_DATE=2026-09-01T10:00:00Z", "GIT_COMMITTER_DATE=2026-09-01T10:00:00Z") | |
| 22 | git := func(dir string, args ...string) string { | |
| 23 | t.Helper() | |
| 24 | cmd := exec.Command("git", args...) | |
| 25 | cmd.Dir, cmd.Env = dir, env | |
| 26 | out, err := cmd.CombinedOutput() | |
| 27 | if err != nil { | |
| 28 | t.Fatalf("git %v: %v\n%s", args, err, out) | |
| 29 | } | |
| 30 | return strings.TrimSpace(string(out)) | |
| 31 | } | |
| 32 | src := filepath.Join(root, "src") | |
| 33 | if err := os.MkdirAll(filepath.Join(src, ".gitbay"), 0o755); err != nil { | |
| 34 | t.Fatal(err) | |
| 35 | } | |
| 36 | os.WriteFile(filepath.Join(src, ".gitbay", "ci.yml"), []byte("jobs:\n test:\n steps:\n - go test ./...\n"), 0o644) | |
| 37 | os.WriteFile(filepath.Join(src, ".gitbay", "issue-template-bug.md"), []byte("## Steps\n"), 0o644) | |
| 38 | os.MkdirAll(filepath.Join(src, ".gitbay", "wiki"), 0o755) | |
| 39 | os.WriteFile(filepath.Join(src, ".gitbay", "wiki", "Home.md"), []byte("# Welcome\n\nStart here.\n"), 0o644) | |
| 40 | git(root, "init", "-q", "-b", "main", "src") | |
| 41 | git(src, "add", ".") | |
| 42 | git(src, "commit", "-q", "-m", "first") | |
| 43 | sha := git(src, "rev-parse", "HEAD") | |
| 44 | git(src, "tag", "v1.0.0") | |
| 45 | dir := RepoDir(root, repo.OwnerName, repo.Name) | |
| 46 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 47 | git(root, "clone", "-q", "--bare", src, dir) | |
| 48 | ||
| 49 | p := repo.Path() | |
| 50 | dispatchIn(t, st, u, root, "", "issue", "create", p, "--title", "Crash on start", "--body", "It crashes.") | |
| 51 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "fix", "main", "Fix crash", "", sha, "md", false); err != nil { | |
| 52 | t.Fatal(err) | |
| 53 | } | |
| 54 | dispatchIn(t, st, u, root, "", "status", "set", p, sha, "--context", "ext/test", "--state", "success", "--description", "all green") | |
| 55 | dispatchIn(t, st, u, root, "", "query", "save", "open", "is:open") | |
| 56 | return st, repo, u, root, sha | |
| 57 | } | |
| 58 | ||
| 59 | func TestWorkPlainPinnedA(t *testing.T) { | |
| 60 | st, repo, u, root, sha := workFixture(t) | |
| 61 | p := repo.Path() | |
| 62 | for name, argv := range map[string][]string{ | |
| 63 | "query-run": {"query", "run", "open"}, | |
| 64 | "issue-list-q": {"issue", "list", "--q", "repo:alice/app is:open"}, | |
| 65 | "query-list": {"query", "list"}, | |
| 66 | "query-show": {"query", "show", "open"}, | |
| 67 | "search": {"search", "crash"}, | |
| 68 | "feed": {"feed"}, | |
| 69 | "mr-revisions": {"mr", "revisions", p, "1"}, | |
| 70 | "issue-templates": {"issue", "templates", p}, | |
| 71 | "build-jobs": {"build", "jobs", p}, | |
| 72 | "status-list": {"status", "list", p, sha}, | |
| 73 | } { | |
| 74 | pinPlain(t, name, dispatchIn(t, st, u, root, "", argv...)) | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | func TestWorkScreensA(t *testing.T) { | |
| 79 | st, repo, u, root, sha := workFixture(t) | |
| 80 | p := repo.Path() | |
| 81 | for _, tc := range []struct { | |
| 82 | argv []string | |
| 83 | want []string | |
| 84 | }{ | |
| 85 | {[]string{"query", "run", "open"}, []string{"open (2)\n", "alice/app#1", "Crash on start", "alice/app!1"}}, | |
| 86 | {[]string{"issue", "list", "--q", "repo:alice/app is:open"}, []string{"Results (1)\n", "Crash on start"}}, | |
| 87 | {[]string{"query", "list"}, []string{"Saved queries (1)\n", "open is:open"}}, | |
| 88 | {[]string{"query", "show", "open"}, []string{"Query:", "is:open", "Matches:", "2"}}, | |
| 89 | {[]string{"search", "crash"}, []string{"Issues (1)\n", "Merge requests (1)\n", "Crash on start"}}, | |
| 90 | {[]string{"feed"}, []string{"Activity ("}}, | |
| 91 | {[]string{"mr", "revisions", p, "1"}, []string{"Revisions (1)\n", "v1"}}, | |
| 92 | {[]string{"issue", "templates", p}, []string{"Issue templates (1)\nissue-template-bug.md"}}, | |
| 93 | {[]string{"build", "jobs", p}, []string{"Jobs (1)\ntest on push\n"}}, | |
| 94 | {[]string{"status", "list", p, sha}, []string{"Commit:", "Combined:", "✓", "Statuses (1)\n", "ext/test all green"}}, | |
| 95 | } { | |
| 96 | out := atTerminalIn(t, st, u, root, tc.argv...) | |
| 97 | for _, w := range tc.want { | |
| 98 | if !strings.Contains(out, w) { | |
| 99 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 100 | } | |
| 101 | } | |
| 102 | if tc.argv[0] != "feed" { | |
| 103 | checkLegend(t, out) | |
| 104 | } | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | func TestWorkPlainPinnedB(t *testing.T) { | |
| 109 | st, repo, u, root, _ := workFixture(t) | |
| 110 | p := repo.Path() | |
| 111 | dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.") | |
| 112 | dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello") | |
| 113 | snippets := dispatchIn(t, st, u, root, "", "snippet", "list") | |
| 114 | id, _, _ := strings.Cut(snippets, "\t") | |
| 115 | for name, argv := range map[string][]string{ | |
| 116 | "release-list": {"release", "list", p}, | |
| 117 | "release-show": {"release", "show", p, "v1.0.0"}, | |
| 118 | "snippet-list": {"snippet", "list"}, | |
| 119 | "snippet-show": {"snippet", "show", id}, | |
| 120 | "wiki-list": {"wiki", "list", p}, | |
| 121 | "wiki-show": {"wiki", "show", p, "Home"}, | |
| 122 | "explore": {"explore"}, | |
| 123 | } { | |
| 124 | got := dispatchIn(t, st, u, root, "", argv...) | |
| 125 | pinPlain(t, name, strings.ReplaceAll(got, id, "<id>")) | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | func TestWorkScreensB(t *testing.T) { | |
| 130 | st, repo, u, root, _ := workFixture(t) | |
| 131 | p := repo.Path() | |
| 132 | dispatchIn(t, st, u, root, "", "release", "create", p, "v1.0.0", "--title", "v1.0.0 — first", "--notes", "First release.") | |
| 133 | dispatchIn(t, st, u, root, "package main\n", "snippet", "create", "main.go", "--description", "Hello") | |
| 134 | snippets := dispatchIn(t, st, u, root, "", "snippet", "list") | |
| 135 | id, _, _ := strings.Cut(snippets, "\t") | |
| 136 | for _, tc := range []struct { | |
| 137 | argv []string | |
| 138 | want []string | |
| 139 | }{ | |
| 140 | {[]string{"release", "list", p}, []string{"Releases (1)\n", "v1.0.0 first"}}, | |
| 141 | {[]string{"release", "show", p, "v1.0.0"}, []string{"Release:", "v1.0.0 v1.0.0 — first", "First release."}}, | |
| 142 | {[]string{"snippet", "list"}, []string{"Snippets (1)\n", "Hello", "main.go"}}, | |
| 143 | {[]string{"snippet", "show", id}, []string{"Snippet:", "Hello", "Files (1)\nmain.go"}}, | |
| 144 | {[]string{"wiki", "list", p}, []string{"Wiki (1)\nHome", "home"}}, | |
| 145 | {[]string{"wiki", "show", p, "Home"}, []string{"Page:", "Home", "Welcome", "Start here."}}, | |
| 146 | {[]string{"explore"}, []string{"Explore (1)\nalice/app"}}, | |
| 147 | } { | |
| 148 | out := atTerminalIn(t, st, u, root, tc.argv...) | |
| 149 | for _, w := range tc.want { | |
| 150 | if !strings.Contains(out, w) { | |
| 151 | t.Errorf("%v: missing %q in:\n%s", tc.argv, w, out) | |
| 152 | } | |
| 153 | } | |
| 154 | checkLegend(t, out) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | func TestAuditScreen(t *testing.T) { | |
| 159 | st, _, uid := newQueueTestRepo(t) | |
| 160 | admin := store.User{ID: uid, Username: "alice", IsAdmin: true} | |
| 161 | dispatchAs(t, st, admin, "", "admin", "user", "create", "bob") | |
| 162 | pinPlain(t, "audit", dispatchAs(t, st, admin, "", "audit")) | |
| 163 | out := atTerminal(t, st, admin, "audit") | |
| 164 | if !strings.Contains(out, "Audit (") || !strings.Contains(out, "alice") { | |
| 165 | t.Errorf("audit:\n%s", out) | |
| 166 | } | |
| 167 | checkLegend(t, out) | |
| 168 | } | |
internal/control/status.go +13 −1
| @@ -137,13 +137,25 @@ func runStatusList(c *Ctx, args []string) int { | ||
| 137 | 137 | Combined string `json:"combined"` |
| 138 | 138 | Statuses []out `json:"statuses"` |
| 139 | 139 | }{full, combinedOf(statuses), ds} |
| 140 | return c.emit(d, func(w io.Writer) { | |
| 140 | return c.emitView(d, func(w io.Writer) { | |
| 141 | 141 | tb := c.table(w, "CONTEXT", "STATE", "DESCRIPTION") |
| 142 | 142 | tb.row(cText("combined"), cState(orNone(d.Combined)), cText(fmt.Sprintf("%.10s", d.SHA))) |
| 143 | 143 | for _, x := range ds { |
| 144 | 144 | tb.row(cText(x.Context), cState(x.State), cFlex(x.Description)) |
| 145 | 145 | } |
| 146 | 146 | tb.flush() |
| 147 | }, func() screen { | |
| 148 | combined := []cell{cGlyph(d.Combined), cState(orNone(d.Combined))} | |
| 149 | statuses := section{title: "Statuses", n: len(ds)} | |
| 150 | for _, x := range ds { | |
| 151 | statuses.rows = append(statuses.rows, rowOf(cGlyph(x.State), cText(x.Context), cFlex(x.Description), cMeta(x.Creator))) | |
| 152 | } | |
| 153 | return screen{fields: []field{ | |
| 154 | {"Commit", []cell{cRef(fmt.Sprintf("%.10s", d.SHA))}}, | |
| 155 | {"Combined", combined}, | |
| 156 | }, sections: []section{statuses}, actions: []action{ | |
| 157 | {"Read", []string{"repo", "commit", repo.Path(), fmt.Sprintf("%.10s", d.SHA)}}, | |
| 158 | }} | |
| 147 | 159 | }) |
| 148 | 160 | } |
| 149 | 161 | |
internal/control/term.go −10
| @@ -194,16 +194,6 @@ func (t Term) paintState(s string) string { | ||
| 194 | 194 | return strings.Join(words, ", ") |
| 195 | 195 | } |
| 196 | 196 | |
| 197 | // heading is a section label at a terminal: capitalised, no trailing | |
| 198 | // colon, bold. | |
| 199 | func (t Term) heading(label string) string { | |
| 200 | label = strings.TrimSuffix(label, ":") | |
| 201 | if r, size := utf8.DecodeRuneInString(label); size > 0 { | |
| 202 | label = string(unicode.ToUpper(r)) + label[size:] | |
| 203 | } | |
| 204 | return t.paint(sgrBold, label) | |
| 205 | } | |
| 206 | ||
| 207 | 197 | // failure is a refusal as a terminal shows it: "error: " in red ahead |
| 208 | 198 | // of the message, and a usage line wrapped to the width between its |
| 209 | 199 | // bracketed groups, continuation lines indented under the command. |
internal/control/term_test.go −6
| @@ -117,12 +117,6 @@ func TestPaintStateEachWord(t *testing.T) { | ||
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | 119 | |
| 120 | func TestHeading(t *testing.T) { | |
| 121 | if got := (Term{Cols: 80}).heading("waiting on your review:"); got != "Waiting on your review" { | |
| 122 | t.Errorf("heading = %q", got) | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | 120 | func TestFailureAtTerminal(t *testing.T) { |
| 127 | 121 | msg := "unknown flag \"--stat\"; did you mean --state?\nusage: gitbay issue list [<owner/name>] [--state open|closed|all] [--label <l>] [--assignee <user>]" |
| 128 | 122 | got := Term{Cols: 50}.failure(msg) |
internal/control/testdata/plain/admin-mail-inbound-check.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | inbound mail is off ([mail.inbound] enabled = false) | |
internal/control/testdata/plain/admin-mr-prune.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | !1 already gone | |
internal/control/testdata/plain/admin-repo-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app public<size><time> | |
internal/control/testdata/plain/admin-runners.txt added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | pending 0 | |
| 2 | claimed 24h 0 | |
| 3 | wait avg 0s | |
| 4 | wait max 0s | |
| 5 | reaped 24h 0 | |
internal/control/testdata/plain/admin-stats.txt added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | users 3 | |
| 2 | orgs 0 | |
| 3 | repos 1 | |
| 4 | issues 1 (1 open) | |
| 5 | MRs 1 (0 open) | |
| 6 | database 0 B | |
| 7 | repositories <size> | |
| 8 | lfs 0 B | |
| 9 | ||
| 10 | repos: | |
| 11 | alice/app <size> | |
internal/control/testdata/plain/admin-user-list.txt added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | alice active admin <time> | |
| 2 | bob active <time> | |
| 3 | gitbay-bot active <time> | |
internal/control/testdata/plain/admin-user-show.txt added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | alice active | |
| 2 | ||
| 3 | admin yes | |
| 4 | created <time> | |
| 5 | repos 1 | |
| 6 | web sessions 0 | |
internal/control/testdata/plain/audit.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | <time> alice cmd admin user create {"argv":["bob"],"source":""} | |
| 2 | <time> alice admin user.created {"user":"bob"} | |
internal/control/testdata/plain/build-jobs.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | test on push | |
internal/control/testdata/plain/explore.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app | |
internal/control/testdata/plain/feed.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | <time> alice status alice/app | |
| 2 | <time> alice opened issue alice/app#1 | |
internal/control/testdata/plain/issue-list-q.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | alice/app#1 open Crash on start alice | |
internal/control/testdata/plain/issue-templates.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | issue-template-bug.md | |
internal/control/testdata/plain/mr-revisions.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | * v1 a59131b356 <time> | |
internal/control/testdata/plain/query-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | open is:open | |
internal/control/testdata/plain/query-run.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | alice/app!1 open Fix crash alice | |
| 2 | alice/app#1 open Crash on start alice | |
internal/control/testdata/plain/query-show.txt added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | open | |
| 2 | ||
| 3 | query is:open | |
| 4 | matches 2 | |
| 5 | pinned no | |
internal/control/testdata/plain/release-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | v1.0.0 v1.0.0 — first 0 asset(s) | |
internal/control/testdata/plain/release-show.txt added +5
| @@ -0,0 +1,5 @@ | ||
| 1 | v1.0.0 v1.0.0 — first | |
| 2 | ||
| 3 | author alice, <time> | |
| 4 | ||
| 5 | First release. | |
internal/control/testdata/plain/search.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | issue alice/app#1 open Crash on start | |
| 2 | mr alice/app!1 open Fix crash | |
internal/control/testdata/plain/snippet-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | <id> unlisted main.go Hello | |
internal/control/testdata/plain/snippet-show.txt added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | <id> Hello unlisted | |
| 2 | ||
| 3 | author alice | |
| 4 | updated <time> | |
| 5 | url /alice/-/snippets/<id> | |
| 6 | ||
| 7 | files: | |
| 8 | main.go 13 bytes | |
internal/control/testdata/plain/status-list.txt added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | combined success a59131b356 | |
| 2 | ext/test success all green | |
internal/control/testdata/plain/wiki-list.txt added +1
| @@ -0,0 +1 @@ | ||
| 1 | Home | |
internal/control/testdata/plain/wiki-show.txt added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | # Welcome | |
| 2 | ||
| 3 | Start here. | |
internal/control/view.go +23 −97
| @@ -27,8 +27,10 @@ func (c *Ctx) siteURL(parts ...string) string { | ||
| 27 | 27 | return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/") |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | // view lays out a show: a title line, aligned fields, a body, events, | |
| 31 | // comments. Plain output is the same lines without colour or wrapping. | |
| 30 | // view lays out a show piped: a title line, aligned fields, a body, | |
| 31 | // events, comments. A terminal draws a screen instead (screen.go); a | |
| 32 | // caller that still reaches view at a terminal gets the same plain | |
| 33 | // layout, with user text made safe. | |
| 32 | 34 | type view struct { |
| 33 | 35 | c *Ctx |
| 34 | 36 | w io.Writer |
| @@ -38,7 +40,7 @@ type view struct { | ||
| 38 | 40 | func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} } |
| 39 | 41 | |
| 40 | 42 | func (v *view) opts() termtext.Options { |
| 41 | return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL} | |
| 43 | return termtext.Options{Base: v.c.Cfg.Server.SiteURL} | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | // sep writes a blank line before the next block, unless this view has |
| @@ -52,82 +54,32 @@ func (v *view) sep() { | ||
| 52 | 54 | v.wrote = true |
| 53 | 55 | } |
| 54 | 56 | |
| 55 | // section prints a sub-table's label: a blank line, then the label | |
| 56 | // capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely | |
| 57 | // when the table it introduces has no rows. | |
| 57 | // section prints a sub-table's label: a blank line, then "label:". | |
| 58 | // Callers skip the call entirely when the table it introduces has no | |
| 59 | // rows. | |
| 58 | 60 | func (v *view) section(label string) { |
| 59 | 61 | v.sep() |
| 60 | if v.c.Term.Cols == 0 { | |
| 61 | io.WriteString(v.w, label+":\n") | |
| 62 | return | |
| 63 | } | |
| 64 | io.WriteString(v.w, v.c.Term.heading(label)+"\n") | |
| 62 | io.WriteString(v.w, label+":\n") | |
| 65 | 63 | } |
| 66 | 64 | |
| 67 | // title prints "ref title state", wrapping title+state to the | |
| 68 | // terminal width. Continuation lines indent under the title, and each | |
| 69 | // line is painted after wrapping so no SGR sequence crosses a break. | |
| 70 | // title or state may be "": either is skipped rather than leaving a | |
| 71 | // trailing blank field. | |
| 65 | // title prints "ref title state" on one line. title or state may be | |
| 66 | // "": either is skipped rather than leaving a trailing blank field. | |
| 72 | 67 | func (v *view) title(ref, title, state string) { |
| 73 | 68 | v.sep() |
| 74 | 69 | t := v.c.Term |
| 75 | 70 | ref, title, state = t.safe(ref), t.safe(title), t.safe(state) |
| 76 | if t.Cols == 0 { | |
| 77 | switch { | |
| 78 | case title == "" && state == "": | |
| 79 | io.WriteString(v.w, ref+"\n") | |
| 80 | case state == "": | |
| 81 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n") | |
| 82 | case title == "": | |
| 83 | io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n") | |
| 84 | default: | |
| 85 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n") | |
| 86 | } | |
| 87 | return | |
| 71 | line := ref | |
| 72 | if title != "" { | |
| 73 | line += " " + title | |
| 88 | 74 | } |
| 89 | prefix := ref + " " | |
| 90 | indent := strings.Repeat(" ", cells(prefix)) | |
| 91 | rest := title | |
| 92 | 75 | if state != "" { |
| 93 | if rest != "" { | |
| 94 | rest += " " | |
| 95 | } | |
| 96 | rest += state | |
| 97 | } | |
| 98 | if rest == "" { | |
| 99 | io.WriteString(v.w, t.paint(sgrDim, ref)+"\n") | |
| 100 | return | |
| 101 | } | |
| 102 | lines := termtext.Wrap(rest, t.Cols-cells(prefix)) | |
| 103 | for i, line := range lines { | |
| 104 | p := indent | |
| 105 | if i == 0 { | |
| 106 | p = t.paint(sgrDim, ref) + " " | |
| 107 | } | |
| 108 | if i < len(lines)-1 || state == "" { | |
| 109 | io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") | |
| 110 | continue | |
| 111 | } | |
| 112 | // Last line carries the state word, the last word overall | |
| 113 | // (states are single words): keep it coloured, not bold. | |
| 114 | head, last := line, line | |
| 115 | if idx := strings.LastIndex(line, " "); idx >= 0 { | |
| 116 | head, last = line[:idx], line[idx+1:] | |
| 117 | } else { | |
| 118 | head = "" | |
| 119 | } | |
| 120 | io.WriteString(v.w, p) | |
| 121 | if head != "" { | |
| 122 | io.WriteString(v.w, t.paint(sgrBold, head)+" ") | |
| 123 | } | |
| 124 | io.WriteString(v.w, t.paint(stateColor(state), last)+"\n") | |
| 76 | line += " " + state | |
| 125 | 77 | } |
| 78 | io.WriteString(v.w, line+"\n") | |
| 126 | 79 | } |
| 127 | 80 | |
| 128 | 81 | // fields prints key/value pairs aligned on the widest key, skipping |
| 129 | // empty values. A value that does not fit wraps, its continuation | |
| 130 | // lines indented to the value column. | |
| 82 | // empty values. | |
| 131 | 83 | func (v *view) fields(kv ...string) { |
| 132 | 84 | wide := 0 |
| 133 | 85 | for i := 0; i+1 < len(kv); i += 2 { |
| @@ -141,19 +93,7 @@ func (v *view) fields(kv ...string) { | ||
| 141 | 93 | if val == "" { |
| 142 | 94 | continue |
| 143 | 95 | } |
| 144 | prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " " | |
| 145 | if v.c.Term.Cols == 0 { | |
| 146 | io.WriteString(v.w, prefix+val+"\n") | |
| 147 | continue | |
| 148 | } | |
| 149 | indent := strings.Repeat(" ", 2+wide+2) | |
| 150 | for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) { | |
| 151 | p := indent | |
| 152 | if j == 0 { | |
| 153 | p = prefix | |
| 154 | } | |
| 155 | io.WriteString(v.w, p+line+"\n") | |
| 156 | } | |
| 96 | io.WriteString(v.w, " "+pad(key, wide)+" "+val+"\n") | |
| 157 | 97 | } |
| 158 | 98 | } |
| 159 | 99 | |
| @@ -190,32 +130,18 @@ func (v *view) text(src string) { | ||
| 190 | 130 | } |
| 191 | 131 | |
| 192 | 132 | // event is one line for a system comment: its text without link |
| 193 | // targets, the time at the right edge at a terminal. | |
| 133 | // targets, then the time. | |
| 194 | 134 | func (v *view) event(text, format, ts string) { |
| 195 | 135 | line := "· " + termtext.Inline(v.c.Term.safe(text), format) |
| 196 | when := v.c.Term.safe(v.c.when(ts)) | |
| 197 | if cols := v.c.Term.Cols; cols > 0 { | |
| 198 | room := cols - 2 - 2 - cells(when) | |
| 199 | line = pad(clip(line, room), room) | |
| 200 | } | |
| 201 | io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n") | |
| 136 | io.WriteString(v.w, " "+line+" "+v.c.Term.safe(stamp(ts))+"\n") | |
| 202 | 137 | v.wrote = true |
| 203 | 138 | } |
| 204 | 139 | |
| 205 | 140 | func (v *view) comment(id int64, author, ts, body, format string) { |
| 206 | when := v.c.Term.safe(v.c.when(ts)) + fmt.Sprintf(" (comment %d)", id) | |
| 207 | author = v.c.Term.safe(author) | |
| 208 | cols := v.c.Term.Cols | |
| 209 | if cols > 0 { | |
| 210 | suffix := ", " + when + " " | |
| 211 | author = clip(author, max(0, cols-cells("── "+suffix)-1)) | |
| 212 | } | |
| 213 | head := "── " + author + ", " + when + " " | |
| 214 | if cols > 0 { | |
| 215 | head += strings.Repeat("─", max(1, cols-cells(head))) | |
| 216 | } | |
| 141 | when := v.c.Term.safe(stamp(ts)) + fmt.Sprintf(" (comment %d)", id) | |
| 142 | head := "── " + v.c.Term.safe(author) + ", " + when + " " | |
| 217 | 143 | v.sep() |
| 218 | io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n") | |
| 144 | io.WriteString(v.w, head+"\n") | |
| 219 | 145 | v.body(body, format) |
| 220 | 146 | } |
| 221 | 147 | |
internal/control/view_test.go +20
| @@ -132,3 +132,23 @@ func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 { | ||
| 132 | 132 | } |
| 133 | 133 | return issue.ID |
| 134 | 134 | } |
| 135 | ||
| 136 | // view is the piped writer: at a terminal it writes what it writes | |
| 137 | // piped, so a caller that never migrated falls back to the plain layout. | |
| 138 | func TestViewIgnoresTerminal(t *testing.T) { | |
| 139 | render := func(term Term) string { | |
| 140 | var b bytes.Buffer | |
| 141 | c := &Ctx{Term: term} | |
| 142 | v := c.view(&b) | |
| 143 | v.title("#1", "A title", "open") | |
| 144 | v.fields("author", "alice", "when", "2026-09-01T10:00:00Z") | |
| 145 | v.body("Some *body* text that is long enough to wrap at forty columns, if it wraps.", "md") | |
| 146 | v.section("files") | |
| 147 | v.event("labelled [bug](/x)", "md", "2026-09-01T10:00:00Z") | |
| 148 | v.comment(1, "bob", "2026-09-01T10:00:00Z", "A comment.", "md") | |
| 149 | return b.String() | |
| 150 | } | |
| 151 | if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want { | |
| 152 | t.Errorf("terminal view differs from piped:\n%s\n---\n%s", got, want) | |
| 153 | } | |
| 154 | } | |
internal/control/wiki.go +26 −19
| @@ -98,12 +98,26 @@ func runWikiList(c *Ctx, args []string) int { | ||
| 98 | 98 | if d.Pages == nil { |
| 99 | 99 | d.Pages = []string{} |
| 100 | 100 | } |
| 101 | return c.emit(d, func(w io.Writer) { | |
| 101 | return c.emitView(d, func(w io.Writer) { | |
| 102 | 102 | tb := c.table(w, "PAGE") |
| 103 | 103 | for _, p := range d.Pages { |
| 104 | 104 | tb.row(cRef(p)) |
| 105 | 105 | } |
| 106 | 106 | tb.flush() |
| 107 | }, func() screen { | |
| 108 | rows := make([]row, len(d.Pages)) | |
| 109 | for i, pg := range d.Pages { | |
| 110 | home := "" | |
| 111 | if pg == d.Home { | |
| 112 | home = "home" | |
| 113 | } | |
| 114 | rows[i] = rowOf(cLink(pg, c.siteURL(repo.Path(), "wiki", pg)), cMeta(home)) | |
| 115 | } | |
| 116 | s := listScreen("Wiki", rows) | |
| 117 | if d.Home != "" { | |
| 118 | s.actions = []action{{"Read", []string{"wiki", "show", repo.Path(), d.Home}}} | |
| 119 | } | |
| 120 | return s | |
| 107 | 121 | }) |
| 108 | 122 | } |
| 109 | 123 | |
| @@ -170,27 +184,20 @@ func runWikiShow(c *Ctx, args []string) int { | ||
| 170 | 184 | if ext == ".org" { |
| 171 | 185 | format = "org" |
| 172 | 186 | } |
| 173 | return c.emit(d, func(w io.Writer) { | |
| 187 | return c.emitView(d, func(w io.Writer) { | |
| 174 | 188 | // Plain: the page source verbatim, same as any other piped |
| 175 | // file read. The title/fields/body layout is terminal-only. | |
| 176 | if c.Term.Cols == 0 { | |
| 177 | fmt.Fprint(w, d.Content) | |
| 178 | return | |
| 179 | } | |
| 180 | v := c.view(w) | |
| 181 | v.title(repo.Path(), page, "") | |
| 182 | binaryNote := "" | |
| 189 | // file read. | |
| 190 | fmt.Fprint(w, d.Content) | |
| 191 | }, func() screen { | |
| 192 | s := screen{fields: []field{ | |
| 193 | {"Page", []cell{cLink(page, c.siteURL(repo.Path(), "wiki", page)), cMeta(repo.Path(), d.File)}}, | |
| 194 | }, actions: []action{{"Read", []string{"wiki", "list", repo.Path()}}}} | |
| 183 | 195 | if binary { |
| 184 | binaryNote = fmt.Sprintf("%d bytes, binary", d.Size) | |
| 185 | } | |
| 186 | v.fields( | |
| 187 | "file", d.File, | |
| 188 | "binary", binaryNote, | |
| 189 | "url", c.siteURL(repo.Path(), "wiki", page), | |
| 190 | ) | |
| 191 | if !binary { | |
| 192 | v.body(d.Content, format) | |
| 196 | s.fields = append(s.fields, field{"Binary", []cell{cSize(int64(d.Size))}}) | |
| 197 | } else { | |
| 198 | s.body, s.format = d.Content, format | |
| 193 | 199 | } |
| 200 | return s | |
| 194 | 201 | }) |
| 195 | 202 | } |
| 196 | 203 | return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path()) |