v0.29.1 d92a60f

login_dialog_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"os"
6	"path/filepath"
7	"strings"
8	"testing"
9
10	"github.com/bnema/zut/packages/tui"
11)
12
13func TestLoginDialogProviderPickerFiltersLikeModelPicker(t *testing.T) {
14	d := newLoginDialog()
15	d.step = loginStepProvider
16	d.method = "apikey"
17	d.status = map[string]string{}
18	for _, r := range "llamacpp" {
19		d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: r})
20	}
21	options := d.providerOptions()
22	if len(options) != 1 || options[0] != "llama.cpp" {
23		t.Fatalf("options = %v", options)
24	}
25	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
26	if d.step != loginStepLlamaURL {
27		t.Fatalf("step = %v", d.step)
28	}
29}
30
31func TestLoginDialogProviderPickerShowsNoMatches(t *testing.T) {
32	d := newLoginDialog()
33	d.step = loginStepProvider
34	d.method = "apikey"
35	d.status = map[string]string{}
36	d.providerQuery = "not-a-provider"
37	if options := d.providerOptions(); len(options) != 0 {
38		t.Fatalf("options = %v", options)
39	}
40	d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
41	if d.cursor != 0 {
42		t.Fatalf("cursor = %d", d.cursor)
43	}
44	if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action != (loginDialogAction{}) {
45		t.Fatalf("action = %+v", action)
46	}
47}
48
49func TestLoginDialogLlamaCPPValidatesURLAndAcceptsOptionalKey(t *testing.T) {
50	d := newLoginDialog()
51	d.step = loginStepLlamaURL
52	d.provider = "llama.cpp"
53	d.method = "apikey"
54	d.llamaEd = tui.NewEditor("")
55
56	d.llamaEd.SetValue("file:///tmp/router")
57	if action := d.HandleKey(tui.Key{Kind: tui.KeyEnter}); action.SaveLlama || d.step != loginStepLlamaURL {
58		t.Fatalf("invalid URL advanced dialog: action=%+v step=%v", action, d.step)
59	}
60	if !strings.Contains(d.message, "http or https") {
61		t.Fatalf("validation message = %q", d.message)
62	}
63
64	d.llamaEd.SetValue("http://127.0.0.1:8080/v1/")
65	d.HandleKey(tui.Key{Kind: tui.KeyEnter})
66	if d.step != loginStepLlamaKey || d.llamaURL != "http://127.0.0.1:8080" {
67		t.Fatalf("step=%v URL=%q", d.step, d.llamaURL)
68	}
69	action := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
70	if !action.SaveLlama || action.LlamaURL != "http://127.0.0.1:8080" || action.LlamaAPIKey != "" {
71		t.Fatalf("action = %+v", action)
72	}
73}
74
75func TestLoginDialogCursorPosMatchesPaddedInputRow(t *testing.T) {
76	d := newLoginDialog()
77	d.Open(t.TempDir())
78	d.method = "oauth"
79	d.provider = "anthropic"
80	d.ShowWaiting("https://example.com/oauth/authorize?code_challenge=abc&state=xyz")
81
82	// Floating panes render this dialog narrower than the main terminal, so
83	// CursorPos must use the identical width to retain wrapping alignment.
84	const paneWidth = 62
85	lines := padDialogFrame(d.Render(tui.Theme{}, paneWidth))
86	row, _ := d.CursorPos(paneWidth)
87	if row < 0 || row >= len(lines) {
88		t.Fatalf("CursorPos row = %d outside rendered lines %d", row, len(lines))
89	}
90	if got := stripANSIBytes(lines[row]); !strings.Contains(got, "▌") {
91		t.Fatalf("CursorPos row %d = %q; want editor input row", row, got)
92	}
93}
94
95func TestLoginDialogWaitingShowsCopyURLShortcut(t *testing.T) {
96	for _, provider := range []string{"openai-codex", "kimi", "xai", "github-copilot"} {
97		t.Run(provider, func(t *testing.T) {
98			d := newLoginDialog()
99			d.Open(t.TempDir())
100			d.method = "oauth"
101			d.provider = provider
102			d.ShowWaiting("https://example.com/oauth/authorize?state=xyz")
103
104			lines := strings.Join(d.Render(tui.Theme{}, 80), "\n")
105			if !strings.Contains(lines, "alt+c copies URL") {
106				t.Fatalf("login dialog does not advertise the copy shortcut: %q", lines)
107			}
108		})
109	}
110}
111
112func TestInteractiveAltCSendsLoginURLToTerminalClipboard(t *testing.T) {
113	t.Setenv("PATH", t.TempDir())
114	t.Setenv("WAYLAND_DISPLAY", "")
115	t.Setenv("DISPLAY", "")
116	t.Setenv("TERM_PROGRAM", "vev")
117
118	const loginURL = "https://example.com/oauth/authorize?state=xyz"
119	term := &alertTestTerminal{}
120	i := NewInteractive(InteractiveConfig{Terminal: term})
121	i.dialog.Open(t.TempDir())
122	i.dialog.method = "oauth"
123	i.dialog.provider = "openai-codex"
124	i.dialog.ShowWaiting(loginURL)
125	i.dialog.Render(tui.Theme{}, 80)
126
127	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
128
129	const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a"
130	if got := term.String(); got != want {
131		t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want)
132	}
133	if got := i.dialog.codeEd.SubmitValue(); got != "" {
134		t.Fatalf("authorization code input = %q after alt+c, want unchanged", got)
135	}
136	i.mu.Lock()
137	defer i.mu.Unlock()
138	if i.statusOK != "sent login URL to terminal clipboard" || i.statusErr != "" {
139		t.Fatalf("status = (%q, %q), want successful terminal copy request", i.statusOK, i.statusErr)
140	}
141}
142
143func TestInteractiveAltCUsesTerminalClipboardBeforeSystemClipboard(t *testing.T) {
144	dir := t.TempDir()
145	state := filepath.Join(dir, "wl-copy-called")
146	if err := os.WriteFile(filepath.Join(dir, "wl-copy"), []byte("#!/bin/sh\n: > \"$CLIPBOARD_TEST_STATE\"\n"), 0o700); err != nil {
147		t.Fatal(err)
148	}
149	t.Setenv("PATH", dir)
150	t.Setenv("WAYLAND_DISPLAY", "wayland-test")
151	t.Setenv("DISPLAY", "")
152	t.Setenv("TERM_PROGRAM", "vev")
153	t.Setenv("CLIPBOARD_TEST_STATE", state)
154
155	const loginURL = "https://example.com/oauth/authorize?state=xyz"
156	term := &alertTestTerminal{}
157	i := NewInteractive(InteractiveConfig{Terminal: term})
158	i.dialog.Open(t.TempDir())
159	i.dialog.ShowWaiting(loginURL)
160
161	i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'c', Alt: true})
162
163	const want = "\x1b]52;c;aHR0cHM6Ly9leGFtcGxlLmNvbS9vYXV0aC9hdXRob3JpemU/c3RhdGU9eHl6\a"
164	if got := term.String(); got != want {
165		t.Fatalf("terminal output = %q, want OSC 52 copy %q", got, want)
166	}
167	if _, err := os.Stat(state); !os.IsNotExist(err) {
168		t.Fatalf("system clipboard helper ran: %v", err)
169	}
170}

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 476

↑↓ moveenter openesc close