detached b70b3b4

feat(daemon): render snatched client panel

commit
b70b3b43ad86f44a65338ccfbdaf89aeae54e3c6
author
brice · · 2026-07-27 17:51 UTC
parent
8c7be4b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +354 -4
M internal/usecase/daemon/client.go +8 -4
@@ -39,10 +39,14 @@ type attachedClient struct {
3939 overlays *overlayRuntime
4040 overlayOnce sync.Once
4141 clientID [16]byte
42- resumeCapable bool
43- resumeToken uint64
44- parked bool
45- echoAck atomic.Uint64
42+ // roleGeneration invalidates role-bound sends. A transition promoting a
43+ // panel-sending client must serialize through sendMu/the activation barrier
44+ // before publishing its generation.
45+ roleGeneration atomic.Uint64
46+ resumeCapable bool
47+ resumeToken uint64
48+ parked bool
49+ echoAck atomic.Uint64
4650 // prepareFailureFallback prevents a direct fallback paint from recursively
4751 // reporting the same failed prepare through its notice repaint. It is only
4852 // needed while no render coordinator is installed.
@@ -39,10 +39,14 @@ type attachedClient struct {
39 overlays *overlayRuntime39 overlays *overlayRuntime
40 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
42- resumeCapable bool42+ // roleGeneration invalidates role-bound sends. A transition promoting a
43- resumeToken uint6443+ // panel-sending client must serialize through sendMu/the activation barrier
44- parked bool44+ // before publishing its generation.
45- echoAck atomic.Uint6445+ roleGeneration atomic.Uint64
46+ resumeCapable bool
47+ resumeToken uint64
48+ parked bool
49+ echoAck atomic.Uint64
46 // prepareFailureFallback prevents a direct fallback paint from recursively50 // prepareFailureFallback prevents a direct fallback paint from recursively
47 // reporting the same failed prepare through its notice repaint. It is only51 // reporting the same failed prepare through its notice repaint. It is only
48 // needed while no render coordinator is installed.52 // needed while no render coordinator is installed.
A internal/usecase/daemon/snatched_render.go +90 -0
@@ -0,0 +1,90 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+var snatchedModal = ui.Modal{
13+ FixedWidth: 42, FixedHeight: 7,
14+ Title: " Session snatched ", Anchor: domain.AnchorCenter,
15+}
16+
17+func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame {
18+ width, height := max(size.Cols, 1), max(size.Rows, 1)
19+ frame := renderer.NewFrame(width, height)
20+ ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase})
21+ if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight {
22+ drawSnatchedCompact(frame, styles, feedback)
23+ return frame
24+ }
25+
26+ inner := snatchedModal.Composite(frame, styles.BorderMuted, styles.PickerBase)
27+ message := "This session is now active elsewhere."
28+ if feedback != "" {
29+ message = feedback
30+ }
31+ drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
32+ drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection)
33+ return frame
34+}
35+
36+func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) {
37+ status := "Session snatched"
38+ if feedback != "" {
39+ status = "Session unavailable"
40+ }
41+ lines := []string{status, "r Resume", "q Quit"}
42+ startY := max((frame.Height-len(lines))/2, 0)
43+ for i, line := range lines {
44+ drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase)
45+ }
46+}
47+
48+func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
49+ width := 0
50+ for _, r := range text {
51+ width += renderer.RuneWidth(r)
52+ }
53+ x := bounds.X + max((bounds.Width-width)/2, 0)
54+ ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style)
55+}
56+
57+var errSnatchedOutputStale = errors.New("snatched output role changed")
58+
59+// sendSnatchedPanel serializes a dependency-free panel reset onto one exact
60+// attachment role and transport lifetime. It does not inspect session state or
61+// capture pane content.
62+func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string) error {
63+ if expected.transport == nil {
64+ return errors.New("snatched client transport is nil")
65+ }
66+ tr, err := d.boundedSendWith(expected.transport, func() error {
67+ ac.sendMu.Lock()
68+ defer ac.sendMu.Unlock()
69+ if !ac.transportSnapshotCurrent(expected) {
70+ return errTransportReplaced
71+ }
72+ if ac.roleGeneration.Load() != generation {
73+ return errSnatchedOutputStale
74+ }
75+
76+ applied := ac.getAppliedTheme()
77+ frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
78+ ac.output.rebase()
79+ prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
80+ if err != nil {
81+ return err
82+ }
83+ data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)
84+ return prepared.send(data, ac.echoAck.Load(), expected.transport.Send)
85+ })
86+ if errors.Is(err, errSendTimedOut) {
87+ _ = ac.closeCapturedTransport(tr)
88+ }
89+ return err
90+}
@@ -0,0 +1,90 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ themeui "github.com/bnema/vev/internal/usecase/theme"
8+ "github.com/bnema/vev/internal/usecase/ui"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+var snatchedModal = ui.Modal{
13+ FixedWidth: 42, FixedHeight: 7,
14+ Title: " Session snatched ", Anchor: domain.AnchorCenter,
15+}
16+
17+func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string) renderer.Frame {
18+ width, height := max(size.Cols, 1), max(size.Rows, 1)
19+ frame := renderer.NewFrame(width, height)
20+ ui.FillRect(frame, domain.Rect{Width: width, Height: height}, renderer.Cell{Rune: ' ', Style: styles.PickerBase})
21+ if width < snatchedModal.FixedWidth || height < snatchedModal.FixedHeight {
22+ drawSnatchedCompact(frame, styles, feedback)
23+ return frame
24+ }
25+
26+ inner := snatchedModal.Composite(frame, styles.BorderMuted, styles.PickerBase)
27+ message := "This session is now active elsewhere."
28+ if feedback != "" {
29+ message = feedback
30+ }
31+ drawCenteredLine(frame, inner, inner.Y+1, message, styles.PickerBase)
32+ drawCenteredLine(frame, inner, inner.Y+3, "r Resume here q / Esc Quit", styles.PickerSelection)
33+ return frame
34+}
35+
36+func drawSnatchedCompact(frame renderer.Frame, styles themeui.Styles, feedback string) {
37+ status := "Session snatched"
38+ if feedback != "" {
39+ status = "Session unavailable"
40+ }
41+ lines := []string{status, "r Resume", "q Quit"}
42+ startY := max((frame.Height-len(lines))/2, 0)
43+ for i, line := range lines {
44+ drawCenteredLine(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, startY+i, line, styles.PickerBase)
45+ }
46+}
47+
48+func drawCenteredLine(frame renderer.Frame, bounds domain.Rect, y int, text string, style renderer.Style) {
49+ width := 0
50+ for _, r := range text {
51+ width += renderer.RuneWidth(r)
52+ }
53+ x := bounds.X + max((bounds.Width-width)/2, 0)
54+ ui.DrawText(frame, x, y, bounds.X+bounds.Width, text, style)
55+}
56+
57+var errSnatchedOutputStale = errors.New("snatched output role changed")
58+
59+// sendSnatchedPanel serializes a dependency-free panel reset onto one exact
60+// attachment role and transport lifetime. It does not inspect session state or
61+// capture pane content.
62+func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapshot, generation uint64, feedback string) error {
63+ if expected.transport == nil {
64+ return errors.New("snatched client transport is nil")
65+ }
66+ tr, err := d.boundedSendWith(expected.transport, func() error {
67+ ac.sendMu.Lock()
68+ defer ac.sendMu.Unlock()
69+ if !ac.transportSnapshotCurrent(expected) {
70+ return errTransportReplaced
71+ }
72+ if ac.roleGeneration.Load() != generation {
73+ return errSnatchedOutputStale
74+ }
75+
76+ applied := ac.getAppliedTheme()
77+ frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
78+ ac.output.rebase()
79+ prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
80+ if err != nil {
81+ return err
82+ }
83+ data := append(append([]byte(nil), prepared.data...), ac.encodeCursorTail(cursorOut{hidden: true}, true)...)
84+ return prepared.send(data, ac.echoAck.Load(), expected.transport.Send)
85+ })
86+ if errors.Is(err, errSendTimedOut) {
87+ _ = ac.closeCapturedTransport(tr)
88+ }
89+ return err
90+}
A internal/usecase/daemon/snatched_render_test.go +256 -0
@@ -0,0 +1,256 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestSnatchedPanelFrame(t *testing.T) {
19+ styles := themeui.Styles{
20+ PickerBase: renderer.Style{Foreground: 1},
21+ PickerSelection: renderer.Style{Foreground: 2},
22+ BorderMuted: renderer.Style{Foreground: 3},
23+ }
24+ tests := []struct {
25+ name string
26+ size domain.Size
27+ feedback string
28+ wantSize domain.Size
29+ want []string
30+ wantBounds domain.Rect
31+ compact bool
32+ }{
33+ {
34+ name: "centered bordered panel",
35+ size: domain.Size{Cols: 80, Rows: 24},
36+ wantSize: domain.Size{Cols: 80, Rows: 24},
37+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
38+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
39+ },
40+ {
41+ name: "compact fallback",
42+ size: domain.Size{Cols: 24, Rows: 4},
43+ wantSize: domain.Size{Cols: 24, Rows: 4},
44+ want: []string{"Session snatched", "r Resume", "q Quit"},
45+ compact: true,
46+ },
47+ {
48+ name: "compact unavailable feedback",
49+ size: domain.Size{Cols: 24, Rows: 4},
50+ feedback: "Session is no longer available.",
51+ wantSize: domain.Size{Cols: 24, Rows: 4},
52+ want: []string{"Session unavailable", "r Resume", "q Quit"},
53+ compact: true,
54+ },
55+ {
56+ name: "bordered unavailable feedback",
57+ size: domain.Size{Cols: 80, Rows: 24},
58+ feedback: "Session is no longer available.",
59+ wantSize: domain.Size{Cols: 80, Rows: 24},
60+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
61+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
62+ },
63+ {
64+ name: "zero dimensions clamp",
65+ size: domain.Size{},
66+ wantSize: domain.Size{Cols: 1, Rows: 1},
67+ compact: true,
68+ },
69+ {
70+ name: "negative dimensions clamp",
71+ size: domain.Size{Cols: -10, Rows: -20},
72+ wantSize: domain.Size{Cols: 1, Rows: 1},
73+ compact: true,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ frame := snatchedPanelFrame(tt.size, styles, tt.feedback)
80+ require.Equal(t, tt.wantSize.Cols, frame.Width)
81+ require.Equal(t, tt.wantSize.Rows, frame.Height)
82+ require.NoError(t, frame.Validate())
83+
84+ text := snatchedFrameText(frame)
85+ for _, want := range tt.want {
86+ require.Contains(t, text, want)
87+ }
88+
89+ if tt.compact {
90+ require.NotContains(t, text, "┌")
91+ require.NotContains(t, text, "┘")
92+ return
93+ }
94+
95+ require.Equal(t, tt.wantBounds, snatchedModal.Bounds(tt.wantSize))
96+ topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
97+ bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
98+ require.Equal(t, '┌', topLeft.Rune)
99+ require.Equal(t, '┘', bottomRight.Rune)
100+ require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
101+ require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
102+ requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase)
103+ })
104+ }
105+}
106+
107+func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
108+ d := newTestDaemon(t, nil, stubClock{})
109+ tr := &closeTrackingTransport{}
110+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
111+ ac.output.next = 7
112+ ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
113+ ac.roleGeneration.Store(1)
114+
115+ expected := ac.transportSnapshot()
116+ require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, ""))
117+
118+ frames := tr.Sends()
119+ require.Len(t, frames, 1)
120+ require.Equal(t, ports.MsgOutput, frames[0].Type)
121+ out, err := ports.UnmarshalOutput(frames[0].Payload)
122+ require.NoError(t, err)
123+ require.Zero(t, out.BaseStateNum)
124+ require.Equal(t, uint64(8), out.NewStateNum)
125+ require.Contains(t, string(out.Data), "Session snatched")
126+ require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
127+}
128+
129+func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
130+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
131+ d := newTestDaemon(t, nil, clock)
132+ old := newBlockingSnatchedTransport()
133+ fresh := &closeTrackingTransport{}
134+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
135+ ac.roleGeneration.Store(1)
136+ expected := ac.transportSnapshot()
137+
138+ result := make(chan error, 1)
139+ go func() {
140+ result <- d.sendSnatchedPanel(ac, expected, 1, "")
141+ }()
142+
143+ timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout")
144+ awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport")
145+ ac.replaceTransport(fresh)
146+ timer.ch <- time.Time{}
147+
148+ require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
149+ defer old.Close() // Release the blocked worker if the assertion below fails.
150+ require.True(t, old.Closed(), "timed-out captured transport was not closed")
151+ require.False(t, fresh.Closed(), "replacement transport must remain open")
152+
153+ sendMuReleased := make(chan uint64, 1)
154+ go func() {
155+ ac.sendMu.Lock()
156+ generation := ac.roleGeneration.Load()
157+ ac.sendMu.Unlock()
158+ sendMuReleased <- generation
159+ }()
160+ require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu"))
161+}
162+
163+type blockingSnatchedTransport struct {
164+ sendEntered chan struct{}
165+ closed chan struct{}
166+ sendOnce sync.Once
167+ closeOnce sync.Once
168+}
169+
170+func newBlockingSnatchedTransport() *blockingSnatchedTransport {
171+ return &blockingSnatchedTransport{
172+ sendEntered: make(chan struct{}),
173+ closed: make(chan struct{}),
174+ }
175+}
176+
177+func (t *blockingSnatchedTransport) Send(ports.Frame) error {
178+ t.sendOnce.Do(func() { close(t.sendEntered) })
179+ <-t.closed
180+ return errors.New("transport closed")
181+}
182+
183+func (*blockingSnatchedTransport) Recv() (ports.Frame, error) {
184+ return ports.Frame{}, errors.New("not implemented")
185+}
186+
187+func (t *blockingSnatchedTransport) Close() error {
188+ t.closeOnce.Do(func() { close(t.closed) })
189+ return nil
190+}
191+
192+func (t *blockingSnatchedTransport) Closed() bool {
193+ select {
194+ case <-t.closed:
195+ return true
196+ default:
197+ return false
198+ }
199+}
200+
201+func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) {
202+ d := newTestDaemon(t, nil, stubClock{})
203+ old := &closeTrackingTransport{}
204+ fresh := &closeTrackingTransport{}
205+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
206+ ac.roleGeneration.Store(1)
207+ stale := ac.transportSnapshot()
208+ ac.replaceTransport(fresh)
209+
210+ require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced)
211+ require.Empty(t, old.Sends())
212+ require.Empty(t, fresh.Sends())
213+}
214+
215+func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) {
216+ d := newTestDaemon(t, nil, stubClock{})
217+ tr := &closeTrackingTransport{}
218+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
219+ ac.roleGeneration.Store(2)
220+ expected := ac.transportSnapshot()
221+
222+ require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale)
223+ require.Empty(t, tr.Sends())
224+}
225+
226+func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) {
227+ t.Helper()
228+ for y := range frame.Height {
229+ for x := range frame.Width {
230+ if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height {
231+ continue
232+ }
233+ cell := frame.At(x, y)
234+ require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y)
235+ require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y)
236+ }
237+ }
238+}
239+
240+func snatchedFrameText(frame renderer.Frame) string {
241+ var out strings.Builder
242+ for y := 0; y < frame.Height; y++ {
243+ for _, cell := range frame.Row(y) {
244+ if cell.Continuation {
245+ continue
246+ }
247+ r := cell.Rune
248+ if r == 0 {
249+ r = ' '
250+ }
251+ out.WriteRune(r)
252+ }
253+ out.WriteByte('\n')
254+ }
255+ return out.String()
256+}
@@ -0,0 +1,256 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "strings"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/pkg/renderer"
16+)
17+
18+func TestSnatchedPanelFrame(t *testing.T) {
19+ styles := themeui.Styles{
20+ PickerBase: renderer.Style{Foreground: 1},
21+ PickerSelection: renderer.Style{Foreground: 2},
22+ BorderMuted: renderer.Style{Foreground: 3},
23+ }
24+ tests := []struct {
25+ name string
26+ size domain.Size
27+ feedback string
28+ wantSize domain.Size
29+ want []string
30+ wantBounds domain.Rect
31+ compact bool
32+ }{
33+ {
34+ name: "centered bordered panel",
35+ size: domain.Size{Cols: 80, Rows: 24},
36+ wantSize: domain.Size{Cols: 80, Rows: 24},
37+ want: []string{"Session snatched", "This session is now active elsewhere.", "r Resume here", "q / Esc Quit"},
38+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
39+ },
40+ {
41+ name: "compact fallback",
42+ size: domain.Size{Cols: 24, Rows: 4},
43+ wantSize: domain.Size{Cols: 24, Rows: 4},
44+ want: []string{"Session snatched", "r Resume", "q Quit"},
45+ compact: true,
46+ },
47+ {
48+ name: "compact unavailable feedback",
49+ size: domain.Size{Cols: 24, Rows: 4},
50+ feedback: "Session is no longer available.",
51+ wantSize: domain.Size{Cols: 24, Rows: 4},
52+ want: []string{"Session unavailable", "r Resume", "q Quit"},
53+ compact: true,
54+ },
55+ {
56+ name: "bordered unavailable feedback",
57+ size: domain.Size{Cols: 80, Rows: 24},
58+ feedback: "Session is no longer available.",
59+ wantSize: domain.Size{Cols: 80, Rows: 24},
60+ want: []string{"Session snatched", "Session is no longer available.", "r Resume here", "q / Esc Quit"},
61+ wantBounds: domain.Rect{X: 19, Y: 8, Width: 42, Height: 7},
62+ },
63+ {
64+ name: "zero dimensions clamp",
65+ size: domain.Size{},
66+ wantSize: domain.Size{Cols: 1, Rows: 1},
67+ compact: true,
68+ },
69+ {
70+ name: "negative dimensions clamp",
71+ size: domain.Size{Cols: -10, Rows: -20},
72+ wantSize: domain.Size{Cols: 1, Rows: 1},
73+ compact: true,
74+ },
75+ }
76+
77+ for _, tt := range tests {
78+ t.Run(tt.name, func(t *testing.T) {
79+ frame := snatchedPanelFrame(tt.size, styles, tt.feedback)
80+ require.Equal(t, tt.wantSize.Cols, frame.Width)
81+ require.Equal(t, tt.wantSize.Rows, frame.Height)
82+ require.NoError(t, frame.Validate())
83+
84+ text := snatchedFrameText(frame)
85+ for _, want := range tt.want {
86+ require.Contains(t, text, want)
87+ }
88+
89+ if tt.compact {
90+ require.NotContains(t, text, "┌")
91+ require.NotContains(t, text, "┘")
92+ return
93+ }
94+
95+ require.Equal(t, tt.wantBounds, snatchedModal.Bounds(tt.wantSize))
96+ topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
97+ bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
98+ require.Equal(t, '┌', topLeft.Rune)
99+ require.Equal(t, '┘', bottomRight.Rune)
100+ require.True(t, topLeft.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
101+ require.True(t, bottomRight.Style.Equal(styles.BorderMuted), "panel border must use captured chrome style")
102+ requireFreshSnatchedBackground(t, frame, tt.wantBounds, styles.PickerBase)
103+ })
104+ }
105+}
106+
107+func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
108+ d := newTestDaemon(t, nil, stubClock{})
109+ tr := &closeTrackingTransport{}
110+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
111+ ac.output.next = 7
112+ ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
113+ ac.roleGeneration.Store(1)
114+
115+ expected := ac.transportSnapshot()
116+ require.NoError(t, d.sendSnatchedPanel(ac, expected, 1, ""))
117+
118+ frames := tr.Sends()
119+ require.Len(t, frames, 1)
120+ require.Equal(t, ports.MsgOutput, frames[0].Type)
121+ out, err := ports.UnmarshalOutput(frames[0].Payload)
122+ require.NoError(t, err)
123+ require.Zero(t, out.BaseStateNum)
124+ require.Equal(t, uint64(8), out.NewStateNum)
125+ require.Contains(t, string(out.Data), "Session snatched")
126+ require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
127+}
128+
129+func TestSendSnatchedPanelTimeoutClosesCapturedTransportOnly(t *testing.T) {
130+ clock := &signalClock{timers: make(chan *signalTimer, 1)}
131+ d := newTestDaemon(t, nil, clock)
132+ old := newBlockingSnatchedTransport()
133+ fresh := &closeTrackingTransport{}
134+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
135+ ac.roleGeneration.Store(1)
136+ expected := ac.transportSnapshot()
137+
138+ result := make(chan error, 1)
139+ go func() {
140+ result <- d.sendSnatchedPanel(ac, expected, 1, "")
141+ }()
142+
143+ timer := awaitTestValue(t, clock.timers, "snatched send did not install its timeout")
144+ awaitTestCompletion(t, old.sendEntered, "snatched send did not block in the captured transport")
145+ ac.replaceTransport(fresh)
146+ timer.ch <- time.Time{}
147+
148+ require.ErrorIs(t, awaitTestValue(t, result, "snatched send did not time out"), errSendTimedOut)
149+ defer old.Close() // Release the blocked worker if the assertion below fails.
150+ require.True(t, old.Closed(), "timed-out captured transport was not closed")
151+ require.False(t, fresh.Closed(), "replacement transport must remain open")
152+
153+ sendMuReleased := make(chan uint64, 1)
154+ go func() {
155+ ac.sendMu.Lock()
156+ generation := ac.roleGeneration.Load()
157+ ac.sendMu.Unlock()
158+ sendMuReleased <- generation
159+ }()
160+ require.Equal(t, uint64(1), awaitTestValue(t, sendMuReleased, "closing the timed-out transport did not release sendMu"))
161+}
162+
163+type blockingSnatchedTransport struct {
164+ sendEntered chan struct{}
165+ closed chan struct{}
166+ sendOnce sync.Once
167+ closeOnce sync.Once
168+}
169+
170+func newBlockingSnatchedTransport() *blockingSnatchedTransport {
171+ return &blockingSnatchedTransport{
172+ sendEntered: make(chan struct{}),
173+ closed: make(chan struct{}),
174+ }
175+}
176+
177+func (t *blockingSnatchedTransport) Send(ports.Frame) error {
178+ t.sendOnce.Do(func() { close(t.sendEntered) })
179+ <-t.closed
180+ return errors.New("transport closed")
181+}
182+
183+func (*blockingSnatchedTransport) Recv() (ports.Frame, error) {
184+ return ports.Frame{}, errors.New("not implemented")
185+}
186+
187+func (t *blockingSnatchedTransport) Close() error {
188+ t.closeOnce.Do(func() { close(t.closed) })
189+ return nil
190+}
191+
192+func (t *blockingSnatchedTransport) Closed() bool {
193+ select {
194+ case <-t.closed:
195+ return true
196+ default:
197+ return false
198+ }
199+}
200+
201+func TestSendSnatchedPanelRejectsStaleTransport(t *testing.T) {
202+ d := newTestDaemon(t, nil, stubClock{})
203+ old := &closeTrackingTransport{}
204+ fresh := &closeTrackingTransport{}
205+ ac := &attachedClient{tr: old, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
206+ ac.roleGeneration.Store(1)
207+ stale := ac.transportSnapshot()
208+ ac.replaceTransport(fresh)
209+
210+ require.ErrorIs(t, d.sendSnatchedPanel(ac, stale, 1, ""), errTransportReplaced)
211+ require.Empty(t, old.Sends())
212+ require.Empty(t, fresh.Sends())
213+}
214+
215+func TestSendSnatchedPanelRejectsStaleRole(t *testing.T) {
216+ d := newTestDaemon(t, nil, stubClock{})
217+ tr := &closeTrackingTransport{}
218+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
219+ ac.roleGeneration.Store(2)
220+ expected := ac.transportSnapshot()
221+
222+ require.ErrorIs(t, d.sendSnatchedPanel(ac, expected, 1, ""), errSnatchedOutputStale)
223+ require.Empty(t, tr.Sends())
224+}
225+
226+func requireFreshSnatchedBackground(t *testing.T, frame renderer.Frame, panel domain.Rect, style renderer.Style) {
227+ t.Helper()
228+ for y := range frame.Height {
229+ for x := range frame.Width {
230+ if x >= panel.X && x < panel.X+panel.Width && y >= panel.Y && y < panel.Y+panel.Height {
231+ continue
232+ }
233+ cell := frame.At(x, y)
234+ require.Equal(t, ' ', cell.Rune, "cell (%d,%d) outside panel retained content", x, y)
235+ require.True(t, cell.Style.Equal(style), "cell (%d,%d) outside panel did not use captured base style", x, y)
236+ }
237+ }
238+}
239+
240+func snatchedFrameText(frame renderer.Frame) string {
241+ var out strings.Builder
242+ for y := 0; y < frame.Height; y++ {
243+ for _, cell := range frame.Row(y) {
244+ if cell.Continuation {
245+ continue
246+ }
247+ r := cell.Rune
248+ if r == 0 {
249+ r = ' '
250+ }
251+ out.WriteRune(r)
252+ }
253+ out.WriteByte('\n')
254+ }
255+ return out.String()
256+}

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