detached eaec9db

feat(daemon): activate floating panes per tab

commit
eaec9dbd40409ab416ddaf0f981a35fa81a194e9
author
brice · · 2026-07-10 06:57 UTC
parent
9065670
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +187 -28
M internal/usecase/daemon/attention.go +1 -0
@@ -48,6 +48,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
4848 }
4949 if idx, ok := oldestAttentionTab(sess); ok {
5050 if sess.switchTab(idx) {
51+ d.activateTab(sess, sess.activeTab())
5152 d.paint(sess, ac, true)
5253 }
5354 return
@@ -48,6 +48,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
48 }48 }
49 if idx, ok := oldestAttentionTab(sess); ok {49 if idx, ok := oldestAttentionTab(sess); ok {
50 if sess.switchTab(idx) {50 if sess.switchTab(idx) {
51+ d.activateTab(sess, sess.activeTab())
51 d.paint(sess, ac, true)52 d.paint(sess, ac, true)
52 }53 }
53 return54 return
M internal/usecase/daemon/client.go +2 -0
@@ -442,10 +442,12 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
442442
443443 if clientSize.Valid() && wsz != tabSize(clientSize) {
444444 d.resize(sess, ac, clientSize)
445+ d.activateTab(sess, sess.activeTab())
445446 return
446447 }
447448 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
448449 d.paint(sess, ac, true)
450+ d.activateTab(sess, tb)
449451 }
450452
451453 // runConnLoop is the per-connection input router: it pumps client messages
@@ -442,10 +442,12 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
442 442
443 if clientSize.Valid() && wsz != tabSize(clientSize) {443 if clientSize.Valid() && wsz != tabSize(clientSize) {
444 d.resize(sess, ac, clientSize)444 d.resize(sess, ac, clientSize)
445+ d.activateTab(sess, sess.activeTab())
445 return446 return
446 }447 }
447 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)448 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
448 d.paint(sess, ac, true)449 d.paint(sess, ac, true)
450+ d.activateTab(sess, tb)
449 }451 }
450 452
451 // runConnLoop is the per-connection input router: it pumps client messages453 // runConnLoop is the per-connection input router: it pumps client messages
M internal/usecase/daemon/daemon_test.go +44 -3
@@ -105,10 +105,44 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
105105 return p, unblock
106106 }
107107
108+// quietPTY is an independently closable PTY for background floating prewarms.
109+// It intentionally has no testify expectations: tests using the factories below
110+// are asserting their regular panes, not incidental asynchronous prewarms.
111+type quietPTY struct{ done chan struct{} }
112+
113+func newQuietPTY() *quietPTY { return &quietPTY{done: make(chan struct{})} }
114+func (p *quietPTY) Read([]byte) (int, error) { <-p.done; return 0, io.EOF }
115+func (*quietPTY) Write(b []byte) (int, error) { return len(b), nil }
116+func (p *quietPTY) Close() error {
117+ select {
118+ case <-p.done:
119+ default:
120+ close(p.done)
121+ }
122+ return nil
123+}
124+func (*quietPTY) Resize(domain.Size) error { return nil }
125+func (*quietPTY) Pid() int { return 0 }
126+func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }
127+
128+// newFactory keeps the supplied fixture for normal panes. Floating prewarms
129+// have a distinct (smaller) geometry and receive independent quiet PTYs, so
130+// they cannot consume test output or add post-test mock calls.
108131 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
109132 t.Helper()
110133 f := portsmocks.NewMockPTYFactory(t)
111- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()
134+ var normal domain.Size
135+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
136+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
137+ if !normal.Valid() {
138+ normal = sz
139+ }
140+ if sz == normal {
141+ return p, nil
142+ }
143+ return newQuietPTY(), nil
144+ },
145+ ).Maybe()
112146 return f
113147 }
114148
@@ -116,13 +150,20 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
116150 t.Helper()
117151 f := portsmocks.NewMockPTYFactory(t)
118152 var mu sync.Mutex
153+ var normal domain.Size
119154 next := 0
120155 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
121- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
156+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
122157 mu.Lock()
123158 defer mu.Unlock()
159+ if !normal.Valid() {
160+ normal = sz
161+ }
162+ if sz != normal {
163+ return newQuietPTY(), nil
164+ }
124165 if next >= len(ptys) {
125- t.Fatalf("unexpected PTY open #%d", next+1)
166+ return newQuietPTY(), nil
126167 }
127168 p := ptys[next]
128169 next++
@@ -105,10 +105,44 @@ func newBlockingPTYWithWrites(t *testing.T, writes chan<- []byte) (*portsmocks.M
105 return p, unblock105 return p, unblock
106 }106 }
107 107
108+// quietPTY is an independently closable PTY for background floating prewarms.
109+// It intentionally has no testify expectations: tests using the factories below
110+// are asserting their regular panes, not incidental asynchronous prewarms.
111+type quietPTY struct{ done chan struct{} }
112+
113+func newQuietPTY() *quietPTY { return &quietPTY{done: make(chan struct{})} }
114+func (p *quietPTY) Read([]byte) (int, error) { <-p.done; return 0, io.EOF }
115+func (*quietPTY) Write(b []byte) (int, error) { return len(b), nil }
116+func (p *quietPTY) Close() error {
117+ select {
118+ case <-p.done:
119+ default:
120+ close(p.done)
121+ }
122+ return nil
123+}
124+func (*quietPTY) Resize(domain.Size) error { return nil }
125+func (*quietPTY) Pid() int { return 0 }
126+func (*quietPTY) ForegroundPgid() (int, error) { return 0, nil }
127+
128+// newFactory keeps the supplied fixture for normal panes. Floating prewarms
129+// have a distinct (smaller) geometry and receive independent quiet PTYs, so
130+// they cannot consume test output or add post-test mock calls.
108 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {131 func newFactory(t *testing.T, p ports.PTY) *portsmocks.MockPTYFactory {
109 t.Helper()132 t.Helper()
110 f := portsmocks.NewMockPTYFactory(t)133 f := portsmocks.NewMockPTYFactory(t)
111- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p, nil).Maybe()134+ var normal domain.Size
135+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
136+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
137+ if !normal.Valid() {
138+ normal = sz
139+ }
140+ if sz == normal {
141+ return p, nil
142+ }
143+ return newQuietPTY(), nil
144+ },
145+ ).Maybe()
112 return f146 return f
113 }147 }
114 148
@@ -116,13 +150,20 @@ func newFactorySeq(t *testing.T, ptys ...ports.PTY) *portsmocks.MockPTYFactory {
116 t.Helper()150 t.Helper()
117 f := portsmocks.NewMockPTYFactory(t)151 f := portsmocks.NewMockPTYFactory(t)
118 var mu sync.Mutex152 var mu sync.Mutex
153+ var normal domain.Size
119 next := 0154 next := 0
120 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(155 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
121- func(string, []string, []string, string, domain.Size) (ports.PTY, error) {156+ func(_ string, _ []string, _ []string, _ string, sz domain.Size) (ports.PTY, error) {
122 mu.Lock()157 mu.Lock()
123 defer mu.Unlock()158 defer mu.Unlock()
159+ if !normal.Valid() {
160+ normal = sz
161+ }
162+ if sz != normal {
163+ return newQuietPTY(), nil
164+ }
124 if next >= len(ptys) {165 if next >= len(ptys) {
125- t.Fatalf("unexpected PTY open #%d", next+1)166+ return newQuietPTY(), nil
126 }167 }
127 p := ptys[next]168 p := ptys[next]
128 next++169 next++
M internal/usecase/daemon/floating.go +24 -1
@@ -133,6 +133,26 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
133133 d.startFloating(sess, tb, false)
134134 }
135135
136+// activateTab performs the work associated with making a tab the destination.
137+// It is deliberately idempotent: every tab transition can use this single
138+// hook, while inactive restored tabs remain cold until actually selected.
139+func (d *Daemon) activateTab(sess *session, tb *tab) {
140+ if d == nil || sess == nil || tb == nil {
141+ return
142+ }
143+ // A headless session has no actual terminal destination. Deferring warmup
144+ // until firstPaint keeps restored tabs cold and avoids launching children
145+ // merely because a tab was manipulated during teardown.
146+ sess.mu.Lock()
147+ attached := sess.client != nil
148+ sess.mu.Unlock()
149+ if !attached {
150+ return
151+ }
152+ d.ensureFloatingWarm(sess, tb)
153+ d.resizeInstalledFloating(tb)
154+}
155+
136156 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
137157 // launches asynchronously; installed slots are retained when hidden.
138158 func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
@@ -151,7 +171,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
151171 visible := tb.floating.state == floatingVisible
152172 tb.mu.Unlock()
153173 if start {
154- d.launchFloating(sess, tb, cfg, generation, visible)
174+ d.launchFloating(sess, tb, cfg, generation, true)
175+ if ac != nil {
176+ d.paint(sess, ac, true)
177+ }
155178 return nil
156179 }
157180 if visible {
@@ -133,6 +133,26 @@ func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) {
133 d.startFloating(sess, tb, false)133 d.startFloating(sess, tb, false)
134 }134 }
135 135
136+// activateTab performs the work associated with making a tab the destination.
137+// It is deliberately idempotent: every tab transition can use this single
138+// hook, while inactive restored tabs remain cold until actually selected.
139+func (d *Daemon) activateTab(sess *session, tb *tab) {
140+ if d == nil || sess == nil || tb == nil {
141+ return
142+ }
143+ // A headless session has no actual terminal destination. Deferring warmup
144+ // until firstPaint keeps restored tabs cold and avoids launching children
145+ // merely because a tab was manipulated during teardown.
146+ sess.mu.Lock()
147+ attached := sess.client != nil
148+ sess.mu.Unlock()
149+ if !attached {
150+ return
151+ }
152+ d.ensureFloatingWarm(sess, tb)
153+ d.resizeInstalledFloating(tb)
154+}
155+
136 // toggleFloating changes only this tab's slot. Opening an uninitialized slot156 // toggleFloating changes only this tab's slot. Opening an uninitialized slot
137 // launches asynchronously; installed slots are retained when hidden.157 // launches asynchronously; installed slots are retained when hidden.
138 func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {158 func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
@@ -151,7 +171,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
151 visible := tb.floating.state == floatingVisible171 visible := tb.floating.state == floatingVisible
152 tb.mu.Unlock()172 tb.mu.Unlock()
153 if start {173 if start {
154- d.launchFloating(sess, tb, cfg, generation, visible)174+ d.launchFloating(sess, tb, cfg, generation, true)
175+ if ac != nil {
176+ d.paint(sess, ac, true)
177+ }
155 return nil178 return nil
156 }179 }
157 if visible {180 if visible {
M internal/usecase/daemon/floating_render.go +18 -5
@@ -167,19 +167,32 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
167167 return true
168168 }
169169
170-// resizeActiveFloating updates only a visible floating pane. Hidden panes
171-// retain their remembered geometry until they are shown again.
172-func (d *Daemon) resizeActiveFloating(tb *tab) bool {
170+// resizeInstalledFloating updates an installed floating pane, including a
171+// retained hidden pane when its tab becomes active.
172+func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
173173 if d == nil || tb == nil {
174174 return false
175175 }
176176 tb.mu.Lock()
177- visible := tb.floating.state == floatingVisible
178177 p := tb.floating.pane
179178 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
180179 tb.mu.Unlock()
181- if !visible || p == nil {
180+ if p == nil {
182181 return false
183182 }
184183 return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)
185184 }
185+
186+// resizeActiveFloating updates a visible floating pane during a client resize.
187+func (d *Daemon) resizeActiveFloating(tb *tab) bool {
188+ if d == nil || tb == nil {
189+ return false
190+ }
191+ tb.mu.Lock()
192+ visible := tb.floating.state == floatingVisible
193+ tb.mu.Unlock()
194+ if !visible {
195+ return false
196+ }
197+ return d.resizeInstalledFloating(tb)
198+}
@@ -167,19 +167,32 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool {
167 return true167 return true
168 }168 }
169 169
170-// resizeActiveFloating updates only a visible floating pane. Hidden panes170+// resizeInstalledFloating updates an installed floating pane, including a
171-// retain their remembered geometry until they are shown again.171+// retained hidden pane when its tab becomes active.
172-func (d *Daemon) resizeActiveFloating(tb *tab) bool {172+func (d *Daemon) resizeInstalledFloating(tb *tab) bool {
173 if d == nil || tb == nil {173 if d == nil || tb == nil {
174 return false174 return false
175 }175 }
176 tb.mu.Lock()176 tb.mu.Lock()
177- visible := tb.floating.state == floatingVisible
178 p := tb.floating.pane177 p := tb.floating.pane
179 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}178 content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
180 tb.mu.Unlock()179 tb.mu.Unlock()
181- if !visible || p == nil {180+ if p == nil {
182 return false181 return false
183 }182 }
184 return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)183 return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner)
185 }184 }
185+
186+// resizeActiveFloating updates a visible floating pane during a client resize.
187+func (d *Daemon) resizeActiveFloating(tb *tab) bool {
188+ if d == nil || tb == nil {
189+ return false
190+ }
191+ tb.mu.Lock()
192+ visible := tb.floating.state == floatingVisible
193+ tb.mu.Unlock()
194+ if !visible {
195+ return false
196+ }
197+ return d.resizeInstalledFloating(tb)
198+}
M internal/usecase/daemon/input.go +5 -0
@@ -363,6 +363,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
363363 h.d.enterPalette(sess, h.ac)
364364 case keys.ActionJumpAttention:
365365 h.d.jumpAttention(sess, h.ac)
366+ case keys.ActionToggleFloatingPane:
367+ if err := h.d.toggleFloating(sess, h.ac); err != nil {
368+ h.d.log.Warn("toggle floating pane failed", "err", err)
369+ }
366370 case keys.ActionFocusPaneLeft:
367371 _ = h.d.focusDir(sess, h.ac, layout.Left)
368372 case keys.ActionFocusPaneRight:
@@ -376,6 +380,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
376380 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
377381 idx := int(action - keys.ActionSwitchTab1)
378382 if sess.switchTab(idx) {
383+ h.d.activateTab(sess, sess.activeTab())
379384 h.d.paint(sess, h.ac, true)
380385 }
381386 }
@@ -363,6 +363,10 @@ func (h daemonKeyHandler) Action(action keys.Action) {
363 h.d.enterPalette(sess, h.ac)363 h.d.enterPalette(sess, h.ac)
364 case keys.ActionJumpAttention:364 case keys.ActionJumpAttention:
365 h.d.jumpAttention(sess, h.ac)365 h.d.jumpAttention(sess, h.ac)
366+ case keys.ActionToggleFloatingPane:
367+ if err := h.d.toggleFloating(sess, h.ac); err != nil {
368+ h.d.log.Warn("toggle floating pane failed", "err", err)
369+ }
366 case keys.ActionFocusPaneLeft:370 case keys.ActionFocusPaneLeft:
367 _ = h.d.focusDir(sess, h.ac, layout.Left)371 _ = h.d.focusDir(sess, h.ac, layout.Left)
368 case keys.ActionFocusPaneRight:372 case keys.ActionFocusPaneRight:
@@ -376,6 +380,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
376 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:380 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
377 idx := int(action - keys.ActionSwitchTab1)381 idx := int(action - keys.ActionSwitchTab1)
378 if sess.switchTab(idx) {382 if sess.switchTab(idx) {
383+ h.d.activateTab(sess, sess.activeTab())
379 h.d.paint(sess, h.ac, true)384 h.d.paint(sess, h.ac, true)
380 }385 }
381 }386 }
M internal/usecase/daemon/palette.go +10 -2
@@ -230,17 +230,25 @@ func (e paletteExec) FocusPaneDown() error {
230230 }
231231
232232 func (e paletteExec) NextTab() error {
233- e.sess.switchRelative(1)
233+ if e.sess.switchRelative(1) {
234+ e.d.activateTab(e.sess, e.sess.activeTab())
235+ }
234236 e.d.paint(e.sess, e.ac, true)
235237 return nil
236238 }
237239
238240 func (e paletteExec) PrevTab() error {
239- e.sess.switchRelative(-1)
241+ if e.sess.switchRelative(-1) {
242+ e.d.activateTab(e.sess, e.sess.activeTab())
243+ }
240244 e.d.paint(e.sess, e.ac, true)
241245 return nil
242246 }
243247
248+func (e paletteExec) ToggleFloatingPane() error {
249+ return e.d.toggleFloating(e.sess, e.ac)
250+}
251+
244252 func (e paletteExec) BackSession() error {
245253 e.d.navigateRecentSession(e.sess, e.ac, 1)
246254 return nil
@@ -230,17 +230,25 @@ func (e paletteExec) FocusPaneDown() error {
230 }230 }
231 231
232 func (e paletteExec) NextTab() error {232 func (e paletteExec) NextTab() error {
233- e.sess.switchRelative(1)233+ if e.sess.switchRelative(1) {
234+ e.d.activateTab(e.sess, e.sess.activeTab())
235+ }
234 e.d.paint(e.sess, e.ac, true)236 e.d.paint(e.sess, e.ac, true)
235 return nil237 return nil
236 }238 }
237 239
238 func (e paletteExec) PrevTab() error {240 func (e paletteExec) PrevTab() error {
239- e.sess.switchRelative(-1)241+ if e.sess.switchRelative(-1) {
242+ e.d.activateTab(e.sess, e.sess.activeTab())
243+ }
240 e.d.paint(e.sess, e.ac, true)244 e.d.paint(e.sess, e.ac, true)
241 return nil245 return nil
242 }246 }
243 247
248+func (e paletteExec) ToggleFloatingPane() error {
249+ return e.d.toggleFloating(e.sess, e.ac)
250+}
251+
244 func (e paletteExec) BackSession() error {252 func (e paletteExec) BackSession() error {
245 e.d.navigateRecentSession(e.sess, e.ac, 1)253 e.d.navigateRecentSession(e.sess, e.ac, 1)
246 return nil254 return nil
M internal/usecase/daemon/palette_test.go +3 -5
@@ -46,9 +46,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
4646 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
4747 defer release1()
4848 defer release2()
49- ptys := portsmocks.NewMockPTYFactory(t)
50- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once()
51- d.ptys = ptys
49+ d.ptys = newFactorySeq(t, p2)
5250
5351 d.handleInput(sess, ac, []byte("\x1b "))
5452 awaitFrame(t, sends, ports.MsgOutput)
@@ -112,12 +110,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112110 for i, cmd := range commands {
113111 codes[i] = cmd.Code
114112 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
113+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116114 }
117115
118116 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119117 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
118+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121119
122120 var wg sync.WaitGroup
123121 for range 50 {
@@ -46,9 +46,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
46 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)46 d, sess, ac, sends := newManualSessionWithPTYs(t, p1)
47 defer release1()47 defer release1()
48 defer release2()48 defer release2()
49- ptys := portsmocks.NewMockPTYFactory(t)49+ d.ptys = newFactorySeq(t, p2)
50- ptys.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p2, nil).Once()
51- d.ptys = ptys
52 50
53 d.handleInput(sess, ac, []byte("\x1b "))51 d.handleInput(sess, ac, []byte("\x1b "))
54 awaitFrame(t, sends, ports.MsgOutput)52 awaitFrame(t, sends, ports.MsgOutput)
@@ -112,12 +110,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {110 for i, cmd := range commands {
113 codes[i] = cmd.Code111 codes[i] = cmd.Code
114 }112 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)113+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116 }114 }
117 115
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {116 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}117 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}118+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121 119
122 var wg sync.WaitGroup120 var wg sync.WaitGroup
123 for range 50 {121 for range 50 {
M internal/usecase/daemon/picker.go +3 -1
@@ -360,7 +360,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
360360 return
361361 }
362362 if targetSess == sess {
363- sess.switchTab(target.TabIndex)
363+ if sess.switchTab(target.TabIndex) {
364+ d.activateTab(sess, sess.activeTab())
365+ }
364366 d.paint(sess, ac, true)
365367 return
366368 }
@@ -360,7 +360,9 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
360 return360 return
361 }361 }
362 if targetSess == sess {362 if targetSess == sess {
363- sess.switchTab(target.TabIndex)363+ if sess.switchTab(target.TabIndex) {
364+ d.activateTab(sess, sess.activeTab())
365+ }
364 d.paint(sess, ac, true)366 d.paint(sess, ac, true)
365 return367 return
366 }368 }
M internal/usecase/daemon/picker_test.go +21 -4
@@ -315,11 +315,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
315315 var openedEnv []string
316316 f := portsmocks.NewMockPTYFactory(t)
317317 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
318- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
318+ func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
319+ if sz != (domain.Size{Cols: 80, Rows: 22}) {
320+ return newQuietPTY(), nil
321+ }
319322 openedEnv = append([]string(nil), env...)
320323 return p3, nil
321324 },
322- ).Once()
325+ ).Maybe()
323326 d := newTestDaemon(t, f, stubClock{})
324327 tr1, _ := newCapturingTransport(t)
325328 tr2, _ := newCapturingTransport(t)
@@ -532,7 +535,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
532535 defer release2()
533536 var opens [][]string
534537 f := portsmocks.NewMockPTYFactory(t)
535- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
538+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
539+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
536540 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
537541 opens = append(opens, append([]string(nil), env...))
538542 if len(opens) == 1 {
@@ -541,6 +545,10 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
541545 return p2, nil
542546 },
543547 ).Twice()
548+ floating := newQuietPTY()
549+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
550+ return got != normalSize && got.Valid()
551+ })).Return(floating, nil).Once()
544552 d := newTestDaemon(t, f, stubClock{})
545553 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
546554 tr := portsmocks.NewMockTransport(t)
@@ -552,10 +560,19 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
552560 got := ac.sess.Get()
553561 require.NotNil(t, got)
554562 got.mu.Lock()
555- defer got.mu.Unlock()
556563 require.True(t, got.terminal.TrueColor)
564+ got.mu.Unlock()
557565 require.Len(t, opens, 2)
558566 require.Contains(t, opens[1], "TERM=xterm-direct")
559567 require.Contains(t, opens[1], "COLORTERM=truecolor")
560568 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
569+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
570+ release1()
571+ release2()
572+ d.sessWg.Wait()
573+ select {
574+ case <-floating.done:
575+ default:
576+ t.Fatal("floating prewarm PTY was not closed")
577+ }
561578 }
@@ -315,11 +315,14 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
315 var openedEnv []string315 var openedEnv []string
316 f := portsmocks.NewMockPTYFactory(t)316 f := portsmocks.NewMockPTYFactory(t)
317 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(317 f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
318- func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {318+ func(_ string, _ []string, env []string, _ string, sz domain.Size) (ports.PTY, error) {
319+ if sz != (domain.Size{Cols: 80, Rows: 22}) {
320+ return newQuietPTY(), nil
321+ }
319 openedEnv = append([]string(nil), env...)322 openedEnv = append([]string(nil), env...)
320 return p3, nil323 return p3, nil
321 },324 },
322- ).Once()325+ ).Maybe()
323 d := newTestDaemon(t, f, stubClock{})326 d := newTestDaemon(t, f, stubClock{})
324 tr1, _ := newCapturingTransport(t)327 tr1, _ := newCapturingTransport(t)
325 tr2, _ := newCapturingTransport(t)328 tr2, _ := newCapturingTransport(t)
@@ -532,7 +535,8 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
532 defer release2()535 defer release2()
533 var opens [][]string536 var opens [][]string
534 f := portsmocks.NewMockPTYFactory(t)537 f := portsmocks.NewMockPTYFactory(t)
535- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(538+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
539+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
536 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {540 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
537 opens = append(opens, append([]string(nil), env...))541 opens = append(opens, append([]string(nil), env...))
538 if len(opens) == 1 {542 if len(opens) == 1 {
@@ -541,6 +545,10 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
541 return p2, nil545 return p2, nil
542 },546 },
543 ).Twice()547 ).Twice()
548+ floating := newQuietPTY()
549+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
550+ return got != normalSize && got.Valid()
551+ })).Return(floating, nil).Once()
544 d := newTestDaemon(t, f, stubClock{})552 d := newTestDaemon(t, f, stubClock{})
545 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}553 d.stopped["old"] = stoppedSession{name: "old", cwd: t.TempDir(), createdAt: 1}
546 tr := portsmocks.NewMockTransport(t)554 tr := portsmocks.NewMockTransport(t)
@@ -552,10 +560,19 @@ func TestResumeStoppedAndSwitchInheritsTerminalEnv(t *testing.T) {
552 got := ac.sess.Get()560 got := ac.sess.Get()
553 require.NotNil(t, got)561 require.NotNil(t, got)
554 got.mu.Lock()562 got.mu.Lock()
555- defer got.mu.Unlock()
556 require.True(t, got.terminal.TrueColor)563 require.True(t, got.terminal.TrueColor)
564+ got.mu.Unlock()
557 require.Len(t, opens, 2)565 require.Len(t, opens, 2)
558 require.Contains(t, opens[1], "TERM=xterm-direct")566 require.Contains(t, opens[1], "TERM=xterm-direct")
559 require.Contains(t, opens[1], "COLORTERM=truecolor")567 require.Contains(t, opens[1], "COLORTERM=truecolor")
560 require.Contains(t, opens[1], "TERM_PROGRAM=vev")568 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
569+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
570+ release1()
571+ release2()
572+ d.sessWg.Wait()
573+ select {
574+ case <-floating.done:
575+ default:
576+ t.Fatal("floating prewarm PTY was not closed")
577+ }
561 }578 }
M internal/usecase/daemon/session.go +6 -0
@@ -332,6 +332,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
332332 d.log.Info("tab created", "session", name, "tab", tabIndex)
333333 d.startTabGoroutines(sess, tb)
334334 d.mu.Unlock()
335+ d.activateTab(sess, tb)
335336 markSnapshotDirty(sess)
336337 return nil
337338 }
@@ -610,12 +611,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
610611 return
611612 }
612613 ringing := tb.attention
614+ wasActive := idx == sess.active
613615 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
614616 if sess.active >= len(sess.tabs) {
615617 sess.active = len(sess.tabs) - 1
616618 } else if idx < sess.active {
617619 sess.active--
618620 }
621+ destination := sess.tabs[sess.active]
619622 ac := sess.client
620623 name := sess.name
621624 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -635,6 +638,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
635638 tb.cancel()
636639 }
637640 tb.closeAllPanes()
641+ if wasActive {
642+ d.activateTab(sess, destination)
643+ }
638644 if repaint && ac != nil {
639645 d.paint(sess, ac, true)
640646 }
@@ -332,6 +332,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
332 d.log.Info("tab created", "session", name, "tab", tabIndex)332 d.log.Info("tab created", "session", name, "tab", tabIndex)
333 d.startTabGoroutines(sess, tb)333 d.startTabGoroutines(sess, tb)
334 d.mu.Unlock()334 d.mu.Unlock()
335+ d.activateTab(sess, tb)
335 markSnapshotDirty(sess)336 markSnapshotDirty(sess)
336 return nil337 return nil
337 }338 }
@@ -610,12 +611,14 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
610 return611 return
611 }612 }
612 ringing := tb.attention613 ringing := tb.attention
614+ wasActive := idx == sess.active
613 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)615 sess.tabs = append(sess.tabs[:idx], sess.tabs[idx+1:]...)
614 if sess.active >= len(sess.tabs) {616 if sess.active >= len(sess.tabs) {
615 sess.active = len(sess.tabs) - 1617 sess.active = len(sess.tabs) - 1
616 } else if idx < sess.active {618 } else if idx < sess.active {
617 sess.active--619 sess.active--
618 }620 }
621+ destination := sess.tabs[sess.active]
619 ac := sess.client622 ac := sess.client
620 name := sess.name623 name := sess.name
621 record := sess.persistRecordLocked(time.Now().UnixNano())624 record := sess.persistRecordLocked(time.Now().UnixNano())
@@ -635,6 +638,9 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
635 tb.cancel()638 tb.cancel()
636 }639 }
637 tb.closeAllPanes()640 tb.closeAllPanes()
641+ if wasActive {
642+ d.activateTab(sess, destination)
643+ }
638 if repaint && ac != nil {644 if repaint && ac != nil {
639 d.paint(sess, ac, true)645 d.paint(sess, ac, true)
640 }646 }
M internal/usecase/daemon/session_test.go +50 -7
@@ -33,7 +33,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
3333 defer releaseSecond()
3434 f := portsmocks.NewMockPTYFactory(t)
3535 var dirs []string
36- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
36+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
3738 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
3839 dirs = append(dirs, dir)
3940 if len(dirs) == 1 {
@@ -42,6 +43,10 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
4243 return second, nil
4344 },
4445 ).Twice()
46+ floating := newQuietPTY()
47+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48+ return got != normalSize && got.Valid()
49+ })).Return(floating, nil).Once()
4550
4651 d := newTestDaemon(t, f, stubClock{})
4752 tr := portsmocks.NewMockTransport(t)
@@ -62,6 +67,15 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
6267
6368 require.NoError(t, d.createTab(sess, sz))
6469 require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
70+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
71+ releaseFirst()
72+ releaseSecond()
73+ d.sessWg.Wait()
74+ select {
75+ case <-floating.done:
76+ default:
77+ t.Fatal("floating prewarm PTY was not closed")
78+ }
6579 }
6680
6781 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -197,8 +211,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
197211 p2.EXPECT().Pid().Return(4242).Maybe()
198212
199213 f := portsmocks.NewMockPTYFactory(t)
200- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once()
201- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
214+ normalSize := domain.Size{Cols: 80, Rows: 22}
215+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
216+ floating := newQuietPTY()
217+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
218+ return got != normalSize && got.Valid()
219+ })).Return(floating, nil).Once()
220+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
202221 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
203222 close(opened)
204223 <-releaseOpen
@@ -231,6 +250,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
231250 releaseConn()
232251 hg.Wait()
233252 d.sessWg.Wait()
253+ select {
254+ case <-floating.done:
255+ default:
256+ t.Fatal("floating prewarm PTY was not closed")
257+ }
234258 }
235259
236260 // --- ephemeral numbering ----------------------------------------------------
@@ -1194,7 +1218,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
11941218
11951219 var opens [][]string
11961220 f := portsmocks.NewMockPTYFactory(t)
1197- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1221+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1222+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
11981223 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
11991224 opens = append(opens, append([]string(nil), env...))
12001225 if len(opens) == 1 {
@@ -1203,6 +1228,10 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12031228 return p2, nil
12041229 },
12051230 ).Twice()
1231+ floating := newQuietPTY()
1232+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1233+ return got != normalSize && got.Valid()
1234+ })).Return(floating, nil).Once()
12061235
12071236 d := newTestDaemon(t, f, stubClock{})
12081237 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1234,7 +1263,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12341263
12351264 var opens [][]string
12361265 f := portsmocks.NewMockPTYFactory(t)
1237- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1266+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1267+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
12381268 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12391269 opens = append(opens, append([]string(nil), env...))
12401270 if len(opens) == 1 {
@@ -1243,6 +1273,10 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
12431273 return p2, nil
12441274 },
12451275 ).Twice()
1276+ floating := newQuietPTY()
1277+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1278+ return got != normalSize && got.Valid()
1279+ })).Return(floating, nil).Once()
12461280
12471281 d := newTestDaemon(t, f, stubClock{})
12481282 tr1 := portsmocks.NewMockTransport(t)
@@ -1276,7 +1310,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12761310 defer release2()
12771311 var opens [][]string
12781312 f := portsmocks.NewMockPTYFactory(t)
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
1313+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1314+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
12801315 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
12811316 opens = append(opens, append([]string(nil), env...))
12821317 if len(opens) == 1 {
@@ -1285,6 +1320,10 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12851320 return p2, nil
12861321 },
12871322 ).Twice()
1323+ floating := newQuietPTY()
1324+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1325+ return got != normalSize && got.Valid()
1326+ })).Return(floating, nil).Once()
12881327 d := newTestDaemon(t, f, stubClock{})
12891328 tr := portsmocks.NewMockTransport(t)
12901329 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1295,10 +1334,14 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
12951334 got := ac.sess.Get()
12961335 require.NotNil(t, got)
12971336 got.mu.Lock()
1298- defer got.mu.Unlock()
12991337 require.True(t, got.terminal.TrueColor)
1338+ got.mu.Unlock()
13001339 require.Len(t, opens, 2)
13011340 require.Contains(t, opens[1], "TERM=xterm-direct")
13021341 require.Contains(t, opens[1], "COLORTERM=truecolor")
13031342 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1343+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
1344+ release1()
1345+ release2()
1346+ d.sessWg.Wait()
13041347 }
@@ -33,7 +33,8 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
33 defer releaseSecond()33 defer releaseSecond()
34 f := portsmocks.NewMockPTYFactory(t)34 f := portsmocks.NewMockPTYFactory(t)
35 var dirs []string35 var dirs []string
36- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(36+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
37+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
37 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {38 func(_ string, _ []string, _ []string, dir string, _ domain.Size) (ports.PTY, error) {
38 dirs = append(dirs, dir)39 dirs = append(dirs, dir)
39 if len(dirs) == 1 {40 if len(dirs) == 1 {
@@ -42,6 +43,10 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
42 return second, nil43 return second, nil
43 },44 },
44 ).Twice()45 ).Twice()
46+ floating := newQuietPTY()
47+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
48+ return got != normalSize && got.Valid()
49+ })).Return(floating, nil).Once()
45 50
46 d := newTestDaemon(t, f, stubClock{})51 d := newTestDaemon(t, f, stubClock{})
47 tr := portsmocks.NewMockTransport(t)52 tr := portsmocks.NewMockTransport(t)
@@ -62,6 +67,15 @@ func TestRoutePropagatesHelloCwdAndTabsInheritIt(t *testing.T) {
62 67
63 require.NoError(t, d.createTab(sess, sz))68 require.NoError(t, d.createTab(sess, sz))
64 require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)69 require.Equal(t, []string{"/tmp/work", "/tmp/work"}, dirs)
70+ _ = d.killSession(sess, ports.ReasonSessionKilled, false)
71+ releaseFirst()
72+ releaseSecond()
73+ d.sessWg.Wait()
74+ select {
75+ case <-floating.done:
76+ default:
77+ t.Fatal("floating prewarm PTY was not closed")
78+ }
65 }79 }
66 80
67 func TestHandshakeEphemeralHappy(t *testing.T) {81 func TestHandshakeEphemeralHappy(t *testing.T) {
@@ -197,8 +211,13 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
197 p2.EXPECT().Pid().Return(4242).Maybe()211 p2.EXPECT().Pid().Return(4242).Maybe()
198 212
199 f := portsmocks.NewMockPTYFactory(t)213 f := portsmocks.NewMockPTYFactory(t)
200- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(p1, nil).Once()214+ normalSize := domain.Size{Cols: 80, Rows: 22}
201- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(215+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).Return(p1, nil).Once()
216+ floating := newQuietPTY()
217+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
218+ return got != normalSize && got.Valid()
219+ })).Return(floating, nil).Once()
220+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
202 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {221 func(string, []string, []string, string, domain.Size) (ports.PTY, error) {
203 close(opened)222 close(opened)
204 <-releaseOpen223 <-releaseOpen
@@ -231,6 +250,11 @@ func TestCreateTabClosesPTYIfSessionKilledDuringOpen(t *testing.T) {
231 releaseConn()250 releaseConn()
232 hg.Wait()251 hg.Wait()
233 d.sessWg.Wait()252 d.sessWg.Wait()
253+ select {
254+ case <-floating.done:
255+ default:
256+ t.Fatal("floating prewarm PTY was not closed")
257+ }
234 }258 }
235 259
236 // --- ephemeral numbering ----------------------------------------------------260 // --- ephemeral numbering ----------------------------------------------------
@@ -1194,7 +1218,8 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1194 1218
1195 var opens [][]string1219 var opens [][]string
1196 f := portsmocks.NewMockPTYFactory(t)1220 f := portsmocks.NewMockPTYFactory(t)
1197- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1221+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1222+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1198 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1223 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1199 opens = append(opens, append([]string(nil), env...))1224 opens = append(opens, append([]string(nil), env...))
1200 if len(opens) == 1 {1225 if len(opens) == 1 {
@@ -1203,6 +1228,10 @@ func TestAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1203 return p2, nil1228 return p2, nil
1204 },1229 },
1205 ).Twice()1230 ).Twice()
1231+ floating := newQuietPTY()
1232+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1233+ return got != normalSize && got.Valid()
1234+ })).Return(floating, nil).Once()
1206 1235
1207 d := newTestDaemon(t, f, stubClock{})1236 d := newTestDaemon(t, f, stubClock{})
1208 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}1237 d.baseEnv = []string{"KEEP=1", "COLORTERM=old"}
@@ -1234,7 +1263,8 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1234 1263
1235 var opens [][]string1264 var opens [][]string
1236 f := portsmocks.NewMockPTYFactory(t)1265 f := portsmocks.NewMockPTYFactory(t)
1237- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1266+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1267+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1238 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1268 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1239 opens = append(opens, append([]string(nil), env...))1269 opens = append(opens, append([]string(nil), env...))
1240 if len(opens) == 1 {1270 if len(opens) == 1 {
@@ -1243,6 +1273,10 @@ func TestLiveAttachUpdatesFutureChildEnvTrueColor(t *testing.T) {
1243 return p2, nil1273 return p2, nil
1244 },1274 },
1245 ).Twice()1275 ).Twice()
1276+ floating := newQuietPTY()
1277+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1278+ return got != normalSize && got.Valid()
1279+ })).Return(floating, nil).Once()
1246 1280
1247 d := newTestDaemon(t, f, stubClock{})1281 d := newTestDaemon(t, f, stubClock{})
1248 tr1 := portsmocks.NewMockTransport(t)1282 tr1 := portsmocks.NewMockTransport(t)
@@ -1276,7 +1310,8 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1276 defer release2()1310 defer release2()
1277 var opens [][]string1311 var opens [][]string
1278 f := portsmocks.NewMockPTYFactory(t)1312 f := portsmocks.NewMockPTYFactory(t)
1279- f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(1313+ normalSize := domain.Size{Cols: sz.Cols, Rows: sz.Rows - 2}
1314+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, normalSize).RunAndReturn(
1280 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {1315 func(_ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
1281 opens = append(opens, append([]string(nil), env...))1316 opens = append(opens, append([]string(nil), env...))
1282 if len(opens) == 1 {1317 if len(opens) == 1 {
@@ -1285,6 +1320,10 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1285 return p2, nil1320 return p2, nil
1286 },1321 },
1287 ).Twice()1322 ).Twice()
1323+ floating := newQuietPTY()
1324+ f.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(got domain.Size) bool {
1325+ return got != normalSize && got.Valid()
1326+ })).Return(floating, nil).Once()
1288 d := newTestDaemon(t, f, stubClock{})1327 d := newTestDaemon(t, f, stubClock{})
1289 tr := portsmocks.NewMockTransport(t)1328 tr := portsmocks.NewMockTransport(t)
1290 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1329 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1295,10 +1334,14 @@ func TestCreateSessionAndSwitchInheritsTerminalEnv(t *testing.T) {
1295 got := ac.sess.Get()1334 got := ac.sess.Get()
1296 require.NotNil(t, got)1335 require.NotNil(t, got)
1297 got.mu.Lock()1336 got.mu.Lock()
1298- defer got.mu.Unlock()
1299 require.True(t, got.terminal.TrueColor)1337 require.True(t, got.terminal.TrueColor)
1338+ got.mu.Unlock()
1300 require.Len(t, opens, 2)1339 require.Len(t, opens, 2)
1301 require.Contains(t, opens[1], "TERM=xterm-direct")1340 require.Contains(t, opens[1], "TERM=xterm-direct")
1302 require.Contains(t, opens[1], "COLORTERM=truecolor")1341 require.Contains(t, opens[1], "COLORTERM=truecolor")
1303 require.Contains(t, opens[1], "TERM_PROGRAM=vev")1342 require.Contains(t, opens[1], "TERM_PROGRAM=vev")
1343+ _ = d.killSession(got, ports.ReasonSessionKilled, false)
1344+ release1()
1345+ release2()
1346+ d.sessWg.Wait()
1304 }1347 }

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