detached c54ef32

test(daemon): cover pane resize modal and control paths

commit
c54ef32d5b26b0dbe20d2b8e3c72ac3a14ef09b4
author
brice · · 2026-07-25 04:51 UTC
parent
9ebcbf7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +369 -0
M internal/app/cmd_test.go +34 -0
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111
1212 "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
1314 )
1415
1516 func TestParseCmdArgs(t *testing.T) {
@@ -104,6 +105,29 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
104105 }
105106 }
106107
108+func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
109+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
110+ t.Run(slug, func(t *testing.T) {
111+ invocation, err := parseArgs([]string{"cmd", "--self", slug})
112+ require.NoError(t, err)
113+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
114+ require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
115+ stdout: io.Discard,
116+ getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
117+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
118+ }))
119+ require.Len(t, transport.sent, 1)
120+ request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
121+ require.NoError(t, err)
122+ require.Equal(t, slug, request.Slug)
123+ require.True(t, request.Self)
124+ require.Equal(t, "work", request.TargetSession)
125+ require.Equal(t, "t_abc", request.TargetTab)
126+ require.Equal(t, "p_def", request.TargetPane)
127+ })
128+ }
129+}
130+
107131 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
108132 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
109133 out := new(strings.Builder)
@@ -225,6 +249,16 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
225249 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
226250 wantCode: 1,
227251 },
252+ {
253+ name: "resize not in split remains an exit one failure",
254+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"},
255+ wantCode: 1,
256+ },
257+ {
258+ name: "resize minimum remains an exit one failure",
259+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"},
260+ wantCode: 1,
261+ },
228262 }
229263 for _, tt := range tests {
230264 t.Run(tt.name, func(t *testing.T) {
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 11
12 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
13 )14 )
14 15
15 func TestParseCmdArgs(t *testing.T) {16 func TestParseCmdArgs(t *testing.T) {
@@ -104,6 +105,29 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
104 }105 }
105 }106 }
106 107
108+func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
109+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
110+ t.Run(slug, func(t *testing.T) {
111+ invocation, err := parseArgs([]string{"cmd", "--self", slug})
112+ require.NoError(t, err)
113+ transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
114+ require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
115+ stdout: io.Discard,
116+ getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
117+ dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
118+ }))
119+ require.Len(t, transport.sent, 1)
120+ request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
121+ require.NoError(t, err)
122+ require.Equal(t, slug, request.Slug)
123+ require.True(t, request.Self)
124+ require.Equal(t, "work", request.TargetSession)
125+ require.Equal(t, "t_abc", request.TargetTab)
126+ require.Equal(t, "p_def", request.TargetPane)
127+ })
128+ }
129+}
130+
107 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {131 func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
108 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}132 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
109 out := new(strings.Builder)133 out := new(strings.Builder)
@@ -225,6 +249,16 @@ func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
225 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},249 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
226 wantCode: 1,250 wantCode: 1,
227 },251 },
252+ {
253+ name: "resize not in split remains an exit one failure",
254+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"},
255+ wantCode: 1,
256+ },
257+ {
258+ name: "resize minimum remains an exit one failure",
259+ result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"},
260+ wantCode: 1,
261+ },
228 }262 }
229 for _, tt := range tests {263 for _, tt := range tests {
230 t.Run(tt.name, func(t *testing.T) {264 t.Run(tt.name, func(t *testing.T) {
M internal/usecase/daemon/control_test.go +96 -0
@@ -271,6 +271,102 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
271271 invoking.mu.Unlock()
272272 }
273273
274+func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
275+ targets := []struct {
276+ name string
277+ request func() ports.CommandRequest
278+ }{
279+ {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }},
280+ {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }},
281+ }
282+ for _, target := range targets {
283+ t.Run(target.name, func(t *testing.T) {
284+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
285+ t.Run(slug, func(t *testing.T) {
286+ factory := &controlPTYFactory{}
287+ d := newTestDaemon(t, factory, stubClock{})
288+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
289+ sess := addControlSession(d, "work", "t_work", "p_work")
290+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
291+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
292+ tb := sess.activeTab()
293+ tb.mu.Lock()
294+ generation := tb.layoutGeneration
295+ tb.mu.Unlock()
296+ sess.snapEligible.Store(true)
297+ sess.snapshotMu.Lock()
298+ snapshotGeneration := sess.snapshotGeneration
299+ sess.snapshotMu.Unlock()
300+ request := target.request()
301+ request.Slug = slug
302+ result := sendCommand(t, d, request)
303+ require.True(t, result.OK, result.Text)
304+ require.Empty(t, result.Output)
305+ tb.mu.Lock()
306+ require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
307+ tb.mu.Unlock()
308+ sess.snapshotMu.Lock()
309+ require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
310+ sess.snapshotMu.Unlock()
311+ sess.mu.Lock()
312+ require.Nil(t, sess.client, "one-shots must work headless")
313+ sess.mu.Unlock()
314+ })
315+ }
316+ })
317+ }
318+}
319+
320+func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
321+ factory := &controlPTYFactory{}
322+ d := newTestDaemon(t, factory, stubClock{})
323+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
324+ sess := addControlSession(d, "work", "t_active", "p_active")
325+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
326+ active := sess.tabs[0]
327+ locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
328+ locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
329+ sess.mu.Lock()
330+ sess.tabs = append(sess.tabs, locator)
331+ sess.active = 0
332+ sess.mu.Unlock()
333+ active.mu.Lock()
334+ before := active.layoutGeneration
335+ active.mu.Unlock()
336+
337+ result := sendCommand(t, d, ports.CommandRequest{
338+ Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
339+ })
340+ require.True(t, result.OK, result.Text)
341+ active.mu.Lock()
342+ require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
343+ active.mu.Unlock()
344+ locator.mu.Lock()
345+ require.Zero(t, locator.layoutGeneration)
346+ locator.mu.Unlock()
347+ sess.mu.Lock()
348+ require.Zero(t, sess.active)
349+ sess.mu.Unlock()
350+}
351+
352+func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
353+ factory := &controlPTYFactory{}
354+ d := newTestDaemon(t, factory, stubClock{})
355+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
356+ sess := addControlSession(d, "work", "t_work", "p_work")
357+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
358+ tb := sess.activeTab()
359+ tb.mu.Lock()
360+ tb.size.Cols = 41
361+ tb.bumpLayoutGenerationLocked()
362+ tb.mu.Unlock()
363+
364+ result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
365+ require.False(t, result.OK)
366+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
367+ require.Equal(t, "pane cannot be resized further", result.Text)
368+}
369+
274370 func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
275371 d := newTestDaemon(t, nil, stubClock{})
276372 sess := addControlSession(d, "work", "t_active", "p_active")
@@ -271,6 +271,102 @@ func TestHandleCommandSelfTargetsNonActiveTabAndPane(t *testing.T) {
271 invoking.mu.Unlock()271 invoking.mu.Unlock()
272 }272 }
273 273
274+func TestResizeControlOneShotsTargetDetachedSessions(t *testing.T) {
275+ targets := []struct {
276+ name string
277+ request func() ports.CommandRequest
278+ }{
279+ {"named", func() ports.CommandRequest { return ports.CommandRequest{TargetSession: "work"} }},
280+ {"unique", func() ports.CommandRequest { return ports.CommandRequest{} }},
281+ }
282+ for _, target := range targets {
283+ t.Run(target.name, func(t *testing.T) {
284+ for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
285+ t.Run(slug, func(t *testing.T) {
286+ factory := &controlPTYFactory{}
287+ d := newTestDaemon(t, factory, stubClock{})
288+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
289+ sess := addControlSession(d, "work", "t_work", "p_work")
290+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
291+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
292+ tb := sess.activeTab()
293+ tb.mu.Lock()
294+ generation := tb.layoutGeneration
295+ tb.mu.Unlock()
296+ sess.snapEligible.Store(true)
297+ sess.snapshotMu.Lock()
298+ snapshotGeneration := sess.snapshotGeneration
299+ sess.snapshotMu.Unlock()
300+ request := target.request()
301+ request.Slug = slug
302+ result := sendCommand(t, d, request)
303+ require.True(t, result.OK, result.Text)
304+ require.Empty(t, result.Output)
305+ tb.mu.Lock()
306+ require.Equal(t, generation+1, tb.layoutGeneration, "one accepted action has one layout generation boundary")
307+ tb.mu.Unlock()
308+ sess.snapshotMu.Lock()
309+ require.Equal(t, snapshotGeneration+1, sess.snapshotGeneration, "one accepted action has one snapshot dirty boundary")
310+ sess.snapshotMu.Unlock()
311+ sess.mu.Lock()
312+ require.Nil(t, sess.client, "one-shots must work headless")
313+ sess.mu.Unlock()
314+ })
315+ }
316+ })
317+ }
318+}
319+
320+func TestResizeControlNonSelfVEVLocatorUsesActiveTarget(t *testing.T) {
321+ factory := &controlPTYFactory{}
322+ d := newTestDaemon(t, factory, stubClock{})
323+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
324+ sess := addControlSession(d, "work", "t_active", "p_active")
325+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
326+ active := sess.tabs[0]
327+ locator := newTabWithStableID("t_locator", "p_locator", newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
328+ locator.ctx, locator.cancel = context.WithCancel(d.serveCtx)
329+ sess.mu.Lock()
330+ sess.tabs = append(sess.tabs, locator)
331+ sess.active = 0
332+ sess.mu.Unlock()
333+ active.mu.Lock()
334+ before := active.layoutGeneration
335+ active.mu.Unlock()
336+
337+ result := sendCommand(t, d, ports.CommandRequest{
338+ Slug: "grow-pane-width", TargetSession: "work", TargetTab: "t_locator", TargetPane: "p_locator",
339+ })
340+ require.True(t, result.OK, result.Text)
341+ active.mu.Lock()
342+ require.Equal(t, before+1, active.layoutGeneration, "stable IDs locate the session but must not redirect a non-self action")
343+ active.mu.Unlock()
344+ locator.mu.Lock()
345+ require.Zero(t, locator.layoutGeneration)
346+ locator.mu.Unlock()
347+ sess.mu.Lock()
348+ require.Zero(t, sess.active)
349+ sess.mu.Unlock()
350+}
351+
352+func TestResizeControlTooSmallUsesStableFailure(t *testing.T) {
353+ factory := &controlPTYFactory{}
354+ d := newTestDaemon(t, factory, stubClock{})
355+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
356+ sess := addControlSession(d, "work", "t_work", "p_work")
357+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
358+ tb := sess.activeTab()
359+ tb.mu.Lock()
360+ tb.size.Cols = 41
361+ tb.bumpLayoutGenerationLocked()
362+ tb.mu.Unlock()
363+
364+ result := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"})
365+ require.False(t, result.OK)
366+ require.Equal(t, ports.ErrNoSuchTarget, result.Code)
367+ require.Equal(t, "pane cannot be resized further", result.Text)
368+}
369+
274 func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {370 func TestHandleCommandSelfListPanesUsesInvokingTab(t *testing.T) {
275 d := newTestDaemon(t, nil, stubClock{})371 d := newTestDaemon(t, nil, stubClock{})
276 sess := addControlSession(d, "work", "t_active", "p_active")372 sess := addControlSession(d, "work", "t_active", "p_active")
M internal/usecase/daemon/resize_mode_test.go +239 -0
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "sync"
45 "testing"
56
67 "github.com/bnema/vev/internal/domain"
@@ -155,3 +156,241 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
155156 require.Equal(t, 1, right)
156157 require.Empty(t, pending)
157158 }
159+
160+// TestResizeModeKeysAndEscapes exercises the modal parser through the real
161+// action seam. Every directional spelling must commit exactly one mutation;
162+// parser prefixes are deliberately split across reads.
163+func TestResizeModeKeysAndEscapes(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ chunks [][]byte
167+ change bool
168+ exit bool
169+ }{
170+ {"h", [][]byte{[]byte("h")}, true, false},
171+ {"j", [][]byte{[]byte("j")}, true, false},
172+ {"k", [][]byte{[]byte("k")}, true, false},
173+ {"l", [][]byte{[]byte("l")}, true, false},
174+ {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false},
175+ {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false},
176+ {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false},
177+ {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false},
178+ {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false},
179+ {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false},
180+ {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false},
181+ {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false},
182+ {"equalize", [][]byte{[]byte("=")}, true, false},
183+ {"ignored", [][]byte{[]byte("z\x00")}, false, false},
184+ {"q exits", [][]byte{[]byte("q")}, false, true},
185+ {"enter exits", [][]byte{[]byte("\r")}, false, true},
186+ {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true},
187+ }
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ d, sess, ac := resizeModeFixture(t)
191+ tb := sess.activeTab()
192+ tb.mu.Lock()
193+ before := tb.layoutGeneration
194+ if tt.name == "equalize" {
195+ tb.tree.Root.Children[0].Weight = 3
196+ }
197+ tb.mu.Unlock()
198+ for _, chunk := range tt.chunks {
199+ d.handleResizeInput(ac, chunk)
200+ }
201+ tb.mu.Lock()
202+ after := tb.layoutGeneration
203+ if tt.name == "equalize" {
204+ for _, child := range tb.tree.Root.Children {
205+ require.Zero(t, child.Weight)
206+ }
207+ }
208+ tb.mu.Unlock()
209+ if tt.change {
210+ require.Equal(t, before+1, after)
211+ } else {
212+ require.Equal(t, before, after)
213+ }
214+ require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
215+ })
216+ }
217+}
218+
219+func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
220+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
221+ tb := sess.activeTab()
222+ second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
223+ tb.mu.Lock()
224+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
225+ tb.panes[second.id] = second
226+ tb.mu.Unlock()
227+ second.screen.Write([]byte("live resize content"))
228+ require.NoError(t, d.enterResizeMode(sess, ac))
229+ data := string(mustOutputData(t, sends))
230+ require.Contains(t, data, "resize: h/j/k/l or arrows")
231+ require.Contains(t, data, "live resize content")
232+ require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
233+}
234+
235+func TestResizeModeOverlayPriority(t *testing.T) {
236+ tests := []struct {
237+ name string
238+ input []byte
239+ setup func(*Daemon, *session, *attachedClient)
240+ check func(*testing.T, *attachedClient)
241+ }{
242+ {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
243+ d.enterCopyMode(sess, ac)
244+ d.enterNotices(sess, ac)
245+ d.enterPicker(sess, ac)
246+ d.enterPalette(sess, ac)
247+ d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
248+ }, func(t *testing.T, ac *attachedClient) {
249+ require.True(t, ac.overlays.promptActive())
250+ require.True(t, ac.overlays.resizeModeActive())
251+ }},
252+ {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
253+ d.enterCopyMode(sess, ac)
254+ d.enterPicker(sess, ac)
255+ d.enterPalette(sess, ac)
256+ }, func(t *testing.T, ac *attachedClient) {
257+ require.True(t, ac.overlays.paletteActive())
258+ require.True(t, ac.overlays.resizeModeActive())
259+ }},
260+ {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
261+ d.enterCopyMode(sess, ac)
262+ d.enterPicker(sess, ac)
263+ }, func(t *testing.T, ac *attachedClient) {
264+ require.True(t, ac.overlays.pickerActive())
265+ require.True(t, ac.overlays.resizeModeActive())
266+ }},
267+ {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
268+ d.enterCopyMode(sess, ac)
269+ d.enterNotices(sess, ac)
270+ }, func(t *testing.T, ac *attachedClient) {
271+ require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
272+ require.True(t, ac.overlays.resizeModeActive())
273+ }},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ d, sess, ac := resizeModeFixture(t)
278+ tt.setup(d, sess, ac)
279+ require.True(t, ac.overlays.HandleInput(d, tt.input))
280+ tt.check(t, ac)
281+ })
282+ }
283+}
284+
285+func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
286+ t.Run("too small warning leaves mode active", func(t *testing.T) {
287+ d, sess, ac := resizeModeFixture(t)
288+ tb := sess.activeTab()
289+ tb.mu.Lock()
290+ tb.size.Cols = 41 // two minimum-width panes and their separator
291+ tb.bumpLayoutGenerationLocked()
292+ tb.mu.Unlock()
293+ d.handleResizeInput(ac, []byte("l"))
294+ require.True(t, ac.overlays.resizeModeActive())
295+ history := d.notices.history()
296+ require.NotEmpty(t, history)
297+ require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
298+ })
299+
300+ t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
301+ type invoke func(*Daemon, *session, *attachedClient) error
302+ invocations := []struct {
303+ name string
304+ run invoke
305+ }{
306+ {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
307+ return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
308+ }},
309+ {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
310+ target := resolveDaemonActionTarget(sess)
311+ return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
312+ }},
313+ {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
314+ d.handleResizeInput(ac, []byte("l"))
315+ return nil
316+ }},
317+ }
318+ var want []float64
319+ for _, tt := range invocations {
320+ t.Run(tt.name, func(t *testing.T) {
321+ d, sess, ac := resizeModeFixture(t)
322+ require.NoError(t, tt.run(d, sess, ac))
323+ tb := sess.activeTab()
324+ tb.mu.Lock()
325+ got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
326+ tb.mu.Unlock()
327+ if want == nil {
328+ want = got
329+ } else {
330+ require.Equal(t, want, got)
331+ }
332+ })
333+ }
334+ })
335+}
336+
337+type resizeLockProbePTY struct {
338+ once sync.Once
339+ entered chan struct{}
340+ release chan struct{}
341+ check func()
342+}
343+
344+func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil }
345+func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
346+func (*resizeLockProbePTY) Close() error { return nil }
347+func (*resizeLockProbePTY) Pid() int { return 0 }
348+func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
349+func (p *resizeLockProbePTY) Resize(domain.Size) error {
350+ p.once.Do(func() {
351+ p.check()
352+ close(p.entered)
353+ <-p.release
354+ })
355+ return nil
356+}
357+
358+func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
359+ // Build a live tree directly so the probe is not concurrently owned by a
360+ // pane reader; this isolates the lock boundary being asserted.
361+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
362+ tb := sess.activeTab()
363+ probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
364+ second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
365+ tb.mu.Lock()
366+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
367+ tb.panes[second.id] = second
368+ tb.mu.Unlock()
369+ probe.check = func() {
370+ require.True(t, tb.mu.TryLock(), "PTY callback must not inherit tab.mu")
371+ tb.mu.Unlock()
372+ require.True(t, ac.overlays.resizeMu.TryLock(), "PTY callback must not inherit resize overlay mutex")
373+ ac.overlays.resizeMu.Unlock()
374+ }
375+ require.NoError(t, d.enterResizeMode(sess, ac))
376+ done := make(chan struct{})
377+ go func() { d.handleResizeInput(ac, []byte("l")); close(done) }()
378+ <-probe.entered
379+ close(probe.release)
380+ <-done
381+}
382+
383+func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
384+ t.Helper()
385+ factory := &controlPTYFactory{}
386+ d := newTestDaemon(t, factory, stubClock{})
387+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
388+ sess := addControlSession(d, "work", "t_work", "p_work")
389+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
390+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
391+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
392+ ac.initOverlays()
393+ ac.setSession(sess)
394+ require.NoError(t, d.enterResizeMode(sess, ac))
395+ return d, sess, ac
396+}
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "sync"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -155,3 +156,241 @@ func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) {
155 require.Equal(t, 1, right)156 require.Equal(t, 1, right)
156 require.Empty(t, pending)157 require.Empty(t, pending)
157 }158 }
159+
160+// TestResizeModeKeysAndEscapes exercises the modal parser through the real
161+// action seam. Every directional spelling must commit exactly one mutation;
162+// parser prefixes are deliberately split across reads.
163+func TestResizeModeKeysAndEscapes(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ chunks [][]byte
167+ change bool
168+ exit bool
169+ }{
170+ {"h", [][]byte{[]byte("h")}, true, false},
171+ {"j", [][]byte{[]byte("j")}, true, false},
172+ {"k", [][]byte{[]byte("k")}, true, false},
173+ {"l", [][]byte{[]byte("l")}, true, false},
174+ {"CSI left split", [][]byte{[]byte("\x1b["), []byte("D")}, true, false},
175+ {"CSI right", [][]byte{[]byte("\x1b[C")}, true, false},
176+ {"CSI up", [][]byte{[]byte("\x1b[A")}, true, false},
177+ {"CSI down", [][]byte{[]byte("\x1b[B")}, true, false},
178+ {"SS3 left", [][]byte{[]byte("\x1bOD")}, true, false},
179+ {"SS3 right split", [][]byte{[]byte("\x1bO"), []byte("C")}, true, false},
180+ {"SS3 up", [][]byte{[]byte("\x1bOA")}, true, false},
181+ {"SS3 down", [][]byte{[]byte("\x1bOB")}, true, false},
182+ {"equalize", [][]byte{[]byte("=")}, true, false},
183+ {"ignored", [][]byte{[]byte("z\x00")}, false, false},
184+ {"q exits", [][]byte{[]byte("q")}, false, true},
185+ {"enter exits", [][]byte{[]byte("\r")}, false, true},
186+ {"escape exits", [][]byte{[]byte("\x1b"), []byte("x")}, false, true},
187+ }
188+ for _, tt := range tests {
189+ t.Run(tt.name, func(t *testing.T) {
190+ d, sess, ac := resizeModeFixture(t)
191+ tb := sess.activeTab()
192+ tb.mu.Lock()
193+ before := tb.layoutGeneration
194+ if tt.name == "equalize" {
195+ tb.tree.Root.Children[0].Weight = 3
196+ }
197+ tb.mu.Unlock()
198+ for _, chunk := range tt.chunks {
199+ d.handleResizeInput(ac, chunk)
200+ }
201+ tb.mu.Lock()
202+ after := tb.layoutGeneration
203+ if tt.name == "equalize" {
204+ for _, child := range tb.tree.Root.Children {
205+ require.Zero(t, child.Weight)
206+ }
207+ }
208+ tb.mu.Unlock()
209+ if tt.change {
210+ require.Equal(t, before+1, after)
211+ } else {
212+ require.Equal(t, before, after)
213+ }
214+ require.Equal(t, !tt.exit, ac.overlays.resizeModeActive())
215+ })
216+ }
217+}
218+
219+func TestResizeModeRendersGuidanceWithoutHidingContentOrCursor(t *testing.T) {
220+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
221+ tb := sess.activeTab()
222+ second := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23})
223+ tb.mu.Lock()
224+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
225+ tb.panes[second.id] = second
226+ tb.mu.Unlock()
227+ second.screen.Write([]byte("live resize content"))
228+ require.NoError(t, d.enterResizeMode(sess, ac))
229+ data := string(mustOutputData(t, sends))
230+ require.Contains(t, data, "resize: h/j/k/l or arrows")
231+ require.Contains(t, data, "live resize content")
232+ require.Contains(t, data, "\x1b[?25h", "resize leaves the focused pane cursor visible")
233+}
234+
235+func TestResizeModeOverlayPriority(t *testing.T) {
236+ tests := []struct {
237+ name string
238+ input []byte
239+ setup func(*Daemon, *session, *attachedClient)
240+ check func(*testing.T, *attachedClient)
241+ }{
242+ {"prompt before palette picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
243+ d.enterCopyMode(sess, ac)
244+ d.enterNotices(sess, ac)
245+ d.enterPicker(sess, ac)
246+ d.enterPalette(sess, ac)
247+ d.enterPrompt(sess, ac, "prompt", "", func(string) error { return nil })
248+ }, func(t *testing.T, ac *attachedClient) {
249+ require.True(t, ac.overlays.promptActive())
250+ require.True(t, ac.overlays.resizeModeActive())
251+ }},
252+ {"palette before picker notices resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
253+ d.enterCopyMode(sess, ac)
254+ d.enterPicker(sess, ac)
255+ d.enterPalette(sess, ac)
256+ }, func(t *testing.T, ac *attachedClient) {
257+ require.True(t, ac.overlays.paletteActive())
258+ require.True(t, ac.overlays.resizeModeActive())
259+ }},
260+ {"picker before resize and copy", []byte("j"), func(d *Daemon, sess *session, ac *attachedClient) {
261+ d.enterCopyMode(sess, ac)
262+ d.enterPicker(sess, ac)
263+ }, func(t *testing.T, ac *attachedClient) {
264+ require.True(t, ac.overlays.pickerActive())
265+ require.True(t, ac.overlays.resizeModeActive())
266+ }},
267+ {"notices before resize and copy", []byte("q"), func(d *Daemon, sess *session, ac *attachedClient) {
268+ d.enterCopyMode(sess, ac)
269+ d.enterNotices(sess, ac)
270+ }, func(t *testing.T, ac *attachedClient) {
271+ require.False(t, ac.overlays.noticesActive(), "q must be consumed by notices")
272+ require.True(t, ac.overlays.resizeModeActive())
273+ }},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ d, sess, ac := resizeModeFixture(t)
278+ tt.setup(d, sess, ac)
279+ require.True(t, ac.overlays.HandleInput(d, tt.input))
280+ tt.check(t, ac)
281+ })
282+ }
283+}
284+
285+func TestResizeModeWarningPersistsAndActionsShareTransaction(t *testing.T) {
286+ t.Run("too small warning leaves mode active", func(t *testing.T) {
287+ d, sess, ac := resizeModeFixture(t)
288+ tb := sess.activeTab()
289+ tb.mu.Lock()
290+ tb.size.Cols = 41 // two minimum-width panes and their separator
291+ tb.bumpLayoutGenerationLocked()
292+ tb.mu.Unlock()
293+ d.handleResizeInput(ac, []byte("l"))
294+ require.True(t, ac.overlays.resizeModeActive())
295+ history := d.notices.history()
296+ require.NotEmpty(t, history)
297+ require.Equal(t, "pane cannot be resized further", history[len(history)-1].Message)
298+ })
299+
300+ t.Run("palette control and modal have the same committed weights", func(t *testing.T) {
301+ type invoke func(*Daemon, *session, *attachedClient) error
302+ invocations := []struct {
303+ name string
304+ run invoke
305+ }{
306+ {"palette", func(d *Daemon, sess *session, ac *attachedClient) error {
307+ return (paletteExec{d: d, sess: sess, ac: ac}).GrowPaneWidth()
308+ }},
309+ {"control", func(d *Daemon, sess *session, _ *attachedClient) error {
310+ target := resolveDaemonActionTarget(sess)
311+ return (controlExec{d: d, sess: sess, tab: target.tab, target: target}).GrowPaneWidth()
312+ }},
313+ {"modal", func(d *Daemon, _ *session, ac *attachedClient) error {
314+ d.handleResizeInput(ac, []byte("l"))
315+ return nil
316+ }},
317+ }
318+ var want []float64
319+ for _, tt := range invocations {
320+ t.Run(tt.name, func(t *testing.T) {
321+ d, sess, ac := resizeModeFixture(t)
322+ require.NoError(t, tt.run(d, sess, ac))
323+ tb := sess.activeTab()
324+ tb.mu.Lock()
325+ got := []float64{tb.tree.Root.Children[0].Weight, tb.tree.Root.Children[1].Weight}
326+ tb.mu.Unlock()
327+ if want == nil {
328+ want = got
329+ } else {
330+ require.Equal(t, want, got)
331+ }
332+ })
333+ }
334+ })
335+}
336+
337+type resizeLockProbePTY struct {
338+ once sync.Once
339+ entered chan struct{}
340+ release chan struct{}
341+ check func()
342+}
343+
344+func (p *resizeLockProbePTY) Read([]byte) (int, error) { return 0, nil }
345+func (*resizeLockProbePTY) Write(b []byte) (int, error) { return len(b), nil }
346+func (*resizeLockProbePTY) Close() error { return nil }
347+func (*resizeLockProbePTY) Pid() int { return 0 }
348+func (*resizeLockProbePTY) ForegroundPgid() (int, error) { return 0, nil }
349+func (p *resizeLockProbePTY) Resize(domain.Size) error {
350+ p.once.Do(func() {
351+ p.check()
352+ close(p.entered)
353+ <-p.release
354+ })
355+ return nil
356+}
357+
358+func TestResizeModeReleasesTabAndOverlayLocksBeforePTY(t *testing.T) {
359+ // Build a live tree directly so the probe is not concurrently owned by a
360+ // pane reader; this isolates the lock boundary being asserted.
361+ d, sess, ac, _ := newManualSessionWithPTYs(t, nil)
362+ tb := sess.activeTab()
363+ probe := &resizeLockProbePTY{entered: make(chan struct{}), release: make(chan struct{})}
364+ second := newPane("pane-2", probe, domain.Size{Cols: 39, Rows: 23})
365+ tb.mu.Lock()
366+ tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}}, Focus: "pane-2"}
367+ tb.panes[second.id] = second
368+ tb.mu.Unlock()
369+ probe.check = func() {
370+ require.True(t, tb.mu.TryLock(), "PTY callback must not inherit tab.mu")
371+ tb.mu.Unlock()
372+ require.True(t, ac.overlays.resizeMu.TryLock(), "PTY callback must not inherit resize overlay mutex")
373+ ac.overlays.resizeMu.Unlock()
374+ }
375+ require.NoError(t, d.enterResizeMode(sess, ac))
376+ done := make(chan struct{})
377+ go func() { d.handleResizeInput(ac, []byte("l")); close(done) }()
378+ <-probe.entered
379+ close(probe.release)
380+ <-done
381+}
382+
383+func resizeModeFixture(t *testing.T) (*Daemon, *session, *attachedClient) {
384+ t.Helper()
385+ factory := &controlPTYFactory{}
386+ d := newTestDaemon(t, factory, stubClock{})
387+ t.Cleanup(func() { factory.close(); d.sessWg.Wait() })
388+ sess := addControlSession(d, "work", "t_work", "p_work")
389+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK)
390+ require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-down", TargetSession: "work"}).OK)
391+ ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
392+ ac.initOverlays()
393+ ac.setSession(sess)
394+ require.NoError(t, d.enterResizeMode(sess, ac))
395+ return d, sess, ac
396+}

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