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	appports "github.com/bnema/vev/internal/ports"
18	portsmocks "github.com/bnema/vev/internal/ports/mocks"
19	"github.com/bnema/vev/internal/protocol"
20	"github.com/bnema/vev/internal/protocol/catalogue"
21	"github.com/bnema/vev/internal/protocol/wire"
22	"github.com/bnema/vev/internal/usecase/command"
23	"github.com/bnema/vev/internal/usecase/layout"
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 TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
740	d := newTestDaemon(t, nil, stubClock{})
741	d.mu.Lock()
742	d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
743	d.mu.Unlock()
744
745	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
746	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
747
748	// Observation is read-only: the stopped record stays stopped, is not
749	// resumed into a live session, and is not purged.
750	d.mu.Lock()
751	defer d.mu.Unlock()
752	entry, ok := d.inactive["old"]
753	require.True(t, ok, "stopped record must survive observation")
754	require.Equal(t, protocol.SessionDown, entry.state)
755	for _, sess := range d.sessions {
756		sess.mu.Lock()
757		name := sess.name
758		sess.mu.Unlock()
759		require.NotEqual(t, "old", name, "observation must not resume stopped sessions")
760	}
761}
762
763func TestRemoteCatalogJSONOutput(t *testing.T) {
764	d := newTestDaemon(t, nil, stubClock{})
765	work := addControlSession(d, "work", "t_work", "p_work")
766	work.incarnation[0] = 1
767	work.ephemeral = false
768	work.registerAttachment(&attachedClient{})
769	work.mruAt.Store(2)
770	tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
771	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
772	work.mu.Lock()
773	work.tabs = append(work.tabs, tb)
774	work.mu.Unlock()
775
776	build := addControlSession(d, "build", "t_build", "p_build")
777	build.incarnation[0] = 2
778	build.ephemeral = true
779	build.mruAt.Store(1)
780
781	d.mu.Lock()
782	d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
783	// Live runtime authority wins if both registries contain the same name.
784	d.inactive["work"] = inactiveSession{name: "work", cwd: "/tmp/work", createdAt: 1, incarnation: domain.IncarnationID{4}, state: protocol.SessionDown}
785	d.mu.Unlock()
786
787	listBefore := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
788	require.True(t, listBefore.Outcome == protocol.CommandSucceeded, listBefore.Text)
789
790	missingJSON := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog"})
791	require.False(t, missingJSON.Outcome == protocol.CommandSucceeded)
792	require.Equal(t, protocol.ErrInvalidCommandArgs, missingJSON.Code)
793
794	withArgs := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
795	require.False(t, withArgs.Outcome == protocol.CommandSucceeded)
796	require.Equal(t, protocol.ErrInvalidCommandArgs, withArgs.Code)
797
798	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
799	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
800	require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
801
802	var catalog catalogue.RemoteCatalog
803	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
804	require.Equal(t, protocol.Version, catalog.ProtocolVersion)
805
806	raw := map[string]json.RawMessage{}
807	require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
808	require.Len(t, raw, 3)
809	_, hasProtocol := raw["protocol_version"]
810	_, hasSchema := raw["schema_version"]
811	_, hasSessions := raw["sessions"]
812	require.True(t, hasProtocol && hasSchema && hasSessions)
813
814	require.Equal(t, []catalogue.RemoteCatalogSession{
815		{LifecycleID: build.incarnation, Name: "build", State: "up", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "t_build", Name: "1", Detail: " (sh)"}}, ActiveTabID: "t_build", LastUsedSeq: 1},
816		{LifecycleID: domain.IncarnationID{3}, Name: "old", State: "down", Tabs: []catalogue.RemoteCatalogTab{}},
817		{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},
818	}, catalog.Sessions)
819
820	require.Equal(t, catalogue.RemoteCatalogSessionDown, catalog.Sessions[1].State)
821
822	listAfter := sendCommand(t, d, protocol.CommandRequest{Slug: "list-sessions"})
823	require.True(t, listAfter.Outcome == protocol.CommandSucceeded, listAfter.Text)
824	require.Equal(t, listBefore.Output, listAfter.Output)
825	require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
826	require.NotContains(t, listAfter.Output, "old")
827}
828
829func TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
830	d := newTestDaemon(t, nil, stubClock{})
831	sess := addControlSession(d, "work", "t_work", "p_work")
832	sess.incarnation[0] = 1
833	tb := sess.tabs[0]
834	tb.name = "shell"
835	p := tb.focusedPane()
836	pty := portsmocks.NewMockPTY(t)
837	pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
838	p.pty = pty
839	d.procComm = func(pid int) (string, error) {
840		require.Equal(t, 1234, pid)
841		return "fish", nil
842	}
843
844	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
845	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
846	var catalog catalogue.RemoteCatalog
847	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
848	require.Equal(t, []catalogue.RemoteCatalogSession{{
849		LifecycleID: sess.incarnation,
850		Name:        "work",
851		State:       "up",
852		Ephemeral:   true,
853		Tabs: []catalogue.RemoteCatalogTab{{
854			ID:     "t_work",
855			Index:  0,
856			Name:   "shell",
857			Detail: " (fish)",
858		}},
859		ActiveTabID: "t_work",
860		LastUsedSeq: 1,
861	}}, catalog.Sessions)
862}
863
864func TestRemoteCatalogRejectsTooManyTabs(t *testing.T) {
865	d := newTestDaemon(t, nil, stubClock{})
866	sess := addControlSession(d, "work", "t_work", "p_work")
867	sess.mu.Lock()
868	sess.tabs = make([]*tab, catalogue.RemoteCatalogMaxTabsPerSess+1)
869	for i := range sess.tabs {
870		sess.tabs[i] = newTabWithStableID(
871			fmt.Sprintf("tab-%d", i), fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 22},
872		)
873	}
874	sess.mu.Unlock()
875
876	result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
877	require.False(t, result.Outcome == protocol.CommandSucceeded)
878	require.Contains(t, result.Text, "too many tabs")
879}
880
881func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
882	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
883	d := newTestDaemon(t, factory, stubClock{})
884	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
885	sess := addControlSession(d, "work", "t_active", "p_active")
886	target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
887	target.ctx, target.cancel = context.WithCancel(d.serveCtx)
888	sess.mu.Lock()
889	sess.tabs = append(sess.tabs, target)
890	selectTestAttachmentTabLocked(sess, 0)
891	sess.mu.Unlock()
892	original := target.tree.Focus
893
894	firstFrame := commandFrame(t, protocol.CommandRequest{
895		Slug: "split-right", Self: true, TargetSession: "work",
896		TargetTab: "t_invoking", TargetPane: "p_invoking",
897	})
898	firstTransport, firstSends, _ := newConn(t, firstFrame)
899	firstDone := make(chan struct{})
900	go func() {
901		if err := d.handleCommandFrame(firstTransport, firstFrame); err != nil {
902			t.Errorf("handle first command: %v", err)
903		}
904		close(firstDone)
905	}()
906	select {
907	case <-factory.entered:
908	case <-time.After(2 * time.Second):
909		t.Fatal("first command did not enter its blocked action")
910	}
911
912	secondFrame := commandFrame(t, protocol.CommandRequest{
913		Slug: "focus-pane-right", Self: true, TargetSession: "work",
914		TargetTab: "t_invoking", TargetPane: "p_invoking",
915	})
916	secondTransport, secondSends, _ := newConn(t, secondFrame)
917	secondDone := make(chan struct{})
918	go func() {
919		if err := d.handleCommandFrame(secondTransport, secondFrame); err != nil {
920			t.Errorf("handle second command: %v", err)
921		}
922		close(secondDone)
923	}()
924	select {
925	case <-secondDone:
926		t.Fatal("second command bypassed serialization")
927	case <-time.After(100 * time.Millisecond):
928	}
929	close(factory.release)
930	<-firstDone
931	<-secondDone
932	require.True(t, awaitCommandResult(t, firstSends).Outcome == protocol.CommandSucceeded)
933	require.True(t, awaitCommandResult(t, secondSends).Outcome == protocol.CommandSucceeded)
934
935	sess.mu.Lock()
936	require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
937	sess.mu.Unlock()
938	target.mu.Lock()
939	defer target.mu.Unlock()
940	require.Len(t, target.panes, 2)
941	require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
942}
943
944func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
945	d := newTestDaemon(t, nil, stubClock{})
946	source := addControlSession(d, "work", "t_work", "p_work")
947	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
948	tb := source.tabs[0]
949	tb.mu.Lock()
950	pane := tb.focusedPane()
951	tb.mu.Unlock()
952	ac := &attachedClient{tr: &closeTrackingTransport{}}
953	ac.setSession(source)
954
955	source.mu.Lock()
956	require.True(t, source.registerAttachmentLocked(ac))
957	target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
958	require.True(t, source.unregisterAttachmentLocked(ac))
959	source.mu.Unlock()
960
961	err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
962	require.ErrorIs(t, err, errMoveStaleTarget)
963}
964
965func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
966	d := newTestDaemon(t, nil, stubClock{})
967	source := addControlSession(d, "work", "t_active", "p_active")
968	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
969	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
970	publishTiledPaneOwners(source, inactive)
971	source.mu.Lock()
972	source.tabs = append(source.tabs, inactive)
973	selectTestAttachmentTabLocked(source, 0)
974	source.mu.Unlock()
975	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
976
977	result := sendCommand(t, d, protocol.CommandRequest{
978		Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
979	})
980	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
981
982	source.mu.Lock()
983	require.Len(t, source.tabs, 1)
984	require.Equal(t, "t_inactive", source.tabs[0].stableID)
985	source.mu.Unlock()
986	destination.mu.Lock()
987	destTab := destination.tabs[0]
988	destination.mu.Unlock()
989	destTab.mu.Lock()
990	var moved *pane
991	for _, candidate := range destTab.panes {
992		if candidate.stableID == "p_active" {
993			moved = candidate
994		}
995	}
996	require.NotNil(t, moved)
997	destTab.mu.Unlock()
998}
999
1000// TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError proves a move
1001// command rejected because a transient KillAll purge owns the exact lifecycle
1002// set surfaces the same retryable internal outcome as the create path, instead
1003// of misreporting the destination as gone.
1004func TestHandleCommandMovePanePurgeAdmissionMapsToRetryableError(t *testing.T) {
1005	d := newTestDaemon(t, nil, stubClock{})
1006	addControlSession(d, "work", "t_work", "p_work")
1007	addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1008
1009	beginTestPurgeAdmission(t, d)
1010	result := sendCommand(t, d, protocol.CommandRequest{
1011		Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
1012	})
1013	require.False(t, result.Outcome == protocol.CommandSucceeded)
1014	require.Equal(t, protocol.ErrInternal, result.Code)
1015	require.Equal(t, errPurgeAdmissionClosed.Error(), result.Text)
1016	d.endPurgeAdmission()
1017}
1018
1019func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
1020	d := newTestDaemon(t, nil, stubClock{})
1021	source := addControlSession(d, "work", "t_active", "p_active")
1022	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1023	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1024	publishTiledPaneOwners(source, inactive)
1025	source.mu.Lock()
1026	source.tabs = append(source.tabs, inactive)
1027	selectTestAttachmentTabLocked(source, 0)
1028	source.mu.Unlock()
1029	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1030
1031	result := sendCommand(t, d, protocol.CommandRequest{
1032		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1033		TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
1034	})
1035	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1036
1037	active := source.tabs[0]
1038	active.mu.Lock()
1039	require.Len(t, active.panes, 1)
1040	require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
1041	active.mu.Unlock()
1042	destination.mu.Lock()
1043	destTab := destination.tabs[0]
1044	destination.mu.Unlock()
1045	destTab.mu.Lock()
1046	var moved *pane
1047	for _, candidate := range destTab.panes {
1048		if candidate.stableID == "p_inactive" {
1049			moved = candidate
1050		}
1051	}
1052	require.NotNil(t, moved)
1053	destTab.mu.Unlock()
1054}
1055
1056func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
1057	d := newTestDaemon(t, nil, stubClock{})
1058	source := addControlSession(d, "work", "t_first", "p_first")
1059	publishTiledPaneOwners(source, source.tabs[0])
1060	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1061	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1062	publishTiledPaneOwners(source, second)
1063	source.mu.Lock()
1064	source.tabs = append(source.tabs, second)
1065	selectTestAttachmentTabLocked(source, 0)
1066	source.mu.Unlock()
1067	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1068
1069	result := sendCommand(t, d, protocol.CommandRequest{
1070		Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1071		TargetTab: "t_second", TargetPane: "p_second",
1072	})
1073	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1074
1075	source.mu.Lock()
1076	require.Len(t, source.tabs, 1)
1077	require.Equal(t, "t_second", source.tabs[0].stableID)
1078	source.mu.Unlock()
1079	destination.mu.Lock()
1080	require.Len(t, destination.tabs, 2)
1081	require.Equal(t, "t_first", destination.tabs[1].stableID)
1082	destination.mu.Unlock()
1083}
1084
1085func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1086	d := newTestDaemon(t, nil, stubClock{})
1087	source := addControlSession(d, "work", "t_first", "p_first")
1088	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1089	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1090	publishTiledPaneOwners(source, second)
1091	source.mu.Lock()
1092	source.tabs = append(source.tabs, second)
1093	selectTestAttachmentTabLocked(source, 0)
1094	source.mu.Unlock()
1095	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1096
1097	result := sendCommand(t, d, protocol.CommandRequest{
1098		Slug: "move-tab", Args: []string{"dest"}, Self: true,
1099		TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1100	})
1101	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1102
1103	source.mu.Lock()
1104	require.Len(t, source.tabs, 1)
1105	require.Equal(t, "t_first", source.tabs[0].stableID)
1106	source.mu.Unlock()
1107	destination.mu.Lock()
1108	require.Len(t, destination.tabs, 2)
1109	require.Equal(t, "t_second", destination.tabs[1].stableID)
1110	destination.mu.Unlock()
1111}
1112
1113func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1114	tests := []struct {
1115		name    string
1116		arrange func(*Daemon) protocol.CommandRequest
1117		code    uint16
1118		text    string
1119	}{
1120		{
1121			name: "move-pane missing destination session",
1122			arrange: func(d *Daemon) protocol.CommandRequest {
1123				addControlSession(d, "work", "t_work", "p_work")
1124				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1125			},
1126			code: protocol.ErrNoSuchTarget,
1127			text: "move request is invalid",
1128		},
1129		{
1130			name: "move-pane unknown destination session",
1131			arrange: func(d *Daemon) protocol.CommandRequest {
1132				addControlSession(d, "work", "t_work", "p_work")
1133				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1134			},
1135			code: protocol.ErrNoSuchTarget,
1136			text: "move target is no longer available",
1137		},
1138		{
1139			name: "move-pane destination tab not in session",
1140			arrange: func(d *Daemon) protocol.CommandRequest {
1141				addControlSession(d, "work", "t_work", "p_work")
1142				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1143				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1144			},
1145			code: protocol.ErrNoSuchTarget,
1146			text: "move target is no longer available",
1147		},
1148		{
1149			name: "move-pane same source tab",
1150			arrange: func(d *Daemon) protocol.CommandRequest {
1151				addControlSession(d, "work", "t_work", "p_work")
1152				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1153			},
1154			code: protocol.ErrNoSuchTarget,
1155			text: "move request is invalid",
1156		},
1157		{
1158			name: "move-tab same session",
1159			arrange: func(d *Daemon) protocol.CommandRequest {
1160				addControlSession(d, "work", "t_work", "p_work")
1161				return protocol.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1162			},
1163			code: protocol.ErrNoSuchTarget,
1164			text: "move request is invalid",
1165		},
1166		{
1167			name: "move-tab stopping destination",
1168			arrange: func(d *Daemon) protocol.CommandRequest {
1169				addControlSession(d, "work", "t_work", "p_work")
1170				destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1171				destination.teardownMu.Lock()
1172				destination.teardownActive = true
1173				destination.teardownMu.Unlock()
1174				return protocol.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1175			},
1176			code: protocol.ErrNoSuchTarget,
1177			text: "move target is no longer available",
1178		},
1179		{
1180			name: "move-pane too few args",
1181			arrange: func(d *Daemon) protocol.CommandRequest {
1182				addControlSession(d, "work", "t_work", "p_work")
1183				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1184			},
1185			code: protocol.ErrInvalidCommandArgs,
1186		},
1187		{
1188			name: "move-pane too many args",
1189			arrange: func(d *Daemon) protocol.CommandRequest {
1190				addControlSession(d, "work", "t_work", "p_work")
1191				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1192			},
1193			code: protocol.ErrInvalidCommandArgs,
1194		},
1195		{
1196			name: "move-tab missing destination",
1197			arrange: func(d *Daemon) protocol.CommandRequest {
1198				addControlSession(d, "work", "t_work", "p_work")
1199				return protocol.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1200			},
1201			code: protocol.ErrInvalidCommandArgs,
1202		},
1203		{
1204			name: "explicit session name without IDs remains authoritative",
1205			arrange: func(d *Daemon) protocol.CommandRequest {
1206				addControlSession(d, "renamed", "t_work", "p_work")
1207				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1208				return protocol.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1209			},
1210			code: protocol.ErrNoSuchTarget,
1211			text: "no such session: old-name",
1212		},
1213	}
1214
1215	for _, tt := range tests {
1216		t.Run(tt.name, func(t *testing.T) {
1217			d := newTestDaemon(t, nil, stubClock{})
1218			request := tt.arrange(d)
1219			result := sendCommand(t, d, request)
1220			require.False(t, result.Outcome == protocol.CommandSucceeded)
1221			require.Equal(t, tt.code, result.Code)
1222			if tt.text != "" {
1223				require.Equal(t, tt.text, result.Text)
1224			}
1225		})
1226	}
1227}
1228
1229func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1230	d := newTestDaemon(t, nil, stubClock{})
1231	advisory := addControlSession(d, "old-name", "t_other", "p_other")
1232	source := addControlSession(d, "renamed", "t_work", "p_work")
1233	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1234
1235	result := sendCommand(t, d, protocol.CommandRequest{
1236		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1237		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1238	})
1239	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1240
1241	source.mu.Lock()
1242	require.Len(t, source.tabs, 0)
1243	source.mu.Unlock()
1244	advisory.mu.Lock()
1245	require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1246	advisory.mu.Unlock()
1247	destination.mu.Lock()
1248	destTab := destination.tabs[0]
1249	destination.mu.Unlock()
1250	destTab.mu.Lock()
1251	require.Len(t, destTab.panes, 2)
1252	destTab.mu.Unlock()
1253}
1254
1255func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1256	d := newTestDaemon(t, nil, stubClock{})
1257	source := addControlSession(d, "renamed", "t_active", "p_active")
1258	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1259	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1260	publishTiledPaneOwners(source, inactive)
1261	source.mu.Lock()
1262	source.tabs = append(source.tabs, inactive)
1263	selectTestAttachmentTabLocked(source, 1)
1264	source.mu.Unlock()
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_inactive", TargetPane: "p_inactive",
1270	})
1271	require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1272
1273	source.mu.Lock()
1274	require.Len(t, source.tabs, 1)
1275	require.Equal(t, "t_active", source.tabs[0].stableID)
1276	source.mu.Unlock()
1277	destination.mu.Lock()
1278	destTab := destination.tabs[0]
1279	destination.mu.Unlock()
1280	destTab.mu.Lock()
1281	var moved *pane
1282	for _, candidate := range destTab.panes {
1283		if candidate.stableID == "p_inactive" {
1284			moved = candidate
1285		}
1286	}
1287	require.NotNil(t, moved)
1288	destTab.mu.Unlock()
1289}
1290
1291func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1292	d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1293	left.mu.Lock()
1294	left.name = "left"
1295	left.tabs[0].stableID = "left-moved"
1296	selectTestAttachmentTabLocked(left, 0)
1297	left.mu.Unlock()
1298
1299	right := addMoveTabTestSession(d, "right", "right-stays")
1300	rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1301	rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1302	publishTiledPaneOwners(right, rightMoved)
1303	right.mu.Lock()
1304	right.tabs = append(right.tabs, rightMoved)
1305	selectTestAttachmentTabLocked(right, 1)
1306	right.mu.Unlock()
1307
1308	leftFrame := commandFrame(t, protocol.CommandRequest{
1309		Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1310	})
1311	rightFrame := commandFrame(t, protocol.CommandRequest{
1312		Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1313	})
1314	leftTransport, leftSends, _ := newConn(t, leftFrame)
1315	rightTransport, rightSends, _ := newConn(t, rightFrame)
1316	start := make(chan struct{})
1317	done := make(chan struct{}, 2)
1318	go func() {
1319		<-start
1320		if err := d.handleCommandFrame(leftTransport, leftFrame); err != nil {
1321			t.Errorf("handle left command: %v", err)
1322		}
1323		done <- struct{}{}
1324	}()
1325	go func() {
1326		<-start
1327		if err := d.handleCommandFrame(rightTransport, rightFrame); err != nil {
1328			t.Errorf("handle right command: %v", err)
1329		}
1330		done <- struct{}{}
1331	}()
1332	close(start)
1333	for _, sends := range []chan wire.Envelope{leftSends, rightSends} {
1334		select {
1335		case <-done:
1336			result := awaitCommandResult(t, sends)
1337			require.True(t, result.Outcome == protocol.CommandSucceeded, result.Text)
1338		case <-time.After(5 * time.Second):
1339			t.Fatal("opposite-direction move commands deadlocked")
1340		}
1341	}
1342}
1343
1344func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1345	ctx, cancel := context.WithCancel(d.serveCtx)
1346	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1347	tb.ctx, tb.cancel = context.WithCancel(ctx)
1348	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1349		ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1350	}
1351	publishTiledPaneOwners(sess, tb)
1352	d.mu.Lock()
1353	d.sessions[sess.id] = sess
1354	d.mu.Unlock()
1355	return sess
1356}
1357
1358func commandFrame(t *testing.T, request protocol.CommandRequest) wire.Envelope {
1359	t.Helper()
1360	if request.Version == 0 {
1361		request.Version = protocol.Version
1362	}
1363	return mustClientEnvelope(request)
1364}
1365
1366func sendCommand(t *testing.T, d *Daemon, request protocol.CommandRequest) protocol.CommandResult {
1367	t.Helper()
1368	frame := commandFrame(t, request)
1369	tr, sends, _ := newConn(t, frame)
1370	require.NoError(t, d.handleCommandFrame(tr, frame))
1371	return awaitCommandResult(t, sends)
1372}
1373
1374func awaitCommandResult(t *testing.T, sends chan wire.Envelope) protocol.CommandResult {
1375	t.Helper()
1376	reply := awaitFrame(t, sends, "CommandResult")
1377	return decodeServerMessage(t, reply).(protocol.CommandResult)
1378}
1379
1380type controlPTYFactory struct {
1381	mu      sync.Mutex
1382	ptys    []*quietPTY
1383	entered chan struct{}
1384	release chan struct{}
1385	once    sync.Once
1386}
1387
1388func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (appports.PTY, error) {
1389	f.once.Do(func() {
1390		if f.entered != nil {
1391			close(f.entered)
1392			<-f.release
1393		}
1394	})
1395	pty := newQuietPTY()
1396	f.mu.Lock()
1397	f.ptys = append(f.ptys, pty)
1398	f.mu.Unlock()
1399	return pty, nil
1400}
1401
1402func (f *controlPTYFactory) close() {
1403	if f.release != nil {
1404		select {
1405		case <-f.release:
1406		default:
1407			close(f.release)
1408		}
1409	}
1410	f.mu.Lock()
1411	defer f.mu.Unlock()
1412	for _, pty := range f.ptys {
1413		_ = pty.Close()
1414	}
1415}
1416
1417type blockingControlSendTransport struct {
1418	started chan struct{}
1419	closed  chan struct{}
1420	once    sync.Once
1421}
1422
1423func newBlockingControlSendTransport() *blockingControlSendTransport {
1424	return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1425}
1426
1427func (tr *blockingControlSendTransport) Send(wire.Envelope) error {
1428	tr.once.Do(func() { close(tr.started) })
1429	<-tr.closed
1430	return io.ErrClosedPipe
1431}
1432
1433func (*blockingControlSendTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1434func (tr *blockingControlSendTransport) Close() error {
1435	tr.once.Do(func() { close(tr.started) })
1436	select {
1437	case <-tr.closed:
1438	default:
1439		close(tr.closed)
1440	}
1441	return nil
1442}
1443
1444type commandSendErrorTransport struct {
1445	err    error
1446	closed bool
1447}
1448
1449func (tr *commandSendErrorTransport) Send(wire.Envelope) error  { return tr.err }
1450func (*commandSendErrorTransport) Recv() (wire.Envelope, error) { return wire.Envelope{}, io.EOF }
1451func (tr *commandSendErrorTransport) Close() error              { tr.closed = true; return nil }
1452
1453func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1454	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1455	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1456	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1457		ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1458	}
1459	sess.mruAt.Store(1)
1460	d.mu.Lock()
1461	d.sessions[sess.id] = sess
1462	d.mu.Unlock()
1463	return sess
1464}
1465
1466// TestHandleKillSessionParticipantsChangedReportsRetryable proves a direct
1467// session kill whose teardown snapshot a concurrent token resume invalidated
1468// reports a clear retryable outcome instead of a permanent delete failure, and
1469// leaves the session live.
1470func TestHandleKillSessionParticipantsChangedReportsRetryable(t *testing.T) {
1471	d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1472
1473	d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1474		if action != "" {
1475			return
1476		}
1477		d.afterAttachmentEffectParticipantsSnapshotted = nil
1478		// A token resume replaces the exact transport after the participant
1479		// snapshot was taken, so the kill aborts before removing anything.
1480		fresh := &closeTrackingTransport{}
1481		ac.replaceTransport(fresh)
1482		ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1483	}
1484	t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1485
1486	tr, sends := newCapturingTransport(t)
1487	d.handleKill(tr, protocol.Kill{RequestID: 1, Scope: protocol.KillSession, Name: "work"})
1488
1489	frame := awaitFrame(t, sends, "KillResult")
1490	result, ok := decodeServerMessage(t, frame).(protocol.KillResult)
1491	require.True(t, ok)
1492	require.Equal(t, uint64(1), result.RequestID)
1493	require.Equal(t, protocol.KillFailed, result.Outcome)
1494	require.Equal(t, protocol.ErrInternal, result.Code)
1495	require.Equal(t, sessionKillRetryMessage, result.Text)
1496	require.Equal(t, 1, sessionCount(d), "an aborted direct kill must leave the session live")
1497	require.Same(t, sess, firstSession(d))
1498}
1499
1500// TestCloseTabParticipantsChangedReportsRetryable proves a last-tab close whose
1501// teardown snapshot a concurrent token resume invalidated reports a clear
1502// retryable outcome to both the command result and the attached client, and
1503// leaves the session and its tab live.
1504func TestCloseTabParticipantsChangedReportsRetryable(t *testing.T) {
1505	d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
1506
1507	d.afterAttachmentEffectParticipantsSnapshotted = func(action string, _ []*attachedClient) {
1508		if action != "" {
1509			return
1510		}
1511		d.afterAttachmentEffectParticipantsSnapshotted = nil
1512		fresh := &closeTrackingTransport{}
1513		ac.replaceTransport(fresh)
1514		ac.installTestAttachmentCapability(sess.captureAttachmentCapability(ac, fresh))
1515	}
1516	t.Cleanup(func() { d.afterAttachmentEffectParticipantsSnapshotted = nil })
1517
1518	result := sendCommand(t, d, protocol.CommandRequest{Slug: "close-tab", TargetSession: "work"})
1519	require.False(t, result.Outcome == protocol.CommandSucceeded)
1520	require.Equal(t, protocol.ErrInternal, result.Code)
1521	require.Equal(t, sessionKillRetryMessage, result.Text)
1522
1523	toasts := awaitToastCount(t, ac, 1)
1524	require.Contains(t, toasts[0].Message, "couldn't close tab")
1525	require.Contains(t, toasts[0].Message, "try again")
1526
1527	sess.mu.Lock()
1528	tabs := len(sess.tabs)
1529	sess.mu.Unlock()
1530	require.Equal(t, 1, tabs, "an aborted last-tab close must retain the tab")
1531	require.Equal(t, 1, sessionCount(d), "an aborted last-tab close must retain the session")
1532}
1533
1534func TestDispatchActionLifecycle(t *testing.T) {
1535	boom := errors.New("boom")
1536	tests := []struct {
1537		name      string
1538		runErr    error
1539		serialize bool
1540		// callerHolds enters dispatchAction already inside the dispatch
1541		// boundary, as the palette and control paths do.
1542		callerHolds bool
1543		wantChanged bool
1544		wantErr     error
1545		wantRender  bool
1546	}{
1547		{name: "change renders the attachment", wantChanged: true, wantRender: true},
1548		{name: "no change is success without render", runErr: errDaemonActionNoChange},
1549		{name: "failure skips render", runErr: boom, wantErr: boom},
1550		{name: "serialized change holds dispatch boundary", serialize: true, wantChanged: true, wantRender: true},
1551		{name: "caller-held boundary is reused and still renders", callerHolds: true, wantChanged: true, wantRender: true},
1552	}
1553	for _, tt := range tests {
1554		t.Run(tt.name, func(t *testing.T) {
1555			d := newTestDaemon(t, nil, stubClock{})
1556			sess := addControlSession(d, "work", "t_work", "p_work")
1557			ac := &attachedClient{}
1558			ac.setSession(sess)
1559			sess.mu.Lock()
1560			sess.registerAttachmentLocked(ac)
1561			sess.mu.Unlock()
1562			invalidations := make(chan renderInvalidation, 2)
1563			rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
1564			rc.attach(ac)
1565			sess.installRenderCoordinator(rc)
1566
1567			runner := newMockdaemonActionRunner(t)
1568			runner.EXPECT().Run(mock.Anything).RunAndReturn(func(request daemonActionRequest) error {
1569				require.Same(t, sess, request.target.session, "unset target resolves from the attachment")
1570				require.Same(t, ac, request.target.attachment)
1571				held := !sess.dispatchMu.TryLock()
1572				if !held {
1573					sess.dispatchMu.Unlock()
1574				}
1575				require.Equal(t, tt.serialize || tt.callerHolds, held, "dispatch boundary ownership")
1576				return tt.runErr
1577			}).Once()
1578			x := daemonActionDispatch{runner: runner, request: daemonActionRequest{kind: daemonActionEqualizePanes}, resolveFrom: sess, attachment: ac, producer: "test"}
1579			if tt.serialize {
1580				x.serialize = sess
1581			}
1582
1583			if tt.callerHolds {
1584				sess.dispatchMu.Lock()
1585			}
1586			changed, err := d.dispatchAction(x)
1587			if tt.callerHolds {
1588				sess.dispatchMu.Unlock()
1589			}
1590
1591			require.ErrorIs(t, err, tt.wantErr)
1592			require.NotErrorIs(t, err, errDaemonActionNoChange)
1593			require.Equal(t, tt.wantChanged, changed)
1594			require.True(t, sess.dispatchMu.TryLock(), "dispatchAction must not leave the boundary held")
1595			sess.dispatchMu.Unlock()
1596			if tt.wantRender {
1597				require.Equal(t, "test", awaitInvalidation(t, invalidations).producer)
1598			} else {
1599				requireNoInvalidation(t, invalidations)
1600			}
1601		})
1602	}
1603}

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