v0.2.0 80c61e3

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.Size) (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, ports.MsgOutput)
33	msg, err := ports.UnmarshalOutput(out.Payload)
34	require.NoError(t, err)
35	require.Contains(t, string(msg.Data), "Rename session")
36	require.Contains(t, string(msg.Data), "> 0")
37	require.True(t, ac.overlays.promptActive())
38}
39
40func TestPromptSubmitRenamesAndPromotesSession(t *testing.T) {
41	p, release := newBlockingPTY(t)
42	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
43	defer release()
44	sess.name = "0"
45	sess.ephemeral = true
46
47	d.enterPrompt(sess, ac, " Rename session ", sess.name, func(name string) error { return d.renameSession(sess, name) })
48	awaitFrame(t, sends, ports.MsgOutput)
49	d.handlePromptInput(ac, []byte("work\r"))
50	repaint := awaitFrame(t, sends, ports.MsgOutput)
51
52	require.False(t, ac.overlays.promptActive())
53	require.Equal(t, "0work", sess.name)
54	require.False(t, sess.ephemeral)
55	out, err := ports.UnmarshalOutput(repaint.Payload)
56	require.NoError(t, err)
57	require.Contains(t, string(out.Data), "0work")
58	require.NotContains(t, string(out.Data), "0work*")
59}
60
61func TestPromptSubmitErrorKeepsPromptOpen(t *testing.T) {
62	p1, release1 := newBlockingPTY(t)
63	p2, release2 := newBlockingPTY(t)
64	d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
65	defer release1()
66	defer release2()
67	ctx, cancel := context.WithCancel(context.Background())
68	defer cancel()
69	d.sessions["other"] = &session{sessionCore: sessionCore{id: "other", name: "taken"}, ctx: ctx, cancel: cancel, tabs: []*tab{newTestTabWithContext(p2, ctx, cancel)}}
70
71	d.enterPrompt(sess, ac, " Rename session ", "", func(name string) error { return d.renameSession(sess, name) })
72	awaitFrame(t, sends, ports.MsgOutput)
73	d.handlePromptInput(ac, []byte("taken\r"))
74	repaint := awaitFrame(t, sends, ports.MsgOutput)
75
76	require.True(t, ac.overlays.promptActive())
77	require.NotEqual(t, "taken", sess.name)
78	out, err := ports.UnmarshalOutput(repaint.Payload)
79	require.NoError(t, err)
80	require.Contains(t, string(out.Data), "name already in use")
81}
82
83func TestPromptSubmitSessionSpawnFailureReportsOneSafeNotice(t *testing.T) {
84	p, release := newBlockingPTY(t)
85	defer release()
86	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
87	cause := errors.New("fork/exec /bin/sh: permission denied")
88	d.ptys = failingPTYFactory{err: cause}
89
90	// Submit through the actual prompt input boundary rather than calling its
91	// callback directly.
92	d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
93		return d.createSessionAndSwitch(sess, ac, name)
94	})
95	awaitFrame(t, sends, ports.MsgOutput)
96	d.handlePromptInput(ac, []byte("new\r"))
97	repaint := awaitFrame(t, sends, ports.MsgOutput)
98	output, err := ports.UnmarshalOutput(repaint.Payload)
99	require.NoError(t, err)
100	require.Contains(t, string(output.Data), "couldn't create session: shell failed to start")
101
102	history := d.notices.history()
103	require.Len(t, history, 1, "the prompt boundary must report the failure once")
104	notice := history[0]
105	require.Equal(t, domain.NoticeSessionSpawn, notice.Code)
106	require.Equal(t, "couldn't create session: shell failed to start", notice.Message)
107	require.NotContains(t, notice.Message, cause.Error())
108	require.Contains(t, notice.Details, cause.Error())
109	require.True(t, ac.overlays.promptActive(), "failed submission stays inline")
110}
111
112func TestPromptSubmitValidationErrorStaysInlineOnly(t *testing.T) {
113	tests := []struct {
114		name    string
115		input   []byte
116		wantErr error
117		setup   func(*Daemon)
118	}{
119		{name: "invalid name", input: []byte("invalid name\r"), wantErr: domain.ErrInvalidSessionName},
120		{name: "required name", input: []byte("\r"), wantErr: errSessionNameRequired},
121		{
122			name:    "name in use",
123			input:   []byte("taken\r"),
124			wantErr: errSessionNameInUse,
125			setup: func(d *Daemon) {
126				d.sessions["taken"] = &session{sessionCore: sessionCore{id: "taken", name: "taken"}}
127			},
128		},
129	}
130
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			p, release := newBlockingPTY(t)
134			defer release()
135			d, sess, ac, sends := newManualSessionWithPTYs(t, p)
136			if tt.setup != nil {
137				tt.setup(d)
138			}
139
140			d.enterPrompt(sess, ac, " Create session ", "", func(name string) error {
141				return d.createSessionAndSwitch(sess, ac, name)
142			})
143			awaitFrame(t, sends, ports.MsgOutput)
144			d.handlePromptInput(ac, tt.input)
145			repaint := awaitFrame(t, sends, ports.MsgOutput)
146			output, err := ports.UnmarshalOutput(repaint.Payload)
147			require.NoError(t, err)
148			require.Contains(t, string(output.Data), tt.wantErr.Error())
149
150			require.True(t, ac.overlays.promptActive())
151			require.Empty(t, d.notices.history())
152		})
153	}
154}
155
156func TestPromptEscapeCancelsWithoutRename(t *testing.T) {
157	p, release := newBlockingPTY(t)
158	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
159	defer release()
160	oldName := sess.name
161
162	d.enterPrompt(sess, ac, " Rename session ", oldName, func(name string) error { return d.renameSession(sess, name) })
163	awaitFrame(t, sends, ports.MsgOutput)
164	d.handlePromptInput(ac, []byte("new\x1b"))
165	awaitFrame(t, sends, ports.MsgOutput)
166
167	require.False(t, ac.overlays.promptActive())
168	require.Equal(t, oldName, sess.name)
169}
170
171func TestPaletteRNSOpensRenamePrompt(t *testing.T) {
172	p, release := newBlockingPTY(t)
173	d, sess, ac, sends := newManualSessionWithPTYs(t, p)
174	defer release()
175	sess.name = "0"
176	sess.ephemeral = true
177
178	d.enterPalette(sess, ac)
179	awaitFrame(t, sends, ports.MsgOutput)
180	d.handlePaletteInput(ac, []byte("RNS\r"))
181	out := awaitFrame(t, sends, ports.MsgOutput)
182
183	require.False(t, ac.overlays.paletteActive())
184	require.True(t, ac.overlays.promptActive())
185	msg, err := ports.UnmarshalOutput(out.Payload)
186	require.NoError(t, err)
187	require.Contains(t, string(msg.Data), "Rename session")
188	require.Contains(t, string(msg.Data), "> 0")
189}

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