prompt_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"testing"
7
8	"github.com/stretchr/testify/require"
9
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/ports"
12)
13
14type failingPTYFactory struct{ err error }
15
16func (f failingPTYFactory) Open(context.Context, string, []string, []string, string, domain.Geometry) (ports.PTY, error) {
17	return nil, f.err
18}
19
20func TestPromptModalGeometry(t *testing.T) {
21	base := domain.Size{Cols: 100, Rows: 40}
22
23	require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
24}
25
26func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
27	p, release := newBlockingPTY(t)
28	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
29	defer release()
30
31	d.enterPrompt(sess, ac, " Rename session ", "0", func(string) error { return nil })
32	out := awaitFrame(t, sends, "Output")
33	msg := unmarshalTestOutput(t, out.Payload)
34	require.Contains(t, string(msg.Data), "Rename session")
35	require.Contains(t, string(msg.Data), "> 0")
36	require.True(t, ac.overlays.promptActive())
37}
38
39func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
40	p, release := newBlockingPTY(t)
41	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
42	defer release()
43	sess.name = "0"
44	sess.ephemeral = true
45
46	d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
47	awaitFrame(t, sends, "Output")
48	d.handlePromptInput(ac, []byte("work\r"))
49	repaint := awaitFrame(t, sends, "Output")
50
51	require.False(t, ac.overlays.promptActive())
52	require.Equal(t, "0work", sess.name)
53	require.False(t, sess.ephemeral)
54	out := unmarshalTestOutput(t, repaint.Payload)
55	require.Contains(t, string(out.Data), "0work")
56	require.NotContains(t, string(out.Data), "0work*")
57}
58
59func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
60	p1, release1 := newBlockingPTY(t)
61	p2, release2 := newBlockingPTY(t)
62	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
63	defer release1()
64	defer release2()
65	ctx, cancel := context.WithCancel(context.Background())
66	defer cancel()
67	d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
68
69	d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
70	awaitFrame(t, sends, "Output")
71	d.handlePromptInput(ac, []byte("taken\r"))
72	repaint := awaitFrame(t, sends, "Output")
73
74	require.True(t, ac.overlays.promptActive())
75	require.NotEqual(t, "taken", sess.name)
76	out := unmarshalTestOutput(t, repaint.Payload)
77	require.Contains(t, string(out.Data), "name already in use")
78}
79
80func TestTransitionPromptFailuresStayVisible(t *testing.T) {
81	tests := []struct {
82		name    string
83		submit  error
84		wantErr string
85	}{
86		{name: "destination unavailable", submit: errCreateDestinationUnavailable, wantErr: "that destination is no longer available"},
87		{name: "transition failed", submit: errors.New("transition failed"), wantErr: "transition failed"},
88	}
89	for _, tt := range tests {
90		t.Run(tt.name, func(t *testing.T) {
91			p, release := newBlockingPTY(t)
92			defer release()
93			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
94
95			d.enterTransitionPrompt(sess, ac, " Create session ", "", func(string, *attachmentEffect) error {
96				return tt.submit
97			})
98			awaitFrame(t, sends, "Output")
99			d.handlePromptInput(ac, []byte("example\r"))
100			repaint := awaitFrame(t, sends, "Output")
101
102			require.True(t, ac.overlays.promptActive())
103			out := unmarshalTestOutput(t, repaint.Payload)
104			require.Contains(t, string(out.Data), tt.wantErr)
105		})
106	}
107}
108
109func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
110	p, release := newBlockingPTY(t)
111	defer release()
112	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
113	cause := errors.New("fork/exec /bin/sh: permission denied")
114	d.ptys = failingPTYFactory{err: cause}
115
116	// Submit through the actual prompt input boundary rather than calling its
117	// callback directly.
118	d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
119		return d.createSessionAndSwitch(sess, ac, name)
120	})
121	awaitFrame(t, sends, "Output")
122	d.handlePromptInput(ac, []byte("new\r"))
123	repaint := awaitFrame(t, sends, "Output")
124	output := unmarshalTestOutput(t, repaint.Payload)
125	require.Contains(t, string(output.Data), "couldn't create session: shell failed to start")
126
127	history := d.notices.history()
128	require.Len(t, history, 1, "the prompt boundary must report the failure once")
129	notice := history[0]
130	require.Equal(t, domain.NoticeSessionSpawn, notice.Code)
131	require.Equal(t, "couldn't create session: shell failed to start", notice.Message)
132	require.NotContains(t, notice.Message, cause.Error())
133	require.Contains(t, notice.Details, cause.Error())
134	require.True(t, ac.overlays.promptActive(), "failed submission stays inline")
135}
136
137func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
138	tests := []struct {
139		name    string
140		input   []byte
141		wantErr error
142		setup   func(*Daemon)
143	}{
144		{name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName},
145		{name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired},
146		{
147			name:    "name in use",
148			input:   []byte("taken\r"),
149			wantErr: errSessionNameInUse,
150			setup: func(d *Daemon) {
151				d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
152			},
153		},
154	}
155
156	for _, tt := range tests {
157		t.Run(tt.name, func(t *testing.T) {
158			p, release := newBlockingPTY(t)
159			defer release()
160			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
161			if tt.setup != nil {
162				tt.setup(d)
163			}
164
165			d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
166				return d.createSessionAndSwitch(sess, ac, name)
167			})
168			awaitFrame(t, sends, "Output")
169			d.handlePromptInput(ac, tt.input)
170			repaint := awaitFrame(t, sends, "Output")
171			output := unmarshalTestOutput(t, repaint.Payload)
172			require.Contains(t, string(output.Data), tt.wantErr.Error())
173
174			require.True(t, ac.overlays.promptActive())
175			require.Empty(t, d.notices.history())
176		})
177	}
178}
179
180func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
181	p, release := newBlockingPTY(t)
182	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
183	defer release()
184	oldName := sess.name
185
186	d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
187	awaitFrame(t, sends, "Output")
188	d.handlePromptInput(ac, []byte("new\x1b"))
189	awaitFrame(t, sends, "Output")
190
191	require.False(t, ac.overlays.promptActive())
192	require.Equal(t, oldName, sess.name)
193}
194
195func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
196	p, release := newBlockingPTY(t)
197	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
198	defer release()
199	sess.name = "0"
200	sess.ephemeral = true
201
202	d.enterPalette(sess, ac)
203	awaitFrame(t, sends, "Output")
204	d.handlePaletteInput(ac, []byte("RNS\r"))
205	out := awaitFrame(t, sends, "Output")
206
207	require.False(t, ac.overlays.paletteActive())
208	require.True(t, ac.overlays.promptActive())
209	msg := unmarshalTestOutput(t, out.Payload)
210	require.Contains(t, string(msg.Data), "Rename session")
211	require.Contains(t, string(msg.Data), "> 0")
212}

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 12

Tags 11

↑↓ moveenter openesc close