detached 8846c59

feat(copy): carry line bounds into the copy-mode snapshot

commit
8846c59041c0ba380d7ce6604c8f7ba9d17f36f4
author
brice · · 2026-07-25 06:16 UTC
parent
5be2475
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +96 -21
M internal/usecase/copy/mode.go +44 -7
@@ -13,34 +13,55 @@ import (
1313
1414 const OSC52MaxPayloadBytes = 75_000
1515
16-// Snapshot is the immutable scrollback document: sealed history plus a cloned screen.
16+// Snapshot is the immutable scrollback document: sealed history plus a cloned
17+// screen. screenBounds is parallel to screen rows, exactly as the history view's
18+// own bounds are parallel to its chunk rows.
1719 type Snapshot struct {
1820 history vt.HistoryView
1921 screen renderer.Frame
22+ screenBounds []vt.LineBound
2023 Width, Height int
2124 }
2225
23-func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {
26+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {
2427 var history vt.HistoryView
2528 if historySource != nil {
2629 history = historySource.SealAndView()
2730 }
28- return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}
31+ return Snapshot{
32+ history: history,
33+ screen: screen.Clone(),
34+ screenBounds: append([]vt.LineBound(nil), bounds...),
35+ Width: screen.Width,
36+ Height: screen.Height,
37+ }
2938 }
30-func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
39+
40+// NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It
41+// exists for tests; production code goes through NewSnapshot.
42+func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
3143 history := vt.NewHistory(vt.HistoryConfig{
32- MaxRows: len(rows),
44+ MaxRows: max(len(rows), 1),
3345 MaxCells: snapshotRowsCellBudget(rows),
3446 ChunkRows: 256,
3547 })
36- for _, row := range rows {
37- if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
48+ for i, row := range rows {
49+ bound := vt.LineBound{End: len(row)}
50+ if i < len(bounds) {
51+ bound = bounds[i]
52+ }
53+ if err := history.Append(row, bound); err != nil {
3854 panic("copy snapshot: configured history rejected supplied row")
3955 }
4056 }
4157 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
4258 }
4359
60+// NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines.
61+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
62+ return NewSnapshotFromLines(rows, nil, width, height)
63+}
64+
4465 // snapshotRowsCellBudget returns a capacity that retains every supplied row.
4566 // Saturation is safe because a larger representable capacity does not exist.
4667 func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
@@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell {
6788 }
6889 return s.screen.Row(i)
6990 }
91+
92+// Bound returns the logical extent of row i, dispatching between sealed history
93+// and the live screen exactly as Row does.
94+func (s Snapshot) Bound(i int) vt.LineBound {
95+ if i < 0 {
96+ return vt.LineBound{}
97+ }
98+ if i < s.history.Len() {
99+ return s.history.Bound(i)
100+ }
101+ i -= s.history.Len()
102+ if i >= s.screen.Height || i >= len(s.screenBounds) {
103+ return vt.LineBound{}
104+ }
105+ return s.screenBounds[i]
106+}
70107 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
71108 i := 0
72109 stopped := false
@@ -13,34 +13,55 @@ import (
13 13
14 const OSC52MaxPayloadBytes = 75_00014 const OSC52MaxPayloadBytes = 75_000
15 15
16-// Snapshot is the immutable scrollback document: sealed history plus a cloned screen.16+// Snapshot is the immutable scrollback document: sealed history plus a cloned
17+// screen. screenBounds is parallel to screen rows, exactly as the history view's
18+// own bounds are parallel to its chunk rows.
17 type Snapshot struct {19 type Snapshot struct {
18 history vt.HistoryView20 history vt.HistoryView
19 screen renderer.Frame21 screen renderer.Frame
22+ screenBounds []vt.LineBound
20 Width, Height int23 Width, Height int
21 }24 }
22 25
23-func NewSnapshot(historySource *vt.History, screen renderer.Frame) Snapshot {26+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {
24 var history vt.HistoryView27 var history vt.HistoryView
25 if historySource != nil {28 if historySource != nil {
26 history = historySource.SealAndView()29 history = historySource.SealAndView()
27 }30 }
28- return Snapshot{history: history, screen: screen.Clone(), Width: screen.Width, Height: screen.Height}31+ return Snapshot{
32+ history: history,
33+ screen: screen.Clone(),
34+ screenBounds: append([]vt.LineBound(nil), bounds...),
35+ Width: screen.Width,
36+ Height: screen.Height,
37+ }
29 }38 }
30-func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {39+
40+// NewSnapshotFromLines builds a snapshot from raw rows and their bounds. It
41+// exists for tests; production code goes through NewSnapshot.
42+func NewSnapshotFromLines(rows [][]renderer.Cell, bounds []vt.LineBound, width, height int) Snapshot {
31 history := vt.NewHistory(vt.HistoryConfig{43 history := vt.NewHistory(vt.HistoryConfig{
32- MaxRows: len(rows),44+ MaxRows: max(len(rows), 1),
33 MaxCells: snapshotRowsCellBudget(rows),45 MaxCells: snapshotRowsCellBudget(rows),
34 ChunkRows: 256,46 ChunkRows: 256,
35 })47 })
36- for _, row := range rows {48+ for i, row := range rows {
37- if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {49+ bound := vt.LineBound{End: len(row)}
50+ if i < len(bounds) {
51+ bound = bounds[i]
52+ }
53+ if err := history.Append(row, bound); err != nil {
38 panic("copy snapshot: configured history rejected supplied row")54 panic("copy snapshot: configured history rejected supplied row")
39 }55 }
40 }56 }
41 return Snapshot{history: history.SealAndView(), Width: width, Height: height}57 return Snapshot{history: history.SealAndView(), Width: width, Height: height}
42 }58 }
43 59
60+// NewSnapshotFromRows builds a snapshot whose rows are all hard logical lines.
61+func NewSnapshotFromRows(rows [][]renderer.Cell, width, height int) Snapshot {
62+ return NewSnapshotFromLines(rows, nil, width, height)
63+}
64+
44 // snapshotRowsCellBudget returns a capacity that retains every supplied row.65 // snapshotRowsCellBudget returns a capacity that retains every supplied row.
45 // Saturation is safe because a larger representable capacity does not exist.66 // Saturation is safe because a larger representable capacity does not exist.
46 func snapshotRowsCellBudget(rows [][]renderer.Cell) int {67 func snapshotRowsCellBudget(rows [][]renderer.Cell) int {
@@ -67,6 +88,22 @@ func (s Snapshot) Row(i int) []renderer.Cell {
67 }88 }
68 return s.screen.Row(i)89 return s.screen.Row(i)
69 }90 }
91+
92+// Bound returns the logical extent of row i, dispatching between sealed history
93+// and the live screen exactly as Row does.
94+func (s Snapshot) Bound(i int) vt.LineBound {
95+ if i < 0 {
96+ return vt.LineBound{}
97+ }
98+ if i < s.history.Len() {
99+ return s.history.Bound(i)
100+ }
101+ i -= s.history.Len()
102+ if i >= s.screen.Height || i >= len(s.screenBounds) {
103+ return vt.LineBound{}
104+ }
105+ return s.screenBounds[i]
106+}
70 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {107 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
71 i := 0108 i := 0
72 stopped := false109 stopped := false
M internal/usecase/copy/mode_test.go +39 -1
@@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4242 }
4343 }
4444
45+func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
46+ row := func(s string) []renderer.Cell {
47+ cells := make([]renderer.Cell, 0, len(s))
48+ for _, r := range s {
49+ cells = append(cells, renderer.Cell{Rune: r})
50+ }
51+ return cells
52+ }
53+
54+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
55+ if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
56+ t.Fatalf("append: %v", err)
57+ }
58+ screen := renderer.NewFrame(4, 2)
59+ copy(screen.Row(0), row("live"))
60+
61+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})
62+
63+ tests := []struct {
64+ name string
65+ i int
66+ want vt.LineBound
67+ }{
68+ {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}},
69+ {name: "first screen row", i: 1, want: vt.LineBound{End: 4}},
70+ {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}},
71+ {name: "past the end", i: 3, want: vt.LineBound{}},
72+ {name: "negative", i: -1, want: vt.LineBound{}},
73+ }
74+ for _, tc := range tests {
75+ t.Run(tc.name, func(t *testing.T) {
76+ if got := snap.Bound(tc.i); got != tc.want {
77+ t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want)
78+ }
79+ })
80+ }
81+}
82+
4583 func TestCopyModeKeyboardSelection(t *testing.T) {
4684 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
4785 require.Equal(t, Pos{Row: 3}, m.Cursor())
@@ -194,7 +232,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
194232 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
195233 }
196234 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
197- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))
235+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
198236 view := history.SealAndView()
199237 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
200238
@@ -42,6 +42,44 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
42 }42 }
43 }43 }
44 44
45+func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
46+ row := func(s string) []renderer.Cell {
47+ cells := make([]renderer.Cell, 0, len(s))
48+ for _, r := range s {
49+ cells = append(cells, renderer.Cell{Rune: r})
50+ }
51+ return cells
52+ }
53+
54+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
55+ if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
56+ t.Fatalf("append: %v", err)
57+ }
58+ screen := renderer.NewFrame(4, 2)
59+ copy(screen.Row(0), row("live"))
60+
61+ snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}})
62+
63+ tests := []struct {
64+ name string
65+ i int
66+ want vt.LineBound
67+ }{
68+ {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}},
69+ {name: "first screen row", i: 1, want: vt.LineBound{End: 4}},
70+ {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}},
71+ {name: "past the end", i: 3, want: vt.LineBound{}},
72+ {name: "negative", i: -1, want: vt.LineBound{}},
73+ }
74+ for _, tc := range tests {
75+ t.Run(tc.name, func(t *testing.T) {
76+ if got := snap.Bound(tc.i); got != tc.want {
77+ t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want)
78+ }
79+ })
80+ }
81+}
82+
45 func TestCopyModeKeyboardSelection(t *testing.T) {83 func TestCopyModeKeyboardSelection(t *testing.T) {
46 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)84 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
47 require.Equal(t, Pos{Row: 3}, m.Cursor())85 require.Equal(t, Pos{Row: 3}, m.Cursor())
@@ -194,7 +232,7 @@ func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
194 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))232 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
195 }233 }
196 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))234 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
197- snapshot := NewSnapshot(history, renderer.NewFrame(16, 1))235+ snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil)
198 view := history.SealAndView()236 view := history.SealAndView()
199 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))237 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
200 238
M internal/usecase/daemon/copy_mouse_input.go +1 -1
@@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab
6868 geometry.pane.mu.Lock()
6969 mouseMode, _ := geometry.pane.screen.MouseMode()
7070 altScreen := geometry.pane.screen.AltScreenActive()
71- snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame)
71+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds())
7272 geometry.pane.mu.Unlock()
7373 if mouseMode != 0 || altScreen {
7474 return false // child forwarding retains its existing raw-byte path.
@@ -68,7 +68,7 @@ func (d *Daemon) handleFreshCopyPress(sess *session, ac *attachedClient, tb *tab
68 geometry.pane.mu.Lock()68 geometry.pane.mu.Lock()
69 mouseMode, _ := geometry.pane.screen.MouseMode()69 mouseMode, _ := geometry.pane.screen.MouseMode()
70 altScreen := geometry.pane.screen.AltScreenActive()70 altScreen := geometry.pane.screen.AltScreenActive()
71- snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame)71+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds())
72 geometry.pane.mu.Unlock()72 geometry.pane.mu.Unlock()
73 if mouseMode != 0 || altScreen {73 if mouseMode != 0 || altScreen {
74 return false // child forwarding retains its existing raw-byte path.74 return false // child forwarding retains its existing raw-byte path.
M internal/usecase/daemon/copymode.go +1 -1
@@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
7474 return
7575 }
7676 p.mu.Lock()
77- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)
77+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
7878 p.mu.Unlock()
7979 cfg := d.currentCopyConfig()
8080 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
@@ -74,7 +74,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
74 return74 return
75 }75 }
76 p.mu.Lock()76 p.mu.Lock()
77- snapshot := scopy.NewSnapshot(p.history, p.screen.Frame)77+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
78 p.mu.Unlock()78 p.mu.Unlock()
79 cfg := d.currentCopyConfig()79 cfg := d.currentCopyConfig()
80 document := scopy.NewDocument(snapshot, cfg.WordSeparators)80 document := scopy.NewDocument(snapshot, cfg.WordSeparators)
M internal/usecase/daemon/copymode_lifecycle_test.go +3 -3
@@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
6868 sess.client = ac
6969 ac.setSession(sess)
7070 closing.mu.Lock()
71- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators)
71+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)
7272 closing.mu.Unlock()
7373 ac.overlays.copyMu.Lock()
7474 ac.overlays.copyPane = closing
@@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
113113 tb := sess.activeTab()
114114 p := tb.focusedPane()
115115 p.mu.Lock()
116- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
116+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
117117 p.mu.Unlock()
118118
119119 // Pause after candidate publication, then make the captured target stale.
@@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
337337 tb := sess.activeTab()
338338 p := tb.focusedPane()
339339 p.mu.Lock()
340- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
340+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
341341 p.mu.Unlock()
342342 tc.invalidate(sess, tb, p)
343343
@@ -68,7 +68,7 @@ func TestCopyModeLifecycleClosePaneClearsRecoveredClientState(t *testing.T) {
68 sess.client = ac68 sess.client = ac
69 ac.setSession(sess)69 ac.setSession(sess)
70 closing.mu.Lock()70 closing.mu.Lock()
71- document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame), domain.DefaultWordSeparators)71+ document := scopy.NewDocument(scopy.NewSnapshot(closing.history, closing.screen.Frame, closing.screen.LineBounds()), domain.DefaultWordSeparators)
72 closing.mu.Unlock()72 closing.mu.Unlock()
73 ac.overlays.copyMu.Lock()73 ac.overlays.copyMu.Lock()
74 ac.overlays.copyPane = closing74 ac.overlays.copyPane = closing
@@ -113,7 +113,7 @@ func TestCopyModeLifecycleDoesNotRenderCandidateBeforeValidation(t *testing.T) {
113 tb := sess.activeTab()113 tb := sess.activeTab()
114 p := tb.focusedPane()114 p := tb.focusedPane()
115 p.mu.Lock()115 p.mu.Lock()
116- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)116+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
117 p.mu.Unlock()117 p.mu.Unlock()
118 118
119 // Pause after candidate publication, then make the captured target stale.119 // Pause after candidate publication, then make the captured target stale.
@@ -337,7 +337,7 @@ func TestCopyModeLifecycleRejectsPublicationForInactiveOrRemovedPane(t *testing.
337 tb := sess.activeTab()337 tb := sess.activeTab()
338 p := tb.focusedPane()338 p := tb.focusedPane()
339 p.mu.Lock()339 p.mu.Lock()
340- document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)340+ document := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
341 p.mu.Unlock()341 p.mu.Unlock()
342 tc.invalidate(sess, tb, p)342 tc.invalidate(sess, tb, p)
343 343
M internal/usecase/daemon/copymode_mouse_test.go +1 -1
@@ -352,7 +352,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
352352 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
353353 p := sess.activeTab().focusedPane()
354354 p.mu.Lock()
355- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)
355+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
356356 p.mu.Unlock()
357357 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
358358 }},
@@ -352,7 +352,7 @@ func TestCopyPointerAndClickResetOnCopyExitAndReplacement(t *testing.T) {
352 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {352 {name: "replacement publication", run: func(d *Daemon, sess *session, ac *attachedClient) {
353 p := sess.activeTab().focusedPane()353 p := sess.activeTab().focusedPane()
354 p.mu.Lock()354 p.mu.Lock()
355- doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame), domain.DefaultWordSeparators)355+ doc := scopy.NewDocument(scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()), domain.DefaultWordSeparators)
356 p.mu.Unlock()356 p.mu.Unlock()
357 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))357 require.True(t, d.publishCopyMode(sess, ac, sess.activeTab(), p, doc, nil, nil))
358 }},358 }},
M internal/usecase/daemon/copymode_test.go +5 -5
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112112 tb := sess.activeTab()
113113 pane := tb.focusedPane()
114114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)
115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())
116116 pane.mu.Unlock()
117117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118118
@@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
146146 tb := sess.activeTab()
147147 tb.focusedPane().screen = vt.NewScreen(12, 3)
148148 tb.focusedPane().screen.Write([]byte("live"))
149- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)
149+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())
150150 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
151151
152152 bars := barState{status: sess.statusSegments(true)}
@@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
434434 }
435435 p.mu.Lock()
436436 defer p.mu.Unlock()
437- return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12
437+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12
438438 }, 2*time.Second, 5*time.Millisecond)
439439
440440 sess := firstSession(d)
@@ -784,7 +784,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
784784 }
785785 fp.screen.Write([]byte("live"))
786786 fp.mu.Lock()
787- total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()
787+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()
788788 fp.mu.Unlock()
789789
790790 d.enterCopyMode(sess, ac)
@@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
894894 base := renderer.NewFrame(20, 8)
895895 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
896896 pane.screen.Write([]byte("copy"))
897- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators))
897+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))
898898 if tt.selectMode {
899899 mode.ToggleLineSelection()
900900 }
@@ -112,7 +112,7 @@ func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
112 tb := sess.activeTab()112 tb := sess.activeTab()
113 pane := tb.focusedPane()113 pane := tb.focusedPane()
114 pane.mu.Lock()114 pane.mu.Lock()
115- snap := scopy.NewSnapshot(pane.history, pane.screen.Frame)115+ snap := scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds())
116 pane.mu.Unlock()116 pane.mu.Unlock()
117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))117 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
118 118
@@ -146,7 +146,7 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
146 tb := sess.activeTab()146 tb := sess.activeTab()
147 tb.focusedPane().screen = vt.NewScreen(12, 3)147 tb.focusedPane().screen = vt.NewScreen(12, 3)
148 tb.focusedPane().screen.Write([]byte("live"))148 tb.focusedPane().screen.Write([]byte("live"))
149- snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame)149+ snap := scopy.NewSnapshot(tb.focusedPane().history, tb.focusedPane().screen.Frame, tb.focusedPane().screen.LineBounds())
150 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))150 mode := scopy.NewMode(scopy.NewDocument(snap, domain.DefaultWordSeparators))
151 151
152 bars := barState{status: sess.statusSegments(true)}152 bars := barState{status: sess.statusSegments(true)}
@@ -434,7 +434,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) {
434 }434 }
435 p.mu.Lock()435 p.mu.Lock()
436 defer p.mu.Unlock()436 defer p.mu.Unlock()
437- return scopy.NewSnapshot(p.history, p.screen.Frame).Len() >= 12437+ return scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds()).Len() >= 12
438 }, 2*time.Second, 5*time.Millisecond)438 }, 2*time.Second, 5*time.Millisecond)
439 439
440 sess := firstSession(d)440 sess := firstSession(d)
@@ -784,7 +784,7 @@ func TestFloatingCopyModeWheelUsesCapturedSnapshot(t *testing.T) {
784 }784 }
785 fp.screen.Write([]byte("live"))785 fp.screen.Write([]byte("live"))
786 fp.mu.Lock()786 fp.mu.Lock()
787- total := scopy.NewSnapshot(fp.history, fp.screen.Frame).Len()787+ total := scopy.NewSnapshot(fp.history, fp.screen.Frame, fp.screen.LineBounds()).Len()
788 fp.mu.Unlock()788 fp.mu.Unlock()
789 789
790 d.enterCopyMode(sess, ac)790 d.enterCopyMode(sess, ac)
@@ -894,7 +894,7 @@ func TestComposeCopyClientFrameStylesStatusContentAndSurround(t *testing.T) {
894 base := renderer.NewFrame(20, 8)894 base := renderer.NewFrame(20, 8)
895 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})895 pane := newPane("split", nil, domain.Size{Cols: 12, Rows: 2})
896 pane.screen.Write([]byte("copy"))896 pane.screen.Write([]byte("copy"))
897- mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame), domain.DefaultWordSeparators))897+ mode := scopy.NewMode(scopy.NewDocument(scopy.NewSnapshot(pane.history, pane.screen.Frame, pane.screen.LineBounds()), domain.DefaultWordSeparators))
898 if tt.selectMode {898 if tt.selectMode {
899 mode.ToggleLineSelection()899 mode.ToggleLineSelection()
900 }900 }
M internal/usecase/daemon/overlay_runtime_test.go +1 -1
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200200 ac.initOverlays()
201201 history := newTestHistory(1)
202202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)
203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)
204204
205205 ac.overlays.copyMu.Lock()
206206 ac.overlays.copyMode = scopy.NewMode(document)
@@ -200,7 +200,7 @@ func TestOverlayRuntimeCopyModeDocumentSurvivesRingOverwrite(t *testing.T) {
200 ac.initOverlays()200 ac.initOverlays()
201 history := newTestHistory(1)201 history := newTestHistory(1)
202 appendHistoryRow(t, history, testRow("before"))202 appendHistoryRow(t, history, testRow("before"))
203- document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1)), domain.DefaultWordSeparators)203+ document := scopy.NewDocument(scopy.NewSnapshot(history, renderer.NewFrame(6, 1), nil), domain.DefaultWordSeparators)
204 204
205 ac.overlays.copyMu.Lock()205 ac.overlays.copyMu.Lock()
206 ac.overlays.copyMode = scopy.NewMode(document)206 ac.overlays.copyMode = scopy.NewMode(document)
M internal/usecase/daemon/render_test.go +1 -1
@@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
12641264 }
12651265 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
12661266 p.screen.Write([]byte("ab\r\ncd"))
1267- document := scopy.NewSnapshot(p.history, p.screen.Frame)
1267+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
12681268 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
12691269 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
12701270
@@ -1264,7 +1264,7 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1264 }1264 }
1265 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})1265 p := newPane("floating", nil, domain.Size{Cols: 18, Rows: 2})
1266 p.screen.Write([]byte("ab\r\ncd"))1266 p.screen.Write([]byte("ab\r\ncd"))
1267- document := scopy.NewSnapshot(p.history, p.screen.Frame)1267+ document := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds())
1268 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))1268 mode := scopy.NewMode(scopy.NewDocument(document, domain.DefaultWordSeparators))
1269 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}1269 target := domain.Rect{X: 2, Y: 3, Width: 18, Height: 2}
1270 1270

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close