v0.3.0 99dc266

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 TestRemoteCatalogRefreshesFocusedTabTitle(t *testing.T) {
785	d := newTestDaemon(t, nil, stubClock{})
786	sess := addControlSession(d, "work", "t_work", "p_work")
787	sess.incarnation[0] = 1
788	tb := sess.tabs[0]
789	tb.name = "shell"
790	p := tb.focusedPane()
791	pty := portsmocks.NewMockPTY(t)
792	pty.EXPECT().ForegroundPgid().Return(1234, nil).Once()
793	p.pty = pty
794	d.procComm = func(pid int) (string, error) {
795		require.Equal(t, 1234, pid)
796		return "fish", nil
797	}
798
799	result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
800	require.True(t, result.OK, result.Text)
801	var catalog ports.RemoteCatalog
802	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
803	require.Equal(t, []ports.RemoteCatalogSession{{
804		LifecycleID: sess.incarnation,
805		Name:        "work",
806		State:       "up",
807		Ephemeral:   true,
808		Tabs: []ports.RemoteCatalogTab{{
809			ID:     "t_work",
810			Index:  0,
811			Name:   "shell",
812			Detail: " (fish)",
813		}},
814		ActiveTabID: "t_work",
815		LastUsedSeq: 1,
816	}}, catalog.Sessions)
817}
818
819func TestRemoteCatalogTabCountSaturates(t *testing.T) {
820	d := newTestDaemon(t, nil, stubClock{})
821	sess := addControlSession(d, "work", "t_work", "p_work")
822	sess.mu.Lock()
823	base := sess.tabs[0]
824	sess.tabs = make([]*tab, int(^uint16(0))+1)
825	for i := range sess.tabs {
826		sess.tabs[i] = base
827	}
828	sess.mu.Unlock()
829
830	result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true})
831	require.True(t, result.OK, result.Text)
832	var catalog ports.RemoteCatalog
833	require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog))
834	require.Equal(t, []ports.RemoteCatalogSession{{
835		Name: "work", State: "up", Ephemeral: true, Tabs: int(^uint16(0)), Attached: false,
836	}}, catalog.Sessions)
837}
838
839func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) {
840	factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})}
841	d := newTestDaemon(t, factory, stubClock{})
842	t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
843	sess := addControlSession(d, "work", "t_active", "p_active")
844	target := newTabWithStableID("t_invoking", "p_invoking", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
845	target.ctx, target.cancel = context.WithCancel(d.serveCtx)
846	sess.mu.Lock()
847	sess.tabs = append(sess.tabs, target)
848	selectTestAttachmentTabLocked(sess, 0)
849	sess.mu.Unlock()
850	original := target.tree.Focus
851
852	firstFrame := commandFrame(t, ports.CommandRequest{
853		Slug: "split-right", Self: true, TargetSession: "work",
854		TargetTab: "t_invoking", TargetPane: "p_invoking",
855	})
856	firstTransport, firstSends, _ := newConn(t, firstFrame)
857	firstDone := make(chan struct{})
858	go func() {
859		if err := d.handleCommand(firstTransport, firstFrame); err != nil {
860			t.Errorf("handle first command: %v", err)
861		}
862		close(firstDone)
863	}()
864	select {
865	case <-factory.entered:
866	case <-time.After(2 * time.Second):
867		t.Fatal("first command did not enter its blocked action")
868	}
869
870	secondFrame := commandFrame(t, ports.CommandRequest{
871		Slug: "focus-pane-right", Self: true, TargetSession: "work",
872		TargetTab: "t_invoking", TargetPane: "p_invoking",
873	})
874	secondTransport, secondSends, _ := newConn(t, secondFrame)
875	secondDone := make(chan struct{})
876	go func() {
877		if err := d.handleCommand(secondTransport, secondFrame); err != nil {
878			t.Errorf("handle second command: %v", err)
879		}
880		close(secondDone)
881	}()
882	select {
883	case <-secondDone:
884		t.Fatal("second command bypassed serialization")
885	case <-time.After(100 * time.Millisecond):
886	}
887	close(factory.release)
888	<-firstDone
889	<-secondDone
890	require.True(t, awaitCommandResult(t, firstSends).OK)
891	require.True(t, awaitCommandResult(t, secondSends).OK)
892
893	sess.mu.Lock()
894	require.Zero(t, testAttachmentTabIndexLocked(sess), "self commands must not select the invoking tab")
895	sess.mu.Unlock()
896	target.mu.Lock()
897	defer target.mu.Unlock()
898	require.Len(t, target.panes, 2)
899	require.NotEqual(t, original, target.tree.Focus, "second command must execute after the split creates its right neighbor")
900}
901
902func TestControlMoveRejectsMissingAttachmentCapability(t *testing.T) {
903	d := newTestDaemon(t, nil, stubClock{})
904	source := addControlSession(d, "work", "t_work", "p_work")
905	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
906	tb := source.tabs[0]
907	tb.mu.Lock()
908	pane := tb.focusedPane()
909	tb.mu.Unlock()
910	ac := &attachedClient{tr: &closeTrackingTransport{}}
911	ac.setSession(source)
912
913	source.mu.Lock()
914	require.True(t, source.registerAttachmentLocked(ac))
915	target := daemonActionTarget{session: source, attachment: ac, tab: tb, pane: pane}
916	require.True(t, source.unregisterAttachmentLocked(ac))
917	source.mu.Unlock()
918
919	err := (controlExec{d: d, sess: source, tab: tb, target: target}).MovePane(destination.name, destination.tabs[0].stableID)
920	require.ErrorIs(t, err, errMoveStaleTarget)
921}
922
923func TestHandleCommandMovePaneUsesActiveFocusedSourceWithSessionFlag(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"}, TargetSession: "work",
937	})
938	require.True(t, result.OK, result.Text)
939
940	source.mu.Lock()
941	require.Len(t, source.tabs, 1)
942	require.Equal(t, "t_inactive", source.tabs[0].stableID)
943	source.mu.Unlock()
944	destination.mu.Lock()
945	destTab := destination.tabs[0]
946	destination.mu.Unlock()
947	destTab.mu.Lock()
948	var moved *pane
949	for _, candidate := range destTab.panes {
950		if candidate.stableID == "p_active" {
951			moved = candidate
952		}
953	}
954	require.NotNil(t, moved)
955	destTab.mu.Unlock()
956}
957
958func TestHandleCommandMovePaneSelfUsesStableSourceIDs(t *testing.T) {
959	d := newTestDaemon(t, nil, stubClock{})
960	source := addControlSession(d, "work", "t_active", "p_active")
961	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
962	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
963	publishTiledPaneOwners(source, inactive)
964	source.mu.Lock()
965	source.tabs = append(source.tabs, inactive)
966	selectTestAttachmentTabLocked(source, 0)
967	source.mu.Unlock()
968	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
969
970	result := sendCommand(t, d, ports.CommandRequest{
971		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
972		TargetSession: "work", TargetTab: "t_inactive", TargetPane: "p_inactive",
973	})
974	require.True(t, result.OK, result.Text)
975
976	active := source.tabs[0]
977	active.mu.Lock()
978	require.Len(t, active.panes, 1)
979	require.Equal(t, "p_active", active.panes[layout.PaneID("pane-1")].stableID)
980	active.mu.Unlock()
981	destination.mu.Lock()
982	destTab := destination.tabs[0]
983	destination.mu.Unlock()
984	destTab.mu.Lock()
985	var moved *pane
986	for _, candidate := range destTab.panes {
987		if candidate.stableID == "p_inactive" {
988			moved = candidate
989		}
990	}
991	require.NotNil(t, moved)
992	destTab.mu.Unlock()
993}
994
995func TestHandleCommandMoveTabUsesActiveTabWithoutSelf(t *testing.T) {
996	d := newTestDaemon(t, nil, stubClock{})
997	source := addControlSession(d, "work", "t_first", "p_first")
998	publishTiledPaneOwners(source, source.tabs[0])
999	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1000	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1001	publishTiledPaneOwners(source, second)
1002	source.mu.Lock()
1003	source.tabs = append(source.tabs, second)
1004	selectTestAttachmentTabLocked(source, 0)
1005	source.mu.Unlock()
1006	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1007
1008	result := sendCommand(t, d, ports.CommandRequest{
1009		Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work", Self: false,
1010		TargetTab: "t_second", TargetPane: "p_second",
1011	})
1012	require.True(t, result.OK, result.Text)
1013
1014	source.mu.Lock()
1015	require.Len(t, source.tabs, 1)
1016	require.Equal(t, "t_second", source.tabs[0].stableID)
1017	source.mu.Unlock()
1018	destination.mu.Lock()
1019	require.Len(t, destination.tabs, 2)
1020	require.Equal(t, "t_first", destination.tabs[1].stableID)
1021	destination.mu.Unlock()
1022}
1023
1024func TestHandleCommandMoveTabSelfUsesStableTab(t *testing.T) {
1025	d := newTestDaemon(t, nil, stubClock{})
1026	source := addControlSession(d, "work", "t_first", "p_first")
1027	second := newTabWithStableID("t_second", "p_second", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1028	second.ctx, second.cancel = context.WithCancel(d.serveCtx)
1029	publishTiledPaneOwners(source, second)
1030	source.mu.Lock()
1031	source.tabs = append(source.tabs, second)
1032	selectTestAttachmentTabLocked(source, 0)
1033	source.mu.Unlock()
1034	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1035
1036	result := sendCommand(t, d, ports.CommandRequest{
1037		Slug: "move-tab", Args: []string{"dest"}, Self: true,
1038		TargetSession: "work", TargetTab: "t_second", TargetPane: "p_second",
1039	})
1040	require.True(t, result.OK, result.Text)
1041
1042	source.mu.Lock()
1043	require.Len(t, source.tabs, 1)
1044	require.Equal(t, "t_first", source.tabs[0].stableID)
1045	source.mu.Unlock()
1046	destination.mu.Lock()
1047	require.Len(t, destination.tabs, 2)
1048	require.Equal(t, "t_second", destination.tabs[1].stableID)
1049	destination.mu.Unlock()
1050}
1051
1052func TestHandleCommandMoveCommandsRejectInvalidArgsAndTargets(t *testing.T) {
1053	tests := []struct {
1054		name    string
1055		arrange func(*Daemon) ports.CommandRequest
1056		code    uint16
1057		text    string
1058	}{
1059		{
1060			name: "move-pane missing destination session",
1061			arrange: func(d *Daemon) ports.CommandRequest {
1062				addControlSession(d, "work", "t_work", "p_work")
1063				return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1064			},
1065			code: ports.ErrNoSuchTarget,
1066			text: "move request is invalid",
1067		},
1068		{
1069			name: "move-pane unknown destination session",
1070			arrange: func(d *Daemon) ports.CommandRequest {
1071				addControlSession(d, "work", "t_work", "p_work")
1072				return ports.CommandRequest{Slug: "move-pane", Args: []string{"missing", "t_dest"}, TargetSession: "work"}
1073			},
1074			code: ports.ErrNoSuchTarget,
1075			text: "move target is no longer available",
1076		},
1077		{
1078			name: "move-pane destination tab not in session",
1079			arrange: func(d *Daemon) ports.CommandRequest {
1080				addControlSession(d, "work", "t_work", "p_work")
1081				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1082				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_other"}, TargetSession: "work"}
1083			},
1084			code: ports.ErrNoSuchTarget,
1085			text: "move target is no longer available",
1086		},
1087		{
1088			name: "move-pane same source tab",
1089			arrange: func(d *Daemon) ports.CommandRequest {
1090				addControlSession(d, "work", "t_work", "p_work")
1091				return ports.CommandRequest{Slug: "move-pane", Args: []string{"work", "t_work"}, TargetSession: "work"}
1092			},
1093			code: ports.ErrNoSuchTarget,
1094			text: "move request is invalid",
1095		},
1096		{
1097			name: "move-tab same session",
1098			arrange: func(d *Daemon) ports.CommandRequest {
1099				addControlSession(d, "work", "t_work", "p_work")
1100				return ports.CommandRequest{Slug: "move-tab", Args: []string{"work"}, TargetSession: "work"}
1101			},
1102			code: ports.ErrNoSuchTarget,
1103			text: "move request is invalid",
1104		},
1105		{
1106			name: "move-tab stopping destination",
1107			arrange: func(d *Daemon) ports.CommandRequest {
1108				addControlSession(d, "work", "t_work", "p_work")
1109				destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1110				destination.teardownMu.Lock()
1111				destination.teardownActive = true
1112				destination.teardownMu.Unlock()
1113				return ports.CommandRequest{Slug: "move-tab", Args: []string{"dest"}, TargetSession: "work"}
1114			},
1115			code: ports.ErrNoSuchTarget,
1116			text: "move target is no longer available",
1117		},
1118		{
1119			name: "move-pane too few args",
1120			arrange: func(d *Daemon) ports.CommandRequest {
1121				addControlSession(d, "work", "t_work", "p_work")
1122				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest"}, TargetSession: "work"}
1123			},
1124			code: ports.ErrInvalidCommandArgs,
1125		},
1126		{
1127			name: "move-pane too many args",
1128			arrange: func(d *Daemon) ports.CommandRequest {
1129				addControlSession(d, "work", "t_work", "p_work")
1130				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest", "extra"}, TargetSession: "work"}
1131			},
1132			code: ports.ErrInvalidCommandArgs,
1133		},
1134		{
1135			name: "move-tab missing destination",
1136			arrange: func(d *Daemon) ports.CommandRequest {
1137				addControlSession(d, "work", "t_work", "p_work")
1138				return ports.CommandRequest{Slug: "move-tab", TargetSession: "work"}
1139			},
1140			code: ports.ErrInvalidCommandArgs,
1141		},
1142		{
1143			name: "explicit session name without IDs remains authoritative",
1144			arrange: func(d *Daemon) ports.CommandRequest {
1145				addControlSession(d, "renamed", "t_work", "p_work")
1146				addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1147				return ports.CommandRequest{Slug: "move-pane", Args: []string{"dest", "t_dest"}, TargetSession: "old-name"}
1148			},
1149			code: ports.ErrNoSuchTarget,
1150			text: "no such session: old-name",
1151		},
1152	}
1153
1154	for _, tt := range tests {
1155		t.Run(tt.name, func(t *testing.T) {
1156			d := newTestDaemon(t, nil, stubClock{})
1157			request := tt.arrange(d)
1158			result := sendCommand(t, d, request)
1159			require.False(t, result.OK)
1160			require.Equal(t, tt.code, result.Code)
1161			if tt.text != "" {
1162				require.Equal(t, tt.text, result.Text)
1163			}
1164		})
1165	}
1166}
1167
1168func TestHandleCommandMovePaneRelocatedStableIDsOverrideAdvisorySessionName(t *testing.T) {
1169	d := newTestDaemon(t, nil, stubClock{})
1170	advisory := addControlSession(d, "old-name", "t_other", "p_other")
1171	source := addControlSession(d, "renamed", "t_work", "p_work")
1172	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1173
1174	result := sendCommand(t, d, ports.CommandRequest{
1175		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1176		TargetSession: "old-name", TargetTab: "t_work", TargetPane: "p_work",
1177	})
1178	require.True(t, result.OK, result.Text)
1179
1180	source.mu.Lock()
1181	require.Len(t, source.tabs, 0)
1182	source.mu.Unlock()
1183	advisory.mu.Lock()
1184	require.Len(t, advisory.tabs, 1, "the advisory session-name match must remain untouched")
1185	advisory.mu.Unlock()
1186	destination.mu.Lock()
1187	destTab := destination.tabs[0]
1188	destination.mu.Unlock()
1189	destTab.mu.Lock()
1190	require.Len(t, destTab.panes, 2)
1191	destTab.mu.Unlock()
1192}
1193
1194func TestHandleCommandMovePaneStableIDsLocateSessionWithoutSelfRedirect(t *testing.T) {
1195	d := newTestDaemon(t, nil, stubClock{})
1196	source := addControlSession(d, "renamed", "t_active", "p_active")
1197	inactive := newTabWithStableID("t_inactive", "p_inactive", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1198	inactive.ctx, inactive.cancel = context.WithCancel(d.serveCtx)
1199	publishTiledPaneOwners(source, inactive)
1200	source.mu.Lock()
1201	source.tabs = append(source.tabs, inactive)
1202	selectTestAttachmentTabLocked(source, 1)
1203	source.mu.Unlock()
1204	destination := addNamedMoveDestination(d, "dest", "t_dest", "p_dest")
1205
1206	result := sendCommand(t, d, ports.CommandRequest{
1207		Slug: "move-pane", Args: []string{"dest", "t_dest"}, Self: true,
1208		TargetSession: "old-name", TargetTab: "t_inactive", TargetPane: "p_inactive",
1209	})
1210	require.True(t, result.OK, result.Text)
1211
1212	source.mu.Lock()
1213	require.Len(t, source.tabs, 1)
1214	require.Equal(t, "t_active", source.tabs[0].stableID)
1215	source.mu.Unlock()
1216	destination.mu.Lock()
1217	destTab := destination.tabs[0]
1218	destination.mu.Unlock()
1219	destTab.mu.Lock()
1220	var moved *pane
1221	for _, candidate := range destTab.panes {
1222		if candidate.stableID == "p_inactive" {
1223			moved = candidate
1224		}
1225	}
1226	require.NotNil(t, moved)
1227	destTab.mu.Unlock()
1228}
1229
1230func TestHandleCommandOppositeMoveCommandsDoNotDeadlock(t *testing.T) {
1231	d, left, _, _ := newManualSessionWithPTYs(t, newQuietPTY(), newQuietPTY())
1232	left.mu.Lock()
1233	left.name = "left"
1234	left.tabs[0].stableID = "left-moved"
1235	selectTestAttachmentTabLocked(left, 0)
1236	left.mu.Unlock()
1237
1238	right := addMoveTabTestSession(d, "right", "right-stays")
1239	rightMoved := newTabWithStableID("right-moved", "right-pane", newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1240	rightMoved.ctx, rightMoved.cancel = context.WithCancel(right.ctx)
1241	publishTiledPaneOwners(right, rightMoved)
1242	right.mu.Lock()
1243	right.tabs = append(right.tabs, rightMoved)
1244	selectTestAttachmentTabLocked(right, 1)
1245	right.mu.Unlock()
1246
1247	leftFrame := commandFrame(t, ports.CommandRequest{
1248		Slug: "move-tab", Args: []string{"right"}, TargetSession: "left",
1249	})
1250	rightFrame := commandFrame(t, ports.CommandRequest{
1251		Slug: "move-tab", Args: []string{"left"}, TargetSession: "right",
1252	})
1253	leftTransport, leftSends, _ := newConn(t, leftFrame)
1254	rightTransport, rightSends, _ := newConn(t, rightFrame)
1255	start := make(chan struct{})
1256	done := make(chan struct{}, 2)
1257	go func() {
1258		<-start
1259		if err := d.handleCommand(leftTransport, leftFrame); err != nil {
1260			t.Errorf("handle left command: %v", err)
1261		}
1262		done <- struct{}{}
1263	}()
1264	go func() {
1265		<-start
1266		if err := d.handleCommand(rightTransport, rightFrame); err != nil {
1267			t.Errorf("handle right command: %v", err)
1268		}
1269		done <- struct{}{}
1270	}()
1271	close(start)
1272	for _, sends := range []chan ports.Frame{leftSends, rightSends} {
1273		select {
1274		case <-done:
1275			result := awaitCommandResult(t, sends)
1276			require.True(t, result.OK, result.Text)
1277		case <-time.After(5 * time.Second):
1278			t.Fatal("opposite-direction move commands deadlocked")
1279		}
1280	}
1281}
1282
1283func addNamedMoveDestination(d *Daemon, name, tabID, paneID string) *session {
1284	ctx, cancel := context.WithCancel(d.serveCtx)
1285	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 23})
1286	tb.ctx, tb.cancel = context.WithCancel(ctx)
1287	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, incarnation: domain.IncarnationID{5},
1288		ephemeral: true}, ctx: ctx, cancel: cancel, tabs: []*tab{tb},
1289	}
1290	publishTiledPaneOwners(sess, tb)
1291	d.mu.Lock()
1292	d.sessions[sess.id] = sess
1293	d.mu.Unlock()
1294	return sess
1295}
1296
1297func commandFrame(t *testing.T, request ports.CommandRequest) ports.Frame {
1298	t.Helper()
1299	if request.Version == 0 {
1300		request.Version = ports.ProtocolVersion
1301	}
1302	payload, err := ports.MarshalCommandRequest(request)
1303	require.NoError(t, err)
1304	return ports.Frame{Type: ports.MsgCommand, Payload: payload}
1305}
1306
1307func sendCommand(t *testing.T, d *Daemon, request ports.CommandRequest) ports.CommandResult {
1308	t.Helper()
1309	frame := commandFrame(t, request)
1310	tr, sends, _ := newConn(t, frame)
1311	require.NoError(t, d.handleCommand(tr, frame))
1312	return awaitCommandResult(t, sends)
1313}
1314
1315func awaitCommandResult(t *testing.T, sends chan ports.Frame) ports.CommandResult {
1316	t.Helper()
1317	reply := awaitFrame(t, sends, ports.MsgCommandResult)
1318	result, err := ports.UnmarshalCommandResult(reply.Payload)
1319	require.NoError(t, err)
1320	return result
1321}
1322
1323type controlPTYFactory struct {
1324	mu      sync.Mutex
1325	ptys    []*quietPTY
1326	entered chan struct{}
1327	release chan struct{}
1328	once    sync.Once
1329}
1330
1331func (f *controlPTYFactory) Open(_ context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
1332	f.once.Do(func() {
1333		if f.entered != nil {
1334			close(f.entered)
1335			<-f.release
1336		}
1337	})
1338	pty := newQuietPTY()
1339	f.mu.Lock()
1340	f.ptys = append(f.ptys, pty)
1341	f.mu.Unlock()
1342	return pty, nil
1343}
1344
1345func (f *controlPTYFactory) close() {
1346	if f.release != nil {
1347		select {
1348		case <-f.release:
1349		default:
1350			close(f.release)
1351		}
1352	}
1353	f.mu.Lock()
1354	defer f.mu.Unlock()
1355	for _, pty := range f.ptys {
1356		_ = pty.Close()
1357	}
1358}
1359
1360type blockingControlSendTransport struct {
1361	started chan struct{}
1362	closed  chan struct{}
1363	once    sync.Once
1364}
1365
1366func newBlockingControlSendTransport() *blockingControlSendTransport {
1367	return &blockingControlSendTransport{started: make(chan struct{}), closed: make(chan struct{})}
1368}
1369
1370func (tr *blockingControlSendTransport) Send(ports.Frame) error {
1371	tr.once.Do(func() { close(tr.started) })
1372	<-tr.closed
1373	return io.ErrClosedPipe
1374}
1375
1376func (*blockingControlSendTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1377func (tr *blockingControlSendTransport) Close() error {
1378	tr.once.Do(func() { close(tr.started) })
1379	select {
1380	case <-tr.closed:
1381	default:
1382		close(tr.closed)
1383	}
1384	return nil
1385}
1386
1387type commandSendErrorTransport struct {
1388	err    error
1389	closed bool
1390}
1391
1392func (tr *commandSendErrorTransport) Send(ports.Frame) error  { return tr.err }
1393func (*commandSendErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
1394func (tr *commandSendErrorTransport) Close() error            { tr.closed = true; return nil }
1395
1396func addControlSession(d *Daemon, name, tabID, paneID string) *session {
1397	tb := newTabWithStableID(tabID, paneID, newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
1398	tb.ctx, tb.cancel = context.WithCancel(d.serveCtx)
1399	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: true}, cwd: "/tmp/" + name,
1400		ctx: d.serveCtx, cancel: func() {}, tabs: []*tab{tb}, env: []string{"MARK=" + name},
1401	}
1402	sess.mruAt.Store(1)
1403	d.mu.Lock()
1404	d.sessions[sess.id] = sess
1405	d.mu.Unlock()
1406	return sess
1407}

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