v0.2.0 80c61e3

control_test.go RawHistoryFolder

1package daemon
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"io"
9	"log/slog"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/ports"
17	portsmocks "github.com/bnema/vev/internal/ports/mocks"
18	"github.com/bnema/vev/internal/usecase/command"
19	"github.com/bnema/vev/internal/usecase/layout"
20	"github.com/stretchr/testify/require"
21)
22
23func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
24	tests := []struct {
25		name      string
26		palette   func(paletteExec) error
27		control   func(controlExec) error
28		kind      daemonActionKind
29		direction layout.Direction
30	}{
31		{name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
32		{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},
33		{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},
34	}
35
36	for _, tt := range tests {
37		t.Run(tt.name, func(t *testing.T) {
38			d := newTestDaemon(t, nil, stubClock{})
39			sess := addControlSession(d, "work", "t_work", "p_work")
40			tb := testAttachmentTab(sess)
41			tb.mu.Lock()
42			pane := tb.focusedPane()
43			tb.mu.Unlock()
44			spy := &actionRunnerSpy{}
45			target := daemonActionTarget{session: sess, tab: tb, pane: pane}
46
47			require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
48			require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
49
50			require.Len(t, spy.requests, 2)
51			for _, request := range spy.requests {
52				require.Equal(t, tt.kind, request.kind)
53				require.Same(t, sess, request.target.session)
54				require.Same(t, tb, request.target.tab)
55				require.Same(t, pane, request.target.pane)
56				require.Equal(t, tt.direction, request.direction)
57			}
58		})
59	}
60}
61
62func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
63	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
64	h.tab.mu.Lock()
65	h.tab.tree.Focus = "pane-1"
66	beforeGeneration := h.tab.layoutGeneration
67	tabStableID := h.tab.stableID
68	targetStableID := h.panes["pane-3"].stableID
69	h.tab.mu.Unlock()
70
71	result := sendCommand(t, h.daemon, ports.CommandRequest{
72		Slug:          "consume-or-expel-pane-left",
73		Self:          true,
74		TargetSession: "work",
75		TargetTab:     tabStableID,
76		TargetPane:    targetStableID,
77	})
78
79	require.True(t, result.OK, result.Text)
80	require.Empty(t, result.Output)
81	require.Zero(t, testAttachmentTabIndex(h.session), "--self must not change the attachment tab")
82	h.tab.mu.Lock()
83	require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
84	require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
85	require.Len(t, h.tab.tree.Root.Children, 2)
86	h.tab.mu.Unlock()
87}
88
89func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
90	h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
91	ac := &attachedClient{}
92	ac.setSession(h.session)
93	h.session.mu.Lock()
94	h.session.registerAttachmentLocked(ac)
95	h.session.mu.Unlock()
96	invalidations := make(chan renderInvalidation, 1)
97	rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
98	rc.attach(ac)
99	h.session.installRenderCoordinator(rc)
100	before := h.snapshot()
101
102	result := sendCommand(t, h.daemon, ports.CommandRequest{
103		Slug:          "consume-or-expel-pane-left",
104		Self:          true,
105		TargetSession: "work",
106		TargetTab:     h.tab.stableID,
107		TargetPane:    h.panes["pane-1"].stableID,
108	})
109
110	require.True(t, result.OK, result.Text)
111	require.Empty(t, result.Output)
112	require.Equal(t, before, h.snapshot())
113	require.Empty(t, h.daemon.notices.history())
114	requireNoInvalidation(t, invalidations)
115}
116
117type actionRunnerSpy struct {
118	requests []daemonActionRequest
119	err      error
120}
121
122func (s *actionRunnerSpy) Run(request daemonActionRequest) error {
123	s.requests = append(s.requests, request)
124	return s.err
125}
126
127func TestHandleCommandTimesOutWithRequestGeneration(t *testing.T) {
128	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
129	clock := &signalClock{timers: make(chan *signalTimer, 1)}
130	d := newTestDaemon(t, factory, clock)
131	addControlSession(d, "work", "t_work", "p_work")
132	frame := commandFrame(t, ports.CommandRequest{
133		RequestID: 17, Slug: "new-tab", TargetSession: "work",
134	})
135	tr, sends, releaseConn := newConn(t, frame)
136	defer releaseConn()
137	done := make(chan error, 1)
138	go func() {
139		done <- d.handleCommand(tr, frame)
140	}()
141	<-factory.entered
142	timer := <-clock.timers
143	require.Equal(t, CommandRequestTimeout, timer.duration)
144	timer.ch <- time.Time{}
145	result := awaitCommandResult(t, sends)
146	require.Equal(t, uint64(17), result.RequestID)
147	require.Equal(t, ports.ErrInternal, result.Code)
148	require.Equal(t, ErrCommandRequestTimeout.Error(), result.Text)
149	require.NoError(t, <-done)
150	close(factory.release)
151}
152
153func TestHandleCommandResponseSendTimeoutClosesTransport(t *testing.T) {
154	clock := &signalClock{timers: make(chan *signalTimer, 1)}
155	d := newTestDaemon(t, nil, clock)
156	tr := newBlockingControlSendTransport()
157	frame := commandFrame(t, ports.CommandRequest{RequestID: 41, Attached: true})
158	done := make(chan error, 1)
159	go func() { done <- d.handleCommand(tr, frame) }()
160
161	<-tr.started
162	timer := <-clock.timers
163	timer.ch <- time.Time{}
164	select {
165	case err := <-done:
166		require.ErrorIs(t, err, errSendTimedOut)
167	case <-time.After(testWaitTimeout):
168		t.Fatal("bounded control response did not return")
169	}
170	select {
171	case <-tr.closed:
172	default:
173		t.Fatal("timed-out control response did not close its transport")
174	}
175}
176
177func TestHandleCommandAttachedRejectionReturnsSendFailure(t *testing.T) {
178	d := newTestDaemon(t, nil, stubClock{})
179	var logs bytes.Buffer
180	d.log = slog.New(slog.NewTextHandler(&logs, nil))
181	sendErr := errors.New("command result send failed")
182	tr := &commandSendErrorTransport{err: sendErr}
183
184	err := d.handleCommand(tr, commandFrame(t, ports.CommandRequest{
185		RequestID: 41,
186		Attached:  true,
187	}))
188
189	require.ErrorIs(t, err, sendErr)
190	require.Contains(t, logs.String(), "command response send failed")
191	require.True(t, tr.closed, "the failed one-shot transport must still close")
192}
193
194func TestHandleCommandRejectsVersionBeforeDecodeOrDispatch(t *testing.T) {
195	// The version prefix is valid and mismatched, while the remainder is not a
196	// decodable request. The daemon must still return ErrVersionMismatch.
197	frame := ports.Frame{Type: ports.MsgCommand, Payload: []byte{0, byte(ports.ProtocolVersion + 1)}}
198	tr, sends, _ := newConn(t, frame)
199
200	d := newTestDaemon(t, nil, stubClock{})
201	require.NoError(t, d.handleCommand(tr, frame))
202
203	result := awaitCommandResult(t, sends)
204	require.False(t, result.OK)
205	require.Equal(t, ports.ErrVersionMismatch, result.Code)
206}
207
208func TestHandleConnRoutesCommand(t *testing.T) {
209	d := newTestDaemon(t, nil, stubClock{})
210	request := ports.CommandRequest{Version: ports.ProtocolVersion, Slug: "list-sessions"}
211	payload, err := ports.MarshalCommandRequest(request)
212	require.NoError(t, err)
213	frame := ports.Frame{Type: ports.MsgCommand, Payload: payload}
214	tr, sends, _ := newConn(t, frame)
215
216	d.handleConn(tr)
217
218	result := awaitCommandResult(t, sends)
219	require.True(t, result.OK, result.Text)
220}
221
222func TestHandleCommandDispatchAndTargetErrors(t *testing.T) {
223	tests := []struct {
224		name    string
225		arrange func(*Daemon)
226		request ports.CommandRequest
227		code    uint16
228	}{
229		{name: "unknown slug", request: ports.CommandRequest{Slug: "no-such"}, code: ports.ErrUnknownCommand},
230		{name: "non-scriptable command", request: ports.CommandRequest{Slug: "session-picker"}, code: ports.ErrNotScriptable},
231		{name: "no sessions", request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrNoSuchTarget},
232		{name: "ambiguous sessions", arrange: func(d *Daemon) {
233			addControlSession(d, "one", "t_one", "p_one")
234			addControlSession(d, "two", "t_two", "p_two")
235		}, request: ports.CommandRequest{Slug: "split-right"}, code: ports.ErrAmbiguousTarget},
236		{name: "missing explicit session", arrange: func(d *Daemon) {
237			addControlSession(d, "work", "t_work", "p_work")
238		}, request: ports.CommandRequest{Slug: "new-tab", TargetSession: "missing"}, code: ports.ErrNoSuchTarget},
239		{name: "missing stable IDs", arrange: func(d *Daemon) {
240			addControlSession(d, "work", "t_work", "p_work")
241		}, request: ports.CommandRequest{Slug: "split-right", TargetTab: "t_nope", TargetPane: "p_nope"}, code: ports.ErrNoSuchTarget},
242		{name: "duplicate stable IDs are ambiguous", arrange: func(d *Daemon) {
243			addControlSession(d, "one", "t_shared", "p_shared")
244			addControlSession(d, "two", "t_shared", "p_shared")
245		}, request: ports.CommandRequest{Slug: "toast", Args: []string{"hello"}, TargetTab: "t_shared", TargetPane: "p_shared"}, code: ports.ErrAmbiguousTarget},
246		{name: "self requires both IDs", arrange: func(d *Daemon) {
247			addControlSession(d, "work", "t_work", "p_work")
248		}, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work"}, code: ports.ErrNoSuchTarget},
249		{name: "self rejects pane from another tab", arrange: func(d *Daemon) {
250			sess := addControlSession(d, "work", "t_work", "p_work")
251			other := newTabWithStableID("t_other", "p_other", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
252			other.ctx, other.cancel = context.WithCancel(d.serveCtx)
253			sess.tabs = append(sess.tabs, other)
254		}, request: ports.CommandRequest{Slug: "split-right", Self: true, TargetSession: "work", TargetTab: "t_work", TargetPane: "p_other"}, code: ports.ErrNoSuchTarget},
255	}
256
257	for _, tt := range tests {
258		t.Run(tt.name, func(t *testing.T) {
259			d := newTestDaemon(t, nil, stubClock{})
260			if tt.arrange != nil {
261				tt.arrange(d)
262			}
263			result := sendCommand(t, d, tt.request)
264			require.False(t, result.OK)
265			require.Equal(t, tt.code, result.Code)
266		})
267	}
268}
269
270func TestHandleCommandResolvesStaleSessionNameByStableIDs(t *testing.T) {
271	d := newTestDaemon(t, nil, stubClock{})
272	sess := addControlSession(d, "renamed", "t_work", "p_work")
273
274	result := sendCommand(t, d, ports.CommandRequest{
275		Slug: "toast", Args: []string{"hello"},
276		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
277	})
278
279	require.True(t, result.OK, result.Text)
280	require.Equal(t, uint64(1), sess.mruAt.Load())
281}
282
283func TestHandleCommandStableIDsResolveSessionWithoutSelectingTab(t *testing.T) {
284	d := newTestDaemon(t, nil, stubClock{})
285	sess := addControlSession(d, "work", "t_first", "p_first")
286	first := sess.tabs[0]
287	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
288	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
289	sess.mu.Lock()
290	sess.tabs = append(sess.tabs, second)
291	selectTestAttachmentTabLocked(sess, 0)
292	sess.mu.Unlock()
293
294	result := sendCommand(t, d, ports.CommandRequest{
295		Slug: "rename-tab", Args: []string{"targeted"}, TargetSession: "work",
296		TargetTab: "t_second", TargetPane: "p_second",
297	})
298
299	require.True(t, result.OK, result.Text)
300	first.mu.Lock()
301	require.Equal(t, "targeted", first.name)
302	first.mu.Unlock()
303	second.mu.Lock()
304	require.Empty(t, second.name)
305	second.mu.Unlock()
306	sess.mu.Lock()
307	require.Zero(t, testAttachmentTabIndexLocked(sess))
308	sess.mu.Unlock()
309}
310
311func TestHandleCommandStableIDsDoNotRedirectSplitFromCurrentFocus(t *testing.T) {
312	factory := &controlPTYFactory{}
313	d := newTestDaemon(t, factory, stubClock{})
314	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
315	sess := addControlSession(d, "work", "t_active", "p_active")
316	active := sess.tabs[0]
317	originFocus := active.tree.Focus
318	origin := active.focusedPane()
319	second := newTabWithStableID("t_origin", "p_origin", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
320	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
321	sess.mu.Lock()
322	sess.tabs = append(sess.tabs, second)
323	selectTestAttachmentTabLocked(sess, 0)
324	sess.mu.Unlock()
325
326	result := sendCommand(t, d, ports.CommandRequest{
327		Slug: "split-right", TargetSession: "work",
328		TargetTab: "t_origin", TargetPane: "p_origin",
329	})
330
331	require.True(t, result.OK, result.Text)
332	sess.mu.Lock()
333	activeIndex := testAttachmentTabIndexLocked(sess)
334	sess.mu.Unlock()
335	require.Zero(t, activeIndex)
336	active.mu.Lock()
337	activePaneCount := len(active.panes)
338	activeFocus := active.tree.Focus
339	originRetained := active.panes[origin.id] == origin
340	active.mu.Unlock()
341	require.Equal(t, 2, activePaneCount, "split must mutate the daemon-focused tab")
342	require.NotEqual(t, originFocus, activeFocus, "split must focus the new pane beside the daemon-focused pane")
343	require.True(t, originRetained)
344	second.mu.Lock()
345	secondPaneCount := len(second.panes)
346	second.mu.Unlock()
347	require.Equal(t, 1, secondPaneCount, "stable IDs are only a session locator")
348}
349
350func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
351	factory := &controlPTYFactory{}
352	d := newTestDaemon(t, factory, stubClock{})
353	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
354	sess := addControlSession(d, "work", "t_active", "p_active")
355	active := sess.tabs[0]
356	invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
357	invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
358	sess.mu.Lock()
359	sess.tabs = append(sess.tabs, invoking)
360	selectTestAttachmentTabLocked(sess, 0)
361	sess.mu.Unlock()
362
363	rename := sendCommand(t, d, ports.CommandRequest{
364		Slug: "rename-tab", Args: []string{"invoking"}, Self: true,
365		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
366	})
367	require.True(t, rename.OK, rename.Text)
368	active.mu.Lock()
369	require.Empty(t, active.name)
370	active.mu.Unlock()
371	invoking.mu.Lock()
372	require.Equal(t, "invoking", invoking.name)
373	invoking.mu.Unlock()
374
375	split := sendCommand(t, d, ports.CommandRequest{
376		Slug: "split-right", Self: true,
377		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
378	})
379	require.True(t, split.OK, split.Text)
380	active.mu.Lock()
381	require.Len(t, active.panes, 1)
382	active.mu.Unlock()
383	invoking.mu.Lock()
384	require.Len(t, invoking.panes, 2)
385	beforeFocus := invoking.tree.Focus
386	beforeGeneration := invoking.layoutGeneration
387	invoking.mu.Unlock()
388
389	grow := sendCommand(t, d, ports.CommandRequest{
390		Slug: "grow-pane-width", Self: true,
391		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
392	})
393	require.True(t, grow.OK, grow.Text)
394	invoking.mu.Lock()
395	require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted resize must not refocus the tab")
396	require.Equal(t, beforeGeneration+1, invoking.layoutGeneration)
397	require.NotZero(t, invoking.tree.Root.Children[0].Weight, "resize must update the invoking tab's shares")
398	invoking.mu.Unlock()
399	active.mu.Lock()
400	require.Len(t, active.panes, 1, "--self resize must not mutate the active tab")
401	active.mu.Unlock()
402
403	equalize := sendCommand(t, d, ports.CommandRequest{
404		Slug: "equalize-panes", Self: true,
405		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
406	})
407	require.True(t, equalize.OK, equalize.Text)
408	invoking.mu.Lock()
409	require.Equal(t, beforeFocus, invoking.tree.Focus, "targeted equalize must not refocus the tab")
410	for _, child := range invoking.tree.Root.Children {
411		require.Zero(t, child.Weight, "equalize must clear each target-tab share")
412	}
413	invoking.mu.Unlock()
414}
415
416func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
417	targets := []struct {
418		name    string
419		request func() ports.CommandRequest
420	}{
421		{"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }},
422		{"unique", func() ports.CommandRequest { return ports.CommandRequest{} }},
423	}
424	for _, target := range targets {
425		t.Run(target.name, func(t *testing.T) {
426			for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
427				t.Run(slug, func(t *testing.T) {
428					factory := &controlPTYFactory{}
429					d := newTestDaemon(t, factory, stubClock{})
430					t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
431					sess := addControlSession(d, "work", "t_work", "p_work")
432					require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
433					require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
434					tb := testAttachmentTab(sess)
435					tb.mu.Lock()
436					generation := tb.layoutGeneration
437					tb.mu.Unlock()
438					sess.snapEligible.Store(true)
439					sess.snapshotMu.Lock()
440					snapshotGeneration := sess.snapshotGeneration
441					sess.snapshotMu.Unlock()
442					request := target.request()
443					request.Slug = slug
444					result := sendCommand(t, d, request)
445					require.True(t, result.OK, result.Text)
446					require.Empty(t, result.Output)
447					tb.mu.Lock()
448					require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
449					tb.mu.Unlock()
450					sess.snapshotMu.Lock()
451					require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
452					sess.snapshotMu.Unlock()
453					sess.mu.Lock()
454					require.Empty(t, sess.snapshotAttachmentsLocked(), "one-shots must work headless")
455					sess.mu.Unlock()
456				})
457			}
458		})
459	}
460}
461
462func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
463	factory := &controlPTYFactory{}
464	d := newTestDaemon(t, factory, stubClock{})
465	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
466	sess := addControlSession(d, "work", "t_active", "p_active")
467	require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
468	active := sess.tabs[0]
469	locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
470	locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
471	sess.mu.Lock()
472	sess.tabs = append(sess.tabs, locator)
473	selectTestAttachmentTabLocked(sess, 0)
474	sess.mu.Unlock()
475	active.mu.Lock()
476	before := active.layoutGeneration
477	active.mu.Unlock()
478
479	result := sendCommand(t, d, ports.CommandRequest{
480		Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
481	})
482	require.True(t, result.OK, result.Text)
483	active.mu.Lock()
484	require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
485	active.mu.Unlock()
486	locator.mu.Lock()
487	require.Zero(t, locator.layoutGeneration)
488	locator.mu.Unlock()
489	sess.mu.Lock()
490	require.Zero(t, testAttachmentTabIndexLocked(sess))
491	sess.mu.Unlock()
492}
493
494func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
495	factory := &controlPTYFactory{}
496	d := newTestDaemon(t, factory, stubClock{})
497	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
498	sess := addControlSession(d, "work", "t_work", "p_work")
499	require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
500	tb := testAttachmentTab(sess)
501	tb.mu.Lock()
502	tb.size.Cols = 41
503	tb.bumpLayoutGenerationLocked()
504	tb.mu.Unlock()
505
506	result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
507	require.False(t, result.OK)
508	require.Equal(t, ports.ErrNoSuchTarget, result.Code)
509	require.Equal(t, "pane cannot be resized further", result.Text)
510}
511
512func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
513	d := newTestDaemon(t, nil, stubClock{})
514	sess := addControlSession(d, "work", "t_active", "p_active")
515	invoking := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
516	invoking.ctx, invoking.cancel = context.WithCancel(d.serveCtx)
517	sess.mu.Lock()
518	sess.tabs = append(sess.tabs, invoking)
519	selectTestAttachmentTabLocked(sess, 0)
520	sess.mu.Unlock()
521
522	result := sendCommand(t, d, ports.CommandRequest{
523		Slug: "list-panes", Self: true, JSON: true,
524		TargetSession: "work", TargetTab: "t_invoking", TargetPane: "p_invoking",
525	})
526	require.True(t, result.OK, result.Text)
527	require.Contains(t, result.Output, "p_invoking")
528	require.NotContains(t, result.Output, "p_active")
529}
530
531func TestHandleCommandRenameSessionRejectsInvalidNameAsCommandArgs(t *testing.T) {
532	d := newTestDaemon(t, nil, stubClock{})
533	addControlSession(d, "work", "t_work", "p_work")
534
535	result := sendCommand(t, d, ports.CommandRequest{
536		Slug: "rename-session", Args: []string{"invalid name"}, TargetSession: "work",
537	})
538
539	require.False(t, result.OK)
540	require.Equal(t, ports.ErrInvalidCommandArgs, result.Code)
541}
542
543func TestCloseCommandsReportMutationOutcome(t *testing.T) {
544	t.Run("stale close tab target fails", func(t *testing.T) {
545		d := newTestDaemon(t, nil, stubClock{})
546		sess := addControlSession(d, "work", "t_work", "p_work")
547		stale := newTabWithStableID("t_stale", "p_stale", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
548		cmd, ok := command.BySlug("close-tab")
549		require.True(t, ok)
550
551		result := d.runControl(cmd, controlExec{
552			d: d, sess: sess, tab: stale,
553			target: daemonActionTarget{session: sess, tab: stale},
554		}, ports.CommandRequest{Slug: "close-tab"})
555
556		require.False(t, result.OK)
557		require.Equal(t, ports.ErrInternal, result.Code)
558		require.Len(t, sess.tabs, 1, "a stale close must retain the live tab")
559	})
560
561	t.Run("failed final pane close fails and retains session", func(t *testing.T) {
562		d := newTestDaemon(t, &controlPTYFactory{}, stubClock{})
563		d.snapsEnabled = true
564		d.snapshotRepository = portsmocks.NewMockSnapshotRepository(t)
565		sess := addControlSession(d, "work", "t_work", "p_work")
566		sess.mu.Lock()
567		sess.ephemeral = false
568		sess.mu.Unlock()
569		sess.snapEligible.Store(true)
570
571		result := sendCommand(t, d, ports.CommandRequest{Slug: "close-pane", TargetSession: "work"})
572
573		require.False(t, result.OK)
574		require.Equal(t, ports.ErrInternal, result.Code)
575		d.mu.Lock()
576		require.Same(t, sess, d.sessions[sess.id], "failed final-pane close must retain the session")
577		d.mu.Unlock()
578		sess.mu.Lock()
579		require.Len(t, sess.tabs, 1)
580		sess.mu.Unlock()
581	})
582
583	t.Run("successful close tab remains successful", func(t *testing.T) {
584		d := newTestDaemon(t, nil, stubClock{})
585		sess := addControlSession(d, "work", "t_first", "p_first")
586		second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
587		second.ctx, second.cancel = context.WithCancel(d.serveCtx)
588		sess.mu.Lock()
589		sess.tabs = append(sess.tabs, second)
590		selectTestAttachmentTabLocked(sess, 1)
591		sess.mu.Unlock()
592
593		result := sendCommand(t, d, ports.CommandRequest{Slug: "close-tab", TargetSession: "work"})
594
595		require.True(t, result.OK, result.Text)
596		sess.mu.Lock()
597		require.Len(t, sess.tabs, 1)
598		require.Equal(t, "t_second", sess.tabs[0].stableID)
599		sess.mu.Unlock()
600	})
601}
602
603func TestHandleCommandHeadlessMutations(t *testing.T) {
604	tests := []struct {
605		name   string
606		slug   string
607		args   []string
608		verify func(*testing.T, *Daemon, *session)
609	}{
610		{name: "split focused pane", slug: "split-right", verify: func(t *testing.T, _ *Daemon, sess *session) {
611			tb := testAttachmentTab(sess)
612			tb.mu.Lock()
613			defer tb.mu.Unlock()
614			require.Len(t, tb.panes, 2)
615		}},
616		{name: "create tab from retained viewport", slug: "new-tab", verify: func(t *testing.T, _ *Daemon, sess *session) {
617			sess.mu.Lock()
618			defer sess.mu.Unlock()
619			require.Len(t, sess.tabs, 2)
620			require.Equal(t, 0, testAttachmentTabIndexLocked(sess))
621		}},
622		{name: "rename tab", slug: "rename-tab", args: []string{"logs"}, verify: func(t *testing.T, _ *Daemon, sess *session) {
623			tb := testAttachmentTab(sess)
624			tb.mu.Lock()
625			defer tb.mu.Unlock()
626			require.Equal(t, "logs", tb.name)
627		}},
628	}
629	for _, tt := range tests {
630		t.Run(tt.name, func(t *testing.T) {
631			factory := &controlPTYFactory{}
632			d := newTestDaemon(t, factory, stubClock{})
633			t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
634			sess := addControlSession(d, "work", "t_work", "p_work")
635
636			result := sendCommand(t, d, ports.CommandRequest{Slug: tt.slug, Args: tt.args, TargetSession: "work"})
637
638			require.True(t, result.OK, result.Text)
639			tt.verify(t, d, sess)
640		})
641	}
642}
643
644func TestHandleCommandNewSessionInheritsHeadlessIdentityAndViewport(t *testing.T) {
645	factory := &controlPTYFactory{}
646	d := newTestDaemon(t, factory, stubClock{})
647	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
648	source := addControlSession(d, "work", "t_work", "p_work")
649	source.mu.Lock()
650	source.cwd = "/tmp/work"
651	source.env = []string{"INHERITED=yes"}
652	source.terminal = terminalEnv{TrueColor: true}
653	source.tabs[0].size = domain.Size{Cols: 118, Rows: 38}
654	source.mu.Unlock()
655
656	result := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
657
658	require.True(t, result.OK, result.Text)
659	d.mu.Lock()
660	created := d.findByNameLocked("scripted")
661	d.mu.Unlock()
662	require.NotNil(t, created)
663	created.mu.Lock()
664	require.Empty(t, created.snapshotAttachmentsLocked())
665	require.Equal(t, "/tmp/work", created.cwd)
666	require.Equal(t, []string{"INHERITED=yes"}, created.env)
667	require.True(t, created.terminal.TrueColor)
668	tb := created.tabs[0]
669	created.mu.Unlock()
670	tb.mu.Lock()
671	require.Equal(t, domain.Size{Cols: 118, Rows: 38}, tb.size)
672	tb.mu.Unlock()
673	require.Empty(t, source.snapshotAttachments())
674
675	taken := sendCommand(t, d, ports.CommandRequest{Slug: "new-session", Args: []string{"scripted"}, TargetSession: "work"})
676	require.False(t, taken.OK)
677	require.Equal(t, ports.ErrNameTaken, taken.Code)
678}
679
680func TestHandleCommandValidatesToastAndQueuesForDetachedSession(t *testing.T) {
681	d := newTestDaemon(t, nil, stubClock{})
682	sess := addControlSession(d, "work", "t_work", "p_work")
683
684	bad := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "loud", "hello"}, TargetSession: "work"})
685	require.False(t, bad.OK)
686	require.Equal(t, ports.ErrInvalidCommandArgs, bad.Code)
687
688	good := sendCommand(t, d, ports.CommandRequest{Slug: "toast", Args: []string{"-l", "warn", "hello"}, TargetSession: "work"})
689	require.True(t, good.OK, good.Text)
690	d.notices.mu.Lock()
691	require.Len(t, d.notices.pending, 1)
692	require.Equal(t, domain.NoticeUser, d.notices.pending[0].Code)
693	require.Equal(t, sess.id, d.notices.pending[0].SessionID)
694	d.notices.mu.Unlock()
695}
696
697func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
698	d := newTestDaemon(t, nil, stubClock{})
699	sess := addControlSession(d, "work", "t_work", "p_work")
700	sess.tabs[0].name = "shell"
701
702	sessions := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
703	require.True(t, sessions.OK, sessions.Text)
704	require.Contains(t, sessions.Output, "NAME\tSTATE\tTABS\tATTACHED\tACTIVE")
705	require.Contains(t, sessions.Output, "work\tephemeral\t1\tfalse\ttrue")
706
707	tabs := sendCommand(t, d, ports.CommandRequest{Slug: "list-tabs", TargetSession: "work"})
708	require.True(t, tabs.OK, tabs.Text)
709	require.Contains(t, tabs.Output, "0\tt_work\tshell\t1\ttrue")
710
711	panes := sendCommand(t, d, ports.CommandRequest{Slug: "list-panes", TargetSession: "work", JSON: true})
712	require.True(t, panes.OK, panes.Text)
713	var decoded []map[string]any
714	require.NoError(t, json.Unmarshal([]byte(panes.Output), &decoded))
715	require.Len(t, decoded, 1)
716	require.Equal(t, "p_work", decoded[0]["id"])
717	require.Equal(t, "/tmp/work", decoded[0]["cwd"])
718	require.Equal(t, true, decoded[0]["focused"])
719}
720
721func TestRemoteCatalogJSONOutput(t *testing.T) {
722	d := newTestDaemon(t, nil, stubClock{})
723	work := addControlSession(d, "work", "t_work", "p_work")
724	work.ephemeral = false
725	work.registerAttachment(&attachedClient{})
726	work.mruAt.Store(2)
727	tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
728	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
729	work.mu.Lock()
730	work.tabs = append(work.tabs, tb)
731	work.mu.Unlock()
732
733	build := addControlSession(d, "build", "t_build", "p_build")
734	build.ephemeral = true
735	build.mruAt.Store(1)
736
737	d.mu.Lock()
738	d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionDown}
739	d.mu.Unlock()
740
741	listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
742	require.True(t, listBefore.OK, listBefore.Text)
743
744	missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"})
745	require.False(t, missingJSON.OK)
746	require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code)
747
748	withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true})
749	require.False(t, withArgs.OK)
750	require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code)
751
752	result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
753	require.True(t, result.OK, result.Text)
754	require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated")
755
756	var catalog ports.RemoteCatalog
757	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
758	require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion)
759
760	raw := map[string]json.RawMessage{}
761	require.NoError(t, json.Unmarshal([]byte(result.Output), &raw))
762	require.Len(t, raw, 2)
763	_, hasProtocol := raw["protocol_version"]
764	_, hasSessions := raw["sessions"]
765	require.True(t, hasProtocol && hasSessions)
766
767	require.Equal(t, []ports.RemoteCatalogSession{
768		{Name: "build", State: "up", Ephemeral: true, Tabs: 1, Attached: false},
769		{Name: "work", State: "up", Ephemeral: false, Tabs: 2, Attached: true},
770	}, catalog.Sessions)
771
772	for _, session := range catalog.Sessions {
773		require.Equal(t, "up", session.State)
774		require.NotEqual(t, "old", session.Name)
775	}
776
777	listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"})
778	require.True(t, listAfter.OK, listAfter.Text)
779	require.Equal(t, listBefore.Output, listAfter.Output)
780	require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t")
781	require.NotContains(t, listAfter.Output, "old")
782}
783
784func TestRemoteCatalogTabCountSaturates(t *testing.T) {
785	d := newTestDaemon(t, nil, stubClock{})
786	sess := addControlSession(d, "work", "t_work", "p_work")
787	sess.mu.Lock()
788	base := sess.tabs[0]
789	sess.tabs = make([]*tab, int(^uint16(0))+1)
790	for i := range sess.tabs {
791		sess.tabs[i] = base
792	}
793	sess.mu.Unlock()
794
795	result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
796	require.True(t, result.OK, result.Text)
797	var catalog ports.RemoteCatalog
798	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
799	require.Equal(t, []ports.RemoteCatalogSession{{
800		Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
801	}}, catalog.Sessions)
802}
803
804func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
805	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
806	d := newTestDaemon(t, factory, stubClock{})
807	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
808	sess := addControlSession(d, "work", "t_active", "p_active")
809	target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
810	target.ctx, target.cancel = context.WithCancel(d.serveCtx)
811	sess.mu.Lock()
812	sess.tabs = append(sess.tabs, target)
813	selectTestAttachmentTabLocked(sess, 0)
814	sess.mu.Unlock()
815	original := target.tree.Focus
816
817	firstFrame := commandFrame(t, ports.CommandRequest{
818		Slug: "split-right", Self: true, TargetSession: "work",
819		TargetTab: "t_invoking", TargetPane: "p_invoking",
820	})
821	firstTransport, firstSends, _ := newConn(t, firstFrame)
822	firstDone := make(chan struct{})
823	go func() {
824		if err := d.handleCommand(firstTransport, firstFrame); err != nil {
825			t.Errorf("handle first command: %v", err)
826		}
827		close(firstDone)
828	}()
829	select {
830	case <-factory.entered:
831	case <-time.After(2 * time.Second):
832		t.Fatal("first command did not enter its blocked action")
833	}
834
835	secondFrame := commandFrame(t, ports.CommandRequest{
836		Slug: "focus-pane-right", Self: true, TargetSession: "work",
837		TargetTab: "t_invoking", TargetPane: "p_invoking",
838	})
839	secondTransport, secondSends, _ := newConn(t, secondFrame)
840	secondDone := make(chan struct{})
841	go func() {
842		if err := d.handleCommand(secondTransport, secondFrame); err != nil {
843			t.Errorf("handle second command: %v", err)
844		}
845		close(secondDone)
846	}()
847	select {
848	case <-secondDone:
849		t.Fatal("second command bypassed serialization")
850	case <-time.After(100 * time.Millisecond):
851	}
852	close(factory.release)
853	<-firstDone
854	<-secondDone
855	require.True(t, awaitCommandResult(t, firstSends).OK)
856	require.True(t, awaitCommandResult(t, secondSends).OK)
857
858	sess.mu.Lock()
859	require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
860	sess.mu.Unlock()
861	target.mu.Lock()
862	defer target.mu.Unlock()
863	require.Len(t, target.panes, 2)
864	require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
865}
866
867func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
868	d := newTestDaemon(t, nil, stubClock{})
869	source := addControlSession(d, "work", "t_work", "p_work")
870	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
871	tb := source.tabs[0]
872	tb.mu.Lock()
873	pane := tb.focusedPane()
874	tb.mu.Unlock()
875	ac := &attachedClient{tr: &closeTrackingTransport{}}
876	ac.setSession(source)
877
878	source.mu.Lock()
879	require.True(t, source.registerAttachmentLocked(ac))
880	target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
881	require.True(t, source.unregisterAttachmentLocked(ac))
882	source.mu.Unlock()
883
884	err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
885	require.ErrorIs(t, err, errMoveStaleTarget)
886}
887
888func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(t *testing.T) {
889	d := newTestDaemon(t, nil, stubClock{})
890	source := addControlSession(d, "work", "t_active", "p_active")
891	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
892	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
893	publishTiledPaneOwners(source, inactive)
894	source.mu.Lock()
895	source.tabs = append(source.tabs, inactive)
896	selectTestAttachmentTabLocked(source, 0)
897	source.mu.Unlock()
898	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
899
900	result := sendCommand(t, d, ports.CommandRequest{
901		Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "work",
902	})
903	require.True(t, result.OK, result.Text)
904
905	source.mu.Lock()
906	require.Len(t, source.tabs, 1)
907	require.Equal(t, "t_inactive", source.tabs[0].stableID)
908	source.mu.Unlock()
909	destination.mu.Lock()
910	destTab := destination.tabs[0]
911	destination.mu.Unlock()
912	destTab.mu.Lock()
913	var moved *pane
914	for _, candidate := range destTab.panes {
915		if candidate.stableID == "p_active" {
916			moved = candidate
917		}
918	}
919	require.NotNil(t, moved)
920	destTab.mu.Unlock()
921}
922
923func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
924	d := newTestDaemon(t, nil, stubClock{})
925	source := addControlSession(d, "work", "t_active", "p_active")
926	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
927	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
928	publishTiledPaneOwners(source, inactive)
929	source.mu.Lock()
930	source.tabs = append(source.tabs, inactive)
931	selectTestAttachmentTabLocked(source, 0)
932	source.mu.Unlock()
933	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
934
935	result := sendCommand(t, d, ports.CommandRequest{
936		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
937		TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
938	})
939	require.True(t, result.OK, result.Text)
940
941	active := source.tabs[0]
942	active.mu.Lock()
943	require.Len(t, active.panes, 1)
944	require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
945	active.mu.Unlock()
946	destination.mu.Lock()
947	destTab := destination.tabs[0]
948	destination.mu.Unlock()
949	destTab.mu.Lock()
950	var moved *pane
951	for _, candidate := range destTab.panes {
952		if candidate.stableID == "p_inactive" {
953			moved = candidate
954		}
955	}
956	require.NotNil(t, moved)
957	destTab.mu.Unlock()
958}
959
960func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
961	d := newTestDaemon(t, nil, stubClock{})
962	source := addControlSession(d, "work", "t_first", "p_first")
963	publishTiledPaneOwners(source, source.tabs[0])
964	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
965	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
966	publishTiledPaneOwners(source, second)
967	source.mu.Lock()
968	source.tabs = append(source.tabs, second)
969	selectTestAttachmentTabLocked(source, 0)
970	source.mu.Unlock()
971	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
972
973	result := sendCommand(t, d, ports.CommandRequest{
974		Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
975		TargetTab: "t_second", TargetPane: "p_second",
976	})
977	require.True(t, result.OK, result.Text)
978
979	source.mu.Lock()
980	require.Len(t, source.tabs, 1)
981	require.Equal(t, "t_second", source.tabs[0].stableID)
982	source.mu.Unlock()
983	destination.mu.Lock()
984	require.Len(t, destination.tabs, 2)
985	require.Equal(t, "t_first", destination.tabs[1].stableID)
986	destination.mu.Unlock()
987}
988
989func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
990	d := newTestDaemon(t, nil, stubClock{})
991	source := addControlSession(d, "work", "t_first", "p_first")
992	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
993	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
994	publishTiledPaneOwners(source, second)
995	source.mu.Lock()
996	source.tabs = append(source.tabs, second)
997	selectTestAttachmentTabLocked(source, 0)
998	source.mu.Unlock()
999	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1000
1001	result := sendCommand(t, d, ports.CommandRequest{
1002		Slug: "move-tab", Args: []string{"dest"}, Self: true,
1003		TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1004	})
1005	require.True(t, result.OK, result.Text)
1006
1007	source.mu.Lock()
1008	require.Len(t, source.tabs, 1)
1009	require.Equal(t, "t_first", source.tabs[0].stableID)
1010	source.mu.Unlock()
1011	destination.mu.Lock()
1012	require.Len(t, destination.tabs, 2)
1013	require.Equal(t, "t_second", destination.tabs[1].stableID)
1014	destination.mu.Unlock()
1015}
1016
1017func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1018	tests := []struct {
1019		name    string
1020		arrange func(*Daemon) ports.CommandRequest
1021		code    uint16
1022		text    string
1023	}{
1024		{
1025			name: "move-pane missing destination session",
1026			arrange: func(d *Daemon) ports.CommandRequest {
1027				addControlSession(d, "work", "t_work", "p_work")
1028				return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1029			},
1030			code: ports.ErrNoSuchTarget,
1031			text: "move request is invalid",
1032		},
1033		{
1034			name: "move-pane unknown destination session",
1035			arrange: func(d *Daemon) ports.CommandRequest {
1036				addControlSession(d, "work", "t_work", "p_work")
1037				return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1038			},
1039			code: ports.ErrNoSuchTarget,
1040			text: "move target is no longer available",
1041		},
1042		{
1043			name: "move-pane destination tab not in session",
1044			arrange: func(d *Daemon) ports.CommandRequest {
1045				addControlSession(d, "work", "t_work", "p_work")
1046				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1047				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1048			},
1049			code: ports.ErrNoSuchTarget,
1050			text: "move target is no longer available",
1051		},
1052		{
1053			name: "move-pane same source tab",
1054			arrange: func(d *Daemon) ports.CommandRequest {
1055				addControlSession(d, "work", "t_work", "p_work")
1056				return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1057			},
1058			code: ports.ErrNoSuchTarget,
1059			text: "move request is invalid",
1060		},
1061		{
1062			name: "move-tab same session",
1063			arrange: func(d *Daemon) ports.CommandRequest {
1064				addControlSession(d, "work", "t_work", "p_work")
1065				return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1066			},
1067			code: ports.ErrNoSuchTarget,
1068			text: "move request is invalid",
1069		},
1070		{
1071			name: "move-tab stopping destination",
1072			arrange: func(d *Daemon) ports.CommandRequest {
1073				addControlSession(d, "work", "t_work", "p_work")
1074				destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1075				destination.teardownMu.Lock()
1076				destination.teardownActive = true
1077				destination.teardownMu.Unlock()
1078				return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1079			},
1080			code: ports.ErrNoSuchTarget,
1081			text: "move target is no longer available",
1082		},
1083		{
1084			name: "move-pane too few args",
1085			arrange: func(d *Daemon) ports.CommandRequest {
1086				addControlSession(d, "work", "t_work", "p_work")
1087				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1088			},
1089			code: ports.ErrInvalidCommandArgs,
1090		},
1091		{
1092			name: "move-pane too many args",
1093			arrange: func(d *Daemon) ports.CommandRequest {
1094				addControlSession(d, "work", "t_work", "p_work")
1095				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1096			},
1097			code: ports.ErrInvalidCommandArgs,
1098		},
1099		{
1100			name: "move-tab missing destination",
1101			arrange: func(d *Daemon) ports.CommandRequest {
1102				addControlSession(d, "work", "t_work", "p_work")
1103				return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1104			},
1105			code: ports.ErrInvalidCommandArgs,
1106		},
1107		{
1108			name: "explicit session name without IDs remains authoritative",
1109			arrange: func(d *Daemon) ports.CommandRequest {
1110				addControlSession(d, "renamed", "t_work", "p_work")
1111				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1112				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1113			},
1114			code: ports.ErrNoSuchTarget,
1115			text: "no such session: old-name",
1116		},
1117	}
1118
1119	for _, tt := range tests {
1120		t.Run(tt.name, func(t *testing.T) {
1121			d := newTestDaemon(t, nil, stubClock{})
1122			request := tt.arrange(d)
1123			result := sendCommand(t, d, request)
1124			require.False(t, result.OK)
1125			require.Equal(t, tt.code, result.Code)
1126			if tt.text != "" {
1127				require.Equal(t, tt.text, result.Text)
1128			}
1129		})
1130	}
1131}
1132
1133func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1134	d := newTestDaemon(t, nil, stubClock{})
1135	advisory := addControlSession(d, "old-name", "t_other", "p_other")
1136	source := addControlSession(d, "renamed", "t_work", "p_work")
1137	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1138
1139	result := sendCommand(t, d, ports.CommandRequest{
1140		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1141		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1142	})
1143	require.True(t, result.OK, result.Text)
1144
1145	source.mu.Lock()
1146	require.Len(t, source.tabs, 0)
1147	source.mu.Unlock()
1148	advisory.mu.Lock()
1149	require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1150	advisory.mu.Unlock()
1151	destination.mu.Lock()
1152	destTab := destination.tabs[0]
1153	destination.mu.Unlock()
1154	destTab.mu.Lock()
1155	require.Len(t, destTab.panes, 2)
1156	destTab.mu.Unlock()
1157}
1158
1159func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1160	d := newTestDaemon(t, nil, stubClock{})
1161	source := addControlSession(d, "renamed", "t_active", "p_active")
1162	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1163	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1164	publishTiledPaneOwners(source, inactive)
1165	source.mu.Lock()
1166	source.tabs = append(source.tabs, inactive)
1167	selectTestAttachmentTabLocked(source, 1)
1168	source.mu.Unlock()
1169	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1170
1171	result := sendCommand(t, d, ports.CommandRequest{
1172		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1173		TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1174	})
1175	require.True(t, result.OK, result.Text)
1176
1177	source.mu.Lock()
1178	require.Len(t, source.tabs, 1)
1179	require.Equal(t, "t_active", source.tabs[0].stableID)
1180	source.mu.Unlock()
1181	destination.mu.Lock()
1182	destTab := destination.tabs[0]
1183	destination.mu.Unlock()
1184	destTab.mu.Lock()
1185	var moved *pane
1186	for _, candidate := range destTab.panes {
1187		if candidate.stableID == "p_inactive" {
1188			moved = candidate
1189		}
1190	}
1191	require.NotNil(t, moved)
1192	destTab.mu.Unlock()
1193}
1194
1195func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1196	d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1197	left.mu.Lock()
1198	left.name = "left"
1199	left.tabs[0].stableID = "left-moved"
1200	selectTestAttachmentTabLocked(left, 0)
1201	left.mu.Unlock()
1202
1203	right := addMoveTabTestSession(d, "right", "right-stays")
1204	rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1205	rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1206	publishTiledPaneOwners(right, rightMoved)
1207	right.mu.Lock()
1208	right.tabs = append(right.tabs, rightMoved)
1209	selectTestAttachmentTabLocked(right, 1)
1210	right.mu.Unlock()
1211
1212	leftFrame := commandFrame(t, ports.CommandRequest{
1213		Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1214	})
1215	rightFrame := commandFrame(t, ports.CommandRequest{
1216		Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1217	})
1218	leftTransport, leftSends, _ := newConn(t, leftFrame)
1219	rightTransport, rightSends, _ := newConn(t, rightFrame)
1220	start := make(chan struct{})
1221	done := make(chan struct{}, 2)
1222	go func() {
1223		<-start
1224		if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1225			t.Errorf("handle left command: %v", err)
1226		}
1227		done <- struct{}{}
1228	}()
1229	go func() {
1230		<-start
1231		if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1232			t.Errorf("handle right command: %v", err)
1233		}
1234		done <- struct{}{}
1235	}()
1236	close(start)
1237	for _, sends := range []chan ports.Frame{leftSends, rightSends} {
1238		select {
1239		case <-done:
1240			result := awaitCommandResult(t, sends)
1241			require.True(t, result.OK, result.Text)
1242		case <-time.After(5 * time.Second):
1243			t.Fatal("opposite-direction move commands deadlocked")
1244		}
1245	}
1246}
1247
1248func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1249	ctx, cancel := context.WithCancel(d.serveCtx)
1250	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1251	tb.ctx, tb.cancel = context.WithCancel(ctx)
1252	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1253		ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1254	}
1255	publishTiledPaneOwners(sess, tb)
1256	d.mu.Lock()
1257	d.sessions[sess.id] = sess
1258	d.mu.Unlock()
1259	return sess
1260}
1261
1262func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
1263	t.Helper()
1264	if request.Version == 0 {
1265		request.Version = ports.ProtocolVersion
1266	}
1267	payload, err := ports.MarshalCommandRequest(request)
1268	require.NoError(t, err)
1269	return ports.Frame{Type: ports.MsgCommand, Payload: payload}
1270}
1271
1272func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
1273	t.Helper()
1274	frame := commandFrame(t, request)
1275	tr, sends, _ := newConn(t, frame)
1276	require.NoError(t, d.handleCommand(tr, frame))
1277	return awaitCommandResult(t, sends)
1278}
1279
1280func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
1281	t.Helper()
1282	reply := awaitFrame(t, sends, ports.MsgCommandResult)
1283	result, err := ports.UnmarshalCommandResult(reply.Payload)
1284	require.NoError(t, err)
1285	return result
1286}
1287
1288type controlPTYFactory struct {
1289	mu      sync.Mutex
1290	ptys    []*quietPTY
1291	entered chan struct{}
1292	release chan struct{}
1293	once    sync.Once
1294}
1295
1296func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
1297	f.once.Do(func() {
1298		if f.entered != nil {
1299			close(f.entered)
1300			<-f.release
1301		}
1302	})
1303	pty := newQuietPTY()
1304	f.mu.Lock()
1305	f.ptys = append(f.ptys, pty)
1306	f.mu.Unlock()
1307	return pty, nil
1308}
1309
1310func (f *controlPTYFactory) close() {
1311	if f.release != nil {
1312		select {
1313		case <-f.release:
1314		default:
1315			close(f.release)
1316		}
1317	}
1318	f.mu.Lock()
1319	defer f.mu.Unlock()
1320	for _, pty := range f.ptys {
1321		_ = pty.Close()
1322	}
1323}
1324
1325type blockingControlSendTransport struct {
1326	started chan struct{}
1327	closed  chan struct{}
1328	once    sync.Once
1329}
1330
1331func newBlockingControlSendTransport() *blockingControlSendTransport {
1332	return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1333}
1334
1335func (tr *blockingControlSendTransport) Send(ports.Frame) error {
1336	tr.once.Do(func() { close(tr.started) })
1337	<-tr.closed
1338	return io.ErrClosedPipe
1339}
1340
1341func (*blockingControlSendTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1342func (tr *blockingControlSendTransport) Close() error {
1343	tr.once.Do(func() { close(tr.started) })
1344	select {
1345	case <-tr.closed:
1346	default:
1347		close(tr.closed)
1348	}
1349	return nil
1350}
1351
1352type commandSendErrorTransport struct {
1353	err    error
1354	closed bool
1355}
1356
1357func (tr *commandSendErrorTransport) Send(ports.Frame) error  { return tr.err }
1358func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1359func (tr *commandSendErrorTransport) Close() error            { tr.closed = true; return nil }
1360
1361func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1362	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1363	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1364	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1365		ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1366	}
1367	sess.mruAt.Store(1)
1368	d.mu.Lock()
1369	d.sessions[sess.id] = sess
1370	d.mu.Unlock()
1371	return sess
1372}

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