detached 43af492

fix(popup): reject blank tooltip tables, compare tooltips without a key string

commit
43af492b857cae81a37caa3b8e0bf8005268a393
author
brice · · 2026-10-09 08:07 UTC
parent
907150d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +67 -40
M internal/bar/popup.go +1 -0
@@ -105,6 +105,7 @@ func (b *Bar) drainControl(m *module.Module) {
105105 for i := range b.ctlBuf {
106106 b.control(m, b.ctlBuf[i])
107107 }
108+ clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up
108109 }
109110
110111 // badControl logs an invalid control line, once per module.
@@ -105,6 +105,7 @@ func (b *Bar) drainControl(m *module.Module) {
105 for i := range b.ctlBuf {105 for i := range b.ctlBuf {
106 b.control(m, b.ctlBuf[i])106 b.control(m, b.ctlBuf[i])
107 }107 }
108+ clear(b.ctlBuf) // keeps no parsed menu or table alive until the next wake-up
108 }109 }
109 110
110 // badControl logs an invalid control line, once per module.111 // badControl logs an invalid control line, once per module.
M internal/module/control.go +6 -1
@@ -114,6 +114,10 @@ func ParseControl(b []byte) (Control, error) {
114114 if len(w.Rows) > MaxRows {
115115 return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116116 }
117+ if len(w.Rows) > 0 {
118+ c.Rows = make([][]string, 0, len(w.Rows))
119+ }
120+ text := c.Title != "" || c.Body != ""
117121 for _, row := range w.Rows {
118122 if len(row) > MaxCells {
119123 return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
@@ -121,10 +125,11 @@ func ParseControl(b []byte) (Control, error) {
121125 cells := make([]string, len(row))
122126 for i, s := range row {
123127 cells[i] = clean(s, MaxLabel, false)
128+ text = text || cells[i] != ""
124129 }
125130 c.Rows = append(c.Rows, cells)
126131 }
127- if c.Title == "" && c.Body == "" && len(c.Rows) == 0 {
132+ if !text {
128133 return Control{}, errors.New("tooltip: no text")
129134 }
130135 return c, nil
@@ -114,6 +114,10 @@ func ParseControl(b []byte) (Control, error) {
114 if len(w.Rows) > MaxRows {114 if len(w.Rows) > MaxRows {
115 return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)115 return Control{}, fmt.Errorf("tooltip: more than %d rows", MaxRows)
116 }116 }
117+ if len(w.Rows) > 0 {
118+ c.Rows = make([][]string, 0, len(w.Rows))
119+ }
120+ text := c.Title != "" || c.Body != ""
117 for _, row := range w.Rows {121 for _, row := range w.Rows {
118 if len(row) > MaxCells {122 if len(row) > MaxCells {
119 return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)123 return Control{}, fmt.Errorf("tooltip: more than %d cells in a row", MaxCells)
@@ -121,10 +125,11 @@ func ParseControl(b []byte) (Control, error) {
121 cells := make([]string, len(row))125 cells := make([]string, len(row))
122 for i, s := range row {126 for i, s := range row {
123 cells[i] = clean(s, MaxLabel, false)127 cells[i] = clean(s, MaxLabel, false)
128+ text = text || cells[i] != ""
124 }129 }
125 c.Rows = append(c.Rows, cells)130 c.Rows = append(c.Rows, cells)
126 }131 }
127- if c.Title == "" && c.Body == "" && len(c.Rows) == 0 {132+ if !text {
128 return Control{}, errors.New("tooltip: no text")133 return Control{}, errors.New("tooltip: no text")
129 }134 }
130 return c, nil135 return c, nil
M internal/module/control_test.go +41 -10
@@ -114,18 +114,34 @@ func TestParseControlCleansText(t *testing.T) {
114114 }
115115
116116 func TestParseControlTableLimits(t *testing.T) {
117- row := `["a"]`
118- tooMany := `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(row+",", MaxRows+1), ",") + `]}`
119- if _, err := ParseControl([]byte(tooMany)); err == nil {
120- t.Fatal("too many rows must fail")
117+ rows := func(n int) string {
118+ return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}`
119+ }
120+ cells := func(n int) string {
121+ return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}`
122+ }
123+ for name, tc := range map[string]struct {
124+ in string
125+ ok bool
126+ }{
127+ "max rows": {rows(MaxRows), true},
128+ "too many rows": {rows(MaxRows + 1), false},
129+ "max cells": {cells(MaxCells), true},
130+ "too many cells": {cells(MaxCells + 1), false},
131+ "empty rows": {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false},
132+ "blank cells": {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false},
133+ } {
134+ if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok {
135+ t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok)
136+ }
121137 }
122- wide := `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, MaxCells+1), ",") + `]]}`
123- if _, err := ParseControl([]byte(wide)); err == nil {
124- t.Fatal("too many cells must fail")
138+ long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each
139+ c, err := ParseControl([]byte(long))
140+ if err != nil {
141+ t.Fatal(err)
125142 }
126- long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("a", 2*MaxLabel) + `"]]}`
127- if c, err := ParseControl([]byte(long)); err != nil || len(c.Rows[0][0]) != MaxLabel {
128- t.Fatalf("long cell: %v", err)
143+ if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) {
144+ t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell))
129145 }
130146 }
131147
@@ -236,6 +252,7 @@ func FuzzParseControl(f *testing.F) {
236252 nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
237253 `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
238254 `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
255+ `{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`,
239256 `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
240257 } {
241258 f.Add([]byte(s))
@@ -256,6 +273,20 @@ func FuzzParseControl(f *testing.F) {
256273 }
257274 bad(c.Title, MaxLabel)
258275 bad(c.Body, MaxText)
276+ if len(c.Rows) > MaxRows {
277+ t.Fatalf("%d rows", len(c.Rows))
278+ }
279+ for _, row := range c.Rows {
280+ if len(row) > MaxCells {
281+ t.Fatalf("%d cells", len(row))
282+ }
283+ for _, cell := range row {
284+ bad(cell, MaxLabel)
285+ if strings.ContainsRune(cell, '\n') {
286+ t.Fatalf("cell %q", cell)
287+ }
288+ }
289+ }
259290 count := 0
260291 var walk func(items []MenuItem, depth int)
261292 walk = func(items []MenuItem, depth int) {
@@ -114,18 +114,34 @@ func TestParseControlCleansText(t *testing.T) {
114 }114 }
115 115
116 func TestParseControlTableLimits(t *testing.T) {116 func TestParseControlTableLimits(t *testing.T) {
117- row := `["a"]`117+ rows := func(n int) string {
118- tooMany := `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(row+",", MaxRows+1), ",") + `]}`118+ return `{"type":"tooltip","col":0,"width":1,"rows":[` + strings.TrimSuffix(strings.Repeat(`["a"],`, n), ",") + `]}`
119- if _, err := ParseControl([]byte(tooMany)); err == nil {119+ }
120- t.Fatal("too many rows must fail")120+ cells := func(n int) string {
121+ return `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, n), ",") + `]]}`
122+ }
123+ for name, tc := range map[string]struct {
124+ in string
125+ ok bool
126+ }{
127+ "max rows": {rows(MaxRows), true},
128+ "too many rows": {rows(MaxRows + 1), false},
129+ "max cells": {cells(MaxCells), true},
130+ "too many cells": {cells(MaxCells + 1), false},
131+ "empty rows": {`{"type":"tooltip","col":0,"width":1,"rows":[[]]}`, false},
132+ "blank cells": {`{"type":"tooltip","col":0,"width":1,"rows":[["","\u001b"]]}`, false},
133+ } {
134+ if _, err := ParseControl([]byte(tc.in)); (err == nil) != tc.ok {
135+ t.Errorf("%s: err = %v, want ok %v", name, err, tc.ok)
136+ }
121 }137 }
122- wide := `{"type":"tooltip","col":0,"width":1,"rows":[[` + strings.TrimSuffix(strings.Repeat(`"a",`, MaxCells+1), ",") + `]]}`138+ long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("é", MaxLabel) + `"]]}` // 2 bytes each
123- if _, err := ParseControl([]byte(wide)); err == nil {139+ c, err := ParseControl([]byte(long))
124- t.Fatal("too many cells must fail")140+ if err != nil {
141+ t.Fatal(err)
125 }142 }
126- long := `{"type":"tooltip","col":0,"width":1,"rows":[["` + strings.Repeat("a", 2*MaxLabel) + `"]]}`143+ if cell := c.Rows[0][0]; len(cell) > MaxLabel || len(cell) < MaxLabel-1 || !utf8.ValidString(cell) {
127- if c, err := ParseControl([]byte(long)); err != nil || len(c.Rows[0][0]) != MaxLabel {144+ t.Fatalf("long cell cut to %d bytes, valid %v", len(cell), utf8.ValidString(cell))
128- t.Fatalf("long cell: %v", err)
129 }145 }
130 }146 }
131 147
@@ -236,6 +252,7 @@ func FuzzParseControl(f *testing.F) {
236 nested(MaxMenuDepth), nested(MaxMenuDepth + 1),252 nested(MaxMenuDepth), nested(MaxMenuDepth + 1),
237 `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,253 `{"type":"menu","col":0,"width":1,"click":1,"items":[{"id":1,"label":"a\u001b[31m\u202e\ud800"}]}`,
238 `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,254 `{"type":"tooltip","col":-1,"width":99999999,"title":"x"}`,
255+ `{"type":"tooltip","col":0,"width":1,"rows":[["a\n\u001b","\ud800"],[]]}`,
239 `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,256 `{"type":`, `[]`, `null`, ``, `{"type":"menu","items":null}`,
240 } {257 } {
241 f.Add([]byte(s))258 f.Add([]byte(s))
@@ -256,6 +273,20 @@ func FuzzParseControl(f *testing.F) {
256 }273 }
257 bad(c.Title, MaxLabel)274 bad(c.Title, MaxLabel)
258 bad(c.Body, MaxText)275 bad(c.Body, MaxText)
276+ if len(c.Rows) > MaxRows {
277+ t.Fatalf("%d rows", len(c.Rows))
278+ }
279+ for _, row := range c.Rows {
280+ if len(row) > MaxCells {
281+ t.Fatalf("%d cells", len(row))
282+ }
283+ for _, cell := range row {
284+ bad(cell, MaxLabel)
285+ if strings.ContainsRune(cell, '\n') {
286+ t.Fatalf("cell %q", cell)
287+ }
288+ }
289+ }
259 count := 0290 count := 0
260 var walk func(items []MenuItem, depth int)291 var walk func(items []MenuItem, depth int)
261 walk = func(items []MenuItem, depth int) {292 walk = func(items []MenuItem, depth int) {
M internal/popup/host.go +9 -21
@@ -4,8 +4,8 @@ import (
44 "errors"
55 "fmt"
66 "log/slog"
7+ "slices"
78 "strconv"
8- "strings"
99 "time"
1010
1111 "github.com/bnema/neferclient"
@@ -76,7 +76,7 @@ type Host struct {
7676 menu *menuModel
7777
7878 kind Kind
79- tipKey tooltipKey // what the open tooltip shows
79+ tipCtl module.Control // what the open tooltip shows
8080 owner *module.Module
8181 ownerGen uint64
8282 anchor neferclient.Rect
@@ -166,23 +166,11 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) {
166166 return true
167167 }
168168
169-// tooltipKey is what identifies a tooltip: showing the same one again is
170-// pointless.
171-type tooltipKey struct {
172- title, body, rows string
173- col, width int
174-}
175-
176-func keyOf(c module.Control) tooltipKey {
177- var rows strings.Builder
178- for _, r := range c.Rows {
179- for _, s := range r {
180- rows.WriteString(s)
181- rows.WriteByte(0) // cells never hold control characters
182- }
183- rows.WriteByte('\n')
184- }
185- return tooltipKey{c.Title, c.Body, rows.String(), c.Col, c.Width}
169+// sameTooltip reports whether two tooltip controls show the same thing:
170+// showing the same one again is pointless.
171+func sameTooltip(a, b module.Control) bool {
172+ return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width &&
173+ slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string])
186174 }
187175
188176 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows
@@ -192,7 +180,7 @@ func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
192180 }
193181
194182 func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
195- return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c)
183+ return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c)
196184 }
197185
198186 // Active reports whether a popup is open.
@@ -276,7 +264,7 @@ func (h *Host) Open(req Request) error {
276264 return fmt.Errorf("popup: %w", err)
277265 }
278266 h.r, h.kind = r, req.Kind
279- h.tipKey = keyOf(req.Ctrl)
267+ h.tipCtl = req.Ctrl
280268 switch req.Kind {
281269 case KindTooltip:
282270 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
@@ -4,8 +4,8 @@ import (
4 "errors"4 "errors"
5 "fmt"5 "fmt"
6 "log/slog"6 "log/slog"
7+ "slices"
7 "strconv"8 "strconv"
8- "strings"
9 "time"9 "time"
10 10
11 "github.com/bnema/neferclient"11 "github.com/bnema/neferclient"
@@ -76,7 +76,7 @@ type Host struct {
76 menu *menuModel76 menu *menuModel
77 77
78 kind Kind78 kind Kind
79- tipKey tooltipKey // what the open tooltip shows79+ tipCtl module.Control // what the open tooltip shows
80 owner *module.Module80 owner *module.Module
81 ownerGen uint6481 ownerGen uint64
82 anchor neferclient.Rect82 anchor neferclient.Rect
@@ -166,23 +166,11 @@ func (h *Host) waitWarm(kind Kind) (proceed bool) {
166 return true166 return true
167 }167 }
168 168
169-// tooltipKey is what identifies a tooltip: showing the same one again is169+// sameTooltip reports whether two tooltip controls show the same thing:
170-// pointless.170+// showing the same one again is pointless.
171-type tooltipKey struct {171+func sameTooltip(a, b module.Control) bool {
172- title, body, rows string172+ return a.Title == b.Title && a.Body == b.Body && a.Col == b.Col && a.Width == b.Width &&
173- col, width int173+ slices.EqualFunc(a.Rows, b.Rows, slices.Equal[[]string])
174-}
175-
176-func keyOf(c module.Control) tooltipKey {
177- var rows strings.Builder
178- for _, r := range c.Rows {
179- for _, s := range r {
180- rows.WriteString(s)
181- rows.WriteByte(0) // cells never hold control characters
182- }
183- rows.WriteByte('\n')
184- }
185- return tooltipKey{c.Title, c.Body, rows.String(), c.Col, c.Width}
186 }174 }
187 175
188 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows176 // ShowsTooltip reports whether the open popup is a tooltip of owner that shows
@@ -192,7 +180,7 @@ func (h *Host) ShowsTooltip(owner *module.Module, c module.Control) bool {
192 }180 }
193 181
194 func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {182 func (h *Host) showsTooltip(owner *module.Module, c module.Control) bool {
195- return h.kind == KindTooltip && h.owner == owner && h.tipKey == keyOf(c)183+ return h.kind == KindTooltip && h.owner == owner && sameTooltip(h.tipCtl, c)
196 }184 }
197 185
198 // Active reports whether a popup is open.186 // Active reports whether a popup is open.
@@ -276,7 +264,7 @@ func (h *Host) Open(req Request) error {
276 return fmt.Errorf("popup: %w", err)264 return fmt.Errorf("popup: %w", err)
277 }265 }
278 h.r, h.kind = r, req.Kind266 h.r, h.kind = r, req.Kind
279- h.tipKey = keyOf(req.Ctrl)267+ h.tipCtl = req.Ctrl
280 switch req.Kind {268 switch req.Kind {
281 case KindTooltip:269 case KindTooltip:
282 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)270 h.tip = newTooltip(req.Ctrl.Title, req.Ctrl.Body, req.Ctrl.Rows)
M internal/popup/popup_test.go +8 -7
@@ -178,14 +178,15 @@ func TestTooltipTable(t *testing.T) {
178178 }
179179 }
180180
181-func TestTooltipKeyIncludesRows(t *testing.T) {
181+func TestSameTooltipComparesRows(t *testing.T) {
182182 a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183- b := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"ab"}}}
184- if keyOf(a) == keyOf(b) {
185- t.Fatal("different tables must not share a key")
183+ for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} {
184+ if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) {
185+ t.Fatalf("%q is a different table", rows)
186+ }
186187 }
187- if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
188- t.Fatal("same table, same key")
188+ if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
189+ t.Fatal("same table, same tooltip")
189190 }
190191 }
191192
@@ -303,7 +304,7 @@ func TestShowsTooltip(t *testing.T) {
303304 m := &module.Module{}
304305 other := &module.Module{}
305306 c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
306- h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)}
307+ h := &Host{kind: KindTooltip, owner: m, tipCtl: c}
307308 if !h.showsTooltip(m, c) {
308309 t.Fatal("the same tooltip of the same owner is shown")
309310 }
@@ -178,14 +178,15 @@ func TestTooltipTable(t *testing.T) {
178 }178 }
179 }179 }
180 180
181-func TestTooltipKeyIncludesRows(t *testing.T) {181+func TestSameTooltipComparesRows(t *testing.T) {
182 a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}182 a := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}
183- b := module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"ab"}}}183+ for _, rows := range [][][]string{{{"ab"}}, {{"a"}, {"b"}}, {{"a", "b"}, {}}, nil} {
184- if keyOf(a) == keyOf(b) {184+ if sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: rows}) {
185- t.Fatal("different tables must not share a key")185+ t.Fatalf("%q is a different table", rows)
186+ }
186 }187 }
187- if keyOf(a) != keyOf(module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {188+ if !sameTooltip(a, module.Control{Type: module.ControlTooltip, Width: 1, Rows: [][]string{{"a", "b"}}}) {
188- t.Fatal("same table, same key")189+ t.Fatal("same table, same tooltip")
189 }190 }
190 }191 }
191 192
@@ -303,7 +304,7 @@ func TestShowsTooltip(t *testing.T) {
303 m := &module.Module{}304 m := &module.Module{}
304 other := &module.Module{}305 other := &module.Module{}
305 c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}306 c := module.Control{Type: module.ControlTooltip, Col: 1, Width: 2, Title: "T", Body: "B"}
306- h := &Host{kind: KindTooltip, owner: m, tipKey: keyOf(c)}307+ h := &Host{kind: KindTooltip, owner: m, tipCtl: c}
307 if !h.showsTooltip(m, c) {308 if !h.showsTooltip(m, c) {
308 t.Fatal("the same tooltip of the same owner is shown")309 t.Fatal("the same tooltip of the same owner is shown")
309 }310 }
M internal/popup/view.go +2 -1
@@ -30,7 +30,8 @@ func newTooltip(title, body string, rows [][]string) *tooltipModel {
3030
3131 // tooltipView draws the title in bold, the body, then the table. The table is
3232 // laid out column by column, so every column is as wide as its widest cell
33-// and the cells of a row line up.
33+// and the cells of a row line up, as long as no cell wraps: a cell wider than
34+// the tooltip can be takes two lines and shifts the rest of its column.
3435 func tooltipView(f *nefergui.Frame, m *tooltipModel) {
3536 col := f.Root().Column()
3637 if m.title != "" {
@@ -30,7 +30,8 @@ func newTooltip(title, body string, rows [][]string) *tooltipModel {
30 30
31 // tooltipView draws the title in bold, the body, then the table. The table is31 // tooltipView draws the title in bold, the body, then the table. The table is
32 // laid out column by column, so every column is as wide as its widest cell32 // laid out column by column, so every column is as wide as its widest cell
33-// and the cells of a row line up.33+// and the cells of a row line up, as long as no cell wraps: a cell wider than
34+// the tooltip can be takes two lines and shifts the rest of its column.
34 func tooltipView(f *nefergui.Frame, m *tooltipModel) {35 func tooltipView(f *nefergui.Frame, m *tooltipModel) {
35 col := f.Root().Column()36 col := f.Root().Column()
36 if m.title != "" {37 if m.title != "" {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close