v0.3.0 da0887c

tray_test.go RawHistoryFolder

1package tray
2
3import (
4	"bufio"
5	"context"
6	"fmt"
7	"io"
8	"log/slog"
9	"os/exec"
10	"slices"
11	"strings"
12	"testing"
13	"time"
14
15	"github.com/bnema/zerobus"
16)
17
18// privateBus starts a dbus-daemon for the test and returns its address.
19func privateBus(t *testing.T) string {
20	t.Helper()
21	path, err := exec.LookPath("dbus-daemon")
22	if err != nil {
23		t.Skip("dbus-daemon not installed")
24	}
25	cmd := exec.Command(path, "--session", "--nofork", "--print-address")
26	out, err := cmd.StdoutPipe()
27	if err != nil {
28		t.Fatal(err)
29	}
30	if err := cmd.Start(); err != nil {
31		t.Fatal(err)
32	}
33	t.Cleanup(func() {
34		_ = cmd.Process.Kill()
35		_ = cmd.Wait()
36	})
37	addr, err := bufio.NewReader(out).ReadString('\n')
38	if err != nil {
39		t.Fatal(err)
40	}
41	return strings.TrimSpace(addr)
42}
43
44func dial(t *testing.T, addr string) *zerobus.Conn {
45	t.Helper()
46	c, err := zerobus.Dial(addr)
47	if err != nil {
48		t.Fatal(err)
49	}
50	t.Cleanup(func() { _ = c.Close() })
51	return c
52}
53
54// fakeItem is an application with one StatusNotifierItem. Its connection is
55// used only by its own goroutine, as a Conn requires: the test drives it
56// through a peer that calls its Do method.
57type fakeItem struct {
58	c      *zerobus.Conn
59	name   string
60	driver *zerobus.Conn
61	cmds   chan func()
62	cur    [3]string // id, icon name, status
63	pixel  [4]byte   // the one ARGB pixel of its icon
64	closed chan struct{}
65
66	// calls records the StatusNotifierItem actions it received, as
67	// "Method(args)". The fields below are set through run.
68	calls   chan string
69	unknown string        // the method it answers with UnknownMethod
70	slow    string        // the method it answers only after slowFor
71	slowFor time.Duration // how long slow takes
72	tip     *[2]string    // its ToolTip title and description, set through run
73	title   string        // its Title property, set through run
74
75	// A dbusmenu at /MenuBar, set through run.
76	menu   *tnode
77	mcalls chan string              // the dbusmenu calls it received
78	badSig bool                     // GetLayout answers with a string instead of a layout, set through run
79	mslow  map[string]time.Duration // dbusmenu methods it answers only after this long, set through run
80}
81
82func newFakeItem(t *testing.T, addr, id, iconName string, pixel [4]byte) *fakeItem {
83	c := dial(t, addr)
84	f := &fakeItem{c: c, name: c.UniqueName(), driver: dial(t, addr), cmds: make(chan func(), 1),
85		cur: [3]string{id, iconName, "Active"}, pixel: pixel, closed: make(chan struct{}),
86		calls: make(chan string, 32), mcalls: make(chan string, 32)}
87	go f.serve()
88	return f
89}
90
91// run hands fn to the item's goroutine, which runs it right after answering:
92// run may return before fn ran. Tests wait for its effect on the output.
93func (f *fakeItem) run(t *testing.T, fn func()) {
94	t.Helper()
95	f.cmds <- fn
96	f.driver.NewCall(f.name, "/", "org.example.Fake", "Do", "")
97	if _, err := f.driver.Call(); err != nil {
98		t.Fatal(err)
99	}
100}
101
102func (f *fakeItem) serve() {
103	defer close(f.closed)
104	for {
105		m, err := f.c.ReadMessage()
106		if err != nil {
107			return
108		}
109		if m.Type == zerobus.TypeMethodCall && m.Member == "Do" {
110			f.c.NewReply(m, "")
111			if _, err := f.c.Send(); err != nil {
112				return
113			}
114			(<-f.cmds)()
115			continue
116		}
117		if m.Type == zerobus.TypeMethodCall && m.Interface == menuIface {
118			if !f.menuCall(m) {
119				return
120			}
121			continue
122		}
123		if m.Type == zerobus.TypeMethodCall && m.Interface == itemIface {
124			if !f.action(m) {
125				return
126			}
127			continue
128		}
129		if m.Type != zerobus.TypeMethodCall || m.Member != "GetAll" {
130			continue
131		}
132		e := f.c.NewReply(m, "a{sv}")
133		a := e.BeginArray('{')
134		for i, k := range [...]string{"Id", "IconName", "Status"} {
135			e.Struct()
136			e.Str(k)
137			e.Variant("s")
138			e.Str(f.cur[i])
139		}
140		if f.tip != nil {
141			e.Struct()
142			e.Str("ToolTip")
143			e.Variant("(sa(iiay)ss)")
144			e.Struct()
145			e.Str("icon")
146			tp := e.BeginArray('(')
147			e.EndArray(tp)
148			e.Str(f.tip[0])
149			e.Str(f.tip[1])
150		}
151		if f.menu != nil {
152			e.Struct()
153			e.Str("Menu")
154			e.Variant("o")
155			e.ObjectPath("/MenuBar")
156		}
157		if f.title != "" {
158			e.Struct()
159			e.Str("Title")
160			e.Variant("s")
161			e.Str(f.title)
162		}
163		e.Struct()
164		e.Str("IconPixmap")
165		e.Variant("a(iiay)")
166		px := e.BeginArray('(')
167		e.Struct()
168		e.Int32(1)
169		e.Int32(1)
170		e.ByteArray(f.pixel[:])
171		e.EndArray(px)
172		e.EndArray(a)
173		if _, err := f.c.Send(); err != nil {
174			return
175		}
176	}
177}
178
179// action records and answers one StatusNotifierItem method call. It reports
180// false when the connection is gone.
181func (f *fakeItem) action(m *zerobus.Message) bool {
182	r := m.Body()
183	var rec string
184	switch m.Member {
185	case "Activate", "SecondaryActivate", "ContextMenu":
186		x, y := r.Int32(), r.Int32()
187		rec = fmt.Sprintf("%s(%d,%d)", m.Member, x, y)
188	case "Scroll":
189		delta, orientation := r.Int32(), r.Str()
190		rec = fmt.Sprintf("Scroll(%d,%s)", delta, orientation)
191	default:
192		return true
193	}
194	f.calls <- rec
195	if m.Member == f.slow {
196		time.Sleep(f.slowFor)
197	}
198	if m.Member == f.unknown {
199		f.c.NewError(m, "org.freedesktop.DBus.Error.UnknownMethod", "s").Str("no such method")
200	} else {
201		f.c.NewReply(m, "")
202	}
203	_, err := f.c.Send()
204	return err == nil
205}
206
207// register registers the item with the watcher, by bus name.
208func (f *fakeItem) register(t *testing.T) {
209	f.run(t, func() {
210		f.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(f.name)
211		_, _ = f.c.Send()
212	})
213}
214
215// setTip changes the item's tooltip and emits NewToolTip.
216func (f *fakeItem) setTip(t *testing.T, title, body string) {
217	f.run(t, func() {
218		f.tip = &[2]string{title, body}
219		f.c.NewSignal(itemPath, itemIface, "NewToolTip", "")
220		_, _ = f.c.Send()
221	})
222}
223
224// setStatus changes the item's status and emits NewStatus.
225func (f *fakeItem) setStatus(t *testing.T, status string) {
226	f.run(t, func() {
227		f.cur[2] = status
228		f.c.NewSignal(itemPath, itemIface, "NewStatus", "s").Str(status)
229		_, _ = f.c.Send()
230	})
231}
232
233// quit closes the item's connection, as when the application exits.
234func (f *fakeItem) quit(t *testing.T) {
235	f.run(t, func() { _ = f.c.Close() })
236	<-f.closed
237}
238
239// lines reads the tray's output.
240type lines struct {
241	ch chan string
242}
243
244func readLines(r io.Reader) *lines {
245	l := &lines{ch: make(chan string, 64)}
246	go func() {
247		sc := bufio.NewScanner(r)
248		for sc.Scan() {
249			l.ch <- sc.Text()
250		}
251		close(l.ch)
252	}()
253	return l
254}
255
256// waitFor returns the first line for which ok is true.
257func (l *lines) waitFor(t *testing.T, what string, ok func(string) bool) string {
258	t.Helper()
259	timeout := time.After(5 * time.Second)
260	for {
261		select {
262		case s, open := <-l.ch:
263			if !open {
264				t.Fatalf("output ended while waiting for %s", what)
265			}
266			if ok(s) {
267				return s
268			}
269		case <-timeout:
270			t.Fatalf("timed out waiting for %s", what)
271		}
272	}
273}
274
275// startTray runs a tray on the bus. Cancel it and receive from the channel
276// to stop it; the test cleanup does both when the test did not.
277func startTray(t *testing.T, addr string) (*lines, context.CancelFunc, <-chan error) {
278	return startTrayWith(t, addr, nil, nil)
279}
280
281// startTrayWith is startTray with the bar's input and a dialer for the
282// control connection.
283func startTrayWith(t *testing.T, addr string, in io.Reader, dialer func() (*zerobus.Conn, error)) (*lines, context.CancelFunc, <-chan error) {
284	pr, pw := io.Pipe()
285	ctx, cancel := context.WithCancel(context.Background())
286	done := make(chan error, 1)
287	var result error
288	stopped := make(chan struct{})
289	opt := Options{
290		Resolver:   NewResolver(lookup, nil, []string{t.TempDir()}),
291		Foreground: RGB{200, 200, 200}, Background: RGB{30, 30, 46}, Accent: RGB{137, 180, 250},
292		Out: pw, In: in, Dial: dialer,
293	}
294	c := dial(t, addr)
295	go func() {
296		result = Run(ctx, c, opt)
297		_ = pw.Close()
298		close(stopped)
299		done <- result
300	}()
301	t.Cleanup(func() {
302		cancel()
303		<-stopped
304	})
305	return readLines(pr), cancel, done
306}
307
308func TestTrayAsWatcher(t *testing.T) {
309	addr := privateBus(t)
310	out, cancel, done := startTray(t, addr)
311	if s := out.waitFor(t, "the empty first line", func(string) bool { return true }); s != "" {
312		t.Fatalf("first line %q, want empty", s)
313	}
314	// Wait until the tray owns the watcher name.
315	probe := dial(t, addr)
316	deadline := time.Now().Add(5 * time.Second)
317	for {
318		probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
319		m, err := probe.Call()
320		if err != nil {
321			t.Fatal(err)
322		}
323		if m.Body().Bool() {
324			break
325		}
326		if time.Now().After(deadline) {
327			t.Fatal("the tray did not become the watcher")
328		}
329		time.Sleep(10 * time.Millisecond)
330	}
331
332	steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
333	steam.register(t)
334	line := out.waitFor(t, "the steam glyph", func(s string) bool { return strings.Contains(s, "S") })
335	if !strings.Contains(line, "38;2;") {
336		t.Fatalf("the icon is not colored: %q", line)
337	}
338
339	steam.setStatus(t, "NeedsAttention")
340	out.waitFor(t, "the attention style", func(s string) bool { return strings.Contains(s, "\x1b[1;38;2;137;180;250mS") })
341
342	steam.setStatus(t, "Passive")
343	out.waitFor(t, "the passive item to hide", func(s string) bool { return s == "" })
344
345	steam.setStatus(t, "Active")
346	out.waitFor(t, "the item to come back", func(s string) bool { return strings.Contains(s, "S") })
347
348	// The application quits: its item goes away.
349	steam.quit(t)
350	out.waitFor(t, "the item to go away", func(s string) bool { return s == "" })
351
352	cancel()
353	if err := <-done; err != nil {
354		t.Fatalf("Run: %v", err)
355	}
356}
357
358// waitWatcher waits until the tray owns the watcher name.
359func waitWatcher(t *testing.T, addr string) {
360	t.Helper()
361	probe := dial(t, addr)
362	deadline := time.Now().Add(5 * time.Second)
363	for {
364		probe.NewCall(busName, busPath, busName, "NameHasOwner", "s").Str(watcherName)
365		m, err := probe.Call()
366		if err != nil {
367			t.Fatal(err)
368		}
369		if m.Body().Bool() {
370			return
371		}
372		if time.Now().After(deadline) {
373			t.Fatal("the tray did not become the watcher")
374		}
375		time.Sleep(10 * time.Millisecond)
376	}
377}
378
379// fakeWatcher is another panel's StatusNotifierWatcher. Like fakeItem, its
380// connection is used only by its own goroutine, which the test drives
381// through Ping.
382type fakeWatcher struct {
383	c      *zerobus.Conn
384	probe  *zerobus.Conn
385	items  []string
386	cmds   chan func()
387	closed chan struct{}
388}
389
390func newFakeWatcher(t *testing.T, addr string, flags uint32, items ...string) *fakeWatcher {
391	t.Helper()
392	w := &fakeWatcher{c: dial(t, addr), probe: dial(t, addr), items: items, cmds: make(chan func(), 1), closed: make(chan struct{})}
393	if code, err := w.c.RequestName(watcherName, flags|zerobus.NameFlagDoNotQueue); err != nil || code != zerobus.NamePrimaryOwner {
394		t.Fatalf("RequestName = %d, %v", code, err)
395	}
396	go w.serve()
397	return w
398}
399
400func (w *fakeWatcher) serve() {
401	defer close(w.closed)
402	for {
403		m, err := w.c.ReadMessage()
404		if err != nil {
405			return
406		}
407		if m.Type != zerobus.TypeMethodCall {
408			continue
409		}
410		switch m.Member {
411		case "Ping":
412			w.c.NewReply(m, "")
413			if _, err := w.c.Send(); err != nil {
414				return
415			}
416			(<-w.cmds)()
417			continue
418		case "Get":
419			e := w.c.NewReply(m, "v")
420			e.Variant("as")
421			a := e.BeginArray('s')
422			for _, s := range w.items {
423				e.Str(s)
424			}
425			e.EndArray(a)
426		default:
427			w.c.NewReply(m, "")
428		}
429		if _, err := w.c.Send(); err != nil {
430			return
431		}
432	}
433}
434
435// do hands fn to the watcher's goroutine; see fakeItem.run.
436func (w *fakeWatcher) do(t *testing.T, fn func()) {
437	t.Helper()
438	w.cmds <- fn
439	w.probe.NewCall(w.c.UniqueName(), "/", "org.freedesktop.DBus.Peer", "Ping", "")
440	if _, err := w.probe.Call(); err != nil {
441		t.Fatal(err)
442	}
443}
444
445func (w *fakeWatcher) emit(t *testing.T, member, service string) {
446	w.do(t, func() {
447		w.c.NewSignal(watcherPath, watcherIface, member, "s").Str(service)
448		_, _ = w.c.Send()
449	})
450}
451
452func (w *fakeWatcher) quit(t *testing.T) {
453	w.do(t, func() { _ = w.c.Close() })
454	<-w.closed
455}
456
457func TestTrayAsHost(t *testing.T) {
458	addr := privateBus(t)
459	obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
460	// Another program is the watcher, and lists one item.
461	w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
462
463	out, _, _ := startTray(t, addr)
464	out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
465
466	// A second item is announced by the other watcher, by bus name only.
467	steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
468	w.emit(t, "StatusNotifierItemRegistered", steam.name)
469	out.waitFor(t, "both items", func(s string) bool { return strings.Contains(s, "O") && strings.Contains(s, "S") })
470
471	// And removed in the same form.
472	w.emit(t, "StatusNotifierItemUnregistered", steam.name)
473	out.waitFor(t, "steam to go away", func(s string) bool { return strings.Contains(s, "O") && !strings.Contains(s, "S") })
474}
475
476func TestHostFollowsReplacedWatcher(t *testing.T) {
477	addr := privateBus(t)
478	obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
479	newFakeWatcher(t, addr, zerobus.NameFlagAllowReplacement, obsidian.name+itemPath)
480	out, _, _ := startTray(t, addr)
481	out.waitFor(t, "the first watcher's item", func(s string) bool { return strings.Contains(s, "O") })
482
483	// A new panel replaces the watcher: its items are the ones to show.
484	steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
485	newFakeWatcher(t, addr, zerobus.NameFlagReplaceExisting, steam.name+itemPath)
486	out.waitFor(t, "the new watcher's item only", func(s string) bool { return strings.Contains(s, "S") && !strings.Contains(s, "O") })
487}
488
489func TestHostTakesOverWatcher(t *testing.T) {
490	addr := privateBus(t)
491	obsidian := newFakeItem(t, addr, "obsidian_status_icon_1", "", [4]byte{255, 120, 80, 220})
492	w := newFakeWatcher(t, addr, 0, obsidian.name+itemPath)
493	out, cancel, done := startTray(t, addr)
494	out.waitFor(t, "the listed item", func(s string) bool { return strings.Contains(s, "O") })
495
496	// The other panel exits: the tray drops its items and becomes the
497	// watcher, where items register again.
498	w.quit(t)
499	out.waitFor(t, "the items to go", func(s string) bool { return s == "" })
500	waitWatcher(t, addr)
501	obsidian.register(t)
502	out.waitFor(t, "the item registered again", func(s string) bool { return strings.Contains(s, "O") })
503
504	cancel()
505	if err := <-done; err != nil {
506		t.Fatalf("Run: %v", err)
507	}
508}
509
510// registered returns the watcher's RegisteredStatusNotifierItems.
511func registered(t *testing.T, c *zerobus.Conn) []string {
512	t.Helper()
513	e := c.NewCall(watcherName, watcherPath, propsIface, "Get", "ss")
514	e.Str(watcherIface)
515	e.Str("RegisteredStatusNotifierItems")
516	m, err := c.Call()
517	if err != nil {
518		t.Fatal(err)
519	}
520	r := m.Body()
521	if v := r.Variant(); v != "as" {
522		t.Fatalf("variant %q", v)
523	}
524	var items []string
525	for end := r.Array('s'); r.More(end); {
526		items = append(items, strings.Clone(r.Str()))
527	}
528	return items
529}
530
531func TestWatcherRegistration(t *testing.T) {
532	addr := privateBus(t)
533	out, _, _ := startTray(t, addr)
534	waitWatcher(t, addr)
535
536	// libappindicator and Electron register an object path, not a name.
537	steam := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
538	steam.run(t, func() {
539		steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(itemPath)
540		_, _ = steam.c.Send()
541	})
542	out.waitFor(t, "the item registered by path", func(s string) bool { return strings.Contains(s, "S") })
543
544	// The same item again, by bus name: still one item.
545	dup := make(chan error, 1)
546	steam.run(t, func() {
547		steam.c.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(steam.name)
548		_, err := steam.c.Call()
549		dup <- err
550	})
551	if err := <-dup; err != nil {
552		t.Fatal(err)
553	}
554
555	// A path D-Bus refuses is rejected and leaves no trace.
556	probe := dial(t, addr)
557	for _, bad := range []string{"/bad//path", "not a name", "1.starts.with.digit"} {
558		probe.NewCall(watcherName, watcherPath, watcherIface, "RegisterStatusNotifierItem", "s").Str(bad)
559		if _, err := probe.Call(); err == nil {
560			t.Fatalf("%q was accepted", bad)
561		}
562	}
563	if items := registered(t, probe); len(items) != 1 || items[0] != steam.name+itemPath {
564		t.Fatalf("registered items %q", items)
565	}
566}
567
568// TestRefreshNoAllocs checks the steady state: an item signals a change, the
569// tray asks for its properties and reads the reply. Nothing allocates once
570// the buffers have grown.
571func TestRefreshNoAllocs(t *testing.T) {
572	addr := privateBus(t)
573	app := newFakeItem(t, addr, "steam", "", [4]byte{255, 30, 60, 200})
574	var out strings.Builder
575	tr := &Tray{c: dial(t, addr), opt: Options{Resolver: NewResolver(lookup, nil, []string{t.TempDir()}), Out: &out},
576		frame: make([]byte, 0, 256), last: make([]byte, 0, 256)}
577	tr.opt.Log = slog.New(slog.DiscardHandler)
578	tr.add(app.name, "")
579	cycle := func() {
580		tr.refresh(tr.items[0])
581		for len(tr.pending) > 0 {
582			m, err := tr.c.ReadMessage()
583			if err != nil {
584				t.Fatal(err)
585			}
586			tr.dispatch(m)
587		}
588	}
589	for range 3 {
590		cycle() // resolve the icon and grow the buffers
591	}
592	if tr.err != nil || !tr.items[0].loaded {
593		t.Fatalf("item not loaded: %v", tr.err)
594	}
595	if allocs := testing.AllocsPerRun(50, cycle); allocs != 0 {
596		t.Fatalf("%v allocations per refresh, want 0", allocs)
597	}
598}
599
600func TestRenderSkipsUnchangedFrames(t *testing.T) {
601	var buf strings.Builder
602	tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
603	tr.items = []*item{{loaded: true, icon: "X"}}
604	for range 3 {
605		if err := tr.render(false); err != nil {
606			t.Fatal(err)
607		}
608	}
609	if got := strings.Count(buf.String(), "\n"); got != 1 {
610		t.Fatalf("%d frames written, want 1", got)
611	}
612	if allocs := testing.AllocsPerRun(100, func() { _ = tr.render(false) }); allocs != 0 {
613		t.Fatalf("render allocates %v times", allocs)
614	}
615}
616
617func TestBetterPixmapSize(t *testing.T) {
618	// The largest icon up to 64x64 wins; above that, the smallest.
619	for _, c := range []struct {
620		sizes []int // sides, in the order the item lists them
621		want  int
622	}{
623		{[]int{16, 32, 128}, 32},
624		{[]int{128, 32, 16}, 32},
625		{[]int{256, 128}, 128},
626		{[]int{22}, 22},
627	} {
628		best := 0
629		for _, s := range c.sizes {
630			if best == 0 || better(s*s, best*best) {
631				best = s
632			}
633		}
634		if best != c.want {
635			t.Errorf("%v: chose %d, want %d", c.sizes, best, c.want)
636		}
637	}
638}
639
640func TestRenderPublishesTargets(t *testing.T) {
641	var buf strings.Builder
642	tr := &Tray{opt: Options{Out: &buf, Foreground: RGB{1, 2, 3}}}
643	tr.items = []*item{
644		{loaded: true, icon: "S", dest: ":1.1", owner: ":1.1", path: "/a", menu: "/ma"},
645		{loaded: true, icon: "Z", status: passive, dest: ":1.9", path: "/z"},
646		{loaded: true, icon: "☀", dest: "org.x.Sun", owner: ":1.2", path: "/b", isMenu: true},
647	}
648	if err := tr.render(false); err != nil {
649		t.Fatal(err)
650	}
651	want := []Target{
652		{Dest: ":1.1", Path: "/a", Menu: "/ma", Start: 0, Width: 1},
653		{Dest: ":1.2", Path: "/b", IsMenu: true, Start: 2, Width: 1},
654	}
655	if got := tr.snap.targets; !slices.Equal(got, want) {
656		t.Fatalf("targets = %+v, want %+v", got, want)
657	}
658	for col, wantPath := range map[int]string{0: "/a", 2: "/b"} {
659		if tg, ok := tr.target(col); !ok || tg.Path != wantPath {
660			t.Errorf("target(%d) = %+v, %v; want %s", col, tg, ok, wantPath)
661		}
662	}
663	for _, col := range []int{-1, 1, 3} {
664		if _, ok := tr.target(col); ok {
665			t.Errorf("target(%d) found an icon in the gap or past the line", col)
666		}
667	}
668	// A hidden item leaves no target, and a wide icon moves the next one.
669	tr.items[0].icon = "界"
670	if err := tr.render(false); err != nil {
671		t.Fatal(err)
672	}
673	if got := tr.snap.targets; len(got) != 2 || got[0].Width != 2 || got[1].Start != 3 {
674		t.Fatalf("targets after a wide icon = %+v", got)
675	}
676}
677
678// A change of menu path alone republishes the targets without a new line.
679func TestRenderRepublishesTargetsWithoutNewLine(t *testing.T) {
680	var buf strings.Builder
681	tr := &Tray{opt: Options{Out: &buf}}
682	tr.items = []*item{{loaded: true, icon: "S", dest: ":1.1", path: "/a"}}
683	for range 2 {
684		if err := tr.render(false); err != nil {
685			t.Fatal(err)
686		}
687	}
688	tr.items[0].menu = "/menu"
689	if err := tr.render(false); err != nil {
690		t.Fatal(err)
691	}
692	if n := strings.Count(buf.String(), "\n"); n != 1 {
693		t.Fatalf("%d lines written, want 1", n)
694	}
695	if tg, _ := tr.target(0); tg.Menu != "/menu" {
696		t.Fatalf("target = %+v", tg)
697	}
698}

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