detached d9a1d18

feat: add copy mode osc52

commit
d9a1d1853f905a424a5348280b5272c5f884f7a7
author
brice · · 2026-07-02 15:56 UTC
parent
9548cb5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +556 -7
A internal/usecase/copy/mode.go +200 -0
@@ -0,0 +1,200 @@
1+package copy
2+
3+import (
4+ "encoding/base64"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const OSC52ChunkSize = 75_000
11+
12+// Snapshot is the copy-mode document: scrollback followed by the live screen.
13+type Snapshot struct {
14+ Rows [][]renderer.Cell
15+ Width int
16+ Height int
17+}
18+
19+// NewSnapshot copies rows from scrollback and then the live screen frame.
20+func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
21+ scrollRows := 0
22+ if sb != nil {
23+ scrollRows = sb.Len()
24+ }
25+ rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)
26+ if sb != nil {
27+ for i := range sb.Len() {
28+ rows = append(rows, sb.Row(i))
29+ }
30+ }
31+ for y := range screen.Height {
32+ rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))
33+ }
34+ return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
35+}
36+
37+// Mode stores per-client copy-mode viewport and line-selection state.
38+type Mode struct {
39+ ViewportTop int
40+ Cursor int
41+ Anchor int
42+ Selecting bool
43+}
44+
45+func NewMode(s Snapshot) *Mode {
46+ m := &Mode{Anchor: -1}
47+ m.ViewportTop = max(len(s.Rows)-s.Height, 0)
48+ m.Cursor = max(len(s.Rows)-1, 0)
49+ m.clamp(s)
50+ return m
51+}
52+
53+func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) }
54+func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) }
55+func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) }
56+func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) }
57+
58+func (m *Mode) ToggleSelection() {
59+ if m.Selecting {
60+ m.Selecting = false
61+ m.Anchor = -1
62+ return
63+ }
64+ m.Selecting = true
65+ m.Anchor = m.Cursor
66+}
67+
68+func (m *Mode) setCursor(s Snapshot, cursor int) {
69+ m.Cursor = cursor
70+ m.clamp(s)
71+ if m.Cursor < m.ViewportTop {
72+ m.ViewportTop = m.Cursor
73+ }
74+ if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom {
75+ m.ViewportTop = m.Cursor - max(s.Height, 1) + 1
76+ }
77+ m.clamp(s)
78+}
79+
80+func (m *Mode) clamp(s Snapshot) {
81+ total := len(s.Rows)
82+ if total == 0 {
83+ m.Cursor, m.ViewportTop = 0, 0
84+ return
85+ }
86+ m.Cursor = min(max(m.Cursor, 0), total-1)
87+ maxTop := max(total-max(s.Height, 1), 0)
88+ m.ViewportTop = min(max(m.ViewportTop, 0), maxTop)
89+}
90+
91+func (m *Mode) SelectedBounds() (int, int, bool) {
92+ if !m.Selecting || m.Anchor < 0 {
93+ return 0, 0, false
94+ }
95+ lo, hi := m.Anchor, m.Cursor
96+ if lo > hi {
97+ lo, hi = hi, lo
98+ }
99+ return lo, hi, true
100+}
101+
102+func (m *Mode) SelectedText(s Snapshot) string {
103+ lo, hi, ok := m.SelectedBounds()
104+ if !ok || len(s.Rows) == 0 {
105+ return ""
106+ }
107+ lo = max(lo, 0)
108+ hi = min(hi, len(s.Rows)-1)
109+ lines := make([]string, 0, hi-lo+1)
110+ for i := lo; i <= hi; i++ {
111+ lines = append(lines, rowString(s.Rows[i]))
112+ }
113+ return strings.Join(lines, "\n")
114+}
115+
116+func (m *Mode) Render(s Snapshot) renderer.Frame {
117+ m.clamp(s)
118+ frame := renderer.NewFrame(s.Width, s.Height+1)
119+ lo, hi, selected := m.SelectedBounds()
120+ for y := range s.Height {
121+ src := m.ViewportTop + y
122+ if src >= len(s.Rows) {
123+ break
124+ }
125+ row := frame.Row(y)
126+ copy(row, s.Rows[src])
127+ if selected && src >= lo && src <= hi {
128+ for x := range row {
129+ row[x].Style.Inverse = !row[x].Style.Inverse
130+ }
131+ }
132+ }
133+ drawCopyStatus(frame.Row(s.Height), m, len(s.Rows))
134+ return frame
135+}
136+
137+func rowString(row []renderer.Cell) string {
138+ runes := make([]rune, 0, len(row))
139+ for _, c := range row {
140+ if c.Continuation {
141+ continue
142+ }
143+ r := c.Rune
144+ if r == 0 {
145+ r = ' '
146+ }
147+ runes = append(runes, r)
148+ }
149+ return strings.TrimRight(string(runes), " ")
150+}
151+
152+func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
153+ for i := range row {
154+ row[i] = renderer.BlankCell()
155+ }
156+ text := " [COPY] "
157+ if total > 0 {
158+ text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
159+ } else {
160+ text += "0/0 "
161+ }
162+ style := renderer.DefaultStyle()
163+ style.Inverse = true
164+ for i, r := range text {
165+ if i >= len(row) {
166+ break
167+ }
168+ row[i] = renderer.Cell{Rune: r, Style: style}
169+ }
170+}
171+
172+// OSC52 encodes text for clipboard transfer. Large payloads are split into
173+// multiple complete OSC 52 sequences so each MsgOutput remains bounded.
174+func OSC52(text string) [][]byte {
175+ encoded := base64.StdEncoding.EncodeToString([]byte(text))
176+ if encoded == "" {
177+ return [][]byte{[]byte("\x1b]52;c;\x07")}
178+ }
179+ chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1)
180+ for len(encoded) > 0 {
181+ n := min(len(encoded), OSC52ChunkSize)
182+ chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07"))
183+ encoded = encoded[n:]
184+ }
185+ return chunks
186+}
187+
188+func strconvItoa(n int) string {
189+ if n == 0 {
190+ return "0"
191+ }
192+ var buf [20]byte
193+ i := len(buf)
194+ for n > 0 {
195+ i--
196+ buf[i] = byte('0' + n%10)
197+ n /= 10
198+ }
199+ return string(buf[i:])
200+}
@@ -0,0 +1,200 @@
1+package copy
2+
3+import (
4+ "encoding/base64"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const OSC52ChunkSize = 75_000
11+
12+// Snapshot is the copy-mode document: scrollback followed by the live screen.
13+type Snapshot struct {
14+ Rows [][]renderer.Cell
15+ Width int
16+ Height int
17+}
18+
19+// NewSnapshot copies rows from scrollback and then the live screen frame.
20+func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot {
21+ scrollRows := 0
22+ if sb != nil {
23+ scrollRows = sb.Len()
24+ }
25+ rows := make([][]renderer.Cell, 0, scrollRows+screen.Height)
26+ if sb != nil {
27+ for i := range sb.Len() {
28+ rows = append(rows, sb.Row(i))
29+ }
30+ }
31+ for y := range screen.Height {
32+ rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...))
33+ }
34+ return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height}
35+}
36+
37+// Mode stores per-client copy-mode viewport and line-selection state.
38+type Mode struct {
39+ ViewportTop int
40+ Cursor int
41+ Anchor int
42+ Selecting bool
43+}
44+
45+func NewMode(s Snapshot) *Mode {
46+ m := &Mode{Anchor: -1}
47+ m.ViewportTop = max(len(s.Rows)-s.Height, 0)
48+ m.Cursor = max(len(s.Rows)-1, 0)
49+ m.clamp(s)
50+ return m
51+}
52+
53+func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) }
54+func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) }
55+func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) }
56+func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) }
57+
58+func (m *Mode) ToggleSelection() {
59+ if m.Selecting {
60+ m.Selecting = false
61+ m.Anchor = -1
62+ return
63+ }
64+ m.Selecting = true
65+ m.Anchor = m.Cursor
66+}
67+
68+func (m *Mode) setCursor(s Snapshot, cursor int) {
69+ m.Cursor = cursor
70+ m.clamp(s)
71+ if m.Cursor < m.ViewportTop {
72+ m.ViewportTop = m.Cursor
73+ }
74+ if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom {
75+ m.ViewportTop = m.Cursor - max(s.Height, 1) + 1
76+ }
77+ m.clamp(s)
78+}
79+
80+func (m *Mode) clamp(s Snapshot) {
81+ total := len(s.Rows)
82+ if total == 0 {
83+ m.Cursor, m.ViewportTop = 0, 0
84+ return
85+ }
86+ m.Cursor = min(max(m.Cursor, 0), total-1)
87+ maxTop := max(total-max(s.Height, 1), 0)
88+ m.ViewportTop = min(max(m.ViewportTop, 0), maxTop)
89+}
90+
91+func (m *Mode) SelectedBounds() (int, int, bool) {
92+ if !m.Selecting || m.Anchor < 0 {
93+ return 0, 0, false
94+ }
95+ lo, hi := m.Anchor, m.Cursor
96+ if lo > hi {
97+ lo, hi = hi, lo
98+ }
99+ return lo, hi, true
100+}
101+
102+func (m *Mode) SelectedText(s Snapshot) string {
103+ lo, hi, ok := m.SelectedBounds()
104+ if !ok || len(s.Rows) == 0 {
105+ return ""
106+ }
107+ lo = max(lo, 0)
108+ hi = min(hi, len(s.Rows)-1)
109+ lines := make([]string, 0, hi-lo+1)
110+ for i := lo; i <= hi; i++ {
111+ lines = append(lines, rowString(s.Rows[i]))
112+ }
113+ return strings.Join(lines, "\n")
114+}
115+
116+func (m *Mode) Render(s Snapshot) renderer.Frame {
117+ m.clamp(s)
118+ frame := renderer.NewFrame(s.Width, s.Height+1)
119+ lo, hi, selected := m.SelectedBounds()
120+ for y := range s.Height {
121+ src := m.ViewportTop + y
122+ if src >= len(s.Rows) {
123+ break
124+ }
125+ row := frame.Row(y)
126+ copy(row, s.Rows[src])
127+ if selected && src >= lo && src <= hi {
128+ for x := range row {
129+ row[x].Style.Inverse = !row[x].Style.Inverse
130+ }
131+ }
132+ }
133+ drawCopyStatus(frame.Row(s.Height), m, len(s.Rows))
134+ return frame
135+}
136+
137+func rowString(row []renderer.Cell) string {
138+ runes := make([]rune, 0, len(row))
139+ for _, c := range row {
140+ if c.Continuation {
141+ continue
142+ }
143+ r := c.Rune
144+ if r == 0 {
145+ r = ' '
146+ }
147+ runes = append(runes, r)
148+ }
149+ return strings.TrimRight(string(runes), " ")
150+}
151+
152+func drawCopyStatus(row []renderer.Cell, m *Mode, total int) {
153+ for i := range row {
154+ row[i] = renderer.BlankCell()
155+ }
156+ text := " [COPY] "
157+ if total > 0 {
158+ text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " "
159+ } else {
160+ text += "0/0 "
161+ }
162+ style := renderer.DefaultStyle()
163+ style.Inverse = true
164+ for i, r := range text {
165+ if i >= len(row) {
166+ break
167+ }
168+ row[i] = renderer.Cell{Rune: r, Style: style}
169+ }
170+}
171+
172+// OSC52 encodes text for clipboard transfer. Large payloads are split into
173+// multiple complete OSC 52 sequences so each MsgOutput remains bounded.
174+func OSC52(text string) [][]byte {
175+ encoded := base64.StdEncoding.EncodeToString([]byte(text))
176+ if encoded == "" {
177+ return [][]byte{[]byte("\x1b]52;c;\x07")}
178+ }
179+ chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1)
180+ for len(encoded) > 0 {
181+ n := min(len(encoded), OSC52ChunkSize)
182+ chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07"))
183+ encoded = encoded[n:]
184+ }
185+ return chunks
186+}
187+
188+func strconvItoa(n int) string {
189+ if n == 0 {
190+ return "0"
191+ }
192+ var buf [20]byte
193+ i := len(buf)
194+ for n > 0 {
195+ i--
196+ buf[i] = byte('0' + n%10)
197+ n /= 10
198+ }
199+ return string(buf[i:])
200+}
A internal/usecase/copy/mode_test.go +138 -0
@@ -0,0 +1,138 @@
1+package copy
2+
3+import (
4+ "encoding/base64"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func snapshot(lines []string, height int) Snapshot {
12+ rows := make([][]renderer.Cell, len(lines))
13+ for i, line := range lines {
14+ rows[i] = row(line)
15+ }
16+ return Snapshot{Rows: rows, Width: 8, Height: height}
17+}
18+
19+func TestCopyModeNavigationBounds(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ lines []string
23+ height int
24+ ops func(*Mode, Snapshot)
25+ wantCursor int
26+ wantTop int
27+ }{
28+ {
29+ name: "initial viewport starts at bottom",
30+ lines: []string{"00", "01", "02", "03", "04"},
31+ height: 3,
32+ ops: func(*Mode, Snapshot) {},
33+ wantCursor: 4,
34+ wantTop: 2,
35+ },
36+ {
37+ name: "k clamps at top and scrolls viewport",
38+ lines: []string{"00", "01", "02", "03", "04"},
39+ height: 3,
40+ ops: func(m *Mode, s Snapshot) {
41+ m.Move(s, -99)
42+ },
43+ wantCursor: 0,
44+ wantTop: 0,
45+ },
46+ {
47+ name: "j clamps at bottom",
48+ lines: []string{"00", "01", "02", "03", "04"},
49+ height: 3,
50+ ops: func(m *Mode, s Snapshot) {
51+ m.Move(s, 99)
52+ },
53+ wantCursor: 4,
54+ wantTop: 2,
55+ },
56+ {
57+ name: "page up moves by viewport height",
58+ lines: []string{"00", "01", "02", "03", "04", "05", "06"},
59+ height: 3,
60+ ops: func(m *Mode, s Snapshot) {
61+ m.Page(s, -1)
62+ },
63+ wantCursor: 3,
64+ wantTop: 3,
65+ },
66+ {
67+ name: "g and G jump to document bounds",
68+ lines: []string{"00", "01", "02", "03", "04"},
69+ height: 3,
70+ ops: func(m *Mode, s Snapshot) {
71+ m.Top(s)
72+ m.Bottom(s)
73+ },
74+ wantCursor: 4,
75+ wantTop: 2,
76+ },
77+ }
78+
79+ for _, tt := range tests {
80+ t.Run(tt.name, func(t *testing.T) {
81+ s := snapshot(tt.lines, tt.height)
82+ m := NewMode(s)
83+ tt.ops(m, s)
84+ if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop {
85+ t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop)
86+ }
87+ })
88+ }
89+}
90+
91+func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
92+ s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
93+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
94+ m.Move(s, 1)
95+
96+ if got, want := m.SelectedText(s), "alpha\nbeta"; got != want {
97+ t.Fatalf("SelectedText() = %q, want %q", got, want)
98+ }
99+ frame := m.Render(s)
100+ for y := 0; y < 2; y++ {
101+ if !frame.At(0, y).Style.Inverse {
102+ t.Fatalf("row %d first cell not inverse", y)
103+ }
104+ }
105+}
106+
107+func TestOSC52Base64EncodingAndChunking(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ text string
111+ }{
112+ {name: "small payload", text: "hello\nworld"},
113+ {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ chunks := OSC52(tt.text)
118+ joined := ""
119+ for _, chunk := range chunks {
120+ s := string(chunk)
121+ if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {
122+ t.Fatalf("chunk %q missing OSC52 wrapper", s)
123+ }
124+ joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
125+ }
126+ decoded, err := base64.StdEncoding.DecodeString(joined)
127+ if err != nil {
128+ t.Fatalf("DecodeString() error = %v", err)
129+ }
130+ if string(decoded) != tt.text {
131+ t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
132+ }
133+ if tt.name == "chunked payload" && len(chunks) < 2 {
134+ t.Fatalf("len(chunks) = %d, want chunking", len(chunks))
135+ }
136+ })
137+ }
138+}
@@ -0,0 +1,138 @@
1+package copy
2+
3+import (
4+ "encoding/base64"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func snapshot(lines []string, height int) Snapshot {
12+ rows := make([][]renderer.Cell, len(lines))
13+ for i, line := range lines {
14+ rows[i] = row(line)
15+ }
16+ return Snapshot{Rows: rows, Width: 8, Height: height}
17+}
18+
19+func TestCopyModeNavigationBounds(t *testing.T) {
20+ tests := []struct {
21+ name string
22+ lines []string
23+ height int
24+ ops func(*Mode, Snapshot)
25+ wantCursor int
26+ wantTop int
27+ }{
28+ {
29+ name: "initial viewport starts at bottom",
30+ lines: []string{"00", "01", "02", "03", "04"},
31+ height: 3,
32+ ops: func(*Mode, Snapshot) {},
33+ wantCursor: 4,
34+ wantTop: 2,
35+ },
36+ {
37+ name: "k clamps at top and scrolls viewport",
38+ lines: []string{"00", "01", "02", "03", "04"},
39+ height: 3,
40+ ops: func(m *Mode, s Snapshot) {
41+ m.Move(s, -99)
42+ },
43+ wantCursor: 0,
44+ wantTop: 0,
45+ },
46+ {
47+ name: "j clamps at bottom",
48+ lines: []string{"00", "01", "02", "03", "04"},
49+ height: 3,
50+ ops: func(m *Mode, s Snapshot) {
51+ m.Move(s, 99)
52+ },
53+ wantCursor: 4,
54+ wantTop: 2,
55+ },
56+ {
57+ name: "page up moves by viewport height",
58+ lines: []string{"00", "01", "02", "03", "04", "05", "06"},
59+ height: 3,
60+ ops: func(m *Mode, s Snapshot) {
61+ m.Page(s, -1)
62+ },
63+ wantCursor: 3,
64+ wantTop: 3,
65+ },
66+ {
67+ name: "g and G jump to document bounds",
68+ lines: []string{"00", "01", "02", "03", "04"},
69+ height: 3,
70+ ops: func(m *Mode, s Snapshot) {
71+ m.Top(s)
72+ m.Bottom(s)
73+ },
74+ wantCursor: 4,
75+ wantTop: 2,
76+ },
77+ }
78+
79+ for _, tt := range tests {
80+ t.Run(tt.name, func(t *testing.T) {
81+ s := snapshot(tt.lines, tt.height)
82+ m := NewMode(s)
83+ tt.ops(m, s)
84+ if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop {
85+ t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop)
86+ }
87+ })
88+ }
89+}
90+
91+func TestCopyModeSelectionPayloadAndInverse(t *testing.T) {
92+ s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2)
93+ m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true}
94+ m.Move(s, 1)
95+
96+ if got, want := m.SelectedText(s), "alpha\nbeta"; got != want {
97+ t.Fatalf("SelectedText() = %q, want %q", got, want)
98+ }
99+ frame := m.Render(s)
100+ for y := 0; y < 2; y++ {
101+ if !frame.At(0, y).Style.Inverse {
102+ t.Fatalf("row %d first cell not inverse", y)
103+ }
104+ }
105+}
106+
107+func TestOSC52Base64EncodingAndChunking(t *testing.T) {
108+ tests := []struct {
109+ name string
110+ text string
111+ }{
112+ {name: "small payload", text: "hello\nworld"},
113+ {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)},
114+ }
115+ for _, tt := range tests {
116+ t.Run(tt.name, func(t *testing.T) {
117+ chunks := OSC52(tt.text)
118+ joined := ""
119+ for _, chunk := range chunks {
120+ s := string(chunk)
121+ if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") {
122+ t.Fatalf("chunk %q missing OSC52 wrapper", s)
123+ }
124+ joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07")
125+ }
126+ decoded, err := base64.StdEncoding.DecodeString(joined)
127+ if err != nil {
128+ t.Fatalf("DecodeString() error = %v", err)
129+ }
130+ if string(decoded) != tt.text {
131+ t.Fatalf("decoded = %q, want %q", string(decoded), tt.text)
132+ }
133+ if tt.name == "chunked payload" && len(chunks) < 2 {
134+ t.Fatalf("len(chunks) = %d, want chunking", len(chunks))
135+ }
136+ })
137+ }
138+}
M internal/usecase/daemon/daemon.go +124 -7
@@ -143,11 +143,12 @@ type window struct {
143143 // scheduler and the connection handler — so the transport's single-writer
144144 // contract holds and Draw→Send stays atomic (correct output ordering).
145145 type attachedClient struct {
146- tr ports.Transport
147- rend *renderer.Renderer
148- size domain.Size
149- keys *keys.Router
150- sendMu sync.Mutex
146+ tr ports.Transport
147+ rend *renderer.Renderer
148+ size domain.Size
149+ keys *keys.Router
150+ copyMode *scopy.Mode
151+ sendMu sync.Mutex
151152 }
152153
153154 // send serialises a frame onto the client's transport.
@@ -679,12 +680,119 @@ func (s *session) activeWindow() *window {
679680 }
680681
681682 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
683+ if ac.copyMode != nil {
684+ d.handleCopyInput(sess, ac, data)
685+ return
686+ }
682687 if ac.keys == nil {
683688 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
684689 }
685690 ac.keys.Route(data)
686691 }
687692
693+func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
694+ win := sess.activeWindow()
695+ if win == nil {
696+ return
697+ }
698+ win.mu.Lock()
699+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
700+ win.mu.Unlock()
701+ ac.copyMode = scopy.NewMode(snap)
702+ d.paint(sess, ac, true)
703+}
704+
705+func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
706+ win := sess.activeWindow()
707+ if win == nil || ac.copyMode == nil {
708+ return
709+ }
710+
711+ win.mu.Lock()
712+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
713+ changed := false
714+ copyOut := false
715+ exit := false
716+ for i := 0; i < len(data); i++ {
717+ switch data[i] {
718+ case 'j':
719+ ac.copyMode.Move(snap, 1)
720+ changed = true
721+ case 'k':
722+ ac.copyMode.Move(snap, -1)
723+ changed = true
724+ case 'g':
725+ ac.copyMode.Top(snap)
726+ changed = true
727+ case 'G':
728+ ac.copyMode.Bottom(snap)
729+ changed = true
730+ case ' ':
731+ ac.copyMode.ToggleSelection()
732+ changed = true
733+ case '\r', '\n', 'y':
734+ copyOut = true
735+ exit = true
736+ case 'q', 0x03, 0x1b:
737+ if data[i] == 0x1b {
738+ consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:])
739+ if ok {
740+ i += consumed - 1
741+ changed = true
742+ continue
743+ }
744+ }
745+ exit = true
746+ }
747+ }
748+ text := ""
749+ if copyOut {
750+ text = ac.copyMode.SelectedText(snap)
751+ }
752+ win.mu.Unlock()
753+
754+ if copyOut {
755+ for _, chunk := range scopy.OSC52(text) {
756+ if err := ac.send(frameOutput(chunk)); err != nil {
757+ d.detachOnSendError(sess, ac)
758+ return
759+ }
760+ }
761+ }
762+ if exit {
763+ ac.copyMode = nil
764+ d.paint(sess, ac, true)
765+ return
766+ }
767+ if changed {
768+ d.paint(sess, ac, true)
769+ }
770+}
771+
772+func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
773+ if len(data) >= 3 && data[1] == '[' {
774+ switch data[2] {
775+ case 'A':
776+ m.Move(snap, -1)
777+ return 3, true
778+ case 'B':
779+ m.Move(snap, 1)
780+ return 3, true
781+ }
782+ }
783+ if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
784+ switch data[2] {
785+ case '5':
786+ m.Page(snap, -1)
787+ return 4, true
788+ case '6':
789+ m.Page(snap, 1)
790+ return 4, true
791+ }
792+ }
793+ return 0, false
794+}
795+
688796 type daemonKeyHandler struct {
689797 d *Daemon
690798 sess *session
@@ -723,7 +831,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
723831 h.d.closeWindow(h.sess, win, true)
724832 }
725833 case keys.ActionCopyMode:
726- // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.
834+ h.d.enterCopyMode(h.sess, h.ac)
727835 case keys.ActionRenameSession:
728836 h.sess.promoteEphemeral()
729837 h.d.paint(h.sess, h.ac, true)
@@ -893,10 +1001,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
8931001
8941002 ac.sendMu.Lock()
8951003 win.mu.Lock()
896- if reset {
1004+ if reset || ac.copyMode != nil {
8971005 ac.rend.Reset()
8981006 }
8991007 frame, damage := composeClientFrame(sess, win, reset)
1008+ if ac.copyMode != nil {
1009+ frame, damage = composeCopyClientFrame(ac.copyMode, win)
1010+ }
9001011 data, err := ac.rend.Draw(frame, damage)
9011012 win.screen.ClearDamage()
9021013 win.mu.Unlock()
@@ -934,6 +1045,12 @@ func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame,
9341045 return frame, damage
9351046 }
9361047
1048+func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) {
1049+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
1050+ frame := mode.Render(snap)
1051+ return frame, []renderer.Damage{renderer.FullRedraw()}
1052+}
1053+
9371054 func drawStatus(row []renderer.Cell, sess *session) {
9381055 for i := range row {
9391056 row[i] = renderer.BlankCell()
@@ -143,11 +143,12 @@ type window struct {
143 // scheduler and the connection handler — so the transport's single-writer143 // scheduler and the connection handler — so the transport's single-writer
144 // contract holds and Draw→Send stays atomic (correct output ordering).144 // contract holds and Draw→Send stays atomic (correct output ordering).
145 type attachedClient struct {145 type attachedClient struct {
146- tr ports.Transport146+ tr ports.Transport
147- rend *renderer.Renderer147+ rend *renderer.Renderer
148- size domain.Size148+ size domain.Size
149- keys *keys.Router149+ keys *keys.Router
150- sendMu sync.Mutex150+ copyMode *scopy.Mode
151+ sendMu sync.Mutex
151 }152 }
152 153
153 // send serialises a frame onto the client's transport.154 // send serialises a frame onto the client's transport.
@@ -679,12 +680,119 @@ func (s *session) activeWindow() *window {
679 }680 }
680 681
681 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {682 func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) {
683+ if ac.copyMode != nil {
684+ d.handleCopyInput(sess, ac, data)
685+ return
686+ }
682 if ac.keys == nil {687 if ac.keys == nil {
683 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})688 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac})
684 }689 }
685 ac.keys.Route(data)690 ac.keys.Route(data)
686 }691 }
687 692
693+func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
694+ win := sess.activeWindow()
695+ if win == nil {
696+ return
697+ }
698+ win.mu.Lock()
699+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
700+ win.mu.Unlock()
701+ ac.copyMode = scopy.NewMode(snap)
702+ d.paint(sess, ac, true)
703+}
704+
705+func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) {
706+ win := sess.activeWindow()
707+ if win == nil || ac.copyMode == nil {
708+ return
709+ }
710+
711+ win.mu.Lock()
712+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
713+ changed := false
714+ copyOut := false
715+ exit := false
716+ for i := 0; i < len(data); i++ {
717+ switch data[i] {
718+ case 'j':
719+ ac.copyMode.Move(snap, 1)
720+ changed = true
721+ case 'k':
722+ ac.copyMode.Move(snap, -1)
723+ changed = true
724+ case 'g':
725+ ac.copyMode.Top(snap)
726+ changed = true
727+ case 'G':
728+ ac.copyMode.Bottom(snap)
729+ changed = true
730+ case ' ':
731+ ac.copyMode.ToggleSelection()
732+ changed = true
733+ case '\r', '\n', 'y':
734+ copyOut = true
735+ exit = true
736+ case 'q', 0x03, 0x1b:
737+ if data[i] == 0x1b {
738+ consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:])
739+ if ok {
740+ i += consumed - 1
741+ changed = true
742+ continue
743+ }
744+ }
745+ exit = true
746+ }
747+ }
748+ text := ""
749+ if copyOut {
750+ text = ac.copyMode.SelectedText(snap)
751+ }
752+ win.mu.Unlock()
753+
754+ if copyOut {
755+ for _, chunk := range scopy.OSC52(text) {
756+ if err := ac.send(frameOutput(chunk)); err != nil {
757+ d.detachOnSendError(sess, ac)
758+ return
759+ }
760+ }
761+ }
762+ if exit {
763+ ac.copyMode = nil
764+ d.paint(sess, ac, true)
765+ return
766+ }
767+ if changed {
768+ d.paint(sess, ac, true)
769+ }
770+}
771+
772+func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) {
773+ if len(data) >= 3 && data[1] == '[' {
774+ switch data[2] {
775+ case 'A':
776+ m.Move(snap, -1)
777+ return 3, true
778+ case 'B':
779+ m.Move(snap, 1)
780+ return 3, true
781+ }
782+ }
783+ if len(data) >= 4 && data[1] == '[' && data[3] == '~' {
784+ switch data[2] {
785+ case '5':
786+ m.Page(snap, -1)
787+ return 4, true
788+ case '6':
789+ m.Page(snap, 1)
790+ return 4, true
791+ }
792+ }
793+ return 0, false
794+}
795+
688 type daemonKeyHandler struct {796 type daemonKeyHandler struct {
689 d *Daemon797 d *Daemon
690 sess *session798 sess *session
@@ -723,7 +831,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
723 h.d.closeWindow(h.sess, win, true)831 h.d.closeWindow(h.sess, win, true)
724 }832 }
725 case keys.ActionCopyMode:833 case keys.ActionCopyMode:
726- // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted.834+ h.d.enterCopyMode(h.sess, h.ac)
727 case keys.ActionRenameSession:835 case keys.ActionRenameSession:
728 h.sess.promoteEphemeral()836 h.sess.promoteEphemeral()
729 h.d.paint(h.sess, h.ac, true)837 h.d.paint(h.sess, h.ac, true)
@@ -893,10 +1001,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
893 1001
894 ac.sendMu.Lock()1002 ac.sendMu.Lock()
895 win.mu.Lock()1003 win.mu.Lock()
896- if reset {1004+ if reset || ac.copyMode != nil {
897 ac.rend.Reset()1005 ac.rend.Reset()
898 }1006 }
899 frame, damage := composeClientFrame(sess, win, reset)1007 frame, damage := composeClientFrame(sess, win, reset)
1008+ if ac.copyMode != nil {
1009+ frame, damage = composeCopyClientFrame(ac.copyMode, win)
1010+ }
900 data, err := ac.rend.Draw(frame, damage)1011 data, err := ac.rend.Draw(frame, damage)
901 win.screen.ClearDamage()1012 win.screen.ClearDamage()
902 win.mu.Unlock()1013 win.mu.Unlock()
@@ -934,6 +1045,12 @@ func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame,
934 return frame, damage1045 return frame, damage
935 }1046 }
936 1047
1048+func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) {
1049+ snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame)
1050+ frame := mode.Render(snap)
1051+ return frame, []renderer.Damage{renderer.FullRedraw()}
1052+}
1053+
937 func drawStatus(row []renderer.Cell, sess *session) {1054 func drawStatus(row []renderer.Cell, sess *session) {
938 for i := range row {1055 for i := range row {
939 row[i] = renderer.BlankCell()1056 row[i] = renderer.BlankCell()
M internal/usecase/daemon/daemon_test.go +94 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "log/slog"
7+ "strings"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -15,6 +16,7 @@ import (
1516 "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
1718 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
1820 "github.com/bnema/vev/pkg/renderer"
1921 "github.com/bnema/vev/pkg/vt"
2022 )
@@ -246,6 +248,98 @@ func activeWindowIndex(sess *session) int {
246248 return sess.active
247249 }
248250
251+func testRow(text string) []renderer.Cell {
252+ cells := make([]renderer.Cell, 0, len(text))
253+ for _, r := range text {
254+ cells = append(cells, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
255+ }
256+ return cells
257+}
258+
259+func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
260+ writes := make(chan []byte, 1)
261+ p, _ := newBlockingPTYWithWrites(t, writes)
262+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
263+ sess.windows[0].scrollback = scopy.NewScrollback(4)
264+ sess.windows[0].screen.Write([]byte("live"))
265+
266+ d.handleInput(sess, ac, []byte("\x1bu"))
267+
268+ if ac.copyMode == nil {
269+ t.Fatal("copy mode not entered")
270+ }
271+ select {
272+ case got := <-writes:
273+ t.Fatalf("copy-mode binding forwarded to PTY: %q", got)
274+ default:
275+ }
276+ out := awaitFrame(t, sends, ports.MsgOutput)
277+ msg, err := ports.UnmarshalOutput(out.Payload)
278+ require.NoError(t, err)
279+ if !strings.Contains(string(msg.Data), "[COPY]") {
280+ t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data))
281+ }
282+}
283+
284+func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
285+ writes := make(chan []byte, 1)
286+ p, _ := newBlockingPTYWithWrites(t, writes)
287+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
288+ sess.windows[0].scrollback = scopy.NewScrollback(4)
289+ sess.windows[0].scrollback.Append(testRow("old1 "))
290+ sess.windows[0].scrollback.Append(testRow("old2 "))
291+ sess.windows[0].screen.Write([]byte("live"))
292+
293+ d.enterCopyMode(sess, ac)
294+ awaitFrame(t, sends, ports.MsgOutput)
295+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
296+
297+ select {
298+ case got := <-writes:
299+ t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
300+ default:
301+ }
302+ out := awaitFrame(t, sends, ports.MsgOutput)
303+ msg, err := ports.UnmarshalOutput(out.Payload)
304+ require.NoError(t, err)
305+ if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
306+ t.Fatalf("OSC52 = %q, want %q", got, want)
307+ }
308+ if ac.copyMode != nil {
309+ t.Fatal("copy mode still active after yank")
310+ }
311+ live := awaitFrame(t, sends, ports.MsgOutput)
312+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
313+ require.NoError(t, err)
314+ if strings.Contains(string(liveMsg.Data), "[COPY]") {
315+ t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data))
316+ }
317+ if !strings.Contains(string(liveMsg.Data), "live") {
318+ t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
319+ }
320+}
321+
322+func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
323+ p, _ := newBlockingPTY(t)
324+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325+ sess.windows[0].scrollback = scopy.NewScrollback(4)
326+ sess.windows[0].screen.Write([]byte("live"))
327+
328+ d.enterCopyMode(sess, ac)
329+ awaitFrame(t, sends, ports.MsgOutput)
330+ d.handleInput(sess, ac, []byte("q"))
331+
332+ if ac.copyMode != nil {
333+ t.Fatal("copy mode still active after q")
334+ }
335+ out := awaitFrame(t, sends, ports.MsgOutput)
336+ msg, err := ports.UnmarshalOutput(out.Payload)
337+ require.NoError(t, err)
338+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
339+ t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
340+ }
341+}
342+
249343 func windowCount(sess *session) int {
250344 sess.mu.Lock()
251345 defer sess.mu.Unlock()
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7+ "strings"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -15,6 +16,7 @@ import (
15 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19+ scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
19 "github.com/bnema/vev/pkg/vt"21 "github.com/bnema/vev/pkg/vt"
20 )22 )
@@ -246,6 +248,98 @@ func activeWindowIndex(sess *session) int {
246 return sess.active248 return sess.active
247 }249 }
248 250
251+func testRow(text string) []renderer.Cell {
252+ cells := make([]renderer.Cell, 0, len(text))
253+ for _, r := range text {
254+ cells = append(cells, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
255+ }
256+ return cells
257+}
258+
259+func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) {
260+ writes := make(chan []byte, 1)
261+ p, _ := newBlockingPTYWithWrites(t, writes)
262+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
263+ sess.windows[0].scrollback = scopy.NewScrollback(4)
264+ sess.windows[0].screen.Write([]byte("live"))
265+
266+ d.handleInput(sess, ac, []byte("\x1bu"))
267+
268+ if ac.copyMode == nil {
269+ t.Fatal("copy mode not entered")
270+ }
271+ select {
272+ case got := <-writes:
273+ t.Fatalf("copy-mode binding forwarded to PTY: %q", got)
274+ default:
275+ }
276+ out := awaitFrame(t, sends, ports.MsgOutput)
277+ msg, err := ports.UnmarshalOutput(out.Payload)
278+ require.NoError(t, err)
279+ if !strings.Contains(string(msg.Data), "[COPY]") {
280+ t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data))
281+ }
282+}
283+
284+func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) {
285+ writes := make(chan []byte, 1)
286+ p, _ := newBlockingPTYWithWrites(t, writes)
287+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
288+ sess.windows[0].scrollback = scopy.NewScrollback(4)
289+ sess.windows[0].scrollback.Append(testRow("old1 "))
290+ sess.windows[0].scrollback.Append(testRow("old2 "))
291+ sess.windows[0].screen.Write([]byte("live"))
292+
293+ d.enterCopyMode(sess, ac)
294+ awaitFrame(t, sends, ports.MsgOutput)
295+ d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'})
296+
297+ select {
298+ case got := <-writes:
299+ t.Fatalf("copy-mode navigation forwarded to PTY: %q", got)
300+ default:
301+ }
302+ out := awaitFrame(t, sends, ports.MsgOutput)
303+ msg, err := ports.UnmarshalOutput(out.Payload)
304+ require.NoError(t, err)
305+ if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want {
306+ t.Fatalf("OSC52 = %q, want %q", got, want)
307+ }
308+ if ac.copyMode != nil {
309+ t.Fatal("copy mode still active after yank")
310+ }
311+ live := awaitFrame(t, sends, ports.MsgOutput)
312+ liveMsg, err := ports.UnmarshalOutput(live.Payload)
313+ require.NoError(t, err)
314+ if strings.Contains(string(liveMsg.Data), "[COPY]") {
315+ t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data))
316+ }
317+ if !strings.Contains(string(liveMsg.Data), "live") {
318+ t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data))
319+ }
320+}
321+
322+func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) {
323+ p, _ := newBlockingPTY(t)
324+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
325+ sess.windows[0].scrollback = scopy.NewScrollback(4)
326+ sess.windows[0].screen.Write([]byte("live"))
327+
328+ d.enterCopyMode(sess, ac)
329+ awaitFrame(t, sends, ports.MsgOutput)
330+ d.handleInput(sess, ac, []byte("q"))
331+
332+ if ac.copyMode != nil {
333+ t.Fatal("copy mode still active after q")
334+ }
335+ out := awaitFrame(t, sends, ports.MsgOutput)
336+ msg, err := ports.UnmarshalOutput(out.Payload)
337+ require.NoError(t, err)
338+ if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") {
339+ t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data))
340+ }
341+}
342+
249 func windowCount(sess *session) int {343 func windowCount(sess *session) int {
250 sess.mu.Lock()344 sess.mu.Lock()
251 defer sess.mu.Unlock()345 defer sess.mu.Unlock()

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close