detached 19cbd1f

test(daemon): cover floating pane lifecycle

commit
19cbd1ffaf504f6604d10052fe2abec83fc39671
author
brice · · 2026-07-10 00:05 UTC
committer
brice · · 2026-07-10 05:40 UTC
parent
8ccec9f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +202 -0
M internal/usecase/daemon/client.go +3 -0
@@ -398,6 +398,9 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
398398 d.touchMRU(sess)
399399 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
400400 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
401+ // A restored inactive tab remains cold; the attached active tab starts its
402+ // independent popup prewarm asynchronously.
403+ d.ensureFloatingWarm(sess, sess.activeTab())
401404 return ac, old
402405 }
403406
@@ -398,6 +398,9 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
398 d.touchMRU(sess)398 d.touchMRU(sess)
399 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)399 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
400 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)400 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
401+ // A restored inactive tab remains cold; the attached active tab starts its
402+ // independent popup prewarm asynchronously.
403+ d.ensureFloatingWarm(sess, sess.activeTab())
401 return ac, old404 return ac, old
402 }405 }
403 406
M internal/usecase/daemon/floating_test.go +149 -0
@@ -1,9 +1,18 @@
11 package daemon
22
33 import (
4+ "context"
5+ "sync"
46 "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
511
612 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/internal/usecase/layout"
716 )
817
918 func TestFloatingSlotTransitions(t *testing.T) {
@@ -92,6 +101,146 @@ func TestFloatingSlotTransitions(t *testing.T) {
92101 }
93102 }
94103
104+func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
105+ pty := portsmocks.NewMockPTY(t)
106+ readerStarted := make(chan struct{})
107+ release := make(chan struct{})
108+ var releaseOnce sync.Once
109+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
110+ pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
111+ close(readerStarted)
112+ <-release
113+ return 0, context.Canceled
114+ }).Once()
115+ pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
116+ factory := portsmocks.NewMockPTYFactory(t)
117+ opened := make(chan struct{})
118+ allowOpen := make(chan struct{})
119+ var gotCommand, gotDir string
120+ var gotArgs []string
121+ var gotSize domain.Size
122+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
123+ func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
124+ gotCommand, gotArgs, gotDir, gotSize = command, append([]string(nil), args...), dir, size
125+ close(opened)
126+ <-allowOpen
127+ return pty, nil
128+ }).Once()
129+ d := newTestDaemon(t, factory, stubClock{})
130+ d.shell = "/bin/custom-shell"
131+ tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
132+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
133+ sess := &session{name: "work", cwd: "/captured", tabs: []*tab{tb}, ctx: t.Context()}
134+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
135+ d.ensureFloatingWarm(sess, tb)
136+ // Open has started while this goroutine owns tab.mu: an external factory
137+ // call under that lock would deadlock this channel-controlled test.
138+ tb.mu.Lock()
139+ select {
140+ case <-opened:
141+ case <-time.After(time.Second):
142+ t.Fatal("floating Open waited for tab.mu")
143+ }
144+ tb.mu.Unlock()
145+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
146+ close(allowOpen)
147+ select {
148+ case <-readerStarted: // install started exactly one reader after the async Open
149+ case <-time.After(time.Second):
150+ t.Fatal("floating pane was not installed")
151+ }
152+ require.Equal(t, "/bin/custom-shell", gotCommand)
153+ require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs)
154+ require.Equal(t, "/captured", gotDir)
155+ require.Equal(t, domain.Size{Cols: 48, Rows: 18}, gotSize)
156+ d.teardownFloating(tb)
157+ unblock()
158+}
159+
160+func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
161+ pty := portsmocks.NewMockPTY(t)
162+ closed := make(chan struct{})
163+ pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
164+ factory := portsmocks.NewMockPTYFactory(t)
165+ opened := make(chan struct{})
166+ allowOpen := make(chan struct{})
167+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
168+ func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
169+ close(opened)
170+ <-allowOpen
171+ return pty, nil
172+ }).Once()
173+ d := newTestDaemon(t, factory, stubClock{})
174+ tb := newFloatingTestTab(t)
175+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
176+ d.ensureFloatingWarm(sess, tb)
177+ <-opened
178+ d.teardownFloating(tb) // invalidate before the delayed Open returns
179+ close(allowOpen)
180+ select {
181+ case <-closed:
182+ case <-time.After(time.Second):
183+ t.Fatal("stale completion did not close its PTY")
184+ }
185+
186+ first := &pane{}
187+ second := &pane{}
188+ tb.mu.Lock()
189+ g1 := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
190+ require.True(t, tb.installFloatingLocked(first, g1))
191+ g2 := tb.clearFloatingLocked(first, g1)
192+ require.True(t, g2)
193+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
194+ require.True(t, tb.installFloatingLocked(second, generation))
195+ tb.mu.Unlock()
196+ d.reapFloating(sess, tb, first, g1)
197+ tb.mu.Lock()
198+ require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
199+ tb.mu.Unlock()
200+}
201+
202+func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
203+ pty, _ := newBlockingPTY(t)
204+ tb := newFloatingTestTab(t)
205+ p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
206+ tb.mu.Lock()
207+ g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false)
208+ require.True(t, tb.installFloatingLocked(p, g))
209+ tb.mu.Unlock()
210+ d := newTestDaemon(t, nil, stubClock{})
211+ d.teardownFloating(tb)
212+ d.teardownFloating(tb)
213+ pty.AssertNumberOfCalls(t, "Close", 1)
214+}
215+
216+func newFloatingTestTab(t *testing.T) *tab {
217+ t.Helper()
218+ tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
219+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
220+ return tb
221+}
222+
223+func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY {
224+ t.Helper()
225+ p, _ := newBlockingPTY(t)
226+ return p
227+}
228+
229+func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
230+ d := newTestDaemon(t, nil, stubClock{})
231+ tb := newFloatingTestTab(t)
232+ sess := &session{tabs: []*tab{tb}, ctx: t.Context()}
233+ tb.mu.Lock()
234+ g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false)
235+ floating := &pane{}
236+ require.True(t, tb.installFloatingLocked(floating, g))
237+ tb.mu.Unlock()
238+ require.NoError(t, d.toggleFloating(sess, nil))
239+ tb.mu.Lock()
240+ require.Same(t, floating, tb.terminalTargetLocked())
241+ tb.mu.Unlock()
242+}
243+
95244 func TestFloatingInnerSize(t *testing.T) {
96245 tests := []struct {
97246 name string
@@ -1,9 +1,18 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+ "sync"
4 "testing"6 "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
5 11
6 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/internal/usecase/layout"
7 )16 )
8 17
9 func TestFloatingSlotTransitions(t *testing.T) {18 func TestFloatingSlotTransitions(t *testing.T) {
@@ -92,6 +101,146 @@ func TestFloatingSlotTransitions(t *testing.T) {
92 }101 }
93 }102 }
94 103
104+func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testing.T) {
105+ pty := portsmocks.NewMockPTY(t)
106+ readerStarted := make(chan struct{})
107+ release := make(chan struct{})
108+ var releaseOnce sync.Once
109+ unblock := func() { releaseOnce.Do(func() { close(release) }) }
110+ pty.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
111+ close(readerStarted)
112+ <-release
113+ return 0, context.Canceled
114+ }).Once()
115+ pty.EXPECT().Close().RunAndReturn(func() error { unblock(); return nil }).Once()
116+ factory := portsmocks.NewMockPTYFactory(t)
117+ opened := make(chan struct{})
118+ allowOpen := make(chan struct{})
119+ var gotCommand, gotDir string
120+ var gotArgs []string
121+ var gotSize domain.Size
122+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
123+ func(command string, args, _ []string, dir string, size domain.Size) (ports.PTY, error) {
124+ gotCommand, gotArgs, gotDir, gotSize = command, append([]string(nil), args...), dir, size
125+ close(opened)
126+ <-allowOpen
127+ return pty, nil
128+ }).Once()
129+ d := newTestDaemon(t, factory, stubClock{})
130+ d.shell = "/bin/custom-shell"
131+ tb := newTabWithStableID("t_stable", "p_normal", newBlockingPanePTY(t), domain.Size{Cols: 100, Rows: 40})
132+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
133+ sess := &session{name: "work", cwd: "/captured", tabs: []*tab{tb}, ctx: t.Context()}
134+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "btop --utf", Width: 50, Height: 50}})
135+ d.ensureFloatingWarm(sess, tb)
136+ // Open has started while this goroutine owns tab.mu: an external factory
137+ // call under that lock would deadlock this channel-controlled test.
138+ tb.mu.Lock()
139+ select {
140+ case <-opened:
141+ case <-time.After(time.Second):
142+ t.Fatal("floating Open waited for tab.mu")
143+ }
144+ tb.mu.Unlock()
145+ d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Command: "later", Width: 90, Height: 90}})
146+ close(allowOpen)
147+ select {
148+ case <-readerStarted: // install started exactly one reader after the async Open
149+ case <-time.After(time.Second):
150+ t.Fatal("floating pane was not installed")
151+ }
152+ require.Equal(t, "/bin/custom-shell", gotCommand)
153+ require.Equal(t, []string{"-lc", "btop --utf"}, gotArgs)
154+ require.Equal(t, "/captured", gotDir)
155+ require.Equal(t, domain.Size{Cols: 48, Rows: 18}, gotSize)
156+ d.teardownFloating(tb)
157+ unblock()
158+}
159+
160+func TestFloatingLifecycleStaleSuccessAndOldExitCannotReplaceCurrentSlot(t *testing.T) {
161+ pty := portsmocks.NewMockPTY(t)
162+ closed := make(chan struct{})
163+ pty.EXPECT().Close().RunAndReturn(func() error { close(closed); return nil }).Once()
164+ factory := portsmocks.NewMockPTYFactory(t)
165+ opened := make(chan struct{})
166+ allowOpen := make(chan struct{})
167+ factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
168+ func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
169+ close(opened)
170+ <-allowOpen
171+ return pty, nil
172+ }).Once()
173+ d := newTestDaemon(t, factory, stubClock{})
174+ tb := newFloatingTestTab(t)
175+ sess := &session{name: "work", tabs: []*tab{tb}, ctx: t.Context()}
176+ d.ensureFloatingWarm(sess, tb)
177+ <-opened
178+ d.teardownFloating(tb) // invalidate before the delayed Open returns
179+ close(allowOpen)
180+ select {
181+ case <-closed:
182+ case <-time.After(time.Second):
183+ t.Fatal("stale completion did not close its PTY")
184+ }
185+
186+ first := &pane{}
187+ second := &pane{}
188+ tb.mu.Lock()
189+ g1 := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
190+ require.True(t, tb.installFloatingLocked(first, g1))
191+ g2 := tb.clearFloatingLocked(first, g1)
192+ require.True(t, g2)
193+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, true)
194+ require.True(t, tb.installFloatingLocked(second, generation))
195+ tb.mu.Unlock()
196+ d.reapFloating(sess, tb, first, g1)
197+ tb.mu.Lock()
198+ require.Same(t, second, tb.floating.pane, "old EOF must not clear replacement")
199+ tb.mu.Unlock()
200+}
201+
202+func TestFloatingTeardownClosesInstalledPaneOnce(t *testing.T) {
203+ pty, _ := newBlockingPTY(t)
204+ tb := newFloatingTestTab(t)
205+ p := newPane(layout.PaneID("floating"), pty, domain.Size{Cols: 10, Rows: 10})
206+ tb.mu.Lock()
207+ g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false)
208+ require.True(t, tb.installFloatingLocked(p, g))
209+ tb.mu.Unlock()
210+ d := newTestDaemon(t, nil, stubClock{})
211+ d.teardownFloating(tb)
212+ d.teardownFloating(tb)
213+ pty.AssertNumberOfCalls(t, "Close", 1)
214+}
215+
216+func newFloatingTestTab(t *testing.T) *tab {
217+ t.Helper()
218+ tb := newTabWithStableID("t_test", "p_test", newBlockingPanePTY(t), domain.Size{Cols: 80, Rows: 24})
219+ tb.ctx, tb.cancel = context.WithCancel(t.Context())
220+ return tb
221+}
222+
223+func newBlockingPanePTY(t *testing.T) *portsmocks.MockPTY {
224+ t.Helper()
225+ p, _ := newBlockingPTY(t)
226+ return p
227+}
228+
229+func TestFloatingToggleUsesVisibleTarget(t *testing.T) {
230+ d := newTestDaemon(t, nil, stubClock{})
231+ tb := newFloatingTestTab(t)
232+ sess := &session{tabs: []*tab{tb}, ctx: t.Context()}
233+ tb.mu.Lock()
234+ g := tb.beginFloatingWarmLocked(domain.FloatingConfig{}, false)
235+ floating := &pane{}
236+ require.True(t, tb.installFloatingLocked(floating, g))
237+ tb.mu.Unlock()
238+ require.NoError(t, d.toggleFloating(sess, nil))
239+ tb.mu.Lock()
240+ require.Same(t, floating, tb.terminalTargetLocked())
241+ tb.mu.Unlock()
242+}
243+
95 func TestFloatingInnerSize(t *testing.T) {244 func TestFloatingInnerSize(t *testing.T) {
96 tests := []struct {245 tests := []struct {
97 name string246 name string
M internal/usecase/daemon/snapshot_restore_test.go +26 -0
@@ -105,6 +105,32 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
105105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
106106 }
107107
108+func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110+ Name: "work", Active: 0, Tabs: []snapcodec.Tab{
111+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
112+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
113+ },
114+ })}}}
115+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
116+ WithSnapshotStore(store)(d)
117+ d.restoreSnapshots(context.Background())
118+ d.mu.Lock()
119+ restored := d.findByNameLocked("work")
120+ d.mu.Unlock()
121+ require.NotNil(t, restored)
122+ restored.mu.Lock()
123+ tabs := append([]*tab(nil), restored.tabs...)
124+ restored.mu.Unlock()
125+ require.Len(t, tabs, 2)
126+ for _, tb := range tabs {
127+ tb.mu.Lock()
128+ require.Equal(t, floatingUninitialized, tb.floating.state)
129+ require.Nil(t, tb.floating.pane)
130+ tb.mu.Unlock()
131+ }
132+}
133+
108134 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
109135 t.Helper()
110136 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
@@ -105,6 +105,32 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)105 require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID)
106 }106 }
107 107
108+func TestRestoreSnapshotsLeavesFloatingUninitializedAndInactiveTabsCold(t *testing.T) {
109+ store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "work", Data: mustSnapshotBytes(t, snapcodec.Session{
110+ Name: "work", Active: 0, Tabs: []snapcodec.Tab{
111+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/one"}}},
112+ {Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/two"}}},
113+ },
114+ })}}}
115+ d := newTestDaemon(t, &restorePTYFactory{}, stubClock{})
116+ WithSnapshotStore(store)(d)
117+ d.restoreSnapshots(context.Background())
118+ d.mu.Lock()
119+ restored := d.findByNameLocked("work")
120+ d.mu.Unlock()
121+ require.NotNil(t, restored)
122+ restored.mu.Lock()
123+ tabs := append([]*tab(nil), restored.tabs...)
124+ restored.mu.Unlock()
125+ require.Len(t, tabs, 2)
126+ for _, tb := range tabs {
127+ tb.mu.Lock()
128+ require.Equal(t, floatingUninitialized, tb.floating.state)
129+ require.Nil(t, tb.floating.pane)
130+ tb.mu.Unlock()
131+ }
132+}
133+
108 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {134 func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore {
109 t.Helper()135 t.Helper()
110 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{136 return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{
M internal/usecase/daemon/snapshot_test.go +24 -0
@@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
6363 }
6464 }
6565
66+func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
71+ tb := sess.tabs[0]
72+ floatingPTY, _ := newBlockingPTY(t)
73+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
74+ tb.mu.Lock()
75+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{Command: "top"}, false)
76+ require.True(t, tb.installFloatingLocked(floating, generation))
77+ tb.mu.Unlock()
78+
79+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
80+ snap, err := snapcodec.Unmarshal(data)
81+ require.NoError(t, err)
82+ require.Len(t, snap.Tabs, 1)
83+ require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted")
84+ require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID)
85+ return nil
86+ }).Once()
87+ require.True(t, d.captureSession(sess))
88+}
89+
6690 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
6791 store := portsmocks.NewMockSnapshotStore(t)
6892 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
@@ -63,6 +63,30 @@ func TestSnapshotSaverWritesDirtyNamedSessionsOnly(t *testing.T) {
63 }63 }
64 }64 }
65 65
66+func TestSnapshotCaptureExcludesFloatingRuntime(t *testing.T) {
67+ store := portsmocks.NewMockSnapshotStore(t)
68+ d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
69+ WithSnapshotStore(store)(d)
70+ sess := newSnapshotTestSession(t, "work", false, "/fallback")
71+ tb := sess.tabs[0]
72+ floatingPTY, _ := newBlockingPTY(t)
73+ floating := newPane(layout.PaneID("floating"), floatingPTY, domain.Size{Cols: 20, Rows: 8})
74+ tb.mu.Lock()
75+ generation := tb.beginFloatingWarmLocked(domain.FloatingConfig{Command: "top"}, false)
76+ require.True(t, tb.installFloatingLocked(floating, generation))
77+ tb.mu.Unlock()
78+
79+ store.EXPECT().Write("work", mock.Anything).RunAndReturn(func(_ string, data []byte) error {
80+ snap, err := snapcodec.Unmarshal(data)
81+ require.NoError(t, err)
82+ require.Len(t, snap.Tabs, 1)
83+ require.Len(t, snap.Tabs[0].Panes, 1, "floating runtime must not be persisted")
84+ require.Equal(t, layout.PaneID("pane-1"), snap.Tabs[0].Panes[0].ID)
85+ return nil
86+ }).Once()
87+ require.True(t, d.captureSession(sess))
88+}
89+
66 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {90 func TestSnapshotCaptureStoresForegroundProcessMetadata(t *testing.T) {
67 store := portsmocks.NewMockSnapshotStore(t)91 store := portsmocks.NewMockSnapshotStore(t)
68 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})92 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})

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