Merge pull request #69 from bnema/perf/immutable-history-baseline
fix(daemon): share immutable history views
24 files · +1219 -240
M docs/performance.md +85 -25
| @@ -1,43 +1,103 @@ | |||
| 1 | 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 | 27 | ```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 | |
| 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 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. | |
| 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 | 54 | ```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 | |
| 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 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: | |
| 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 | | |
| 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 | |
| 38 | 90 | ||
| 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 | ```sh | 27 | ```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 | |||
| 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 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. | |||
| 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 | ```sh | 54 | ```sh | ||
| 18 | - | VEV_PPROF_ADDR=127.0.0.1:6060 strace -ff -c -o vev-daemon.strace vev --daemon | 55 | + | { |
| 19 | - | strace -ff -c -o vev-client.strace vev new perf-manual | 56 | + | 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 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: | |||
| 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/base64" | |
| 5 | + | "slices" | |
| 5 | 6 | "strings" | |
| 6 | 7 | "unicode" | |
| 7 | 8 | ||
| @@ -15,29 +16,52 @@ import ( | |||
| 15 | 16 | // specific continuation protocol is supported. | |
| 16 | 17 | 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 | 22 | 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 | |
| 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 | 30 | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |
| 27 | - | scrollRows := 0 | |
| 31 | + | var history HistoryView | |
| 28 | 32 | if sb != nil { | |
| 29 | - | scrollRows = sb.Len() | |
| 33 | + | history = sb.View() | |
| 30 | 34 | } | |
| 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 | |
| 36 | 56 | } | |
| 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) | |
| 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 | 67 | // Mode stores per-client scrollback viewport and line-selection state. | |
| @@ -60,8 +84,8 @@ type Mode struct { | |||
| 60 | 84 | ||
| 61 | 85 | func NewMode(s Snapshot) *Mode { | |
| 62 | 86 | 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) | |
| 65 | 89 | m.clamp(s) | |
| 66 | 90 | return m | |
| 67 | 91 | } | |
| @@ -69,7 +93,7 @@ func NewMode(s Snapshot) *Mode { | |||
| 69 | 93 | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | |
| 70 | 94 | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 71 | 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 | 98 | func FindMatches(s Snapshot, query string) []SearchMatch { | |
| 75 | 99 | query = strings.TrimSpace(query) | |
| @@ -78,11 +102,12 @@ func FindMatches(s Snapshot, query string) []SearchMatch { | |||
| 78 | 102 | } | |
| 79 | 103 | needle := lowerRunes(query) | |
| 80 | 104 | matches := make([]SearchMatch, 0) | |
| 81 | - | for row, cells := range s.Rows { | |
| 105 | + | for row := range s.Len() { | |
| 106 | + | cells := s.Row(row) | |
| 82 | 107 | haystack, cellIndexes := searchableCells(cells) | |
| 83 | 108 | var text string | |
| 84 | 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 | 111 | start++ | |
| 87 | 112 | continue | |
| 88 | 113 | } | |
| @@ -126,18 +151,6 @@ func lowerRunes(s string) []rune { | |||
| 126 | 151 | 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 | 154 | func (m *Mode) Search(s Snapshot, query string) bool { | |
| 142 | 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 | 203 | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | |
| 191 | 204 | ||
| 192 | 205 | func (m *Mode) AtBottom(s Snapshot) bool { | |
| 193 | - | total := len(s.Rows) | |
| 206 | + | total := s.Len() | |
| 194 | 207 | if total == 0 { | |
| 195 | 208 | 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 | 235 | func (m *Mode) clamp(s Snapshot) { | |
| 223 | - | total := len(s.Rows) | |
| 236 | + | total := s.Len() | |
| 224 | 237 | if total == 0 { | |
| 225 | 238 | m.Cursor, m.ViewportTop = 0, 0 | |
| 226 | 239 | return | |
| @@ -243,14 +256,14 @@ func (m *Mode) SelectedBounds() (int, int, bool) { | |||
| 243 | 256 | ||
| 244 | 257 | func (m *Mode) SelectedText(s Snapshot) string { | |
| 245 | 258 | lo, hi, ok := m.SelectedBounds() | |
| 246 | - | if !ok || len(s.Rows) == 0 { | |
| 259 | + | if !ok || s.Len() == 0 { | |
| 247 | 260 | return "" | |
| 248 | 261 | } | |
| 249 | 262 | lo = max(lo, 0) | |
| 250 | - | hi = min(hi, len(s.Rows)-1) | |
| 263 | + | hi = min(hi, s.Len()-1) | |
| 251 | 264 | lines := make([]string, 0, hi-lo+1) | |
| 252 | 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 | 268 | return strings.Join(lines, "\n") | |
| 256 | 269 | } | |
| @@ -262,11 +275,11 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||
| 262 | 275 | selectionStyle, hasSelectionStyle := optionalStyle(styles, 1) | |
| 263 | 276 | for y := range s.Height { | |
| 264 | 277 | src := m.ViewportTop + y | |
| 265 | - | if src >= len(s.Rows) { | |
| 278 | + | if src >= s.Len() { | |
| 266 | 279 | break | |
| 267 | 280 | } | |
| 268 | 281 | row := frame.Row(y) | |
| 269 | - | copy(row, s.Rows[src]) | |
| 282 | + | copy(row, s.Row(src)) | |
| 270 | 283 | lineSelected := selected && src >= lo && src <= hi | |
| 271 | 284 | if lineSelected { | |
| 272 | 285 | for x := range row { | |
| @@ -286,7 +299,7 @@ func (m *Mode) Render(s Snapshot, styles ...renderer.Style) renderer.Frame { | |||
| 286 | 299 | if len(styles) > 0 { | |
| 287 | 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 | 303 | return frame | |
| 291 | 304 | } | |
| 292 | 305 | ||
| @@ -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_000 | 17 | 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.Cell | 23 | + | history HistoryView |
| 21 | - | Width int | 24 | + | screen renderer.Frame |
| 22 | - | Height int | 25 | + | 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 := 0 | 31 | + | 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 m | 90 | 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 string | 108 | 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 | continue | 112 | continue | ||
| 88 | } | 113 | } | ||
| @@ -126,18 +151,6 @@ func lowerRunes(s string) []rune { | |||||
| 126 | return runes | 151 | 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 == 0 | 208 | 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, 0 | 238 | m.Cursor, m.ViewportTop = 0, 0 | ||
| 226 | return | 239 | 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 + y | 277 | src := m.ViewportTop + y | ||
| 265 | - | if src >= len(s.Rows) { | 278 | + | if src >= s.Len() { |
| 266 | break | 279 | 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 <= hi | 283 | 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 frame | 303 | return frame | ||
| 291 | } | 304 | } | ||
| 292 | 305 | ||||
M internal/usecase/copy/mode_test.go +29 -5
| @@ -10,12 +10,14 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | var benchmarkSnapshotSink Snapshot | |
| 14 | + | ||
| 13 | 15 | func snapshot(lines []string, height int) Snapshot { | |
| 14 | 16 | rows := make([][]renderer.Cell, len(lines)) | |
| 15 | 17 | for i, line := range lines { | |
| 16 | 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 | 23 | func inverseRow(text string) []renderer.Cell { | |
| @@ -225,8 +227,7 @@ func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |||
| 225 | 227 | } | |
| 226 | 228 | ||
| 227 | 229 | 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) | |
| 230 | 231 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 231 | 232 | m.Move(s, 1) | |
| 232 | 233 | ||
| @@ -274,7 +275,7 @@ func TestCopyModeSelectionFallbackKeepsAlreadyInverseCellInverse(t *testing.T) { | |||
| 274 | 275 | // flip it back to non-inverse and hide the highlight. | |
| 275 | 276 | rows := [][]renderer.Cell{row("alpha "), row("beta ")} | |
| 276 | 277 | rows[0][0].Style.Inverse = true | |
| 277 | - | s := Snapshot{Rows: rows, Width: 16, Height: 2} | |
| 278 | + | s := NewSnapshotFromRows(rows, 16, 2) | |
| 278 | 279 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 279 | 280 | ||
| 280 | 281 | frame := m.Render(s) | |
| @@ -365,7 +366,7 @@ func TestCopyModeSearchUsesDisplayCellOffsetsForWideRows(t *testing.T) { | |||
| 365 | 366 | {Rune: 'h'}, | |
| 366 | 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 | 371 | wideMatches := FindMatches(s, "界") | |
| 371 | 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 | 504 | func decodeOSC52Chunk(t *testing.T, chunk []byte) []byte { | |
| 481 | 505 | t.Helper() | |
| 482 | 506 | 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 = true | 277 | 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 { | |||
| 56 | 56 | return rows | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 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) { | |
| 62 | 80 | return nil | |
| 63 | 81 | } | |
| 64 | - | row := s.rows[(s.head+i)%len(s.rows)] | |
| 65 | - | return append([]renderer.Cell(nil), row...) | |
| 82 | + | return v.rows[i] | |
| 66 | 83 | } | |
| @@ -56,11 +56,28 @@ func (s *Scrollback) Snapshot() [][]renderer.Cell { | |||||
| 56 | return rows | 56 | 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 nil | 80 | 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) { | |||
| 82 | 82 | t.Fatalf("Len() = %d, want %d", got, tt.wantLen) | |
| 83 | 83 | } | |
| 84 | 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 | 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 | 103 | sb.Append(r) | |
| 104 | 104 | r[0].Rune = 'z' | |
| 105 | 105 | ||
| 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) | |
| 110 | 108 | } | |
| 111 | 109 | }) | |
| 112 | 110 | } | |
| @@ -151,7 +149,7 @@ func TestScrollbackSnapshot(t *testing.T) { | |||
| 151 | 149 | } | |
| 152 | 150 | if len(got) > 0 { | |
| 153 | 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 | 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 | 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 | + | } | |
| @@ -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) { | |||
| 578 | 578 | if !sz.Valid() { | |
| 579 | 579 | return | |
| 580 | 580 | } | |
| 581 | + | d.exitCopyMode(ac) | |
| 581 | 582 | tbSize := tabSize(sz) | |
| 582 | 583 | if ac != nil { | |
| 583 | 584 | 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 | return | 579 | 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 = sz | 584 | ac.size = sz | ||
M internal/usecase/daemon/copymode.go +77 -67
| @@ -45,59 +45,77 @@ func copyTargetPane(rt *overlayRuntime) *pane { | |||
| 45 | 45 | } | |
| 46 | 46 | rt.copyMu.Lock() | |
| 47 | 47 | defer rt.copyMu.Unlock() | |
| 48 | + | if rt.copyMode == nil { | |
| 49 | + | return nil | |
| 50 | + | } | |
| 48 | 51 | return rt.copyPane | |
| 49 | 52 | } | |
| 50 | 53 | ||
| 51 | 54 | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 52 | 55 | rt := ac.overlays | |
| 53 | - | p := copyTargetPane(rt) | |
| 54 | - | if p == nil { | |
| 55 | - | return | |
| 56 | - | } | |
| 57 | - | p.mu.Lock() | |
| 58 | 56 | rt.copyMu.Lock() | |
| 59 | - | if rt.copyMode == nil || rt.copyPane != p { | |
| 57 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil { | |
| 60 | 58 | rt.copyMu.Unlock() | |
| 61 | - | p.mu.Unlock() | |
| 62 | 59 | return | |
| 63 | 60 | } | |
| 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) { | |
| 66 | 63 | rt.clearCopyModeLocked() | |
| 67 | 64 | rt.copyMu.Unlock() | |
| 68 | - | p.mu.Unlock() | |
| 69 | 65 | d.paint(sess, ac, true) | |
| 70 | 66 | return | |
| 71 | 67 | } | |
| 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) | |
| 74 | 70 | if exit { | |
| 75 | 71 | rt.clearCopyModeLocked() | |
| 76 | 72 | } | |
| 77 | 73 | rt.copyMu.Unlock() | |
| 78 | - | p.mu.Unlock() | |
| 79 | 74 | ||
| 80 | 75 | d.paint(sess, ac, true) | |
| 81 | 76 | } | |
| 82 | 77 | ||
| 83 | 78 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 84 | - | rt := ac.overlays | |
| 85 | 79 | tb := sess.activeTab() | |
| 86 | 80 | if tb == nil { | |
| 87 | 81 | return | |
| 88 | 82 | } | |
| 89 | 83 | tb.mu.Lock() | |
| 90 | 84 | p := tb.terminalTargetLocked() | |
| 91 | - | floatingSource := tb.floating.state == floatingVisible && tb.floating.pane == p | |
| 92 | 85 | tb.mu.Unlock() | |
| 93 | 86 | if p == nil { | |
| 94 | 87 | 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 | 91 | p.mu.Lock() | |
| 97 | - | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 92 | + | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 98 | 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 | 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 | 119 | rt.copyPane = p | |
| 102 | 120 | rt.copySearch = nil | |
| 103 | 121 | rt.copySearchPending = nil | |
| @@ -105,15 +123,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 105 | 123 | rt.copyDragging = false | |
| 106 | 124 | rt.normalMousePressValid = false | |
| 107 | 125 | rt.copyMu.Unlock() | |
| 108 | - | if floatingSource { | |
| 126 | + | ||
| 127 | + | active := sess.activeTab() | |
| 128 | + | valid := active == tb | |
| 129 | + | if valid { | |
| 109 | 130 | 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) | |
| 111 | 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 | 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 | 151 | if ev.Button != mouse.Left { | |
| 122 | 152 | return | |
| 123 | 153 | } | |
| 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() | |
| 126 | 157 | return | |
| 127 | 158 | } | |
| 128 | - | p.mu.Lock() | |
| 129 | - | if ev.Row >= p.screen.Frame.Height { | |
| 159 | + | document := rt.copySnapshot | |
| 160 | + | if ev.Row >= document.Height { | |
| 130 | 161 | if ev.Type == mouse.Press { | |
| 131 | - | rt.copyMu.Lock() | |
| 132 | 162 | rt.copyPressRowValid = false | |
| 133 | 163 | 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 | 165 | rt.copyMu.Unlock() | |
| 142 | - | p.mu.Unlock() | |
| 143 | 166 | return | |
| 144 | 167 | } | |
| 145 | - | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 146 | 168 | absRow := rt.copyMode.ViewportTop + ev.Row | |
| 147 | 169 | changed := false | |
| 148 | 170 | switch ev.Type { | |
| 149 | 171 | case mouse.Press: | |
| 150 | - | rt.copyMode.SetCursor(snap, absRow) | |
| 172 | + | rt.copyMode.SetCursor(*document, absRow) | |
| 151 | 173 | rt.copyPressRow = rt.copyMode.Cursor | |
| 152 | 174 | rt.copyPressRowValid = true | |
| 153 | 175 | rt.copyDragging = false | |
| @@ -157,16 +179,15 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 157 | 179 | break | |
| 158 | 180 | } | |
| 159 | 181 | if !rt.copyDragging { | |
| 160 | - | rt.copyMode.StartSelectionAt(snap, rt.copyPressRow) | |
| 182 | + | rt.copyMode.StartSelectionAt(*document, rt.copyPressRow) | |
| 161 | 183 | rt.copyDragging = true | |
| 162 | 184 | } | |
| 163 | - | rt.copyMode.ExtendTo(snap, absRow) | |
| 185 | + | rt.copyMode.ExtendTo(*document, absRow) | |
| 164 | 186 | changed = true | |
| 165 | 187 | case mouse.Release: | |
| 166 | 188 | // Button release intentionally has no visual effect. | |
| 167 | 189 | } | |
| 168 | 190 | rt.copyMu.Unlock() | |
| 169 | - | p.mu.Unlock() | |
| 170 | 191 | ||
| 171 | 192 | if changed { | |
| 172 | 193 | d.paint(sess, ac, true) | |
| @@ -179,17 +200,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 179 | 200 | if sess == nil { | |
| 180 | 201 | return | |
| 181 | 202 | } | |
| 182 | - | p := copyTargetPane(rt) | |
| 183 | - | if p == nil { | |
| 184 | - | return | |
| 185 | - | } | |
| 186 | - | p.mu.Lock() | |
| 187 | 203 | rt.copyMu.Lock() | |
| 188 | - | if rt.copyMode == nil || rt.copyPane != p { | |
| 204 | + | if rt.copyMode == nil || rt.copyPane == nil || rt.copySnapshot == nil { | |
| 189 | 205 | rt.copyPending = nil | |
| 190 | 206 | d.stopCopyPendingTimerLocked(ac) | |
| 191 | 207 | rt.copyMu.Unlock() | |
| 192 | - | p.mu.Unlock() | |
| 193 | 208 | return | |
| 194 | 209 | } | |
| 195 | 210 | if len(rt.copyPending) > 0 { | |
| @@ -200,11 +215,10 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 200 | 215 | data = combined | |
| 201 | 216 | rt.copyPending = nil | |
| 202 | 217 | } | |
| 203 | - | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 218 | + | document := rt.copySnapshot | |
| 204 | 219 | if rt.copySearch != nil { | |
| 205 | - | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, snap, data) | |
| 220 | + | changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data) | |
| 206 | 221 | rt.copyMu.Unlock() | |
| 207 | - | p.mu.Unlock() | |
| 208 | 222 | if changed || closeSearch || accepted { | |
| 209 | 223 | d.paint(sess, ac, true) | |
| 210 | 224 | } | |
| @@ -216,40 +230,40 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 216 | 230 | for i := 0; i < len(data); i++ { | |
| 217 | 231 | switch data[i] { | |
| 218 | 232 | case 'j': | |
| 219 | - | rt.copyMode.Move(snap, 1) | |
| 233 | + | rt.copyMode.Move(*document, 1) | |
| 220 | 234 | changed = true | |
| 221 | 235 | case 'k': | |
| 222 | - | rt.copyMode.Move(snap, -1) | |
| 236 | + | rt.copyMode.Move(*document, -1) | |
| 223 | 237 | changed = true | |
| 224 | 238 | case 'g': | |
| 225 | - | rt.copyMode.Top(snap) | |
| 239 | + | rt.copyMode.Top(*document) | |
| 226 | 240 | changed = true | |
| 227 | 241 | case 'G': | |
| 228 | - | rt.copyMode.Bottom(snap) | |
| 242 | + | rt.copyMode.Bottom(*document) | |
| 229 | 243 | changed = true | |
| 230 | 244 | case ' ', 'v': | |
| 231 | 245 | rt.copyMode.ToggleSelection() | |
| 232 | 246 | changed = true | |
| 233 | 247 | case '/': | |
| 234 | - | rt.copySearch = visualsearch.New(snap) | |
| 248 | + | rt.copySearch = visualsearch.New(*document) | |
| 235 | 249 | rt.copySearchPending = nil | |
| 236 | 250 | changed = true | |
| 237 | 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 | 253 | changed = changed || searchChanged || accepted | |
| 240 | 254 | } | |
| 241 | 255 | i = len(data) | |
| 242 | 256 | case 'n': | |
| 243 | - | changed = rt.copyMode.NextSearchMatch(snap, 1) || changed | |
| 257 | + | changed = rt.copyMode.NextSearchMatch(*document, 1) || changed | |
| 244 | 258 | case 'N': | |
| 245 | - | changed = rt.copyMode.NextSearchMatch(snap, -1) || changed | |
| 259 | + | changed = rt.copyMode.NextSearchMatch(*document, -1) || changed | |
| 246 | 260 | case '\r', '\n', 'y': | |
| 247 | 261 | copyOut = true | |
| 248 | 262 | exit = true | |
| 249 | 263 | case 'q', 0x03, 0x1b: | |
| 250 | 264 | if data[i] == 0x1b { | |
| 251 | 265 | tail := data[i:] | |
| 252 | - | consumed, ok := routeCopyEscape(rt.copyMode, snap, tail) | |
| 266 | + | consumed, ok := routeCopyEscape(rt.copyMode, *document, tail) | |
| 253 | 267 | if ok { | |
| 254 | 268 | i += consumed - 1 | |
| 255 | 269 | changed = true | |
| @@ -269,14 +283,13 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 269 | 283 | } | |
| 270 | 284 | text := "" | |
| 271 | 285 | if copyOut { | |
| 272 | - | text = rt.copyMode.SelectedText(snap) | |
| 286 | + | text = rt.copyMode.SelectedText(*document) | |
| 273 | 287 | } | |
| 274 | 288 | if exit { | |
| 275 | 289 | d.stopCopyPendingTimerLocked(ac) | |
| 276 | 290 | rt.clearCopyModeLocked() | |
| 277 | 291 | } | |
| 278 | 292 | rt.copyMu.Unlock() | |
| 279 | - | p.mu.Unlock() | |
| 280 | 293 | ||
| 281 | 294 | if copyOut && text != "" { | |
| 282 | 295 | chunks := scopy.OSC52(text) | |
| @@ -417,15 +430,12 @@ func composeCopySearchClientFrame(model *visualsearch.Model, base renderer.Frame | |||
| 417 | 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) { | |
| 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 { | |
| 422 | 435 | return frame, nil | |
| 423 | 436 | } | |
| 424 | 437 | 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) | |
| 429 | 439 | bodyRows := max(copyFrame.Height-1, 0) | |
| 430 | 440 | for y := 0; y < target.Height && y < bodyRows && target.Y+y < frame.Height-1; y++ { | |
| 431 | 441 | 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.copyPane | 51 | 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.overlays | 55 | 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 | return | 59 | 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 | return | 66 | 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 | return | 81 | 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 | return | 87 | 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 = p | 119 | rt.copyPane = p | ||
| 102 | rt.copySearch = nil | 120 | rt.copySearch = nil | ||
| 103 | rt.copySearchPending = nil | 121 | rt.copySearchPending = nil | ||
| @@ -105,15 +123,27 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 105 | rt.copyDragging = false | 123 | rt.copyDragging = false | ||
| 106 | rt.normalMousePressValid = false | 124 | 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 == p | 131 | + | 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 | return | 152 | 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 | return | 157 | 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 = false | 162 | rt.copyPressRowValid = false | ||
| 133 | rt.copyDragging = false | 163 | 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 | return | 166 | return | ||
| 144 | } | 167 | } | ||
| 145 | - | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |||
| 146 | absRow := rt.copyMode.ViewportTop + ev.Row | 168 | absRow := rt.copyMode.ViewportTop + ev.Row | ||
| 147 | changed := false | 169 | 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.Cursor | 173 | rt.copyPressRow = rt.copyMode.Cursor | ||
| 152 | rt.copyPressRowValid = true | 174 | rt.copyPressRowValid = true | ||
| 153 | rt.copyDragging = false | 175 | rt.copyDragging = false | ||
| @@ -157,16 +179,15 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||||
| 157 | break | 179 | 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 = true | 183 | rt.copyDragging = true | ||
| 162 | } | 184 | } | ||
| 163 | - | rt.copyMode.ExtendTo(snap, absRow) | 185 | + | rt.copyMode.ExtendTo(*document, absRow) |
| 164 | changed = true | 186 | 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 | return | 201 | 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 = nil | 205 | rt.copyPending = nil | ||
| 190 | d.stopCopyPendingTimerLocked(ac) | 206 | d.stopCopyPendingTimerLocked(ac) | ||
| 191 | rt.copyMu.Unlock() | 207 | rt.copyMu.Unlock() | ||
| 192 | - | p.mu.Unlock() | |||
| 193 | return | 208 | 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 = combined | 215 | data = combined | ||
| 201 | rt.copyPending = nil | 216 | 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 = true | 234 | changed = true | ||
| 221 | case 'k': | 235 | case 'k': | ||
| 222 | - | rt.copyMode.Move(snap, -1) | 236 | + | rt.copyMode.Move(*document, -1) |
| 223 | changed = true | 237 | changed = true | ||
| 224 | case 'g': | 238 | case 'g': | ||
| 225 | - | rt.copyMode.Top(snap) | 239 | + | rt.copyMode.Top(*document) |
| 226 | changed = true | 240 | changed = true | ||
| 227 | case 'G': | 241 | case 'G': | ||
| 228 | - | rt.copyMode.Bottom(snap) | 242 | + | rt.copyMode.Bottom(*document) |
| 229 | changed = true | 243 | changed = true | ||
| 230 | case ' ', 'v': | 244 | case ' ', 'v': | ||
| 231 | rt.copyMode.ToggleSelection() | 245 | rt.copyMode.ToggleSelection() | ||
| 232 | changed = true | 246 | changed = true | ||
| 233 | case '/': | 247 | case '/': | ||
| 234 | - | rt.copySearch = visualsearch.New(snap) | 248 | + | rt.copySearch = visualsearch.New(*document) |
| 235 | rt.copySearchPending = nil | 249 | rt.copySearchPending = nil | ||
| 236 | changed = true | 250 | 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 || accepted | 253 | 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) || changed | 257 | + | changed = rt.copyMode.NextSearchMatch(*document, 1) || changed |
| 244 | case 'N': | 258 | case 'N': | ||
| 245 | - | changed = rt.copyMode.NextSearchMatch(snap, -1) || changed | 259 | + | changed = rt.copyMode.NextSearchMatch(*document, -1) || changed |
| 246 | case '\r', '\n', 'y': | 260 | case '\r', '\n', 'y': | ||
| 247 | copyOut = true | 261 | copyOut = true | ||
| 248 | exit = true | 262 | 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 - 1 | 268 | i += consumed - 1 | ||
| 255 | changed = true | 269 | 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, nil | 435 | 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) { | |||
| 133 | 133 | go func() { | |
| 134 | 134 | defer wg.Done() | |
| 135 | 135 | for range 200 { | |
| 136 | - | _, _ = composeCopyClientFrame(mode, pane, target, base, bars) | |
| 136 | + | _, _ = composeCopyClientFrame(mode, &snap, target, base, bars) | |
| 137 | 137 | } | |
| 138 | 138 | }() | |
| 139 | 139 | wg.Wait() | |
| @@ -152,7 +152,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) { | |||
| 152 | 152 | ||
| 153 | 153 | bars := barState{status: sess.statusSegments()} | |
| 154 | 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 | 157 | require.Equal(t, 80, frame.Width) | |
| 158 | 158 | require.Equal(t, 25, frame.Height) | |
| @@ -412,7 +412,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 412 | 412 | } | |
| 413 | 413 | p.mu.Lock() | |
| 414 | 414 | 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 | |
| 416 | 416 | }, 2*time.Second, 5*time.Millisecond) | |
| 417 | 417 | ||
| 418 | 418 | sess := firstSession(d) | |
| @@ -546,7 +546,8 @@ func TestCopyModeLoneEscapeExitsAfterDelay(t *testing.T) { | |||
| 546 | 546 | } | |
| 547 | 547 | ||
| 548 | 548 | func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |
| 549 | - | p, _ := newBlockingPTY(t) | |
| 549 | + | p, release := newBlockingPTY(t) | |
| 550 | + | defer release() | |
| 550 | 551 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 551 | 552 | clk := &signalClock{timers: make(chan *signalTimer, 1)} | |
| 552 | 553 | d.clock = clk | |
| @@ -560,6 +561,14 @@ func TestCopyModePendingEscapeDoesNotCloseNewMode(t *testing.T) { | |||
| 560 | 561 | d.enterCopyMode(sess, ac) | |
| 561 | 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 | 572 | timer.ch <- time.Now() | |
| 564 | 573 | require.Never(t, func() bool { | |
| 565 | 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 | 643 | func TestCopyModeEnterExitConcurrentWithPaintRace(t *testing.T) { | |
| 612 | 644 | p, _ := newBlockingPTY(t) | |
| 613 | 645 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -730,7 +762,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) { | |||
| 730 | 762 | } | |
| 731 | 763 | fp.screen.Write([]byte("live")) | |
| 732 | 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 | 766 | fp.mu.Unlock() | |
| 735 | 767 | ||
| 736 | 768 | 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) >= 12 | 415 | + | 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 = clk | 553 | 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 } | |||
| 63 | 63 | func (t *signalTimer) Reset(time.Duration) bool { return false } | |
| 64 | 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 | 67 | t.Helper() | |
| 68 | 68 | d := New(ptys, clk, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 69 | 69 | d.serveCtx, d.serveCancel = context.WithCancel(context.Background()) | |
| @@ -286,7 +286,7 @@ func listSessions(t *testing.T, d *Daemon) ports.Sessions { | |||
| 286 | 286 | 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 | 290 | t.Helper() | |
| 291 | 291 | tr := portsmocks.NewMockTransport(t) | |
| 292 | 292 | sends := make(chan ports.Frame, 64) | |
| @@ -298,7 +298,7 @@ func newCapturingTransport(t *testing.T) (*portsmocks.MockTransport, chan ports. | |||
| 298 | 298 | 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 | 302 | t.Helper() | |
| 303 | 303 | d := newTestDaemon(t, nil, stubClock{}) | |
| 304 | 304 | 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 sessions | 286 | 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, sends | 298 | 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) { | |||
| 162 | 162 | // until firstPaint keeps restored tabs cold and avoids launching children | |
| 163 | 163 | // merely because a tab was manipulated during teardown. | |
| 164 | 164 | sess.mu.Lock() | |
| 165 | - | attached := sess.client != nil | |
| 165 | + | ac := sess.client | |
| 166 | 166 | sess.mu.Unlock() | |
| 167 | - | if !attached { | |
| 167 | + | d.exitCopyMode(ac) | |
| 168 | + | if ac == nil { | |
| 168 | 169 | return | |
| 169 | 170 | } | |
| 170 | 171 | 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 children | 162 | // 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 != nil | 165 | + | ac := sess.client |
| 166 | sess.mu.Unlock() | 166 | sess.mu.Unlock() | ||
| 167 | - | if !attached { | 167 | + | d.exitCopyMode(ac) |
| 168 | + | if ac == nil { | |||
| 168 | return | 169 | 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, | |||
| 270 | 270 | return | |
| 271 | 271 | } | |
| 272 | 272 | ||
| 273 | + | // The immutable snapshot is the only live-pane access needed for drag entry. | |
| 273 | 274 | p.mu.Lock() | |
| 274 | - | snap := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 275 | + | document := scopy.NewSnapshot(p.scrollback, p.screen.Frame) | |
| 275 | 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 | 284 | 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 | + | } | |
| 287 | 292 | rt.copyMu.Unlock() | |
| 288 | 293 | d.paint(sess, ac, true) | |
| 289 | 294 | case mouse.Release: | |
| @@ -270,20 +270,25 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 270 | return | 270 | 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 = mode | 288 | + | rt.copyPressRow = pressTop + pressRow |
| 282 | - | rt.copyPane = p | 289 | + | rt.copyPressRowValid = true |
| 283 | - | rt.copyPressRow = pressTop + pressRow | 290 | + | rt.copyDragging = true |
| 284 | - | rt.copyPressRowValid = true | 291 | + | } |
| 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) { | |||
| 143 | 143 | return | |
| 144 | 144 | } | |
| 145 | 145 | rt := ac.overlays | |
| 146 | + | if rt == nil { | |
| 147 | + | return | |
| 148 | + | } | |
| 146 | 149 | rt.copyMu.Lock() | |
| 147 | 150 | rt.clearCopyModeLocked() | |
| 148 | 151 | rt.copyPressRowValid = false | |
| @@ -143,6 +143,9 @@ func (d *Daemon) exitCopyMode(ac *attachedClient) { | |||||
| 143 | return | 143 | return | ||
| 144 | } | 144 | } | ||
| 145 | rt := ac.overlays | 145 | 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 = false | 151 | rt.copyPressRowValid = false | ||
M internal/usecase/daemon/overlay_runtime.go +7 -1
| @@ -30,6 +30,8 @@ type overlayRuntime struct { | |||
| 30 | 30 | ||
| 31 | 31 | copyMu sync.Mutex | |
| 32 | 32 | copyMode *scopy.Mode | |
| 33 | + | copyCandidate *scopy.Mode | |
| 34 | + | copySnapshot *scopy.Snapshot | |
| 33 | 35 | copyPane *pane | |
| 34 | 36 | copyPending []byte | |
| 35 | 37 | copyESC pendingByteTimer | |
| @@ -102,6 +104,8 @@ func (rt *overlayRuntime) copySearchActive() bool { | |||
| 102 | 104 | ||
| 103 | 105 | func (rt *overlayRuntime) clearCopyModeLocked() { | |
| 104 | 106 | rt.copyMode = nil | |
| 107 | + | rt.copyCandidate = nil | |
| 108 | + | rt.copySnapshot = nil | |
| 105 | 109 | rt.copyPane = nil | |
| 106 | 110 | rt.copySearch = nil | |
| 107 | 111 | rt.copySearchPending = nil | |
| @@ -113,7 +117,7 @@ func (rt *overlayRuntime) clearCopyModeForPane(p *pane) bool { | |||
| 113 | 117 | } | |
| 114 | 118 | rt.copyMu.Lock() | |
| 115 | 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 | 121 | return false | |
| 118 | 122 | } | |
| 119 | 123 | rt.clearCopyModeLocked() | |
| @@ -149,6 +153,7 @@ type overlayRenderSnapshot struct { | |||
| 149 | 153 | ||
| 150 | 154 | copyActive bool | |
| 151 | 155 | copyMode *scopy.Mode | |
| 156 | + | copySnapshot *scopy.Snapshot | |
| 152 | 157 | copyPane *pane | |
| 153 | 158 | copySearchModel *visualsearch.Model | |
| 154 | 159 | copyFeedback string | |
| @@ -181,6 +186,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 181 | 186 | rt.copyMu.Lock() | |
| 182 | 187 | snap.copyActive = rt.copyMode != nil | |
| 183 | 188 | snap.copyPane = rt.copyPane | |
| 189 | + | snap.copySnapshot = rt.copySnapshot | |
| 184 | 190 | if rt.copyMode != nil { | |
| 185 | 191 | copyModeValue := *rt.copyMode | |
| 186 | 192 | copyModeValue.Searches = append([]scopy.SearchMatch(nil), rt.copyMode.Searches...) | |
| @@ -30,6 +30,8 @@ type overlayRuntime struct { | |||||
| 30 | 30 | ||||
| 31 | copyMu sync.Mutex | 31 | copyMu sync.Mutex | ||
| 32 | copyMode *scopy.Mode | 32 | copyMode *scopy.Mode | ||
| 33 | + | copyCandidate *scopy.Mode | |||
| 34 | + | copySnapshot *scopy.Snapshot | |||
| 33 | copyPane *pane | 35 | copyPane *pane | ||
| 34 | copyPending []byte | 36 | copyPending []byte | ||
| 35 | copyESC pendingByteTimer | 37 | 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 = nil | 106 | rt.copyMode = nil | ||
| 107 | + | rt.copyCandidate = nil | |||
| 108 | + | rt.copySnapshot = nil | |||
| 105 | rt.copyPane = nil | 109 | rt.copyPane = nil | ||
| 106 | rt.copySearch = nil | 110 | rt.copySearch = nil | ||
| 107 | rt.copySearchPending = nil | 111 | 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 false | 121 | return false | ||
| 118 | } | 122 | } | ||
| 119 | rt.clearCopyModeLocked() | 123 | rt.clearCopyModeLocked() | ||
| @@ -149,6 +153,7 @@ type overlayRenderSnapshot struct { | |||||
| 149 | 153 | ||||
| 150 | copyActive bool | 154 | copyActive bool | ||
| 151 | copyMode *scopy.Mode | 155 | copyMode *scopy.Mode | ||
| 156 | + | copySnapshot *scopy.Snapshot | |||
| 152 | copyPane *pane | 157 | copyPane *pane | ||
| 153 | copySearchModel *visualsearch.Model | 158 | copySearchModel *visualsearch.Model | ||
| 154 | copyFeedback string | 159 | copyFeedback string | ||
| @@ -181,6 +186,7 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 181 | rt.copyMu.Lock() | 186 | rt.copyMu.Lock() | ||
| 182 | snap.copyActive = rt.copyMode != nil | 187 | snap.copyActive = rt.copyMode != nil | ||
| 183 | snap.copyPane = rt.copyPane | 188 | snap.copyPane = rt.copyPane | ||
| 189 | + | snap.copySnapshot = rt.copySnapshot | |||
| 184 | if rt.copyMode != nil { | 190 | if rt.copyMode != nil { | ||
| 185 | copyModeValue := *rt.copyMode | 191 | 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) { | |||
| 113 | 113 | func TestOverlayRuntimeSnapshotDoesNotAliasCopySearchState(t *testing.T) { | |
| 114 | 114 | ac := &attachedClient{} | |
| 115 | 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 | 117 | mode := scopy.NewMode(snap) | |
| 118 | 118 | require.True(t, mode.Search(snap, "alpha")) | |
| 119 | 119 | search := visualsearch.New(snap) | |
| @@ -145,24 +145,46 @@ func TestOverlayRuntimeHandleInputInactive(t *testing.T) { | |||
| 145 | 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 | 149 | ac := &attachedClient{} | |
| 150 | 150 | ac.initOverlays() | |
| 151 | 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 | 154 | ac.overlays.copyMu.Lock() | |
| 155 | - | ac.overlays.copyMode = scopy.NewMode(snap) | |
| 155 | + | ac.overlays.copyMode = scopy.NewMode(document) | |
| 156 | 156 | ac.overlays.copyPane = p | |
| 157 | + | ac.overlays.copySnapshot = &document | |
| 157 | 158 | ac.overlays.copyMu.Unlock() | |
| 158 | 159 | ||
| 159 | 160 | renderSnap := ac.overlays.SnapshotForRender() | |
| 160 | 161 | require.Same(t, p, renderSnap.copyPane) | |
| 162 | + | require.Same(t, &document, renderSnap.copySnapshot) | |
| 161 | 163 | renderSnap.Unlock() | |
| 162 | 164 | ||
| 163 | 165 | ac.overlays.copyMu.Lock() | |
| 164 | 166 | ac.overlays.clearCopyModeLocked() | |
| 165 | 167 | require.Nil(t, ac.overlays.copyPane) | |
| 166 | 168 | require.Nil(t, ac.overlays.copyMode) | |
| 169 | + | require.Nil(t, ac.overlays.copySnapshot) | |
| 167 | 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 | + | } | |
| @@ -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 = p | 156 | 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 | |||
| 226 | 226 | if tb == nil { | |
| 227 | 227 | return layout.ErrNotFound | |
| 228 | 228 | } | |
| 229 | + | if ac == nil { | |
| 230 | + | sess.mu.Lock() | |
| 231 | + | ac = sess.client | |
| 232 | + | sess.mu.Unlock() | |
| 233 | + | } | |
| 229 | 234 | tb.mu.Lock() | |
| 230 | 235 | p := tb.panes[id] | |
| 231 | 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 | 240 | if len(layout.LeafIDs(tb.tree.Root)) <= 1 { | |
| 236 | 241 | tb.mu.Unlock() | |
| 242 | + | if ac != nil { | |
| 243 | + | ac.overlays.clearCopyModeForPane(p) | |
| 244 | + | } | |
| 237 | 245 | d.closeTab(sess, tb, repaint) | |
| 238 | 246 | return nil | |
| 239 | 247 | } | |
| @@ -245,6 +253,10 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 245 | 253 | d.applyLayoutLocked(tb) | |
| 246 | 254 | tb.mu.Unlock() | |
| 247 | 255 | ||
| 256 | + | if ac != nil { | |
| 257 | + | ac.overlays.clearCopyModeForPane(p) | |
| 258 | + | } | |
| 259 | + | ||
| 248 | 260 | if p.cancel != nil { | |
| 249 | 261 | p.cancel() | |
| 250 | 262 | } | |
| @@ -254,11 +266,6 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache | |||
| 254 | 266 | d.log.Info("pane closed", "session", sess.name, "pane", id) | |
| 255 | 267 | markSnapshotDirty(sess) | |
| 256 | 268 | if repaint { | |
| 257 | - | if ac == nil { | |
| 258 | - | sess.mu.Lock() | |
| 259 | - | ac = sess.client | |
| 260 | - | sess.mu.Unlock() | |
| 261 | - | } | |
| 262 | 269 | if ac != nil { | |
| 263 | 270 | d.paint(sess, ac, true) | |
| 264 | 271 | } | |
| @@ -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.ErrNotFound | 227 | 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 nil | 246 | 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) { | |||
| 407 | 407 | copyPane = p | |
| 408 | 408 | } | |
| 409 | 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 | 412 | // A palette above normal/copy content dims that composed content. When a | |
| 413 | 413 | // 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 = p | 407 | 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 a | 412 | // A palette above normal/copy content dims that composed content. When a | ||
| 413 | // floating pane is present its own backdrop already dims normal pane | 413 | // 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) { | |||
| 107 | 107 | ||
| 108 | 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 | 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 117 | 111 | // 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 | 373 | require.Equal(t, "2222", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| 380 | 374 | require.Equal(t, "3333", frameRowString(win.focusedPane().screen.Frame, 1)) | |
| 381 | 375 | 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))) | |
| 384 | 378 | ||
| 385 | 379 | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 6}) | |
| 386 | 380 | require.Equal(t, "2222 ", frameRowString(win.focusedPane().screen.Frame, 0)) | |
| @@ -648,36 +642,6 @@ func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) { | |||
| 648 | 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 | 645 | func TestComposeTabFrameStackUsesShellFallback(t *testing.T) { | |
| 682 | 646 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 683 | 647 | p := win.focusedPane() | |
| @@ -1455,10 +1419,11 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) { | |||
| 1455 | 1419 | } | |
| 1456 | 1420 | p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2}) | |
| 1457 | 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 | 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 | 1428 | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |
| 1464 | 1429 | 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 it | 110 | // 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 by | 111 | // 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) { | |||
| 36 | 36 | if got := win.focusedPane().scrollback.Len(); got != tt.wantLen { | |
| 37 | 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 | 40 | runes := make([]rune, len(row)) | |
| 41 | 41 | for i, c := range row { | |
| 42 | 42 | 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.Rune | 42 | runes[i] = c.Rune | ||
M internal/usecase/daemon/snapshot_restore_test.go +3 -3
| @@ -77,12 +77,12 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 77 | 77 | p := tb.panes["pane-2"] | |
| 78 | 78 | tb.mu.Unlock() | |
| 79 | 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 | 81 | require.Equal(t, 1, p.scrollback.Len()) | |
| 82 | 82 | require.Equal(t, "vis2", rowText(p.screen.PrimaryVisibleRows()[0][:4])) | |
| 83 | 83 | 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])) | |
| 86 | 86 | p.mu.Unlock() | |
| 87 | 87 | ||
| 88 | 88 | 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 { | |||
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | func New(snapshot scopy.Snapshot) *Model { | |
| 21 | - | m := &Model{snapshot: cloneSnapshot(snapshot), selected: -1} | |
| 21 | + | m := &Model{snapshot: snapshot, selected: -1} | |
| 22 | 22 | m.refresh() | |
| 23 | 23 | return m | |
| 24 | 24 | } | |
| @@ -28,7 +28,6 @@ func (m *Model) Clone() *Model { | |||
| 28 | 28 | return nil | |
| 29 | 29 | } | |
| 30 | 30 | clone := *m | |
| 31 | - | clone.snapshot = cloneSnapshot(m.snapshot) | |
| 32 | 31 | clone.input.SetValue(m.input.Value()) | |
| 33 | 32 | clone.matches = append([]scopy.SearchMatch(nil), m.matches...) | |
| 34 | 33 | return &clone | |
| @@ -36,9 +35,9 @@ func (m *Model) Clone() *Model { | |||
| 36 | 35 | ||
| 37 | 36 | func (m *Model) Snapshot() scopy.Snapshot { | |
| 38 | 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 | 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 | 148 | func (m *Model) Render(inner domain.Size, selectedStyle ...renderer.Style) renderer.Frame { | |
| 159 | 149 | frame := renderer.NewFrame(max(inner.Cols, 0), max(inner.Rows, 0)) | |
| 160 | 150 | 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 m | 23 | return m | ||
| 24 | } | 24 | } | ||
| @@ -28,7 +28,6 @@ func (m *Model) Clone() *Model { | |||||
| 28 | return nil | 28 | return nil | ||
| 29 | } | 29 | } | ||
| 30 | clone := *m | 30 | 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 &clone | 33 | 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 ( | |||
| 11 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | var benchmarkModelSink *Model | |
| 15 | + | ||
| 14 | 16 | func testSnapshot(lines ...string) scopy.Snapshot { | |
| 15 | 17 | rows := make([][]renderer.Cell, len(lines)) | |
| 16 | 18 | for i, line := range lines { | |
| @@ -20,7 +22,7 @@ func testSnapshot(lines ...string) scopy.Snapshot { | |||
| 20 | 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 | 28 | func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) { | |
| @@ -45,18 +47,36 @@ func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) { | |||
| 45 | 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 | 65 | m := New(testSnapshot("alpha", "beta alpha")) | |
| 50 | 66 | for _, r := range "alpha" { | |
| 51 | 67 | m.Insert(r) | |
| 52 | 68 | } | |
| 53 | 69 | clone := m.Clone() | |
| 54 | 70 | ||
| 71 | + | require.Same(t, &m.snapshot.Row(0)[0], &clone.snapshot.Row(0)[0]) | |
| 72 | + | clone.Down() | |
| 55 | 73 | clone.Insert('z') | |
| 56 | 74 | ||
| 57 | 75 | require.Equal(t, "alpha", m.Query()) | |
| 58 | - | require.Equal(t, "alphaz", clone.Query()) | |
| 76 | + | require.Equal(t, 0, m.SelectedIndex()) | |
| 59 | 77 | require.Len(t, m.Matches(), 2) | |
| 78 | + | require.Equal(t, "alphaz", clone.Query()) | |
| 79 | + | require.Equal(t, -1, clone.SelectedIndex()) | |
| 60 | 80 | require.Len(t, clone.Matches(), 0) | |
| 61 | 81 | } | |
| 62 | 82 | ||
| @@ -87,6 +107,74 @@ func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) { | |||
| 87 | 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 | 178 | func rowText(row []renderer.Cell) string { | |
| 91 | 179 | var b strings.Builder | |
| 92 | 180 | 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.Builder | 179 | var b strings.Builder | ||
| 92 | for _, c := range row { | 180 | for _, c := range row { | ||