Commit cd2df24eb6

cd2df24eb637fbaddbade0cbafcc54acf2c52149

parent: 00817b309d

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-24 01:33 UTC

search: repository rows line up under TITLE at a terminal

Ref #254
internal/control/search.go +20 −9
@@ -97,18 +97,29 @@ func runSearch(c *Ctx, args []string) int {
9797 if err != nil {
9898 return c.fail(protocol.ExitFailure, "%v", err)
9999 }
100 return c.emit(results, func(w io.Writer) {
101 tb := c.table(w, "KIND", "REF", "STATE", "TITLE")
102 for _, r := range results {
103 switch r.Kind {
104 case "repo":
100 return c.emit(results, func(w io.Writer) { writeSearchTable(c, w, results) })
101}
102
103// writeSearchTable renders search results: a repo hit has no ref number or
104// state, an issue/mr hit has both. Plain output keeps a repo row at 3
105// cells, matching what it always printed. At a terminal the header still
106// reserves a STATE column, so a repo row gets an empty state cell there —
107// otherwise its title would render under STATE instead of TITLE.
108func writeSearchTable(c *Ctx, w io.Writer, results []SearchResult) {
109 tb := c.table(w, "KIND", "REF", "STATE", "TITLE")
110 for _, r := range results {
111 switch r.Kind {
112 case "repo":
113 if c.Term.Cols > 0 {
114 tb.row(cText("repo"), cRef(r.Repo), cState(""), cFlex(r.Title))
115 } else {
105116 tb.row(cText("repo"), cRef(r.Repo), cFlex(r.Title))
106 default:
107 tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
108117 }
118 default:
119 tb.row(cText(r.Kind), cRef(fmt.Sprintf("%s%s%d", r.Repo, SearchMarker(r.Kind), r.Number)), cState(r.State), cFlex(r.Title))
109120 }
110 tb.flush()
111 })
121 }
122 tb.flush()
112123}
113124
114125// SearchMarker is the sigil a result's number carries, shared with the web
internal/control/search_test.go added +54
@@ -0,0 +1,54 @@
1package control
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9// A repo hit has no state, an issue/mr hit does. At a terminal both must
10// still put TITLE under the TITLE header, not have the repo's title slide
11// left under STATE.
12func TestSearchTableAlignsTitleAtTerminal(t *testing.T) {
13 results := []SearchResult{
14 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
15 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
16 }
17 var b bytes.Buffer
18 writeSearchTable(&Ctx{Term: Term{Cols: 100}}, &b, results)
19
20 lines := strings.Split(strings.TrimRight(b.String(), "\n"), "\n")
21 if len(lines) != 3 {
22 t.Fatalf("want header + 2 rows, got %d lines:\n%s", len(lines), b.String())
23 }
24 header, repoRow, issueRow := lines[0], lines[1], lines[2]
25 titleAt := strings.Index(header, "TITLE")
26 repoTitleAt := strings.Index(repoRow, "a web application")
27 issueTitleAt := strings.Index(issueRow, "memory leak")
28 if titleAt < 0 || repoTitleAt < 0 || issueTitleAt < 0 {
29 t.Fatalf("columns not found:\n%s", b.String())
30 }
31 if repoTitleAt != issueTitleAt {
32 t.Errorf("titles not aligned: repo row at %d, issue row at %d\n%s", repoTitleAt, issueTitleAt, b.String())
33 }
34 if repoTitleAt != titleAt {
35 t.Errorf("title not under TITLE header: header at %d, repo row at %d\n%s", titleAt, repoTitleAt, b.String())
36 }
37}
38
39// Plain output has no header to align to, so a repo row stays 3 cells —
40// bytes must not change from before the terminal fix.
41func TestSearchTablePlainRepoRowIsThreeCells(t *testing.T) {
42 results := []SearchResult{
43 {Kind: "repo", Repo: "alice/webapp", Title: "a web application"},
44 {Kind: "issue", Repo: "alice/webapp", Number: 4, Title: "memory leak", State: "open"},
45 }
46 var b bytes.Buffer
47 writeSearchTable(&Ctx{}, &b, results)
48
49 want := "repo\talice/webapp\ta web application\n" +
50 "issue\talice/webapp#4\topen\tmemory leak\n"
51 if b.String() != want {
52 t.Errorf("plain:\n%q\nwant\n%q", b.String(), want)
53 }
54}