Commit c75998ff8e

c75998ff8e2224fe9e450b572d84497651d63b49

parent: 7f6601266e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-02 04:39 UTC

control: view writes piped output only

Every list and show at a terminal is a screen, so view's terminal
layout is gone: a caller that still reached it would get the plain
layout, with user text made safe as before. Term.heading had no other
caller.

Ref #319

Layout: unified · split

internal/control/term.go −10
@@ -194,16 +194,6 @@ func (t Term) paintState(s string) string {
194194 return strings.Join(words, ", ")
195195}
196196
197// heading is a section label at a terminal: capitalised, no trailing
198// colon, bold.
199func (t Term) heading(label string) string {
200 label = strings.TrimSuffix(label, ":")
201 if r, size := utf8.DecodeRuneInString(label); size > 0 {
202 label = string(unicode.ToUpper(r)) + label[size:]
203 }
204 return t.paint(sgrBold, label)
205}
206
207197// failure is a refusal as a terminal shows it: "error: " in red ahead
208198// of the message, and a usage line wrapped to the width between its
209199// bracketed groups, continuation lines indented under the command.
internal/control/term_test.go −6
@@ -117,12 +117,6 @@ func TestPaintStateEachWord(t *testing.T) {
117117 }
118118}
119119
120func TestHeading(t *testing.T) {
121 if got := (Term{Cols: 80}).heading("waiting on your review:"); got != "Waiting on your review" {
122 t.Errorf("heading = %q", got)
123 }
124}
125
126120func TestFailureAtTerminal(t *testing.T) {
127121 msg := "unknown flag \"--stat\"; did you mean --state?\nusage: gitbay issue list [<owner/name>] [--state open|closed|all] [--label <l>] [--assignee <user>]"
128122 got := Term{Cols: 50}.failure(msg)
internal/control/view.go +23 −97
@@ -27,8 +27,10 @@ func (c *Ctx) siteURL(parts ...string) string {
2727 return strings.TrimRight(c.Cfg.Server.SiteURL, "/") + "/" + strings.Join(parts, "/")
2828}
2929
30// view lays out a show: a title line, aligned fields, a body, events,
31// comments. Plain output is the same lines without colour or wrapping.
30// view lays out a show piped: a title line, aligned fields, a body,
31// events, comments. A terminal draws a screen instead (screen.go); a
32// caller that still reaches view at a terminal gets the same plain
33// layout, with user text made safe.
3234type view struct {
3335 c *Ctx
3436 w io.Writer
@@ -38,7 +40,7 @@ type view struct {
3840func (c *Ctx) view(w io.Writer) *view { return &view{c: c, w: w} }
3941
4042func (v *view) opts() termtext.Options {
41 return termtext.Options{Width: max(0, v.c.Term.Cols-2), Color: v.c.Term.Color, Base: v.c.Cfg.Server.SiteURL}
43 return termtext.Options{Base: v.c.Cfg.Server.SiteURL}
4244}
4345
4446// sep writes a blank line before the next block, unless this view has
@@ -52,82 +54,32 @@ func (v *view) sep() {
5254 v.wrote = true
5355}
5456
55// section prints a sub-table's label: a blank line, then the label
56// capitalised and bold at a terminal, or "label:" in plain. Callers skip the call entirely
57// when the table it introduces has no rows.
57// section prints a sub-table's label: a blank line, then "label:".
58// Callers skip the call entirely when the table it introduces has no
59// rows.
5860func (v *view) section(label string) {
5961 v.sep()
60 if v.c.Term.Cols == 0 {
61 io.WriteString(v.w, label+":\n")
62 return
63 }
64 io.WriteString(v.w, v.c.Term.heading(label)+"\n")
62 io.WriteString(v.w, label+":\n")
6563}
6664
67// title prints "ref title state", wrapping title+state to the
68// terminal width. Continuation lines indent under the title, and each
69// line is painted after wrapping so no SGR sequence crosses a break.
70// title or state may be "": either is skipped rather than leaving a
71// trailing blank field.
65// title prints "ref title state" on one line. title or state may be
66// "": either is skipped rather than leaving a trailing blank field.
7267func (v *view) title(ref, title, state string) {
7368 v.sep()
7469 t := v.c.Term
7570 ref, title, state = t.safe(ref), t.safe(title), t.safe(state)
76 if t.Cols == 0 {
77 switch {
78 case title == "" && state == "":
79 io.WriteString(v.w, ref+"\n")
80 case state == "":
81 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+"\n")
82 case title == "":
83 io.WriteString(v.w, ref+" "+t.paint(stateColor(state), state)+"\n")
84 default:
85 io.WriteString(v.w, ref+" "+t.paint(sgrBold, title)+" "+t.paint(stateColor(state), state)+"\n")
86 }
87 return
71 line := ref
72 if title != "" {
73 line += " " + title
8874 }
89 prefix := ref + " "
90 indent := strings.Repeat(" ", cells(prefix))
91 rest := title
9275 if state != "" {
93 if rest != "" {
94 rest += " "
95 }
96 rest += state
97 }
98 if rest == "" {
99 io.WriteString(v.w, t.paint(sgrDim, ref)+"\n")
100 return
101 }
102 lines := termtext.Wrap(rest, t.Cols-cells(prefix))
103 for i, line := range lines {
104 p := indent
105 if i == 0 {
106 p = t.paint(sgrDim, ref) + " "
107 }
108 if i < len(lines)-1 || state == "" {
109 io.WriteString(v.w, p+t.paint(sgrBold, line)+"\n")
110 continue
111 }
112 // Last line carries the state word, the last word overall
113 // (states are single words): keep it coloured, not bold.
114 head, last := line, line
115 if idx := strings.LastIndex(line, " "); idx >= 0 {
116 head, last = line[:idx], line[idx+1:]
117 } else {
118 head = ""
119 }
120 io.WriteString(v.w, p)
121 if head != "" {
122 io.WriteString(v.w, t.paint(sgrBold, head)+" ")
123 }
124 io.WriteString(v.w, t.paint(stateColor(state), last)+"\n")
76 line += " " + state
12577 }
78 io.WriteString(v.w, line+"\n")
12679}
12780
12881// fields prints key/value pairs aligned on the widest key, skipping
129// empty values. A value that does not fit wraps, its continuation
130// lines indented to the value column.
82// empty values.
13183func (v *view) fields(kv ...string) {
13284 wide := 0
13385 for i := 0; i+1 < len(kv); i += 2 {
@@ -141,19 +93,7 @@ func (v *view) fields(kv ...string) {
14193 if val == "" {
14294 continue
14395 }
144 prefix := " " + v.c.Term.paint(sgrDim, pad(key, wide)) + " "
145 if v.c.Term.Cols == 0 {
146 io.WriteString(v.w, prefix+val+"\n")
147 continue
148 }
149 indent := strings.Repeat(" ", 2+wide+2)
150 for j, line := range termtext.Wrap(val, v.c.Term.Cols-2-wide-2) {
151 p := indent
152 if j == 0 {
153 p = prefix
154 }
155 io.WriteString(v.w, p+line+"\n")
156 }
96 io.WriteString(v.w, " "+pad(key, wide)+" "+val+"\n")
15797 }
15898}
15999
@@ -190,32 +130,18 @@ func (v *view) text(src string) {
190130}
191131
192132// event is one line for a system comment: its text without link
193// targets, the time at the right edge at a terminal.
133// targets, then the time.
194134func (v *view) event(text, format, ts string) {
195135 line := "· " + termtext.Inline(v.c.Term.safe(text), format)
196 when := v.c.Term.safe(v.c.when(ts))
197 if cols := v.c.Term.Cols; cols > 0 {
198 room := cols - 2 - 2 - cells(when)
199 line = pad(clip(line, room), room)
200 }
201 io.WriteString(v.w, " "+v.c.Term.paint(sgrDim, line+" "+when)+"\n")
136 io.WriteString(v.w, " "+line+" "+v.c.Term.safe(stamp(ts))+"\n")
202137 v.wrote = true
203138}
204139
205140func (v *view) comment(id int64, author, ts, body, format string) {
206 when := v.c.Term.safe(v.c.when(ts)) + fmt.Sprintf(" (comment %d)", id)
207 author = v.c.Term.safe(author)
208 cols := v.c.Term.Cols
209 if cols > 0 {
210 suffix := ", " + when + " "
211 author = clip(author, max(0, cols-cells("── "+suffix)-1))
212 }
213 head := "── " + author + ", " + when + " "
214 if cols > 0 {
215 head += strings.Repeat("─", max(1, cols-cells(head)))
216 }
141 when := v.c.Term.safe(stamp(ts)) + fmt.Sprintf(" (comment %d)", id)
142 head := "── " + v.c.Term.safe(author) + ", " + when + " "
217143 v.sep()
218 io.WriteString(v.w, v.c.Term.paint(sgrDim, head)+"\n")
144 io.WriteString(v.w, head+"\n")
219145 v.body(body, format)
220146}
221147
internal/control/view_test.go +20
@@ -132,3 +132,23 @@ func issueIDFor(t *testing.T, st *store.Store, repoID, n int64) int64 {
132132 }
133133 return issue.ID
134134}
135
136// view is the piped writer: at a terminal it writes what it writes
137// piped, so a caller that never migrated falls back to the plain layout.
138func TestViewIgnoresTerminal(t *testing.T) {
139 render := func(term Term) string {
140 var b bytes.Buffer
141 c := &Ctx{Term: term}
142 v := c.view(&b)
143 v.title("#1", "A title", "open")
144 v.fields("author", "alice", "when", "2026-09-01T10:00:00Z")
145 v.body("Some *body* text that is long enough to wrap at forty columns, if it wraps.", "md")
146 v.section("files")
147 v.event("labelled [bug](/x)", "md", "2026-09-01T10:00:00Z")
148 v.comment(1, "bob", "2026-09-01T10:00:00Z", "A comment.", "md")
149 return b.String()
150 }
151 if got, want := render(Term{Cols: 40, Color: true}), render(Term{}); got != want {
152 t.Errorf("terminal view differs from piped:\n%s\n---\n%s", got, want)
153 }
154}