detached a84c06c

fix(client): address bracketed paste review findings

commit
a84c06c36d9a0a5881e862575fbd9bd69282c9ad
author
brice · · 2026-07-04 16:07 UTC
parent
f87757e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +127 -28
A internal/ports/paste.go +9 -0
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
@@ -0,0 +1,9 @@
1+package ports
2+
3+// Bracketed-paste markers bracket terminal paste payloads when bracketed-paste
4+// mode is enabled. The client coalescer and daemon key router both depend on
5+// these bytes staying in sync.
6+var (
7+ BracketedPasteOpenMarker = []byte("\x1b[200~")
8+ BracketedPasteCloseMarker = []byte("\x1b[201~")
9+)
M internal/usecase/client/client_test.go +85 -0
@@ -107,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
107107
108108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
109109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
110135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
111136 tm := portsmocks.NewMockTerminal(t)
112137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -486,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
486511 }
487512 }
488513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
489574 func TestAttachForwardsResize(t *testing.T) {
490575 var out bytes.Buffer
491576 var restoreCount atomic.Int32
@@ -107,6 +107,31 @@ func (r *oneShotBlockingReader) Read(p []byte) (int, error) {
107 107
108 func (r *oneShotBlockingReader) unblock() { close(r.done) }108 func (r *oneShotBlockingReader) unblock() { close(r.done) }
109 109
110+type chunkedBlockingReader struct {
111+ chunks [][]byte
112+ done chan struct{}
113+ mu sync.Mutex
114+ next int
115+}
116+
117+func newChunkedBlockingReader(chunks ...[]byte) *chunkedBlockingReader {
118+ return &chunkedBlockingReader{chunks: chunks, done: make(chan struct{})}
119+}
120+
121+func (r *chunkedBlockingReader) Read(p []byte) (int, error) {
122+ r.mu.Lock()
123+ defer r.mu.Unlock()
124+ if r.next < len(r.chunks) {
125+ chunk := r.chunks[r.next]
126+ r.next++
127+ return copy(p, chunk), nil
128+ }
129+ <-r.done
130+ return 0, io.EOF
131+}
132+
133+func (r *chunkedBlockingReader) unblock() { close(r.done) }
134+
110 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {135 func newHappyTerminal(t *testing.T, out *bytes.Buffer, restoreCount *atomic.Int32, resizeCh chan domain.Size) (*portsmocks.MockTerminal, *blockingReader) {
111 tm := portsmocks.NewMockTerminal(t)136 tm := portsmocks.NewMockTerminal(t)
112 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()137 tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
@@ -486,6 +511,66 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
486 }511 }
487 }512 }
488 513
514+func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
515+ var out bytes.Buffer
516+ var restoreCount atomic.Int32
517+ resizeCh := make(chan domain.Size)
518+ paste := []byte("\x1b[200~hello\nworld\x1b[201~")
519+ input := newChunkedBlockingReader([]byte("\x1b[200~hello\n"), []byte("world\x1b[201~"))
520+ defer input.unblock()
521+
522+ tm := portsmocks.NewMockTerminal(t)
523+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
524+ tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
525+ tm.EXPECT().In().Return(input).Maybe()
526+ tm.EXPECT().Out().Return(&out).Maybe()
527+ tm.EXPECT().Flush().Return(nil).Maybe()
528+ tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
529+
530+ gotInput := make(chan []byte, 1)
531+ allowDetach := make(chan struct{})
532+ tr := portsmocks.NewMockTransport(t)
533+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
534+ tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
535+ in, err := ports.UnmarshalInput(f.Payload)
536+ require.NoError(t, err)
537+ gotInput <- in.Data
538+ close(allowDetach)
539+ return nil
540+ }).Once()
541+ welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
542+ detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
543+ recvCh := make(chan recvItem, 1)
544+ recvCh <- recvItem{f: welcome}
545+ closed := make(chan struct{})
546+ tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
547+ select {
548+ case it := <-recvCh:
549+ return it.f, it.err
550+ case <-allowDetach:
551+ select {
552+ case <-closed:
553+ return ports.Frame{}, io.EOF
554+ default:
555+ close(closed)
556+ return detached, nil
557+ }
558+ case <-closed:
559+ return ports.Frame{}, io.EOF
560+ }
561+ }).Maybe()
562+ tr.EXPECT().Close().Return(nil).Once()
563+
564+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
565+ require.NoError(t, err)
566+ select {
567+ case got := <-gotInput:
568+ require.Equal(t, paste, got, "split bracketed paste must arrive as one intact MsgInput frame")
569+ case <-time.After(2 * time.Second):
570+ t.Fatal("split bracketed paste input was not sent")
571+ }
572+}
573+
489 func TestAttachForwardsResize(t *testing.T) {574 func TestAttachForwardsResize(t *testing.T) {
490 var out bytes.Buffer575 var out bytes.Buffer
491 var restoreCount atomic.Int32576 var restoreCount atomic.Int32
M internal/usecase/client/paste_coalescer.go +17 -17
@@ -8,14 +8,6 @@ import (
88 "github.com/bnema/vev/internal/ports"
99 )
1010
11-// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12-// text with these so the receiving application can tell a paste from typed
13-// input.
14-var (
15- pasteOpenMarker = []byte("\x1b[200~")
16- pasteCloseMarker = []byte("\x1b[201~")
17-)
18-
1911 const (
2012 // pasteFlushDelay bounds how long a trailing strict prefix of the opening
2113 // marker is held before it is emitted as ordinary bytes. It only has to
@@ -91,9 +83,17 @@ func (c *pasteCoalescer) Scan(data []byte) {
9183 func (c *pasteCoalescer) process(data []byte) {
9284 for {
9385 if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
9492 c.buf = append(c.buf, data...)
95- data = nil
96- idx := bytes.Index(c.buf, pasteCloseMarker)
93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
9797 if idx < 0 {
9898 if len(c.buf) > maxPasteBuffer {
9999 c.emit(c.buf)
@@ -101,7 +101,7 @@ func (c *pasteCoalescer) process(data []byte) {
101101 }
102102 return
103103 }
104- end := idx + len(pasteCloseMarker)
104+ end := idx + len(ports.BracketedPasteCloseMarker)
105105 c.emit(c.buf[:end])
106106 rest := append([]byte(nil), c.buf[end:]...)
107107 c.resetBuffering()
@@ -113,7 +113,7 @@ func (c *pasteCoalescer) process(data []byte) {
113113 return
114114 }
115115
116- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {
116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117117 if idx > 0 {
118118 c.emit(data[:idx])
119119 }
@@ -240,12 +240,12 @@ func (c *pasteCoalescer) stopSafetyTimer() {
240240 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
241241 // "\x1b[20"). A full marker match is handled by the caller before this runs.
242242 func trailingOpenPrefixLen(data []byte) int {
243- max := len(pasteOpenMarker) - 1
244- if max > len(data) {
245- max = len(data)
243+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
244+ if matchLimit > len(data) {
245+ matchLimit = len(data)
246246 }
247- for n := max; n >= 1; n-- {
248- if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {
247+ for n := matchLimit; n >= 1; n-- {
248+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
249249 return n
250250 }
251251 }
@@ -8,14 +8,6 @@ import (
8 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
9 )9 )
10 10
11-// Bracketed-paste markers. A terminal in bracketed-paste mode brackets pasted
12-// text with these so the receiving application can tell a paste from typed
13-// input.
14-var (
15- pasteOpenMarker = []byte("\x1b[200~")
16- pasteCloseMarker = []byte("\x1b[201~")
17-)
18-
19 const (11 const (
20 // pasteFlushDelay bounds how long a trailing strict prefix of the opening12 // pasteFlushDelay bounds how long a trailing strict prefix of the opening
21 // marker is held before it is emitted as ordinary bytes. It only has to13 // marker is held before it is emitted as ordinary bytes. It only has to
@@ -91,9 +83,17 @@ func (c *pasteCoalescer) Scan(data []byte) {
91 func (c *pasteCoalescer) process(data []byte) {83 func (c *pasteCoalescer) process(data []byte) {
92 for {84 for {
93 if c.buffering {85 if c.buffering {
86+ searchFrom := len(c.buf)
87+ if overlap := len(ports.BracketedPasteCloseMarker) - 1; searchFrom > overlap {
88+ searchFrom -= overlap
89+ } else {
90+ searchFrom = 0
91+ }
94 c.buf = append(c.buf, data...)92 c.buf = append(c.buf, data...)
95- data = nil93+ idx := bytes.Index(c.buf[searchFrom:], ports.BracketedPasteCloseMarker)
96- idx := bytes.Index(c.buf, pasteCloseMarker)94+ if idx >= 0 {
95+ idx += searchFrom
96+ }
97 if idx < 0 {97 if idx < 0 {
98 if len(c.buf) > maxPasteBuffer {98 if len(c.buf) > maxPasteBuffer {
99 c.emit(c.buf)99 c.emit(c.buf)
@@ -101,7 +101,7 @@ func (c *pasteCoalescer) process(data []byte) {
101 }101 }
102 return102 return
103 }103 }
104- end := idx + len(pasteCloseMarker)104+ end := idx + len(ports.BracketedPasteCloseMarker)
105 c.emit(c.buf[:end])105 c.emit(c.buf[:end])
106 rest := append([]byte(nil), c.buf[end:]...)106 rest := append([]byte(nil), c.buf[end:]...)
107 c.resetBuffering()107 c.resetBuffering()
@@ -113,7 +113,7 @@ func (c *pasteCoalescer) process(data []byte) {
113 return113 return
114 }114 }
115 115
116- if idx := bytes.Index(data, pasteOpenMarker); idx >= 0 {116+ if idx := bytes.Index(data, ports.BracketedPasteOpenMarker); idx >= 0 {
117 if idx > 0 {117 if idx > 0 {
118 c.emit(data[:idx])118 c.emit(data[:idx])
119 }119 }
@@ -240,12 +240,12 @@ func (c *pasteCoalescer) stopSafetyTimer() {
240 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or240 // is a non-empty strict prefix of the opening marker (e.g. a trailing "\x1b" or
241 // "\x1b[20"). A full marker match is handled by the caller before this runs.241 // "\x1b[20"). A full marker match is handled by the caller before this runs.
242 func trailingOpenPrefixLen(data []byte) int {242 func trailingOpenPrefixLen(data []byte) int {
243- max := len(pasteOpenMarker) - 1243+ matchLimit := len(ports.BracketedPasteOpenMarker) - 1
244- if max > len(data) {244+ if matchLimit > len(data) {
245- max = len(data)245+ matchLimit = len(data)
246 }246 }
247- for n := max; n >= 1; n-- {247+ for n := matchLimit; n >= 1; n-- {
248- if bytes.Equal(data[len(data)-n:], pasteOpenMarker[:n]) {248+ if bytes.Equal(data[len(data)-n:], ports.BracketedPasteOpenMarker[:n]) {
249 return n249 return n
250 }250 }
251 }251 }
M internal/usecase/client/paste_coalescer_test.go +12 -0
@@ -117,6 +117,18 @@ func TestPasteCoalescerByteByByte(t *testing.T) {
117117 "paste fed one byte at a time must still emit as one contiguous paste")
118118 }
119119
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
120132 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121133 pc, clk, col := newTestCoalescer()
122134 defer pc.Close()
@@ -117,6 +117,18 @@ func TestPasteCoalescerByteByByte(t *testing.T) {
117 "paste fed one byte at a time must still emit as one contiguous paste")117 "paste fed one byte at a time must still emit as one contiguous paste")
118 }118 }
119 119
120+func TestPasteCoalescerBackToBackPastesInOneRead(t *testing.T) {
121+ pc, _, col := newTestCoalescer()
122+ defer pc.Close()
123+
124+ first := []byte("\x1b[200~first\x1b[201~")
125+ second := []byte("\x1b[200~second\x1b[201~")
126+ pc.Scan(append(append([]byte(nil), first...), second...))
127+
128+ require.Equal(t, [][]byte{first, second}, col.snapshot(),
129+ "back-to-back pastes in one read must emit as two intact paste frames")
130+}
131+
120 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {132 func TestPasteCoalescerLoneEscapeFlushedAfterTimer(t *testing.T) {
121 pc, clk, col := newTestCoalescer()133 pc, clk, col := newTestCoalescer()
122 defer pc.Close()134 defer pc.Close()
M internal/usecase/keys/router.go +3 -10
@@ -17,13 +17,6 @@ const (
1717 ESCDelay = 40 * time.Millisecond
1818 )
1919
20-// Bracketed-paste markers. A paste whose opening and closing markers land in
21-// one frame is forwarded verbatim so its content can never trip a key binding.
22-var (
23- pasteOpenMarker = []byte("\x1b[200~")
24- pasteCloseMarker = []byte("\x1b[201~")
25-)
26-
2720 // Action is an intercepted vev key binding.
2821 type Action int
2922
@@ -112,7 +105,7 @@ func (r *Router) route(data []byte) {
112105 return
113106 }
114107 remaining := data[i+1:]
115- if bytes.HasPrefix(data[i:], pasteOpenMarker) {
108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
116109 // A bracketed paste whose closing marker is in this same frame is
117110 // forwarded verbatim: pasted content bytes (including embedded
118111 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
@@ -120,8 +113,8 @@ func (r *Router) route(data []byte) {
120113 // fall through to today's per-byte routing (the '[' passes through
121114 // as a control prefix); Part A keeps pastes single-frame in
122115 // practice, so no cross-frame paste state is tracked here.
123- if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {
124- end := i + rel + len(pasteCloseMarker)
116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
125118 buf = append(buf, data[i:end]...)
126119 i = end
127120 continue
@@ -17,13 +17,6 @@ const (
17 ESCDelay = 40 * time.Millisecond17 ESCDelay = 40 * time.Millisecond
18 )18 )
19 19
20-// Bracketed-paste markers. A paste whose opening and closing markers land in
21-// one frame is forwarded verbatim so its content can never trip a key binding.
22-var (
23- pasteOpenMarker = []byte("\x1b[200~")
24- pasteCloseMarker = []byte("\x1b[201~")
25-)
26-
27 // Action is an intercepted vev key binding.20 // Action is an intercepted vev key binding.
28 type Action int21 type Action int
29 22
@@ -112,7 +105,7 @@ func (r *Router) route(data []byte) {
112 return105 return
113 }106 }
114 remaining := data[i+1:]107 remaining := data[i+1:]
115- if bytes.HasPrefix(data[i:], pasteOpenMarker) {108+ if bytes.HasPrefix(data[i:], ports.BracketedPasteOpenMarker) {
116 // A bracketed paste whose closing marker is in this same frame is109 // A bracketed paste whose closing marker is in this same frame is
117 // forwarded verbatim: pasted content bytes (including embedded110 // forwarded verbatim: pasted content bytes (including embedded
118 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never111 // ESC+letter Alt lookalikes or ESC [ 1 ; 3 A sequences) must never
@@ -120,8 +113,8 @@ func (r *Router) route(data []byte) {
120 // fall through to today's per-byte routing (the '[' passes through113 // fall through to today's per-byte routing (the '[' passes through
121 // as a control prefix); Part A keeps pastes single-frame in114 // as a control prefix); Part A keeps pastes single-frame in
122 // practice, so no cross-frame paste state is tracked here.115 // practice, so no cross-frame paste state is tracked here.
123- if rel := bytes.Index(data[i:], pasteCloseMarker); rel >= 0 {116+ if rel := bytes.Index(data[i:], ports.BracketedPasteCloseMarker); rel >= 0 {
124- end := i + rel + len(pasteCloseMarker)117+ end := i + rel + len(ports.BracketedPasteCloseMarker)
125 buf = append(buf, data[i:end]...)118 buf = append(buf, data[i:end]...)
126 i = end119 i = end
127 continue120 continue
M internal/usecase/keys/router_test.go +1 -1
@@ -506,7 +506,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
506506 h := &captureHandler{}
507507 r := NewRouter(clk, h)
508508
509- paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)
509+ paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510510 r.Route(paste)
511511
512512 require.Empty(t, h.actions, "paste content must not fire any binding")
@@ -506,7 +506,7 @@ func TestRouterForwardsSingleFramePasteVerbatim(t *testing.T) {
506 h := &captureHandler{}506 h := &captureHandler{}
507 r := NewRouter(clk, h)507 r := NewRouter(clk, h)
508 508
509- paste := append(append(append([]byte(nil), pasteOpenMarker...), pasteBindingLookalikes...), pasteCloseMarker...)509+ paste := append(append(append([]byte(nil), ports.BracketedPasteOpenMarker...), pasteBindingLookalikes...), ports.BracketedPasteCloseMarker...)
510 r.Route(paste)510 r.Route(paste)
511 511
512 require.Empty(t, h.actions, "paste content must not fire any binding")512 require.Empty(t, h.actions, "paste content must not fire any binding")

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