detached 9394293

test(client): cover attach palette deadlines

commit
93942932294f5c381a1abcf9006ec526a6f7d164
author
brice · · 2026-07-18 06:40 UTC
parent
12b1b9b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +308 -0
A internal/usecase/client/palette_attach_generation_test.go +308 -0
@@ -0,0 +1,308 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+// attachPaletteClock deliberately exposes each independently-created timer.
19+// Tests fire only the deadline they intend to exercise; no wall clock is used.
20+type attachPaletteClock struct{ timers chan *attachPaletteTimer }
21+
22+type attachPaletteTimer struct{ ch chan time.Time }
23+
24+func newAttachPaletteClock() *attachPaletteClock {
25+ return &attachPaletteClock{timers: make(chan *attachPaletteTimer, 16)}
26+}
27+
28+func (*attachPaletteClock) Now() time.Time { return time.Time{} }
29+func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer {
30+ timer := &attachPaletteTimer{ch: make(chan time.Time, 1)}
31+ c.timers <- timer
32+ return timer
33+}
34+func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
35+func (*attachPaletteTimer) Reset(time.Duration) bool { return false }
36+func (*attachPaletteTimer) Stop() bool { return true }
37+func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} }
38+
39+type attachPaletteReader struct{ chunks chan []byte }
40+
41+func newAttachPaletteReader() *attachPaletteReader {
42+ return &attachPaletteReader{chunks: make(chan []byte, 16)}
43+}
44+func (r *attachPaletteReader) Read(p []byte) (int, error) {
45+ data := <-r.chunks
46+ return copy(p, data), nil
47+}
48+func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) }
49+
50+type attachPaletteWriter struct {
51+ mu sync.Mutex
52+ writes []string
53+ wrote chan string
54+}
55+
56+func newAttachPaletteWriter() *attachPaletteWriter {
57+ return &attachPaletteWriter{wrote: make(chan string, 16)}
58+}
59+func (w *attachPaletteWriter) Write(data []byte) (int, error) {
60+ copyData := string(data)
61+ w.mu.Lock()
62+ w.writes = append(w.writes, copyData)
63+ w.mu.Unlock()
64+ w.wrote <- copyData
65+ return len(data), nil
66+}
67+
68+type attachPaletteTerminalHarness struct {
69+ in io.Reader
70+ out *attachPaletteWriter
71+ resize chan domain.Size
72+}
73+
74+func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
75+ return func() error { return nil }, nil
76+}
77+func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
78+ return domain.Size{Cols: 80, Rows: 24}, nil
79+}
80+func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
81+func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
82+func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
83+func (*attachPaletteTerminalHarness) Flush() error { return nil }
84+
85+type attachPaletteTransport struct {
86+ mu sync.Mutex
87+ frames []ports.Frame
88+ themes []ports.Theme
89+ themeCh chan ports.Theme
90+ inputCh chan ports.Input
91+ detached chan ports.Frame
92+ welcomed bool
93+}
94+
95+func newAttachPaletteTransport() *attachPaletteTransport {
96+ return &attachPaletteTransport{
97+ themeCh: make(chan ports.Theme, 16),
98+ inputCh: make(chan ports.Input, 16),
99+ detached: make(chan ports.Frame, 1),
100+ }
101+}
102+func (t *attachPaletteTransport) Send(frame ports.Frame) error {
103+ t.mu.Lock()
104+ t.frames = append(t.frames, frame)
105+ t.mu.Unlock()
106+ switch frame.Type {
107+ case ports.MsgTheme:
108+ got, err := ports.UnmarshalTheme(frame.Payload)
109+ if err != nil {
110+ return err
111+ }
112+ t.mu.Lock()
113+ t.themes = append(t.themes, got)
114+ t.mu.Unlock()
115+ t.themeCh <- got
116+ case ports.MsgInput:
117+ got, err := ports.UnmarshalInput(frame.Payload)
118+ if err != nil {
119+ return err
120+ }
121+ t.inputCh <- got
122+ }
123+ return nil
124+}
125+func (t *attachPaletteTransport) Recv() (ports.Frame, error) {
126+ t.mu.Lock()
127+ if !t.welcomed {
128+ t.welcomed = true
129+ t.mu.Unlock()
130+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
131+ }
132+ t.mu.Unlock()
133+ return <-t.detached, nil
134+}
135+func (*attachPaletteTransport) Close() error { return nil }
136+func (t *attachPaletteTransport) snapshots() []ports.Theme {
137+ t.mu.Lock()
138+ defer t.mu.Unlock()
139+ return append([]ports.Theme(nil), t.themes...)
140+}
141+
142+type attachPaletteHarness struct {
143+ clock *attachPaletteClock
144+ reader *attachPaletteReader
145+ writer *attachPaletteWriter
146+ transport *attachPaletteTransport
147+ done chan attachResult
148+}
149+
150+func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
151+ clock := newAttachPaletteClock()
152+ reader := newAttachPaletteReader()
153+ writer := newAttachPaletteWriter()
154+ term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
155+ transport := newAttachPaletteTransport()
156+ ms := &milestones{}
157+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
158+ attempt := &attachAttempt{
159+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
160+ milestones: ms, themeState: state,
161+ enterRaw: func() error { ms.rawEntered = true; return nil },
162+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
163+ }
164+ h := &attachPaletteHarness{clock: clock, reader: reader, writer: writer, transport: transport, done: make(chan attachResult, 1)}
165+ go func() { h.done <- attempt.run(context.Background()) }()
166+ // The handshake's two bounded-pre-welcome timers are unrelated to palette
167+ // acquisition. Remove them in this harness so every exposed timer is a
168+ // generation deadline (or a timer deliberately created by input handling).
169+ <-clock.timers
170+ <-clock.timers
171+ return h
172+}
173+
174+func (h *attachPaletteHarness) nextWrite(t *testing.T) string {
175+ t.Helper()
176+ return <-h.writer.wrote
177+}
178+func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme {
179+ t.Helper()
180+ return <-h.transport.themeCh
181+}
182+func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
183+ t.Helper()
184+ return <-h.clock.timers
185+}
186+func (h *attachPaletteHarness) detach(t *testing.T) {
187+ t.Helper()
188+ h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
189+ require.NoError(t, (<-h.done).err)
190+}
191+
192+func paletteReply(foreground, background string, slot uint8, color string) string {
193+ return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
194+}
195+
196+func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
197+ t.Setenv("COLORTERM", "truecolor")
198+ state := &terminalThemeState{}
199+ first := startAttachPaletteHarness(state)
200+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
201+ first.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
202+ _ = first.nextTheme(t) // initial cleared snapshot
203+ final := first.nextTheme(t)
204+ require.True(t, final.HasForeground)
205+ first.detach(t)
206+
207+ reconnected := startAttachPaletteHarness(state)
208+ require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
209+ cleared := reconnected.nextTheme(t)
210+ require.True(t, cleared.HasForeground)
211+ require.Equal(t, final.Foreground, cleared.Foreground)
212+ require.True(t, cleared.HasBackground)
213+ require.Equal(t, final.Background, cleared.Background)
214+ require.Zero(t, cleared.PaletteKnown)
215+ require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
216+
217+ reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122"))
218+ _ = reconnected.nextTheme(t)
219+ reconnected.detach(t)
220+ require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
221+}
222+
223+func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
224+ t.Setenv("COLORTERM", "truecolor")
225+ h := startAttachPaletteHarness(&terminalThemeState{})
226+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
227+ initialDeadline := h.nextTimer(t)
228+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
229+ _ = h.nextTheme(t)
230+ _ = h.nextTheme(t)
231+ // The initial completion timer was cancelled by the marker; it is distinct
232+ // from both replacement deadlines.
233+ _ = initialDeadline
234+
235+ h.reader.send("\x1b[?997;2n")
236+ cleared := h.nextTheme(t)
237+ require.True(t, cleared.SchemeKnown)
238+ require.True(t, cleared.Light)
239+ require.Zero(t, cleared.PaletteKnown)
240+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
241+ drainDeadline := h.nextTimer(t)
242+
243+ // stdin is now blocked in Read. Firing the independently-created drain
244+ // deadline still starts the color batch, and its completion deadline still
245+ // finalizes the partial generation.
246+ drainDeadline.fire()
247+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
248+ completionDeadline := h.nextTimer(t)
249+ completionDeadline.fire()
250+ final := h.nextTheme(t)
251+ require.True(t, final.SchemeKnown)
252+ require.True(t, final.Light)
253+ require.False(t, final.HasForeground)
254+ require.False(t, final.HasBackground)
255+ h.detach(t)
256+}
257+
258+func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
259+ t.Setenv("COLORTERM", "truecolor")
260+ h := startAttachPaletteHarness(&terminalThemeState{})
261+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
262+ _ = h.nextTimer(t)
263+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
264+ _ = h.nextTheme(t)
265+ _ = h.nextTheme(t)
266+
267+ h.reader.send("\x1b[?997;1n")
268+ _ = h.nextTheme(t)
269+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
270+ drainDeadline := h.nextTimer(t)
271+ drainDeadline.fire()
272+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
273+ completionDeadline := h.nextTimer(t)
274+
275+ // The first marker is the late drain response. The second is the batch
276+ // completion marker. Colors between them prove the first cannot finalize.
277+ h.reader.send("\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
278+ final := h.nextTheme(t)
279+ require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
280+ require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
281+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
282+ // The marker finalization cancels, rather than fires, the independently
283+ // armed completion deadline.
284+ _ = completionDeadline
285+ h.detach(t)
286+}
287+
288+func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) {
289+ t.Setenv("COLORTERM", "truecolor")
290+ h := startAttachPaletteHarness(&terminalThemeState{})
291+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
292+ _ = h.nextTimer(t)
293+ cleared := h.nextTheme(t)
294+ require.Zero(t, cleared.PaletteKnown)
295+
296+ h.reader.send("\x1b]10;#0102")
297+ h.reader.send("03\az")
298+ input := <-h.transport.inputCh
299+ require.Equal(t, []byte("z"), input.Data)
300+ require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme")
301+
302+ h.reader.send("\x1b[?2031;1$y")
303+ final := h.nextTheme(t)
304+ require.True(t, final.HasForeground)
305+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
306+ require.Len(t, h.transport.snapshots(), 2)
307+ h.detach(t)
308+}
@@ -0,0 +1,308 @@
1+package client
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+// attachPaletteClock deliberately exposes each independently-created timer.
19+// Tests fire only the deadline they intend to exercise; no wall clock is used.
20+type attachPaletteClock struct{ timers chan *attachPaletteTimer }
21+
22+type attachPaletteTimer struct{ ch chan time.Time }
23+
24+func newAttachPaletteClock() *attachPaletteClock {
25+ return &attachPaletteClock{timers: make(chan *attachPaletteTimer, 16)}
26+}
27+
28+func (*attachPaletteClock) Now() time.Time { return time.Time{} }
29+func (c *attachPaletteClock) NewTimer(time.Duration) ports.Timer {
30+ timer := &attachPaletteTimer{ch: make(chan time.Time, 1)}
31+ c.timers <- timer
32+ return timer
33+}
34+func (t *attachPaletteTimer) C() <-chan time.Time { return t.ch }
35+func (*attachPaletteTimer) Reset(time.Duration) bool { return false }
36+func (*attachPaletteTimer) Stop() bool { return true }
37+func (t *attachPaletteTimer) fire() { t.ch <- time.Time{} }
38+
39+type attachPaletteReader struct{ chunks chan []byte }
40+
41+func newAttachPaletteReader() *attachPaletteReader {
42+ return &attachPaletteReader{chunks: make(chan []byte, 16)}
43+}
44+func (r *attachPaletteReader) Read(p []byte) (int, error) {
45+ data := <-r.chunks
46+ return copy(p, data), nil
47+}
48+func (r *attachPaletteReader) send(data string) { r.chunks <- []byte(data) }
49+
50+type attachPaletteWriter struct {
51+ mu sync.Mutex
52+ writes []string
53+ wrote chan string
54+}
55+
56+func newAttachPaletteWriter() *attachPaletteWriter {
57+ return &attachPaletteWriter{wrote: make(chan string, 16)}
58+}
59+func (w *attachPaletteWriter) Write(data []byte) (int, error) {
60+ copyData := string(data)
61+ w.mu.Lock()
62+ w.writes = append(w.writes, copyData)
63+ w.mu.Unlock()
64+ w.wrote <- copyData
65+ return len(data), nil
66+}
67+
68+type attachPaletteTerminalHarness struct {
69+ in io.Reader
70+ out *attachPaletteWriter
71+ resize chan domain.Size
72+}
73+
74+func (t *attachPaletteTerminalHarness) EnterRaw() (func() error, error) {
75+ return func() error { return nil }, nil
76+}
77+func (*attachPaletteTerminalHarness) Size() (domain.Size, error) {
78+ return domain.Size{Cols: 80, Rows: 24}, nil
79+}
80+func (t *attachPaletteTerminalHarness) ResizeEvents() <-chan domain.Size { return t.resize }
81+func (t *attachPaletteTerminalHarness) In() io.Reader { return t.in }
82+func (t *attachPaletteTerminalHarness) Out() io.Writer { return t.out }
83+func (*attachPaletteTerminalHarness) Flush() error { return nil }
84+
85+type attachPaletteTransport struct {
86+ mu sync.Mutex
87+ frames []ports.Frame
88+ themes []ports.Theme
89+ themeCh chan ports.Theme
90+ inputCh chan ports.Input
91+ detached chan ports.Frame
92+ welcomed bool
93+}
94+
95+func newAttachPaletteTransport() *attachPaletteTransport {
96+ return &attachPaletteTransport{
97+ themeCh: make(chan ports.Theme, 16),
98+ inputCh: make(chan ports.Input, 16),
99+ detached: make(chan ports.Frame, 1),
100+ }
101+}
102+func (t *attachPaletteTransport) Send(frame ports.Frame) error {
103+ t.mu.Lock()
104+ t.frames = append(t.frames, frame)
105+ t.mu.Unlock()
106+ switch frame.Type {
107+ case ports.MsgTheme:
108+ got, err := ports.UnmarshalTheme(frame.Payload)
109+ if err != nil {
110+ return err
111+ }
112+ t.mu.Lock()
113+ t.themes = append(t.themes, got)
114+ t.mu.Unlock()
115+ t.themeCh <- got
116+ case ports.MsgInput:
117+ got, err := ports.UnmarshalInput(frame.Payload)
118+ if err != nil {
119+ return err
120+ }
121+ t.inputCh <- got
122+ }
123+ return nil
124+}
125+func (t *attachPaletteTransport) Recv() (ports.Frame, error) {
126+ t.mu.Lock()
127+ if !t.welcomed {
128+ t.welcomed = true
129+ t.mu.Unlock()
130+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
131+ }
132+ t.mu.Unlock()
133+ return <-t.detached, nil
134+}
135+func (*attachPaletteTransport) Close() error { return nil }
136+func (t *attachPaletteTransport) snapshots() []ports.Theme {
137+ t.mu.Lock()
138+ defer t.mu.Unlock()
139+ return append([]ports.Theme(nil), t.themes...)
140+}
141+
142+type attachPaletteHarness struct {
143+ clock *attachPaletteClock
144+ reader *attachPaletteReader
145+ writer *attachPaletteWriter
146+ transport *attachPaletteTransport
147+ done chan attachResult
148+}
149+
150+func startAttachPaletteHarness(state *terminalThemeState) *attachPaletteHarness {
151+ clock := newAttachPaletteClock()
152+ reader := newAttachPaletteReader()
153+ writer := newAttachPaletteWriter()
154+ term := &attachPaletteTerminalHarness{in: reader, out: writer, resize: make(chan domain.Size)}
155+ transport := newAttachPaletteTransport()
156+ ms := &milestones{}
157+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
158+ attempt := &attachAttempt{
159+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
160+ milestones: ms, themeState: state,
161+ enterRaw: func() error { ms.rawEntered = true; return nil },
162+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
163+ }
164+ h := &attachPaletteHarness{clock: clock, reader: reader, writer: writer, transport: transport, done: make(chan attachResult, 1)}
165+ go func() { h.done <- attempt.run(context.Background()) }()
166+ // The handshake's two bounded-pre-welcome timers are unrelated to palette
167+ // acquisition. Remove them in this harness so every exposed timer is a
168+ // generation deadline (or a timer deliberately created by input handling).
169+ <-clock.timers
170+ <-clock.timers
171+ return h
172+}
173+
174+func (h *attachPaletteHarness) nextWrite(t *testing.T) string {
175+ t.Helper()
176+ return <-h.writer.wrote
177+}
178+func (h *attachPaletteHarness) nextTheme(t *testing.T) ports.Theme {
179+ t.Helper()
180+ return <-h.transport.themeCh
181+}
182+func (h *attachPaletteHarness) nextTimer(t *testing.T) *attachPaletteTimer {
183+ t.Helper()
184+ return <-h.clock.timers
185+}
186+func (h *attachPaletteHarness) detach(t *testing.T) {
187+ t.Helper()
188+ h.transport.detached <- ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}
189+ require.NoError(t, (<-h.done).err)
190+}
191+
192+func paletteReply(foreground, background string, slot uint8, color string) string {
193+ return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
194+}
195+
196+func TestAttachReconnectClearsRetainedPaletteInOnePublication(t *testing.T) {
197+ t.Setenv("COLORTERM", "truecolor")
198+ state := &terminalThemeState{}
199+ first := startAttachPaletteHarness(state)
200+ require.Equal(t, paletteColorBatch, first.nextWrite(t))
201+ first.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
202+ _ = first.nextTheme(t) // initial cleared snapshot
203+ final := first.nextTheme(t)
204+ require.True(t, final.HasForeground)
205+ first.detach(t)
206+
207+ reconnected := startAttachPaletteHarness(state)
208+ require.Equal(t, paletteColorBatch, reconnected.nextWrite(t))
209+ cleared := reconnected.nextTheme(t)
210+ require.True(t, cleared.HasForeground)
211+ require.Equal(t, final.Foreground, cleared.Foreground)
212+ require.True(t, cleared.HasBackground)
213+ require.Equal(t, final.Background, cleared.Background)
214+ require.Zero(t, cleared.PaletteKnown)
215+ require.Equal(t, [16]renderer.RGB{}, cleared.Palette)
216+
217+ reconnected.reader.send(paletteReply("#111213", "#141516", 2, "#202122"))
218+ _ = reconnected.nextTheme(t)
219+ reconnected.detach(t)
220+ require.Len(t, reconnected.transport.snapshots(), 2, "reconnect must not send a restore Theme before its cleared publication")
221+}
222+
223+func TestAttachSchemeReplacementAndDeadlinesProgressWhileReadBlocks(t *testing.T) {
224+ t.Setenv("COLORTERM", "truecolor")
225+ h := startAttachPaletteHarness(&terminalThemeState{})
226+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
227+ initialDeadline := h.nextTimer(t)
228+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
229+ _ = h.nextTheme(t)
230+ _ = h.nextTheme(t)
231+ // The initial completion timer was cancelled by the marker; it is distinct
232+ // from both replacement deadlines.
233+ _ = initialDeadline
234+
235+ h.reader.send("\x1b[?997;2n")
236+ cleared := h.nextTheme(t)
237+ require.True(t, cleared.SchemeKnown)
238+ require.True(t, cleared.Light)
239+ require.Zero(t, cleared.PaletteKnown)
240+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
241+ drainDeadline := h.nextTimer(t)
242+
243+ // stdin is now blocked in Read. Firing the independently-created drain
244+ // deadline still starts the color batch, and its completion deadline still
245+ // finalizes the partial generation.
246+ drainDeadline.fire()
247+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
248+ completionDeadline := h.nextTimer(t)
249+ completionDeadline.fire()
250+ final := h.nextTheme(t)
251+ require.True(t, final.SchemeKnown)
252+ require.True(t, final.Light)
253+ require.False(t, final.HasForeground)
254+ require.False(t, final.HasBackground)
255+ h.detach(t)
256+}
257+
258+func TestAttachLateDrainCannotFinalizeReplacementEarly(t *testing.T) {
259+ t.Setenv("COLORTERM", "truecolor")
260+ h := startAttachPaletteHarness(&terminalThemeState{})
261+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
262+ _ = h.nextTimer(t)
263+ h.reader.send(paletteReply("#010203", "#040506", 2, "#102030"))
264+ _ = h.nextTheme(t)
265+ _ = h.nextTheme(t)
266+
267+ h.reader.send("\x1b[?997;1n")
268+ _ = h.nextTheme(t)
269+ require.Equal(t, paletteBoundaryQuery, h.nextWrite(t))
270+ drainDeadline := h.nextTimer(t)
271+ drainDeadline.fire()
272+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
273+ completionDeadline := h.nextTimer(t)
274+
275+ // The first marker is the late drain response. The second is the batch
276+ // completion marker. Colors between them prove the first cannot finalize.
277+ h.reader.send("\x1b[?2031;1$y\x1b]10;#111213\a\x1b]11;#141516\a\x1b]4;3;#212223\a\x1b[?2031;1$y")
278+ final := h.nextTheme(t)
279+ require.Equal(t, renderer.RGB{R: 17, G: 18, B: 19}, final.Foreground)
280+ require.Equal(t, renderer.RGB{R: 20, G: 21, B: 22}, final.Background)
281+ require.Equal(t, uint16(1<<3), final.PaletteKnown)
282+ // The marker finalization cancels, rather than fires, the independently
283+ // armed completion deadline.
284+ _ = completionDeadline
285+ h.detach(t)
286+}
287+
288+func TestAttachForwardsInputOnceAndPartialOSCDoesNotPublishTheme(t *testing.T) {
289+ t.Setenv("COLORTERM", "truecolor")
290+ h := startAttachPaletteHarness(&terminalThemeState{})
291+ require.Equal(t, paletteColorBatch, h.nextWrite(t))
292+ _ = h.nextTimer(t)
293+ cleared := h.nextTheme(t)
294+ require.Zero(t, cleared.PaletteKnown)
295+
296+ h.reader.send("\x1b]10;#0102")
297+ h.reader.send("03\az")
298+ input := <-h.transport.inputCh
299+ require.Equal(t, []byte("z"), input.Data)
300+ require.Len(t, h.transport.snapshots(), 1, "partial and incomplete OSC replies must not publish Theme")
301+
302+ h.reader.send("\x1b[?2031;1$y")
303+ final := h.nextTheme(t)
304+ require.True(t, final.HasForeground)
305+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, final.Foreground)
306+ require.Len(t, h.transport.snapshots(), 2)
307+ h.detach(t)
308+}

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