detached 440c1a2

fix(copy): invalidate stale copy documents

commit
440c1a2328557397def16cd708504d3e27b82869
author
brice · · 2026-07-11 06:58 UTC
parent
25d9db5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +181 -26
M internal/usecase/daemon/client.go +1 -0
@@ -578,6 +578,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
578578 if !sz.Valid() {
579579 return
580580 }
581+ d.exitCopyMode(ac)
581582 tbSize := tabSize(sz)
582583 if ac != nil {
583584 ac.size = sz
@@ -578,6 +578,7 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
578 if !sz.Valid() {578 if !sz.Valid() {
579 return579 return
580 }580 }
581+ d.exitCopyMode(ac)
581 tbSize := tabSize(sz)582 tbSize := tabSize(sz)
582 if ac != nil {583 if ac != nil {
583 ac.size = sz584 ac.size = sz
M internal/usecase/daemon/copymode.go +34 -9
@@ -73,14 +73,12 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
7373 }
7474
7575 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
76- rt := ac.overlays
7776 tb := sess.activeTab()
7877 if tb == nil {
7978 return
8079 }
8180 tb.mu.Lock()
8281 p := tb.terminalTargetLocked()
83- floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
8482 tb.mu.Unlock()
8583 if p == nil {
8684 return
@@ -90,8 +88,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9088 p.mu.Lock()
9189 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
9290 p.mu.Unlock()
91+ if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
92+ return
93+ }
94+ d.paint(sess, ac, true)
95+}
96+
97+// publishCopyMode publishes an immutable copy document, then validates the
98+// captured target without overlapping copyMu with session or tab locks. A tab
99+// transition or pane close may race capture; retaining copy state in either
100+// case would let an old document render or be yanked.
101+func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
102+ if sess == nil || ac == nil || tb == nil || p == nil {
103+ return false
104+ }
105+ mode := scopy.NewMode(document)
106+ if prepare != nil {
107+ prepare(mode)
108+ }
109+ rt := ac.overlays
93110 rt.copyMu.Lock()
94- rt.copyMode = scopy.NewMode(document)
111+ rt.copyMode = mode
95112 rt.copySnapshot = &document
96113 rt.copyPane = p
97114 rt.copySearch = nil
@@ -100,15 +117,23 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
100117 rt.copyDragging = false
101118 rt.normalMousePressValid = false
102119 rt.copyMu.Unlock()
103- if floatingSource {
120+
121+ active := sess.activeTab()
122+ valid := active == tb
123+ if valid {
104124 tb.mu.Lock()
105- stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
125+ valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
106126 tb.mu.Unlock()
107- if !stillVisible {
108- rt.clearCopyModeForPane(p)
109- }
110127 }
111- d.paint(sess, ac, true)
128+ if valid {
129+ return true
130+ }
131+ rt.copyMu.Lock()
132+ if rt.copyMode == mode && rt.copyPane == p {
133+ rt.clearCopyModeLocked()
134+ }
135+ rt.copyMu.Unlock()
136+ return false
112137 }
113138
114139 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
@@ -73,14 +73,12 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
73 }73 }
74 74
75 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {75 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
76- rt := ac.overlays
77 tb := sess.activeTab()76 tb := sess.activeTab()
78 if tb == nil {77 if tb == nil {
79 return78 return
80 }79 }
81 tb.mu.Lock()80 tb.mu.Lock()
82 p := tb.terminalTargetLocked()81 p := tb.terminalTargetLocked()
83- floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
84 tb.mu.Unlock()82 tb.mu.Unlock()
85 if p == nil {83 if p == nil {
86 return84 return
@@ -90,8 +88,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
90 p.mu.Lock()88 p.mu.Lock()
91 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)89 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
92 p.mu.Unlock()90 p.mu.Unlock()
91+ if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
92+ return
93+ }
94+ d.paint(sess, ac, true)
95+}
96+
97+// publishCopyMode publishes an immutable copy document, then validates the
98+// captured target without overlapping copyMu with session or tab locks. A tab
99+// transition or pane close may race capture; retaining copy state in either
100+// case would let an old document render or be yanked.
101+func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
102+ if sess == nil || ac == nil || tb == nil || p == nil {
103+ return false
104+ }
105+ mode := scopy.NewMode(document)
106+ if prepare != nil {
107+ prepare(mode)
108+ }
109+ rt := ac.overlays
93 rt.copyMu.Lock()110 rt.copyMu.Lock()
94- rt.copyMode = scopy.NewMode(document)111+ rt.copyMode = mode
95 rt.copySnapshot = &document112 rt.copySnapshot = &document
96 rt.copyPane = p113 rt.copyPane = p
97 rt.copySearch = nil114 rt.copySearch = nil
@@ -100,15 +117,23 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
100 rt.copyDragging = false117 rt.copyDragging = false
101 rt.normalMousePressValid = false118 rt.normalMousePressValid = false
102 rt.copyMu.Unlock()119 rt.copyMu.Unlock()
103- if floatingSource {120+
121+ active := sess.activeTab()
122+ valid := active == tb
123+ if valid {
104 tb.mu.Lock()124 tb.mu.Lock()
105- stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p125+ valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
106 tb.mu.Unlock()126 tb.mu.Unlock()
107- if !stillVisible {
108- rt.clearCopyModeForPane(p)
109- }
110 }127 }
111- d.paint(sess, ac, true)128+ if valid {
129+ return true
130+ }
131+ rt.copyMu.Lock()
132+ if rt.copyMode == mode && rt.copyPane == p {
133+ rt.clearCopyModeLocked()
134+ }
135+ rt.copyMu.Unlock()
136+ return false
112 }137 }
113 138
114 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {139 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
A internal/usecase/daemon/copymode_lifecycle_test.go +107 -0
@@ -0,0 +1,107 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
15+ pty, release := newBlockingPTY(t)
16+ defer release()
17+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
18+ d.enterCopyMode(sess, ac)
19+ require.True(t, ac.overlays.copyActive())
20+
21+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
22+
23+ require.False(t, ac.overlays.copyActive())
24+}
25+
26+func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 2)
28+ defer func() {
29+ for _, release := range releases {
30+ release()
31+ }
32+ }()
33+ d.enterCopyMode(sess, ac)
34+ require.True(t, ac.overlays.copyActive())
35+
36+ require.True(t, sess.switchTab(1))
37+ d.activateTab(sess, sess.activeTab())
38+
39+ require.False(t, ac.overlays.copyActive())
40+}
41+
42+func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
43+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
44+ tb := sess.activeTab()
45+ closingPTY := portsmocks.NewMockPTY(t)
46+ closingPTY.EXPECT().Close().Return(nil).Once()
47+ 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"}
48+ closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
49+ tb.panes["pane-2"] = closing
50+ ac := &attachedClient{output: newOutputStateStream()}
51+ ac.initOverlays()
52+ sess.client = ac
53+ ac.setSession(sess)
54+ closing.mu.Lock()
55+ document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
56+ closing.mu.Unlock()
57+ ac.overlays.copyMu.Lock()
58+ ac.overlays.copyPane = closing
59+ ac.overlays.copySnapshot = &document
60+ ac.overlays.copyMode = scopy.NewMode(document)
61+ ac.overlays.copyMu.Unlock()
62+
63+ require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
64+ require.False(t, ac.overlays.copyActive())
65+ require.Nil(t, copyTargetPane(ac.overlays))
66+}
67+
68+func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
69+ for _, tc := range []struct {
70+ name string
71+ invalidate func(*session, *tab, *pane)
72+ }{
73+ {
74+ name: "removed pane",
75+ invalidate: func(_ *session, tb *tab, p *pane) {
76+ tb.mu.Lock()
77+ delete(tb.panes, p.id)
78+ tb.mu.Unlock()
79+ },
80+ },
81+ {
82+ name: "inactive tab",
83+ invalidate: func(sess *session, _ *tab, _ *pane) {
84+ require.True(t, sess.switchTab(1))
85+ },
86+ },
87+ } {
88+ t.Run(tc.name, func(t *testing.T) {
89+ d, sess, ac, _, releases := newManualTabSession(t, 2)
90+ defer func() {
91+ for _, release := range releases {
92+ release()
93+ }
94+ }()
95+ tb := sess.activeTab()
96+ p := tb.focusedPane()
97+ p.mu.Lock()
98+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
99+ p.mu.Unlock()
100+ tc.invalidate(sess, tb, p)
101+
102+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))
103+ require.False(t, ac.overlays.copyActive())
104+ require.Nil(t, copyTargetPane(ac.overlays))
105+ })
106+ }
107+}
@@ -0,0 +1,107 @@
1+package daemon
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ scopy "github.com/bnema/vev/internal/usecase/copy"
11+ "github.com/bnema/vev/internal/usecase/layout"
12+)
13+
14+func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
15+ pty, release := newBlockingPTY(t)
16+ defer release()
17+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
18+ d.enterCopyMode(sess, ac)
19+ require.True(t, ac.overlays.copyActive())
20+
21+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
22+
23+ require.False(t, ac.overlays.copyActive())
24+}
25+
26+func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
27+ d, sess, ac, _, releases := newManualTabSession(t, 2)
28+ defer func() {
29+ for _, release := range releases {
30+ release()
31+ }
32+ }()
33+ d.enterCopyMode(sess, ac)
34+ require.True(t, ac.overlays.copyActive())
35+
36+ require.True(t, sess.switchTab(1))
37+ d.activateTab(sess, sess.activeTab())
38+
39+ require.False(t, ac.overlays.copyActive())
40+}
41+
42+func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
43+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
44+ tb := sess.activeTab()
45+ closingPTY := portsmocks.NewMockPTY(t)
46+ closingPTY.EXPECT().Close().Return(nil).Once()
47+ 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"}
48+ closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
49+ tb.panes["pane-2"] = closing
50+ ac := &attachedClient{output: newOutputStateStream()}
51+ ac.initOverlays()
52+ sess.client = ac
53+ ac.setSession(sess)
54+ closing.mu.Lock()
55+ document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
56+ closing.mu.Unlock()
57+ ac.overlays.copyMu.Lock()
58+ ac.overlays.copyPane = closing
59+ ac.overlays.copySnapshot = &document
60+ ac.overlays.copyMode = scopy.NewMode(document)
61+ ac.overlays.copyMu.Unlock()
62+
63+ require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
64+ require.False(t, ac.overlays.copyActive())
65+ require.Nil(t, copyTargetPane(ac.overlays))
66+}
67+
68+func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
69+ for _, tc := range []struct {
70+ name string
71+ invalidate func(*session, *tab, *pane)
72+ }{
73+ {
74+ name: "removed pane",
75+ invalidate: func(_ *session, tb *tab, p *pane) {
76+ tb.mu.Lock()
77+ delete(tb.panes, p.id)
78+ tb.mu.Unlock()
79+ },
80+ },
81+ {
82+ name: "inactive tab",
83+ invalidate: func(sess *session, _ *tab, _ *pane) {
84+ require.True(t, sess.switchTab(1))
85+ },
86+ },
87+ } {
88+ t.Run(tc.name, func(t *testing.T) {
89+ d, sess, ac, _, releases := newManualTabSession(t, 2)
90+ defer func() {
91+ for _, release := range releases {
92+ release()
93+ }
94+ }()
95+ tb := sess.activeTab()
96+ p := tb.focusedPane()
97+ p.mu.Lock()
98+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
99+ p.mu.Unlock()
100+ tc.invalidate(sess, tb, p)
101+
102+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))
103+ require.False(t, ac.overlays.copyActive())
104+ require.Nil(t, copyTargetPane(ac.overlays))
105+ })
106+ }
107+}
M internal/usecase/daemon/floating.go +3 -2
@@ -162,9 +162,10 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
162162 // until firstPaint keeps restored tabs cold and avoids launching children
163163 // merely because a tab was manipulated during teardown.
164164 sess.mu.Lock()
165- attached := sess.client != nil
165+ ac := sess.client
166166 sess.mu.Unlock()
167- if !attached {
167+ d.exitCopyMode(ac)
168+ if ac == nil {
168169 return
169170 }
170171 d.ensureFloatingWarm(sess, tb)
@@ -162,9 +162,10 @@ func (d *Daemon) activateTab(sess *session, tb *tab) {
162 // until firstPaint keeps restored tabs cold and avoids launching children162 // until firstPaint keeps restored tabs cold and avoids launching children
163 // merely because a tab was manipulated during teardown.163 // merely because a tab was manipulated during teardown.
164 sess.mu.Lock()164 sess.mu.Lock()
165- attached := sess.client != nil165+ ac := sess.client
166 sess.mu.Unlock()166 sess.mu.Unlock()
167- if !attached {167+ d.exitCopyMode(ac)
168+ if ac == nil {
168 return169 return
169 }170 }
170 d.ensureFloatingWarm(sess, tb)171 d.ensureFloatingWarm(sess, tb)
M internal/usecase/daemon/input.go +14 -10
@@ -274,17 +274,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
274274 p.mu.Lock()
275275 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
276276 p.mu.Unlock()
277+ tb := sess.activeTab()
278+ if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
279+ mode.StartSelectionAt(document, pressTop+pressRow)
280+ mode.ExtendTo(document, document.Len()-document.Height+ev.Row)
281+ }) {
282+ return
283+ }
277284 rt.copyMu.Lock()
278- mode := scopy.NewMode(document)
279- mode.StartSelectionAt(document, pressTop+pressRow)
280- mode.ExtendTo(document, document.Len()-document.Height+ev.Row)
281- rt.copyMode = mode
282- rt.copySnapshot = &document
283- rt.copyPane = p
284- rt.copyPressRow = pressTop + pressRow
285- rt.copyPressRowValid = true
286- rt.copyDragging = true
287- rt.normalMousePressValid = false
285+ // Publication is revalidated before this state is installed, and no
286+ // lifecycle lock is held while copyMu is taken.
287+ if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil {
288+ rt.copyPressRow = pressTop + pressRow
289+ rt.copyPressRowValid = true
290+ rt.copyDragging = true
291+ }
288292 rt.copyMu.Unlock()
289293 d.paint(sess, ac, true)
290294 case mouse.Release:
@@ -274,17 +274,21 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
274 p.mu.Lock()274 p.mu.Lock()
275 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)275 document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
276 p.mu.Unlock()276 p.mu.Unlock()
277+ tb := sess.activeTab()
278+ if !d.publishCopyMode(sess, ac, tb, p, document, func(mode *scopy.Mode) {
279+ mode.StartSelectionAt(document, pressTop+pressRow)
280+ mode.ExtendTo(document, document.Len()-document.Height+ev.Row)
281+ }) {
282+ return
283+ }
277 rt.copyMu.Lock()284 rt.copyMu.Lock()
278- mode := scopy.NewMode(document)285+ // Publication is revalidated before this state is installed, and no
279- mode.StartSelectionAt(document, pressTop+pressRow)286+ // lifecycle lock is held while copyMu is taken.
280- mode.ExtendTo(document, document.Len()-document.Height+ev.Row)287+ if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil {
281- rt.copyMode = mode288+ rt.copyPressRow = pressTop + pressRow
282- rt.copySnapshot = &document289+ rt.copyPressRowValid = true
283- rt.copyPane = p290+ rt.copyDragging = true
284- rt.copyPressRow = pressTop + pressRow291+ }
285- rt.copyPressRowValid = true
286- rt.copyDragging = true
287- rt.normalMousePressValid = false
288 rt.copyMu.Unlock()292 rt.copyMu.Unlock()
289 d.paint(sess, ac, true)293 d.paint(sess, ac, true)
290 case mouse.Release:294 case mouse.Release:
M internal/usecase/daemon/mouse_panes.go +3 -0
@@ -143,6 +143,9 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
143143 return
144144 }
145145 rt := ac.overlays
146+ if rt == nil {
147+ return
148+ }
146149 rt.copyMu.Lock()
147150 rt.clearCopyModeLocked()
148151 rt.copyPressRowValid = false
@@ -143,6 +143,9 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) {
143 return143 return
144 }144 }
145 rt := ac.overlays145 rt := ac.overlays
146+ if rt == nil {
147+ return
148+ }
146 rt.copyMu.Lock()149 rt.copyMu.Lock()
147 rt.clearCopyModeLocked()150 rt.clearCopyModeLocked()
148 rt.copyPressRowValid = false151 rt.copyPressRowValid = false
M internal/usecase/daemon/pane_actions.go +19 -5
@@ -234,6 +234,14 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
234234 }
235235 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
236236 tb.mu.Unlock()
237+ if ac == nil {
238+ sess.mu.Lock()
239+ ac = sess.client
240+ sess.mu.Unlock()
241+ }
242+ if ac != nil {
243+ ac.overlays.clearCopyModeForPane(p)
244+ }
237245 d.closeTab(sess, tb, repaint)
238246 return nil
239247 }
@@ -245,6 +253,17 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
245253 d.applyLayoutLocked(tb)
246254 tb.mu.Unlock()
247255
256+ // A reap/normal close can be called without the client argument. Recover
257+ // the attached client after releasing tab.mu so copyMu never overlaps it.
258+ if ac == nil {
259+ sess.mu.Lock()
260+ ac = sess.client
261+ sess.mu.Unlock()
262+ }
263+ if ac != nil {
264+ ac.overlays.clearCopyModeForPane(p)
265+ }
266+
248267 if p.cancel != nil {
249268 p.cancel()
250269 }
@@ -254,11 +273,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254273 d.log.Info("pane closed", "session", sess.name, "pane", id)
255274 markSnapshotDirty(sess)
256275 if repaint {
257- if ac == nil {
258- sess.mu.Lock()
259- ac = sess.client
260- sess.mu.Unlock()
261- }
262276 if ac != nil {
263277 d.paint(sess, ac, true)
264278 }
@@ -234,6 +234,14 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
234 }234 }
235 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {235 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
236 tb.mu.Unlock()236 tb.mu.Unlock()
237+ if ac == nil {
238+ sess.mu.Lock()
239+ ac = sess.client
240+ sess.mu.Unlock()
241+ }
242+ if ac != nil {
243+ ac.overlays.clearCopyModeForPane(p)
244+ }
237 d.closeTab(sess, tb, repaint)245 d.closeTab(sess, tb, repaint)
238 return nil246 return nil
239 }247 }
@@ -245,6 +253,17 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
245 d.applyLayoutLocked(tb)253 d.applyLayoutLocked(tb)
246 tb.mu.Unlock()254 tb.mu.Unlock()
247 255
256+ // A reap/normal close can be called without the client argument. Recover
257+ // the attached client after releasing tab.mu so copyMu never overlaps it.
258+ if ac == nil {
259+ sess.mu.Lock()
260+ ac = sess.client
261+ sess.mu.Unlock()
262+ }
263+ if ac != nil {
264+ ac.overlays.clearCopyModeForPane(p)
265+ }
266+
248 if p.cancel != nil {267 if p.cancel != nil {
249 p.cancel()268 p.cancel()
250 }269 }
@@ -254,11 +273,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254 d.log.Info("pane closed", "session", sess.name, "pane", id)273 d.log.Info("pane closed", "session", sess.name, "pane", id)
255 markSnapshotDirty(sess)274 markSnapshotDirty(sess)
256 if repaint {275 if repaint {
257- if ac == nil {
258- sess.mu.Lock()
259- ac = sess.client
260- sess.mu.Unlock()
261- }
262 if ac != nil {276 if ac != nil {
263 d.paint(sess, ac, true)277 d.paint(sess, ac, true)
264 }278 }

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