v0.8.1 1f54f61

control_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"log/slog"
11	"strings"
12	"sync"
13	"testing"
14	"time"
15
16	"github.com/bnema/vev/internal/domain"
17	"github.com/bnema/vev/internal/domain/layout"
18	appports "github.com/bnema/vev/internal/ports"
19	portsmocks "github.com/bnema/vev/internal/ports/mocks"
20	"github.com/bnema/vev/internal/protocol"
21	"github.com/bnema/vev/internal/protocol/catalogue"
22	"github.com/bnema/vev/internal/protocol/wire"
23	"github.com/bnema/vev/internal/usecase/command"
24	"github.com/stretchr/testify/mock"
25	"github.com/stretchr/testify/require"
26)
27
28func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
29	tests := []struct {
30		name      string
31		palette   func(paletteExec) error
32		control   func(controlExec) error
33		kind      daemonActionKind
34		direction layout.Direction
35	}{
36		{name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
37		{name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
38		{name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
39	}
40
41	for _, tt := range tests {
42		t.Run(tt.name, func(t *testing.T) {
43			d := newTestDaemon(t, nil, stubClock{})
44			sess := addControlSession(d, "work", "t_work", "p_work")
45			tb := testAttachmentTab(sess)
46			tb.mu.Lock()
47			pane := tb.focusedPane()
48			tb.mu.Unlock()
49			spy := newMockdaemonActionRunner(t)
50			requests := recordActionRuns(spy, nil)
51			target := daemonActionTarget{session: sess, tab: tb, pane: pane}
52
53			require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
54			require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
55
56			require.Len(t, *requests, 2)
57			for _, request := range *requests {
58				require.Equal(t, tt.kind, request.kind)
59				require.Same(t, sess, request.target.session)
60				require.Same(t, tb, request.target.tab)
61				require.Same(t, pane, request.target.pane)
62				require.Equal(t, tt.direction, request.direction)
63			}
64		})
65	}
66}
67
68func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
69	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
70	h.tab.mu.Lock()
71	h.tab.tree.Focus = "pane-1"
72	beforeGeneration := h.tab.layoutGeneration
73	tabStableID := h.tab.stableID
74	targetStableID := h.panes["pane-3"].stableID
75	h.tab.mu.Unlock()
76
77	result := sendCommand(t, h.daemon, protocol.CommandRequest{
78		Slug:          "consume-or-expel-pane-left",
79		Self:          true,
80		TargetSession: "work",
81		TargetTab:     tabStableID,
82		TargetPane:    targetStableID,
83	})
84
85	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
86	require.Empty(t, result.Output)
87	require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
88	h.tab.mu.Lock()
89	require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
90	require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
91	require.Len(t, h.tab.tree.Root.Children, 2)
92	h.tab.mu.Unlock()
93}
94
95func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
96	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
97	ac := &attachedClient{}
98	ac.setSession(h.session)
99	h.session.mu.Lock()
100	h.session.registerAttachmentLocked(ac)
101	h.session.mu.Unlock()
102	invalidations := make(chan renderInvalidation, 1)
103	rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
104	rc.attach(ac)
105	h.session.installRenderCoordinator(rc)
106	before := h.snapshot()
107
108	result := sendCommand(t, h.daemon, protocol.CommandRequest{
109		Slug:          "consume-or-expel-pane-left",
110		Self:          true,
111		TargetSession: "work",
112		TargetTab:     h.tab.stableID,
113		TargetPane:    h.panes["pane-1"].stableID,
114	})
115
116	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
117	require.Empty(t, result.Output)
118	require.Equal(t, before, h.snapshot())
119	require.Empty(t, h.daemon.notices.history())
120	requireNoInvalidation(t, invalidations)
121}
122
123// recordActionRuns makes runner return err and records every request it runs.
124func recordActionRuns(runner *mockdaemonActionRunner, err error) *[]daemonActionRequest {
125	var requests []daemonActionRequest
126	runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
127		requests = append(requests, request)
128		return err
129	})
130	return &requests
131}
132
133func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
134	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
135	clock := &signalClock{timers: make(chan *signalTimer, 1)}
136	d := newTestDaemon(t, factory, clock)
137	addControlSession(d, "work", "t_work", "p_work")
138	frame := commandFrame(t, protocol.CommandRequest{
139		RequestID: 17, Slug: "new-tab", TargetSession: "work",
140	})
141	tr, sends, releaseConn := newConn(t, frame)
142	defer releaseConn()
143	done := make(chan error, 1)
144	go func() {
145		done <- d.handleCommandFrame(tr, frame)
146	}()
147	<-factory.entered
148	timer := <-clock.timers
149	require.Equal(t, CommandRequestTimeout, timer.duration)
150	timer.ch <- time.Time{}
151	result := awaitCommandResult(t, sends)
152	require.Equal(t, uint64(17), result.RequestID)
153	require.Equal(t, uint16(0), result.Code)
154	require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
155	require.NoError(t, <-done)
156	close(factory.release)
157}
158
159func TestHandleCommandResponseSendTimeoutClosesTransport(t *testing.T) {
160	clock := &signalClock{timers: make(chan *signalTimer, 1)}
161	d := newTestDaemon(t, nil, clock)
162	tr := newBlockingControlSendTransport()
163	frame := commandFrame(t, protocol.CommandRequest{RequestID: 41, Attached: true})
164	done := make(chan error, 1)
165	go func() { done <- d.handleCommandFrame(tr, frame) }()
166
167	<-tr.started
168	timer := <-clock.timers
169	timer.ch <- time.Time{}
170	select {
171	case err := <-done:
172		require.ErrorIs(t, err, errSendTimedOut)
173	case <-time.After(testWaitTimeout):
174		t.Fatal("bounded control response did not return")
175	}
176	select {
177	case <-tr.closed:
178	default:
179		t.Fatal("timed-out control response did not close its transport")
180	}
181}
182
183func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
184	d := newTestDaemon(t, nil, stubClock{})
185	var logs bytes.Buffer
186	d.log = slog.New(slog.NewTextHandler(&logs, nil))
187	sendErr := errors.New("command result send failed")
188	tr := &commandSendErrorTransport{err: sendErr}
189
190	err := d.handleCommandFrame(tr, commandFrame(t, protocol.CommandRequest{
191		RequestID: 41,
192		Attached:  true,
193	}))
194
195	require.ErrorIs(t, err, sendErr)
196	require.Contains(t, logs.String(), "command response send failed")
197	require.True(t, tr.closed, "the failed one-shot transport must still close")
198}
199
200func TestHandleConnRoutesCommand(t *testing.T) {
201	d := newTestDaemon(t, nil, stubClock{})
202	request := protocol.CommandRequest{Version: protocol.Version, Slug: "list-sessions"}
203	tr, sends, _ := newConn(t, mustClientEnvelope(request))
204
205	d.handleConn(tr)
206
207	result := awaitCommandResult(t, sends)
208	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
209}
210
211func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
212	tests := []struct {
213		name    string
214		arrange func(*Daemon)
215		request protocol.CommandRequest
216		code    uint16
217	}{
218		{name: "unknown slug", request: protocol.CommandRequest{Slug: "no-such"}, code: protocol.ErrUnknownCommand},
219		{name: "non-scriptable command", request: protocol.CommandRequest{Slug: "session-picker"}, code: protocol.ErrNotScriptable},
220		{name: "no sessions", request: protocol.CommandRequest{Slug: "split-right"}, code: protocol.ErrNoSuchTarget},
221		{name: "ambiguous sessions", arrange: func(d *Daemon) {
222			addControlSession(d, "one", "t_one", "p_one")
223			addControlSession(d, "two", "t_two", "p_two")
224		}, request: protocol.CommandRequest{Slug: "split-right"}, code: protocol.ErrAmbiguousTarget},
225		{name: "missing explicit session", arrange: func(d *Daemon) {
226			addControlSession(d, "work", "t_work", "p_work")
227		}, request: protocol.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: protocol.ErrNoSuchTarget},
228		{name: "missing stable IDs", arrange: func(d *Daemon) {
229			addControlSession(d, "work", "t_work", "p_work")
230		}, request: protocol.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: protocol.ErrNoSuchTarget},
231		{name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
232			addControlSession(d, "one", "t_shared", "p_shared")
233			addControlSession(d, "two", "t_shared", "p_shared")
234		}, request: protocol.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: protocol.ErrAmbiguousTarget},
235		{name: "self requires both IDs", arrange: func(d *Daemon) {
236			addControlSession(d, "work", "t_work", "p_work")
237		}, request: protocol.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: protocol.ErrNoSuchTarget},
238		{name: "self rejects pane from another tab", arrange: func(d *Daemon) {
239			sess := addControlSession(d, "work", "t_work", "p_work")
240			other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
241			other.ctx, other.cancel = context.WithCancel(d.serveCtx)
242			sess.tabs = append(sess.tabs, other)
243		}, request: protocol.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: protocol.ErrNoSuchTarget},
244	}
245
246	for _, tt := range tests {
247		t.Run(tt.name, func(t *testing.T) {
248			d := newTestDaemon(t, nil, stubClock{})
249			if tt.arrange != nil {
250				tt.arrange(d)
251			}
252			result := sendCommand(t, d, tt.request)
253			require.False(t, result.Outcome == protocol.CommandSucceeded)
254			require.Equal(t, tt.code, result.Code)
255		})
256	}
257}
258
259func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
260	d := newTestDaemon(t, nil, stubClock{})
261	sess := addControlSession(d, "renamed", "t_work", "p_work")
262
263	result := sendCommand(t, d, protocol.CommandRequest{
264		Slug: "toast", Args: []string{"hello"},
265		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
266	})
267
268	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
269	require.Equal(t, uint64(1), sess.mruAt.Load())
270}
271
272func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
273	d := newTestDaemon(t, nil, stubClock{})
274	sess := addControlSession(d, "work", "t_first", "p_first")
275	first := sess.tabs[0]
276	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
277	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
278	sess.mu.Lock()
279	sess.tabs = append(sess.tabs, second)
280	selectTestAttachmentTabLocked(sess, 0)
281	sess.mu.Unlock()
282
283	result := sendCommand(t, d, protocol.CommandRequest{
284		Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
285		TargetTab: "t_second", TargetPane: "p_second",
286	})
287
288	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
289	first.mu.Lock()
290	require.Equal(t, "targeted", first.name)
291	first.mu.Unlock()
292	second.mu.Lock()
293	require.Empty(t, second.name)
294	second.mu.Unlock()
295	sess.mu.Lock()
296	require.Zero(t, testAttachmentTabIndexLocked(sess))
297	sess.mu.Unlock()
298}
299
300func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
301	factory := &controlPTYFactory{}
302	d := newTestDaemon(t, factory, stubClock{})
303	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
304	sess := addControlSession(d, "work", "t_active", "p_active")
305	active := sess.tabs[0]
306	originFocus := active.tree.Focus
307	origin := active.focusedPane()
308	second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
309	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
310	sess.mu.Lock()
311	sess.tabs = append(sess.tabs, second)
312	selectTestAttachmentTabLocked(sess, 0)
313	sess.mu.Unlock()
314
315	result := sendCommand(t, d, protocol.CommandRequest{
316		Slug: "split-right", TargetSession: "work",
317		TargetTab: "t_origin", TargetPane: "p_origin",
318	})
319
320	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
321	sess.mu.Lock()
322	activeIndex := testAttachmentTabIndexLocked(sess)
323	sess.mu.Unlock()
324	require.Zero(t, activeIndex)
325	active.mu.Lock()
326	activePaneCount := len(active.panes)
327	activeFocus := active.tree.Focus
328	originRetained := active.panes[origin.id] == origin
329	active.mu.Unlock()
330	require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
331	require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
332	require.True(t, originRetained)
333	second.mu.Lock()
334	secondPaneCount := len(second.panes)
335	second.mu.Unlock()
336	require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
337}
338
339func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
340	factory := &controlPTYFactory{}
341	d := newTestDaemon(t, factory, stubClock{})
342	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
343	sess := addControlSession(d, "work", "t_active", "p_active")
344	active := sess.tabs[0]
345	invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
346	invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
347	sess.mu.Lock()
348	sess.tabs = append(sess.tabs, invoking)
349	selectTestAttachmentTabLocked(sess, 0)
350	sess.mu.Unlock()
351
352	rename := sendCommand(t, d, protocol.CommandRequest{
353		Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
354		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
355	})
356	require.True(t, rename.Outcome == protocol.CommandSucceeded, rename.Text)
357	active.mu.Lock()
358	require.Empty(t, active.name)
359	active.mu.Unlock()
360	invoking.mu.Lock()
361	require.Equal(t, "invoking", invoking.name)
362	invoking.mu.Unlock()
363
364	split := sendCommand(t, d, protocol.CommandRequest{
365		Slug: "split-right", Self: true,
366		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
367	})
368	require.True(t, split.Outcome == protocol.CommandSucceeded, split.Text)
369	active.mu.Lock()
370	require.Len(t, active.panes, 1)
371	active.mu.Unlock()
372	invoking.mu.Lock()
373	require.Len(t, invoking.panes, 2)
374	beforeFocus := invoking.tree.Focus
375	beforeGeneration := invoking.layoutGeneration
376	invoking.mu.Unlock()
377
378	grow := sendCommand(t, d, protocol.CommandRequest{
379		Slug: "grow-pane-width", Self: true,
380		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
381	})
382	require.True(t, grow.Outcome == protocol.CommandSucceeded, grow.Text)
383	invoking.mu.Lock()
384	require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
385	require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
386	require.NotZero(t, invoking.tree.Root.Children[0].Weight, "resize must update the invoking tab's shares")
387	invoking.mu.Unlock()
388	active.mu.Lock()
389	require.Len(t, active.panes, 1, "--self resize must not mutate the active tab")
390	active.mu.Unlock()
391
392	equalize := sendCommand(t, d, protocol.CommandRequest{
393		Slug: "equalize-panes", Self: true,
394		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
395	})
396	require.True(t, equalize.Outcome == protocol.CommandSucceeded, equalize.Text)
397	invoking.mu.Lock()
398	require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
399	for _, child := range invoking.tree.Root.Children {
400		require.Zero(t, child.Weight, "equalize must clear each target-tab share")
401	}
402	invoking.mu.Unlock()
403}
404
405func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
406	targets := []struct {
407		name    string
408		request func() protocol.CommandRequest
409	}{
410		{"named", func() protocol.CommandRequest { return protocol.CommandRequest{TargetSession: "work"} }},
411		{"unique", func() protocol.CommandRequest { return protocol.CommandRequest{} }},
412	}
413	for _, target := range targets {
414		t.Run(target.name, func(t *testing.T) {
415			for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
416				t.Run(slug, func(t *testing.T) {
417					factory := &controlPTYFactory{}
418					d := newTestDaemon(t, factory, stubClock{})
419					t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
420					sess := addControlSession(d, "work", "t_work", "p_work")
421					require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
422					require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-down", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
423					tb := testAttachmentTab(sess)
424					tb.mu.Lock()
425					generation := tb.layoutGeneration
426					tb.mu.Unlock()
427					sess.snapEligible.Store(true)
428					sess.snapshotMu.Lock()
429					snapshotGeneration := sess.snapshotGeneration
430					sess.snapshotMu.Unlock()
431					request := target.request()
432					request.Slug = slug
433					result := sendCommand(t, d, request)
434					require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
435					require.Empty(t, result.Output)
436					tb.mu.Lock()
437					require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
438					tb.mu.Unlock()
439					sess.snapshotMu.Lock()
440					require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
441					sess.snapshotMu.Unlock()
442					sess.mu.Lock()
443					require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless")
444					sess.mu.Unlock()
445				})
446			}
447		})
448	}
449}
450
451func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
452	factory := &controlPTYFactory{}
453	d := newTestDaemon(t, factory, stubClock{})
454	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
455	sess := addControlSession(d, "work", "t_active", "p_active")
456	require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
457	active := sess.tabs[0]
458	locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
459	locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
460	sess.mu.Lock()
461	sess.tabs = append(sess.tabs, locator)
462	selectTestAttachmentTabLocked(sess, 0)
463	sess.mu.Unlock()
464	active.mu.Lock()
465	before := active.layoutGeneration
466	active.mu.Unlock()
467
468	result := sendCommand(t, d, protocol.CommandRequest{
469		Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
470	})
471	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
472	active.mu.Lock()
473	require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
474	active.mu.Unlock()
475	locator.mu.Lock()
476	require.Zero(t, locator.layoutGeneration)
477	locator.mu.Unlock()
478	sess.mu.Lock()
479	require.Zero(t, testAttachmentTabIndexLocked(sess))
480	sess.mu.Unlock()
481}
482
483func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
484	factory := &controlPTYFactory{}
485	d := newTestDaemon(t, factory, stubClock{})
486	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
487	sess := addControlSession(d, "work", "t_work", "p_work")
488	require.True(t, sendCommand(t, d, protocol.CommandRequest{Slug: "split-right", TargetSession: "work"}).Outcome == protocol.CommandSucceeded)
489	tb := testAttachmentTab(sess)
490	tb.mu.Lock()
491	tb.size.Cols = 41
492	tb.bumpLayoutGenerationLocked()
493	tb.mu.Unlock()
494
495	result := sendCommand(t, d, protocol.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
496	require.False(t, result.Outcome == protocol.CommandSucceeded)
497	require.Equal(t, protocol.ErrNoSuchTarget, result.Code)
498	require.Equal(t, "pane cannot be resized further", result.Text)
499}
500
501func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
502	d := newTestDaemon(t, nil, stubClock{})
503	sess := addControlSession(d, "work", "t_active", "p_active")
504	invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
505	invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
506	sess.mu.Lock()
507	sess.tabs = append(sess.tabs, invoking)
508	selectTestAttachmentTabLocked(sess, 0)
509	sess.mu.Unlock()
510
511	result := sendCommand(t, d, protocol.CommandRequest{
512		Slug: "list-panes", Self: true, JSON: true,
513		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
514	})
515	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
516	require.Contains(t, result.Output, "p_invoking")
517	require.NotContains(t, result.Output, "p_active")
518}
519
520func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
521	d := newTestDaemon(t, nil, stubClock{})
522	addControlSession(d, "work", "t_work", "p_work")
523
524	result := sendCommand(t, d, protocol.CommandRequest{
525		Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
526	})
527
528	require.False(t, result.Outcome == protocol.CommandSucceeded)
529	require.Equal(t, protocol.ErrInvalidCommandArgs, result.Code)
530}
531
532func TestCloseCommandsReportMutationOutcome(t *testing.T) {
533	t.Run("stale close tab target fails", func(t *testing.T) {
534		d := newTestDaemon(t, nil, stubClock{})
535		sess := addControlSession(d, "work", "t_work", "p_work")
536		stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
537		cmd, ok := command.BySlug("close-tab")
538		require.True(t, ok)
539
540		result := d.runControl(cmd, controlExec{
541			d: d, sess: sess, tab: stale,
542			target: daemonActionTarget{session: sess, tab: stale},
543		}, protocol.CommandRequest{Slug: "close-tab"})
544
545		require.False(t, result.Outcome == protocol.CommandSucceeded)
546		require.Equal(t, protocol.ErrInternal, result.Code)
547		require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
548	})
549
550	t.Run("failed final pane close fails and retains session", func(t *testing.T) {
551		d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
552		d.snapsEnabled = true
553		d.persistEnabled = true
554		d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
555		sess := addControlSession(d, "work", "t_work", "p_work")
556		sess.mu.Lock()
557		sess.ephemeral = false
558		sess.mu.Unlock()
559		sess.snapEligible.Store(true)
560
561		result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-pane", TargetSession: "work"})
562
563		require.False(t, result.Outcome == protocol.CommandSucceeded)
564		require.Equal(t, protocol.ErrInternal, result.Code)
565		d.mu.Lock()
566		require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
567		d.mu.Unlock()
568		sess.mu.Lock()
569		require.Len(t, sess.tabs, 1)
570		sess.mu.Unlock()
571	})
572
573	t.Run("successful close tab remains successful", func(t *testing.T) {
574		d := newTestDaemon(t, nil, stubClock{})
575		sess := addControlSession(d, "work", "t_first", "p_first")
576		second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
577		second.ctx, second.cancel = context.WithCancel(d.serveCtx)
578		sess.mu.Lock()
579		sess.tabs = append(sess.tabs, second)
580		selectTestAttachmentTabLocked(sess, 1)
581		sess.mu.Unlock()
582
583		result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
584
585		require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
586		sess.mu.Lock()
587		require.Len(t, sess.tabs, 1)
588		require.Equal(t, "t_second", sess.tabs[0].stableID)
589		sess.mu.Unlock()
590	})
591}
592
593func TestHandleCommandHeadlessMutations(t *testing.T) {
594	tests := []struct {
595		name   string
596		slug   string
597		args   []string
598		verify func(*testing.T, *Daemon, *session)
599	}{
600		{name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
601			tb := testAttachmentTab(sess)
602			tb.mu.Lock()
603			defer tb.mu.Unlock()
604			require.Len(t, tb.panes, 2)
605		}},
606		{name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
607			sess.mu.Lock()
608			defer sess.mu.Unlock()
609			require.Len(t, sess.tabs, 2)
610			require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
611		}},
612		{name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
613			tb := testAttachmentTab(sess)
614			tb.mu.Lock()
615			defer tb.mu.Unlock()
616			require.Equal(t, "logs", tb.name)
617		}},
618	}
619	for _, tt := range tests {
620		t.Run(tt.name, func(t *testing.T) {
621			factory := &controlPTYFactory{}
622			d := newTestDaemon(t, factory, stubClock{})
623			t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
624			sess := addControlSession(d, "work", "t_work", "p_work")
625
626			result := sendCommand(t, d, protocol.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
627
628			require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
629			tt.verify(t, d, sess)
630		})
631	}
632}
633
634func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
635	factory := &controlPTYFactory{}
636	d := newTestDaemon(t, factory, stubClock{})
637	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
638	source := addControlSession(d, "work", "t_work", "p_work")
639	source.mu.Lock()
640	source.cwd = "/tmp/work"
641	source.env = []string{"INHERITED=yes"}
642	source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
643	source.mu.Unlock()
644
645	result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
646
647	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
648	d.mu.Lock()
649	created := d.findByNameLocked("scripted")
650	d.mu.Unlock()
651	require.NotNil(t, created)
652	created.mu.Lock()
653	require.Empty(t, created.snapshotAttachmentsLocked())
654	require.Equal(t, "/tmp/work", created.cwd)
655	require.Equal(t, []string{"INHERITED=yes"}, created.env)
656	tb := created.tabs[0]
657	created.mu.Unlock()
658	tb.mu.Lock()
659	require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
660	tb.mu.Unlock()
661	require.Empty(t, source.snapshotAttachments())
662
663	taken := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
664	require.False(t, taken.Outcome == protocol.CommandSucceeded)
665	require.Equal(t, protocol.ErrNameTaken, taken.Code)
666}
667
668func TestHandleCommandNewSessionPreservesSourceGeometry(t *testing.T) {
669	factory := &controlPTYFactory{}
670	d := newTestDaemon(t, factory, stubClock{})
671	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
672	source := addControlSession(d, "work", "t_work", "p_work")
673	client := &attachedClient{}
674	client.setGeometry(domain.Geometry{
675		Size: domain.Size{Cols: 120, Rows: 40}, PixelWidth: 1200, PixelHeight: 800,
676	})
677	require.True(t, source.registerAttachment(client))
678
679	result := sendCommand(t, d, protocol.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
680
681	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
682	d.mu.Lock()
683	created := d.findByNameLocked("scripted")
684	d.mu.Unlock()
685	require.NotNil(t, created)
686	created.mu.Lock()
687	tb := created.tabs[0]
688	created.mu.Unlock()
689	tb.mu.Lock()
690	pane := tb.focusedPane()
691	require.NotNil(t, pane)
692	require.Equal(t, domain.Geometry{
693		Size: domain.Size{Cols: 120, Rows: 38}, PixelWidth: 1200, PixelHeight: 760,
694	}, pane.geometry)
695	tb.mu.Unlock()
696}
697
698func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
699	d := newTestDaemon(t, nil, stubClock{})
700	sess := addControlSession(d, "work", "t_work", "p_work")
701
702	bad := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
703	require.False(t, bad.Outcome == protocol.CommandSucceeded)
704	require.Equal(t, protocol.ErrInvalidCommandArgs, bad.Code)
705
706	good := sendCommand(t, d, protocol.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
707	require.True(t, good.Outcome == protocol.CommandSucceeded, good.Text)
708	d.notices.mu.Lock()
709	require.Len(t, d.notices.pending, 1)
710	require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
711	require.Equal(t, sess.id, d.notices.pending[0].SessionID)
712	d.notices.mu.Unlock()
713}
714
715func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
716	d := newTestDaemon(t, nil, stubClock{})
717	sess := addControlSession(d, "work", "t_work", "p_work")
718	sess.tabs[0].name = "shell"
719
720	sessions := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
721	require.True(t, sessions.Outcome == protocol.CommandSucceeded, sessions.Text)
722	require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
723	require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
724
725	tabs := sendCommand(t, d, protocol.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
726	require.True(t, tabs.Outcome == protocol.CommandSucceeded, tabs.Text)
727	require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
728
729	panes := sendCommand(t, d, protocol.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
730	require.True(t, panes.Outcome == protocol.CommandSucceeded, panes.Text)
731	var decoded []map[string]any
732	require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
733	require.Len(t, decoded, 1)
734	require.Equal(t, "p_work", decoded[0]["id"])
735	require.Equal(t, "/tmp/work", decoded[0]["cwd"])
736	require.Equal(t, true, decoded[0]["focused"])
737}
738
739func TestHandleCommandEnvExportsTargetSessionEnvironment(t *testing.T) {
740	tests := []struct {
741		name    string
742		request protocol.CommandRequest
743		want    string
744	}{
745		{
746			name:    "text by session name",
747			request: protocol.CommandRequest{Slug: "env", TargetSession: "work"},
748			want:    "WAYLAND_DISPLAY=wayland-1\n",
749		},
750		{
751			name:    "json from inside a pane",
752			request: protocol.CommandRequest{Slug: "env", TargetTab: "t_work", TargetPane: "p_work", JSON: true},
753			want:    `{"DISPLAY":null,"SSH_AGENT_PID":null,"SSH_AUTH_SOCK":null,"SSH_CLIENT":null,"SSH_CONNECTION":null,"SSH_TTY":null,"WAYLAND_DISPLAY":"wayland-1","XAUTHORITY":null,"XDG_CURRENT_DESKTOP":null,"XDG_SESSION_CLASS":null,"XDG_SESSION_DESKTOP":null,"XDG_SESSION_ID":null,"XDG_SESSION_TYPE":null}` + "\n",
754		},
755	}
756	for _, tt := range tests {
757		t.Run(tt.name, func(t *testing.T) {
758			d := newTestDaemon(t, nil, stubClock{})
759			sess := addControlSession(d, "work", "t_work", "p_work")
760			sess.mu.Lock()
761			sess.env = []string{"SHELL=/bin/sh", "SECRET=hidden", "WAYLAND_DISPLAY=wayland-1"}
762			sess.mu.Unlock()
763
764			result := sendCommand(t, d, tt.request)
765			require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
766			require.Equal(t, tt.want, result.Output)
767		})
768	}
769}
770
771func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
772	d := newTestDaemon(t, nil, stubClock{})
773	d.mu.Lock()
774	d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
775	d.mu.Unlock()
776
777	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
778	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
779
780	// Observation is read-only: the stopped record stays stopped, is not
781	// resumed into a live session, and is not purged.
782	d.mu.Lock()
783	defer d.mu.Unlock()
784	entry, ok := d.inactive["old"]
785	require.True(t, ok, "stopped record must survive observation")
786	require.Equal(t, protocol.SessionDown, entry.state)
787	for _, sess := range d.sessions {
788		sess.mu.Lock()
789		name := sess.name
790		sess.mu.Unlock()
791		require.NotEqual(t, "old", name, "observation must not resume stopped sessions")
792	}
793}
794
795func TestRemoteCatalogJSONOutput(t *testing.T) {
796	d := newTestDaemon(t, nil, stubClock{})
797	work := addControlSession(d, "work", "t_work", "p_work")
798	work.incarnation[0] = 1
799	work.ephemeral = false
800	work.registerAttachment(&attachedClient{})
801	work.mruAt.Store(2)
802	tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
803	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
804	work.mu.Lock()
805	work.tabs = append(work.tabs, tb)
806	work.mu.Unlock()
807
808	build := addControlSession(d, "build", "t_build", "p_build")
809	build.incarnation[0] = 2
810	build.ephemeral = true
811	build.mruAt.Store(1)
812
813	d.mu.Lock()
814	d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
815	// Live runtime authority wins if both registries contain the same name.
816	d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: protocol.SessionDown}
817	d.mu.Unlock()
818
819	listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
820	require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
821
822	missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
823	require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
824	require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
825
826	withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
827	require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
828	require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
829
830	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
831	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
832	require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
833
834	var catalog catalogue.RemoteCatalog
835	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
836	require.Equal(t, protocol.Version, catalog.ProtocolVersion)
837
838	raw := map[string]json.RawMessage{}
839	require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
840	require.Len(t, raw, 3)
841	_, hasProtocol := raw["protocol_version"]
842	_, hasSchema := raw["schema_version"]
843	_, hasSessions := raw["sessions"]
844	require.True(t, hasProtocol && hasSchema && hasSessions)
845
846	require.Equal(t, []catalogue.RemoteCatalogSession{
847		{LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
848		{LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.RemoteCatalogTab{}},
849		{LifecycleID: work.incarnation, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "t_work", Name: "1", Detail: " (sh)"}, {ID: "t_work_2", Index: 1, Name: "2", Detail: " (sh)"}}, Attached: true, ActiveTabID: "t_work", LastUsedSeq: 2},
850	}, catalog.Sessions)
851
852	require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
853
854	listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
855	require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
856	require.Equal(t, listBefore.Output, listAfter.Output)
857	require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
858	require.NotContains(t, listAfter.Output, "old")
859}
860
861func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
862	d := newTestDaemon(t, nil, stubClock{})
863	sess := addControlSession(d, "work", "t_work", "p_work")
864	sess.incarnation[0] = 1
865	tb := sess.tabs[0]
866	tb.name = "shell"
867	p := tb.focusedPane()
868	pty := portsmocks.NewMockPTY(t)
869	pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
870	p.pty = pty
871	d.proc = &processInspectorFake{comm: func(pid int) (string, error) {
872		require.Equal(t, 1234, pid)
873		return "fish", nil
874	}}
875
876	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
878	var catalog catalogue.RemoteCatalog
879	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
880	require.Equal(t, []catalogue.RemoteCatalogSession{{
881		LifecycleID: sess.incarnation,
882		Name:        "work",
883		State:       "up",
884		Ephemeral:   true,
885		Tabs: []catalogue.RemoteCatalogTab{{
886			ID:     "t_work",
887			Index:  0,
888			Name:   "shell",
889			Detail: " (fish)",
890		}},
891		ActiveTabID: "t_work",
892		LastUsedSeq: 1,
893	}}, catalog.Sessions)
894}
895
896func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
897	d := newTestDaemon(t, nil, stubClock{})
898	sess := addControlSession(d, "work", "t_work", "p_work")
899	sess.mu.Lock()
900	sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
901	for i := range sess.tabs {
902		sess.tabs[i] = newTabWithStableID(
903			fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
904		)
905	}
906	sess.mu.Unlock()
907
908	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
909	require.False(t, result.Outcome == protocol.CommandSucceeded)
910	require.Contains(t, result.Text, "too many tabs")
911}
912
913func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
914	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
915	d := newTestDaemon(t, factory, stubClock{})
916	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
917	sess := addControlSession(d, "work", "t_active", "p_active")
918	target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
919	target.ctx, target.cancel = context.WithCancel(d.serveCtx)
920	sess.mu.Lock()
921	sess.tabs = append(sess.tabs, target)
922	selectTestAttachmentTabLocked(sess, 0)
923	sess.mu.Unlock()
924	original := target.tree.Focus
925
926	firstFrame := commandFrame(t, protocol.CommandRequest{
927		Slug: "split-right", Self: true, TargetSession: "work",
928		TargetTab: "t_invoking", TargetPane: "p_invoking",
929	})
930	firstTransport, firstSends, _ := newConn(t, firstFrame)
931	firstDone := make(chan struct{})
932	go func() {
933		if err := d.handleCommandFrame(firstTransport, firstFrame); err != nil {
934			t.Errorf("handle first command: %v", err)
935		}
936		close(firstDone)
937	}()
938	select {
939	case <-factory.entered:
940	case <-time.After(2 * time.Second):
941		t.Fatal("first command did not enter its blocked action")
942	}
943
944	secondFrame := commandFrame(t, protocol.CommandRequest{
945		Slug: "focus-pane-right", Self: true, TargetSession: "work",
946		TargetTab: "t_invoking", TargetPane: "p_invoking",
947	})
948	secondTransport, secondSends, _ := newConn(t, secondFrame)
949	secondDone := make(chan struct{})
950	go func() {
951		if err := d.handleCommandFrame(secondTransport, secondFrame); err != nil {
952			t.Errorf("handle second command: %v", err)
953		}
954		close(secondDone)
955	}()
956	select {
957	case <-secondDone:
958		t.Fatal("second command bypassed serialization")
959	case <-time.After(100 * time.Millisecond):
960	}
961	close(factory.release)
962	<-firstDone
963	<-secondDone
964	require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
965	require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
966
967	sess.mu.Lock()
968	require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
969	sess.mu.Unlock()
970	target.mu.Lock()
971	defer target.mu.Unlock()
972	require.Len(t, target.panes, 2)
973	require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
974}
975
976func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
977	d := newTestDaemon(t, nil, stubClock{})
978	source := addControlSession(d, "work", "t_work", "p_work")
979	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
980	tb := source.tabs[0]
981	tb.mu.Lock()
982	pane := tb.focusedPane()
983	tb.mu.Unlock()
984	ac := &attachedClient{tr: &closeTrackingTransport{}}
985	ac.setSession(source)
986
987	source.mu.Lock()
988	require.True(t, source.registerAttachmentLocked(ac))
989	target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
990	require.True(t, source.unregisterAttachmentLocked(ac))
991	source.mu.Unlock()
992
993	err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
994	require.ErrorIs(t, err, errMoveStaleTarget)
995}
996
997func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
998	d := newTestDaemon(t, nil, stubClock{})
999	source := addControlSession(d, "work", "t_active", "p_active")
1000	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1001	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1002	publishTiledPaneOwners(source, inactive)
1003	source.mu.Lock()
1004	source.tabs = append(source.tabs, inactive)
1005	selectTestAttachmentTabLocked(source, 0)
1006	source.mu.Unlock()
1007	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1008
1009	result := sendCommand(t, d, protocol.CommandRequest{
1010		Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1011	})
1012	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1013
1014	source.mu.Lock()
1015	require.Len(t, source.tabs, 1)
1016	require.Equal(t, "t_inactive", source.tabs[0].stableID)
1017	source.mu.Unlock()
1018	destination.mu.Lock()
1019	destTab := destination.tabs[0]
1020	destination.mu.Unlock()
1021	destTab.mu.Lock()
1022	var moved *pane
1023	for _, candidate := range destTab.panes {
1024		if candidate.stableID == "p_active" {
1025			moved = candidate
1026		}
1027	}
1028	require.NotNil(t, moved)
1029	destTab.mu.Unlock()
1030}
1031
1032// TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError proves a move
1033// command rejected because a transient KillAll purge owns the exact lifecycle
1034// set surfaces the same retryable internal outcome as the create path, instead
1035// of misreporting the destination as gone.
1036func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
1037	d := newTestDaemon(t, nil, stubClock{})
1038	addControlSession(d, "work", "t_work", "p_work")
1039	addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1040
1041	beginTestPurgeAdmission(t, d)
1042	result := sendCommand(t, d, protocol.CommandRequest{
1043		Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1044	})
1045	require.False(t, result.Outcome == protocol.CommandSucceeded)
1046	require.Equal(t, protocol.ErrInternal, result.Code)
1047	require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
1048	d.endPurgeAdmission()
1049}
1050
1051func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
1052	d := newTestDaemon(t, nil, stubClock{})
1053	source := addControlSession(d, "work", "t_active", "p_active")
1054	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1055	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1056	publishTiledPaneOwners(source, inactive)
1057	source.mu.Lock()
1058	source.tabs = append(source.tabs, inactive)
1059	selectTestAttachmentTabLocked(source, 0)
1060	source.mu.Unlock()
1061	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1062
1063	result := sendCommand(t, d, protocol.CommandRequest{
1064		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1065		TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
1066	})
1067	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1068
1069	active := source.tabs[0]
1070	active.mu.Lock()
1071	require.Len(t, active.panes, 1)
1072	require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
1073	active.mu.Unlock()
1074	destination.mu.Lock()
1075	destTab := destination.tabs[0]
1076	destination.mu.Unlock()
1077	destTab.mu.Lock()
1078	var moved *pane
1079	for _, candidate := range destTab.panes {
1080		if candidate.stableID == "p_inactive" {
1081			moved = candidate
1082		}
1083	}
1084	require.NotNil(t, moved)
1085	destTab.mu.Unlock()
1086}
1087
1088func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
1089	d := newTestDaemon(t, nil, stubClock{})
1090	source := addControlSession(d, "work", "t_first", "p_first")
1091	publishTiledPaneOwners(source, source.tabs[0])
1092	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1093	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1094	publishTiledPaneOwners(source, second)
1095	source.mu.Lock()
1096	source.tabs = append(source.tabs, second)
1097	selectTestAttachmentTabLocked(source, 0)
1098	source.mu.Unlock()
1099	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1100
1101	result := sendCommand(t, d, protocol.CommandRequest{
1102		Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1103		TargetTab: "t_second", TargetPane: "p_second",
1104	})
1105	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1106
1107	source.mu.Lock()
1108	require.Len(t, source.tabs, 1)
1109	require.Equal(t, "t_second", source.tabs[0].stableID)
1110	source.mu.Unlock()
1111	destination.mu.Lock()
1112	require.Len(t, destination.tabs, 2)
1113	require.Equal(t, "t_first", destination.tabs[1].stableID)
1114	destination.mu.Unlock()
1115}
1116
1117func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1118	d := newTestDaemon(t, nil, stubClock{})
1119	source := addControlSession(d, "work", "t_first", "p_first")
1120	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1121	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1122	publishTiledPaneOwners(source, second)
1123	source.mu.Lock()
1124	source.tabs = append(source.tabs, second)
1125	selectTestAttachmentTabLocked(source, 0)
1126	source.mu.Unlock()
1127	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1128
1129	result := sendCommand(t, d, protocol.CommandRequest{
1130		Slug: "move-tab", Args: []string{"dest"}, Self: true,
1131		TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1132	})
1133	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1134
1135	source.mu.Lock()
1136	require.Len(t, source.tabs, 1)
1137	require.Equal(t, "t_first", source.tabs[0].stableID)
1138	source.mu.Unlock()
1139	destination.mu.Lock()
1140	require.Len(t, destination.tabs, 2)
1141	require.Equal(t, "t_second", destination.tabs[1].stableID)
1142	destination.mu.Unlock()
1143}
1144
1145func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1146	tests := []struct {
1147		name    string
1148		arrange func(*Daemon) protocol.CommandRequest
1149		code    uint16
1150		text    string
1151	}{
1152		{
1153			name: "move-pane missing destination session",
1154			arrange: func(d *Daemon) protocol.CommandRequest {
1155				addControlSession(d, "work", "t_work", "p_work")
1156				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1157			},
1158			code: protocol.ErrNoSuchTarget,
1159			text: "move request is invalid",
1160		},
1161		{
1162			name: "move-pane unknown destination session",
1163			arrange: func(d *Daemon) protocol.CommandRequest {
1164				addControlSession(d, "work", "t_work", "p_work")
1165				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1166			},
1167			code: protocol.ErrNoSuchTarget,
1168			text: "move target is no longer available",
1169		},
1170		{
1171			name: "move-pane destination tab not in session",
1172			arrange: func(d *Daemon) protocol.CommandRequest {
1173				addControlSession(d, "work", "t_work", "p_work")
1174				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1175				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1176			},
1177			code: protocol.ErrNoSuchTarget,
1178			text: "move target is no longer available",
1179		},
1180		{
1181			name: "move-pane same source tab",
1182			arrange: func(d *Daemon) protocol.CommandRequest {
1183				addControlSession(d, "work", "t_work", "p_work")
1184				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1185			},
1186			code: protocol.ErrNoSuchTarget,
1187			text: "move request is invalid",
1188		},
1189		{
1190			name: "move-tab same session",
1191			arrange: func(d *Daemon) protocol.CommandRequest {
1192				addControlSession(d, "work", "t_work", "p_work")
1193				return protocol.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1194			},
1195			code: protocol.ErrNoSuchTarget,
1196			text: "move request is invalid",
1197		},
1198		{
1199			name: "move-tab stopping destination",
1200			arrange: func(d *Daemon) protocol.CommandRequest {
1201				addControlSession(d, "work", "t_work", "p_work")
1202				destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1203				destination.teardownMu.Lock()
1204				destination.teardownActive = true
1205				destination.teardownMu.Unlock()
1206				return protocol.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1207			},
1208			code: protocol.ErrNoSuchTarget,
1209			text: "move target is no longer available",
1210		},
1211		{
1212			name: "move-pane too few args",
1213			arrange: func(d *Daemon) protocol.CommandRequest {
1214				addControlSession(d, "work", "t_work", "p_work")
1215				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1216			},
1217			code: protocol.ErrInvalidCommandArgs,
1218		},
1219		{
1220			name: "move-pane too many args",
1221			arrange: func(d *Daemon) protocol.CommandRequest {
1222				addControlSession(d, "work", "t_work", "p_work")
1223				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1224			},
1225			code: protocol.ErrInvalidCommandArgs,
1226		},
1227		{
1228			name: "move-tab missing destination",
1229			arrange: func(d *Daemon) protocol.CommandRequest {
1230				addControlSession(d, "work", "t_work", "p_work")
1231				return protocol.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1232			},
1233			code: protocol.ErrInvalidCommandArgs,
1234		},
1235		{
1236			name: "explicit session name without IDs remains authoritative",
1237			arrange: func(d *Daemon) protocol.CommandRequest {
1238				addControlSession(d, "renamed", "t_work", "p_work")
1239				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1240				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1241			},
1242			code: protocol.ErrNoSuchTarget,
1243			text: "no such session: old-name",
1244		},
1245	}
1246
1247	for _, tt := range tests {
1248		t.Run(tt.name, func(t *testing.T) {
1249			d := newTestDaemon(t, nil, stubClock{})
1250			request := tt.arrange(d)
1251			result := sendCommand(t, d, request)
1252			require.False(t, result.Outcome == protocol.CommandSucceeded)
1253			require.Equal(t, tt.code, result.Code)
1254			if tt.text != "" {
1255				require.Equal(t, tt.text, result.Text)
1256			}
1257		})
1258	}
1259}
1260
1261func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1262	d := newTestDaemon(t, nil, stubClock{})
1263	advisory := addControlSession(d, "old-name", "t_other", "p_other")
1264	source := addControlSession(d, "renamed", "t_work", "p_work")
1265	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1266
1267	result := sendCommand(t, d, protocol.CommandRequest{
1268		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1269		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1270	})
1271	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1272
1273	source.mu.Lock()
1274	require.Len(t, source.tabs, 0)
1275	source.mu.Unlock()
1276	advisory.mu.Lock()
1277	require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1278	advisory.mu.Unlock()
1279	destination.mu.Lock()
1280	destTab := destination.tabs[0]
1281	destination.mu.Unlock()
1282	destTab.mu.Lock()
1283	require.Len(t, destTab.panes, 2)
1284	destTab.mu.Unlock()
1285}
1286
1287func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1288	d := newTestDaemon(t, nil, stubClock{})
1289	source := addControlSession(d, "renamed", "t_active", "p_active")
1290	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1291	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1292	publishTiledPaneOwners(source, inactive)
1293	source.mu.Lock()
1294	source.tabs = append(source.tabs, inactive)
1295	selectTestAttachmentTabLocked(source, 1)
1296	source.mu.Unlock()
1297	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1298
1299	result := sendCommand(t, d, protocol.CommandRequest{
1300		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1301		TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1302	})
1303	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1304
1305	source.mu.Lock()
1306	require.Len(t, source.tabs, 1)
1307	require.Equal(t, "t_active", source.tabs[0].stableID)
1308	source.mu.Unlock()
1309	destination.mu.Lock()
1310	destTab := destination.tabs[0]
1311	destination.mu.Unlock()
1312	destTab.mu.Lock()
1313	var moved *pane
1314	for _, candidate := range destTab.panes {
1315		if candidate.stableID == "p_inactive" {
1316			moved = candidate
1317		}
1318	}
1319	require.NotNil(t, moved)
1320	destTab.mu.Unlock()
1321}
1322
1323func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1324	d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1325	left.mu.Lock()
1326	left.name = "left"
1327	left.tabs[0].stableID = "left-moved"
1328	selectTestAttachmentTabLocked(left, 0)
1329	left.mu.Unlock()
1330
1331	right := addMoveTabTestSession(d, "right", "right-stays")
1332	rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1333	rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1334	publishTiledPaneOwners(right, rightMoved)
1335	right.mu.Lock()
1336	right.tabs = append(right.tabs, rightMoved)
1337	selectTestAttachmentTabLocked(right, 1)
1338	right.mu.Unlock()
1339
1340	leftFrame := commandFrame(t, protocol.CommandRequest{
1341		Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1342	})
1343	rightFrame := commandFrame(t, protocol.CommandRequest{
1344		Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1345	})
1346	leftTransport, leftSends, _ := newConn(t, leftFrame)
1347	rightTransport, rightSends, _ := newConn(t, rightFrame)
1348	start := make(chan struct{})
1349	done := make(chan struct{}, 2)
1350	go func() {
1351		<-start
1352		if err := d.handleCommandFrame(leftTransport, leftFrame); err != nil {
1353			t.Errorf("handle left command: %v", err)
1354		}
1355		done <- struct{}{}
1356	}()
1357	go func() {
1358		<-start
1359		if err := d.handleCommandFrame(rightTransport, rightFrame); err != nil {
1360			t.Errorf("handle right command: %v", err)
1361		}
1362		done <- struct{}{}
1363	}()
1364	close(start)
1365	for _, sends := range []chan wire.Envelope{leftSends, rightSends} {
1366		select {
1367		case <-done:
1368			result := awaitCommandResult(t, sends)
1369			require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1370		case <-time.After(5 * time.Second):
1371			t.Fatal("opposite-direction move commands deadlocked")
1372		}
1373	}
1374}
1375
1376func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1377	ctx, cancel := context.WithCancel(d.serveCtx)
1378	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1379	tb.ctx, tb.cancel = context.WithCancel(ctx)
1380	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1381		ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1382	}
1383	publishTiledPaneOwners(sess, tb)
1384	d.mu.Lock()
1385	d.sessions[sess.id] = sess
1386	d.mu.Unlock()
1387	return sess
1388}
1389
1390func commandFrame(t *testing.T, request protocol.CommandRequest) wire.Envelope {
1391	t.Helper()
1392	if request.Version == 0 {
1393		request.Version = protocol.Version
1394	}
1395	return mustClientEnvelope(request)
1396}
1397
1398func sendCommand(t *testing.T, d *Daemon, request protocol.CommandRequest) protocol.CommandResult {
1399	t.Helper()
1400	frame := commandFrame(t, request)
1401	tr, sends, _ := newConn(t, frame)
1402	require.NoError(t, d.handleCommandFrame(tr, frame))
1403	return awaitCommandResult(t, sends)
1404}
1405
1406func awaitCommandResult(t *testing.T, sends chan wire.Envelope) protocol.CommandResult {
1407	t.Helper()
1408	reply := awaitFrame(t, sends, "CommandResult")
1409	return decodeServerMessage(t, reply).(protocol.CommandResult)
1410}
1411
1412type controlPTYFactory struct {
1413	mu      sync.Mutex
1414	ptys    []*quietPTY
1415	entered chan struct{}
1416	release chan struct{}
1417	once    sync.Once
1418}
1419
1420func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (appports.PTY, error) {
1421	f.once.Do(func() {
1422		if f.entered != nil {
1423			close(f.entered)
1424			<-f.release
1425		}
1426	})
1427	pty := newQuietPTY()
1428	f.mu.Lock()
1429	f.ptys = append(f.ptys, pty)
1430	f.mu.Unlock()
1431	return pty, nil
1432}
1433
1434func (f *controlPTYFactory) close() {
1435	if f.release != nil {
1436		select {
1437		case <-f.release:
1438		default:
1439			close(f.release)
1440		}
1441	}
1442	f.mu.Lock()
1443	defer f.mu.Unlock()
1444	for _, pty := range f.ptys {
1445		_ = pty.Close()
1446	}
1447}
1448
1449type blockingControlSendTransport struct {
1450	started chan struct{}
1451	closed  chan struct{}
1452	once    sync.Once
1453}
1454
1455func newBlockingControlSendTransport() *blockingControlSendTransport {
1456	return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1457}
1458
1459func (tr *blockingControlSendTransport) Send(wire.Envelope) error {
1460	tr.once.Do(func() { close(tr.started) })
1461	<-tr.closed
1462	return io.ErrClosedPipe
1463}
1464
1465func (*blockingControlSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1466func (tr *blockingControlSendTransport) Close() error {
1467	tr.once.Do(func() { close(tr.started) })
1468	select {
1469	case <-tr.closed:
1470	default:
1471		close(tr.closed)
1472	}
1473	return nil
1474}
1475
1476type commandSendErrorTransport struct {
1477	err    error
1478	closed bool
1479}
1480
1481func (tr *commandSendErrorTransport) Send(wire.Envelope) error  { return tr.err }
1482func (*commandSendErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1483func (tr *commandSendErrorTransport) Close() error              { tr.closed = true; return nil }
1484
1485func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1486	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1487	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1488	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1489		ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1490	}
1491	sess.mruAt.Store(1)
1492	d.mu.Lock()
1493	d.sessions[sess.id] = sess
1494	d.mu.Unlock()
1495	return sess
1496}
1497
1498// TestHandleKillSessionParticipantsChangedReportsRetryable proves a direct
1499// session kill whose teardown snapshot a concurrent token resume invalidated
1500// reports a clear retryable outcome instead of a permanent delete failure, and
1501// leaves the session live.
1502func TestHandleKillSessionParticipantsChangedReportsRetryable(t *testing.T) {
1503	d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1504
1505	d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1506		if action != "" {
1507			return
1508		}
1509		d.afterAttachmentEffectParticipantsSnapshotted = nil
1510		// A token resume replaces the exact transport after the participant
1511		// snapshot was taken, so the kill aborts before removing anything.
1512		fresh := &closeTrackingTransport{}
1513		ac.replaceTransport(fresh)
1514		ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1515	}
1516	t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1517
1518	tr, sends := newCapturingTransport(t)
1519	d.handleKill(tr, protocol.Kill{RequestID: 1, Scope: protocol.KillSession, Name: "work"})
1520
1521	frame := awaitFrame(t, sends, "KillResult")
1522	result, ok := decodeServerMessage(t, frame).(protocol.KillResult)
1523	require.True(t, ok)
1524	require.Equal(t, uint64(1), result.RequestID)
1525	require.Equal(t, protocol.KillFailed, result.Outcome)
1526	require.Equal(t, protocol.ErrInternal, result.Code)
1527	require.Equal(t, sessionKillRetryMessage, result.Text)
1528	require.Equal(t, 1, sessionCount(d), "an aborted direct kill must leave the session live")
1529	require.Same(t, sess, firstSession(d))
1530}
1531
1532// TestCloseTabParticipantsChangedReportsRetryable proves a last-tab close whose
1533// teardown snapshot a concurrent token resume invalidated reports a clear
1534// retryable outcome to both the command result and the attached client, and
1535// leaves the session and its tab live.
1536func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1537	d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1538
1539	d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1540		if action != "" {
1541			return
1542		}
1543		d.afterAttachmentEffectParticipantsSnapshotted = nil
1544		fresh := &closeTrackingTransport{}
1545		ac.replaceTransport(fresh)
1546		ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1547	}
1548	t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1549
1550	result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1551	require.False(t, result.Outcome == protocol.CommandSucceeded)
1552	require.Equal(t, protocol.ErrInternal, result.Code)
1553	require.Equal(t, sessionKillRetryMessage, result.Text)
1554
1555	toasts := awaitToastCount(t, ac, 1)
1556	require.Contains(t, toasts[0].Message, "couldn't close tab")
1557	require.Contains(t, toasts[0].Message, "try again")
1558
1559	sess.mu.Lock()
1560	tabs := len(sess.tabs)
1561	sess.mu.Unlock()
1562	require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
1563	require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
1564}
1565
1566func TestDispatchActionLifecycle(t *testing.T) {
1567	boom := errors.New("boom")
1568	tests := []struct {
1569		name      string
1570		runErr    error
1571		serialize bool
1572		// callerHolds enters dispatchAction already inside the dispatch
1573		// boundary, as the palette and control paths do.
1574		callerHolds bool
1575		wantChanged bool
1576		wantErr     error
1577		wantRender  bool
1578	}{
1579		{name: "change renders the attachment", wantChanged: true, wantRender: true},
1580		{name: "no change is success without render", runErr: errDaemonActionNoChange},
1581		{name: "failure skips render", runErr: boom, wantErr: boom},
1582		{name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1583		{name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1584	}
1585	for _, tt := range tests {
1586		t.Run(tt.name, func(t *testing.T) {
1587			d := newTestDaemon(t, nil, stubClock{})
1588			sess := addControlSession(d, "work", "t_work", "p_work")
1589			ac := &attachedClient{}
1590			ac.setSession(sess)
1591			sess.mu.Lock()
1592			sess.registerAttachmentLocked(ac)
1593			sess.mu.Unlock()
1594			invalidations := make(chan renderInvalidation, 2)
1595			rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1596			rc.attach(ac)
1597			sess.installRenderCoordinator(rc)
1598
1599			runner := newMockdaemonActionRunner(t)
1600			runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1601				require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1602				require.Same(t, ac, request.target.attachment)
1603				held := !sess.dispatchMu.TryLock()
1604				if !held {
1605					sess.dispatchMu.Unlock()
1606				}
1607				require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1608				return tt.runErr
1609			}).Once()
1610			x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1611			if tt.serialize {
1612				x.serialize = sess
1613			}
1614
1615			if tt.callerHolds {
1616				sess.dispatchMu.Lock()
1617			}
1618			changed, err := d.dispatchAction(x)
1619			if tt.callerHolds {
1620				sess.dispatchMu.Unlock()
1621			}
1622
1623			require.ErrorIs(t, err, tt.wantErr)
1624			require.NotErrorIs(t, err, errDaemonActionNoChange)
1625			require.Equal(t, tt.wantChanged, changed)
1626			require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1627			sess.dispatchMu.Unlock()
1628			if tt.wantRender {
1629				require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1630			} else {
1631				requireNoInvalidation(t, invalidations)
1632			}
1633		})
1634	}
1635}

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