detached a628e59

perf(copy): retain one immutable copy document

commit
a628e5995e9de02c1e9c074870baa253db2fbd02
author
brice · · 2026-07-11 06:07 UTC
committer
brice · · 2026-07-11 06:16 UTC
parent
2f5b567
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +83 -43
M internal/usecase/daemon/copymode.go +42 -29
@@ -61,16 +61,21 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6161 p.mu.Unlock()
6262 return
6363 }
64- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
65- if delta > 0 && rt.copyMode.AtBottom(snap) {
64+ if rt.copySnapshot == nil {
65+ rt.copyMu.Unlock()
66+ p.mu.Unlock()
67+ return
68+ }
69+ document := rt.copySnapshot
70+ if delta > 0 && rt.copyMode.AtBottom(*document) {
6671 rt.clearCopyModeLocked()
6772 rt.copyMu.Unlock()
6873 p.mu.Unlock()
6974 d.paint(sess, ac, true)
7075 return
7176 }
72- rt.copyMode.Move(snap, delta)
73- exit := delta > 0 && rt.copyMode.AtBottom(snap)
77+ rt.copyMode.Move(*document, delta)
78+ exit := delta > 0 && rt.copyMode.AtBottom(*document)
7479 if exit {
7580 rt.clearCopyModeLocked()
7681 }
@@ -94,10 +99,10 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9499 return
95100 }
96101 p.mu.Lock()
97- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98- p.mu.Unlock()
102+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
99103 rt.copyMu.Lock()
100- rt.copyMode = scopy.NewMode(snap)
104+ rt.copyMode = scopy.NewMode(document)
105+ rt.copySnapshot = &document
101106 rt.copyPane = p
102107 rt.copySearch = nil
103108 rt.copySearchPending = nil
@@ -105,6 +110,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
105110 rt.copyDragging = false
106111 rt.normalMousePressValid = false
107112 rt.copyMu.Unlock()
113+ p.mu.Unlock()
108114 if floatingSource {
109115 tb.mu.Lock()
110116 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
@@ -142,12 +148,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142148 p.mu.Unlock()
143149 return
144150 }
145- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
151+ if rt.copySnapshot == nil {
152+ rt.copyMu.Unlock()
153+ p.mu.Unlock()
154+ return
155+ }
156+ document := rt.copySnapshot
146157 absRow := rt.copyMode.ViewportTop + ev.Row
147158 changed := false
148159 switch ev.Type {
149160 case mouse.Press:
150- rt.copyMode.SetCursor(snap, absRow)
161+ rt.copyMode.SetCursor(*document, absRow)
151162 rt.copyPressRow = rt.copyMode.Cursor
152163 rt.copyPressRowValid = true
153164 rt.copyDragging = false
@@ -157,10 +168,10 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
157168 break
158169 }
159170 if !rt.copyDragging {
160- rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
171+ rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)
161172 rt.copyDragging = true
162173 }
163- rt.copyMode.ExtendTo(snap, absRow)
174+ rt.copyMode.ExtendTo(*document, absRow)
164175 changed = true
165176 case mouse.Release:
166177 // Button release intentionally has no visual effect.
@@ -200,9 +211,14 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200211 data = combined
201212 rt.copyPending = nil
202213 }
203- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
214+ if rt.copySnapshot == nil {
215+ rt.copyMu.Unlock()
216+ p.mu.Unlock()
217+ return
218+ }
219+ document := rt.copySnapshot
204220 if rt.copySearch != nil {
205- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
221+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
206222 rt.copyMu.Unlock()
207223 p.mu.Unlock()
208224 if changed || closeSearch || accepted {
@@ -216,40 +232,40 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
216232 for i := 0; i < len(data); i++ {
217233 switch data[i] {
218234 case 'j':
219- rt.copyMode.Move(snap, 1)
235+ rt.copyMode.Move(*document, 1)
220236 changed = true
221237 case 'k':
222- rt.copyMode.Move(snap, -1)
238+ rt.copyMode.Move(*document, -1)
223239 changed = true
224240 case 'g':
225- rt.copyMode.Top(snap)
241+ rt.copyMode.Top(*document)
226242 changed = true
227243 case 'G':
228- rt.copyMode.Bottom(snap)
244+ rt.copyMode.Bottom(*document)
229245 changed = true
230246 case ' ', 'v':
231247 rt.copyMode.ToggleSelection()
232248 changed = true
233249 case '/':
234- rt.copySearch = visualsearch.New(snap)
250+ rt.copySearch = visualsearch.New(*document)
235251 rt.copySearchPending = nil
236252 changed = true
237253 if i+1 < len(data) {
238- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
254+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])
239255 changed = changed || searchChanged || accepted
240256 }
241257 i = len(data)
242258 case 'n':
243- changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
259+ changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
244260 case 'N':
245- changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
261+ changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
246262 case '\r', '\n', 'y':
247263 copyOut = true
248264 exit = true
249265 case 'q', 0x03, 0x1b:
250266 if data[i] == 0x1b {
251267 tail := data[i:]
252- consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
268+ consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
253269 if ok {
254270 i += consumed - 1
255271 changed = true
@@ -269,7 +285,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
269285 }
270286 text := ""
271287 if copyOut {
272- text = rt.copyMode.SelectedText(snap)
288+ text = rt.copyMode.SelectedText(*document)
273289 }
274290 if exit {
275291 d.stopCopyPendingTimerLocked(ac)
@@ -417,15 +433,12 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
417433 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
418434 }
419435
420-func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
421- if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
436+func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
437+ if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422438 return frame, nil
423439 }
424440 styles := newThemeStyles(bars.theme)
425- p.mu.Lock()
426- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
427- p.mu.Unlock()
428- copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
441+ copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)
429442 bodyRows := max(copyFrame.Height-1, 0)
430443 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431444 dstX := max(target.X, 0)
@@ -61,16 +61,21 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
61 p.mu.Unlock()61 p.mu.Unlock()
62 return62 return
63 }63 }
64- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)64+ if rt.copySnapshot == nil {
65- if delta > 0 && rt.copyMode.AtBottom(snap) {65+ rt.copyMu.Unlock()
66+ p.mu.Unlock()
67+ return
68+ }
69+ document := rt.copySnapshot
70+ if delta > 0 && rt.copyMode.AtBottom(*document) {
66 rt.clearCopyModeLocked()71 rt.clearCopyModeLocked()
67 rt.copyMu.Unlock()72 rt.copyMu.Unlock()
68 p.mu.Unlock()73 p.mu.Unlock()
69 d.paint(sess, ac, true)74 d.paint(sess, ac, true)
70 return75 return
71 }76 }
72- rt.copyMode.Move(snap, delta)77+ rt.copyMode.Move(*document, delta)
73- exit := delta > 0 && rt.copyMode.AtBottom(snap)78+ exit := delta > 0 && rt.copyMode.AtBottom(*document)
74 if exit {79 if exit {
75 rt.clearCopyModeLocked()80 rt.clearCopyModeLocked()
76 }81 }
@@ -94,10 +99,10 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
94 return99 return
95 }100 }
96 p.mu.Lock()101 p.mu.Lock()
97- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)102+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98- p.mu.Unlock()
99 rt.copyMu.Lock()103 rt.copyMu.Lock()
100- rt.copyMode = scopy.NewMode(snap)104+ rt.copyMode = scopy.NewMode(document)
105+ rt.copySnapshot = &document
101 rt.copyPane = p106 rt.copyPane = p
102 rt.copySearch = nil107 rt.copySearch = nil
103 rt.copySearchPending = nil108 rt.copySearchPending = nil
@@ -105,6 +110,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
105 rt.copyDragging = false110 rt.copyDragging = false
106 rt.normalMousePressValid = false111 rt.normalMousePressValid = false
107 rt.copyMu.Unlock()112 rt.copyMu.Unlock()
113+ p.mu.Unlock()
108 if floatingSource {114 if floatingSource {
109 tb.mu.Lock()115 tb.mu.Lock()
110 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p116 stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
@@ -142,12 +148,17 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
142 p.mu.Unlock()148 p.mu.Unlock()
143 return149 return
144 }150 }
145- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)151+ if rt.copySnapshot == nil {
152+ rt.copyMu.Unlock()
153+ p.mu.Unlock()
154+ return
155+ }
156+ document := rt.copySnapshot
146 absRow := rt.copyMode.ViewportTop + ev.Row157 absRow := rt.copyMode.ViewportTop + ev.Row
147 changed := false158 changed := false
148 switch ev.Type {159 switch ev.Type {
149 case mouse.Press:160 case mouse.Press:
150- rt.copyMode.SetCursor(snap, absRow)161+ rt.copyMode.SetCursor(*document, absRow)
151 rt.copyPressRow = rt.copyMode.Cursor162 rt.copyPressRow = rt.copyMode.Cursor
152 rt.copyPressRowValid = true163 rt.copyPressRowValid = true
153 rt.copyDragging = false164 rt.copyDragging = false
@@ -157,10 +168,10 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
157 break168 break
158 }169 }
159 if !rt.copyDragging {170 if !rt.copyDragging {
160- rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)171+ rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)
161 rt.copyDragging = true172 rt.copyDragging = true
162 }173 }
163- rt.copyMode.ExtendTo(snap, absRow)174+ rt.copyMode.ExtendTo(*document, absRow)
164 changed = true175 changed = true
165 case mouse.Release:176 case mouse.Release:
166 // Button release intentionally has no visual effect.177 // Button release intentionally has no visual effect.
@@ -200,9 +211,14 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200 data = combined211 data = combined
201 rt.copyPending = nil212 rt.copyPending = nil
202 }213 }
203- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)214+ if rt.copySnapshot == nil {
215+ rt.copyMu.Unlock()
216+ p.mu.Unlock()
217+ return
218+ }
219+ document := rt.copySnapshot
204 if rt.copySearch != nil {220 if rt.copySearch != nil {
205- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)221+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
206 rt.copyMu.Unlock()222 rt.copyMu.Unlock()
207 p.mu.Unlock()223 p.mu.Unlock()
208 if changed || closeSearch || accepted {224 if changed || closeSearch || accepted {
@@ -216,40 +232,40 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
216 for i := 0; i < len(data); i++ {232 for i := 0; i < len(data); i++ {
217 switch data[i] {233 switch data[i] {
218 case 'j':234 case 'j':
219- rt.copyMode.Move(snap, 1)235+ rt.copyMode.Move(*document, 1)
220 changed = true236 changed = true
221 case 'k':237 case 'k':
222- rt.copyMode.Move(snap, -1)238+ rt.copyMode.Move(*document, -1)
223 changed = true239 changed = true
224 case 'g':240 case 'g':
225- rt.copyMode.Top(snap)241+ rt.copyMode.Top(*document)
226 changed = true242 changed = true
227 case 'G':243 case 'G':
228- rt.copyMode.Bottom(snap)244+ rt.copyMode.Bottom(*document)
229 changed = true245 changed = true
230 case ' ', 'v':246 case ' ', 'v':
231 rt.copyMode.ToggleSelection()247 rt.copyMode.ToggleSelection()
232 changed = true248 changed = true
233 case '/':249 case '/':
234- rt.copySearch = visualsearch.New(snap)250+ rt.copySearch = visualsearch.New(*document)
235 rt.copySearchPending = nil251 rt.copySearchPending = nil
236 changed = true252 changed = true
237 if i+1 < len(data) {253 if i+1 < len(data) {
238- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])254+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])
239 changed = changed || searchChanged || accepted255 changed = changed || searchChanged || accepted
240 }256 }
241 i = len(data)257 i = len(data)
242 case 'n':258 case 'n':
243- changed = rt.copyMode.NextSearchMatch(snap, 1) || changed259+ changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
244 case 'N':260 case 'N':
245- changed = rt.copyMode.NextSearchMatch(snap, -1) || changed261+ changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
246 case '\r', '\n', 'y':262 case '\r', '\n', 'y':
247 copyOut = true263 copyOut = true
248 exit = true264 exit = true
249 case 'q', 0x03, 0x1b:265 case 'q', 0x03, 0x1b:
250 if data[i] == 0x1b {266 if data[i] == 0x1b {
251 tail := data[i:]267 tail := data[i:]
252- consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)268+ consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
253 if ok {269 if ok {
254 i += consumed - 1270 i += consumed - 1
255 changed = true271 changed = true
@@ -269,7 +285,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
269 }285 }
270 text := ""286 text := ""
271 if copyOut {287 if copyOut {
272- text = rt.copyMode.SelectedText(snap)288+ text = rt.copyMode.SelectedText(*document)
273 }289 }
274 if exit {290 if exit {
275 d.stopCopyPendingTimerLocked(ac)291 d.stopCopyPendingTimerLocked(ac)
@@ -417,15 +433,12 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)433 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
418 }434 }
419 435
420-func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {436+func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
421- if mode == nil || p == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {437+ if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422 return frame, nil438 return frame, nil
423 }439 }
424 styles := newThemeStyles(bars.theme)440 styles := newThemeStyles(bars.theme)
425- p.mu.Lock()441+ copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)
426- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
427- p.mu.Unlock()
428- copyFrame := mode.Render(snap, styles.copyStatus, styles.selection)
429 bodyRows := max(copyFrame.Height-1, 0)442 bodyRows := max(copyFrame.Height-1, 0)
430 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {443 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431 dstX := max(target.X, 0)444 dstX := max(target.X, 0)
M internal/usecase/daemon/copymode_test.go +2 -2
@@ -124,7 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
124124 go func() {
125125 defer wg.Done()
126126 for range 200 {
127- _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
127+ _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)
128128 }
129129 }()
130130 wg.Wait()
@@ -143,7 +143,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
143143
144144 bars := barState{status: sess.statusSegments()}
145145 base, _ := composeClientFrameWithState(bars, tb, true)
146- frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
146+ frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
147147
148148 require.Equal(t, 80, frame.Width)
149149 require.Equal(t, 25, frame.Height)
@@ -124,7 +124,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
124 go func() {124 go func() {
125 defer wg.Done()125 defer wg.Done()
126 for range 200 {126 for range 200 {
127- _, _ = composeCopyClientFrame(mode, pane, target, base, bars)127+ _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)
128 }128 }
129 }()129 }()
130 wg.Wait()130 wg.Wait()
@@ -143,7 +143,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
143 143
144 bars := barState{status: sess.statusSegments()}144 bars := barState{status: sess.statusSegments()}
145 base, _ := composeClientFrameWithState(bars, tb, true)145 base, _ := composeClientFrameWithState(bars, tb, true)
146- frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)146+ frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
147 147
148 require.Equal(t, 80, frame.Width)148 require.Equal(t, 80, frame.Width)
149 require.Equal(t, 25, frame.Height)149 require.Equal(t, 25, frame.Height)
M internal/usecase/daemon/input.go +6 -6
@@ -271,20 +271,20 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
271271 }
272272
273273 p.mu.Lock()
274- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
275- p.mu.Unlock()
276-
274+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
277275 rt.copyMu.Lock()
278- mode := scopy.NewMode(snap)
279- mode.StartSelectionAt(snap, pressTop+pressRow)
280- mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row)
276+ mode := scopy.NewMode(document)
277+ mode.StartSelectionAt(document, pressTop+pressRow)
278+ mode.ExtendTo(document, document.Len()-document.Height+ev.Row)
281279 rt.copyMode = mode
280+ rt.copySnapshot = &document
282281 rt.copyPane = p
283282 rt.copyPressRow = pressTop + pressRow
284283 rt.copyPressRowValid = true
285284 rt.copyDragging = true
286285 rt.normalMousePressValid = false
287286 rt.copyMu.Unlock()
287+ p.mu.Unlock()
288288 d.paint(sess, ac, true)
289289 case mouse.Release:
290290 rt.copyMu.Lock()
@@ -271,20 +271,20 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
271 }271 }
272 272
273 p.mu.Lock()273 p.mu.Lock()
274- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)274+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
275- p.mu.Unlock()
276-
277 rt.copyMu.Lock()275 rt.copyMu.Lock()
278- mode := scopy.NewMode(snap)276+ mode := scopy.NewMode(document)
279- mode.StartSelectionAt(snap, pressTop+pressRow)277+ mode.StartSelectionAt(document, pressTop+pressRow)
280- mode.ExtendTo(snap, snap.Len()-snap.Height+ev.Row)278+ mode.ExtendTo(document, document.Len()-document.Height+ev.Row)
281 rt.copyMode = mode279 rt.copyMode = mode
280+ rt.copySnapshot = &document
282 rt.copyPane = p281 rt.copyPane = p
283 rt.copyPressRow = pressTop + pressRow282 rt.copyPressRow = pressTop + pressRow
284 rt.copyPressRowValid = true283 rt.copyPressRowValid = true
285 rt.copyDragging = true284 rt.copyDragging = true
286 rt.normalMousePressValid = false285 rt.normalMousePressValid = false
287 rt.copyMu.Unlock()286 rt.copyMu.Unlock()
287+ p.mu.Unlock()
288 d.paint(sess, ac, true)288 d.paint(sess, ac, true)
289 case mouse.Release:289 case mouse.Release:
290 rt.copyMu.Lock()290 rt.copyMu.Lock()
M internal/usecase/daemon/overlay_runtime.go +4 -0
@@ -30,6 +30,7 @@ type overlayRuntime struct {
3030
3131 copyMu sync.Mutex
3232 copyMode *scopy.Mode
33+ copySnapshot *scopy.Snapshot
3334 copyPane *pane
3435 copyPending []byte
3536 copyESC pendingByteTimer
@@ -102,6 +103,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
102103
103104 func (rt *overlayRuntime) clearCopyModeLocked() {
104105 rt.copyMode = nil
106+ rt.copySnapshot = nil
105107 rt.copyPane = nil
106108 rt.copySearch = nil
107109 rt.copySearchPending = nil
@@ -149,6 +151,7 @@ type overlayRenderSnapshot struct {
149151
150152 copyActive bool
151153 copyMode *scopy.Mode
154+ copySnapshot *scopy.Snapshot
152155 copyPane *pane
153156 copySearchModel *visualsearch.Model
154157 copyFeedback string
@@ -181,6 +184,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
181184 rt.copyMu.Lock()
182185 snap.copyActive = rt.copyMode != nil
183186 snap.copyPane = rt.copyPane
187+ snap.copySnapshot = rt.copySnapshot
184188 if rt.copyMode != nil {
185189 copyModeValue := *rt.copyMode
186190 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
@@ -30,6 +30,7 @@ type overlayRuntime struct {
30 30
31 copyMu sync.Mutex31 copyMu sync.Mutex
32 copyMode *scopy.Mode32 copyMode *scopy.Mode
33+ copySnapshot *scopy.Snapshot
33 copyPane *pane34 copyPane *pane
34 copyPending []byte35 copyPending []byte
35 copyESC pendingByteTimer36 copyESC pendingByteTimer
@@ -102,6 +103,7 @@ func (rt *overlayRuntime) copySearchActive() bool {
102 103
103 func (rt *overlayRuntime) clearCopyModeLocked() {104 func (rt *overlayRuntime) clearCopyModeLocked() {
104 rt.copyMode = nil105 rt.copyMode = nil
106+ rt.copySnapshot = nil
105 rt.copyPane = nil107 rt.copyPane = nil
106 rt.copySearch = nil108 rt.copySearch = nil
107 rt.copySearchPending = nil109 rt.copySearchPending = nil
@@ -149,6 +151,7 @@ type overlayRenderSnapshot struct {
149 151
150 copyActive bool152 copyActive bool
151 copyMode *scopy.Mode153 copyMode *scopy.Mode
154+ copySnapshot *scopy.Snapshot
152 copyPane *pane155 copyPane *pane
153 copySearchModel *visualsearch.Model156 copySearchModel *visualsearch.Model
154 copyFeedback string157 copyFeedback string
@@ -181,6 +184,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
181 rt.copyMu.Lock()184 rt.copyMu.Lock()
182 snap.copyActive = rt.copyMode != nil185 snap.copyActive = rt.copyMode != nil
183 snap.copyPane = rt.copyPane186 snap.copyPane = rt.copyPane
187+ snap.copySnapshot = rt.copySnapshot
184 if rt.copyMode != nil {188 if rt.copyMode != nil {
185 copyModeValue := *rt.copyMode189 copyModeValue := *rt.copyMode
186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)190 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +25 -3
@@ -145,24 +145,46 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
145145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
146146 }
147147
148-func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {
148+func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.T) {
149149 ac := &attachedClient{}
150150 ac.initOverlays()
151151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152- snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)
152+ document := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)
153153
154154 ac.overlays.copyMu.Lock()
155- ac.overlays.copyMode = scopy.NewMode(snap)
155+ ac.overlays.copyMode = scopy.NewMode(document)
156156 ac.overlays.copyPane = p
157+ ac.overlays.copySnapshot = &document
157158 ac.overlays.copyMu.Unlock()
158159
159160 renderSnap := ac.overlays.SnapshotForRender()
160161 require.Same(t, p, renderSnap.copyPane)
162+ require.Same(t, &document, renderSnap.copySnapshot)
161163 renderSnap.Unlock()
162164
163165 ac.overlays.copyMu.Lock()
164166 ac.overlays.clearCopyModeLocked()
165167 require.Nil(t, ac.overlays.copyPane)
166168 require.Nil(t, ac.overlays.copyMode)
169+ require.Nil(t, ac.overlays.copySnapshot)
167170 ac.overlays.copyMu.Unlock()
168171 }
172+
173+func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174+ ac := &attachedClient{}
175+ ac.initOverlays()
176+ history := scopy.NewScrollback(1)
177+ history.Append(testRow("before"))
178+ document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179+
180+ ac.overlays.copyMu.Lock()
181+ ac.overlays.copyMode = scopy.NewMode(document)
182+ ac.overlays.copySnapshot = &document
183+ ac.overlays.copyMu.Unlock()
184+
185+ history.Append(testRow("after"))
186+ renderSnap := ac.overlays.SnapshotForRender()
187+ defer renderSnap.Unlock()
188+ require.Same(t, &document, renderSnap.copySnapshot)
189+ require.Equal(t, "before", rowText(renderSnap.copySnapshot.Row(0)))
190+}
@@ -145,24 +145,46 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) {
145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))145 require.False(t, ac.overlays.HandleInput(&Daemon{}, []byte("x")))
146 }146 }
147 147
148-func TestOverlayRuntimeSnapshotCarriesCopyPaneAndClearReleasesIt(t *testing.T) {148+func TestOverlayRuntimeSnapshotCarriesCopyDocumentAndClearReleasesIt(t *testing.T) {
149 ac := &attachedClient{}149 ac := &attachedClient{}
150 ac.initOverlays()150 ac.initOverlays()
151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})151 p := newPane("floating", nil, domain.Size{Cols: 4, Rows: 2})
152- snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)152+ document := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("row")}, 4, 2)
153 153
154 ac.overlays.copyMu.Lock()154 ac.overlays.copyMu.Lock()
155- ac.overlays.copyMode = scopy.NewMode(snap)155+ ac.overlays.copyMode = scopy.NewMode(document)
156 ac.overlays.copyPane = p156 ac.overlays.copyPane = p
157+ ac.overlays.copySnapshot = &document
157 ac.overlays.copyMu.Unlock()158 ac.overlays.copyMu.Unlock()
158 159
159 renderSnap := ac.overlays.SnapshotForRender()160 renderSnap := ac.overlays.SnapshotForRender()
160 require.Same(t, p, renderSnap.copyPane)161 require.Same(t, p, renderSnap.copyPane)
162+ require.Same(t, &document, renderSnap.copySnapshot)
161 renderSnap.Unlock()163 renderSnap.Unlock()
162 164
163 ac.overlays.copyMu.Lock()165 ac.overlays.copyMu.Lock()
164 ac.overlays.clearCopyModeLocked()166 ac.overlays.clearCopyModeLocked()
165 require.Nil(t, ac.overlays.copyPane)167 require.Nil(t, ac.overlays.copyPane)
166 require.Nil(t, ac.overlays.copyMode)168 require.Nil(t, ac.overlays.copyMode)
169+ require.Nil(t, ac.overlays.copySnapshot)
167 ac.overlays.copyMu.Unlock()170 ac.overlays.copyMu.Unlock()
168 }171 }
172+
173+func TestOverlayRuntimeCopyDocumentSurvivesRingOverwrite(t *testing.T) {
174+ ac := &attachedClient{}
175+ ac.initOverlays()
176+ history := scopy.NewScrollback(1)
177+ history.Append(testRow("before"))
178+ document := scopy.NewSnapshot(history, renderer.NewFrame(6, 1))
179+
180+ ac.overlays.copyMu.Lock()
181+ ac.overlays.copyMode = scopy.NewMode(document)
182+ ac.overlays.copySnapshot = &document
183+ ac.overlays.copyMu.Unlock()
184+
185+ history.Append(testRow("after"))
186+ renderSnap := ac.overlays.SnapshotForRender()
187+ defer renderSnap.Unlock()
188+ require.Same(t, &document, renderSnap.copySnapshot)
189+ require.Equal(t, "before", rowText(renderSnap.copySnapshot.Row(0)))
190+}
M internal/usecase/daemon/render.go +1 -1
@@ -406,7 +406,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
406406 copyPane = p
407407 }
408408 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
409- frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
409+ frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
410410 }
411411 // A palette above normal/copy content dims that composed content. When a
412412 // floating pane is present its own backdrop already dims normal pane
@@ -406,7 +406,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
406 copyPane = p406 copyPane = p
407 }407 }
408 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)408 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
409- frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)409+ frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
410 }410 }
411 // A palette above normal/copy content dims that composed content. When a411 // A palette above normal/copy content dims that composed content. When a
412 // floating pane is present its own backdrop already dims normal pane412 // floating pane is present its own backdrop already dims normal pane
M internal/usecase/daemon/render_test.go +3 -2
@@ -1419,10 +1419,11 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14191419 }
14201420 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
14211421 p.screen.Write([]byte("ab\r\ncd"))
1422- mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))
1422+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1423+ mode := scopy.NewMode(document)
14231424 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
14241425
1425- frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})
1426+ frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})
14261427
14271428 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
14281429 require.Equal(t, "##ab ", rowText(frame.Row(3)))
@@ -1419,10 +1419,11 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1419 }1419 }
1420 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1420 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1421 p.screen.Write([]byte("ab\r\ncd"))1421 p.screen.Write([]byte("ab\r\ncd"))
1422- mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))1422+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1423+ mode := scopy.NewMode(document)
1423 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1424 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1424 1425
1425- frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})1426+ frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})
1426 1427
1427 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)1428 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1428 require.Equal(t, "##ab ", rowText(frame.Row(3)))1429 require.Equal(t, "##ab ", rowText(frame.Row(3)))

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 2

Tags 13

↑↓ moveenter openesc close