v0.38.1 969e52a

login_dialog_linux_test.go RawHistoryFolder

1//go:build linux
2
3package modes
4
5import (
6	"context"
7	"os"
8	"path/filepath"
9	"strings"
10	"sync"
11	"testing"
12	"time"
13
14	"github.com/bnema/zut/packages/tui"
15)
16
17type blockingLoginClipboardTerminal struct {
18	alertTestTerminal
19	started chan struct{}
20	release chan struct{}
21	once    sync.Once
22}
23
24func (t *blockingLoginClipboardTerminal) Write(p []byte) (int, error) {
25	t.once.Do(func() { close(t.started) })
26	<-t.release
27	return t.alertTestTerminal.Write(p)
28}
29
30func (t *blockingLoginClipboardTerminal) WriteString(s string) (int, error) {
31	return t.Write([]byte(s))
32}
33
34func TestInteractiveAltCUsesSystemClipboardWithoutTerminal(t *testing.T) {
35	dir := t.TempDir()
36	payload := filepath.Join(dir, "clipboard-payload")
37	if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil {
38		t.Fatal(err)
39	}
40	t.Setenv("PATH", dir)
41	t.Setenv("WAYLAND_DISPLAY", "wayland-test")
42	t.Setenv("DISPLAY", "")
43	t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
44
45	const loginURL = "https://example.com/oauth/authorize?state=xyz"
46	i := NewInteractive(InteractiveConfig{})
47	i.dialog.Open(t.TempDir())
48	i.dialog.ShowWaiting(loginURL)
49
50	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
51
52	got, err := os.ReadFile(payload)
53	if err != nil {
54		t.Fatal(err)
55	}
56	if string(got) != loginURL {
57		t.Fatalf("clipboard payload = %q, want %q", got, loginURL)
58	}
59	i.mu.Lock()
60	defer i.mu.Unlock()
61	if i.statusOK != "login URL copied" || i.statusErr != "" {
62		t.Fatalf("status = (%q, %q), want successful system copy", i.statusOK, i.statusErr)
63	}
64}
65
66func TestInteractiveAltCReportsSystemClipboardError(t *testing.T) {
67	dir := t.TempDir()
68	if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil {
69		t.Fatal(err)
70	}
71	t.Setenv("PATH", dir)
72	t.Setenv("WAYLAND_DISPLAY", "wayland-test")
73	t.Setenv("DISPLAY", "")
74
75	i := NewInteractive(InteractiveConfig{})
76	i.dialog.Open(t.TempDir())
77	i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
78
79	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
80
81	i.mu.Lock()
82	defer i.mu.Unlock()
83	if !strings.Contains(i.statusErr, "exit status 23") {
84		t.Fatalf("status error = %q, want clipboard command failure", i.statusErr)
85	}
86	if i.statusOK != "" {
87		t.Fatalf("success status = %q, want empty", i.statusOK)
88	}
89}
90
91func TestInteractiveAltCUsesSystemClipboardWhenOSC52IsUnknown(t *testing.T) {
92	dir := t.TempDir()
93	payload := filepath.Join(dir, "clipboard-payload")
94	if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\nexec /bin/cat > \"$CLIPBOARD_TEST_PAYLOAD\"\n"), 0o700); err != nil {
95		t.Fatal(err)
96	}
97	t.Setenv("PATH", dir)
98	t.Setenv("WAYLAND_DISPLAY", "wayland-test")
99	t.Setenv("DISPLAY", "")
100	t.Setenv("TERM_PROGRAM", "")
101	t.Setenv("CLIPBOARD_TEST_PAYLOAD", payload)
102
103	const loginURL = "https://example.com/oauth/authorize?state=xyz"
104	term := &alertTestTerminal{}
105	i := NewInteractive(InteractiveConfig{Terminal: term})
106	i.dialog.Open(t.TempDir())
107	i.dialog.ShowWaiting(loginURL)
108
109	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
110
111	if got := term.String(); got != "" {
112		t.Fatalf("terminal output = %q, want no OSC 52 request", got)
113	}
114	got, err := os.ReadFile(payload)
115	if err != nil {
116		t.Fatal(err)
117	}
118	if string(got) != loginURL {
119		t.Fatalf("clipboard payload = %q, want %q", got, loginURL)
120	}
121}
122
123func TestInteractiveAltCDoesNotHoldMutexDuringTerminalWrite(t *testing.T) {
124	t.Setenv("TERM_PROGRAM", "vev")
125	term := &blockingLoginClipboardTerminal{started: make(chan struct{}), release: make(chan struct{})}
126	i := NewInteractive(InteractiveConfig{Terminal: term})
127	i.dialog.Open(t.TempDir())
128	i.dialog.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
129
130	done := make(chan struct{})
131	go func() {
132		i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
133		close(done)
134	}()
135	<-term.started
136
137	unlocked := make(chan struct{})
138	go func() {
139		i.mu.Lock()
140		close(unlocked)
141		i.mu.Unlock()
142	}()
143	select {
144	case <-unlocked:
145	case <-time.After(time.Second):
146		t.Fatal("interactive mutex remained locked during terminal write")
147	}
148
149	close(term.release)
150	select {
151	case <-done:
152	case <-time.After(time.Second):
153		t.Fatal("copy did not finish after terminal write unblocked")
154	}
155}

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 1

Tags 477

↑↓ moveenter openesc close