detached 52c0e49

Merge pull request #69 from bnema/perf/immutable-history-baseline

fix(daemon): share immutable history views
commit
52c0e49ba428aa577526bdf74b1becd94d15fb62
author
bnema · · 2026-07-11 08:43 UTC
committer
GitHub · · 2026-07-11 08:43 UTC
merge of
d0ab56b + e03e10d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
24 files · +1219 -240
M docs/performance.md +85 -25
@@ -1,43 +1,103 @@
11 # Performance methodology (M5)
22
3-Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (or by exporting the variable before launching clients). When unset, no debug HTTP server is started.
3+This baseline measures in-process daemon work only. It deliberately does **not**
4+measure coordinator behavior, chunk encoding, compression, GC tuning, or real
5+network latency. Output metrics count frames and encoded output data produced
6+by the daemon; snapshot writes and serialized bytes are local snapshot-store
7+proxies. These metrics approximate local rendering, IPC payload, and persistence
8+work, not a Unix socket, SSH, or UDP transport. Real transport impairment and
9+latency benchmarks are deferred.
410
5-Run microbenchmarks with:
11+## Large-history daemon baseline
12+
13+Use a single iteration only as a smoke check; it is not suitable for drawing
14+performance conclusions:
15+
16+```sh
17+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
18+```
19+
20+For before/after comparison, collect repeated samples for the **full matrix**
21+(all workloads and topologies), pinning CPU/governor and recording the host.
22+The benchmark harness must be committed and present in **both** measured
23+revisions: collect the baseline after the harness is introduced but before the
24+optimization, then collect the optimized revision with that same harness. The
25+fixed duration and count make the output suitable for `benchstat`:
626
727 ```sh
8-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem
28+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt
29+benchstat /tmp/vev-history-before.txt /tmp/vev-history-after.txt
930 ```
1031
11-Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.
32+The matrix exercises live paint, snapshot capture, copy-mode entry, copy search,
33+and resize at `120x40` client geometry. Each pane is populated with 10,000
34+full-width deterministic history rows containing a stable `needle-<tab>-<row>`
35+prefix and patterned text. The layouts are:
36+
37+- 1 tab x 1 pane (control)
38+- 1 tab x 4 panes
39+- 4 tabs x 1 pane
40+- 4 tabs x 4 panes
41+- 8 tabs x 1 pane
1242
13-## Local baseline status
43+The fixture starts no PTY readers, schedulers, clocks, or transports. It primes
44+the renderer shadow before timing; live paint then alternates fixed writes. The
45+reported `outputframes/op` value counts output messages. `outputbytes/op`
46+counts their encoded terminal data and excludes the 24-byte output header;
47+`framepayloadbytes/op` counts the complete output payload including that header.
48+`snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and
49+serialized bytes. They must not be presented as network throughput or latency.
1450
15-Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair, external tracing tools around both processes, and (for automated vev flood workloads) future command override support. Until that exists, only start the daemon/client normally and do not claim a scripted vev flood command. tmux/raw comparison commands remain useful for shaping workloads:
51+Capture a pre-change result outside the repository, including Go and kernel
52+context:
1653
1754 ```sh
18-VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon
19-strace -ff -c -o vev-client.strace vev new perf-manual
20-strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes'
21-strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"'
22-strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'
55+{
56+ date -Is
57+ go version
58+ uname -a
59+ git rev-parse HEAD
60+ # Smoke only; do not use this result for comparisons.
61+ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
62+ # Repeated full matrix for every workload/topology used in conclusions.
63+ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem
64+} > /tmp/vev-history-before.txt 2>&1
2365 ```
2466
25-Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.
67+## Immutable document allocation gate
2668
27-## UDP mobile congestion behavior
69+The copy-mode document retains an immutable view of existing scrollback rows and
70+clones only the visible frame. Visual-search models share that document; cloning
71+a model copies only its mutable input and match state. Check those two paths at
72+10,000 history rows with:
2873
29-The datagram transport uses one shared congestion-responsive byte pacer for initial sends and retransmits. Retransmissions therefore consume the same byte budget as new data instead of bypassing congestion feedback. Datagram clients use a one-state output window: the cumulative ACK releases that state, and newer repaint state coalesces while it remains unacknowledged rather than retaining an unbounded queue.
74+```sh
75+go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem
76+```
3077
31-Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`:
78+Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
79+output with the accepted baseline before merging, and investigate any increase.
80+The automated scaling gates also compare equal row/match counts at narrow and
81+wide row widths; `B/op` may grow by at most 2x, a conservative relative
82+allowance that catches full cell-row copies without depending on an absolute
83+Go-version-specific budget. This scope does not yet include an
84+incremental/full-text search index: queries still scan the immutable document
85+and produce a fresh match list. Coordinator
86+behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or
87+UDP impairment/latency remain out of scope.
3288
33-| benchmark | methodology |
34-| --- | --- |
35-| BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. |
36-| BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. |
37-| BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. |
89+## Lower-level smoke baseline
3890
39-| workload | vev baseline | tmux baseline | notes |
40-| --- | --- | --- | --- |
41-| yes | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration |
42-| seq | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration |
43-| cat | not run in this non-interactive review environment | not run in this non-interactive review environment | use same input file, terminal size, and duration |
91+Use this command to check renderer, VT, IPC, and daemon microbenchmarks without
92+claiming an end-to-end transport result:
93+
94+```sh
95+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
96+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem
97+```
98+
99+Enable daemon pprof explicitly when collecting a daemon profile:
100+
101+```sh
102+VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon
103+```
@@ -1,43 +1,103 @@
1 # Performance methodology (M5)1 # Performance methodology (M5)
2 2
3-Enable daemon pprof explicitly with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon` (or by exporting the variable before launching clients). When unset, no debug HTTP server is started.3+This baseline measures in-process daemon work only. It deliberately does **not**
4+measure coordinator behavior, chunk encoding, compression, GC tuning, or real
5+network latency. Output metrics count frames and encoded output data produced
6+by the daemon; snapshot writes and serialized bytes are local snapshot-store
7+proxies. These metrics approximate local rendering, IPC payload, and persistence
8+work, not a Unix socket, SSH, or UDP transport. Real transport impairment and
9+latency benchmarks are deferred.
4 10
5-Run microbenchmarks with:11+## Large-history daemon baseline
12+
13+Use a single iteration only as a smoke check; it is not suitable for drawing
14+performance conclusions:
15+
16+```sh
17+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
18+```
19+
20+For before/after comparison, collect repeated samples for the **full matrix**
21+(all workloads and topologies), pinning CPU/governor and recording the host.
22+The benchmark harness must be committed and present in **both** measured
23+revisions: collect the baseline after the harness is introduced but before the
24+optimization, then collect the optimized revision with that same harness. The
25+fixed duration and count make the output suitable for `benchstat`:
6 26
7 ```sh27 ```sh
8-go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem28+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem > /tmp/vev-history-after.txt
29+benchstat /tmp/vev-history-before.txt /tmp/vev-history-after.txt
9 ```30 ```
10 31
11-Demo flood workloads are scripted in `scripts/bench-workloads.sh` for `yes`, `seq`, and `cat` styles for raw/tmux runs. vev does not currently support `vev new <name> -- command`, so automated vev workload command overrides are intentionally not documented. Capture bytes/frame and syscalls/frame externally (for example with `strace -c` around the client/daemon) and allocations with `-benchmem`/pprof.32+The matrix exercises live paint, snapshot capture, copy-mode entry, copy search,
33+and resize at `120x40` client geometry. Each pane is populated with 10,000
34+full-width deterministic history rows containing a stable `needle-<tab>-<row>`
35+prefix and patterned text. The layouts are:
36+
37+- 1 tab x 1 pane (control)
38+- 1 tab x 4 panes
39+- 4 tabs x 1 pane
40+- 4 tabs x 4 panes
41+- 8 tabs x 1 pane
12 42
13-## Local baseline status43+The fixture starts no PTY readers, schedulers, clocks, or transports. It primes
44+the renderer shadow before timing; live paint then alternates fixed writes. The
45+reported `outputframes/op` value counts output messages. `outputbytes/op`
46+counts their encoded terminal data and excludes the 24-byte output header;
47+`framepayloadbytes/op` counts the complete output payload including that header.
48+`snapshotwrites/op` and `snapshotbytes/op` are local snapshot-store writes and
49+serialized bytes. They must not be presented as network throughput or latency.
14 50
15-Full end-to-end vev-vs-tmux workload baselines are deferred/waived for this non-interactive branch because they require an interactive terminal/daemon pair, external tracing tools around both processes, and (for automated vev flood workloads) future command override support. Until that exists, only start the daemon/client normally and do not claim a scripted vev flood command. tmux/raw comparison commands remain useful for shaping workloads:51+Capture a pre-change result outside the repository, including Go and kernel
52+context:
16 53
17 ```sh54 ```sh
18-VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon55+{
19-strace -ff -c -o vev-client.strace vev new perf-manual56+ date -Is
20-strace -ff -c -o tmux-yes.strace tmux new-session -d -s perf-yes 'timeout 30s yes'57+ go version
21-strace -ff -c -o tmux-seq.strace tmux new-session -d -s perf-seq 'timeout 30s sh -c "while :; do seq 1 10000; done"'58+ uname -a
22-strace -ff -c -o tmux-cat.strace tmux new-session -d -s perf-cat 'timeout 30s sh -c "while :; do cat /path/to/sample.txt; done"'59+ git rev-parse HEAD
60+ # Smoke only; do not use this result for comparisons.
61+ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1x -benchmem
62+ # Repeated full matrix for every workload/topology used in conclusions.
63+ go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkDaemonHistory' -benchtime=1s -count=5 -benchmem
64+} > /tmp/vev-history-before.txt 2>&1
23 ```65 ```
24 66
25-Record host CPU, OS/kernel, terminal emulator, vev commit, tmux version, terminal size, workload duration, raw command lines, syscall summaries, bytes written per rendered frame, and heap profiles or allocation counts before comparing.67+## Immutable document allocation gate
26 68
27-## UDP mobile congestion behavior69+The copy-mode document retains an immutable view of existing scrollback rows and
70+clones only the visible frame. Visual-search models share that document; cloning
71+a model copies only its mutable input and match state. Check those two paths at
72+10,000 history rows with:
28 73
29-The datagram transport uses one shared congestion-responsive byte pacer for initial sends and retransmits. Retransmissions therefore consume the same byte budget as new data instead of bypassing congestion feedback. Datagram clients use a one-state output window: the cumulative ACK releases that state, and newer repaint state coalesces while it remains unacknowledged rather than retaining an unbounded queue.74+```sh
75+go test ./internal/usecase/copy ./internal/usecase/visualsearch -run '^$' -bench '^(BenchmarkNewSnapshot10KRows|BenchmarkVisualSearchClone10KRows)$' -benchtime=1s -count=5 -benchmem
76+```
30 77
31-Microbenchmarks executed locally for this review using `go test ./internal/adapters/ipc ./internal/usecase/daemon -run '^$' -bench=. -benchmem`:78+Treat `allocs/op` and `B/op` as allocation gates: compare the complete repeated
79+output with the accepted baseline before merging, and investigate any increase.
80+The automated scaling gates also compare equal row/match counts at narrow and
81+wide row widths; `B/op` may grow by at most 2x, a conservative relative
82+allowance that catches full cell-row copies without depending on an absolute
83+Go-version-specific budget. This scope does not yet include an
84+incremental/full-text search index: queries still scan the immutable document
85+and produce a fresh match list. Coordinator
86+behavior, chunk encoding, compression, GC tuning, and real Unix-socket, SSH, or
87+UDP impairment/latency remain out of scope.
32 88
33-| benchmark | methodology |89+## Lower-level smoke baseline
34-| --- | --- |
35-| BenchmarkTransportSend | writes encoded frames to an in-memory discard `net.Conn`; allocations reported here are Send-side frame assembly costs. |
36-| BenchmarkTransportRecvReuse | reads a pre-encoded frame from a looping in-memory reader; no `Send` call, goroutine, channel, or frame production work runs inside the measured loop, so allocations reported here are Recv-side costs only. |
37-| BenchmarkPaintCachedSinglePaneDamage | writes a small damage update into one pane, paints through the daemon compose→diff→send path, and advances the output ACK each iteration so the measured loop does not hit the unacked-output bailout. |
38 90
39-| workload | vev baseline | tmux baseline | notes |91+Use this command to check renderer, VT, IPC, and daemon microbenchmarks without
40-| --- | --- | --- | --- |92+claiming an end-to-end transport result:
41-| yes | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration |93+
42-| seq | not run in this non-interactive review environment | not run in this non-interactive review environment | use same terminal size and duration |94+```sh
43-| cat | not run in this non-interactive review environment | not run in this non-interactive review environment | use same input file, terminal size, and duration |95+go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc -run '^$' -bench=. -benchmem
96+go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkComposeFloatingFrameCached$' -benchmem
97+```
98+
99+Enable daemon pprof explicitly when collecting a daemon profile:
100+
101+```sh
102+VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon
103+```
M internal/usecase/copy/mode.go +53 -40
@@ -2,6 +2,7 @@ package copy
22
33 import (
44 "encoding/base64"
5+ "slices"
56 "strings"
67 "unicode"
78
@@ -15,29 +16,52 @@ import (
1516 // specific continuation protocol is supported.
1617 const OSC52MaxPayloadBytes = 75_000
1718
18-// Snapshot is the scrollback-mode document: scrollback followed by the live screen.
19+// Snapshot is the immutable scrollback-mode document: history followed by the
20+// visible screen. History rows are immutable by Scrollback's append contract;
21+// the visible frame is cloned once when the snapshot is constructed.
1922 type Snapshot struct {
20- Rows [][]renderer.Cell
21- Width int
22- Height int
23+ history HistoryView
24+ screen renderer.Frame
25+ Width int
26+ Height int
2327 }
2428
25-// NewSnapshot copies rows from scrollback and then the live screen frame.
29+// NewSnapshot freezes the current scrollback view and clones the visible screen.
2630 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
27- scrollRows := 0
31+ var history HistoryView
2832 if sb != nil {
29- scrollRows = sb.Len()
33+ history = sb.View()
3034 }
31- rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)
32- if sb != nil {
33- for i := range sb.Len() {
34- rows = append(rows, sb.Row(i))
35- }
35+ return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
36+}
37+
38+// NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
39+// It is intended for callers that already have a complete document rather than
40+// a scrollback and visible frame.
41+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42+ owned := make([][]renderer.Cell, len(rows))
43+ for i, row := range rows {
44+ owned[i] = append([]renderer.Cell(nil), row...)
45+ }
46+ return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
47+}
48+
49+// Len returns the number of document rows.
50+func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
51+
52+// Row returns document row i, or nil when i is out of range.
53+func (s Snapshot) Row(i int) []renderer.Cell {
54+ if i < 0 {
55+ return nil
3656 }
37- for y := range screen.Height {
38- rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))
57+ if i < s.history.Len() {
58+ return s.history.Row(i)
3959 }
40- return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
60+ i -= s.history.Len()
61+ if i >= s.screen.Height {
62+ return nil
63+ }
64+ return s.screen.Row(i)
4165 }
4266
4367 // Mode stores per-client scrollback viewport and line-selection state.
@@ -60,8 +84,8 @@ type Mode struct {
6084
6185 func NewMode(s Snapshot) *Mode {
6286 m := &Mode{Anchor: -1}
63- m.ViewportTop = max(len(s.Rows)-s.Height, 0)
64- m.Cursor = max(len(s.Rows)-1, 0)
87+ m.ViewportTop = max(s.Len()-s.Height, 0)
88+ m.Cursor = max(s.Len()-1, 0)
6589 m.clamp(s)
6690 return m
6791 }
@@ -69,7 +93,7 @@ func NewMode(s Snapshot) *Mode {
6993 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }
7094 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }
7195 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
72-func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }
96+func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) }
7397
7498 func FindMatches(s Snapshot, query string) []SearchMatch {
7599 query = strings.TrimSpace(query)
@@ -78,11 +102,12 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
78102 }
79103 needle := lowerRunes(query)
80104 matches := make([]SearchMatch, 0)
81- for row, cells := range s.Rows {
105+ for row := range s.Len() {
106+ cells := s.Row(row)
82107 haystack, cellIndexes := searchableCells(cells)
83108 var text string
84109 for start := 0; start+len(needle) <= len(haystack); {
85- if !sameRunes(haystack[start:start+len(needle)], needle) {
110+ if !slices.Equal(haystack[start:start+len(needle)], needle) {
86111 start++
87112 continue
88113 }
@@ -126,18 +151,6 @@ func lowerRunes(s string) []rune {
126151 return runes
127152 }
128153
129-func sameRunes(a, b []rune) bool {
130- if len(a) != len(b) {
131- return false
132- }
133- for i := range a {
134- if a[i] != b[i] {
135- return false
136- }
137- }
138- return true
139-}
140-
141154 func (m *Mode) Search(s Snapshot, query string) bool {
142155 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143156 }
@@ -190,7 +203,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) {
190203 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }
191204
192205 func (m *Mode) AtBottom(s Snapshot) bool {
193- total := len(s.Rows)
206+ total := s.Len()
194207 if total == 0 {
195208 return m.Cursor == 0 && m.ViewportTop == 0
196209 }
@@ -220,7 +233,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) {
220233 }
221234
222235 func (m *Mode) clamp(s Snapshot) {
223- total := len(s.Rows)
236+ total := s.Len()
224237 if total == 0 {
225238 m.Cursor, m.ViewportTop = 0, 0
226239 return
@@ -243,14 +256,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) {
243256
244257 func (m *Mode) SelectedText(s Snapshot) string {
245258 lo, hi, ok := m.SelectedBounds()
246- if !ok || len(s.Rows) == 0 {
259+ if !ok || s.Len() == 0 {
247260 return ""
248261 }
249262 lo = max(lo, 0)
250- hi = min(hi, len(s.Rows)-1)
263+ hi = min(hi, s.Len()-1)
251264 lines := make([]string, 0, hi-lo+1)
252265 for i := lo; i <= hi; i++ {
253- lines = append(lines, rowString(s.Rows[i]))
266+ lines = append(lines, rowString(s.Row(i)))
254267 }
255268 return strings.Join(lines, "\n")
256269 }
@@ -262,11 +275,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
262275 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
263276 for y := range s.Height {
264277 src := m.ViewportTop + y
265- if src >= len(s.Rows) {
278+ if src >= s.Len() {
266279 break
267280 }
268281 row := frame.Row(y)
269- copy(row, s.Rows[src])
282+ copy(row, s.Row(src))
270283 lineSelected := selected && src >= lo && src <= hi
271284 if lineSelected {
272285 for x := range row {
@@ -286,7 +299,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
286299 if len(styles) > 0 {
287300 style = styles[0]
288301 }
289- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)
302+ drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
290303 return frame
291304 }
292305
@@ -2,6 +2,7 @@ package copy
2 2
3 import (3 import (
4 "encoding/base64"4 "encoding/base64"
5+ "slices"
5 "strings"6 "strings"
6 "unicode"7 "unicode"
7 8
@@ -15,29 +16,52 @@ import (
15 // specific continuation protocol is supported.16 // specific continuation protocol is supported.
16 const OSC52MaxPayloadBytes = 75_00017 const OSC52MaxPayloadBytes = 75_000
17 18
18-// Snapshot is the scrollback-mode document: scrollback followed by the live screen.19+// Snapshot is the immutable scrollback-mode document: history followed by the
20+// visible screen. History rows are immutable by Scrollback's append contract;
21+// the visible frame is cloned once when the snapshot is constructed.
19 type Snapshot struct {22 type Snapshot struct {
20- Rows [][]renderer.Cell23+ history HistoryView
21- Width int24+ screen renderer.Frame
22- Height int25+ Width int
26+ Height int
23 }27 }
24 28
25-// NewSnapshot copies rows from scrollback and then the live screen frame.29+// NewSnapshot freezes the current scrollback view and clones the visible screen.
26 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {30 func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
27- scrollRows := 031+ var history HistoryView
28 if sb != nil {32 if sb != nil {
29- scrollRows = sb.Len()33+ history = sb.View()
30 }34 }
31- rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)35+ return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
32- if sb != nil {36+}
33- for i := range sb.Len() {37+
34- rows = append(rows, sb.Row(i))38+// NewSnapshotFromRows constructs a snapshot that owns explicit caller rows.
35- }39+// It is intended for callers that already have a complete document rather than
40+// a scrollback and visible frame.
41+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
42+ owned := make([][]renderer.Cell, len(rows))
43+ for i, row := range rows {
44+ owned[i] = append([]renderer.Cell(nil), row...)
45+ }
46+ return Snapshot{history: HistoryView{rows: owned}, Width: width, Height: height}
47+}
48+
49+// Len returns the number of document rows.
50+func (s Snapshot) Len() int { return s.history.Len() + s.screen.Height }
51+
52+// Row returns document row i, or nil when i is out of range.
53+func (s Snapshot) Row(i int) []renderer.Cell {
54+ if i < 0 {
55+ return nil
36 }56 }
37- for y := range screen.Height {57+ if i < s.history.Len() {
38- rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))58+ return s.history.Row(i)
39 }59 }
40- return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}60+ i -= s.history.Len()
61+ if i >= s.screen.Height {
62+ return nil
63+ }
64+ return s.screen.Row(i)
41 }65 }
42 66
43 // Mode stores per-client scrollback viewport and line-selection state.67 // Mode stores per-client scrollback viewport and line-selection state.
@@ -60,8 +84,8 @@ type Mode struct {
60 84
61 func NewMode(s Snapshot) *Mode {85 func NewMode(s Snapshot) *Mode {
62 m := &Mode{Anchor: -1}86 m := &Mode{Anchor: -1}
63- m.ViewportTop = max(len(s.Rows)-s.Height, 0)87+ m.ViewportTop = max(s.Len()-s.Height, 0)
64- m.Cursor = max(len(s.Rows)-1, 0)88+ m.Cursor = max(s.Len()-1, 0)
65 m.clamp(s)89 m.clamp(s)
66 return m90 return m
67 }91 }
@@ -69,7 +93,7 @@ func NewMode(s Snapshot) *Mode {
69 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }93 func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) }
70 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }94 func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) }
71 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }95 func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) }
72-func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) }96+func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, s.Len()-1) }
73 97
74 func FindMatches(s Snapshot, query string) []SearchMatch {98 func FindMatches(s Snapshot, query string) []SearchMatch {
75 query = strings.TrimSpace(query)99 query = strings.TrimSpace(query)
@@ -78,11 +102,12 @@ func FindMatches(s Snapshot, query string) []SearchMatch {
78 }102 }
79 needle := lowerRunes(query)103 needle := lowerRunes(query)
80 matches := make([]SearchMatch, 0)104 matches := make([]SearchMatch, 0)
81- for row, cells := range s.Rows {105+ for row := range s.Len() {
106+ cells := s.Row(row)
82 haystack, cellIndexes := searchableCells(cells)107 haystack, cellIndexes := searchableCells(cells)
83 var text string108 var text string
84 for start := 0; start+len(needle) <= len(haystack); {109 for start := 0; start+len(needle) <= len(haystack); {
85- if !sameRunes(haystack[start:start+len(needle)], needle) {110+ if !slices.Equal(haystack[start:start+len(needle)], needle) {
86 start++111 start++
87 continue112 continue
88 }113 }
@@ -126,18 +151,6 @@ func lowerRunes(s string) []rune {
126 return runes151 return runes
127 }152 }
128 153
129-func sameRunes(a, b []rune) bool {
130- if len(a) != len(b) {
131- return false
132- }
133- for i := range a {
134- if a[i] != b[i] {
135- return false
136- }
137- }
138- return true
139-}
140-
141 func (m *Mode) Search(s Snapshot, query string) bool {154 func (m *Mode) Search(s Snapshot, query string) bool {
142 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)155 return m.SetSearchMatches(s, query, FindMatches(s, query), 0)
143 }156 }
@@ -190,7 +203,7 @@ func (m *Mode) StartSelectionAt(s Snapshot, row int) {
190 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }203 func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) }
191 204
192 func (m *Mode) AtBottom(s Snapshot) bool {205 func (m *Mode) AtBottom(s Snapshot) bool {
193- total := len(s.Rows)206+ total := s.Len()
194 if total == 0 {207 if total == 0 {
195 return m.Cursor == 0 && m.ViewportTop == 0208 return m.Cursor == 0 && m.ViewportTop == 0
196 }209 }
@@ -220,7 +233,7 @@ func (m *Mode) setCursor(s Snapshot, cursor int) {
220 }233 }
221 234
222 func (m *Mode) clamp(s Snapshot) {235 func (m *Mode) clamp(s Snapshot) {
223- total := len(s.Rows)236+ total := s.Len()
224 if total == 0 {237 if total == 0 {
225 m.Cursor, m.ViewportTop = 0, 0238 m.Cursor, m.ViewportTop = 0, 0
226 return239 return
@@ -243,14 +256,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) {
243 256
244 func (m *Mode) SelectedText(s Snapshot) string {257 func (m *Mode) SelectedText(s Snapshot) string {
245 lo, hi, ok := m.SelectedBounds()258 lo, hi, ok := m.SelectedBounds()
246- if !ok || len(s.Rows) == 0 {259+ if !ok || s.Len() == 0 {
247 return ""260 return ""
248 }261 }
249 lo = max(lo, 0)262 lo = max(lo, 0)
250- hi = min(hi, len(s.Rows)-1)263+ hi = min(hi, s.Len()-1)
251 lines := make([]string, 0, hi-lo+1)264 lines := make([]string, 0, hi-lo+1)
252 for i := lo; i <= hi; i++ {265 for i := lo; i <= hi; i++ {
253- lines = append(lines, rowString(s.Rows[i]))266+ lines = append(lines, rowString(s.Row(i)))
254 }267 }
255 return strings.Join(lines, "\n")268 return strings.Join(lines, "\n")
256 }269 }
@@ -262,11 +275,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
262 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)275 selectionStyle, hasSelectionStyle := optionalStyle(styles, 1)
263 for y := range s.Height {276 for y := range s.Height {
264 src := m.ViewportTop + y277 src := m.ViewportTop + y
265- if src >= len(s.Rows) {278+ if src >= s.Len() {
266 break279 break
267 }280 }
268 row := frame.Row(y)281 row := frame.Row(y)
269- copy(row, s.Rows[src])282+ copy(row, s.Row(src))
270 lineSelected := selected && src >= lo && src <= hi283 lineSelected := selected && src >= lo && src <= hi
271 if lineSelected {284 if lineSelected {
272 for x := range row {285 for x := range row {
@@ -286,7 +299,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame {
286 if len(styles) > 0 {299 if len(styles) > 0 {
287 style = styles[0]300 style = styles[0]
288 }301 }
289- drawCopyStatus(frame.Row(s.Height), m, len(s.Rows), style)302+ drawCopyStatus(frame.Row(s.Height), m, s.Len(), style)
290 return frame303 return frame
291 }304 }
292 305
M internal/usecase/copy/mode_test.go +29 -5
@@ -10,12 +10,14 @@ import (
1010 "github.com/stretchr/testify/require"
1111 )
1212
13+var benchmarkSnapshotSink Snapshot
14+
1315 func snapshot(lines []string, height int) Snapshot {
1416 rows := make([][]renderer.Cell, len(lines))
1517 for i, line := range lines {
1618 rows[i] = row(line)
1719 }
18- return Snapshot{Rows: rows, Width: 16, Height: height}
20+ return NewSnapshotFromRows(rows, 16, height)
1921 }
2022
2123 func inverseRow(text string) []renderer.Cell {
@@ -225,8 +227,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
225227 }
226228
227229 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
228- s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
229- s.Rows[1] = inverseRow("beta ")
230+ s := NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2)
230231 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
231232 m.Move(s, 1)
232233
@@ -274,7 +275,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
274275 // flip it back to non-inverse and hide the highlight.
275276 rows := [][]renderer.Cell{row("alpha "), row("beta ")}
276277 rows[0][0].Style.Inverse = true
277- s := Snapshot{Rows: rows, Width: 16, Height: 2}
278+ s := NewSnapshotFromRows(rows, 16, 2)
278279 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
279280
280281 frame := m.Render(s)
@@ -365,7 +366,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
365366 {Rune: 'h'},
366367 {Rune: 'a'},
367368 }
368- s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}
369+ s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1)
369370
370371 wideMatches := FindMatches(s, "界")
371372 require.Len(t, wideMatches, 1)
@@ -477,6 +478,29 @@ func TestOSC52FromBase64(t *testing.T) {
477478 }
478479 }
479480
481+func BenchmarkNewSnapshot10KRows(b *testing.B) {
482+ const (
483+ width = 120
484+ height = 40
485+ historyRows = 10_000
486+ )
487+
488+ scrollback := NewScrollback(historyRows)
489+ for range historyRows {
490+ scrollback.Append(row(strings.Repeat("x", width)))
491+ }
492+ screen := renderer.NewFrame(width, height)
493+ for y := range height {
494+ copy(screen.Row(y), row(strings.Repeat("s", width)))
495+ }
496+
497+ b.ReportAllocs()
498+ b.ResetTimer()
499+ for b.Loop() {
500+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
501+ }
502+}
503+
480504 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
481505 t.Helper()
482506 s := string(chunk)
@@ -10,12 +10,14 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 )11 )
12 12
13+var benchmarkSnapshotSink Snapshot
14+
13 func snapshot(lines []string, height int) Snapshot {15 func snapshot(lines []string, height int) Snapshot {
14 rows := make([][]renderer.Cell, len(lines))16 rows := make([][]renderer.Cell, len(lines))
15 for i, line := range lines {17 for i, line := range lines {
16 rows[i] = row(line)18 rows[i] = row(line)
17 }19 }
18- return Snapshot{Rows: rows, Width: 16, Height: height}20+ return NewSnapshotFromRows(rows, 16, height)
19 }21 }
20 22
21 func inverseRow(text string) []renderer.Cell {23 func inverseRow(text string) []renderer.Cell {
@@ -225,8 +227,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) {
225 }227 }
226 228
227 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {229 func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
228- s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)230+ s := NewSnapshotFromRows([][]renderer.Cell{row("alpha "), inverseRow("beta "), row("gamma ")}, 16, 2)
229- s.Rows[1] = inverseRow("beta ")
230 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}231 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
231 m.Move(s, 1)232 m.Move(s, 1)
232 233
@@ -274,7 +275,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) {
274 // flip it back to non-inverse and hide the highlight.275 // flip it back to non-inverse and hide the highlight.
275 rows := [][]renderer.Cell{row("alpha "), row("beta ")}276 rows := [][]renderer.Cell{row("alpha "), row("beta ")}
276 rows[0][0].Style.Inverse = true277 rows[0][0].Style.Inverse = true
277- s := Snapshot{Rows: rows, Width: 16, Height: 2}278+ s := NewSnapshotFromRows(rows, 16, 2)
278 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}279 m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
279 280
280 frame := m.Render(s)281 frame := m.Render(s)
@@ -365,7 +366,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) {
365 {Rune: 'h'},366 {Rune: 'h'},
366 {Rune: 'a'},367 {Rune: 'a'},
367 }368 }
368- s := Snapshot{Rows: [][]renderer.Cell{row}, Width: 8, Height: 1}369+ s := NewSnapshotFromRows([][]renderer.Cell{row}, 8, 1)
369 370
370 wideMatches := FindMatches(s, "界")371 wideMatches := FindMatches(s, "界")
371 require.Len(t, wideMatches, 1)372 require.Len(t, wideMatches, 1)
@@ -477,6 +478,29 @@ func TestOSC52FromBase64(t *testing.T) {
477 }478 }
478 }479 }
479 480
481+func BenchmarkNewSnapshot10KRows(b *testing.B) {
482+ const (
483+ width = 120
484+ height = 40
485+ historyRows = 10_000
486+ )
487+
488+ scrollback := NewScrollback(historyRows)
489+ for range historyRows {
490+ scrollback.Append(row(strings.Repeat("x", width)))
491+ }
492+ screen := renderer.NewFrame(width, height)
493+ for y := range height {
494+ copy(screen.Row(y), row(strings.Repeat("s", width)))
495+ }
496+
497+ b.ReportAllocs()
498+ b.ResetTimer()
499+ for b.Loop() {
500+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
501+ }
502+}
503+
480 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {504 func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte {
481 t.Helper()505 t.Helper()
482 s := string(chunk)506 s := string(chunk)
M internal/usecase/copy/scrollback.go +22 -5
@@ -56,11 +56,28 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell {
5656 return rows
5757 }
5858
59-// Row returns a copy of the retained row at logical index i, oldest first.
60-func (s *Scrollback) Row(i int) []renderer.Cell {
61- if i < 0 || i >= s.len || len(s.rows) == 0 {
59+// HistoryView is a frozen oldest-first view of scrollback rows.
60+//
61+// Its rows share cell slices with the scrollback at the time View is called.
62+// Callers must treat rows returned by Row as read-only.
63+type HistoryView struct {
64+ rows [][]renderer.Cell
65+}
66+
67+// View returns a frozen scrollback view. Appending to the scrollback cannot
68+// alter the view because Append replaces ring slots rather than mutating rows.
69+func (s *Scrollback) View() HistoryView {
70+ return HistoryView{rows: s.Snapshot()}
71+}
72+
73+// Len returns the number of rows in the view.
74+func (v HistoryView) Len() int { return len(v.rows) }
75+
76+// Row returns the row at logical index i, oldest first, or nil when i is out
77+// of range. Returned rows are shared with the view and must be read-only.
78+func (v HistoryView) Row(i int) []renderer.Cell {
79+ if i < 0 || i >= len(v.rows) {
6280 return nil
6381 }
64- row := s.rows[(s.head+i)%len(s.rows)]
65- return append([]renderer.Cell(nil), row...)
82+ return v.rows[i]
6683 }
@@ -56,11 +56,28 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell {
56 return rows56 return rows
57 }57 }
58 58
59-// Row returns a copy of the retained row at logical index i, oldest first.59+// HistoryView is a frozen oldest-first view of scrollback rows.
60-func (s *Scrollback) Row(i int) []renderer.Cell {60+//
61- if i < 0 || i >= s.len || len(s.rows) == 0 {61+// Its rows share cell slices with the scrollback at the time View is called.
62+// Callers must treat rows returned by Row as read-only.
63+type HistoryView struct {
64+ rows [][]renderer.Cell
65+}
66+
67+// View returns a frozen scrollback view. Appending to the scrollback cannot
68+// alter the view because Append replaces ring slots rather than mutating rows.
69+func (s *Scrollback) View() HistoryView {
70+ return HistoryView{rows: s.Snapshot()}
71+}
72+
73+// Len returns the number of rows in the view.
74+func (v HistoryView) Len() int { return len(v.rows) }
75+
76+// Row returns the row at logical index i, oldest first, or nil when i is out
77+// of range. Returned rows are shared with the view and must be read-only.
78+func (v HistoryView) Row(i int) []renderer.Cell {
79+ if i < 0 || i >= len(v.rows) {
62 return nil80 return nil
63 }81 }
64- row := s.rows[(s.head+i)%len(s.rows)]82+ return v.rows[i]
65- return append([]renderer.Cell(nil), row...)
66 }83 }
M internal/usecase/copy/scrollback_test.go +38 -6
@@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) {
8282 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
8383 }
8484 for i, want := range tt.wantRows {
85- if got := rowText(sb.Row(i)); got != want {
85+ if got := rowText(sb.View().Row(i)); got != want {
8686 t.Fatalf("Row(%d) = %q, want %q", i, got, want)
8787 }
8888 }
@@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) {
103103 sb.Append(r)
104104 r[0].Rune = 'z'
105105
106- got := sb.Row(0)
107- got[1].Rune = 'y'
108- if text := rowText(sb.Row(0)); text != "ab" {
109- t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
106+ if text := rowText(sb.View().Row(0)); text != "ab" {
107+ t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
110108 }
111109 })
112110 }
@@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) {
151149 }
152150 if len(got) > 0 {
153151 got[0] = row("zz")
154- if text := rowText(sb.Row(0)); text != tt.wantRows[0] {
152+ if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
155153 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156154 }
157155 }
@@ -175,3 +173,37 @@ func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
175173 t.Fatalf("snapshot[1] after append = %q, want original row", text)
176174 }
177175 }
176+
177+func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178+ sb := NewScrollback(2)
179+ sb.Append(row("aa"))
180+ sb.Append(row("bb"))
181+ view := sb.View()
182+ if &view.Row(0)[0] != &sb.rows[0][0] {
183+ t.Fatal("View() deep-copied cells, want shared row storage")
184+ }
185+
186+ sb.Append(row("cc"))
187+ sb.Append(row("dd"))
188+
189+ if got := view.Len(); got != 2 {
190+ t.Fatalf("View().Len() = %d, want 2", got)
191+ }
192+ for i, want := range []string{"aa", "bb"} {
193+ if got := rowText(view.Row(i)); got != want {
194+ t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195+ }
196+ }
197+}
198+
199+func TestHistoryViewRowBounds(t *testing.T) {
200+ sb := NewScrollback(1)
201+ sb.Append(row("aa"))
202+ view := sb.View()
203+
204+ for _, i := range []int{-1, 1} {
205+ if got := view.Row(i); got != nil {
206+ t.Errorf("View().Row(%d) = %v, want nil", i, got)
207+ }
208+ }
209+}
@@ -82,7 +82,7 @@ func TestScrollbackRing(t *testing.T) {
82 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)82 t.Fatalf("Len() = %d, want %d", got, tt.wantLen)
83 }83 }
84 for i, want := range tt.wantRows {84 for i, want := range tt.wantRows {
85- if got := rowText(sb.Row(i)); got != want {85+ if got := rowText(sb.View().Row(i)); got != want {
86 t.Fatalf("Row(%d) = %q, want %q", i, got, want)86 t.Fatalf("Row(%d) = %q, want %q", i, got, want)
87 }87 }
88 }88 }
@@ -103,10 +103,8 @@ func TestScrollbackCopiesRows(t *testing.T) {
103 sb.Append(r)103 sb.Append(r)
104 r[0].Rune = 'z'104 r[0].Rune = 'z'
105 105
106- got := sb.Row(0)106+ if text := rowText(sb.View().Row(0)); text != "ab" {
107- got[1].Rune = 'y'107+ t.Fatalf("stored row = %q, want copy unaffected by caller mutation", text)
108- if text := rowText(sb.Row(0)); text != "ab" {
109- t.Fatalf("stored row = %q, want copy unaffected by caller/read mutation", text)
110 }108 }
111 })109 })
112 }110 }
@@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) {
151 }149 }
152 if len(got) > 0 {150 if len(got) > 0 {
153 got[0] = row("zz")151 got[0] = row("zz")
154- if text := rowText(sb.Row(0)); text != tt.wantRows[0] {152+ if text := rowText(sb.View().Row(0)); text != tt.wantRows[0] {
155 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])153 t.Fatalf("Snapshot() outer slice mutation changed storage: Row(0) = %q, want %q", text, tt.wantRows[0])
156 }154 }
157 }155 }
@@ -175,3 +173,37 @@ func TestScrollbackAppendDoesNotMutateSnapshottedRows(t *testing.T) {
175 t.Fatalf("snapshot[1] after append = %q, want original row", text)173 t.Fatalf("snapshot[1] after append = %q, want original row", text)
176 }174 }
177 }175 }
176+
177+func TestScrollbackViewSurvivesCompleteRingOverwrite(t *testing.T) {
178+ sb := NewScrollback(2)
179+ sb.Append(row("aa"))
180+ sb.Append(row("bb"))
181+ view := sb.View()
182+ if &view.Row(0)[0] != &sb.rows[0][0] {
183+ t.Fatal("View() deep-copied cells, want shared row storage")
184+ }
185+
186+ sb.Append(row("cc"))
187+ sb.Append(row("dd"))
188+
189+ if got := view.Len(); got != 2 {
190+ t.Fatalf("View().Len() = %d, want 2", got)
191+ }
192+ for i, want := range []string{"aa", "bb"} {
193+ if got := rowText(view.Row(i)); got != want {
194+ t.Fatalf("View().Row(%d) = %q, want %q", i, got, want)
195+ }
196+ }
197+}
198+
199+func TestHistoryViewRowBounds(t *testing.T) {
200+ sb := NewScrollback(1)
201+ sb.Append(row("aa"))
202+ view := sb.View()
203+
204+ for _, i := range []int{-1, 1} {
205+ if got := view.Row(i); got != nil {
206+ t.Errorf("View().Row(%d) = %v, want nil", i, got)
207+ }
208+ }
209+}
A internal/usecase/copy/snapshot_test.go +85 -0
@@ -0,0 +1,85 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10+ sb := NewScrollback(1)
11+ sb.Append(row("old"))
12+ screen := renderer.NewFrame(3, 1)
13+ copy(screen.Row(0), row("one"))
14+
15+ snapshot := NewSnapshot(sb, screen)
16+ if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17+ t.Fatal("NewSnapshot() deep-copied immutable history")
18+ }
19+ sb.Append(row("new"))
20+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21+
22+ if got := snapshot.Len(); got != 2 {
23+ t.Fatalf("Len() = %d, want 2", got)
24+ }
25+ for i, want := range []string{"old", "one"} {
26+ if got := rowText(snapshot.Row(i)); got != want {
27+ t.Errorf("Row(%d) = %q, want %q", i, got, want)
28+ }
29+ }
30+ for _, i := range []int{-1, 2} {
31+ if got := snapshot.Row(i); got != nil {
32+ t.Errorf("Row(%d) = %v, want nil", i, got)
33+ }
34+ }
35+}
36+
37+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38+ rows := [][]renderer.Cell{row("one")}
39+ snapshot := NewSnapshotFromRows(rows, 3, 1)
40+ rows[0][0].Rune = 'x'
41+ if got := rowText(snapshot.Row(0)); got != "one" {
42+ t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43+ }
44+}
45+
46+// This scaling gate deliberately compares equal row counts rather than an
47+// absolute budget, which varies between Go releases. A full history-row clone
48+// grows with row width; the immutable HistoryView allocation does not.
49+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50+ if testing.Short() {
51+ t.Skip("skipping benchmark-driven allocation gate in short mode")
52+ }
53+ narrow := benchmarkNewSnapshotBytes(16)
54+ wide := benchmarkNewSnapshotBytes(512)
55+ assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56+}
57+
58+func benchmarkNewSnapshotBytes(width int) int64 {
59+ const historyRows = 256
60+
61+ scrollback := NewScrollback(historyRows)
62+ for range historyRows {
63+ scrollback.Append(make([]renderer.Cell, width))
64+ }
65+ // Keep visible-frame work constant so the comparison isolates history rows.
66+ screen := renderer.NewFrame(8, 4)
67+ result := testing.Benchmark(func(b *testing.B) {
68+ b.ReportAllocs()
69+ b.ResetTimer()
70+ for b.Loop() {
71+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72+ }
73+ })
74+ return result.AllocedBytesPerOp()
75+}
76+
77+func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78+ t.Helper()
79+ // Two times permits allocator noise while decisively rejecting a cell-row
80+ // copy, whose allocation grows by 32x for the widths above.
81+ const conservativeWidthTolerance = 2
82+ if wide > narrow*conservativeWidthTolerance {
83+ t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package copy
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestNewSnapshotFreezesHistoryAndVisibleScreen(t *testing.T) {
10+ sb := NewScrollback(1)
11+ sb.Append(row("old"))
12+ screen := renderer.NewFrame(3, 1)
13+ copy(screen.Row(0), row("one"))
14+
15+ snapshot := NewSnapshot(sb, screen)
16+ if &snapshot.Row(0)[0] != &sb.View().Row(0)[0] {
17+ t.Fatal("NewSnapshot() deep-copied immutable history")
18+ }
19+ sb.Append(row("new"))
20+ screen.Set(0, 0, renderer.Cell{Rune: 'x'})
21+
22+ if got := snapshot.Len(); got != 2 {
23+ t.Fatalf("Len() = %d, want 2", got)
24+ }
25+ for i, want := range []string{"old", "one"} {
26+ if got := rowText(snapshot.Row(i)); got != want {
27+ t.Errorf("Row(%d) = %q, want %q", i, got, want)
28+ }
29+ }
30+ for _, i := range []int{-1, 2} {
31+ if got := snapshot.Row(i); got != nil {
32+ t.Errorf("Row(%d) = %v, want nil", i, got)
33+ }
34+ }
35+}
36+
37+func TestNewSnapshotFromRowsOwnsCallerRows(t *testing.T) {
38+ rows := [][]renderer.Cell{row("one")}
39+ snapshot := NewSnapshotFromRows(rows, 3, 1)
40+ rows[0][0].Rune = 'x'
41+ if got := rowText(snapshot.Row(0)); got != "one" {
42+ t.Fatalf("Row(0) = %q, want deep-owned caller row", got)
43+ }
44+}
45+
46+// This scaling gate deliberately compares equal row counts rather than an
47+// absolute budget, which varies between Go releases. A full history-row clone
48+// grows with row width; the immutable HistoryView allocation does not.
49+func TestNewSnapshotAllocationIsWidthIndependent(t *testing.T) {
50+ if testing.Short() {
51+ t.Skip("skipping benchmark-driven allocation gate in short mode")
52+ }
53+ narrow := benchmarkNewSnapshotBytes(16)
54+ wide := benchmarkNewSnapshotBytes(512)
55+ assertWidthIndependentAllocations(t, "NewSnapshot", narrow, wide)
56+}
57+
58+func benchmarkNewSnapshotBytes(width int) int64 {
59+ const historyRows = 256
60+
61+ scrollback := NewScrollback(historyRows)
62+ for range historyRows {
63+ scrollback.Append(make([]renderer.Cell, width))
64+ }
65+ // Keep visible-frame work constant so the comparison isolates history rows.
66+ screen := renderer.NewFrame(8, 4)
67+ result := testing.Benchmark(func(b *testing.B) {
68+ b.ReportAllocs()
69+ b.ResetTimer()
70+ for b.Loop() {
71+ benchmarkSnapshotSink = NewSnapshot(scrollback, screen)
72+ }
73+ })
74+ return result.AllocedBytesPerOp()
75+}
76+
77+func assertWidthIndependentAllocations(t *testing.T, operation string, narrow, wide int64) {
78+ t.Helper()
79+ // Two times permits allocator noise while decisively rejecting a cell-row
80+ // copy, whose allocation grows by 32x for the widths above.
81+ const conservativeWidthTolerance = 2
82+ if wide > narrow*conservativeWidthTolerance {
83+ t.Fatalf("%s B/op scaled with row width: narrow=%d wide=%d (limit=%d)", operation, narrow, wide, narrow*conservativeWidthTolerance)
84+ }
85+}
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 +77 -67
@@ -45,59 +45,77 @@ func copyTargetPane(rt *overlayRuntime) *pane {
4545 }
4646 rt.copyMu.Lock()
4747 defer rt.copyMu.Unlock()
48+ if rt.copyMode == nil {
49+ return nil
50+ }
4851 return rt.copyPane
4952 }
5053
5154 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
5255 rt := ac.overlays
53- p := copyTargetPane(rt)
54- if p == nil {
55- return
56- }
57- p.mu.Lock()
5856 rt.copyMu.Lock()
59- if rt.copyMode == nil || rt.copyPane != p {
57+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
6058 rt.copyMu.Unlock()
61- p.mu.Unlock()
6259 return
6360 }
64- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
65- if delta > 0 && rt.copyMode.AtBottom(snap) {
61+ document := rt.copySnapshot
62+ if delta > 0 && rt.copyMode.AtBottom(*document) {
6663 rt.clearCopyModeLocked()
6764 rt.copyMu.Unlock()
68- p.mu.Unlock()
6965 d.paint(sess, ac, true)
7066 return
7167 }
72- rt.copyMode.Move(snap, delta)
73- exit := delta > 0 && rt.copyMode.AtBottom(snap)
68+ rt.copyMode.Move(*document, delta)
69+ exit := delta > 0 && rt.copyMode.AtBottom(*document)
7470 if exit {
7571 rt.clearCopyModeLocked()
7672 }
7773 rt.copyMu.Unlock()
78- p.mu.Unlock()
7974
8075 d.paint(sess, ac, true)
8176 }
8277
8378 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
84- rt := ac.overlays
8579 tb := sess.activeTab()
8680 if tb == nil {
8781 return
8882 }
8983 tb.mu.Lock()
9084 p := tb.terminalTargetLocked()
91- floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
9285 tb.mu.Unlock()
9386 if p == nil {
9487 return
9588 }
89+ // Capture the live pane under pane.mu; all subsequent copy interaction uses
90+ // the immutable document while holding only copyMu.
9691 p.mu.Lock()
97- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
92+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
9893 p.mu.Unlock()
94+ if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
95+ return
96+ }
97+ d.paint(sess, ac, true)
98+}
99+
100+// publishCopyMode installs a non-renderable candidate, validates its captured
101+// target without overlapping copyMu with session or tab locks, then atomically
102+// activates that same candidate. A tab transition, pane close, or newer
103+// publication invalidates the candidate before it can render or be yanked.
104+func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
105+ if sess == nil || ac == nil || tb == nil || p == nil {
106+ return false
107+ }
108+ mode := scopy.NewMode(document)
109+ if prepare != nil {
110+ prepare(mode)
111+ }
112+ rt := ac.overlays
99113 rt.copyMu.Lock()
100- rt.copyMode = scopy.NewMode(snap)
114+ d.stopCopyPendingTimerLocked(ac)
115+ rt.copyPending = nil
116+ rt.copyMode = nil
117+ rt.copyCandidate = mode
118+ rt.copySnapshot = &document
101119 rt.copyPane = p
102120 rt.copySearch = nil
103121 rt.copySearchPending = nil
@@ -105,15 +123,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
105123 rt.copyDragging = false
106124 rt.normalMousePressValid = false
107125 rt.copyMu.Unlock()
108- if floatingSource {
126+
127+ active := sess.activeTab()
128+ valid := active == tb
129+ if valid {
109130 tb.mu.Lock()
110- stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p
131+ valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
111132 tb.mu.Unlock()
112- if !stillVisible {
113- rt.clearCopyModeForPane(p)
114- }
115133 }
116- d.paint(sess, ac, true)
134+
135+ rt.copyMu.Lock()
136+ defer rt.copyMu.Unlock()
137+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {
138+ return false
139+ }
140+ if !valid {
141+ rt.clearCopyModeLocked()
142+ return false
143+ }
144+ rt.copyCandidate = nil
145+ rt.copyMode = mode
146+ return true
117147 }
118148
119149 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
@@ -121,33 +151,25 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
121151 if ev.Button != mouse.Left {
122152 return
123153 }
124- p := copyTargetPane(rt)
125- if p == nil {
154+ rt.copyMu.Lock()
155+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
156+ rt.copyMu.Unlock()
126157 return
127158 }
128- p.mu.Lock()
129- if ev.Row >= p.screen.Frame.Height {
159+ document := rt.copySnapshot
160+ if ev.Row >= document.Height {
130161 if ev.Type == mouse.Press {
131- rt.copyMu.Lock()
132162 rt.copyPressRowValid = false
133163 rt.copyDragging = false
134- rt.copyMu.Unlock()
135164 }
136- p.mu.Unlock()
137- return
138- }
139- rt.copyMu.Lock()
140- if rt.copyMode == nil || rt.copyPane != p {
141165 rt.copyMu.Unlock()
142- p.mu.Unlock()
143166 return
144167 }
145- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
146168 absRow := rt.copyMode.ViewportTop + ev.Row
147169 changed := false
148170 switch ev.Type {
149171 case mouse.Press:
150- rt.copyMode.SetCursor(snap, absRow)
172+ rt.copyMode.SetCursor(*document, absRow)
151173 rt.copyPressRow = rt.copyMode.Cursor
152174 rt.copyPressRowValid = true
153175 rt.copyDragging = false
@@ -157,16 +179,15 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
157179 break
158180 }
159181 if !rt.copyDragging {
160- rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)
182+ rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)
161183 rt.copyDragging = true
162184 }
163- rt.copyMode.ExtendTo(snap, absRow)
185+ rt.copyMode.ExtendTo(*document, absRow)
164186 changed = true
165187 case mouse.Release:
166188 // Button release intentionally has no visual effect.
167189 }
168190 rt.copyMu.Unlock()
169- p.mu.Unlock()
170191
171192 if changed {
172193 d.paint(sess, ac, true)
@@ -179,17 +200,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
179200 if sess == nil {
180201 return
181202 }
182- p := copyTargetPane(rt)
183- if p == nil {
184- return
185- }
186- p.mu.Lock()
187203 rt.copyMu.Lock()
188- if rt.copyMode == nil || rt.copyPane != p {
204+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
189205 rt.copyPending = nil
190206 d.stopCopyPendingTimerLocked(ac)
191207 rt.copyMu.Unlock()
192- p.mu.Unlock()
193208 return
194209 }
195210 if len(rt.copyPending) > 0 {
@@ -200,11 +215,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200215 data = combined
201216 rt.copyPending = nil
202217 }
203- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
218+ document := rt.copySnapshot
204219 if rt.copySearch != nil {
205- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)
220+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
206221 rt.copyMu.Unlock()
207- p.mu.Unlock()
208222 if changed || closeSearch || accepted {
209223 d.paint(sess, ac, true)
210224 }
@@ -216,40 +230,40 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
216230 for i := 0; i < len(data); i++ {
217231 switch data[i] {
218232 case 'j':
219- rt.copyMode.Move(snap, 1)
233+ rt.copyMode.Move(*document, 1)
220234 changed = true
221235 case 'k':
222- rt.copyMode.Move(snap, -1)
236+ rt.copyMode.Move(*document, -1)
223237 changed = true
224238 case 'g':
225- rt.copyMode.Top(snap)
239+ rt.copyMode.Top(*document)
226240 changed = true
227241 case 'G':
228- rt.copyMode.Bottom(snap)
242+ rt.copyMode.Bottom(*document)
229243 changed = true
230244 case ' ', 'v':
231245 rt.copyMode.ToggleSelection()
232246 changed = true
233247 case '/':
234- rt.copySearch = visualsearch.New(snap)
248+ rt.copySearch = visualsearch.New(*document)
235249 rt.copySearchPending = nil
236250 changed = true
237251 if i+1 < len(data) {
238- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])
252+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])
239253 changed = changed || searchChanged || accepted
240254 }
241255 i = len(data)
242256 case 'n':
243- changed = rt.copyMode.NextSearchMatch(snap, 1) || changed
257+ changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
244258 case 'N':
245- changed = rt.copyMode.NextSearchMatch(snap, -1) || changed
259+ changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
246260 case '\r', '\n', 'y':
247261 copyOut = true
248262 exit = true
249263 case 'q', 0x03, 0x1b:
250264 if data[i] == 0x1b {
251265 tail := data[i:]
252- consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)
266+ consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
253267 if ok {
254268 i += consumed - 1
255269 changed = true
@@ -269,14 +283,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
269283 }
270284 text := ""
271285 if copyOut {
272- text = rt.copyMode.SelectedText(snap)
286+ text = rt.copyMode.SelectedText(*document)
273287 }
274288 if exit {
275289 d.stopCopyPendingTimerLocked(ac)
276290 rt.clearCopyModeLocked()
277291 }
278292 rt.copyMu.Unlock()
279- p.mu.Unlock()
280293
281294 if copyOut && text != "" {
282295 chunks := scopy.OSC52(text)
@@ -417,15 +430,12 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
417430 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
418431 }
419432
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 {
433+func composeCopyClientFrame(mode *scopy.Mode, document *scopy.Snapshot, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {
434+ if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422435 return frame, nil
423436 }
424437 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)
438+ copyFrame := mode.Render(*document, styles.copyStatus, styles.selection)
429439 bodyRows := max(copyFrame.Height-1, 0)
430440 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431441 dstX := max(target.X, 0)
@@ -45,59 +45,77 @@ func copyTargetPane(rt *overlayRuntime) *pane {
45 }45 }
46 rt.copyMu.Lock()46 rt.copyMu.Lock()
47 defer rt.copyMu.Unlock()47 defer rt.copyMu.Unlock()
48+ if rt.copyMode == nil {
49+ return nil
50+ }
48 return rt.copyPane51 return rt.copyPane
49 }52 }
50 53
51 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {54 func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
52 rt := ac.overlays55 rt := ac.overlays
53- p := copyTargetPane(rt)
54- if p == nil {
55- return
56- }
57- p.mu.Lock()
58 rt.copyMu.Lock()56 rt.copyMu.Lock()
59- if rt.copyMode == nil || rt.copyPane != p {57+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
60 rt.copyMu.Unlock()58 rt.copyMu.Unlock()
61- p.mu.Unlock()
62 return59 return
63 }60 }
64- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)61+ document := rt.copySnapshot
65- if delta > 0 && rt.copyMode.AtBottom(snap) {62+ if delta > 0 && rt.copyMode.AtBottom(*document) {
66 rt.clearCopyModeLocked()63 rt.clearCopyModeLocked()
67 rt.copyMu.Unlock()64 rt.copyMu.Unlock()
68- p.mu.Unlock()
69 d.paint(sess, ac, true)65 d.paint(sess, ac, true)
70 return66 return
71 }67 }
72- rt.copyMode.Move(snap, delta)68+ rt.copyMode.Move(*document, delta)
73- exit := delta > 0 && rt.copyMode.AtBottom(snap)69+ exit := delta > 0 && rt.copyMode.AtBottom(*document)
74 if exit {70 if exit {
75 rt.clearCopyModeLocked()71 rt.clearCopyModeLocked()
76 }72 }
77 rt.copyMu.Unlock()73 rt.copyMu.Unlock()
78- p.mu.Unlock()
79 74
80 d.paint(sess, ac, true)75 d.paint(sess, ac, true)
81 }76 }
82 77
83 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
84- rt := ac.overlays
85 tb := sess.activeTab()79 tb := sess.activeTab()
86 if tb == nil {80 if tb == nil {
87 return81 return
88 }82 }
89 tb.mu.Lock()83 tb.mu.Lock()
90 p := tb.terminalTargetLocked()84 p := tb.terminalTargetLocked()
91- floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p
92 tb.mu.Unlock()85 tb.mu.Unlock()
93 if p == nil {86 if p == nil {
94 return87 return
95 }88 }
89+ // Capture the live pane under pane.mu; all subsequent copy interaction uses
90+ // the immutable document while holding only copyMu.
96 p.mu.Lock()91 p.mu.Lock()
97- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)92+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98 p.mu.Unlock()93 p.mu.Unlock()
94+ if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
95+ return
96+ }
97+ d.paint(sess, ac, true)
98+}
99+
100+// publishCopyMode installs a non-renderable candidate, validates its captured
101+// target without overlapping copyMu with session or tab locks, then atomically
102+// activates that same candidate. A tab transition, pane close, or newer
103+// publication invalidates the candidate before it can render or be yanked.
104+func (d *Daemon) publishCopyMode(sess *session, ac *attachedClient, tb *tab, p *pane, document scopy.Snapshot, prepare func(*scopy.Mode)) bool {
105+ if sess == nil || ac == nil || tb == nil || p == nil {
106+ return false
107+ }
108+ mode := scopy.NewMode(document)
109+ if prepare != nil {
110+ prepare(mode)
111+ }
112+ rt := ac.overlays
99 rt.copyMu.Lock()113 rt.copyMu.Lock()
100- rt.copyMode = scopy.NewMode(snap)114+ d.stopCopyPendingTimerLocked(ac)
115+ rt.copyPending = nil
116+ rt.copyMode = nil
117+ rt.copyCandidate = mode
118+ rt.copySnapshot = &document
101 rt.copyPane = p119 rt.copyPane = p
102 rt.copySearch = nil120 rt.copySearch = nil
103 rt.copySearchPending = nil121 rt.copySearchPending = nil
@@ -105,15 +123,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
105 rt.copyDragging = false123 rt.copyDragging = false
106 rt.normalMousePressValid = false124 rt.normalMousePressValid = false
107 rt.copyMu.Unlock()125 rt.copyMu.Unlock()
108- if floatingSource {126+
127+ active := sess.activeTab()
128+ valid := active == tb
129+ if valid {
109 tb.mu.Lock()130 tb.mu.Lock()
110- stillVisible := tb.floating.state == floatingVisible && tb.floating.pane == p131+ valid = tb.panes[p.id] == p || (tb.floating.state == floatingVisible && tb.floating.pane == p)
111 tb.mu.Unlock()132 tb.mu.Unlock()
112- if !stillVisible {
113- rt.clearCopyModeForPane(p)
114- }
115 }133 }
116- d.paint(sess, ac, true)134+
135+ rt.copyMu.Lock()
136+ defer rt.copyMu.Unlock()
137+ if rt.copyCandidate != mode || rt.copyPane != p || rt.copySnapshot != &document {
138+ return false
139+ }
140+ if !valid {
141+ rt.clearCopyModeLocked()
142+ return false
143+ }
144+ rt.copyCandidate = nil
145+ rt.copyMode = mode
146+ return true
117 }147 }
118 148
119 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {149 func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
@@ -121,33 +151,25 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
121 if ev.Button != mouse.Left {151 if ev.Button != mouse.Left {
122 return152 return
123 }153 }
124- p := copyTargetPane(rt)154+ rt.copyMu.Lock()
125- if p == nil {155+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
156+ rt.copyMu.Unlock()
126 return157 return
127 }158 }
128- p.mu.Lock()159+ document := rt.copySnapshot
129- if ev.Row >= p.screen.Frame.Height {160+ if ev.Row >= document.Height {
130 if ev.Type == mouse.Press {161 if ev.Type == mouse.Press {
131- rt.copyMu.Lock()
132 rt.copyPressRowValid = false162 rt.copyPressRowValid = false
133 rt.copyDragging = false163 rt.copyDragging = false
134- rt.copyMu.Unlock()
135 }164 }
136- p.mu.Unlock()
137- return
138- }
139- rt.copyMu.Lock()
140- if rt.copyMode == nil || rt.copyPane != p {
141 rt.copyMu.Unlock()165 rt.copyMu.Unlock()
142- p.mu.Unlock()
143 return166 return
144 }167 }
145- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
146 absRow := rt.copyMode.ViewportTop + ev.Row168 absRow := rt.copyMode.ViewportTop + ev.Row
147 changed := false169 changed := false
148 switch ev.Type {170 switch ev.Type {
149 case mouse.Press:171 case mouse.Press:
150- rt.copyMode.SetCursor(snap, absRow)172+ rt.copyMode.SetCursor(*document, absRow)
151 rt.copyPressRow = rt.copyMode.Cursor173 rt.copyPressRow = rt.copyMode.Cursor
152 rt.copyPressRowValid = true174 rt.copyPressRowValid = true
153 rt.copyDragging = false175 rt.copyDragging = false
@@ -157,16 +179,15 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
157 break179 break
158 }180 }
159 if !rt.copyDragging {181 if !rt.copyDragging {
160- rt.copyMode.StartSelectionAt(snap, rt.copyPressRow)182+ rt.copyMode.StartSelectionAt(*document, rt.copyPressRow)
161 rt.copyDragging = true183 rt.copyDragging = true
162 }184 }
163- rt.copyMode.ExtendTo(snap, absRow)185+ rt.copyMode.ExtendTo(*document, absRow)
164 changed = true186 changed = true
165 case mouse.Release:187 case mouse.Release:
166 // Button release intentionally has no visual effect.188 // Button release intentionally has no visual effect.
167 }189 }
168 rt.copyMu.Unlock()190 rt.copyMu.Unlock()
169- p.mu.Unlock()
170 191
171 if changed {192 if changed {
172 d.paint(sess, ac, true)193 d.paint(sess, ac, true)
@@ -179,17 +200,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
179 if sess == nil {200 if sess == nil {
180 return201 return
181 }202 }
182- p := copyTargetPane(rt)
183- if p == nil {
184- return
185- }
186- p.mu.Lock()
187 rt.copyMu.Lock()203 rt.copyMu.Lock()
188- if rt.copyMode == nil || rt.copyPane != p {204+ if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil {
189 rt.copyPending = nil205 rt.copyPending = nil
190 d.stopCopyPendingTimerLocked(ac)206 d.stopCopyPendingTimerLocked(ac)
191 rt.copyMu.Unlock()207 rt.copyMu.Unlock()
192- p.mu.Unlock()
193 return208 return
194 }209 }
195 if len(rt.copyPending) > 0 {210 if len(rt.copyPending) > 0 {
@@ -200,11 +215,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
200 data = combined215 data = combined
201 rt.copyPending = nil216 rt.copyPending = nil
202 }217 }
203- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)218+ document := rt.copySnapshot
204 if rt.copySearch != nil {219 if rt.copySearch != nil {
205- changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data)220+ changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
206 rt.copyMu.Unlock()221 rt.copyMu.Unlock()
207- p.mu.Unlock()
208 if changed || closeSearch || accepted {222 if changed || closeSearch || accepted {
209 d.paint(sess, ac, true)223 d.paint(sess, ac, true)
210 }224 }
@@ -216,40 +230,40 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
216 for i := 0; i < len(data); i++ {230 for i := 0; i < len(data); i++ {
217 switch data[i] {231 switch data[i] {
218 case 'j':232 case 'j':
219- rt.copyMode.Move(snap, 1)233+ rt.copyMode.Move(*document, 1)
220 changed = true234 changed = true
221 case 'k':235 case 'k':
222- rt.copyMode.Move(snap, -1)236+ rt.copyMode.Move(*document, -1)
223 changed = true237 changed = true
224 case 'g':238 case 'g':
225- rt.copyMode.Top(snap)239+ rt.copyMode.Top(*document)
226 changed = true240 changed = true
227 case 'G':241 case 'G':
228- rt.copyMode.Bottom(snap)242+ rt.copyMode.Bottom(*document)
229 changed = true243 changed = true
230 case ' ', 'v':244 case ' ', 'v':
231 rt.copyMode.ToggleSelection()245 rt.copyMode.ToggleSelection()
232 changed = true246 changed = true
233 case '/':247 case '/':
234- rt.copySearch = visualsearch.New(snap)248+ rt.copySearch = visualsearch.New(*document)
235 rt.copySearchPending = nil249 rt.copySearchPending = nil
236 changed = true250 changed = true
237 if i+1 < len(data) {251 if i+1 < len(data) {
238- searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, snap, data[i+1:])252+ searchChanged, _, accepted := d.routeCopySearchInputLocked(rt, *document, data[i+1:])
239 changed = changed || searchChanged || accepted253 changed = changed || searchChanged || accepted
240 }254 }
241 i = len(data)255 i = len(data)
242 case 'n':256 case 'n':
243- changed = rt.copyMode.NextSearchMatch(snap, 1) || changed257+ changed = rt.copyMode.NextSearchMatch(*document, 1) || changed
244 case 'N':258 case 'N':
245- changed = rt.copyMode.NextSearchMatch(snap, -1) || changed259+ changed = rt.copyMode.NextSearchMatch(*document, -1) || changed
246 case '\r', '\n', 'y':260 case '\r', '\n', 'y':
247 copyOut = true261 copyOut = true
248 exit = true262 exit = true
249 case 'q', 0x03, 0x1b:263 case 'q', 0x03, 0x1b:
250 if data[i] == 0x1b {264 if data[i] == 0x1b {
251 tail := data[i:]265 tail := data[i:]
252- consumed, ok := routeCopyEscape(rt.copyMode, snap, tail)266+ consumed, ok := routeCopyEscape(rt.copyMode, *document, tail)
253 if ok {267 if ok {
254 i += consumed - 1268 i += consumed - 1
255 changed = true269 changed = true
@@ -269,14 +283,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
269 }283 }
270 text := ""284 text := ""
271 if copyOut {285 if copyOut {
272- text = rt.copyMode.SelectedText(snap)286+ text = rt.copyMode.SelectedText(*document)
273 }287 }
274 if exit {288 if exit {
275 d.stopCopyPendingTimerLocked(ac)289 d.stopCopyPendingTimerLocked(ac)
276 rt.clearCopyModeLocked()290 rt.clearCopyModeLocked()
277 }291 }
278 rt.copyMu.Unlock()292 rt.copyMu.Unlock()
279- p.mu.Unlock()
280 293
281 if copyOut && text != "" {294 if copyOut && text != "" {
282 chunks := scopy.OSC52(text)295 chunks := scopy.OSC52(text)
@@ -417,15 +430,12 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame
417 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)430 return composeModalClientFrame(base, copySearchModal, styleSet, styleSet.selection, model.Render)
418 }431 }
419 432
420-func composeCopyClientFrame(mode *scopy.Mode, p *pane, target domain.Rect, frame renderer.Frame, bars barState) (renderer.Frame, []renderer.Damage) {433+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 {434+ if mode == nil || document == nil || target.Width <= 0 || target.Height <= 0 || frame.Width <= 0 || frame.Height <= 0 {
422 return frame, nil435 return frame, nil
423 }436 }
424 styles := newThemeStyles(bars.theme)437 styles := newThemeStyles(bars.theme)
425- p.mu.Lock()438+ 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)439 bodyRows := max(copyFrame.Height-1, 0)
430 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {440 for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ {
431 dstX := max(target.X, 0)441 dstX := max(target.X, 0)
A internal/usecase/daemon/copymode_lifecycle_test.go +169 -0
@@ -0,0 +1,169 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ scopy "github.com/bnema/vev/internal/usecase/copy"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
17+ pty, release := newBlockingPTY(t)
18+ defer release()
19+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20+ d.enterCopyMode(sess, ac)
21+ require.True(t, ac.overlays.copyActive())
22+
23+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
24+
25+ require.False(t, ac.overlays.copyActive())
26+}
27+
28+func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
29+ d, sess, ac, _, releases := newManualTabSession(t, 2)
30+ defer func() {
31+ for _, release := range releases {
32+ release()
33+ }
34+ }()
35+ d.enterCopyMode(sess, ac)
36+ require.True(t, ac.overlays.copyActive())
37+
38+ require.True(t, sess.switchTab(1))
39+ d.activateTab(sess, sess.activeTab())
40+
41+ require.False(t, ac.overlays.copyActive())
42+}
43+
44+func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
45+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
46+ tb := sess.activeTab()
47+ closingPTY := portsmocks.NewMockPTY(t)
48+ closingPTY.EXPECT().Close().Return(nil).Once()
49+ 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"}
50+ closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
51+ tb.panes["pane-2"] = closing
52+ ac := &attachedClient{output: newOutputStateStream()}
53+ ac.initOverlays()
54+ sess.client = ac
55+ ac.setSession(sess)
56+ closing.mu.Lock()
57+ document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
58+ closing.mu.Unlock()
59+ ac.overlays.copyMu.Lock()
60+ ac.overlays.copyPane = closing
61+ ac.overlays.copySnapshot = &document
62+ ac.overlays.copyMode = scopy.NewMode(document)
63+ ac.overlays.copyMu.Unlock()
64+
65+ require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
66+ require.False(t, ac.overlays.copyActive())
67+ require.Nil(t, copyTargetPane(ac.overlays))
68+}
69+
70+func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
71+ d, sess, ac, _, releases := newManualTabSession(t, 2)
72+ defer func() {
73+ for _, release := range releases {
74+ release()
75+ }
76+ }()
77+ tb := sess.activeTab()
78+ p := tb.focusedPane()
79+ d.enterCopyMode(sess, ac)
80+ require.True(t, ac.overlays.copyActive())
81+
82+ require.NoError(t, d.closePane(sess, tb, p.id, nil, false))
83+ require.False(t, ac.overlays.copyActive())
84+ require.Nil(t, copyTargetPane(ac.overlays))
85+}
86+
87+func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
88+ d, sess, ac, _, releases := newManualTabSession(t, 2)
89+ defer func() {
90+ for _, release := range releases {
91+ release()
92+ }
93+ }()
94+ tb := sess.activeTab()
95+ p := tb.focusedPane()
96+ p.mu.Lock()
97+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98+ p.mu.Unlock()
99+
100+ // Hold session validation after publication and make the captured target
101+ // stale. A render snapshot taken in this window must not expose the
102+ // candidate document.
103+ sess.mu.Lock()
104+ sess.active = 1
105+ result := make(chan bool, 1)
106+ go func() {
107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil)
108+ }()
109+ published := assert.Eventually(t, func() bool {
110+ ac.overlays.copyMu.Lock()
111+ defer ac.overlays.copyMu.Unlock()
112+ return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil
113+ }, time.Second, time.Millisecond)
114+ if !published {
115+ sess.mu.Unlock()
116+ <-result
117+ return
118+ }
119+ renderSnapshot := ac.overlays.SnapshotForRender()
120+ candidateWasRenderable := renderSnapshot.copyActive
121+ renderSnapshot.Unlock()
122+ sess.mu.Unlock()
123+
124+ require.False(t, <-result)
125+ require.False(t, candidateWasRenderable)
126+ require.False(t, ac.overlays.copyActive())
127+ require.Nil(t, copyTargetPane(ac.overlays))
128+}
129+
130+func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
131+ for _, tc := range []struct {
132+ name string
133+ invalidate func(*session, *tab, *pane)
134+ }{
135+ {
136+ name: "removed pane",
137+ invalidate: func(_ *session, tb *tab, p *pane) {
138+ tb.mu.Lock()
139+ delete(tb.panes, p.id)
140+ tb.mu.Unlock()
141+ },
142+ },
143+ {
144+ name: "inactive tab",
145+ invalidate: func(sess *session, _ *tab, _ *pane) {
146+ require.True(t, sess.switchTab(1))
147+ },
148+ },
149+ } {
150+ t.Run(tc.name, func(t *testing.T) {
151+ d, sess, ac, _, releases := newManualTabSession(t, 2)
152+ defer func() {
153+ for _, release := range releases {
154+ release()
155+ }
156+ }()
157+ tb := sess.activeTab()
158+ p := tb.focusedPane()
159+ p.mu.Lock()
160+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
161+ p.mu.Unlock()
162+ tc.invalidate(sess, tb, p)
163+
164+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))
165+ require.False(t, ac.overlays.copyActive())
166+ require.Nil(t, copyTargetPane(ac.overlays))
167+ })
168+ }
169+}
@@ -0,0 +1,169 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
12+ scopy "github.com/bnema/vev/internal/usecase/copy"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+)
15+
16+func TestCopyModeLifecycleResizeExitsBeforeGeometryChange(t *testing.T) {
17+ pty, release := newBlockingPTY(t)
18+ defer release()
19+ d, sess, ac, _ := newManualSessionWithPTYs(t, pty)
20+ d.enterCopyMode(sess, ac)
21+ require.True(t, ac.overlays.copyActive())
22+
23+ d.resize(sess, ac, domain.Size{Cols: 100, Rows: 30})
24+
25+ require.False(t, ac.overlays.copyActive())
26+}
27+
28+func TestCopyModeLifecycleActivateTabClearsRuntime(t *testing.T) {
29+ d, sess, ac, _, releases := newManualTabSession(t, 2)
30+ defer func() {
31+ for _, release := range releases {
32+ release()
33+ }
34+ }()
35+ d.enterCopyMode(sess, ac)
36+ require.True(t, ac.overlays.copyActive())
37+
38+ require.True(t, sess.switchTab(1))
39+ d.activateTab(sess, sess.activeTab())
40+
41+ require.False(t, ac.overlays.copyActive())
42+}
43+
44+func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
45+ d, sess, _, _ := newSplitTestDaemon(t, domain.Size{Cols: 41, Rows: 10})
46+ tb := sess.activeTab()
47+ closingPTY := portsmocks.NewMockPTY(t)
48+ closingPTY.EXPECT().Close().Return(nil).Once()
49+ 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"}
50+ closing := newPane("pane-2", closingPTY, domain.Size{Cols: 20, Rows: 10})
51+ tb.panes["pane-2"] = closing
52+ ac := &attachedClient{output: newOutputStateStream()}
53+ ac.initOverlays()
54+ sess.client = ac
55+ ac.setSession(sess)
56+ closing.mu.Lock()
57+ document := scopy.NewSnapshot(closing.scrollback, closing.screen.Frame)
58+ closing.mu.Unlock()
59+ ac.overlays.copyMu.Lock()
60+ ac.overlays.copyPane = closing
61+ ac.overlays.copySnapshot = &document
62+ ac.overlays.copyMode = scopy.NewMode(document)
63+ ac.overlays.copyMu.Unlock()
64+
65+ require.NoError(t, d.closePane(sess, tb, "pane-2", nil, false))
66+ require.False(t, ac.overlays.copyActive())
67+ require.Nil(t, copyTargetPane(ac.overlays))
68+}
69+
70+func TestCopyModeLifecycleCloseOnlyPaneInTabClearsRecoveredClientState(t *testing.T) {
71+ d, sess, ac, _, releases := newManualTabSession(t, 2)
72+ defer func() {
73+ for _, release := range releases {
74+ release()
75+ }
76+ }()
77+ tb := sess.activeTab()
78+ p := tb.focusedPane()
79+ d.enterCopyMode(sess, ac)
80+ require.True(t, ac.overlays.copyActive())
81+
82+ require.NoError(t, d.closePane(sess, tb, p.id, nil, false))
83+ require.False(t, ac.overlays.copyActive())
84+ require.Nil(t, copyTargetPane(ac.overlays))
85+}
86+
87+func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
88+ d, sess, ac, _, releases := newManualTabSession(t, 2)
89+ defer func() {
90+ for _, release := range releases {
91+ release()
92+ }
93+ }()
94+ tb := sess.activeTab()
95+ p := tb.focusedPane()
96+ p.mu.Lock()
97+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
98+ p.mu.Unlock()
99+
100+ // Hold session validation after publication and make the captured target
101+ // stale. A render snapshot taken in this window must not expose the
102+ // candidate document.
103+ sess.mu.Lock()
104+ sess.active = 1
105+ result := make(chan bool, 1)
106+ go func() {
107+ result <- d.publishCopyMode(sess, ac, tb, p, document, nil)
108+ }()
109+ published := assert.Eventually(t, func() bool {
110+ ac.overlays.copyMu.Lock()
111+ defer ac.overlays.copyMu.Unlock()
112+ return ac.overlays.copyPane == p && ac.overlays.copySnapshot != nil
113+ }, time.Second, time.Millisecond)
114+ if !published {
115+ sess.mu.Unlock()
116+ <-result
117+ return
118+ }
119+ renderSnapshot := ac.overlays.SnapshotForRender()
120+ candidateWasRenderable := renderSnapshot.copyActive
121+ renderSnapshot.Unlock()
122+ sess.mu.Unlock()
123+
124+ require.False(t, <-result)
125+ require.False(t, candidateWasRenderable)
126+ require.False(t, ac.overlays.copyActive())
127+ require.Nil(t, copyTargetPane(ac.overlays))
128+}
129+
130+func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.T) {
131+ for _, tc := range []struct {
132+ name string
133+ invalidate func(*session, *tab, *pane)
134+ }{
135+ {
136+ name: "removed pane",
137+ invalidate: func(_ *session, tb *tab, p *pane) {
138+ tb.mu.Lock()
139+ delete(tb.panes, p.id)
140+ tb.mu.Unlock()
141+ },
142+ },
143+ {
144+ name: "inactive tab",
145+ invalidate: func(sess *session, _ *tab, _ *pane) {
146+ require.True(t, sess.switchTab(1))
147+ },
148+ },
149+ } {
150+ t.Run(tc.name, func(t *testing.T) {
151+ d, sess, ac, _, releases := newManualTabSession(t, 2)
152+ defer func() {
153+ for _, release := range releases {
154+ release()
155+ }
156+ }()
157+ tb := sess.activeTab()
158+ p := tb.focusedPane()
159+ p.mu.Lock()
160+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
161+ p.mu.Unlock()
162+ tc.invalidate(sess, tb, p)
163+
164+ require.False(t, d.publishCopyMode(sess, ac, tb, p, document, nil))
165+ require.False(t, ac.overlays.copyActive())
166+ require.Nil(t, copyTargetPane(ac.overlays))
167+ })
168+ }
169+}
M internal/usecase/daemon/copymode_test.go +37 -5
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133133 go func() {
134134 defer wg.Done()
135135 for range 200 {
136- _, _ = composeCopyClientFrame(mode, pane, target, base, bars)
136+ _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)
137137 }
138138 }()
139139 wg.Wait()
@@ -152,7 +152,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
152152
153153 bars := barState{status: sess.statusSegments()}
154154 base, _ := composeClientFrameWithState(bars, tb, true)
155- frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
155+ frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156156
157157 require.Equal(t, 80, frame.Width)
158158 require.Equal(t, 25, frame.Height)
@@ -412,7 +412,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
412412 }
413413 p.mu.Lock()
414414 defer p.mu.Unlock()
415- return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12
415+ return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
416416 }, 2*time.Second, 5*time.Millisecond)
417417
418418 sess := firstSession(d)
@@ -546,7 +546,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
546546 }
547547
548548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
549- p, _ := newBlockingPTY(t)
549+ p, release := newBlockingPTY(t)
550+ defer release()
550551 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
551552 clk := &signalClock{timers: make(chan *signalTimer, 1)}
552553 d.clock = clk
@@ -560,6 +561,14 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
560561 d.enterCopyMode(sess, ac)
561562 awaitFrame(t, sends, ports.MsgOutput)
562563
564+ ac.overlays.copyMu.Lock()
565+ require.Nil(t, ac.overlays.copyESC.timer)
566+ require.Nil(t, ac.overlays.copyESC.done)
567+ require.Empty(t, ac.overlays.copyPending)
568+ ac.overlays.copyMu.Unlock()
569+ d.handleInput(sess, ac, []byte("x"))
570+ require.True(t, ac.overlays.copyActive(), "pending input from the replaced mode affected the new mode")
571+
563572 timer.ch <- time.Now()
564573 require.Never(t, func() bool {
565574 ac.overlays.copyMu.Lock()
@@ -608,6 +617,29 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
608617 }
609618 }
610619
620+func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
621+ p, release := newBlockingPTY(t)
622+ defer release()
623+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
624+ pane := sess.activeTab().focusedPane()
625+ pane.screen.Write([]byte("live"))
626+ d.enterCopyMode(sess, ac)
627+ awaitFrame(t, sends, ports.MsgOutput)
628+
629+ pane.mu.Lock()
630+ defer pane.mu.Unlock()
631+ done := make(chan struct{})
632+ go func() {
633+ d.handleCopyInput(ac, []byte("x"))
634+ close(done)
635+ }()
636+ select {
637+ case <-done:
638+ case <-time.After(time.Second):
639+ t.Fatal("immutable copy input waited for the live pane lock")
640+ }
641+}
642+
611643 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
612644 p, _ := newBlockingPTY(t)
613645 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -730,7 +762,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
730762 }
731763 fp.screen.Write([]byte("live"))
732764 fp.mu.Lock()
733- total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)
765+ total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
734766 fp.mu.Unlock()
735767
736768 d.enterCopyMode(sess, ac)
@@ -133,7 +133,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
133 go func() {133 go func() {
134 defer wg.Done()134 defer wg.Done()
135 for range 200 {135 for range 200 {
136- _, _ = composeCopyClientFrame(mode, pane, target, base, bars)136+ _, _ = composeCopyClientFrame(mode, &snap, target, base, bars)
137 }137 }
138 }()138 }()
139 wg.Wait()139 wg.Wait()
@@ -152,7 +152,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
152 152
153 bars := barState{status: sess.statusSegments()}153 bars := barState{status: sess.statusSegments()}
154 base, _ := composeClientFrameWithState(bars, tb, true)154 base, _ := composeClientFrameWithState(bars, tb, true)
155- frame, damage := composeCopyClientFrame(mode, tb.focusedPane(), domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)155+ frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156 156
157 require.Equal(t, 80, frame.Width)157 require.Equal(t, 80, frame.Width)
158 require.Equal(t, 25, frame.Height)158 require.Equal(t, 25, frame.Height)
@@ -412,7 +412,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
412 }412 }
413 p.mu.Lock()413 p.mu.Lock()
414 defer p.mu.Unlock()414 defer p.mu.Unlock()
415- return len(scopy.NewSnapshot(p.scrollback, p.screen.Frame).Rows) >= 12415+ return scopy.NewSnapshot(p.scrollback, p.screen.Frame).Len() >= 12
416 }, 2*time.Second, 5*time.Millisecond)416 }, 2*time.Second, 5*time.Millisecond)
417 417
418 sess := firstSession(d)418 sess := firstSession(d)
@@ -546,7 +546,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) {
546 }546 }
547 547
548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {548 func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
549- p, _ := newBlockingPTY(t)549+ p, release := newBlockingPTY(t)
550+ defer release()
550 d, sess, ac, sends := newManualSessionWithPTYs(t, p)551 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
551 clk := &signalClock{timers: make(chan *signalTimer, 1)}552 clk := &signalClock{timers: make(chan *signalTimer, 1)}
552 d.clock = clk553 d.clock = clk
@@ -560,6 +561,14 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) {
560 d.enterCopyMode(sess, ac)561 d.enterCopyMode(sess, ac)
561 awaitFrame(t, sends, ports.MsgOutput)562 awaitFrame(t, sends, ports.MsgOutput)
562 563
564+ ac.overlays.copyMu.Lock()
565+ require.Nil(t, ac.overlays.copyESC.timer)
566+ require.Nil(t, ac.overlays.copyESC.done)
567+ require.Empty(t, ac.overlays.copyPending)
568+ ac.overlays.copyMu.Unlock()
569+ d.handleInput(sess, ac, []byte("x"))
570+ require.True(t, ac.overlays.copyActive(), "pending input from the replaced mode affected the new mode")
571+
563 timer.ch <- time.Now()572 timer.ch <- time.Now()
564 require.Never(t, func() bool {573 require.Never(t, func() bool {
565 ac.overlays.copyMu.Lock()574 ac.overlays.copyMu.Lock()
@@ -608,6 +617,29 @@ func TestCopyModeEmptyYankDoesNotClearClipboard(t *testing.T) {
608 }617 }
609 }618 }
610 619
620+func TestHandleCopyInputUsesImmutableSnapshotWithoutPaneLock(t *testing.T) {
621+ p, release := newBlockingPTY(t)
622+ defer release()
623+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
624+ pane := sess.activeTab().focusedPane()
625+ pane.screen.Write([]byte("live"))
626+ d.enterCopyMode(sess, ac)
627+ awaitFrame(t, sends, ports.MsgOutput)
628+
629+ pane.mu.Lock()
630+ defer pane.mu.Unlock()
631+ done := make(chan struct{})
632+ go func() {
633+ d.handleCopyInput(ac, []byte("x"))
634+ close(done)
635+ }()
636+ select {
637+ case <-done:
638+ case <-time.After(time.Second):
639+ t.Fatal("immutable copy input waited for the live pane lock")
640+ }
641+}
642+
611 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {643 func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) {
612 p, _ := newBlockingPTY(t)644 p, _ := newBlockingPTY(t)
613 d, sess, ac, sends := newManualSessionWithPTYs(t, p)645 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -730,7 +762,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
730 }762 }
731 fp.screen.Write([]byte("live"))763 fp.screen.Write([]byte("live"))
732 fp.mu.Lock()764 fp.mu.Lock()
733- total := len(scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Rows)765+ total := scopy.NewSnapshot(fp.scrollback, fp.screen.Frame).Len()
734 fp.mu.Unlock()766 fp.mu.Unlock()
735 767
736 d.enterCopyMode(sess, ac)768 d.enterCopyMode(sess, ac)
M internal/usecase/daemon/daemon_test.go +3 -3
@@ -63,7 +63,7 @@ func (t *signalTimer) C() <-chan time.Time { return t.ch }
6363 func (t *signalTimer) Reset(time.Duration) bool { return false }
6464 func (t *signalTimer) Stop() bool { return true }
6565
66-func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
66+func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
6767 t.Helper()
6868 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
6969 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
@@ -286,7 +286,7 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286286 return sessions
287287 }
288288
289-func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {
289+func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290290 t.Helper()
291291 tr := portsmocks.NewMockTransport(t)
292292 sends := make(chan ports.Frame, 64)
@@ -298,7 +298,7 @@ func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.
298298 return tr, sends
299299 }
300300
301-func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
301+func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
302302 t.Helper()
303303 d := newTestDaemon(t, nil, stubClock{})
304304 tr, sends := newCapturingTransport(t)
@@ -63,7 +63,7 @@ func (t *signalTimer) C() <-chan time.Time { return t.ch }
63 func (t *signalTimer) Reset(time.Duration) bool { return false }63 func (t *signalTimer) Reset(time.Duration) bool { return false }
64 func (t *signalTimer) Stop() bool { return true }64 func (t *signalTimer) Stop() bool { return true }
65 65
66-func newTestDaemon(t *testing.T, ptys ports.PTYFactory, clk ports.Clock) *Daemon {66+func newTestDaemon(t testing.TB, ptys ports.PTYFactory, clk ports.Clock) *Daemon {
67 t.Helper()67 t.Helper()
68 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))68 d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil)))
69 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())69 d.serveCtx, d.serveCancel = context.WithCancel(context.Background())
@@ -286,7 +286,7 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions {
286 return sessions286 return sessions
287 }287 }
288 288
289-func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.Frame) {289+func newCapturingTransport(t testing.TB) (*portsmocks.MockTransport, chan ports.Frame) {
290 t.Helper()290 t.Helper()
291 tr := portsmocks.NewMockTransport(t)291 tr := portsmocks.NewMockTransport(t)
292 sends := make(chan ports.Frame, 64)292 sends := make(chan ports.Frame, 64)
@@ -298,7 +298,7 @@ func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports.
298 return tr, sends298 return tr, sends
299 }299 }
300 300
301-func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {301+func newManualSessionWithPTYs(t testing.TB, ptys ...ports.PTY) (*Daemon, *session, *attachedClient, chan ports.Frame) {
302 t.Helper()302 t.Helper()
303 d := newTestDaemon(t, nil, stubClock{})303 d := newTestDaemon(t, nil, stubClock{})
304 tr, sends := newCapturingTransport(t)304 tr, sends := newCapturingTransport(t)
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 +16 -11
@@ -270,20 +270,25 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
270270 return
271271 }
272272
273+ // The immutable snapshot is the only live-pane access needed for drag entry.
273274 p.mu.Lock()
274- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
275+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
275276 p.mu.Unlock()
276-
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(snap)
279- mode.StartSelectionAt(snap, pressTop+pressRow)
280- mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)
281- rt.copyMode = mode
282- rt.copyPane = p
283- rt.copyPressRow = pressTop + pressRow
284- rt.copyPressRowValid = true
285- rt.copyDragging = true
286- 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+ }
287292 rt.copyMu.Unlock()
288293 d.paint(sess, ac, true)
289294 case mouse.Release:
@@ -270,20 +270,25 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
270 return270 return
271 }271 }
272 272
273+ // The immutable snapshot is the only live-pane access needed for drag entry.
273 p.mu.Lock()274 p.mu.Lock()
274- snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)275+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
275 p.mu.Unlock()276 p.mu.Unlock()
276-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(snap)285+ // Publication is revalidated before this state is installed, and no
279- mode.StartSelectionAt(snap, pressTop+pressRow)286+ // lifecycle lock is held while copyMu is taken.
280- mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row)287+ if rt.copyPane == p && rt.copySnapshot != nil && rt.copyMode != nil {
281- rt.copyMode = mode288+ rt.copyPressRow = pressTop + pressRow
282- rt.copyPane = p289+ rt.copyPressRowValid = true
283- rt.copyPressRow = pressTop + pressRow290+ rt.copyDragging = true
284- rt.copyPressRowValid = true291+ }
285- rt.copyDragging = true
286- rt.normalMousePressValid = false
287 rt.copyMu.Unlock()292 rt.copyMu.Unlock()
288 d.paint(sess, ac, true)293 d.paint(sess, ac, true)
289 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/overlay_runtime.go +7 -1
@@ -30,6 +30,8 @@ type overlayRuntime struct {
3030
3131 copyMu sync.Mutex
3232 copyMode *scopy.Mode
33+ copyCandidate *scopy.Mode
34+ copySnapshot *scopy.Snapshot
3335 copyPane *pane
3436 copyPending []byte
3537 copyESC pendingByteTimer
@@ -102,6 +104,8 @@ func (rt *overlayRuntime) copySearchActive() bool {
102104
103105 func (rt *overlayRuntime) clearCopyModeLocked() {
104106 rt.copyMode = nil
107+ rt.copyCandidate = nil
108+ rt.copySnapshot = nil
105109 rt.copyPane = nil
106110 rt.copySearch = nil
107111 rt.copySearchPending = nil
@@ -113,7 +117,7 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
113117 }
114118 rt.copyMu.Lock()
115119 defer rt.copyMu.Unlock()
116- if rt.copyMode == nil || rt.copyPane != p {
120+ if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {
117121 return false
118122 }
119123 rt.clearCopyModeLocked()
@@ -149,6 +153,7 @@ type overlayRenderSnapshot struct {
149153
150154 copyActive bool
151155 copyMode *scopy.Mode
156+ copySnapshot *scopy.Snapshot
152157 copyPane *pane
153158 copySearchModel *visualsearch.Model
154159 copyFeedback string
@@ -181,6 +186,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
181186 rt.copyMu.Lock()
182187 snap.copyActive = rt.copyMode != nil
183188 snap.copyPane = rt.copyPane
189+ snap.copySnapshot = rt.copySnapshot
184190 if rt.copyMode != nil {
185191 copyModeValue := *rt.copyMode
186192 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
@@ -30,6 +30,8 @@ type overlayRuntime struct {
30 30
31 copyMu sync.Mutex31 copyMu sync.Mutex
32 copyMode *scopy.Mode32 copyMode *scopy.Mode
33+ copyCandidate *scopy.Mode
34+ copySnapshot *scopy.Snapshot
33 copyPane *pane35 copyPane *pane
34 copyPending []byte36 copyPending []byte
35 copyESC pendingByteTimer37 copyESC pendingByteTimer
@@ -102,6 +104,8 @@ func (rt *overlayRuntime) copySearchActive() bool {
102 104
103 func (rt *overlayRuntime) clearCopyModeLocked() {105 func (rt *overlayRuntime) clearCopyModeLocked() {
104 rt.copyMode = nil106 rt.copyMode = nil
107+ rt.copyCandidate = nil
108+ rt.copySnapshot = nil
105 rt.copyPane = nil109 rt.copyPane = nil
106 rt.copySearch = nil110 rt.copySearch = nil
107 rt.copySearchPending = nil111 rt.copySearchPending = nil
@@ -113,7 +117,7 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool {
113 }117 }
114 rt.copyMu.Lock()118 rt.copyMu.Lock()
115 defer rt.copyMu.Unlock()119 defer rt.copyMu.Unlock()
116- if rt.copyMode == nil || rt.copyPane != p {120+ if rt.copyPane != p || (rt.copyMode == nil && rt.copyCandidate == nil) {
117 return false121 return false
118 }122 }
119 rt.clearCopyModeLocked()123 rt.clearCopyModeLocked()
@@ -149,6 +153,7 @@ type overlayRenderSnapshot struct {
149 153
150 copyActive bool154 copyActive bool
151 copyMode *scopy.Mode155 copyMode *scopy.Mode
156+ copySnapshot *scopy.Snapshot
152 copyPane *pane157 copyPane *pane
153 copySearchModel *visualsearch.Model158 copySearchModel *visualsearch.Model
154 copyFeedback string159 copyFeedback string
@@ -181,6 +186,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot {
181 rt.copyMu.Lock()186 rt.copyMu.Lock()
182 snap.copyActive = rt.copyMode != nil187 snap.copyActive = rt.copyMode != nil
183 snap.copyPane = rt.copyPane188 snap.copyPane = rt.copyPane
189+ snap.copySnapshot = rt.copySnapshot
184 if rt.copyMode != nil {190 if rt.copyMode != nil {
185 copyModeValue := *rt.copyMode191 copyModeValue := *rt.copyMode
186 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)192 copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...)
M internal/usecase/daemon/overlay_runtime_test.go +26 -4
@@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
113113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
114114 ac := &attachedClient{}
115115 ac.initOverlays()
116- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}
116+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)
117117 mode := scopy.NewMode(snap)
118118 require.True(t, mode.Search(snap, "alpha"))
119119 search := visualsearch.New(snap)
@@ -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.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 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+}
@@ -113,7 +113,7 @@ func TestOverlayRuntimeHandleInputPrecedence(t *testing.T) {
113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {113 func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) {
114 ac := &attachedClient{}114 ac := &attachedClient{}
115 ac.initOverlays()115 ac.initOverlays()
116- snap := scopy.Snapshot{Rows: [][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, Width: 16, Height: 2}116+ snap := scopy.NewSnapshotFromRows([][]renderer.Cell{testRow("alpha"), testRow("beta alpha")}, 16, 2)
117 mode := scopy.NewMode(snap)117 mode := scopy.NewMode(snap)
118 require.True(t, mode.Search(snap, "alpha"))118 require.True(t, mode.Search(snap, "alpha"))
119 search := visualsearch.New(snap)119 search := visualsearch.New(snap)
@@ -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.Snapshot{Rows: [][]renderer.Cell{testRow("row")}, Width: 4, Height: 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/pane_actions.go +12 -5
@@ -226,6 +226,11 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
226226 if tb == nil {
227227 return layout.ErrNotFound
228228 }
229+ if ac == nil {
230+ sess.mu.Lock()
231+ ac = sess.client
232+ sess.mu.Unlock()
233+ }
229234 tb.mu.Lock()
230235 p := tb.panes[id]
231236 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
@@ -234,6 +239,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
234239 }
235240 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
236241 tb.mu.Unlock()
242+ if ac != nil {
243+ ac.overlays.clearCopyModeForPane(p)
244+ }
237245 d.closeTab(sess, tb, repaint)
238246 return nil
239247 }
@@ -245,6 +253,10 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
245253 d.applyLayoutLocked(tb)
246254 tb.mu.Unlock()
247255
256+ if ac != nil {
257+ ac.overlays.clearCopyModeForPane(p)
258+ }
259+
248260 if p.cancel != nil {
249261 p.cancel()
250262 }
@@ -254,11 +266,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
254266 d.log.Info("pane closed", "session", sess.name, "pane", id)
255267 markSnapshotDirty(sess)
256268 if repaint {
257- if ac == nil {
258- sess.mu.Lock()
259- ac = sess.client
260- sess.mu.Unlock()
261- }
262269 if ac != nil {
263270 d.paint(sess, ac, true)
264271 }
@@ -226,6 +226,11 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
226 if tb == nil {226 if tb == nil {
227 return layout.ErrNotFound227 return layout.ErrNotFound
228 }228 }
229+ if ac == nil {
230+ sess.mu.Lock()
231+ ac = sess.client
232+ sess.mu.Unlock()
233+ }
229 tb.mu.Lock()234 tb.mu.Lock()
230 p := tb.panes[id]235 p := tb.panes[id]
231 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {236 if p == nil || tb.tree == nil || !layout.ContainsLeaf(tb.tree.Root, id) {
@@ -234,6 +239,9 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
234 }239 }
235 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {240 if len(layout.LeafIDs(tb.tree.Root)) <= 1 {
236 tb.mu.Unlock()241 tb.mu.Unlock()
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,10 @@ 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+ if ac != nil {
257+ ac.overlays.clearCopyModeForPane(p)
258+ }
259+
248 if p.cancel != nil {260 if p.cancel != nil {
249 p.cancel()261 p.cancel()
250 }262 }
@@ -254,11 +266,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)266 d.log.Info("pane closed", "session", sess.name, "pane", id)
255 markSnapshotDirty(sess)267 markSnapshotDirty(sess)
256 if repaint {268 if repaint {
257- if ac == nil {
258- sess.mu.Lock()
259- ac = sess.client
260- sess.mu.Unlock()
261- }
262 if ac != nil {269 if ac != nil {
263 d.paint(sess, ac, true)270 d.paint(sess, ac, true)
264 }271 }
A internal/usecase/daemon/performance_benchmark_test.go +449 -0
@@ -0,0 +1,449 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {
18+ store := &countingSnapshotStore{}
19+ payload := []byte{0xff, 0x00, 0xfe}
20+
21+ require.NoError(t, store.Write("session", payload))
22+ require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())
23+ require.Equal(t, payload, store.lastPayload())
24+
25+ payload[0] = 0x01
26+ require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
27+}
28+
29+func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
30+ transport := &countingOutputTransport{}
31+
32+ err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 23)})
33+ require.Error(t, err)
34+ require.Equal(t, countingOutputMetrics{}, transport.metrics())
35+
36+ payload := append(make([]byte, 24), 0xff, 0x00, 0xfe)
37+ require.NoError(t, transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}))
38+ require.Equal(t, countingOutputMetrics{frames: 1, bytes: 3, payloadBytes: uint64(len(payload))}, transport.metrics())
39+ require.Equal(t, payload, transport.lastPayload())
40+
41+ payload[24] = 0x01
42+ require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
43+}
44+
45+func TestPerformanceFixtureCounters(t *testing.T) {
46+ fixture := newPerformanceFixture(t, performanceConfig{})
47+
48+ fixture.paintLive()
49+ fixture.paintLive()
50+ metrics := fixture.metrics()
51+ require.Equal(t, uint64(2), metrics.outputFrames)
52+ require.Positive(t, metrics.outputBytes)
53+ require.Positive(t, metrics.outputPayloadBytes)
54+
55+ fixture.captureSnapshot()
56+ metrics = fixture.metrics()
57+ require.Equal(t, uint64(1), metrics.snapshotWrites)
58+ require.Positive(t, metrics.snapshotBytes)
59+
60+ fixture.enterCopySearch()
61+ require.Positive(t, fixture.searchMatches())
62+
63+ fixture.resize()
64+ require.True(t, fixture.resized())
65+}
66+
67+func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
68+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
69+
70+ for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {
71+ fixture.resize()
72+ require.Equal(t, want, fixture.ac.size)
73+ require.True(t, fixture.resized())
74+ }
75+}
76+
77+func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
78+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000})
79+
80+ require.Len(t, fixture.sess.tabs, 4)
81+ for _, tb := range fixture.sess.tabs {
82+ require.Len(t, tb.panes, 4)
83+ for _, p := range tb.panes {
84+ require.Equal(t, 10_000, p.scrollback.Len())
85+ }
86+ }
87+}
88+
89+func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
90+ benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
91+}
92+func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
93+ benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() })
94+}
95+func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
96+ benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
97+}
98+func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
99+ benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
100+}
101+func BenchmarkDaemonHistoryResize(b *testing.B) {
102+ benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })
103+}
104+
105+func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
106+ b.Helper()
107+ for _, topology := range []struct {
108+ name string
109+ tabs, panes int
110+ }{
111+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
112+ {name: "1tab-4panes", tabs: 1, panes: 4},
113+ {name: "4tabs-1pane", tabs: 4, panes: 1},
114+ {name: "4tabs-4panes", tabs: 4, panes: 4},
115+ {name: "8tabs-1pane", tabs: 8, panes: 1},
116+ } {
117+ b.Run(topology.name, func(b *testing.B) {
118+ fixture := newPerformanceFixture(b, performanceConfig{
119+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
120+ })
121+ if workload == "copy-search" {
122+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
123+ }
124+ b.ReportAllocs()
125+ fixture.resetMetrics()
126+ for b.Loop() {
127+ run(fixture)
128+ fixture.ac.ackOutputState(fixture.ac.output.next)
129+ }
130+ metrics := fixture.metrics()
131+ if payload := fixture.output.lastPayload(); payload != nil {
132+ if _, err := ports.UnmarshalOutput(payload); err != nil {
133+ b.Fatalf("decode last output: %v", err)
134+ }
135+ }
136+ if workload == "snapshot-capture" {
137+ payload := fixture.snaps.lastPayload()
138+ if payload == nil {
139+ b.Fatal("snapshot capture retained no snapshot payload")
140+ }
141+ if _, err := snapcodec.Unmarshal(payload); err != nil {
142+ b.Fatalf("decode last snapshot: %v", err)
143+ }
144+ }
145+ if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
146+ b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
147+ }
148+ if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) {
149+ b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N)
150+ }
151+ benchmarkReportMetrics(b, metrics, b.N, 10_000)
152+ })
153+ }
154+}
155+
156+func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
157+ b.Helper()
158+ if operations == 0 {
159+ return
160+ }
161+ perOperation := float64(operations)
162+ b.ReportMetric(float64(metrics.outputFrames)/perOperation, "outputframes/op")
163+ b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
164+ b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
165+ b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
166+ b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")
167+ b.ReportMetric(float64(historyRows), "historyrows/pane")
168+}
169+
170+func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
171+ fixture := newPerformanceFixture(t, performanceConfig{})
172+
173+ require.NotNil(t, fixture.activePane)
174+ require.Equal(t, [][]byte{
175+ []byte("\x1b[1;1HA\x1b[2;2HA"),
176+ []byte("\x1b[1;1HB\x1b[2;2HB"),
177+ }, fixture.liveWrites)
178+
179+ for range 4 {
180+ fixture.paintLive()
181+ }
182+ require.Equal(t, uint64(4), fixture.metrics().outputFrames)
183+}
184+
185+// performanceConfig intentionally describes only an in-process daemon shape:
186+// no PTY readers, schedulers, clocks, or transports are started by this fixture.
187+type performanceConfig struct {
188+ size domain.Size
189+ tabs int
190+ panes int
191+ historyRows int
192+}
193+
194+type performanceMetrics struct {
195+ outputFrames uint64
196+ outputBytes uint64
197+ outputPayloadBytes uint64
198+ snapshotWrites uint64
199+ snapshotBytes uint64
200+}
201+
202+type performanceFixture struct {
203+ t testing.TB
204+ d *Daemon
205+ sess *session
206+ ac *attachedClient
207+ output *countingOutputTransport
208+ snaps *countingSnapshotStore
209+ activePane *pane
210+ liveWrites [][]byte
211+ paints int
212+ resizeSizes [2]domain.Size
213+ resizes int
214+ resizedSize domain.Size
215+}
216+
217+func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
218+ t.Helper()
219+ if !config.size.Valid() {
220+ config.size = domain.Size{Cols: 80, Rows: 24}
221+ }
222+ if config.tabs == 0 {
223+ config.tabs = 1
224+ }
225+ if config.panes == 0 {
226+ config.panes = 2
227+ }
228+ if config.historyRows == 0 {
229+ config.historyRows = 1
230+ }
231+ ptys := make([]ports.PTY, config.tabs)
232+ d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
233+ output := &countingOutputTransport{}
234+ ac.tr = output
235+ ac.size = config.size
236+ sess.name = "performance"
237+ sess.ephemeral = false
238+ sess.snapEligible.Store(true)
239+
240+ fixture := &performanceFixture{
241+ t: t,
242+ d: d,
243+ sess: sess,
244+ ac: ac,
245+ output: output,
246+ snaps: &countingSnapshotStore{},
247+ liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
248+ resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
249+ }
250+ WithSnapshotStore(fixture.snaps)(d)
251+ for tabIndex, tb := range sess.tabs {
252+ fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
253+ }
254+ fixture.activePane = fixture.findActivePane()
255+
256+ // Prime the real renderer shadow before measurements. Subsequent paints use
257+ // actual production diffs rather than the initial full frame.
258+ d.paint(sess, ac, true)
259+ fixture.resetMetrics()
260+ return fixture
261+}
262+
263+func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
264+ f.t.Helper()
265+ tb.mu.Lock()
266+ defer tb.mu.Unlock()
267+ tb.size = size
268+ left := tb.focusedPane()
269+ for paneIndex := 1; paneIndex < paneCount; paneIndex++ {
270+ id := layout.PaneID(fmt.Sprintf("pane-%d-%d", tabIndex+1, paneIndex+1))
271+ tb.panes[id] = newPane(id, nil, size)
272+ require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, id, domain.Rect{Width: size.Cols, Height: size.Rows}))
273+ }
274+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
275+ require.True(f.t, ok, "performance fixture layout must be solvable")
276+ require.Len(f.t, placements, paneCount)
277+ f.d.applyLayoutLocked(tb)
278+ for _, p := range tb.panes {
279+ p.mu.Lock()
280+ width := p.screen.Frame.Width
281+ for row := 0; p.scrollback.Len() < historyRows; row++ {
282+ p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
283+ }
284+ p.mu.Unlock()
285+ }
286+}
287+
288+// performanceFullWidthRow produces stable, full-width visible content without
289+// relying on process output or timing.
290+func performanceFullWidthRow(width, tabIndex, row int) string {
291+ prefix := fmt.Sprintf("needle-%d-%02d ", tabIndex, row)
292+ out := make([]byte, width)
293+ for i := range out {
294+ out[i] = byte('a' + (tabIndex+row+i)%26)
295+ }
296+ copy(out, prefix)
297+ return string(out)
298+}
299+
300+func (f *performanceFixture) findActivePane() *pane {
301+ f.t.Helper()
302+ tb := f.sess.activeTab()
303+ require.NotNil(f.t, tb)
304+ tb.mu.Lock()
305+ defer tb.mu.Unlock()
306+ return tb.focusedPane()
307+}
308+
309+func (f *performanceFixture) paintLive() {
310+ p := f.activePane
311+ p.mu.Lock()
312+ p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
313+ p.mu.Unlock()
314+ f.d.paint(f.sess, f.ac, false)
315+ f.paints++
316+}
317+
318+func (f *performanceFixture) captureSnapshot() {
319+ f.t.Helper()
320+ require.True(f.t, f.d.captureSession(f.sess))
321+}
322+
323+func (f *performanceFixture) enterCopySearch() {
324+ f.t.Helper()
325+ f.d.enterCopyMode(f.sess, f.ac)
326+ f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
327+}
328+
329+func (f *performanceFixture) searchMatches() int {
330+ f.ac.overlays.copyMu.Lock()
331+ defer f.ac.overlays.copyMu.Unlock()
332+ if f.ac.overlays.copyMode == nil {
333+ return 0
334+ }
335+ return len(f.ac.overlays.copyMode.Searches)
336+}
337+
338+func (f *performanceFixture) resize() {
339+ f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]
340+ f.resizes++
341+ f.d.resize(f.sess, f.ac, f.resizedSize)
342+}
343+
344+func (f *performanceFixture) resized() bool {
345+ if f.ac.size != f.resizedSize {
346+ return false
347+ }
348+ for _, tb := range f.sess.tabs {
349+ tb.mu.Lock()
350+ ok := tb.size == tabSize(f.resizedSize)
351+ tb.mu.Unlock()
352+ if !ok {
353+ return false
354+ }
355+ }
356+ return true
357+}
358+
359+func (f *performanceFixture) resetMetrics() {
360+ f.output.reset()
361+ f.snaps.reset()
362+}
363+
364+func (f *performanceFixture) metrics() performanceMetrics {
365+ output := f.output.metrics()
366+ snapshots := f.snaps.metrics()
367+ return performanceMetrics{
368+ outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
369+ snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
370+ }
371+}
372+
373+const outputPayloadHeaderBytes = 24
374+
375+type countingOutputMetrics struct{ frames, bytes, payloadBytes uint64 }
376+
377+type countingOutputTransport struct {
378+ mu sync.Mutex
379+ countingOutputMetrics
380+ last []byte
381+}
382+
383+func (t *countingOutputTransport) Send(frame ports.Frame) error {
384+ if frame.Type != ports.MsgOutput {
385+ return nil
386+ }
387+ if len(frame.Payload) < outputPayloadHeaderBytes {
388+ return fmt.Errorf("output payload is %d bytes, want at least %d", len(frame.Payload), outputPayloadHeaderBytes)
389+ }
390+ t.mu.Lock()
391+ t.frames++
392+ t.bytes += uint64(len(frame.Payload) - outputPayloadHeaderBytes)
393+ t.payloadBytes += uint64(len(frame.Payload))
394+ t.last = frame.Payload
395+ t.mu.Unlock()
396+ return nil
397+}
398+func (*countingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
399+func (*countingOutputTransport) Close() error { return nil }
400+func (t *countingOutputTransport) reset() {
401+ t.mu.Lock()
402+ t.countingOutputMetrics = countingOutputMetrics{}
403+ t.last = nil
404+ t.mu.Unlock()
405+}
406+func (t *countingOutputTransport) metrics() countingOutputMetrics {
407+ t.mu.Lock()
408+ defer t.mu.Unlock()
409+ return t.countingOutputMetrics
410+}
411+func (t *countingOutputTransport) lastPayload() []byte {
412+ t.mu.Lock()
413+ defer t.mu.Unlock()
414+ return t.last
415+}
416+
417+type countingSnapshotMetrics struct{ writes, bytes uint64 }
418+type countingSnapshotStore struct {
419+ mu sync.Mutex
420+ countingSnapshotMetrics
421+ last []byte
422+}
423+
424+func (s *countingSnapshotStore) Write(_ string, data []byte) error {
425+ s.mu.Lock()
426+ s.writes++
427+ s.bytes += uint64(len(data))
428+ s.last = data
429+ s.mu.Unlock()
430+ return nil
431+}
432+func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
433+func (*countingSnapshotStore) Delete(string) error { return nil }
434+func (s *countingSnapshotStore) reset() {
435+ s.mu.Lock()
436+ s.countingSnapshotMetrics = countingSnapshotMetrics{}
437+ s.last = nil
438+ s.mu.Unlock()
439+}
440+func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
441+ s.mu.Lock()
442+ defer s.mu.Unlock()
443+ return s.countingSnapshotMetrics
444+}
445+func (s *countingSnapshotStore) lastPayload() []byte {
446+ s.mu.Lock()
447+ defer s.mu.Unlock()
448+ return s.last
449+}
@@ -0,0 +1,449 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sync"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/layout"
14+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
15+)
16+
17+func TestCountingSnapshotStoreWriteCountsAndRetainsOpaquePayload(t *testing.T) {
18+ store := &countingSnapshotStore{}
19+ payload := []byte{0xff, 0x00, 0xfe}
20+
21+ require.NoError(t, store.Write("session", payload))
22+ require.Equal(t, countingSnapshotMetrics{writes: 1, bytes: uint64(len(payload))}, store.metrics())
23+ require.Equal(t, payload, store.lastPayload())
24+
25+ payload[0] = 0x01
26+ require.Equal(t, payload, store.lastPayload(), "the counting sink must retain the payload slice header, not copy it")
27+}
28+
29+func TestCountingOutputTransportCountsOpaquePayloadAndRejectsShortPayload(t *testing.T) {
30+ transport := &countingOutputTransport{}
31+
32+ err := transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 23)})
33+ require.Error(t, err)
34+ require.Equal(t, countingOutputMetrics{}, transport.metrics())
35+
36+ payload := append(make([]byte, 24), 0xff, 0x00, 0xfe)
37+ require.NoError(t, transport.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}))
38+ require.Equal(t, countingOutputMetrics{frames: 1, bytes: 3, payloadBytes: uint64(len(payload))}, transport.metrics())
39+ require.Equal(t, payload, transport.lastPayload())
40+
41+ payload[24] = 0x01
42+ require.Equal(t, payload, transport.lastPayload(), "the counting transport must retain the payload slice header, not copy it")
43+}
44+
45+func TestPerformanceFixtureCounters(t *testing.T) {
46+ fixture := newPerformanceFixture(t, performanceConfig{})
47+
48+ fixture.paintLive()
49+ fixture.paintLive()
50+ metrics := fixture.metrics()
51+ require.Equal(t, uint64(2), metrics.outputFrames)
52+ require.Positive(t, metrics.outputBytes)
53+ require.Positive(t, metrics.outputPayloadBytes)
54+
55+ fixture.captureSnapshot()
56+ metrics = fixture.metrics()
57+ require.Equal(t, uint64(1), metrics.snapshotWrites)
58+ require.Positive(t, metrics.snapshotBytes)
59+
60+ fixture.enterCopySearch()
61+ require.Positive(t, fixture.searchMatches())
62+
63+ fixture.resize()
64+ require.True(t, fixture.resized())
65+}
66+
67+func TestPerformanceFixtureResizeAlternatesRealDimensions(t *testing.T) {
68+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}})
69+
70+ for _, want := range []domain.Size{{Cols: 100, Rows: 30}, {Cols: 120, Rows: 40}} {
71+ fixture.resize()
72+ require.Equal(t, want, fixture.ac.size)
73+ require.True(t, fixture.resized())
74+ }
75+}
76+
77+func TestPerformanceFixtureLargeHistoryTopology(t *testing.T) {
78+ fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: 4, panes: 4, historyRows: 10_000})
79+
80+ require.Len(t, fixture.sess.tabs, 4)
81+ for _, tb := range fixture.sess.tabs {
82+ require.Len(t, tb.panes, 4)
83+ for _, p := range tb.panes {
84+ require.Equal(t, 10_000, p.scrollback.Len())
85+ }
86+ }
87+}
88+
89+func BenchmarkDaemonHistoryLivePaint(b *testing.B) {
90+ benchmarkDaemonLargeHistory(b, "live-paint", func(f *performanceFixture) { f.paintLive() })
91+}
92+func BenchmarkDaemonHistorySnapshotCapture(b *testing.B) {
93+ benchmarkDaemonLargeHistory(b, "snapshot-capture", func(f *performanceFixture) { f.captureSnapshot() })
94+}
95+func BenchmarkDaemonHistoryCopyEnter(b *testing.B) {
96+ benchmarkDaemonLargeHistory(b, "copy-enter", func(f *performanceFixture) { f.d.enterCopyMode(f.sess, f.ac) })
97+}
98+func BenchmarkDaemonHistoryCopySearch(b *testing.B) {
99+ benchmarkDaemonLargeHistory(b, "copy-search", func(f *performanceFixture) { f.d.handleInput(f.sess, f.ac, []byte("/needle\r")) })
100+}
101+func BenchmarkDaemonHistoryResize(b *testing.B) {
102+ benchmarkDaemonLargeHistory(b, "resize", func(f *performanceFixture) { f.resize() })
103+}
104+
105+func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*performanceFixture)) {
106+ b.Helper()
107+ for _, topology := range []struct {
108+ name string
109+ tabs, panes int
110+ }{
111+ {name: "1tab-1pane-control", tabs: 1, panes: 1},
112+ {name: "1tab-4panes", tabs: 1, panes: 4},
113+ {name: "4tabs-1pane", tabs: 4, panes: 1},
114+ {name: "4tabs-4panes", tabs: 4, panes: 4},
115+ {name: "8tabs-1pane", tabs: 8, panes: 1},
116+ } {
117+ b.Run(topology.name, func(b *testing.B) {
118+ fixture := newPerformanceFixture(b, performanceConfig{
119+ size: domain.Size{Cols: 120, Rows: 40}, tabs: topology.tabs, panes: topology.panes, historyRows: 10_000,
120+ })
121+ if workload == "copy-search" {
122+ fixture.d.enterCopyMode(fixture.sess, fixture.ac)
123+ }
124+ b.ReportAllocs()
125+ fixture.resetMetrics()
126+ for b.Loop() {
127+ run(fixture)
128+ fixture.ac.ackOutputState(fixture.ac.output.next)
129+ }
130+ metrics := fixture.metrics()
131+ if payload := fixture.output.lastPayload(); payload != nil {
132+ if _, err := ports.UnmarshalOutput(payload); err != nil {
133+ b.Fatalf("decode last output: %v", err)
134+ }
135+ }
136+ if workload == "snapshot-capture" {
137+ payload := fixture.snaps.lastPayload()
138+ if payload == nil {
139+ b.Fatal("snapshot capture retained no snapshot payload")
140+ }
141+ if _, err := snapcodec.Unmarshal(payload); err != nil {
142+ b.Fatalf("decode last snapshot: %v", err)
143+ }
144+ }
145+ if workload == "live-paint" && metrics.outputFrames != uint64(b.N) {
146+ b.Fatalf("live paint emitted %d frames for %d operations", metrics.outputFrames, b.N)
147+ }
148+ if workload == "snapshot-capture" && metrics.snapshotWrites != uint64(b.N) {
149+ b.Fatalf("snapshot capture wrote %d snapshots for %d operations", metrics.snapshotWrites, b.N)
150+ }
151+ benchmarkReportMetrics(b, metrics, b.N, 10_000)
152+ })
153+ }
154+}
155+
156+func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations, historyRows int) {
157+ b.Helper()
158+ if operations == 0 {
159+ return
160+ }
161+ perOperation := float64(operations)
162+ b.ReportMetric(float64(metrics.outputFrames)/perOperation, "outputframes/op")
163+ b.ReportMetric(float64(metrics.outputBytes)/perOperation, "outputbytes/op")
164+ b.ReportMetric(float64(metrics.outputPayloadBytes)/perOperation, "framepayloadbytes/op")
165+ b.ReportMetric(float64(metrics.snapshotWrites)/perOperation, "snapshotwrites/op")
166+ b.ReportMetric(float64(metrics.snapshotBytes)/perOperation, "snapshotbytes/op")
167+ b.ReportMetric(float64(historyRows), "historyrows/pane")
168+}
169+
170+func TestPerformanceFixturePaintLiveUsesPrecomputedAlternatingWrites(t *testing.T) {
171+ fixture := newPerformanceFixture(t, performanceConfig{})
172+
173+ require.NotNil(t, fixture.activePane)
174+ require.Equal(t, [][]byte{
175+ []byte("\x1b[1;1HA\x1b[2;2HA"),
176+ []byte("\x1b[1;1HB\x1b[2;2HB"),
177+ }, fixture.liveWrites)
178+
179+ for range 4 {
180+ fixture.paintLive()
181+ }
182+ require.Equal(t, uint64(4), fixture.metrics().outputFrames)
183+}
184+
185+// performanceConfig intentionally describes only an in-process daemon shape:
186+// no PTY readers, schedulers, clocks, or transports are started by this fixture.
187+type performanceConfig struct {
188+ size domain.Size
189+ tabs int
190+ panes int
191+ historyRows int
192+}
193+
194+type performanceMetrics struct {
195+ outputFrames uint64
196+ outputBytes uint64
197+ outputPayloadBytes uint64
198+ snapshotWrites uint64
199+ snapshotBytes uint64
200+}
201+
202+type performanceFixture struct {
203+ t testing.TB
204+ d *Daemon
205+ sess *session
206+ ac *attachedClient
207+ output *countingOutputTransport
208+ snaps *countingSnapshotStore
209+ activePane *pane
210+ liveWrites [][]byte
211+ paints int
212+ resizeSizes [2]domain.Size
213+ resizes int
214+ resizedSize domain.Size
215+}
216+
217+func newPerformanceFixture(t testing.TB, config performanceConfig) *performanceFixture {
218+ t.Helper()
219+ if !config.size.Valid() {
220+ config.size = domain.Size{Cols: 80, Rows: 24}
221+ }
222+ if config.tabs == 0 {
223+ config.tabs = 1
224+ }
225+ if config.panes == 0 {
226+ config.panes = 2
227+ }
228+ if config.historyRows == 0 {
229+ config.historyRows = 1
230+ }
231+ ptys := make([]ports.PTY, config.tabs)
232+ d, sess, ac, _ := newManualSessionWithPTYs(t, ptys...)
233+ output := &countingOutputTransport{}
234+ ac.tr = output
235+ ac.size = config.size
236+ sess.name = "performance"
237+ sess.ephemeral = false
238+ sess.snapEligible.Store(true)
239+
240+ fixture := &performanceFixture{
241+ t: t,
242+ d: d,
243+ sess: sess,
244+ ac: ac,
245+ output: output,
246+ snaps: &countingSnapshotStore{},
247+ liveWrites: [][]byte{[]byte("\x1b[1;1HA\x1b[2;2HA"), []byte("\x1b[1;1HB\x1b[2;2HB")},
248+ resizeSizes: [2]domain.Size{{Cols: 100, Rows: 30}, config.size},
249+ }
250+ WithSnapshotStore(fixture.snaps)(d)
251+ for tabIndex, tb := range sess.tabs {
252+ fixture.configureTab(tb, tabIndex, config.panes, config.historyRows, tabSize(config.size))
253+ }
254+ fixture.activePane = fixture.findActivePane()
255+
256+ // Prime the real renderer shadow before measurements. Subsequent paints use
257+ // actual production diffs rather than the initial full frame.
258+ d.paint(sess, ac, true)
259+ fixture.resetMetrics()
260+ return fixture
261+}
262+
263+func (f *performanceFixture) configureTab(tb *tab, tabIndex, paneCount, historyRows int, size domain.Size) {
264+ f.t.Helper()
265+ tb.mu.Lock()
266+ defer tb.mu.Unlock()
267+ tb.size = size
268+ left := tb.focusedPane()
269+ for paneIndex := 1; paneIndex < paneCount; paneIndex++ {
270+ id := layout.PaneID(fmt.Sprintf("pane-%d-%d", tabIndex+1, paneIndex+1))
271+ tb.panes[id] = newPane(id, nil, size)
272+ require.NoError(f.t, tb.tree.Split(left.id, layout.Right, true, id, domain.Rect{Width: size.Cols, Height: size.Rows}))
273+ }
274+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
275+ require.True(f.t, ok, "performance fixture layout must be solvable")
276+ require.Len(f.t, placements, paneCount)
277+ f.d.applyLayoutLocked(tb)
278+ for _, p := range tb.panes {
279+ p.mu.Lock()
280+ width := p.screen.Frame.Width
281+ for row := 0; p.scrollback.Len() < historyRows; row++ {
282+ p.screen.Write([]byte(performanceFullWidthRow(width, tabIndex, row) + "\r\n"))
283+ }
284+ p.mu.Unlock()
285+ }
286+}
287+
288+// performanceFullWidthRow produces stable, full-width visible content without
289+// relying on process output or timing.
290+func performanceFullWidthRow(width, tabIndex, row int) string {
291+ prefix := fmt.Sprintf("needle-%d-%02d ", tabIndex, row)
292+ out := make([]byte, width)
293+ for i := range out {
294+ out[i] = byte('a' + (tabIndex+row+i)%26)
295+ }
296+ copy(out, prefix)
297+ return string(out)
298+}
299+
300+func (f *performanceFixture) findActivePane() *pane {
301+ f.t.Helper()
302+ tb := f.sess.activeTab()
303+ require.NotNil(f.t, tb)
304+ tb.mu.Lock()
305+ defer tb.mu.Unlock()
306+ return tb.focusedPane()
307+}
308+
309+func (f *performanceFixture) paintLive() {
310+ p := f.activePane
311+ p.mu.Lock()
312+ p.screen.Write(f.liveWrites[f.paints%len(f.liveWrites)])
313+ p.mu.Unlock()
314+ f.d.paint(f.sess, f.ac, false)
315+ f.paints++
316+}
317+
318+func (f *performanceFixture) captureSnapshot() {
319+ f.t.Helper()
320+ require.True(f.t, f.d.captureSession(f.sess))
321+}
322+
323+func (f *performanceFixture) enterCopySearch() {
324+ f.t.Helper()
325+ f.d.enterCopyMode(f.sess, f.ac)
326+ f.d.handleInput(f.sess, f.ac, []byte("/needle\r"))
327+}
328+
329+func (f *performanceFixture) searchMatches() int {
330+ f.ac.overlays.copyMu.Lock()
331+ defer f.ac.overlays.copyMu.Unlock()
332+ if f.ac.overlays.copyMode == nil {
333+ return 0
334+ }
335+ return len(f.ac.overlays.copyMode.Searches)
336+}
337+
338+func (f *performanceFixture) resize() {
339+ f.resizedSize = f.resizeSizes[f.resizes%len(f.resizeSizes)]
340+ f.resizes++
341+ f.d.resize(f.sess, f.ac, f.resizedSize)
342+}
343+
344+func (f *performanceFixture) resized() bool {
345+ if f.ac.size != f.resizedSize {
346+ return false
347+ }
348+ for _, tb := range f.sess.tabs {
349+ tb.mu.Lock()
350+ ok := tb.size == tabSize(f.resizedSize)
351+ tb.mu.Unlock()
352+ if !ok {
353+ return false
354+ }
355+ }
356+ return true
357+}
358+
359+func (f *performanceFixture) resetMetrics() {
360+ f.output.reset()
361+ f.snaps.reset()
362+}
363+
364+func (f *performanceFixture) metrics() performanceMetrics {
365+ output := f.output.metrics()
366+ snapshots := f.snaps.metrics()
367+ return performanceMetrics{
368+ outputFrames: output.frames, outputBytes: output.bytes, outputPayloadBytes: output.payloadBytes,
369+ snapshotWrites: snapshots.writes, snapshotBytes: snapshots.bytes,
370+ }
371+}
372+
373+const outputPayloadHeaderBytes = 24
374+
375+type countingOutputMetrics struct{ frames, bytes, payloadBytes uint64 }
376+
377+type countingOutputTransport struct {
378+ mu sync.Mutex
379+ countingOutputMetrics
380+ last []byte
381+}
382+
383+func (t *countingOutputTransport) Send(frame ports.Frame) error {
384+ if frame.Type != ports.MsgOutput {
385+ return nil
386+ }
387+ if len(frame.Payload) < outputPayloadHeaderBytes {
388+ return fmt.Errorf("output payload is %d bytes, want at least %d", len(frame.Payload), outputPayloadHeaderBytes)
389+ }
390+ t.mu.Lock()
391+ t.frames++
392+ t.bytes += uint64(len(frame.Payload) - outputPayloadHeaderBytes)
393+ t.payloadBytes += uint64(len(frame.Payload))
394+ t.last = frame.Payload
395+ t.mu.Unlock()
396+ return nil
397+}
398+func (*countingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
399+func (*countingOutputTransport) Close() error { return nil }
400+func (t *countingOutputTransport) reset() {
401+ t.mu.Lock()
402+ t.countingOutputMetrics = countingOutputMetrics{}
403+ t.last = nil
404+ t.mu.Unlock()
405+}
406+func (t *countingOutputTransport) metrics() countingOutputMetrics {
407+ t.mu.Lock()
408+ defer t.mu.Unlock()
409+ return t.countingOutputMetrics
410+}
411+func (t *countingOutputTransport) lastPayload() []byte {
412+ t.mu.Lock()
413+ defer t.mu.Unlock()
414+ return t.last
415+}
416+
417+type countingSnapshotMetrics struct{ writes, bytes uint64 }
418+type countingSnapshotStore struct {
419+ mu sync.Mutex
420+ countingSnapshotMetrics
421+ last []byte
422+}
423+
424+func (s *countingSnapshotStore) Write(_ string, data []byte) error {
425+ s.mu.Lock()
426+ s.writes++
427+ s.bytes += uint64(len(data))
428+ s.last = data
429+ s.mu.Unlock()
430+ return nil
431+}
432+func (*countingSnapshotStore) Load() ([]ports.SnapshotBlob, error) { return nil, nil }
433+func (*countingSnapshotStore) Delete(string) error { return nil }
434+func (s *countingSnapshotStore) reset() {
435+ s.mu.Lock()
436+ s.countingSnapshotMetrics = countingSnapshotMetrics{}
437+ s.last = nil
438+ s.mu.Unlock()
439+}
440+func (s *countingSnapshotStore) metrics() countingSnapshotMetrics {
441+ s.mu.Lock()
442+ defer s.mu.Unlock()
443+ return s.countingSnapshotMetrics
444+}
445+func (s *countingSnapshotStore) lastPayload() []byte {
446+ s.mu.Lock()
447+ defer s.mu.Unlock()
448+ return s.last
449+}
M internal/usecase/daemon/render.go +1 -1
@@ -407,7 +407,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
407407 copyPane = p
408408 }
409409 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
410- frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)
410+ frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
411411 }
412412 // A palette above normal/copy content dims that composed content. When a
413413 // floating pane is present its own backdrop already dims normal pane
@@ -407,7 +407,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
407 copyPane = p407 copyPane = p
408 }408 }
409 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)409 copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingFrameGeometry)
410- frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars)410+ frame, damage = composeCopyClientFrame(overlays.copyMode, overlays.copySnapshot, copyTarget, frame, bars)
411 }411 }
412 // A palette above normal/copy content dims that composed content. When a412 // A palette above normal/copy content dims that composed content. When a
413 // floating pane is present its own backdrop already dims normal pane413 // floating pane is present its own backdrop already dims normal pane
M internal/usecase/daemon/render_test.go +5 -40
@@ -107,12 +107,6 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
107107
108108 // --- test doubles -----------------------------------------------------------
109109
110-type discardTransport struct{}
111-
112-func (discardTransport) Send(ports.Frame) error { return nil }
113-func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
114-func (discardTransport) Close() error { return nil }
115-
116110 // stubClock returns timers whose channel never fires, so a scheduler under it
117111 // blocks in its debounce loop until the session context is cancelled. Used by
118112
@@ -379,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
379373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
380374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
381375 require.Equal(t, 2, win.focusedPane().scrollback.Len())
382- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))
383- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))
376+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
377+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
384378
385379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
386380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
@@ -648,36 +642,6 @@ func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
648642 require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
649643 }
650644
651-func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
652- win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
653- left := win.focusedPane()
654- right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
655- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
656- win.tree.Focus = right.id
657- win.panes[right.id] = right
658- left.screen.Write([]byte("left"))
659- right.screen.Write([]byte("right"))
660- sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
661- ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}}
662- ac.initOverlays()
663- sess.client = ac
664- ac.setSession(sess)
665- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
666- d.paint(sess, ac, true)
667- left.screen.ClearDamage()
668- right.screen.ClearDamage()
669-
670- b.ReportAllocs()
671- b.ResetTimer()
672- for i := 0; i < b.N; i++ {
673- right.screen.Write([]byte("x\b"))
674- d.paint(sess, ac, false)
675- // Mirror the client ACK pump so the steady state measures compose→diff→send,
676- // not the ack-gate bailout after maxUnackedOutputStates frames.
677- ac.ackOutputState(ac.output.next)
678- }
679-}
680-
681645 func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
682646 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
683647 p := win.focusedPane()
@@ -1455,10 +1419,11 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14551419 }
14561420 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
14571421 p.screen.Write([]byte("ab\r\ncd"))
1458- mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))
1422+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1423+ mode := scopy.NewMode(document)
14591424 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
14601425
1461- frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})
1426+ frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})
14621427
14631428 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
14641429 require.Equal(t, "##ab ", rowText(frame.Row(3)))
@@ -107,12 +107,6 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
107 107
108 // --- test doubles -----------------------------------------------------------108 // --- test doubles -----------------------------------------------------------
109 109
110-type discardTransport struct{}
111-
112-func (discardTransport) Send(ports.Frame) error { return nil }
113-func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
114-func (discardTransport) Close() error { return nil }
115-
116 // stubClock returns timers whose channel never fires, so a scheduler under it110 // stubClock returns timers whose channel never fires, so a scheduler under it
117 // blocks in its debounce loop until the session context is cancelled. Used by111 // blocks in its debounce loop until the session context is cancelled. Used by
118 112
@@ -379,8 +373,8 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
379 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))373 require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0))
380 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))374 require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1))
381 require.Equal(t, 2, win.focusedPane().scrollback.Len())375 require.Equal(t, 2, win.focusedPane().scrollback.Len())
382- require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.Row(0)))376+ require.Equal(t, "0000", cellsString(win.focusedPane().scrollback.View().Row(0)))
383- require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.Row(1)))377+ require.Equal(t, "1111", cellsString(win.focusedPane().scrollback.View().Row(1)))
384 378
385 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})379 d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6})
386 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))380 require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0))
@@ -648,36 +642,6 @@ func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
648 require.Zero(t, allocs, "valid cached title-bar renders must not allocate")642 require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
649 }643 }
650 644
651-func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
652- win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
653- left := win.focusedPane()
654- right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
655- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
656- win.tree.Focus = right.id
657- win.panes[right.id] = right
658- left.screen.Write([]byte("left"))
659- right.screen.Write([]byte("right"))
660- sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
661- ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}}
662- ac.initOverlays()
663- sess.client = ac
664- ac.setSession(sess)
665- d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
666- d.paint(sess, ac, true)
667- left.screen.ClearDamage()
668- right.screen.ClearDamage()
669-
670- b.ReportAllocs()
671- b.ResetTimer()
672- for i := 0; i < b.N; i++ {
673- right.screen.Write([]byte("x\b"))
674- d.paint(sess, ac, false)
675- // Mirror the client ACK pump so the steady state measures compose→diff→send,
676- // not the ack-gate bailout after maxUnackedOutputStates frames.
677- ac.ackOutputState(ac.output.next)
678- }
679-}
680-
681 func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {645 func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
682 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})646 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
683 p := win.focusedPane()647 p := win.focusedPane()
@@ -1455,10 +1419,11 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1455 }1419 }
1456 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1420 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1457 p.screen.Write([]byte("ab\r\ncd"))1421 p.screen.Write([]byte("ab\r\ncd"))
1458- mode := scopy.NewMode(scopy.NewSnapshot(p.scrollback, p.screen.Frame))1422+ document := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
1423+ mode := scopy.NewMode(document)
1459 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1424 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1460 1425
1461- frame, damage := composeCopyClientFrame(mode, p, target, base, barState{})1426+ frame, damage := composeCopyClientFrame(mode, &document, target, base, barState{})
1462 1427
1463 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)1428 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1464 require.Equal(t, "##ab ", rowText(frame.Row(3)))1429 require.Equal(t, "##ab ", rowText(frame.Row(3)))
M internal/usecase/daemon/scrollback_test.go +1 -1
@@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) {
3636 if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
3737 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
3838 }
39- row := win.focusedPane().scrollback.Row(0)
39+ row := win.focusedPane().scrollback.View().Row(0)
4040 runes := make([]rune, len(row))
4141 for i, c := range row {
4242 runes[i] = c.Rune
@@ -36,7 +36,7 @@ func TestNewTabInitializesScrollback(t *testing.T) {
36 if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {36 if got := win.focusedPane().scrollback.Len(); got != tt.wantLen {
37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)37 t.Fatalf("scrollback len = %d, want %d", got, tt.wantLen)
38 }38 }
39- row := win.focusedPane().scrollback.Row(0)39+ row := win.focusedPane().scrollback.View().Row(0)
40 runes := make([]rune, len(row))40 runes := make([]rune, len(row))
41 for i, c := range row {41 for i, c := range row {
42 runes[i] = c.Rune42 runes[i] = c.Rune
M internal/usecase/daemon/snapshot_restore_test.go +3 -3
@@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
7777 p := tb.panes["pane-2"]
7878 tb.mu.Unlock()
7979 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.Row(0)))
80+ require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
8181 require.Equal(t, 1, p.scrollback.Len())
8282 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
8383 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
84- require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))
85- require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))
84+ require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85+ require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
8686 p.mu.Unlock()
8787
8888 tb.mu.Lock()
@@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
77 p := tb.panes["pane-2"]77 p := tb.panes["pane-2"]
78 tb.mu.Unlock()78 tb.mu.Unlock()
79 p.mu.Lock()79 p.mu.Lock()
80- require.Equal(t, "old2", rowText(p.scrollback.Row(0)))80+ require.Equal(t, "old2", rowText(p.scrollback.View().Row(0)))
81 require.Equal(t, 1, p.scrollback.Len())81 require.Equal(t, 1, p.scrollback.Len())
82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))82 require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4]))
83 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)83 copySnap := scopy.NewSnapshot(p.scrollback, p.screen.Frame)
84- require.Equal(t, "old2", rowText(copySnap.Rows[0][:4]))84+ require.Equal(t, "old2", rowText(copySnap.Row(0)[:4]))
85- require.Equal(t, "vis2", rowText(copySnap.Rows[1][:4]))85+ require.Equal(t, "vis2", rowText(copySnap.Row(1)[:4]))
86 p.mu.Unlock()86 p.mu.Unlock()
87 87
88 tb.mu.Lock()88 tb.mu.Lock()
M internal/usecase/visualsearch/model.go +3 -13
@@ -18,7 +18,7 @@ type Model struct {
1818 }
1919
2020 func New(snapshot scopy.Snapshot) *Model {
21- m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}
21+ m := &Model{snapshot: snapshot, selected: -1}
2222 m.refresh()
2323 return m
2424 }
@@ -28,7 +28,6 @@ func (m *Model) Clone() *Model {
2828 return nil
2929 }
3030 clone := *m
31- clone.snapshot = cloneSnapshot(m.snapshot)
3231 clone.input.SetValue(m.input.Value())
3332 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
3433 return &clone
@@ -36,9 +35,9 @@ func (m *Model) Clone() *Model {
3635
3736 func (m *Model) Snapshot() scopy.Snapshot {
3837 if m == nil {
39- return scopy.Snapshot{}
38+ return scopy.NewSnapshotFromRows(nil, 0, 0)
4039 }
41- return cloneSnapshot(m.snapshot)
40+ return m.snapshot
4241 }
4342
4443 func (m *Model) Insert(r rune) {
@@ -146,15 +145,6 @@ func (m *Model) ensureVisible(visible int) {
146145 }
147146 }
148147
149-func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- clone := s
151- clone.Rows = make([][]renderer.Cell, len(s.Rows))
152- for i, row := range s.Rows {
153- clone.Rows[i] = append([]renderer.Cell(nil), row...)
154- }
155- return clone
156-}
157-
158148 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
159149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
160150 if frame.Width == 0 || frame.Height == 0 {
@@ -18,7 +18,7 @@ type Model struct {
18 }18 }
19 19
20 func New(snapshot scopy.Snapshot) *Model {20 func New(snapshot scopy.Snapshot) *Model {
21- m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1}21+ m := &Model{snapshot: snapshot, selected: -1}
22 m.refresh()22 m.refresh()
23 return m23 return m
24 }24 }
@@ -28,7 +28,6 @@ func (m *Model) Clone() *Model {
28 return nil28 return nil
29 }29 }
30 clone := *m30 clone := *m
31- clone.snapshot = cloneSnapshot(m.snapshot)
32 clone.input.SetValue(m.input.Value())31 clone.input.SetValue(m.input.Value())
33 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)32 clone.matches = append([]scopy.SearchMatch(nil), m.matches...)
34 return &clone33 return &clone
@@ -36,9 +35,9 @@ func (m *Model) Clone() *Model {
36 35
37 func (m *Model) Snapshot() scopy.Snapshot {36 func (m *Model) Snapshot() scopy.Snapshot {
38 if m == nil {37 if m == nil {
39- return scopy.Snapshot{}38+ return scopy.NewSnapshotFromRows(nil, 0, 0)
40 }39 }
41- return cloneSnapshot(m.snapshot)40+ return m.snapshot
42 }41 }
43 42
44 func (m *Model) Insert(r rune) {43 func (m *Model) Insert(r rune) {
@@ -146,15 +145,6 @@ func (m *Model) ensureVisible(visible int) {
146 }145 }
147 }146 }
148 147
149-func cloneSnapshot(s scopy.Snapshot) scopy.Snapshot {
150- clone := s
151- clone.Rows = make([][]renderer.Cell, len(s.Rows))
152- for i, row := range s.Rows {
153- clone.Rows[i] = append([]renderer.Cell(nil), row...)
154- }
155- return clone
156-}
157-
158 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {148 func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame {
159 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))149 frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0))
160 if frame.Width == 0 || frame.Height == 0 {150 if frame.Width == 0 || frame.Height == 0 {
M internal/usecase/visualsearch/model_test.go +91 -3
@@ -11,6 +11,8 @@ import (
1111 "github.com/bnema/vev/pkg/renderer"
1212 )
1313
14+var benchmarkModelSink *Model
15+
1416 func testSnapshot(lines ...string) scopy.Snapshot {
1517 rows := make([][]renderer.Cell, len(lines))
1618 for i, line := range lines {
@@ -20,7 +22,7 @@ func testSnapshot(lines ...string) scopy.Snapshot {
2022 rows[i][x] = renderer.Cell{Rune: r}
2123 }
2224 }
23- return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}
25+ return scopy.NewSnapshotFromRows(rows, 40, 4)
2426 }
2527
2628 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
@@ -45,18 +47,36 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
4547 require.Equal(t, matches[1], selected)
4648 }
4749
48-func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {
50+func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
51+ rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
52+ m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
53+
54+ rows[0][0].Rune = 'z'
55+ for _, r := range "alpha" {
56+ m.Insert(r)
57+ }
58+
59+ matches := m.Matches()
60+ require.Len(t, matches, 1)
61+ require.Equal(t, "alpha", matches[0].Text)
62+}
63+
64+func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
4965 m := New(testSnapshot("alpha", "beta alpha"))
5066 for _, r := range "alpha" {
5167 m.Insert(r)
5268 }
5369 clone := m.Clone()
5470
71+ require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
72+ clone.Down()
5573 clone.Insert('z')
5674
5775 require.Equal(t, "alpha", m.Query())
58- require.Equal(t, "alphaz", clone.Query())
76+ require.Equal(t, 0, m.SelectedIndex())
5977 require.Len(t, m.Matches(), 2)
78+ require.Equal(t, "alphaz", clone.Query())
79+ require.Equal(t, -1, clone.SelectedIndex())
6080 require.Len(t, clone.Matches(), 0)
6181 }
6282
@@ -87,6 +107,74 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
87107 require.Contains(t, text, "2:6 beta alpha")
88108 }
89109
110+func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
111+ if testing.Short() {
112+ t.Skip("skipping benchmark-driven allocation gate in short mode")
113+ }
114+ narrow := benchmarkVisualSearchCloneBytes(16)
115+ wide := benchmarkVisualSearchCloneBytes(512)
116+ assertWidthIndependentCloneAllocations(t, narrow, wide)
117+}
118+
119+// The comparison holds row and match counts equal. Model.Clone must copy only
120+// mutable UI/match state, so a document-row clone is observable as width-scaled
121+// B/op without relying on a Go-version-specific absolute allocation budget.
122+func benchmarkVisualSearchCloneBytes(width int) int64 {
123+ const rows = 256
124+
125+ line := strings.Repeat("x", width-7) + "needle"
126+ lines := make([]string, rows)
127+ for i := range lines {
128+ lines[i] = line
129+ }
130+ model := New(testSnapshot(lines...))
131+ for _, r := range "needle" {
132+ model.Insert(r)
133+ }
134+ if got := len(model.Matches()); got != rows {
135+ panic("visual-search allocation gate requires one match per row")
136+ }
137+
138+ result := testing.Benchmark(func(b *testing.B) {
139+ b.ReportAllocs()
140+ b.ResetTimer()
141+ for b.Loop() {
142+ benchmarkModelSink = model.Clone()
143+ }
144+ })
145+ return result.AllocedBytesPerOp()
146+}
147+
148+func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
149+ t.Helper()
150+ const conservativeWidthTolerance = 2
151+ if wide > narrow*conservativeWidthTolerance {
152+ t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
153+ }
154+}
155+
156+func BenchmarkVisualSearchClone10KRows(b *testing.B) {
157+ const historyRows = 10_000
158+
159+ lines := make([]string, historyRows)
160+ for i := range lines {
161+ lines[i] = "immutable history row"
162+ }
163+ model := New(testSnapshot(lines...))
164+ for _, r := range "history" {
165+ model.Insert(r)
166+ }
167+ if got := len(model.Matches()); got != historyRows {
168+ b.Fatalf("representative query matches = %d, want %d", got, historyRows)
169+ }
170+
171+ b.ReportAllocs()
172+ b.ResetTimer()
173+ for b.Loop() {
174+ benchmarkModelSink = model.Clone()
175+ }
176+}
177+
90178 func rowText(row []renderer.Cell) string {
91179 var b strings.Builder
92180 for _, c := range row {
@@ -11,6 +11,8 @@ import (
11 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14+var benchmarkModelSink *Model
15+
14 func testSnapshot(lines ...string) scopy.Snapshot {16 func testSnapshot(lines ...string) scopy.Snapshot {
15 rows := make([][]renderer.Cell, len(lines))17 rows := make([][]renderer.Cell, len(lines))
16 for i, line := range lines {18 for i, line := range lines {
@@ -20,7 +22,7 @@ func testSnapshot(lines ...string) scopy.Snapshot {
20 rows[i][x] = renderer.Cell{Rune: r}22 rows[i][x] = renderer.Cell{Rune: r}
21 }23 }
22 }24 }
23- return scopy.Snapshot{Rows: rows, Width: 40, Height: 4}25+ return scopy.NewSnapshotFromRows(rows, 40, 4)
24 }26 }
25 27
26 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {28 func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
@@ -45,18 +47,36 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
45 require.Equal(t, matches[1], selected)47 require.Equal(t, matches[1], selected)
46 }48 }
47 49
48-func TestVisualSearchCloneDoesNotAliasInput(t *testing.T) {50+func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
51+ rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
52+ m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
53+
54+ rows[0][0].Rune = 'z'
55+ for _, r := range "alpha" {
56+ m.Insert(r)
57+ }
58+
59+ matches := m.Matches()
60+ require.Len(t, matches, 1)
61+ require.Equal(t, "alpha", matches[0].Text)
62+}
63+
64+func TestVisualSearchCloneSharesDocumentAndKeepsUIStateIndependent(t *testing.T) {
49 m := New(testSnapshot("alpha", "beta alpha"))65 m := New(testSnapshot("alpha", "beta alpha"))
50 for _, r := range "alpha" {66 for _, r := range "alpha" {
51 m.Insert(r)67 m.Insert(r)
52 }68 }
53 clone := m.Clone()69 clone := m.Clone()
54 70
71+ require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0])
72+ clone.Down()
55 clone.Insert('z')73 clone.Insert('z')
56 74
57 require.Equal(t, "alpha", m.Query())75 require.Equal(t, "alpha", m.Query())
58- require.Equal(t, "alphaz", clone.Query())76+ require.Equal(t, 0, m.SelectedIndex())
59 require.Len(t, m.Matches(), 2)77 require.Len(t, m.Matches(), 2)
78+ require.Equal(t, "alphaz", clone.Query())
79+ require.Equal(t, -1, clone.SelectedIndex())
60 require.Len(t, clone.Matches(), 0)80 require.Len(t, clone.Matches(), 0)
61 }81 }
62 82
@@ -87,6 +107,74 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
87 require.Contains(t, text, "2:6 beta alpha")107 require.Contains(t, text, "2:6 beta alpha")
88 }108 }
89 109
110+func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
111+ if testing.Short() {
112+ t.Skip("skipping benchmark-driven allocation gate in short mode")
113+ }
114+ narrow := benchmarkVisualSearchCloneBytes(16)
115+ wide := benchmarkVisualSearchCloneBytes(512)
116+ assertWidthIndependentCloneAllocations(t, narrow, wide)
117+}
118+
119+// The comparison holds row and match counts equal. Model.Clone must copy only
120+// mutable UI/match state, so a document-row clone is observable as width-scaled
121+// B/op without relying on a Go-version-specific absolute allocation budget.
122+func benchmarkVisualSearchCloneBytes(width int) int64 {
123+ const rows = 256
124+
125+ line := strings.Repeat("x", width-7) + "needle"
126+ lines := make([]string, rows)
127+ for i := range lines {
128+ lines[i] = line
129+ }
130+ model := New(testSnapshot(lines...))
131+ for _, r := range "needle" {
132+ model.Insert(r)
133+ }
134+ if got := len(model.Matches()); got != rows {
135+ panic("visual-search allocation gate requires one match per row")
136+ }
137+
138+ result := testing.Benchmark(func(b *testing.B) {
139+ b.ReportAllocs()
140+ b.ResetTimer()
141+ for b.Loop() {
142+ benchmarkModelSink = model.Clone()
143+ }
144+ })
145+ return result.AllocedBytesPerOp()
146+}
147+
148+func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
149+ t.Helper()
150+ const conservativeWidthTolerance = 2
151+ if wide > narrow*conservativeWidthTolerance {
152+ t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
153+ }
154+}
155+
156+func BenchmarkVisualSearchClone10KRows(b *testing.B) {
157+ const historyRows = 10_000
158+
159+ lines := make([]string, historyRows)
160+ for i := range lines {
161+ lines[i] = "immutable history row"
162+ }
163+ model := New(testSnapshot(lines...))
164+ for _, r := range "history" {
165+ model.Insert(r)
166+ }
167+ if got := len(model.Matches()); got != historyRows {
168+ b.Fatalf("representative query matches = %d, want %d", got, historyRows)
169+ }
170+
171+ b.ReportAllocs()
172+ b.ResetTimer()
173+ for b.Loop() {
174+ benchmarkModelSink = model.Clone()
175+ }
176+}
177+
90 func rowText(row []renderer.Cell) string {178 func rowText(row []renderer.Cell) string {
91 var b strings.Builder179 var b strings.Builder
92 for _, c := range row {180 for _, c := range row {

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