detached 7560131

feat(copy): carry stable row identities

commit
7560131e189233c389394b1892b99acb20fe3b2c
author
brice · · 2026-08-02 20:15 UTC
committer
brice · · 2026-08-03 17:54 UTC
parent
8a3d00a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +100 -5
M internal/usecase/copy/document.go +3 -0
@@ -5,6 +5,7 @@ import (
55 "unicode"
66
77 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
89 )
910
1011 // Pos identifies a terminal display cell in a document. Valid positions are
@@ -40,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
4041 func (d *Document) Width() int { return d.snapshot.Width }
4142 func (d *Document) Height() int { return d.snapshot.Height }
4243 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
45+func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
4346
4447 // Normalize returns pos as a valid glyph head. Empty physical rows have the
4548 // stable logical position {row, 0}, which is useful to vertical navigation.
@@ -5,6 +5,7 @@ import (
5 "unicode"5 "unicode"
6 6
7 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
8 )9 )
9 10
10 // Pos identifies a terminal display cell in a document. Valid positions are11 // Pos identifies a terminal display cell in a document. Valid positions are
@@ -40,6 +41,8 @@ func (d *Document) Len() int { return d.snapshot.Len() }
40 func (d *Document) Width() int { return d.snapshot.Width }41 func (d *Document) Width() int { return d.snapshot.Width }
41 func (d *Document) Height() int { return d.snapshot.Height }42 func (d *Document) Height() int { return d.snapshot.Height }
42 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }43 func (d *Document) Row(row int) []renderer.Cell { return d.snapshot.Row(row) }
44+func (d *Document) RowID(row int) vt.RowID { return d.snapshot.RowID(row) }
45+func (d *Document) FindRowID(id vt.RowID) int { return d.snapshot.FindRowID(id) }
43 46
44 // Normalize returns pos as a valid glyph head. Empty physical rows have the47 // Normalize returns pos as a valid glyph head. Empty physical rows have the
45 // stable logical position {row, 0}, which is useful to vertical navigation.48 // stable logical position {row, 0}, which is useful to vertical navigation.
M internal/usecase/copy/mode.go +54 -3
@@ -14,24 +14,30 @@ import (
1414 const OSC52MaxPayloadBytes = 75_000
1515
1616 // 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+// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18+// the history view's own metadata is parallel to its chunk rows.
1919 type Snapshot struct {
2020 history vt.HistoryView
2121 screen renderer.Frame
2222 screenBounds []vt.LineBound
23+ screenRowIDs []vt.RowID
2324 Width, Height int
2425 }
2526
26-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {
27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {
2728 var history vt.HistoryView
2829 if historySource != nil {
2930 history = historySource.SealAndView()
3031 }
32+ var screenRowIDs []vt.RowID
33+ if len(rowIDs) > 0 {
34+ screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
35+ }
3136 return Snapshot{
3237 history: history,
3338 screen: screen.Clone(),
3439 screenBounds: append([]vt.LineBound(nil), bounds...),
40+ screenRowIDs: screenRowIDs,
3541 Width: screen.Width,
3642 Height: screen.Height,
3743 }
@@ -104,6 +110,51 @@ func (s Snapshot) Bound(i int) vt.LineBound {
104110 }
105111 return s.screenBounds[i]
106112 }
113+
114+// RowIDs returns an owned copy of physical row identities in document order.
115+func (s Snapshot) RowIDs() []vt.RowID {
116+ ids := make([]vt.RowID, s.Len())
117+ for i := range ids {
118+ ids[i] = s.RowID(i)
119+ }
120+ return ids
121+}
122+
123+// RowID returns the identity of document row i, or zero when it is unavailable.
124+func (s Snapshot) RowID(i int) vt.RowID {
125+ if i < 0 {
126+ return 0
127+ }
128+ if i < s.history.Len() {
129+ return s.history.RowID(i)
130+ }
131+ i -= s.history.Len()
132+ if i >= s.screen.Height || i >= len(s.screenRowIDs) {
133+ return 0
134+ }
135+ return s.screenRowIDs[i]
136+}
137+
138+// FindRowID returns the document row containing id, or -1 when it is absent.
139+func (s Snapshot) FindRowID(id vt.RowID) int {
140+ if id == 0 {
141+ return -1
142+ }
143+ if row := s.history.FindRowID(id); row >= 0 {
144+ return row
145+ }
146+ offset := s.history.Len()
147+ for i, candidate := range s.screenRowIDs {
148+ if i >= s.screen.Height {
149+ break
150+ }
151+ if candidate == id {
152+ return offset + i
153+ }
154+ }
155+ return -1
156+}
157+
107158 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
108159 i := 0
109160 stopped := false
@@ -14,24 +14,30 @@ import (
14 const OSC52MaxPayloadBytes = 75_00014 const OSC52MaxPayloadBytes = 75_000
15 15
16 // Snapshot is the immutable scrollback document: sealed history plus a cloned16 // Snapshot is the immutable scrollback document: sealed history plus a cloned
17-// screen. screenBounds is parallel to screen rows, exactly as the history view's17+// screen. screenBounds and screenRowIDs are parallel to screen rows, exactly as
18-// own bounds are parallel to its chunk rows.18+// the history view's own metadata is parallel to its chunk rows.
19 type Snapshot struct {19 type Snapshot struct {
20 history vt.HistoryView20 history vt.HistoryView
21 screen renderer.Frame21 screen renderer.Frame
22 screenBounds []vt.LineBound22 screenBounds []vt.LineBound
23+ screenRowIDs []vt.RowID
23 Width, Height int24 Width, Height int
24 }25 }
25 26
26-func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound) Snapshot {27+func NewSnapshot(historySource *vt.History, screen renderer.Frame, bounds []vt.LineBound, rowIDs ...[]vt.RowID) Snapshot {
27 var history vt.HistoryView28 var history vt.HistoryView
28 if historySource != nil {29 if historySource != nil {
29 history = historySource.SealAndView()30 history = historySource.SealAndView()
30 }31 }
32+ var screenRowIDs []vt.RowID
33+ if len(rowIDs) > 0 {
34+ screenRowIDs = append([]vt.RowID(nil), rowIDs[0]...)
35+ }
31 return Snapshot{36 return Snapshot{
32 history: history,37 history: history,
33 screen: screen.Clone(),38 screen: screen.Clone(),
34 screenBounds: append([]vt.LineBound(nil), bounds...),39 screenBounds: append([]vt.LineBound(nil), bounds...),
40+ screenRowIDs: screenRowIDs,
35 Width: screen.Width,41 Width: screen.Width,
36 Height: screen.Height,42 Height: screen.Height,
37 }43 }
@@ -104,6 +110,51 @@ func (s Snapshot) Bound(i int) vt.LineBound {
104 }110 }
105 return s.screenBounds[i]111 return s.screenBounds[i]
106 }112 }
113+
114+// RowIDs returns an owned copy of physical row identities in document order.
115+func (s Snapshot) RowIDs() []vt.RowID {
116+ ids := make([]vt.RowID, s.Len())
117+ for i := range ids {
118+ ids[i] = s.RowID(i)
119+ }
120+ return ids
121+}
122+
123+// RowID returns the identity of document row i, or zero when it is unavailable.
124+func (s Snapshot) RowID(i int) vt.RowID {
125+ if i < 0 {
126+ return 0
127+ }
128+ if i < s.history.Len() {
129+ return s.history.RowID(i)
130+ }
131+ i -= s.history.Len()
132+ if i >= s.screen.Height || i >= len(s.screenRowIDs) {
133+ return 0
134+ }
135+ return s.screenRowIDs[i]
136+}
137+
138+// FindRowID returns the document row containing id, or -1 when it is absent.
139+func (s Snapshot) FindRowID(id vt.RowID) int {
140+ if id == 0 {
141+ return -1
142+ }
143+ if row := s.history.FindRowID(id); row >= 0 {
144+ return row
145+ }
146+ offset := s.history.Len()
147+ for i, candidate := range s.screenRowIDs {
148+ if i >= s.screen.Height {
149+ break
150+ }
151+ if candidate == id {
152+ return offset + i
153+ }
154+ }
155+ return -1
156+}
157+
107 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {158 func (s Snapshot) rangeRows(yield func(int, []renderer.Cell) bool) {
108 i := 0159 i := 0
109 stopped := false160 stopped := false
M internal/usecase/copy/mode_test.go +23 -0
@@ -42,6 +42,29 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
4242 }
4343 }
4444
45+func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47+ require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48+ require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49+ screen := renderer.NewFrame(4, 2)
50+ copy(screen.Row(0), row("live"))
51+ copy(screen.Row(1), row("more"))
52+ rowIDs := []vt.RowID{21, 22}
53+
54+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55+ rowIDs[0] = 99
56+ doc := NewDocument(snapshot, "")
57+
58+ require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59+ ids := snapshot.RowIDs()
60+ ids[0] = 100
61+ require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62+ require.Equal(t, vt.RowID(21), doc.RowID(2))
63+ require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64+ require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65+ require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66+}
67+
4568 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
4669 row := func(s string) []renderer.Cell {
4770 cells := make([]renderer.Cell, 0, len(s))
@@ -42,6 +42,29 @@ func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
42 }42 }
43 }43 }
44 44
45+func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxCells: 1024})
47+ require.NoError(t, history.Append(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48+ require.NoError(t, history.Append(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49+ screen := renderer.NewFrame(4, 2)
50+ copy(screen.Row(0), row("live"))
51+ copy(screen.Row(1), row("more"))
52+ rowIDs := []vt.RowID{21, 22}
53+
54+ snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55+ rowIDs[0] = 99
56+ doc := NewDocument(snapshot, "")
57+
58+ require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59+ ids := snapshot.RowIDs()
60+ ids[0] = 100
61+ require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62+ require.Equal(t, vt.RowID(21), doc.RowID(2))
63+ require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64+ require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65+ require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66+}
67+
45 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {68 func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
46 row := func(s string) []renderer.Cell {69 row := func(s string) []renderer.Cell {
47 cells := make([]renderer.Cell, 0, len(s))70 cells := make([]renderer.Cell, 0, len(s))
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, geometry.pane.screen.LineBounds())
71+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs())
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, geometry.pane.screen.LineBounds())71+ snapshot := scopy.NewSnapshot(geometry.pane.history, geometry.pane.screen.Frame, geometry.pane.screen.LineBounds(), geometry.pane.screen.RowIDs())
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, p.screen.LineBounds())
77+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs())
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, p.screen.LineBounds())77+ snapshot := scopy.NewSnapshot(p.history, p.screen.Frame, p.screen.LineBounds(), p.screen.RowIDs())
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_test.go +18 -0
@@ -105,6 +105,24 @@ func TestCopySearchModalGeometry(t *testing.T) {
105105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106106 }
107107
108+func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109+ p, _ := newBlockingPTY(t)
110+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111+ pane := sess.activeTab().focusedPane()
112+ appendHistoryRow(t, pane.history, testRow("history"))
113+ pane.screen.Write([]byte("live"))
114+ historyID := pane.history.View().RowID(0)
115+ liveID := pane.screen.RowID(0)
116+
117+ d.enterCopyMode(sess, ac)
118+ awaitFrame(t, sends, ports.MsgOutput)
119+ doc := ac.overlays.copyDocument
120+
121+ require.Equal(t, 0, doc.FindRowID(historyID))
122+ require.Equal(t, pane.history.Len(), doc.FindRowID(liveID))
123+ require.Equal(t, liveID, doc.RowID(pane.history.Len()))
124+}
125+
108126 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
109127 p, release := newBlockingPTY(t)
110128 defer release()
@@ -105,6 +105,24 @@ func TestCopySearchModalGeometry(t *testing.T) {
105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106 }106 }
107 107
108+func TestCopyModeDocumentCarriesPaneRowIDs(t *testing.T) {
109+ p, _ := newBlockingPTY(t)
110+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111+ pane := sess.activeTab().focusedPane()
112+ appendHistoryRow(t, pane.history, testRow("history"))
113+ pane.screen.Write([]byte("live"))
114+ historyID := pane.history.View().RowID(0)
115+ liveID := pane.screen.RowID(0)
116+
117+ d.enterCopyMode(sess, ac)
118+ awaitFrame(t, sends, ports.MsgOutput)
119+ doc := ac.overlays.copyDocument
120+
121+ require.Equal(t, 0, doc.FindRowID(historyID))
122+ require.Equal(t, pane.history.Len(), doc.FindRowID(liveID))
123+ require.Equal(t, liveID, doc.RowID(pane.history.Len()))
124+}
125+
108 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {126 func TestComposeCopyClientFrameConcurrentPaneOutput(t *testing.T) {
109 p, release := newBlockingPTY(t)127 p, release := newBlockingPTY(t)
110 defer release()128 defer release()

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