v0.3.0 99dc266

palette_attach_test.go RawHistoryFolder

1package client
2
3import (
4	"bytes"
5	"context"
6	"io"
7	"log/slog"
8	"sync"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/require"
13
14	renderer "github.com/bnema/vev-vt"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/ports"
17)
18
19type paletteAttachClock struct{}
20
21func (paletteAttachClock) Now() time.Time { return time.Time{} }
22func (paletteAttachClock) NewTimer(time.Duration) ports.Timer {
23	return paletteAttachTimer{ch: make(chan time.Time)}
24}
25
26type paletteAttachTimer struct{ ch chan time.Time }
27
28func (t paletteAttachTimer) C() <-chan time.Time    { return t.ch }
29func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30func (paletteAttachTimer) Stop() bool               { return true }
31
32type paletteAttachReader struct {
33	mu   sync.Mutex
34	data []byte
35	done chan struct{}
36	once sync.Once
37}
38
39func newPaletteAttachReader(data []byte) *paletteAttachReader {
40	return &paletteAttachReader{data: append([]byte(nil), data...), done: make(chan struct{})}
41}
42
43// Read retains unread bytes after a short read and blocks only until close.
44// This keeps the test terminal reader faithful to io.Reader and makes a
45// failed test teardown deterministic instead of leaving a goroutine parked.
46func (r *paletteAttachReader) Read(p []byte) (int, error) {
47	r.mu.Lock()
48	if len(r.data) != 0 {
49		n := copy(p, r.data)
50		r.data = r.data[n:]
51		r.mu.Unlock()
52		return n, nil
53	}
54	r.mu.Unlock()
55	<-r.done
56	return 0, io.EOF
57}
58
59func (r *paletteAttachReader) close() { r.once.Do(func() { close(r.done) }) }
60
61type paletteAttachTerminal struct {
62	in     io.Reader
63	out    bytes.Buffer
64	resize chan domain.Size
65}
66
67func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
68	return func() error { return nil }, nil
69}
70func (t *paletteAttachTerminal) Size() (domain.Size, error) {
71	return domain.Size{Cols: 80, Rows: 24}, nil
72}
73func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
74func (t *paletteAttachTerminal) In() io.Reader                    { return t.in }
75func (t *paletteAttachTerminal) Out() io.Writer                   { return &t.out }
76func (t *paletteAttachTerminal) Flush() error                     { return nil }
77
78type paletteAttachTransport struct {
79	mu       sync.Mutex
80	sent     []ports.Frame
81	themes   []ports.Theme
82	finals   int
83	finalSet chan struct{}
84}
85
86func (t *paletteAttachTransport) Send(frame ports.Frame) error {
87	t.mu.Lock()
88	defer t.mu.Unlock()
89	t.sent = append(t.sent, frame)
90	if frame.Type == ports.MsgTheme {
91		theme, err := ports.UnmarshalTheme(frame.Payload)
92		if err != nil {
93			return err
94		}
95		t.themes = append(t.themes, theme)
96		if theme.HasForeground && theme.HasBackground {
97			t.finals++
98			if t.finals == 1 {
99				close(t.finalSet)
100			}
101		}
102	}
103	return nil
104}
105
106func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
107	t.mu.Lock()
108	first := len(t.sent) == 1
109	t.mu.Unlock()
110	if first {
111		return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
112	}
113	<-t.finalSet
114	return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil
115}
116func (*paletteAttachTransport) Close() error { return nil }
117
118func TestPaletteAttachReaderPreservesShortReadRemainderAndCloses(t *testing.T) {
119	reader := newPaletteAttachReader([]byte("abcdef"))
120	first := make([]byte, 2)
121	n, err := reader.Read(first)
122	require.NoError(t, err)
123	require.Equal(t, "ab", string(first[:n]))
124
125	rest := make([]byte, 8)
126	n, err = reader.Read(rest)
127	require.NoError(t, err)
128	require.Equal(t, "cdef", string(rest[:n]))
129
130	reader.close()
131	n, err = reader.Read(rest)
132	require.Zero(t, n)
133	require.ErrorIs(t, err, io.EOF)
134}
135
136func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
137	input := newPaletteAttachReader([]byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y"))
138	t.Cleanup(input.close)
139	term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
140	transport := &paletteAttachTransport{finalSet: make(chan struct{})}
141	ms := &milestones{}
142	runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
143	attempt := &attachAttempt{
144		runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
145		milestones: ms, themeState: &terminalThemeState{},
146		enterRaw:  func() error { ms.rawEntered = true; return nil },
147		reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
148	}
149
150	result := attempt.run(context.Background())
151	require.NoError(t, result.err)
152	require.Equal(t, paletteColorBatch, term.out.String())
153
154	transport.mu.Lock()
155	defer transport.mu.Unlock()
156	require.Len(t, transport.sent, 3)
157	require.Equal(t, []ports.MsgType{ports.MsgHello, ports.MsgTheme, ports.MsgTheme}, []ports.MsgType{
158		transport.sent[0].Type, transport.sent[1].Type, transport.sent[2].Type,
159	}, "the protocol requires Hello before the two palette publications")
160	require.Len(t, transport.themes, 2)
161	cleared, definitive := transport.themes[0], transport.themes[1]
162	require.Zero(t, cleared.PaletteKnown)
163	require.False(t, cleared.HasForeground)
164	require.True(t, definitive.HasForeground)
165	require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground)
166	require.True(t, definitive.HasBackground)
167	require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background)
168	require.Equal(t, uint16(1<<2), definitive.PaletteKnown)
169}

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