CLI output: show views and the pager !469
43 files changed, +1848 −220
Layout: unified · split
cmd/gitbay/main.go +1 −1
| @@ -290,7 +290,7 @@ func runPass(o passOpts, args []string) int { | |||
| 290 | stdin = r | 290 | stdin = r |
| 291 | } | 291 | } |
| 292 | } | 292 | } |
| 293 | return runSSH(t, append(o.server, args...), stdin) | 293 | return runSSHPaged(t, append(o.server, args...), stdin, pages(o.server, args)) |
| 294 | } | 294 | } |
| 295 | 295 | ||
| 296 | func isEmptyReader(r io.Reader) bool { | 296 | func isEmptyReader(r io.Reader) bool { |
cmd/gitbay/ssh.go +63 −1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | "slices" | 12 | "slices" |
| 13 | "strconv" | 13 | "strconv" |
| 14 | "strings" | 14 | "strings" |
| 15 | "syscall" | ||
| 15 | 16 | ||
| 16 | "gitbay.org/gitbay/internal/cliconfig" | 17 | "gitbay.org/gitbay/internal/cliconfig" |
| 17 | "gitbay.org/gitbay/internal/protocol" | 18 | "gitbay.org/gitbay/internal/protocol" |
| @@ -139,9 +140,40 @@ func stripNoColor(args []string) ([]string, bool) { | |||
| 139 | return out, found | 140 | return out, found |
| 140 | } | 141 | } |
| 141 | 142 | ||
| 143 | // pagerArgv is the pager to run long output through: GITBAY_PAGER, then | ||
| 144 | // PAGER, then less. An empty GITBAY_PAGER turns paging off. | ||
| 145 | func pagerArgv(env func(string) (string, bool)) []string { | ||
| 146 | if v, ok := env("GITBAY_PAGER"); ok { | ||
| 147 | return strings.Fields(v) | ||
| 148 | } | ||
| 149 | if v, ok := env("PAGER"); ok && v != "" { | ||
| 150 | return strings.Fields(v) | ||
| 151 | } | ||
| 152 | return []string{"less"} | ||
| 153 | } | ||
| 154 | |||
| 155 | // pages reports whether a command's output goes through the pager at a | ||
| 156 | // terminal: views, diffs and logs, never a follow or JSON. | ||
| 157 | func pages(server, args []string) bool { | ||
| 158 | if len(server) == 0 || slices.Contains(args, "--json") || slices.Contains(args, "--follow") { | ||
| 159 | return false | ||
| 160 | } | ||
| 161 | switch server[len(server)-1] { | ||
| 162 | case "show", "diff", "log": | ||
| 163 | return true | ||
| 164 | } | ||
| 165 | return false | ||
| 166 | } | ||
| 167 | |||
| 142 | // runSSH executes the server command over the system ssh binary, wiring | 168 | // runSSH executes the server command over the system ssh binary, wiring |
| 143 | // stdio through. It returns the remote exit code. | 169 | // stdio through, with no pager. It returns the remote exit code. |
| 144 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { | 170 | func runSSH(t target, serverArgv []string, stdin io.Reader) int { |
| 171 | return runSSHPaged(t, serverArgv, stdin, false) | ||
| 172 | } | ||
| 173 | |||
| 174 | // runSSHPaged is runSSH with output optionally run through the pager when | ||
| 175 | // stdout is a terminal and page is true. It returns the remote exit code. | ||
| 176 | func runSSHPaged(t target, serverArgv []string, stdin io.Reader, page bool) int { | ||
| 145 | args := sshArgs(t.inst) | 177 | args := sshArgs(t.inst) |
| 146 | 178 | ||
| 147 | fd := int(os.Stdout.Fd()) | 179 | fd := int(os.Stdout.Fd()) |
| @@ -164,11 +196,41 @@ func runSSH(t target, serverArgv []string, stdin io.Reader) int { | |||
| 164 | cmd.Stdin = stdin | 196 | cmd.Stdin = stdin |
| 165 | cmd.Stdout = os.Stdout | 197 | cmd.Stdout = os.Stdout |
| 166 | cmd.Stderr = os.Stderr | 198 | cmd.Stderr = os.Stderr |
| 199 | |||
| 200 | var pager *exec.Cmd | ||
| 201 | var pw io.WriteCloser | ||
| 202 | if page && isTTY { | ||
| 203 | if argv := pagerArgv(os.LookupEnv); len(argv) > 0 { | ||
| 204 | pager = exec.Command(toolpath.Look(argv[0]), argv[1:]...) | ||
| 205 | pager.Stdout, pager.Stderr = os.Stdout, os.Stderr | ||
| 206 | if _, ok := os.LookupEnv("LESS"); !ok { | ||
| 207 | pager.Env = append(os.Environ(), "LESS=FRX") | ||
| 208 | } | ||
| 209 | if w, err := pager.StdinPipe(); err == nil && pager.Start() == nil { | ||
| 210 | pw = w | ||
| 211 | cmd.Stdout = pw | ||
| 212 | } else { | ||
| 213 | pager = nil | ||
| 214 | } | ||
| 215 | } | ||
| 216 | } | ||
| 217 | |||
| 167 | err := cmd.Run() | 218 | err := cmd.Run() |
| 219 | if pager != nil { | ||
| 220 | pw.Close() | ||
| 221 | pager.Wait() | ||
| 222 | } | ||
| 168 | if err == nil { | 223 | if err == nil { |
| 169 | return 0 | 224 | return 0 |
| 170 | } | 225 | } |
| 171 | if ee, ok := err.(*exec.ExitError); ok { | 226 | if ee, ok := err.(*exec.ExitError); ok { |
| 227 | if pager != nil { | ||
| 228 | if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() && ws.Signal() == syscall.SIGPIPE { | ||
| 229 | // The user quit the pager before ssh finished writing; | ||
| 230 | // that is not a failure of the command itself. | ||
| 231 | return 0 | ||
| 232 | } | ||
| 233 | } | ||
| 172 | code := ee.ExitCode() | 234 | code := ee.ExitCode() |
| 173 | if code == 255 { // ssh-level failure (connection, auth, host key) | 235 | if code == 255 { // ssh-level failure (connection, auth, host key) |
| 174 | fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+ | 236 | fmt.Fprintln(os.Stderr, "gitbay: ssh could not connect or authenticate; if this worked a moment ago,"+ |
cmd/gitbay/term_test.go +42 −1
| @@ -1,6 +1,9 @@ | |||
| 1 | package main | 1 | package main |
| 2 | 2 | ||
| 3 | import "testing" | 3 | import ( |
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 4 | 7 | ||
| 5 | func TestTermValue(t *testing.T) { | 8 | func TestTermValue(t *testing.T) { |
| 6 | env := func(m map[string]string) func(string) string { | 9 | env := func(m map[string]string) func(string) string { |
| @@ -35,3 +38,41 @@ func TestStripNoColor(t *testing.T) { | |||
| 35 | t.Errorf("got %v %v", args, ok) | 38 | t.Errorf("got %v %v", args, ok) |
| 36 | } | 39 | } |
| 37 | } | 40 | } |
| 41 | |||
| 42 | func TestPagerArgv(t *testing.T) { | ||
| 43 | env := func(m map[string]string) func(string) (string, bool) { | ||
| 44 | return func(k string) (string, bool) { v, ok := m[k]; return v, ok } | ||
| 45 | } | ||
| 46 | cases := []struct { | ||
| 47 | env map[string]string | ||
| 48 | want string | ||
| 49 | }{ | ||
| 50 | {nil, "less"}, | ||
| 51 | {map[string]string{"PAGER": "more -s"}, "more -s"}, | ||
| 52 | {map[string]string{"PAGER": "more", "GITBAY_PAGER": "bat -p"}, "bat -p"}, | ||
| 53 | {map[string]string{"PAGER": "more", "GITBAY_PAGER": ""}, ""}, | ||
| 54 | } | ||
| 55 | for _, c := range cases { | ||
| 56 | if got := strings.Join(pagerArgv(env(c.env)), " "); got != c.want { | ||
| 57 | t.Errorf("%v: got %q want %q", c.env, got, c.want) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | func TestPages(t *testing.T) { | ||
| 63 | yes := [][]string{{"issue", "show"}, {"mr", "diff"}, {"build", "log"}, {"repo", "log"}} | ||
| 64 | for _, s := range yes { | ||
| 65 | if !pages(s, nil) { | ||
| 66 | t.Errorf("%v should page", s) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | if pages([]string{"build", "log"}, []string{"--follow"}) { | ||
| 70 | t.Error("build log --follow must not page") | ||
| 71 | } | ||
| 72 | if pages([]string{"issue", "show"}, []string{"--json"}) { | ||
| 73 | t.Error("--json must not page") | ||
| 74 | } | ||
| 75 | if pages([]string{"issue", "list"}, nil) { | ||
| 76 | t.Error("list must not page") | ||
| 77 | } | ||
| 78 | } | ||
e2e/adminusers_test.go +1 −1
| @@ -165,7 +165,7 @@ func TestAdminUserListAndShow(t *testing.T) { | |||
| 165 | t.Fatalf("unknown user: exit %d", code) | 165 | t.Fatalf("unknown user: exit %d", code) |
| 166 | } | 166 | } |
| 167 | // Plain output carries the same facts. | 167 | // Plain output carries the same facts. |
| 168 | if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.Contains(out, "alice\tactive") || | 168 | if out, _, _ := inst.ssh(t, adminKey, "", "admin", "user", "show", "alice"); !strings.HasPrefix(out, "alice active\n") || |
| 169 | !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") { | 169 | !strings.Contains(out, "acme\tadmin") || !strings.Contains(out, "verified by admin") { |
| 170 | t.Fatalf("plain show:\n%s", out) | 170 | t.Fatalf("plain show:\n%s", out) |
| 171 | } | 171 | } |
e2e/gates_test.go +2 −1
| @@ -3,6 +3,7 @@ package e2e | |||
| 3 | import ( | 3 | import ( |
| 4 | "os" | 4 | "os" |
| 5 | "path/filepath" | 5 | "path/filepath" |
| 6 | "regexp" | ||
| 6 | "strings" | 7 | "strings" |
| 7 | "testing" | 8 | "testing" |
| 8 | ) | 9 | ) |
| @@ -88,7 +89,7 @@ func TestMergeGatesVisible(t *testing.T) { | |||
| 88 | if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) { | 89 | if strings.Contains(out, `"unmet"`) || !strings.Contains(out, `"approvals":["bob"]`) { |
| 89 | t.Fatalf("gates after approval:\n%s", out) | 90 | t.Fatalf("gates after approval:\n%s", out) |
| 90 | } | 91 | } |
| 91 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !strings.Contains(out, "gates: met; fast-forward possible") { | 92 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/svc", "1"); !regexp.MustCompile(`gates\s+met; fast-forward possible`).MatchString(out) { |
| 92 | t.Fatalf("text gates line: %s", out) | 93 | t.Fatalf("text gates line: %s", out) |
| 93 | } | 94 | } |
| 94 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { | 95 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 { |
e2e/import_test.go +2 −1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "fmt" | 4 | "fmt" |
| 5 | "os" | 5 | "os" |
| 6 | "path/filepath" | 6 | "path/filepath" |
| 7 | "regexp" | ||
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| 9 | ) | 10 | ) |
| @@ -63,7 +64,7 @@ func TestRepoImport(t *testing.T) { | |||
| 63 | t.Fatalf("imported HEAD = %s", head) | 64 | t.Fatalf("imported HEAD = %s", head) |
| 64 | } | 65 | } |
| 65 | showOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/mirror") | 66 | showOut, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/mirror") |
| 66 | if !strings.Contains(showOut, "private") || !strings.Contains(showOut, "default: trunk") { | 67 | if !strings.Contains(showOut, "private") || !regexp.MustCompile(`default branch\s+trunk`).MatchString(showOut) { |
| 67 | t.Fatalf("repo show after import: %s", showOut) | 68 | t.Fatalf("repo show after import: %s", showOut) |
| 68 | } | 69 | } |
| 69 | 70 | ||
e2e/maint_test.go +2 −1
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | "fmt" | 4 | "fmt" |
| 5 | "os" | 5 | "os" |
| 6 | "path/filepath" | 6 | "path/filepath" |
| 7 | "regexp" | ||
| 7 | "strings" | 8 | "strings" |
| 8 | "testing" | 9 | "testing" |
| 9 | ) | 10 | ) |
| @@ -39,7 +40,7 @@ func TestAdminGCAndStats(t *testing.T) { | |||
| 39 | 40 | ||
| 40 | // Stats: counts and per-repo disk usage. | 41 | // Stats: counts and per-repo disk usage. |
| 41 | out := inst.admin(t, "admin", "stats") | 42 | out := inst.admin(t, "admin", "stats") |
| 42 | if !strings.Contains(out, "repos 2") || !strings.Contains(out, "issues 1 (1 open)") || | 43 | if !regexp.MustCompile(`repos\s+2`).MatchString(out) || !strings.Contains(out, "1 (1 open)") || |
| 43 | !strings.Contains(out, "alice/app") || !strings.Contains(out, "alice/other") { | 44 | !strings.Contains(out, "alice/app") || !strings.Contains(out, "alice/other") { |
| 44 | t.Fatalf("stats output: %s", out) | 45 | t.Fatalf("stats output: %s", out) |
| 45 | } | 46 | } |
e2e/mr_test.go +1 −1
| @@ -282,7 +282,7 @@ func TestMergeRequests(t *testing.T) { | |||
| 282 | } | 282 | } |
| 283 | // So does mr show, human and JSON. | 283 | // So does mr show, human and JSON. |
| 284 | showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1") | 284 | showOut, _, code := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1") |
| 285 | if code != 0 || !strings.Contains(showOut, "commit: ") { | 285 | if code != 0 || !strings.Contains(showOut, "add feature (amended)") { |
| 286 | t.Fatalf("mr show missing commits: %d\n%s", code, showOut) | 286 | t.Fatalf("mr show missing commits: %d\n%s", code, showOut) |
| 287 | } | 287 | } |
| 288 | showJSON, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json") | 288 | showJSON, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json") |
e2e/pages_test.go +2 −1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "net/http" | 8 | "net/http" |
| 9 | "os" | 9 | "os" |
| 10 | "path/filepath" | 10 | "path/filepath" |
| 11 | "regexp" | ||
| 11 | "strings" | 12 | "strings" |
| 12 | "sync/atomic" | 13 | "sync/atomic" |
| 13 | "testing" | 14 | "testing" |
| @@ -244,7 +245,7 @@ func TestPages(t *testing.T) { | |||
| 244 | } | 245 | } |
| 245 | // repo show lists it; removal stops serving. | 246 | // repo show lists it; removal stops serving. |
| 246 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site") | 247 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site") |
| 247 | if !strings.Contains(out, "pages domains: docs.example.org") { | 248 | if !regexp.MustCompile(`pages domains\s+docs\.example\.org`).MatchString(out) { |
| 248 | t.Fatalf("repo show missing domains:\n%s", out) | 249 | t.Fatalf("repo show missing domains:\n%s", out) |
| 249 | } | 250 | } |
| 250 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "remove", "alice/site", "docs.example.org"); code != 0 { | 251 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "domain", "remove", "alice/site", "docs.example.org"); code != 0 { |
e2e/readonly_test.go +2 −7
| @@ -173,13 +173,8 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) { | |||
| 173 | // rawOutput prints content verbatim (a file, a log, a diff) and is | 173 | // rawOutput prints content verbatim (a file, a log, a diff) and is |
| 174 | // not fitted to the terminal. | 174 | // not fitted to the terminal. |
| 175 | rawOutput := map[string]bool{ | 175 | rawOutput := map[string]bool{ |
| 176 | "repo download": true, | 176 | "repo download": true, |
| 177 | "account export": true, | 177 | "account export": true, |
| 178 | "admin user show": true, // until the view layout (Part 3) | ||
| 179 | "admin runners": true, // until the view layout (Part 3): the queue summary line | ||
| 180 | "admin stats": true, // until the view layout (Part 3) | ||
| 181 | "repo deps status": true, // until the view layout (Part 3) | ||
| 182 | "release show": true, // until the view layout (Part 3) | ||
| 183 | } | 178 | } |
| 184 | // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not | 179 | // binaryOutput's bytes are not text: a stray 0x1b is coincidence, not |
| 185 | // an SGR sequence escaping into plain output. | 180 | // an SGR sequence escaping into plain output. |
e2e/reap_test.go +3 −2
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "fmt" | 7 | "fmt" |
| 8 | "os" | 8 | "os" |
| 9 | "path/filepath" | 9 | "path/filepath" |
| 10 | "regexp" | ||
| 10 | "strings" | 11 | "strings" |
| 11 | "testing" | 12 | "testing" |
| 12 | "time" | 13 | "time" |
| @@ -111,7 +112,7 @@ func TestAdminRunners(t *testing.T) { | |||
| 111 | t.Fatal("runner next failed") | 112 | t.Fatal("runner next failed") |
| 112 | } | 113 | } |
| 113 | out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners") | 114 | out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners") |
| 114 | if !strings.HasPrefix(out, "queue: 0 pending; last 24h: 0 claimed") || !strings.Contains(out, "\nci\t") || | 115 | if !regexp.MustCompile(`pending\s+0`).MatchString(out) || !regexp.MustCompile(`claimed 24h\s+0`).MatchString(out) || !strings.Contains(out, "\nci\t") || |
| 115 | !strings.Contains(out, "\talice/app\tidle") { | 116 | !strings.Contains(out, "\talice/app\tidle") { |
| 116 | t.Fatalf("idle runner row:\n%s", out) | 117 | t.Fatalf("idle runner row:\n%s", out) |
| 117 | } | 118 | } |
| @@ -142,7 +143,7 @@ func TestAdminRunners(t *testing.T) { | |||
| 142 | t.Fatal("runner done failed") | 143 | t.Fatal("runner done failed") |
| 143 | } | 144 | } |
| 144 | if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") || | 145 | if out, _, _ := inst.ssh(t, rootKey, "", "admin", "runners"); !strings.Contains(out, "\tany\tidle") || |
| 145 | !strings.HasPrefix(out, "queue: 0 pending; last 24h: 1 claimed, wait avg ") { | 146 | !regexp.MustCompile(`pending\s+0`).MatchString(out) || !regexp.MustCompile(`claimed 24h\s+1`).MatchString(out) { |
| 146 | t.Fatalf("runner still holds a build after done:\n%s", out) | 147 | t.Fatalf("runner still holds a build after done:\n%s", out) |
| 147 | } | 148 | } |
| 148 | // Host-local, the same read. | 149 | // Host-local, the same read. |
e2e/release_test.go +2 −2
| @@ -49,14 +49,14 @@ func TestReleases(t *testing.T) { | |||
| 49 | t.Fatalf("release edit: %s", errOut) | 49 | t.Fatalf("release edit: %s", errOut) |
| 50 | } | 50 | } |
| 51 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") | 51 | out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") |
| 52 | if !strings.Contains(out, "the **rebuilt** notes") || !strings.Contains(out, "First light") { | 52 | if !strings.Contains(out, "the rebuilt notes") || !strings.Contains(out, "First light") { |
| 53 | t.Fatalf("edit lost a field:\n%s", out) | 53 | t.Fatalf("edit lost a field:\n%s", out) |
| 54 | } | 54 | } |
| 55 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0", "--title", "'Second light'"); code != 0 { | 55 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v1.0", "--title", "'Second light'"); code != 0 { |
| 56 | t.Fatal("title edit failed") | 56 | t.Fatal("title edit failed") |
| 57 | } | 57 | } |
| 58 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") | 58 | out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0") |
| 59 | if !strings.Contains(out, "Second light") || !strings.Contains(out, "the **rebuilt** notes") { | 59 | if !strings.Contains(out, "Second light") || !strings.Contains(out, "the rebuilt notes") { |
| 60 | t.Fatalf("title edit lost notes:\n%s", out) | 60 | t.Fatalf("title edit lost notes:\n%s", out) |
| 61 | } | 61 | } |
| 62 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v9.9", "--title", "x"); code != 3 { | 62 | if _, _, code := inst.ssh(t, aliceKey, "", "release", "edit", "alice/app", "v9.9", "--title", "x"); code != 3 { |
e2e/reposhowstate_test.go +2 −1
| @@ -1,6 +1,7 @@ | |||
| 1 | package e2e | 1 | package e2e |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "regexp" | ||
| 4 | "strings" | 5 | "strings" |
| 5 | "testing" | 6 | "testing" |
| 6 | ) | 7 | ) |
| @@ -54,7 +55,7 @@ func TestRepoShowCarriesViewerState(t *testing.T) { | |||
| 54 | } | 55 | } |
| 55 | 56 | ||
| 56 | // Plain output carries the same. | 57 | // Plain output carries the same. |
| 57 | if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !strings.Contains(out, "watch: watching") || | 58 | if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "alice/app"); !regexp.MustCompile(`watch\s+watching`).MatchString(out) || |
| 58 | !strings.Contains(out, "bookmarked") { | 59 | !strings.Contains(out, "bookmarked") { |
| 59 | t.Errorf("plain output lacks the state:\n%s", out) | 60 | t.Errorf("plain output lacks the state:\n%s", out) |
| 60 | } | 61 | } |
internal/control/admin.go +67 −44
| @@ -249,57 +249,71 @@ func runAdminUserShow(c *Ctx, args []string) int { | |||
| 249 | } | 249 | } |
| 250 | 250 | ||
| 251 | return c.emit(d, func(w io.Writer) { | 251 | return c.emit(d, func(w io.Writer) { |
| 252 | fmt.Fprintf(w, "%s\t%s", d.Username, d.State) | 252 | admin := "" |
| 253 | if d.Admin { | 253 | if d.Admin { |
| 254 | fmt.Fprint(w, "\tadmin") | 254 | admin = "yes" |
| 255 | } | 255 | } |
| 256 | fmt.Fprintf(w, "\ncreated\t%s\n", d.CreatedAt) | 256 | v := c.view(w) |
| 257 | if d.LastSeen != "" { | 257 | v.title(d.Username, "", d.State) |
| 258 | fmt.Fprintf(w, "last seen\t%s\n", d.LastSeen) | 258 | v.fields( |
| 259 | } | 259 | "admin", admin, |
| 260 | fmt.Fprintf(w, "repos\t%d\nweb sessions\t%d\n", d.Repos, d.WebSessions) | 260 | "created", c.when(d.CreatedAt), |
| 261 | fmt.Fprintln(w, "keys:") | 261 | "last seen", c.when(d.LastSeen), |
| 262 | tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") | 262 | "repos", fmt.Sprintf("%d", d.Repos), |
| 263 | for _, k := range d.Keys { | 263 | "web sessions", fmt.Sprintf("%d", d.WebSessions), |
| 264 | tk.row(cRef(" "+k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt)) | 264 | ) |
| 265 | } | 265 | if len(d.Keys) > 0 { |
| 266 | tk.flush() | 266 | v.section("keys") |
| 267 | fmt.Fprintln(w, "emails:") | 267 | tk := c.table(w, "FINGERPRINT", "ALGO", "SCOPE", "LAST USED") |
| 268 | te := c.table(w, "ADDRESS", "STATE") | 268 | for _, k := range d.Keys { |
| 269 | for _, e := range d.Emails { | 269 | tk.row(cFlex(k.Fingerprint), cText(k.Algo), cState(k.Scope), cAge(k.LastUsedAt)) |
| 270 | state := "unverified" | ||
| 271 | if e.Verified { | ||
| 272 | state = "verified by " + e.VerifiedBy | ||
| 273 | } | 270 | } |
| 274 | cells := []cell{cRef(" " + e.Address), cState(state)} | 271 | tk.flush() |
| 275 | if e.Primary { | 272 | } |
| 276 | cells = append(cells, cText("primary")) | 273 | if len(d.Emails) > 0 { |
| 274 | v.section("emails") | ||
| 275 | te := c.table(w, "ADDRESS", "STATE") | ||
| 276 | for _, e := range d.Emails { | ||
| 277 | state := "unverified" | ||
| 278 | if e.Verified { | ||
| 279 | state = "verified by " + e.VerifiedBy | ||
| 280 | } | ||
| 281 | cells := []cell{cRef(e.Address), cState(state)} | ||
| 282 | if e.Primary { | ||
| 283 | cells = append(cells, cText("primary")) | ||
| 284 | } | ||
| 285 | te.row(cells...) | ||
| 277 | } | 286 | } |
| 278 | te.row(cells...) | 287 | te.flush() |
| 279 | } | 288 | } |
| 280 | te.flush() | 289 | if len(d.PGPKeys) > 0 { |
| 281 | fmt.Fprintln(w, "pgp keys:") | 290 | v.section("pgp keys") |
| 282 | tp := c.table(w, "FINGERPRINT") | 291 | tp := c.table(w, "FINGERPRINT") |
| 283 | for _, k := range d.PGPKeys { | 292 | for _, k := range d.PGPKeys { |
| 284 | tp.row(cRef(" " + k.Fingerprint)) | 293 | tp.row(cFlex(k.Fingerprint)) |
| 294 | } | ||
| 295 | tp.flush() | ||
| 285 | } | 296 | } |
| 286 | tp.flush() | 297 | if len(d.Orgs) > 0 { |
| 287 | fmt.Fprintln(w, "orgs:") | 298 | v.section("orgs") |
| 288 | to := c.table(w, "ORG", "ROLE") | 299 | to := c.table(w, "ORG", "ROLE") |
| 289 | for _, o := range d.Orgs { | 300 | for _, o := range d.Orgs { |
| 290 | to.row(cRef(" "+o.Org), cState(o.Role)) | 301 | to.row(cRef(o.Org), cState(o.Role)) |
| 302 | } | ||
| 303 | to.flush() | ||
| 291 | } | 304 | } |
| 292 | to.flush() | 305 | if len(d.APITokens) > 0 { |
| 293 | fmt.Fprintln(w, "api tokens:") | 306 | v.section("api tokens") |
| 294 | tt := c.table(w, "NAME", "SCOPE", "LAST USED") | 307 | tt := c.table(w, "NAME", "SCOPE", "LAST USED") |
| 295 | for _, t := range d.APITokens { | 308 | for _, t := range d.APITokens { |
| 296 | used := "" | 309 | used := "" |
| 297 | if t.LastUsedAt != nil { | 310 | if t.LastUsedAt != nil { |
| 298 | used = t.LastUsedAt.UTC().Format(time.RFC3339Nano) | 311 | used = t.LastUsedAt.UTC().Format(time.RFC3339Nano) |
| 312 | } | ||
| 313 | tt.row(cRef(t.Name), cState(t.Scope), cAge(used)) | ||
| 299 | } | 314 | } |
| 300 | tt.row(cRef(" "+t.Name), cState(t.Scope), cAge(used)) | 315 | tt.flush() |
| 301 | } | 316 | } |
| 302 | tt.flush() | ||
| 303 | }) | 317 | }) |
| 304 | } | 318 | } |
| 305 | 319 | ||
| @@ -529,8 +543,17 @@ func runAdminRunners(c *Ctx, args []string) int { | |||
| 529 | } | 543 | } |
| 530 | d := map[string]any{"queue": queue, "runners": runners} | 544 | d := map[string]any{"queue": queue, "runners": runners} |
| 531 | return c.emit(d, func(w io.Writer) { | 545 | return c.emit(d, func(w io.Writer) { |
| 532 | fmt.Fprintf(w, "queue: %d pending; last 24h: %d claimed, wait avg %ds max %ds, %d reaped\n", | 546 | v := c.view(w) |
| 533 | queue.Pending, queue.Claimed24h, queue.ClaimWaitAvgS, queue.ClaimWaitMaxS, queue.Reaped24h) | 547 | v.fields( |
| 548 | "pending", fmt.Sprintf("%d", queue.Pending), | ||
| 549 | "claimed 24h", fmt.Sprintf("%d", queue.Claimed24h), | ||
| 550 | "wait avg", fmt.Sprintf("%ds", queue.ClaimWaitAvgS), | ||
| 551 | "wait max", fmt.Sprintf("%ds", queue.ClaimWaitMaxS), | ||
| 552 | "reaped 24h", fmt.Sprintf("%d", queue.Reaped24h), | ||
| 553 | ) | ||
| 554 | if len(runners) > 0 { | ||
| 555 | v.section("runners") | ||
| 556 | } | ||
| 534 | tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") | 557 | tb := c.table(w, "USER", "FINGERPRINT", "LAST SEEN", "SCOPE", "HELD") |
| 535 | for _, r := range runners { | 558 | for _, r := range runners { |
| 536 | scope := r.Scope | 559 | scope := r.Scope |
internal/control/adminhost.go +18 −6
| @@ -302,12 +302,24 @@ func runAdminStats(c *Ctx, args []string) int { | |||
| 302 | d.DBBytes = fi.Size() | 302 | d.DBBytes = fi.Size() |
| 303 | } | 303 | } |
| 304 | return c.emit(d, func(w io.Writer) { | 304 | return c.emit(d, func(w io.Writer) { |
| 305 | fmt.Fprintf(w, "users %d · orgs %d · repos %d · issues %d (%d open) · MRs %d (%d open)\n", | 305 | v := c.view(w) |
| 306 | counts.Users, counts.Orgs, counts.Repos, | 306 | v.fields( |
| 307 | counts.Issues, counts.OpenIssues, counts.MRs, counts.OpenMRs) | 307 | "users", fmt.Sprintf("%d", counts.Users), |
| 308 | fmt.Fprintf(w, "database %s · repositories %s · lfs %s\n\n", humanBytes(d.DBBytes), humanBytes(d.RepoBytes), humanBytes(d.LFSBytes)) | 308 | "orgs", fmt.Sprintf("%d", counts.Orgs), |
| 309 | for _, r := range d.Repos { | 309 | "repos", fmt.Sprintf("%d", counts.Repos), |
| 310 | fmt.Fprintf(w, "%s\t%s\n", r.Path, humanBytes(r.Bytes)) | 310 | "issues", fmt.Sprintf("%d (%d open)", counts.Issues, counts.OpenIssues), |
| 311 | "MRs", fmt.Sprintf("%d (%d open)", counts.MRs, counts.OpenMRs), | ||
| 312 | "database", humanBytes(d.DBBytes), | ||
| 313 | "repositories", humanBytes(d.RepoBytes), | ||
| 314 | "lfs", humanBytes(d.LFSBytes), | ||
| 315 | ) | ||
| 316 | if len(d.Repos) > 0 { | ||
| 317 | io.WriteString(w, "\n") | ||
| 318 | tb := c.table(w, "PATH", "BYTES") | ||
| 319 | for _, r := range d.Repos { | ||
| 320 | tb.row(cRef(r.Path), cText(humanBytes(r.Bytes))) | ||
| 321 | } | ||
| 322 | tb.flush() | ||
| 311 | } | 323 | } |
| 312 | }) | 324 | }) |
| 313 | } | 325 | } |
internal/control/build.go +10 −6
| @@ -183,17 +183,21 @@ func buildSubjects(c *Ctx, repo store.Repo, ds []BuildOut) map[string]string { | |||
| 183 | } | 183 | } |
| 184 | 184 | ||
| 185 | func runBuildShow(c *Ctx, args []string) int { | 185 | func runBuildShow(c *Ctx, args []string) int { |
| 186 | _, b, code := buildRef(c, args) | 186 | repo, b, code := buildRef(c, args) |
| 187 | if code >= 0 { | 187 | if code >= 0 { |
| 188 | return code | 188 | return code |
| 189 | } | 189 | } |
| 190 | d := buildToOut(b) | 190 | d := buildToOut(b) |
| 191 | return c.emit(d, func(w io.Writer) { | 191 | return c.emit(d, func(w io.Writer) { |
| 192 | fmt.Fprintf(w, "build %d\t%s\t%s\n%.10s on %s\nqueued %s", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.CreatedAt) | 192 | v := c.view(w) |
| 193 | if d.FinishedAt != "" { | 193 | v.title(fmt.Sprintf("#%d", d.Number), d.Job, d.Status) |
| 194 | fmt.Fprintf(w, ", finished %s", d.FinishedAt) | 194 | v.fields( |
| 195 | } | 195 | "sha", fmt.Sprintf("%.10s", d.SHA), |
| 196 | fmt.Fprintln(w) | 196 | "ref", d.Ref, |
| 197 | "queued", c.when(d.CreatedAt), | ||
| 198 | "finished", c.when(d.FinishedAt), | ||
| 199 | "url", c.siteURL(repo.Path(), "builds", strconv.FormatInt(d.Number, 10)), | ||
| 200 | ) | ||
| 197 | }) | 201 | }) |
| 198 | } | 202 | } |
| 199 | 203 | ||
internal/control/deps.go +18 −8
| @@ -87,7 +87,8 @@ func runDepsStatus(c *Ctx, args []string) int { | |||
| 87 | check, err := c.Store.DepCheckFor(repo.ID) | 87 | check, err := c.Store.DepCheckFor(repo.ID) |
| 88 | if errors.Is(err, store.ErrNotFound) { | 88 | if errors.Is(err, store.ErrNotFound) { |
| 89 | return c.emit(map[string]any{"enabled": false}, func(w io.Writer) { | 89 | return c.emit(map[string]any{"enabled": false}, func(w io.Writer) { |
| 90 | fmt.Fprintf(w, "dependency checks are off for %s (repo deps enable %s)\n", repo.Path(), repo.Path()) | 90 | v := c.view(w) |
| 91 | v.fields("checks", fmt.Sprintf("off (repo deps enable %s)", repo.Path())) | ||
| 91 | }) | 92 | }) |
| 92 | } | 93 | } |
| 93 | if err != nil { | 94 | if err != nil { |
| @@ -103,15 +104,24 @@ func runDepsStatus(c *Ctx, args []string) int { | |||
| 103 | out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest}) | 104 | out.Behind = append(out.Behind, DepBehind{r.Ecosystem, r.Name, r.Current, r.Latest}) |
| 104 | } | 105 | } |
| 105 | return c.emit(out, func(w io.Writer) { | 106 | return c.emit(out, func(w io.Writer) { |
| 106 | fmt.Fprintf(w, "checks on, last %s\n", orDash(check.LastCheck)) | 107 | tracked := "" |
| 107 | if check.LastError != "" { | ||
| 108 | fmt.Fprintf(w, "last error: %s\n", check.LastError) | ||
| 109 | } | ||
| 110 | if check.IssueNumber != 0 { | 108 | if check.IssueNumber != 0 { |
| 111 | fmt.Fprintf(w, "tracked in #%d\n", check.IssueNumber) | 109 | tracked = fmt.Sprintf("#%d", check.IssueNumber) |
| 112 | } | 110 | } |
| 113 | for _, b := range out.Behind { | 111 | v := c.view(w) |
| 114 | fmt.Fprintf(w, "%s\t%s\t%s\t-> %s\n", b.Ecosystem, b.Name, b.Current, b.Latest) | 112 | v.fields( |
| 113 | "checks", "on", | ||
| 114 | "last check", c.when(check.LastCheck), | ||
| 115 | "last error", check.LastError, | ||
| 116 | "tracked in", tracked, | ||
| 117 | ) | ||
| 118 | if len(out.Behind) > 0 { | ||
| 119 | io.WriteString(w, "\n") | ||
| 120 | tb := c.table(w, "ECOSYSTEM", "NAME", "CURRENT", "LATEST") | ||
| 121 | for _, b := range out.Behind { | ||
| 122 | tb.row(cText(b.Ecosystem), cRef(b.Name), cText(b.Current), cText(b.Latest)) | ||
| 123 | } | ||
| 124 | tb.flush() | ||
| 115 | } | 125 | } |
| 116 | }) | 126 | }) |
| 117 | } | 127 | } |
internal/control/issue.go +25 −12
| @@ -226,23 +226,36 @@ func runIssueShow(c *Ctx, args []string) int { | |||
| 226 | } | 226 | } |
| 227 | var cs []commentOut | 227 | var cs []commentOut |
| 228 | for _, cm := range comments { | 228 | for _, cm := range comments { |
| 229 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt}) | 229 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) |
| 230 | } | 230 | } |
| 231 | d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs} | 231 | d := IssueShow{issueOut: issueToOut(issue, true), Comments: cs} |
| 232 | _ = repo | ||
| 233 | return c.emit(d, func(w io.Writer) { | 232 | return c.emit(d, func(w io.Writer) { |
| 234 | fmt.Fprintf(w, "#%d %s [%s] by %s\n", d.Number, d.Title, d.State, d.Author) | 233 | v := c.view(w) |
| 235 | if len(d.Labels) > 0 { | 234 | v.title(fmt.Sprintf("#%d", d.Number), d.Title, d.State) |
| 236 | fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", ")) | 235 | v.fields( |
| 237 | } | 236 | "author", d.Author+", "+c.when(d.CreatedAt), |
| 238 | if len(d.Assignees) > 0 { | 237 | "assignees", strings.Join(d.Assignees, ", "), |
| 239 | fmt.Fprintf(w, "assignees: %s\n", strings.Join(d.Assignees, ", ")) | 238 | "labels", strings.Join(d.Labels, ", "), |
| 240 | } | 239 | "milestone", d.Milestone, |
| 241 | if d.Body != "" { | 240 | "url", c.siteURL(repo.Path(), "issues", strconv.FormatInt(d.Number, 10)), |
| 242 | fmt.Fprintf(w, "\n%s\n", d.Body) | 241 | ) |
| 242 | v.body(d.Body, d.BodyFormat) | ||
| 243 | events := false | ||
| 244 | for _, cm := range cs { | ||
| 245 | if cm.Kind != "system" { | ||
| 246 | continue | ||
| 247 | } | ||
| 248 | if !events { | ||
| 249 | io.WriteString(w, "\n") | ||
| 250 | events = true | ||
| 251 | } | ||
| 252 | v.event(cm.Body, cm.BodyFormat, cm.CreatedAt) | ||
| 243 | } | 253 | } |
| 244 | for _, cm := range cs { | 254 | for _, cm := range cs { |
| 245 | fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) | 255 | if cm.Kind == "system" { |
| 256 | continue | ||
| 257 | } | ||
| 258 | v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | ||
| 246 | } | 259 | } |
| 247 | }) | 260 | }) |
| 248 | } | 261 | } |
internal/control/mr.go +120 −40
| @@ -558,7 +558,7 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 558 | } | 558 | } |
| 559 | var cs []commentOut | 559 | var cs []commentOut |
| 560 | for _, cm := range comments { | 560 | for _, cm := range comments { |
| 561 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt}) | 561 | cs = append(cs, commentOut{cm.Author, cm.Body, cm.BodyFormat, cm.CreatedAt, cm.Kind}) |
| 562 | } | 562 | } |
| 563 | var rs []ReviewOut | 563 | var rs []ReviewOut |
| 564 | counts := ReviewersWhoCount(c.Store, repo, reviews) | 564 | counts := ReviewersWhoCount(c.Store, repo, reviews) |
| @@ -602,75 +602,155 @@ func runMRShow(c *Ctx, args []string) int { | |||
| 602 | if d.Draft { | 602 | if d.Draft { |
| 603 | state = "draft" | 603 | state = "draft" |
| 604 | } | 604 | } |
| 605 | fmt.Fprintf(w, "!%d %s [%s] by %s\n%s -> %s @ %.10s\n", d.Number, d.Title, state, d.Author, d.Source, d.TargetRef, d.HeadSHA) | 605 | v := c.view(w) |
| 606 | if len(d.Labels) > 0 { | 606 | v.title(fmt.Sprintf("!%d", d.Number), d.Title, state) |
| 607 | fmt.Fprintf(w, "labels: %s\n", strings.Join(d.Labels, ", ")) | 607 | |
| 608 | } | 608 | stackedOn, stacked := "", "" |
| 609 | if len(d.ReviewRequests) > 0 { | ||
| 610 | fmt.Fprintf(w, "reviewers: %s\n", strings.Join(d.ReviewRequests, ", ")) | ||
| 611 | } | ||
| 612 | if d.StackedOn != nil { | 609 | if d.StackedOn != nil { |
| 613 | fmt.Fprintf(w, "stacked on !%d %s\n", d.StackedOn.Number, d.StackedOn.Title) | 610 | stackedOn = fmt.Sprintf("!%d %s", d.StackedOn.Number, d.StackedOn.Title) |
| 614 | } | 611 | } |
| 615 | for _, k := range d.Stacked { | 612 | for _, k := range d.Stacked { |
| 616 | fmt.Fprintf(w, "stacked: !%d %s\n", k.Number, k.Title) | 613 | if stacked != "" { |
| 614 | stacked += ", " | ||
| 615 | } | ||
| 616 | stacked += fmt.Sprintf("!%d %s", k.Number, k.Title) | ||
| 617 | } | 617 | } |
| 618 | merged, closed, superseded := "", "", "" | ||
| 618 | if d.MergedAt != "" { | 619 | if d.MergedAt != "" { |
| 619 | fmt.Fprintf(w, "merged %s%s\n", d.MergedAt, byWhom(d.MergedBy)) | 620 | merged = c.when(d.MergedAt) + byWhom(d.MergedBy) |
| 620 | } | 621 | } |
| 621 | if d.ClosedAt != "" { | 622 | if d.ClosedAt != "" { |
| 622 | fmt.Fprintf(w, "closed %s%s\n", d.ClosedAt, byWhom(d.ClosedBy)) | 623 | closed = c.when(d.ClosedAt) + byWhom(d.ClosedBy) |
| 623 | } | 624 | } |
| 624 | if d.SupersededBy != 0 { | 625 | if d.SupersededBy != 0 { |
| 625 | fmt.Fprintf(w, "superseded by: !%d\n", d.SupersededBy) | 626 | superseded = fmt.Sprintf("!%d", d.SupersededBy) |
| 627 | } | ||
| 628 | gates := "" | ||
| 629 | if g := d.Gates; g != nil { | ||
| 630 | ff := "fast-forward possible" | ||
| 631 | if !g.FastForward { | ||
| 632 | ff = "fast-forward not possible" | ||
| 633 | } | ||
| 634 | if len(g.Unmet) == 0 { | ||
| 635 | gates = "met; " + ff | ||
| 636 | } else { | ||
| 637 | gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff) | ||
| 638 | } | ||
| 639 | } | ||
| 640 | unresolved := "" | ||
| 641 | if d.UnresolvedThreads > 0 { | ||
| 642 | unresolved = fmt.Sprintf("%d", d.UnresolvedThreads) | ||
| 643 | } | ||
| 644 | // One fields call for every one-row fact, including the unmet | ||
| 645 | // gates and the commit/check/review that only has a single row | ||
| 646 | // to show: separate calls each compute their own key width, so | ||
| 647 | // keeping them in one call keeps every key aligned. | ||
| 648 | kv := []string{ | ||
| 649 | "author", d.Author + ", " + c.when(d.CreatedAt), | ||
| 650 | "source", fmt.Sprintf("%s -> %s", d.Source, d.TargetRef), | ||
| 651 | "head", fmt.Sprintf("%.10s", d.HeadSHA), | ||
| 652 | "milestone", d.Milestone, | ||
| 653 | "labels", strings.Join(d.Labels, ", "), | ||
| 654 | "reviewers", strings.Join(d.ReviewRequests, ", "), | ||
| 655 | "stacked on", stackedOn, | ||
| 656 | "stacked", stacked, | ||
| 657 | "merged", merged, | ||
| 658 | "closed", closed, | ||
| 659 | "superseded by", superseded, | ||
| 660 | "unresolved threads", unresolved, | ||
| 661 | "gates", gates, | ||
| 626 | } | 662 | } |
| 627 | if d.Body != "" { | 663 | if g := d.Gates; g != nil { |
| 628 | fmt.Fprintf(w, "\n%s\n", d.Body) | 664 | for _, u := range g.Unmet { |
| 665 | kv = append(kv, "unmet", u) | ||
| 666 | } | ||
| 629 | } | 667 | } |
| 630 | for _, cm := range commits { | 668 | if len(commits) == 1 { |
| 631 | fmt.Fprintf(w, "commit: %.10s %s\n", cm.SHA, cm.Subject) | 669 | kv = append(kv, "commit", fmt.Sprintf("%.10s %s", commits[0].SHA, commits[0].Subject)) |
| 632 | } | 670 | } |
| 633 | for _, x := range checks { | 671 | if len(checks) == 1 { |
| 672 | x := checks[0] | ||
| 634 | dur := "" | 673 | dur := "" |
| 635 | if x.Duration != "" { | 674 | if x.Duration != "" { |
| 636 | dur = " in " + x.Duration | 675 | dur = " in " + x.Duration |
| 637 | } | 676 | } |
| 638 | fmt.Fprintf(w, "check: %s %s at %s%s\n", x.Context, x.State, x.UpdatedAt, dur) | 677 | kv = append(kv, "check", fmt.Sprintf("%s %s at %s%s", x.Context, x.State, c.when(x.UpdatedAt), dur)) |
| 639 | } | 678 | } |
| 640 | if d.UnresolvedThreads > 0 { | 679 | if len(rs) == 1 { |
| 641 | fmt.Fprintf(w, "unresolved threads: %d\n", d.UnresolvedThreads) | 680 | kv = append(kv, "review", reviewLine(rs[0])+" at "+c.when(rs[0].CreatedAt)) |
| 642 | } | 681 | } |
| 643 | if g := d.Gates; g != nil { | 682 | kv = append(kv, "url", c.siteURL(repo.Path(), "mrs", strconv.FormatInt(d.Number, 10))) |
| 644 | ff := "fast-forward possible" | 683 | v.fields(kv...) |
| 645 | if !g.FastForward { | 684 | |
| 646 | ff = "fast-forward not possible" | 685 | v.body(d.Body, d.BodyFormat) |
| 686 | |||
| 687 | if len(commits) > 1 { | ||
| 688 | io.WriteString(w, "\n") | ||
| 689 | tb := c.table(w, "SHA", "SUBJECT") | ||
| 690 | for _, cm := range commits { | ||
| 691 | tb.row(cRef(fmt.Sprintf("%.10s", cm.SHA)), cFlex(cm.Subject)) | ||
| 647 | } | 692 | } |
| 648 | if len(g.Unmet) == 0 { | 693 | tb.flush() |
| 649 | fmt.Fprintf(w, "gates: met; %s\n", ff) | 694 | } |
| 650 | } else { | 695 | |
| 651 | fmt.Fprintf(w, "gates: %d unmet; %s\n", len(g.Unmet), ff) | 696 | if len(checks) > 1 { |
| 652 | for _, u := range g.Unmet { | 697 | v.section("check") |
| 653 | fmt.Fprintf(w, "gate: %s\n", u) | 698 | tb := c.table(w, "CHECK", "STATE", "DURATION", "UPDATED") |
| 699 | for _, x := range checks { | ||
| 700 | tb.row(cText(x.Context), cState(x.State), cText(x.Duration), cText(c.when(x.UpdatedAt))) | ||
| 701 | } | ||
| 702 | tb.flush() | ||
| 703 | } | ||
| 704 | |||
| 705 | if len(rs) > 1 { | ||
| 706 | v.section("review") | ||
| 707 | tb := c.table(w, "REVIEWER", "VERDICT", "WHEN") | ||
| 708 | for _, r := range rs { | ||
| 709 | verdict := r.Verdict | ||
| 710 | if r.Stale { | ||
| 711 | verdict += " (stale)" | ||
| 712 | } | ||
| 713 | if !r.Counts { | ||
| 714 | verdict += " (advisory)" | ||
| 654 | } | 715 | } |
| 716 | tb.row(cText(r.Reviewer), cState(verdict), cText(c.when(r.CreatedAt))) | ||
| 655 | } | 717 | } |
| 718 | tb.flush() | ||
| 656 | } | 719 | } |
| 657 | for _, r := range rs { | 720 | |
| 658 | stale := "" | 721 | events := false |
| 659 | if r.Stale { | 722 | for _, cm := range cs { |
| 660 | stale = " (stale)" | 723 | if cm.Kind != "system" { |
| 724 | continue | ||
| 661 | } | 725 | } |
| 662 | advisory := "" | 726 | if !events { |
| 663 | if !r.Counts { | 727 | io.WriteString(w, "\n") |
| 664 | advisory = " (advisory: no write access)" | 728 | events = true |
| 665 | } | 729 | } |
| 666 | fmt.Fprintf(w, "review: %s %s%s%s at %s\n", r.Reviewer, r.Verdict, stale, advisory, r.CreatedAt) | 730 | v.event(cm.Body, cm.BodyFormat, cm.CreatedAt) |
| 667 | } | 731 | } |
| 668 | for _, cm := range cs { | 732 | for _, cm := range cs { |
| 669 | fmt.Fprintf(w, "\n--- %s at %s\n%s\n", cm.Author, cm.CreatedAt, cm.Body) | 733 | if cm.Kind == "system" { |
| 734 | continue | ||
| 735 | } | ||
| 736 | v.comment(cm.Author, cm.CreatedAt, cm.Body, cm.BodyFormat) | ||
| 670 | } | 737 | } |
| 671 | }) | 738 | }) |
| 672 | } | 739 | } |
| 673 | 740 | ||
| 741 | // reviewLine renders one review as fields prose: "reviewer verdict | ||
| 742 | // (stale) (advisory) at when". | ||
| 743 | func reviewLine(r ReviewOut) string { | ||
| 744 | s := r.Reviewer + " " + r.Verdict | ||
| 745 | if r.Stale { | ||
| 746 | s += " (stale)" | ||
| 747 | } | ||
| 748 | if !r.Counts { | ||
| 749 | s += " (advisory: no write access)" | ||
| 750 | } | ||
| 751 | return s | ||
| 752 | } | ||
| 753 | |||
| 674 | func runMRDiff(c *Ctx, args []string) int { | 754 | func runMRDiff(c *Ctx, args []string) int { |
| 675 | repo, mr, code := mrRef(c, args, policy.CanRead) | 755 | repo, mr, code := mrRef(c, args, policy.CanRead) |
| 676 | if code >= 0 { | 756 | if code >= 0 { |
internal/control/notifications.go +6 −1
| @@ -198,7 +198,12 @@ func emitNotificationSettings(c *Ctx) int { | |||
| 198 | } | 198 | } |
| 199 | return "off" | 199 | return "off" |
| 200 | } | 200 | } |
| 201 | fmt.Fprintf(w, "mail: %s\nwatch: %s\npush: %s\n", onOff(mail), onOff(watch), onOff(push)) | 201 | v := c.view(w) |
| 202 | v.fields( | ||
| 203 | "mail", onOff(mail), | ||
| 204 | "watch", onOff(watch), | ||
| 205 | "push", onOff(push), | ||
| 206 | ) | ||
| 202 | }) | 207 | }) |
| 203 | } | 208 | } |
| 204 | 209 | ||
internal/control/org.go +9 −5
| @@ -123,12 +123,16 @@ func runOrgShow(c *Ctx, args []string) int { | |||
| 123 | Members []memberOut `json:"members"` | 123 | Members []memberOut `json:"members"` |
| 124 | }{org.Name, ms} | 124 | }{org.Name, ms} |
| 125 | return c.emit(d, func(w io.Writer) { | 125 | return c.emit(d, func(w io.Writer) { |
| 126 | fmt.Fprintf(w, "%s\n", d.Org) | 126 | v := c.view(w) |
| 127 | tb := c.table(w, "USER", "ROLE") | 127 | v.title(d.Org, "", "") |
| 128 | for _, m := range ms { | 128 | if len(ms) > 0 { |
| 129 | tb.row(cRef(" "+m.User), cState(m.Role)) | 129 | io.WriteString(w, "\n") |
| 130 | tb := c.table(w, "USER", "ROLE") | ||
| 131 | for _, m := range ms { | ||
| 132 | tb.row(cRef(m.User), cState(m.Role)) | ||
| 133 | } | ||
| 134 | tb.flush() | ||
| 130 | } | 135 | } |
| 131 | tb.flush() | ||
| 132 | }) | 136 | }) |
| 133 | } | 137 | } |
| 134 | 138 | ||
internal/control/profile.go +39 −20
| @@ -240,31 +240,50 @@ func ActivityWindow() string { | |||
| 240 | 240 | ||
| 241 | func emitProfile(c *Ctx, d ProfileOut) int { | 241 | func emitProfile(c *Ctx, d ProfileOut) int { |
| 242 | return c.emit(d, func(w io.Writer) { | 242 | return c.emit(d, func(w io.Writer) { |
| 243 | fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind) | 243 | activity := "" |
| 244 | if d.Description != "" { | 244 | if d.ActivityTotal > 0 { |
| 245 | fmt.Fprintf(w, "%s\n", d.Description) | 245 | activity = fmt.Sprintf("%d in the last year", d.ActivityTotal) |
| 246 | } | ||
| 247 | if d.Website != "" { | ||
| 248 | fmt.Fprintf(w, "%s\n", d.Website) | ||
| 249 | } | ||
| 250 | for _, l := range d.Links { | ||
| 251 | fmt.Fprintf(w, "link\t%s\t%s\n", l.Label, l.URL) | ||
| 252 | } | ||
| 253 | for _, m := range d.Orgs { | ||
| 254 | fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role) | ||
| 255 | } | 246 | } |
| 256 | for _, m := range d.Members { | 247 | v := c.view(w) |
| 257 | fmt.Fprintf(w, "member\t%s\t%s\n", m.Name, m.Role) | 248 | v.title(d.Name, d.Description, d.Kind) |
| 249 | v.fields( | ||
| 250 | "website", d.Website, | ||
| 251 | "url", c.siteURL(d.Name), | ||
| 252 | "activity", activity, | ||
| 253 | ) | ||
| 254 | if len(d.Links) > 0 { | ||
| 255 | v.section("link") | ||
| 256 | tb := c.table(w, "LINK", "URL") | ||
| 257 | for _, l := range d.Links { | ||
| 258 | tb.row(cText(l.Label), cFlex(l.URL)) | ||
| 259 | } | ||
| 260 | tb.flush() | ||
| 258 | } | 261 | } |
| 259 | for _, r := range d.Repos { | 262 | if len(d.Orgs) > 0 { |
| 260 | fmt.Fprintf(w, "repo\t%s\t%s\t%s\n", r.Path, r.Visibility, r.Description) | 263 | v.section("org") |
| 264 | tb := c.table(w, "ORG", "ROLE") | ||
| 265 | for _, m := range d.Orgs { | ||
| 266 | tb.row(cRef(m.Name), cState(m.Role)) | ||
| 267 | } | ||
| 268 | tb.flush() | ||
| 261 | } | 269 | } |
| 262 | if d.ActivityTotal > 0 { | 270 | if len(d.Members) > 0 { |
| 263 | fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal) | 271 | v.section("member") |
| 272 | tb := c.table(w, "MEMBER", "ROLE") | ||
| 273 | for _, m := range d.Members { | ||
| 274 | tb.row(cRef(m.Name), cState(m.Role)) | ||
| 275 | } | ||
| 276 | tb.flush() | ||
| 264 | } | 277 | } |
| 265 | if d.About != "" { | 278 | if len(d.Repos) > 0 { |
| 266 | fmt.Fprintf(w, "\n%s\n", d.About) | 279 | v.section("repo") |
| 280 | tb := c.table(w, "REPO", "VISIBILITY", "DESCRIPTION") | ||
| 281 | for _, r := range d.Repos { | ||
| 282 | tb.row(cRef(r.Path), cState(r.Visibility), cFlex(r.Description)) | ||
| 283 | } | ||
| 284 | tb.flush() | ||
| 267 | } | 285 | } |
| 286 | v.body(d.About, d.AboutFormat) | ||
| 268 | }) | 287 | }) |
| 269 | } | 288 | } |
| 270 | 289 | ||
internal/control/release.go +16 −5
| @@ -265,12 +265,23 @@ func runReleaseShow(c *Ctx, args []string) int { | |||
| 265 | } | 265 | } |
| 266 | d := releaseToOut(rel, true) | 266 | d := releaseToOut(rel, true) |
| 267 | return c.emit(d, func(w io.Writer) { | 267 | return c.emit(d, func(w io.Writer) { |
| 268 | fmt.Fprintf(w, "%s\t%s\tby %s on %s\n", d.Tag, d.Title, d.Author, d.CreatedAt) | 268 | title := d.Title |
| 269 | if d.Notes != "" { | 269 | if title == d.Tag { |
| 270 | fmt.Fprintf(w, "\n%s\n", d.Notes) | 270 | title = "" |
| 271 | } | 271 | } |
| 272 | for _, a := range d.Assets { | 272 | v := c.view(w) |
| 273 | fmt.Fprintf(w, "%s\t%d\t%s\n", a.Name, a.Size, a.SHA256) | 273 | v.title(d.Tag, title, "") |
| 274 | v.fields( | ||
| 275 | "author", d.Author+", "+c.when(d.CreatedAt), | ||
| 276 | ) | ||
| 277 | v.body(d.Notes, d.NotesFormat) | ||
| 278 | if len(d.Assets) > 0 { | ||
| 279 | io.WriteString(w, "\n") | ||
| 280 | tb := c.table(w, "NAME", "SIZE", "SHA256") | ||
| 281 | for _, a := range d.Assets { | ||
| 282 | tb.row(cRef(a.Name), cNum(a.Size), cFlex(a.SHA256)) | ||
| 283 | } | ||
| 284 | tb.flush() | ||
| 274 | } | 285 | } |
| 275 | }) | 286 | }) |
| 276 | } | 287 | } |
internal/control/repo.go +43 −37
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | "path" | 8 | "path" |
| 9 | "path/filepath" | 9 | "path/filepath" |
| 10 | "slices" | 10 | "slices" |
| 11 | "strconv" | ||
| 11 | "strings" | 12 | "strings" |
| 12 | 13 | ||
| 13 | "gitbay.org/gitbay/internal/gitutil" | 14 | "gitbay.org/gitbay/internal/gitutil" |
| @@ -361,44 +362,41 @@ func runRepoShow(c *Ctx, args []string) int { | |||
| 361 | } | 362 | } |
| 362 | } | 363 | } |
| 363 | return c.emit(d, func(w io.Writer) { | 364 | return c.emit(d, func(w io.Writer) { |
| 364 | line := fmt.Sprintf("%s\t%s\tdefault: %s", d.Path, d.Visibility, d.DefaultBranch) | 365 | bookmarked, archived := "", "" |
| 365 | if d.Archived { | ||
| 366 | line += "\t[archived]" | ||
| 367 | } | ||
| 368 | fmt.Fprintln(w, line) | ||
| 369 | if d.Description != "" { | ||
| 370 | fmt.Fprintf(w, "%s\n", d.Description) | ||
| 371 | } | ||
| 372 | if d.Website != "" { | ||
| 373 | fmt.Fprintf(w, "website: %s\n", d.Website) | ||
| 374 | } | ||
| 375 | if len(d.Topics) > 0 { | ||
| 376 | fmt.Fprintf(w, "topics: %s\n", strings.Join(d.Topics, ", ")) | ||
| 377 | } | ||
| 378 | if len(d.ProtectedBranches) > 0 { | ||
| 379 | fmt.Fprintf(w, "protected: %s\n", strings.Join(d.ProtectedBranches, ", ")) | ||
| 380 | } | ||
| 381 | if len(d.Domains) > 0 { | ||
| 382 | fmt.Fprintf(w, "pages domains: %s\n", strings.Join(d.Domains, ", ")) | ||
| 383 | } | ||
| 384 | if d.ForkOf != "" { | ||
| 385 | fmt.Fprintf(w, "fork of: %s\n", d.ForkOf) | ||
| 386 | } | ||
| 387 | if d.Watch != "" { | ||
| 388 | fmt.Fprintf(w, "watch: %s\n", d.Watch) | ||
| 389 | } | ||
| 390 | if d.Bookmarked { | 366 | if d.Bookmarked { |
| 391 | fmt.Fprintln(w, "bookmarked") | 367 | bookmarked = "yes" |
| 392 | } | 368 | } |
| 393 | for _, m := range d.Mirrors { | 369 | if d.Archived { |
| 394 | status := "ok" | 370 | archived = "yes" |
| 395 | if m.Pending { | 371 | } |
| 396 | status = "pending" | 372 | v := c.view(w) |
| 397 | } | 373 | v.title(d.Path, d.Description, d.Visibility) |
| 398 | if m.LastError != "" { | 374 | v.fields( |
| 399 | status = "error: " + m.LastError | 375 | "default branch", d.DefaultBranch, |
| 376 | "website", d.Website, | ||
| 377 | "topics", strings.Join(d.Topics, ", "), | ||
| 378 | "protected", strings.Join(d.ProtectedBranches, ", "), | ||
| 379 | "pages domains", strings.Join(d.Domains, ", "), | ||
| 380 | "fork of", d.ForkOf, | ||
| 381 | "watch", d.Watch, | ||
| 382 | "bookmarked", bookmarked, | ||
| 383 | "archived", archived, | ||
| 384 | "url", c.siteURL(d.Path), | ||
| 385 | ) | ||
| 386 | if len(d.Mirrors) > 0 { | ||
| 387 | v.section("mirror") | ||
| 388 | tb := c.table(w, "DIRECTION", "URL", "LAST SYNC", "STATUS") | ||
| 389 | for _, m := range d.Mirrors { | ||
| 390 | status := "ok" | ||
| 391 | if m.Pending { | ||
| 392 | status = "pending" | ||
| 393 | } | ||
| 394 | if m.LastError != "" { | ||
| 395 | status = "error: " + m.LastError | ||
| 396 | } | ||
| 397 | tb.row(cText(m.Direction), cFlex(m.URL), cText(orDash(m.LastSync)), cState(status)) | ||
| 400 | } | 398 | } |
| 401 | fmt.Fprintf(w, "mirror: %s %s\tlast %s\t%s\n", m.Direction, m.URL, orDash(m.LastSync), status) | 399 | tb.flush() |
| 402 | } | 400 | } |
| 403 | }) | 401 | }) |
| 404 | } | 402 | } |
| @@ -630,8 +628,16 @@ func runSettingsShow(c *Ctx, args []string) int { | |||
| 630 | return code | 628 | return code |
| 631 | } | 629 | } |
| 632 | return c.emit(repo.Settings, func(w io.Writer) { | 630 | return c.emit(repo.Settings, func(w io.Writer) { |
| 633 | fmt.Fprintf(w, "protected_branches: %s\nprotected_tags: %s\nrequire_mr: %v\nrequire_signed_commits: %v\ngit_daemon: %v\narchived: %v\n", | 631 | v := c.view(w) |
| 634 | strings.Join(repo.Settings.ProtectedBranches, ", "), strings.Join(repo.Settings.ProtectedTags, ", "), repo.Settings.RequireMR, repo.Settings.RequireSignedCommits, repo.Settings.GitDaemon, repo.Settings.Archived) | 632 | v.title(repo.Path(), "settings", "") |
| 633 | v.fields( | ||
| 634 | "protected branches", strings.Join(repo.Settings.ProtectedBranches, ", "), | ||
| 635 | "protected tags", strings.Join(repo.Settings.ProtectedTags, ", "), | ||
| 636 | "require mr", strconv.FormatBool(repo.Settings.RequireMR), | ||
| 637 | "require signed commits", strconv.FormatBool(repo.Settings.RequireSignedCommits), | ||
| 638 | "git daemon", strconv.FormatBool(repo.Settings.GitDaemon), | ||
| 639 | "archived", strconv.FormatBool(repo.Settings.Archived), | ||
| 640 | ) | ||
| 635 | }) | 641 | }) |
| 636 | } | 642 | } |
| 637 | 643 | ||
internal/control/snippet.go +14 −9
| @@ -197,16 +197,21 @@ func runSnippetShow(c *Ctx, args []string) int { | |||
| 197 | } | 197 | } |
| 198 | sn.Files = files | 198 | sn.Files = files |
| 199 | return c.emit(snippetOut(c, sn), func(w io.Writer) { | 199 | return c.emit(snippetOut(c, sn), func(w io.Writer) { |
| 200 | fmt.Fprintf(w, "snippet %s by %s (%s)\n", sn.PublicID, sn.OwnerName, sn.Visibility) | 200 | v := c.view(w) |
| 201 | if sn.Description != "" { | 201 | v.title(sn.PublicID, sn.Description, sn.Visibility) |
| 202 | fmt.Fprintf(w, "%s\n", sn.Description) | 202 | v.fields( |
| 203 | } | 203 | "author", sn.OwnerName, |
| 204 | fmt.Fprintf(w, "%s\nupdated %s\n", snippetURL(c, sn), sn.UpdatedAt) | 204 | "updated", c.when(sn.UpdatedAt), |
| 205 | tb := c.table(w, "NAME", "SIZE") | 205 | "url", snippetURL(c, sn), |
| 206 | for _, f := range files { | 206 | ) |
| 207 | tb.row(cRef(" "+f.Name), cText(fmt.Sprintf("%d bytes", f.Size))) | 207 | if len(files) > 0 { |
| 208 | io.WriteString(w, "\n") | ||
| 209 | tb := c.table(w, "NAME", "SIZE") | ||
| 210 | for _, f := range files { | ||
| 211 | tb.row(cRef(f.Name), cText(fmt.Sprintf("%d bytes", f.Size))) | ||
| 212 | } | ||
| 213 | tb.flush() | ||
| 208 | } | 214 | } |
| 209 | tb.flush() | ||
| 210 | }) | 215 | }) |
| 211 | } | 216 | } |
| 212 | 217 | ||
internal/control/teams.go +10 −5
| @@ -177,12 +177,17 @@ func runTeamShow(c *Ctx, args []string) int { | |||
| 177 | Grants []store.TeamGrant `json:"grants,omitempty"` | 177 | Grants []store.TeamGrant `json:"grants,omitempty"` |
| 178 | }{team.Name, members, grants} | 178 | }{team.Name, members, grants} |
| 179 | return c.emit(d, func(w io.Writer) { | 179 | return c.emit(d, func(w io.Writer) { |
| 180 | fmt.Fprintf(w, "%s/%s\nmembers: %s\n", org.Name, team.Name, strings.Join(members, ", ")) | 180 | v := c.view(w) |
| 181 | tb := c.table(w, "REPO", "ROLE") | 181 | v.title(org.Name+"/"+team.Name, "", "") |
| 182 | for _, g := range grants { | 182 | v.fields("members", strings.Join(members, ", ")) |
| 183 | tb.row(cRef(g.RepoPath), cState(g.Role)) | 183 | if len(grants) > 0 { |
| 184 | io.WriteString(w, "\n") | ||
| 185 | tb := c.table(w, "REPO", "ROLE") | ||
| 186 | for _, g := range grants { | ||
| 187 | tb.row(cRef(g.RepoPath), cState(g.Role)) | ||
| 188 | } | ||
| 189 | tb.flush() | ||
| 184 | } | 190 | } |
| 185 | tb.flush() | ||
| 186 | }) | 191 | }) |
| 187 | } | 192 | } |
| 188 | 193 | ||
internal/control/thread.go +1
| @@ -54,6 +54,7 @@ type commentOut struct { | |||
| 54 | Body string `json:"body"` | 54 | Body string `json:"body"` |
| 55 | BodyFormat string `json:"body_format,omitempty"` | 55 | BodyFormat string `json:"body_format,omitempty"` |
| 56 | CreatedAt string `json:"created_at"` | 56 | CreatedAt string `json:"created_at"` |
| 57 | Kind string `json:"-"` // "comment" or "system"; not part of the wire shape | ||
| 57 | } | 58 | } |
| 58 | 59 | ||
| 59 | // thread is what a comment command needs to know about its noun. | 60 | // thread is what a comment command needs to know about its noun. |
internal/control/view.go added +198
| @@ -0,0 +1,198 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "io" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/termtext" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // when is a stored timestamp in a view: the web's format at a | ||
| 11 | // terminal, RFC3339 to the second when plain. | ||
| 12 | func (c *Ctx) when(s string) string { | ||
| 13 | if c.Term.Cols == 0 { | ||
| 14 | return stamp(s) | ||
| 15 | } | ||
| 16 | t, ok := parseStamp(s) | ||
| 17 | if !ok { | ||
| 18 | return s | ||
| 19 | } | ||
| 20 | return t.Format("2006-01-02 15:04 UTC") | ||
| 21 | } | ||
| 22 | |||
| 23 | // siteURL is the instance's address with path segments appended. | ||
| 24 | func (c *Ctx) siteURL(parts ...string) string { | ||
| 25 | return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/") | ||
| 26 | } | ||
| 27 | |||
| 28 | // view lays out a show: a title line, aligned fields, a body, events, | ||
| 29 | // comments. Plain output is the same lines without colour or wrapping. | ||
| 30 | type view struct { | ||
| 31 | c *Ctx | ||
| 32 | w io.Writer | ||
| 33 | wrote bool // has this view written anything yet | ||
| 34 | } | ||
| 35 | |||
| 36 | func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} } | ||
| 37 | |||
| 38 | func (v *view) opts() termtext.Options { | ||
| 39 | return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL} | ||
| 40 | } | ||
| 41 | |||
| 42 | // sep writes a blank line before the next block, unless this view has | ||
| 43 | // written nothing yet: fields, a body or a section as the first thing a | ||
| 44 | // command prints (admin runners, admin stats, notifications settings | ||
| 45 | // show, ...) does not open with an empty line. | ||
| 46 | func (v *view) sep() { | ||
| 47 | if v.wrote { | ||
| 48 | io.WriteString(v.w, "\n") | ||
| 49 | } | ||
| 50 | v.wrote = true | ||
| 51 | } | ||
| 52 | |||
| 53 | // section prints a sub-table's label: a blank line, then the label bold | ||
| 54 | // at a terminal or "label:" in plain. Callers skip the call entirely | ||
| 55 | // when the table it introduces has no rows. | ||
| 56 | func (v *view) section(label string) { | ||
| 57 | v.sep() | ||
| 58 | if v.c.Term.Cols == 0 { | ||
| 59 | io.WriteString(v.w, label+":\n") | ||
| 60 | return | ||
| 61 | } | ||
| 62 | io.WriteString(v.w, v.c.Term.paint(sgrBold, label)+"\n") | ||
| 63 | } | ||
| 64 | |||
| 65 | // title prints "ref title state", wrapping title+state to the | ||
| 66 | // terminal width. Continuation lines indent under the title, and each | ||
| 67 | // line is painted after wrapping so no SGR sequence crosses a break. | ||
| 68 | // title or state may be "": either is skipped rather than leaving a | ||
| 69 | // trailing blank field. | ||
| 70 | func (v *view) title(ref, title, state string) { | ||
| 71 | v.sep() | ||
| 72 | t := v.c.Term | ||
| 73 | if t.Cols == 0 { | ||
| 74 | switch { | ||
| 75 | case title == "" && state == "": | ||
| 76 | io.WriteString(v.w, ref+"\n") | ||
| 77 | case state == "": | ||
| 78 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n") | ||
| 79 | case title == "": | ||
| 80 | io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n") | ||
| 81 | default: | ||
| 82 | io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n") | ||
| 83 | } | ||
| 84 | return | ||
| 85 | } | ||
| 86 | prefix := ref + " " | ||
| 87 | indent := strings.Repeat(" ", cells(prefix)) | ||
| 88 | rest := title | ||
| 89 | if state != "" { | ||
| 90 | if rest != "" { | ||
| 91 | rest += " " | ||
| 92 | } | ||
| 93 | rest += state | ||
| 94 | } | ||
| 95 | if rest == "" { | ||
| 96 | io.WriteString(v.w, ref+"\n") | ||
| 97 | return | ||
| 98 | } | ||
| 99 | lines := termtext.Wrap(rest, t.Cols-cells(prefix)) | ||
| 100 | for i, line := range lines { | ||
| 101 | p := indent | ||
| 102 | if i == 0 { | ||
| 103 | p = prefix | ||
| 104 | } | ||
| 105 | if i < len(lines)-1 || state == "" { | ||
| 106 | io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n") | ||
| 107 | continue | ||
| 108 | } | ||
| 109 | // Last line carries the state word, the last word overall | ||
| 110 | // (states are single words): keep it coloured, not bold. | ||
| 111 | head, last := line, line | ||
| 112 | if idx := strings.LastIndex(line, " "); idx >= 0 { | ||
| 113 | head, last = line[:idx], line[idx+1:] | ||
| 114 | } else { | ||
| 115 | head = "" | ||
| 116 | } | ||
| 117 | io.WriteString(v.w, p) | ||
| 118 | if head != "" { | ||
| 119 | io.WriteString(v.w, t.paint(sgrBold, head)+" ") | ||
| 120 | } | ||
| 121 | io.WriteString(v.w, t.paint(stateColor(state), last)+"\n") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | // fields prints key/value pairs aligned on the widest key, skipping | ||
| 126 | // empty values. A value that does not fit wraps, its continuation | ||
| 127 | // lines indented to the value column. | ||
| 128 | func (v *view) fields(kv ...string) { | ||
| 129 | wide := 0 | ||
| 130 | for i := 0; i+1 < len(kv); i += 2 { | ||
| 131 | if kv[i+1] != "" { | ||
| 132 | wide = max(wide, cells(kv[i])) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | v.sep() | ||
| 136 | for i := 0; i+1 < len(kv); i += 2 { | ||
| 137 | key, val := kv[i], kv[i+1] | ||
| 138 | if val == "" { | ||
| 139 | continue | ||
| 140 | } | ||
| 141 | prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " " | ||
| 142 | if v.c.Term.Cols == 0 { | ||
| 143 | io.WriteString(v.w, prefix+val+"\n") | ||
| 144 | continue | ||
| 145 | } | ||
| 146 | indent := strings.Repeat(" ", 2+wide+2) | ||
| 147 | for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) { | ||
| 148 | p := indent | ||
| 149 | if j == 0 { | ||
| 150 | p = prefix | ||
| 151 | } | ||
| 152 | io.WriteString(v.w, p+line+"\n") | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | func (v *view) body(src, format string) { | ||
| 158 | if strings.TrimSpace(src) == "" { | ||
| 159 | return | ||
| 160 | } | ||
| 161 | v.sep() | ||
| 162 | for _, line := range strings.Split(strings.TrimRight(termtext.Render(src, format, v.opts()), "\n"), "\n") { | ||
| 163 | if line == "" { | ||
| 164 | io.WriteString(v.w, "\n") | ||
| 165 | continue | ||
| 166 | } | ||
| 167 | io.WriteString(v.w, " "+line+"\n") | ||
| 168 | } | ||
| 169 | } | ||
| 170 | |||
| 171 | // event is one line for a system comment: its text without link | ||
| 172 | // targets, the time at the right edge at a terminal. | ||
| 173 | func (v *view) event(text, format, ts string) { | ||
| 174 | line := "· " + termtext.Inline(text, format) | ||
| 175 | when := v.c.when(ts) | ||
| 176 | if cols := v.c.Term.Cols; cols > 0 { | ||
| 177 | room := cols - 2 - 2 - cells(when) | ||
| 178 | line = pad(clip(line, room), room) | ||
| 179 | } | ||
| 180 | io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n") | ||
| 181 | v.wrote = true | ||
| 182 | } | ||
| 183 | |||
| 184 | func (v *view) comment(author, ts, body, format string) { | ||
| 185 | when := v.c.when(ts) | ||
| 186 | cols := v.c.Term.Cols | ||
| 187 | if cols > 0 { | ||
| 188 | suffix := ", " + when + " " | ||
| 189 | author = clip(author, max(0, cols-cells("── "+suffix)-1)) | ||
| 190 | } | ||
| 191 | head := "── " + author + ", " + when + " " | ||
| 192 | if cols > 0 { | ||
| 193 | head += strings.Repeat("─", max(1, cols-cells(head))) | ||
| 194 | } | ||
| 195 | v.sep() | ||
| 196 | io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n") | ||
| 197 | v.body(body, format) | ||
| 198 | } | ||
internal/control/view_test.go added +128
| @@ -0,0 +1,128 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/protocol" | ||
| 9 | "gitbay.org/gitbay/internal/store" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func showIssue(t *testing.T, term Term) string { | ||
| 13 | t.Helper() | ||
| 14 | st, repo, uid := newQueueTestRepo(t) | ||
| 15 | n, err := st.CreateIssue(repo.ID, uid, "A title", "Body with a [link](/x/y).", "md") | ||
| 16 | if err != nil { | ||
| 17 | t.Fatal(err) | ||
| 18 | } | ||
| 19 | issue, err := st.IssueByNumber(repo.ID, n) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if err := st.AddIssueSystemComment(issue.ID, uid, "referenced in commit [abc1234567](/o/r/commit/abc) by [alice](/alice)"); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | ||
| 27 | c.Cfg.Server.SiteURL = "https://forge.test" | ||
| 28 | c.Term = term | ||
| 29 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { | ||
| 30 | t.Fatalf("exit %d: %s", code, errOut) | ||
| 31 | } | ||
| 32 | return c.Stdout.(*bytes.Buffer).String() | ||
| 33 | } | ||
| 34 | |||
| 35 | func TestIssueShowPlain(t *testing.T) { | ||
| 36 | out := showIssue(t, Term{}) | ||
| 37 | for _, want := range []string{ | ||
| 38 | "#1 A title open\n", | ||
| 39 | " author alice, ", | ||
| 40 | " url https://forge.test/", | ||
| 41 | "Body with a link (https://forge.test/x/y).", | ||
| 42 | " · referenced in commit abc1234567 by alice ", | ||
| 43 | } { | ||
| 44 | if !strings.Contains(out, want) { | ||
| 45 | t.Errorf("missing %q in:\n%s", want, out) | ||
| 46 | } | ||
| 47 | } | ||
| 48 | if strings.Contains(out, "\x1b") || strings.Contains(out, "](") { | ||
| 49 | t.Errorf("markup or SGR in plain view:\n%s", out) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | func TestIssueShowTerminal(t *testing.T) { | ||
| 54 | out := showIssue(t, Term{Cols: 60, Color: true}) | ||
| 55 | if !strings.Contains(out, sgrGreen+"open"+sgrReset) { | ||
| 56 | t.Errorf("state not coloured:\n%s", out) | ||
| 57 | } | ||
| 58 | if !strings.Contains(out, " UTC") { | ||
| 59 | t.Errorf("no web-format timestamp:\n%s", out) | ||
| 60 | } | ||
| 61 | for _, line := range strings.Split(stripSGR(out), "\n") { | ||
| 62 | if cells(line) > 60 { | ||
| 63 | t.Errorf("line over 60 cells: %q", line) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | // TestIssueShowNarrowWraps: a long title and long labels must not push | ||
| 69 | // any line past the terminal width, and no content is lost to it. | ||
| 70 | func TestIssueShowNarrowWraps(t *testing.T) { | ||
| 71 | st, repo, uid := newQueueTestRepo(t) | ||
| 72 | title := strings.TrimSpace(strings.Repeat("a very long issue title that keeps going on and on ", 3))[:90] | ||
| 73 | n, err := st.CreateIssue(repo.ID, uid, title, "body", "md") | ||
| 74 | if err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | for _, name := range []string{"needs-review-before-merge", "blocked-on-release", "requires-design-signoff"} { | ||
| 78 | if err := st.SetLabel(repo, name, "888888"); err != nil { | ||
| 79 | t.Fatal(err) | ||
| 80 | } | ||
| 81 | if err := st.SetIssueLabel(repo, issueIDFor(t, st, repo.ID, n), name, true); err != nil { | ||
| 82 | t.Fatal(err) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | // No SiteURL set: the url field stays a short path so it does not | ||
| 86 | // itself force an unbreakable line over width, which is not what | ||
| 87 | // this test is about (title/labels/author wrapping is). | ||
| 88 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice"}) | ||
| 89 | c.Term = Term{Cols: 40, Color: true} | ||
| 90 | if code := Dispatch(c, []string{"issue", "show", repo.Path(), "1"}); code != protocol.ExitOK { | ||
| 91 | t.Fatalf("exit %d: %s", code, errOut) | ||
| 92 | } | ||
| 93 | out := c.Stdout.(*bytes.Buffer).String() | ||
| 94 | |||
| 95 | var titleLines []string | ||
| 96 | inTitle := false | ||
| 97 | for _, line := range strings.Split(stripSGR(out), "\n") { | ||
| 98 | if cells(line) > 40 { | ||
| 99 | t.Errorf("line over 40 cells: %q", line) | ||
| 100 | } | ||
| 101 | switch { | ||
| 102 | case strings.HasPrefix(line, "#1 "): | ||
| 103 | inTitle = true | ||
| 104 | titleLines = append(titleLines, strings.TrimPrefix(line, "#1 ")) | ||
| 105 | case inTitle && strings.HasPrefix(line, " "): | ||
| 106 | titleLines = append(titleLines, strings.TrimSpace(line)) | ||
| 107 | default: | ||
| 108 | inTitle = false | ||
| 109 | } | ||
| 110 | } | ||
| 111 | got := strings.Join(strings.Fields(strings.Join(titleLines, " ")), " ") | ||
| 112 | want := strings.Join(strings.Fields(title+" open"), " ") | ||
| 113 | if got != want { | ||
| 114 | t.Errorf("title text lost across wrap: got %q, want %q", got, want) | ||
| 115 | } | ||
| 116 | if n := strings.Count(stripSGR(out), "open"); n != 1 { | ||
| 117 | t.Errorf("state word appears %d times, want 1:\n%s", n, out) | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 { | ||
| 122 | t.Helper() | ||
| 123 | issue, err := st.IssueByNumber(repoID, n) | ||
| 124 | if err != nil { | ||
| 125 | t.Fatal(err) | ||
| 126 | } | ||
| 127 | return issue.ID | ||
| 128 | } | ||
internal/control/wiki.go +24 −1
| @@ -163,8 +163,31 @@ func runWikiShow(c *Ctx, args []string) int { | |||
| 163 | } else { | 163 | } else { |
| 164 | d.Content = string(raw) | 164 | d.Content = string(raw) |
| 165 | } | 165 | } |
| 166 | format := "md" | ||
| 167 | if ext == ".org" { | ||
| 168 | format = "org" | ||
| 169 | } | ||
| 166 | return c.emit(d, func(w io.Writer) { | 170 | return c.emit(d, func(w io.Writer) { |
| 167 | fmt.Fprint(w, d.Content) | 171 | // Plain: the page source verbatim, same as any other piped |
| 172 | // file read. The title/fields/body layout is terminal-only. | ||
| 173 | if c.Term.Cols == 0 { | ||
| 174 | fmt.Fprint(w, d.Content) | ||
| 175 | return | ||
| 176 | } | ||
| 177 | v := c.view(w) | ||
| 178 | v.title(repo.Path(), page, "") | ||
| 179 | binaryNote := "" | ||
| 180 | if binary { | ||
| 181 | binaryNote = fmt.Sprintf("%d bytes, binary", d.Size) | ||
| 182 | } | ||
| 183 | v.fields( | ||
| 184 | "file", d.File, | ||
| 185 | "binary", binaryNote, | ||
| 186 | "url", c.siteURL(repo.Path(), "wiki", page), | ||
| 187 | ) | ||
| 188 | if !binary { | ||
| 189 | v.body(d.Content, format) | ||
| 190 | } | ||
| 168 | }) | 191 | }) |
| 169 | } | 192 | } |
| 170 | return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path()) | 193 | return c.fail(protocol.ExitNotFound, "no wiki page %q in %s", page, repo.Path()) |
internal/termtext/markdown.go added +197
| @@ -0,0 +1,197 @@ | |||
| 1 | package termtext | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "github.com/yuin/goldmark" | ||
| 8 | "github.com/yuin/goldmark/ast" | ||
| 9 | "github.com/yuin/goldmark/extension" | ||
| 10 | east "github.com/yuin/goldmark/extension/ast" | ||
| 11 | "github.com/yuin/goldmark/text" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // md parses as the web does (CommonMark plus GFM); raw HTML is dropped | ||
| 15 | // there and here. | ||
| 16 | var md = goldmark.New(goldmark.WithExtensions(extension.GFM)) | ||
| 17 | |||
| 18 | func Markdown(src string, o Options) string { | ||
| 19 | return renderMarkdown(src, &out{o: o}) | ||
| 20 | } | ||
| 21 | |||
| 22 | func renderMarkdown(src string, w *out) string { | ||
| 23 | source := []byte(src) | ||
| 24 | doc := md.Parser().Parse(text.NewReader(source)) | ||
| 25 | r := mdRenderer{w: w, src: source} | ||
| 26 | r.blocks(doc, "", "") | ||
| 27 | return w.String() | ||
| 28 | } | ||
| 29 | |||
| 30 | // Inline is src as one line of plain text, links reduced to their | ||
| 31 | // text, for event lines. | ||
| 32 | func Inline(src, format string) string { | ||
| 33 | w := &out{noURLs: true} | ||
| 34 | var s string | ||
| 35 | if format == "org" { | ||
| 36 | s = renderOrg(src, w) | ||
| 37 | } else { | ||
| 38 | s = renderMarkdown(src, w) | ||
| 39 | } | ||
| 40 | return strings.Join(strings.Fields(s), " ") | ||
| 41 | } | ||
| 42 | |||
| 43 | type mdRenderer struct { | ||
| 44 | w *out | ||
| 45 | src []byte | ||
| 46 | } | ||
| 47 | |||
| 48 | // blocks renders n's children with a blank line between them. The | ||
| 49 | // first child's first line is prefixed by first, every other line by | ||
| 50 | // rest. | ||
| 51 | func (r mdRenderer) blocks(n ast.Node, first, rest string) { | ||
| 52 | p := first | ||
| 53 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { | ||
| 54 | if c != n.FirstChild() { | ||
| 55 | r.w.blank() | ||
| 56 | } | ||
| 57 | r.block(c, p, rest) | ||
| 58 | p = rest | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | func (r mdRenderer) block(n ast.Node, first, rest string) { | ||
| 63 | switch n := n.(type) { | ||
| 64 | case *ast.Heading: | ||
| 65 | r.w.para(r.w.paint(sgrBold, r.inline(n)), first, rest) | ||
| 66 | case *ast.Paragraph: | ||
| 67 | r.w.para(r.inline(n), first, rest) | ||
| 68 | case *ast.TextBlock: | ||
| 69 | r.w.para(r.inline(n), first, rest) | ||
| 70 | case *ast.List: | ||
| 71 | i := n.Start | ||
| 72 | p := first | ||
| 73 | for item := n.FirstChild(); item != nil; item = item.NextSibling() { | ||
| 74 | marker := "• " | ||
| 75 | if n.IsOrdered() { | ||
| 76 | marker = fmt.Sprintf("%d. ", i) | ||
| 77 | i++ | ||
| 78 | } | ||
| 79 | hang := rest + strings.Repeat(" ", cells(marker)) | ||
| 80 | for c := item.FirstChild(); c != nil; c = c.NextSibling() { | ||
| 81 | if c == item.FirstChild() { | ||
| 82 | r.block(c, p+marker, hang) | ||
| 83 | } else { | ||
| 84 | if !n.IsTight { | ||
| 85 | r.w.blank() | ||
| 86 | } | ||
| 87 | r.block(c, hang, hang) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | p = rest | ||
| 91 | } | ||
| 92 | case *ast.FencedCodeBlock: | ||
| 93 | r.w.code(r.lines(n), string(n.Language(r.src)), rest) | ||
| 94 | case *ast.CodeBlock: | ||
| 95 | r.w.code(r.lines(n), "", rest) | ||
| 96 | case *ast.Blockquote: | ||
| 97 | bar := r.w.paint(sgrDim, "│ ") | ||
| 98 | r.blocks(n, first+bar, rest+bar) | ||
| 99 | case *ast.ThematicBreak: | ||
| 100 | r.w.rule(first) | ||
| 101 | case *ast.HTMLBlock: | ||
| 102 | // Dropped, as the web drops it. | ||
| 103 | default: | ||
| 104 | // GFM tables and anything else: the source, as a code block. | ||
| 105 | // Tables (and their rows/cells) don't carry Lines() themselves, | ||
| 106 | // so span the raw source under the node instead. | ||
| 107 | r.w.code(r.raw(n), "", rest) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | func (r mdRenderer) lines(n ast.Node) string { | ||
| 112 | var b strings.Builder | ||
| 113 | ls := n.Lines() | ||
| 114 | for i := 0; i < ls.Len(); i++ { | ||
| 115 | seg := ls.At(i) | ||
| 116 | b.Write(seg.Value(r.src)) | ||
| 117 | } | ||
| 118 | return b.String() | ||
| 119 | } | ||
| 120 | |||
| 121 | // raw returns the source text spanned by n and its descendants, | ||
| 122 | // extended to whole lines. | ||
| 123 | func (r mdRenderer) raw(n ast.Node) string { | ||
| 124 | start, end := -1, -1 | ||
| 125 | var walk func(ast.Node) | ||
| 126 | walk = func(x ast.Node) { | ||
| 127 | if x.Type() == ast.TypeBlock { | ||
| 128 | ls := x.Lines() | ||
| 129 | for i := 0; i < ls.Len(); i++ { | ||
| 130 | seg := ls.At(i) | ||
| 131 | if start == -1 || seg.Start < start { | ||
| 132 | start = seg.Start | ||
| 133 | } | ||
| 134 | if seg.Stop > end { | ||
| 135 | end = seg.Stop | ||
| 136 | } | ||
| 137 | } | ||
| 138 | } | ||
| 139 | for c := x.FirstChild(); c != nil; c = c.NextSibling() { | ||
| 140 | walk(c) | ||
| 141 | } | ||
| 142 | } | ||
| 143 | walk(n) | ||
| 144 | if start == -1 { | ||
| 145 | return "" | ||
| 146 | } | ||
| 147 | for start > 0 && r.src[start-1] != '\n' { | ||
| 148 | start-- | ||
| 149 | } | ||
| 150 | for end < len(r.src) && r.src[end] != '\n' { | ||
| 151 | end++ | ||
| 152 | } | ||
| 153 | return string(r.src[start:end]) | ||
| 154 | } | ||
| 155 | |||
| 156 | func (r mdRenderer) inline(n ast.Node) string { | ||
| 157 | var b strings.Builder | ||
| 158 | for c := n.FirstChild(); c != nil; c = c.NextSibling() { | ||
| 159 | switch c := c.(type) { | ||
| 160 | case *ast.Text: | ||
| 161 | b.Write(c.Segment.Value(r.src)) | ||
| 162 | switch { | ||
| 163 | case c.HardLineBreak(): | ||
| 164 | b.WriteString("\n") | ||
| 165 | case c.SoftLineBreak(): | ||
| 166 | b.WriteString(" ") | ||
| 167 | } | ||
| 168 | case *ast.String: | ||
| 169 | b.Write(c.Value) | ||
| 170 | case *ast.CodeSpan: | ||
| 171 | b.WriteString(r.inline(c)) | ||
| 172 | case *ast.Emphasis: | ||
| 173 | sgr := sgrUnderline | ||
| 174 | if c.Level == 2 { | ||
| 175 | sgr = sgrBold | ||
| 176 | } | ||
| 177 | b.WriteString(r.w.paint(sgr, r.inline(c))) | ||
| 178 | case *ast.Link: | ||
| 179 | b.WriteString(r.w.link(r.inline(c), string(c.Destination))) | ||
| 180 | case *ast.AutoLink: | ||
| 181 | u := string(c.URL(r.src)) | ||
| 182 | b.WriteString(r.w.link(u, u)) | ||
| 183 | case *ast.Image: | ||
| 184 | b.WriteString("[image: " + r.inline(c) + "]") | ||
| 185 | case *ast.RawHTML: | ||
| 186 | case *east.TaskCheckBox: | ||
| 187 | if c.IsChecked { | ||
| 188 | b.WriteString("[x] ") | ||
| 189 | } else { | ||
| 190 | b.WriteString("[ ] ") | ||
| 191 | } | ||
| 192 | default: | ||
| 193 | b.WriteString(r.inline(c)) | ||
| 194 | } | ||
| 195 | } | ||
| 196 | return b.String() | ||
| 197 | } | ||
internal/termtext/markdown_test.go added +101
| @@ -0,0 +1,101 @@ | |||
| 1 | package termtext | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "flag" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | var update = flag.Bool("update", false, "rewrite golden files") | ||
| 12 | |||
| 13 | func golden(t *testing.T, name, got string) { | ||
| 14 | t.Helper() | ||
| 15 | path := filepath.Join("testdata", name) | ||
| 16 | if *update { | ||
| 17 | os.WriteFile(path, []byte(got), 0o644) | ||
| 18 | } | ||
| 19 | want, err := os.ReadFile(path) | ||
| 20 | if err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if got != string(want) { | ||
| 24 | t.Errorf("%s differs:\n--- got\n%s\n--- want\n%s", name, got, want) | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | func TestMarkdownGolden(t *testing.T) { | ||
| 29 | src, err := os.ReadFile("testdata/doc.md") | ||
| 30 | if err != nil { | ||
| 31 | t.Fatal(err) | ||
| 32 | } | ||
| 33 | for _, o := range []struct { | ||
| 34 | name string | ||
| 35 | opt Options | ||
| 36 | }{ | ||
| 37 | {"doc.md.plain.golden", Options{Base: "https://forge.test"}}, | ||
| 38 | {"doc.md.60.golden", Options{Width: 60, Base: "https://forge.test"}}, | ||
| 39 | {"doc.md.60color.golden", Options{Width: 60, Color: true, Base: "https://forge.test"}}, | ||
| 40 | } { | ||
| 41 | golden(t, o.name, Markdown(string(src), o.opt)) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | func TestMarkdownWidth(t *testing.T) { | ||
| 46 | src, _ := os.ReadFile("testdata/doc.md") | ||
| 47 | out := Markdown(string(src), Options{Width: 60, Color: true}) | ||
| 48 | inCode := false | ||
| 49 | for _, line := range strings.Split(out, "\n") { | ||
| 50 | plain := stripSGR(line) | ||
| 51 | if strings.HasPrefix(plain, " ") { | ||
| 52 | inCode = true | ||
| 53 | } else if plain != "" { | ||
| 54 | inCode = false | ||
| 55 | } | ||
| 56 | if !inCode && cells(plain) > 60 { | ||
| 57 | t.Errorf("line of %d cells: %q", cells(plain), plain) | ||
| 58 | } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | // TestWrappedColorClosesAtLineEnd covers a bold span that wraps mid-run: | ||
| 63 | // the style must not bleed across the line break onto the unstyled | ||
| 64 | // list-hang prefix, and stripping colour must reproduce the plain | ||
| 65 | // rendering exactly. | ||
| 66 | func TestWrappedColorClosesAtLineEnd(t *testing.T) { | ||
| 67 | src := "- item with **some very long bold phrase spanning many words here** and more text after it to force a wrap" | ||
| 68 | color := Markdown(src, Options{Width: 30, Color: true}) | ||
| 69 | plain := Markdown(src, Options{Width: 30}) | ||
| 70 | |||
| 71 | if got := stripSGR(color); got != plain { | ||
| 72 | t.Errorf("stripSGR(color) = %q, want %q", got, plain) | ||
| 73 | } | ||
| 74 | |||
| 75 | // Both continuation lines land wholly inside the wrapped bold span | ||
| 76 | // (the first) or start inside it (the second): the plain prefix | ||
| 77 | // always comes before the reopened style, never styled itself, and | ||
| 78 | // a run left open at wrap time is closed again at line's end. | ||
| 79 | lines := strings.Split(strings.TrimRight(color, "\n"), "\n") | ||
| 80 | for _, i := range []int{1, 2} { | ||
| 81 | if !strings.HasPrefix(lines[i], " \x1b[1m") { | ||
| 82 | t.Errorf("line %d: styled run starts before the plain prefix, or is missing: %q", i, lines[i]) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | if !strings.HasSuffix(lines[1], sgrReset) { | ||
| 86 | t.Errorf("line 1: open run never closed: %q", lines[1]) | ||
| 87 | } | ||
| 88 | if want := " \x1b[1mbold phrase spanning many\x1b[0m"; lines[1] != want { | ||
| 89 | t.Errorf("line 1 = %q, want %q", lines[1], want) | ||
| 90 | } | ||
| 91 | if want := " \x1b[1mwords here\x1b[0m and more text"; lines[2] != want { | ||
| 92 | t.Errorf("line 2 = %q, want %q", lines[2], want) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | func TestInlineDropsLinkTargets(t *testing.T) { | ||
| 97 | got := Inline("referenced in commit [6c4d1e1454](/krz/gitbay/commit/6c4d) by [cmc](/cmc): landing", "md") | ||
| 98 | if got != "referenced in commit 6c4d1e1454 by cmc: landing" { | ||
| 99 | t.Errorf("Inline = %q", got) | ||
| 100 | } | ||
| 101 | } | ||
internal/termtext/org.go added +210
| @@ -0,0 +1,210 @@ | |||
| 1 | package termtext | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "errors" | ||
| 6 | "io" | ||
| 7 | "log" | ||
| 8 | "strings" | ||
| 9 | |||
| 10 | "github.com/niklasfasching/go-org/org" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func Org(src string, o Options) string { | ||
| 14 | return renderOrg(src, &out{o: o}) | ||
| 15 | } | ||
| 16 | |||
| 17 | // renderOrg parses with the same restrictions as the web: no file is | ||
| 18 | // ever read (#+INCLUDE, #+SETUPFILE), and parse warnings go nowhere. | ||
| 19 | func renderOrg(src string, w *out) string { | ||
| 20 | c := org.New() | ||
| 21 | c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("org: includes are disabled") } | ||
| 22 | c.Log = log.New(io.Discard, "", 0) | ||
| 23 | doc := c.Parse(bytes.NewReader([]byte(src)), "") | ||
| 24 | r := orgRenderer{w: w} | ||
| 25 | r.nodes(doc.Nodes, "", "") | ||
| 26 | return w.String() | ||
| 27 | } | ||
| 28 | |||
| 29 | type orgRenderer struct{ w *out } | ||
| 30 | |||
| 31 | func (r orgRenderer) nodes(ns []org.Node, first, rest string) { | ||
| 32 | p := first | ||
| 33 | wrote := false | ||
| 34 | for _, n := range ns { | ||
| 35 | if skipOrg(n) { | ||
| 36 | continue | ||
| 37 | } | ||
| 38 | if wrote { | ||
| 39 | r.w.blank() | ||
| 40 | } | ||
| 41 | r.block(n, p, rest) | ||
| 42 | p, wrote = rest, true | ||
| 43 | } | ||
| 44 | } | ||
| 45 | |||
| 46 | // skipOrg drops nodes that carry nothing to render: keywords | ||
| 47 | // (#+INCLUDE included — ReadFile already refuses the read, but the | ||
| 48 | // keyword itself is still a line of source, not content), property | ||
| 49 | // drawers, comments, and the empty paragraph go-org emits for a blank | ||
| 50 | // line that separated two blocks or ended a list item. | ||
| 51 | func skipOrg(n org.Node) bool { | ||
| 52 | switch n := n.(type) { | ||
| 53 | case org.Keyword, org.Include, *org.PropertyDrawer, org.Comment: | ||
| 54 | return true | ||
| 55 | case org.Paragraph: | ||
| 56 | return len(n.Children) == 0 | ||
| 57 | } | ||
| 58 | return false | ||
| 59 | } | ||
| 60 | |||
| 61 | func (r orgRenderer) block(n org.Node, first, rest string) { | ||
| 62 | switch n := n.(type) { | ||
| 63 | case org.Headline: | ||
| 64 | r.w.para(r.w.paint(sgrBold, r.inline(n.Title)), first, rest) | ||
| 65 | if len(n.Children) > 0 { | ||
| 66 | r.w.blank() | ||
| 67 | r.nodes(n.Children, rest, rest) | ||
| 68 | } | ||
| 69 | case org.Paragraph: | ||
| 70 | r.w.para(r.inline(n.Children), first, rest) | ||
| 71 | case org.List: | ||
| 72 | loose := looseOrgList(n) | ||
| 73 | p := first | ||
| 74 | for i, item := range n.Items { | ||
| 75 | if i > 0 && loose { | ||
| 76 | r.w.blank() | ||
| 77 | } | ||
| 78 | switch item := item.(type) { | ||
| 79 | case org.ListItem: | ||
| 80 | marker := "• " | ||
| 81 | if n.Kind == "ordered" { | ||
| 82 | marker = item.Bullet + " " | ||
| 83 | } | ||
| 84 | hang := rest + strings.Repeat(" ", cells(marker)) | ||
| 85 | r.itemChildren(item.Children, p+marker, hang, loose) | ||
| 86 | case org.DescriptiveListItem: | ||
| 87 | term := r.w.paint(sgrBold, r.inline(item.Term)) | ||
| 88 | r.w.para(term, p, rest) | ||
| 89 | r.itemChildren(item.Details, rest+" ", rest+" ", loose) | ||
| 90 | default: | ||
| 91 | r.w.code(org.String(item), "", rest) | ||
| 92 | } | ||
| 93 | p = rest | ||
| 94 | } | ||
| 95 | case org.Block: | ||
| 96 | switch strings.ToUpper(n.Name) { | ||
| 97 | case "SRC": | ||
| 98 | lang := "" | ||
| 99 | if len(n.Parameters) > 0 { | ||
| 100 | lang = n.Parameters[0] | ||
| 101 | } | ||
| 102 | r.w.code(org.String(n.Children...), lang, rest) | ||
| 103 | case "QUOTE": | ||
| 104 | bar := r.w.paint(sgrDim, "│ ") | ||
| 105 | r.nodes(n.Children, first+bar, rest+bar) | ||
| 106 | default: | ||
| 107 | r.w.code(org.String(n.Children...), "", rest) | ||
| 108 | } | ||
| 109 | case org.Example: | ||
| 110 | r.w.code(org.String(n.Children...), "", rest) | ||
| 111 | case org.HorizontalRule: | ||
| 112 | r.w.rule(first) | ||
| 113 | default: | ||
| 114 | r.w.code(org.String(n), "", rest) | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | // looseOrgList reports whether n's source had a blank line between | ||
| 119 | // any two of its items. go-org marks the item before such a blank | ||
| 120 | // with a trailing empty org.Paragraph — the same artifact skipOrg | ||
| 121 | // drops elsewhere, checked here first since skipOrg would erase it. | ||
| 122 | // The list's own last item gets that trailing empty paragraph too | ||
| 123 | // whenever a blank line follows the whole list (ending it before the | ||
| 124 | // next block), which says nothing about spacing inside the list, so | ||
| 125 | // only non-last items are checked. A single-item list is always | ||
| 126 | // tight by this measure: there is no gap between items to have or | ||
| 127 | // lack a blank line. | ||
| 128 | func looseOrgList(n org.List) bool { | ||
| 129 | for i := 0; i < len(n.Items)-1; i++ { | ||
| 130 | var children []org.Node | ||
| 131 | switch item := n.Items[i].(type) { | ||
| 132 | case org.ListItem: | ||
| 133 | children = item.Children | ||
| 134 | case org.DescriptiveListItem: | ||
| 135 | children = item.Details | ||
| 136 | default: | ||
| 137 | continue | ||
| 138 | } | ||
| 139 | if len(children) == 0 { | ||
| 140 | continue | ||
| 141 | } | ||
| 142 | if p, ok := children[len(children)-1].(org.Paragraph); ok && len(p.Children) == 0 { | ||
| 143 | return true | ||
| 144 | } | ||
| 145 | } | ||
| 146 | return false | ||
| 147 | } | ||
| 148 | |||
| 149 | // itemChildren renders a list item's children. A loose list keeps | ||
| 150 | // nodes' blank line between them; a tight list runs them straight | ||
| 151 | // together, so a nested list sits directly under its parent item's | ||
| 152 | // line rather than a line below it. | ||
| 153 | func (r orgRenderer) itemChildren(ns []org.Node, first, rest string, loose bool) { | ||
| 154 | if loose { | ||
| 155 | r.nodes(ns, first, rest) | ||
| 156 | return | ||
| 157 | } | ||
| 158 | p := first | ||
| 159 | for _, n := range ns { | ||
| 160 | if skipOrg(n) { | ||
| 161 | continue | ||
| 162 | } | ||
| 163 | r.block(n, p, rest) | ||
| 164 | p = rest | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | // trimOrgLineBreaks drops leading and trailing org.LineBreak nodes: a | ||
| 169 | // paragraph that follows a keyword across a blank line, or a blank | ||
| 170 | // line gap already handled elsewhere, otherwise renders that break as | ||
| 171 | // the leading or trailing space inline() gives it. | ||
| 172 | func trimOrgLineBreaks(ns []org.Node) []org.Node { | ||
| 173 | isBreak := func(n org.Node) bool { _, ok := n.(org.LineBreak); return ok } | ||
| 174 | for len(ns) > 0 && isBreak(ns[0]) { | ||
| 175 | ns = ns[1:] | ||
| 176 | } | ||
| 177 | for len(ns) > 0 && isBreak(ns[len(ns)-1]) { | ||
| 178 | ns = ns[:len(ns)-1] | ||
| 179 | } | ||
| 180 | return ns | ||
| 181 | } | ||
| 182 | |||
| 183 | func (r orgRenderer) inline(ns []org.Node) string { | ||
| 184 | ns = trimOrgLineBreaks(ns) | ||
| 185 | var b strings.Builder | ||
| 186 | for _, n := range ns { | ||
| 187 | switch n := n.(type) { | ||
| 188 | case org.Text: | ||
| 189 | b.WriteString(n.Content) | ||
| 190 | case org.LineBreak: | ||
| 191 | b.WriteString(" ") | ||
| 192 | case org.ExplicitLineBreak: | ||
| 193 | b.WriteString("\n") | ||
| 194 | case org.Emphasis: | ||
| 195 | s := r.inline(n.Content) | ||
| 196 | switch n.Kind { | ||
| 197 | case "*": | ||
| 198 | s = r.w.paint(sgrBold, s) | ||
| 199 | case "/", "_": | ||
| 200 | s = r.w.paint(sgrUnderline, s) | ||
| 201 | } | ||
| 202 | b.WriteString(s) | ||
| 203 | case org.RegularLink: | ||
| 204 | b.WriteString(r.w.link(r.inline(n.Description), n.URL)) | ||
| 205 | default: | ||
| 206 | b.WriteString(org.String(n)) | ||
| 207 | } | ||
| 208 | } | ||
| 209 | return b.String() | ||
| 210 | } | ||
internal/termtext/org_test.go added +45
| @@ -0,0 +1,45 @@ | |||
| 1 | package termtext | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "os" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | ) | ||
| 8 | |||
| 9 | func TestOrgGolden(t *testing.T) { | ||
| 10 | src, err := os.ReadFile("testdata/doc.org") | ||
| 11 | if err != nil { | ||
| 12 | t.Fatal(err) | ||
| 13 | } | ||
| 14 | golden(t, "doc.org.plain.golden", Org(string(src), Options{Base: "https://forge.test"})) | ||
| 15 | golden(t, "doc.org.60.golden", Org(string(src), Options{Width: 60, Base: "https://forge.test"})) | ||
| 16 | golden(t, "doc.org.60color.golden", Org(string(src), Options{Width: 60, Color: true, Base: "https://forge.test"})) | ||
| 17 | } | ||
| 18 | |||
| 19 | // #+INCLUDE reads nothing from the server's disk, and the keyword | ||
| 20 | // itself renders nothing either. | ||
| 21 | func TestOrgIncludeIsInert(t *testing.T) { | ||
| 22 | got := Org("#+INCLUDE: \"/etc/passwd\"\n\ntext\n", Options{}) | ||
| 23 | if strings.Contains(got, "root:") { | ||
| 24 | t.Fatalf("include read a file: %q", got) | ||
| 25 | } | ||
| 26 | if strings.Contains(got, "#+INCLUDE") { | ||
| 27 | t.Fatalf("include rendered as text: %q", got) | ||
| 28 | } | ||
| 29 | } | ||
| 30 | |||
| 31 | // A keyword (dropped) followed by a blank line then a paragraph must | ||
| 32 | // not leave the paragraph's leading LineBreak as a stray space. | ||
| 33 | func TestOrgKeywordGapNoLeadingSpace(t *testing.T) { | ||
| 34 | got := Org("#+SETUPFILE: \"x\"\n\ntext\n", Options{}) | ||
| 35 | if got != "text\n" { | ||
| 36 | t.Errorf("Org = %q, want %q", got, "text\n") | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | func TestOrgInlineDropsLinkTargets(t *testing.T) { | ||
| 41 | got := Inline("see [[https://x.test/a][the page]] now", "org") | ||
| 42 | if got != "see the page now" { | ||
| 43 | t.Errorf("Inline = %q", got) | ||
| 44 | } | ||
| 45 | } | ||
internal/termtext/termtext.go added +210
| @@ -0,0 +1,210 @@ | |||
| 1 | // Package termtext renders markdown and org to text for a terminal: | ||
| 2 | // wrapped to a width, links reduced to their text, code highlighted | ||
| 3 | // with 16 colours. Width 0 is plain: no wrapping and no SGR, for | ||
| 4 | // piped output. | ||
| 5 | package termtext | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "bytes" | ||
| 9 | "strings" | ||
| 10 | "unicode" | ||
| 11 | "unicode/utf8" | ||
| 12 | |||
| 13 | "github.com/alecthomas/chroma/v2/quick" | ||
| 14 | "golang.org/x/text/width" | ||
| 15 | ) | ||
| 16 | |||
| 17 | type Options struct { | ||
| 18 | Width int | ||
| 19 | Color bool | ||
| 20 | Base string | ||
| 21 | } | ||
| 22 | |||
| 23 | func Render(src, format string, o Options) string { | ||
| 24 | if format == "org" { | ||
| 25 | return Org(src, o) | ||
| 26 | } | ||
| 27 | return Markdown(src, o) | ||
| 28 | } | ||
| 29 | |||
| 30 | const ( | ||
| 31 | sgrReset = "\x1b[0m" | ||
| 32 | sgrBold = "\x1b[1m" | ||
| 33 | sgrDim = "\x1b[2m" | ||
| 34 | sgrUnderline = "\x1b[4m" | ||
| 35 | ) | ||
| 36 | |||
| 37 | // out collects rendered lines. Every block goes through it so the | ||
| 38 | // prefixes (indent, list marker, quote bar) and the wrap live in one | ||
| 39 | // place. | ||
| 40 | type out struct { | ||
| 41 | o Options | ||
| 42 | b strings.Builder | ||
| 43 | noURLs bool // links as their text only (Inline) | ||
| 44 | } | ||
| 45 | |||
| 46 | func (w *out) paint(sgr, s string) string { | ||
| 47 | if !w.o.Color || s == "" { | ||
| 48 | return s | ||
| 49 | } | ||
| 50 | return sgr + s + sgrReset | ||
| 51 | } | ||
| 52 | |||
| 53 | // para writes s wrapped to the width, the first line after first and | ||
| 54 | // the rest after rest. Hard breaks in s ("\n") start a new line. An | ||
| 55 | // SGR run open at a wrapped line's end is closed there and reopened | ||
| 56 | // after the next line's prefix, so the prefix itself is never painted | ||
| 57 | // and a style never bleeds past a line break. | ||
| 58 | func (w *out) para(s, first, rest string) { | ||
| 59 | prefix := first | ||
| 60 | var open []string | ||
| 61 | for _, hard := range strings.Split(s, "\n") { | ||
| 62 | for _, line := range Wrap(hard, w.o.Width-cells(rest)) { | ||
| 63 | w.b.WriteString(prefix) | ||
| 64 | for _, sgr := range open { | ||
| 65 | w.b.WriteString(sgr) | ||
| 66 | } | ||
| 67 | w.b.WriteString(line) | ||
| 68 | open = sgrOpen(line, open) | ||
| 69 | if len(open) > 0 { | ||
| 70 | w.b.WriteString(sgrReset) | ||
| 71 | } | ||
| 72 | w.b.WriteString("\n") | ||
| 73 | prefix = rest | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | // sgrOpen scans s for SGR sequences, starting from the stack of runs | ||
| 79 | // already open, and returns the stack still open at s's end. sgrReset | ||
| 80 | // clears the whole stack; any other sequence pushes onto it. | ||
| 81 | func sgrOpen(s string, open []string) []string { | ||
| 82 | for i := 0; i < len(s); i++ { | ||
| 83 | if s[i] != 0x1b { | ||
| 84 | continue | ||
| 85 | } | ||
| 86 | j := strings.IndexByte(s[i:], 'm') | ||
| 87 | if j < 0 { | ||
| 88 | break | ||
| 89 | } | ||
| 90 | sgr := s[i : i+j+1] | ||
| 91 | if sgr == sgrReset { | ||
| 92 | open = nil | ||
| 93 | } else { | ||
| 94 | open = append(open, sgr) | ||
| 95 | } | ||
| 96 | i += j | ||
| 97 | } | ||
| 98 | return open | ||
| 99 | } | ||
| 100 | |||
| 101 | // code writes lines verbatim under prefix plus four spaces, | ||
| 102 | // highlighted when colour is on. | ||
| 103 | func (w *out) code(src, lang, prefix string) { | ||
| 104 | src = strings.TrimRight(src, "\n") | ||
| 105 | if w.o.Color && w.o.Width > 0 { | ||
| 106 | var hb bytes.Buffer | ||
| 107 | if lang == "" { | ||
| 108 | lang = "plaintext" | ||
| 109 | } | ||
| 110 | if quick.Highlight(&hb, src, lang, "terminal16", "monokai") == nil { | ||
| 111 | src = strings.TrimRight(hb.String(), "\n") | ||
| 112 | } | ||
| 113 | } | ||
| 114 | for _, line := range strings.Split(src, "\n") { | ||
| 115 | w.b.WriteString(prefix + " " + line + "\n") | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | func (w *out) rule(prefix string) { | ||
| 120 | w.b.WriteString(prefix + w.paint(sgrDim, "───") + "\n") | ||
| 121 | } | ||
| 122 | |||
| 123 | func (w *out) blank() { w.b.WriteString("\n") } | ||
| 124 | |||
| 125 | func (w *out) String() string { | ||
| 126 | return strings.TrimRight(w.b.String(), "\n") + "\n" | ||
| 127 | } | ||
| 128 | |||
| 129 | // link is a link as terminal text: its text, then the target when the | ||
| 130 | // target says something the text does not. Relative targets are made | ||
| 131 | // absolute against Base. | ||
| 132 | func (w *out) link(text, target string) string { | ||
| 133 | if w.noURLs && text != "" { | ||
| 134 | return text | ||
| 135 | } | ||
| 136 | if strings.HasPrefix(target, "/") && w.o.Base != "" { | ||
| 137 | target = strings.TrimRight(w.o.Base, "/") + target | ||
| 138 | } | ||
| 139 | if text == "" { | ||
| 140 | return target | ||
| 141 | } | ||
| 142 | if target == "" || target == text || "mailto:"+text == target || | ||
| 143 | strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://") == text { | ||
| 144 | return text | ||
| 145 | } | ||
| 146 | return text + " (" + target + ")" | ||
| 147 | } | ||
| 148 | |||
| 149 | // Wrap breaks s at spaces into lines of at most width cells. A word | ||
| 150 | // wider than width is a line of its own. width <= 0 is no wrapping. | ||
| 151 | func Wrap(s string, width int) []string { | ||
| 152 | if width <= 0 { | ||
| 153 | return []string{s} | ||
| 154 | } | ||
| 155 | var lines []string | ||
| 156 | var cur string | ||
| 157 | for _, word := range strings.Fields(s) { | ||
| 158 | switch { | ||
| 159 | case cur == "": | ||
| 160 | cur = word | ||
| 161 | case cells(cur)+1+cells(word) <= width: | ||
| 162 | cur += " " + word | ||
| 163 | default: | ||
| 164 | lines = append(lines, cur) | ||
| 165 | cur = word | ||
| 166 | } | ||
| 167 | } | ||
| 168 | if cur != "" || len(lines) == 0 { | ||
| 169 | lines = append(lines, cur) | ||
| 170 | } | ||
| 171 | return lines | ||
| 172 | } | ||
| 173 | |||
| 174 | func cells(s string) int { | ||
| 175 | n := 0 | ||
| 176 | for i := 0; i < len(s); { | ||
| 177 | if s[i] == 0x1b { | ||
| 178 | j := strings.IndexByte(s[i:], 'm') | ||
| 179 | if j < 0 { | ||
| 180 | break | ||
| 181 | } | ||
| 182 | i += j + 1 | ||
| 183 | continue | ||
| 184 | } | ||
| 185 | r, size := utf8.DecodeRuneInString(s[i:]) | ||
| 186 | i += size | ||
| 187 | switch { | ||
| 188 | case unicode.In(r, unicode.Mn, unicode.Me) || r == '': | ||
| 189 | case width.LookupRune(r).Kind() == width.EastAsianWide || width.LookupRune(r).Kind() == width.EastAsianFullwidth: | ||
| 190 | n += 2 | ||
| 191 | default: | ||
| 192 | n++ | ||
| 193 | } | ||
| 194 | } | ||
| 195 | return n | ||
| 196 | } | ||
| 197 | |||
| 198 | func stripSGR(s string) string { | ||
| 199 | var b strings.Builder | ||
| 200 | for i := 0; i < len(s); i++ { | ||
| 201 | if s[i] == 0x1b { | ||
| 202 | if j := strings.IndexByte(s[i:], 'm'); j >= 0 { | ||
| 203 | i += j | ||
| 204 | continue | ||
| 205 | } | ||
| 206 | } | ||
| 207 | b.WriteByte(s[i]) | ||
| 208 | } | ||
| 209 | return b.String() | ||
| 210 | } | ||
internal/termtext/testdata/doc.md added +27
| @@ -0,0 +1,27 @@ | |||
| 1 | # A heading | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with **strong** and *emphasis*, `code`, a [link](https://example.com/page), a [forge link](/krz/gitbay/issues/1), an autolink <https://example.com>, and ~~struck~~ text. | ||
| 4 | |||
| 5 | - one | ||
| 6 | - two, which is long enough that its continuation line has to hang under the text rather than the bullet | ||
| 7 | - nested | ||
| 8 | |||
| 9 | 1. first | ||
| 10 | 2. second | ||
| 11 | |||
| 12 | - [x] done | ||
| 13 | - [ ] open | ||
| 14 | |||
| 15 | > quoted text | ||
| 16 | |||
| 17 | ```go | ||
| 18 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 19 | ``` | ||
| 20 | |||
| 21 |  | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | | a | b | | ||
| 26 | |---|---| | ||
| 27 | | 1 | 2 | | ||
internal/termtext/testdata/doc.md.60.golden added +30
| @@ -0,0 +1,30 @@ | |||
| 1 | A heading | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with | ||
| 4 | strong and emphasis, code, a link | ||
| 5 | (https://example.com/page), a forge link | ||
| 6 | (https://forge.test/krz/gitbay/issues/1), an autolink | ||
| 7 | https://example.com, and struck text. | ||
| 8 | |||
| 9 | • one | ||
| 10 | • two, which is long enough that its continuation line has | ||
| 11 | to hang under the text rather than the bullet | ||
| 12 | • nested | ||
| 13 | |||
| 14 | 1. first | ||
| 15 | 2. second | ||
| 16 | |||
| 17 | • [x] done | ||
| 18 | • [ ] open | ||
| 19 | |||
| 20 | │ quoted text | ||
| 21 | |||
| 22 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 23 | |||
| 24 | [image: alt text] | ||
| 25 | |||
| 26 | ─── | ||
| 27 | |||
| 28 | | a | b | | ||
| 29 | |---|---| | ||
| 30 | | 1 | 2 | | ||
internal/termtext/testdata/doc.md.60color.golden added +30
| @@ -0,0 +1,30 @@ | |||
| 1 | [1mA heading[0m | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with | ||
| 4 | [1mstrong[0m and [4memphasis[0m, code, a link | ||
| 5 | (https://example.com/page), a forge link | ||
| 6 | (https://forge.test/krz/gitbay/issues/1), an autolink | ||
| 7 | https://example.com, and struck text. | ||
| 8 | |||
| 9 | • one | ||
| 10 | • two, which is long enough that its continuation line has | ||
| 11 | to hang under the text rather than the bullet | ||
| 12 | • nested | ||
| 13 | |||
| 14 | 1. first | ||
| 15 | 2. second | ||
| 16 | |||
| 17 | • [x] done | ||
| 18 | • [ ] open | ||
| 19 | |||
| 20 | [2m│ [0mquoted text | ||
| 21 | |||
| 22 | [96mfunc[0m[97m [0m[93mmain[0m[97m()[0m[97m [0m[97m{[0m[97m [0m[93mfmt[0m[97m.[0m[93mPrintln[0m[97m([0m[37m"a line longer than sixty columns stays on one line, unwrapped"[0m[97m)[0m[97m [0m[97m}[0m | ||
| 23 | |||
| 24 | [image: alt text] | ||
| 25 | |||
| 26 | [2m───[0m | ||
| 27 | |||
| 28 | [97m| a | b |[0m | ||
| 29 | [97m|---|---|[0m | ||
| 30 | [97m| 1 | 2 |[0m | ||
internal/termtext/testdata/doc.md.plain.golden added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | A heading | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with strong and emphasis, code, a link (https://example.com/page), a forge link (https://forge.test/krz/gitbay/issues/1), an autolink https://example.com, and struck text. | ||
| 4 | |||
| 5 | • one | ||
| 6 | • two, which is long enough that its continuation line has to hang under the text rather than the bullet | ||
| 7 | • nested | ||
| 8 | |||
| 9 | 1. first | ||
| 10 | 2. second | ||
| 11 | |||
| 12 | • [x] done | ||
| 13 | • [ ] open | ||
| 14 | |||
| 15 | │ quoted text | ||
| 16 | |||
| 17 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 18 | |||
| 19 | [image: alt text] | ||
| 20 | |||
| 21 | ─── | ||
| 22 | |||
| 23 | | a | b | | ||
| 24 | |---|---| | ||
| 25 | | 1 | 2 | | ||
internal/termtext/testdata/doc.org added +27
| @@ -0,0 +1,27 @@ | |||
| 1 | #+TITLE: ignored keyword | ||
| 2 | |||
| 3 | * A heading | ||
| 4 | A paragraph long enough to wrap at sixty columns, with *bold*, /italic/, _underline_, =verbatim=, ~code~, a [[https://example.com/page][link]], and a bare [[https://example.com]]. | ||
| 5 | |||
| 6 | - one | ||
| 7 | - two, which is long enough that its continuation line has to hang under the text rather than the bullet | ||
| 8 | - nested | ||
| 9 | |||
| 10 | 1. first | ||
| 11 | 2. second | ||
| 12 | |||
| 13 | - term :: its description | ||
| 14 | |||
| 15 | #+BEGIN_SRC go | ||
| 16 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 17 | #+END_SRC | ||
| 18 | |||
| 19 | #+BEGIN_QUOTE | ||
| 20 | quoted text | ||
| 21 | #+END_QUOTE | ||
| 22 | |||
| 23 | ----- | ||
| 24 | |||
| 25 | | a | b | | ||
| 26 | |---+---| | ||
| 27 | | 1 | 2 | | ||
internal/termtext/testdata/doc.org.60.golden added +26
| @@ -0,0 +1,26 @@ | |||
| 1 | A heading | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with bold, | ||
| 4 | italic, underline, verbatim, code, a link | ||
| 5 | (https://example.com/page), and a bare https://example.com. | ||
| 6 | |||
| 7 | • one | ||
| 8 | • two, which is long enough that its continuation line has | ||
| 9 | to hang under the text rather than the bullet | ||
| 10 | • nested | ||
| 11 | |||
| 12 | 1. first | ||
| 13 | 2. second | ||
| 14 | |||
| 15 | term | ||
| 16 | its description | ||
| 17 | |||
| 18 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 19 | |||
| 20 | │ quoted text | ||
| 21 | |||
| 22 | ─── | ||
| 23 | |||
| 24 | | a | b | | ||
| 25 | |---+---| | ||
| 26 | | 1 | 2 | | ||
internal/termtext/testdata/doc.org.60color.golden added +26
| @@ -0,0 +1,26 @@ | |||
| 1 | [1mA heading[0m | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with [1mbold[0m, | ||
| 4 | [4mitalic[0m, [4munderline[0m, verbatim, code, a link | ||
| 5 | (https://example.com/page), and a bare https://example.com. | ||
| 6 | |||
| 7 | • one | ||
| 8 | • two, which is long enough that its continuation line has | ||
| 9 | to hang under the text rather than the bullet | ||
| 10 | • nested | ||
| 11 | |||
| 12 | 1. first | ||
| 13 | 2. second | ||
| 14 | |||
| 15 | [1mterm[0m | ||
| 16 | its description | ||
| 17 | |||
| 18 | [96mfunc[0m[97m [0m[93mmain[0m[97m()[0m[97m [0m[97m{[0m[97m [0m[93mfmt[0m[97m.[0m[93mPrintln[0m[97m([0m[37m"a line longer than sixty columns stays on one line, unwrapped"[0m[97m)[0m[97m [0m[97m}[0m | ||
| 19 | |||
| 20 | [2m│ [0mquoted text | ||
| 21 | |||
| 22 | [2m───[0m | ||
| 23 | |||
| 24 | [97m| a | b |[0m | ||
| 25 | [97m|---+---|[0m | ||
| 26 | [97m| 1 | 2 |[0m | ||
internal/termtext/testdata/doc.org.plain.golden added +23
| @@ -0,0 +1,23 @@ | |||
| 1 | A heading | ||
| 2 | |||
| 3 | A paragraph long enough to wrap at sixty columns, with bold, italic, underline, verbatim, code, a link (https://example.com/page), and a bare https://example.com. | ||
| 4 | |||
| 5 | • one | ||
| 6 | • two, which is long enough that its continuation line has to hang under the text rather than the bullet | ||
| 7 | • nested | ||
| 8 | |||
| 9 | 1. first | ||
| 10 | 2. second | ||
| 11 | |||
| 12 | term | ||
| 13 | its description | ||
| 14 | |||
| 15 | func main() { fmt.Println("a line longer than sixty columns stays on one line, unwrapped") } | ||
| 16 | |||
| 17 | │ quoted text | ||
| 18 | |||
| 19 | ─── | ||
| 20 | |||
| 21 | | a | b | | ||
| 22 | |---+---| | ||
| 23 | | 1 | 2 | | ||