detached 76a1920

fix(clipboard): harden image transfer review findings

commit
76a19204659fa942e508bc16675c7070f1877da7
author
brice · · 2026-07-04 17:03 UTC
parent
e9c558f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +53 -29
M internal/adapters/clipboard/wlpaste.go +3 -2
@@ -29,8 +29,9 @@ const listTypesTimeout = 1 * time.Second
2929 // wl-paste that hangs mid-read (as opposed to during the --list-types probe,
3030 // which listTypesTimeout already covers) would stall the client's stdin pump
3131 // indefinitely, since ReadImage is called with context.Background() from the
32-// interceptor. Generous for a read of up to the 10 MiB cap.
33-const defaultReadTimeout = 10 * time.Second
32+// interceptor. Keep this short so a slow clipboard read cannot noticeably
33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
3435
3536 // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
3637 // holds no state and is safe for concurrent use; every call runs a fresh
@@ -29,8 +29,9 @@ const listTypesTimeout = 1 * time.Second
29 // wl-paste that hangs mid-read (as opposed to during the --list-types probe,29 // wl-paste that hangs mid-read (as opposed to during the --list-types probe,
30 // which listTypesTimeout already covers) would stall the client's stdin pump30 // which listTypesTimeout already covers) would stall the client's stdin pump
31 // indefinitely, since ReadImage is called with context.Background() from the31 // indefinitely, since ReadImage is called with context.Background() from the
32-// interceptor. Generous for a read of up to the 10 MiB cap.32+// interceptor. Keep this short so a slow clipboard read cannot noticeably
33-const defaultReadTimeout = 10 * time.Second33+// freeze typing for longer than the image-transfer MVP's small payload cap.
34+const defaultReadTimeout = time.Second
34 35
35 // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It36 // WlPaste implements ports.ClipboardReader by shelling out to wl-paste. It
36 // holds no state and is safe for concurrent use; every call runs a fresh37 // holds no state and is safe for concurrent use; every call runs a fresh
M internal/ports/frame.go +1 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 6
4+const ProtocolVersion uint16 = 7
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 64+const ProtocolVersion uint16 = 7
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 6 {
7- t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion)
6+ if ProtocolVersion != 7 {
7+ t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)
88 }
99 }
1010
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 6 {6+ if ProtocolVersion != 7 {
7- t.Fatalf("ProtocolVersion = %d, want 6", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 7", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/wire.go +12 -6
@@ -91,8 +91,9 @@ type Theme struct {
9191 // a temp file and injected into the focused pane's PTY as a path (see
9292 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
9393 type ImagePush struct {
94- Mime string
95- Data []byte
94+ InputSeq uint64
95+ Mime string
96+ Data []byte
9697 }
9798
9899 // Detach asks the daemon to detach the current client without killing the
@@ -368,21 +369,26 @@ func UnmarshalInput(b []byte) (Input, error) {
368369 // MarshalImagePush encodes m into an ImagePush message payload.
369370 func MarshalImagePush(m ImagePush) []byte {
370371 w := payloadWriter{}
372+ w.putUint64(m.InputSeq)
371373 w.putString(m.Mime)
372374 w.putBytes(m.Data)
373375 return w.b
374376 }
375377
376-// UnmarshalImagePush decodes an ImagePush message payload. After the
377-// length-prefixed mime string, the rest of the payload is data; there is no
378-// length prefix for it.
378+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
379+// input sequence and length-prefixed mime string, the rest of the payload is
380+// data; there is no length prefix for it.
379381 func UnmarshalImagePush(b []byte) (ImagePush, error) {
380382 r := payloadReader{b: b}
383+ seq, err := r.getUint64()
384+ if err != nil {
385+ return ImagePush{}, err
386+ }
381387 mime, err := r.getString()
382388 if err != nil {
383389 return ImagePush{}, err
384390 }
385- return ImagePush{Mime: mime, Data: r.rest()}, nil
391+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
386392 }
387393
388394 // MarshalResize encodes m into a Resize message payload.
@@ -91,8 +91,9 @@ type Theme struct {
91 // a temp file and injected into the focused pane's PTY as a path (see91 // a temp file and injected into the focused pane's PTY as a path (see
92 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).92 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
93 type ImagePush struct {93 type ImagePush struct {
94- Mime string94+ InputSeq uint64
95- Data []byte95+ Mime string
96+ Data []byte
96 }97 }
97 98
98 // Detach asks the daemon to detach the current client without killing the99 // Detach asks the daemon to detach the current client without killing the
@@ -368,21 +369,26 @@ func UnmarshalInput(b []byte) (Input, error) {
368 // MarshalImagePush encodes m into an ImagePush message payload.369 // MarshalImagePush encodes m into an ImagePush message payload.
369 func MarshalImagePush(m ImagePush) []byte {370 func MarshalImagePush(m ImagePush) []byte {
370 w := payloadWriter{}371 w := payloadWriter{}
372+ w.putUint64(m.InputSeq)
371 w.putString(m.Mime)373 w.putString(m.Mime)
372 w.putBytes(m.Data)374 w.putBytes(m.Data)
373 return w.b375 return w.b
374 }376 }
375 377
376-// UnmarshalImagePush decodes an ImagePush message payload. After the378+// UnmarshalImagePush decodes an ImagePush message payload. After the fixed
377-// length-prefixed mime string, the rest of the payload is data; there is no379+// input sequence and length-prefixed mime string, the rest of the payload is
378-// length prefix for it.380+// data; there is no length prefix for it.
379 func UnmarshalImagePush(b []byte) (ImagePush, error) {381 func UnmarshalImagePush(b []byte) (ImagePush, error) {
380 r := payloadReader{b: b}382 r := payloadReader{b: b}
383+ seq, err := r.getUint64()
384+ if err != nil {
385+ return ImagePush{}, err
386+ }
381 mime, err := r.getString()387 mime, err := r.getString()
382 if err != nil {388 if err != nil {
383 return ImagePush{}, err389 return ImagePush{}, err
384 }390 }
385- return ImagePush{Mime: mime, Data: r.rest()}, nil391+ return ImagePush{InputSeq: seq, Mime: mime, Data: r.rest()}, nil
386 }392 }
387 393
388 // MarshalResize encodes m into a Resize message payload.394 // MarshalResize encodes m into a Resize message payload.
M internal/ports/wire_test.go +4 -4
@@ -148,10 +148,10 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
148148 }{
149149 {
150150 name: "png data",
151- msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
152- want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
151+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
152+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
153153 },
154- {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}},
154+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
155155 }
156156
157157 for _, tt := range tests {
@@ -171,7 +171,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
171171 }
172172
173173 full := MarshalImagePush(tests[0].msg)
174- assertAllPrefixesFail(t, full[:11], UnmarshalImagePush)
174+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
175175 }
176176
177177 func TestThemeGoldenAndRoundTrip(t *testing.T) {
@@ -148,10 +148,10 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
148 }{148 }{
149 {149 {
150 name: "png data",150 name: "png data",
151- msg: ImagePush{Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},151+ msg: ImagePush{InputSeq: 7, Mime: "image/png", Data: []byte{0x01, 0x02, 0x03}},
152- want: []byte{0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},152+ want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x00, 0x09, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x2f, 0x70, 0x6e, 0x67, 0x01, 0x02, 0x03},
153 },153 },
154- {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00}},154+ {name: "empty", msg: ImagePush{}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
155 }155 }
156 156
157 for _, tt := range tests {157 for _, tt := range tests {
@@ -171,7 +171,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
171 }171 }
172 172
173 full := MarshalImagePush(tests[0].msg)173 full := MarshalImagePush(tests[0].msg)
174- assertAllPrefixesFail(t, full[:11], UnmarshalImagePush)174+ assertAllPrefixesFail(t, full[:19], UnmarshalImagePush)
175 }175 }
176 176
177 func TestThemeGoldenAndRoundTrip(t *testing.T) {177 func TestThemeGoldenAndRoundTrip(t *testing.T) {
M internal/usecase/client/client.go +2 -1
@@ -511,7 +511,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
511511 reader: clipboard,
512512 log: log,
513513 sendImage: func(mime string, data []byte) {
514- send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})
514+ inputSeq++
515+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
515516 },
516517 next: coalescer.Scan,
517518 }
@@ -511,7 +511,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
511 reader: clipboard,511 reader: clipboard,
512 log: log,512 log: log,
513 sendImage: func(mime string, data []byte) {513 sendImage: func(mime string, data []byte) {
514- send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{Mime: mime, Data: data})})514+ inputSeq++
515+ send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
515 },516 },
516 next: coalescer.Scan,517 next: coalescer.Scan,
517 }518 }
M internal/usecase/client/clipboard_intercept.go +3 -3
@@ -13,9 +13,9 @@ import (
1313 const ctrlV = 0x16
1414
1515 // maxClipboardImagePush caps the image the client will send in one
16-// MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit
17-// means one frame always suffices under this cap.
18-const maxClipboardImagePush = 10 << 20 // 10 MiB
16+// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18+const maxClipboardImagePush = 1 << 20 // 1 MiB
1919
2020 // clipboardIntercept sits between the terminal's theme scanner and the paste
2121 // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
@@ -13,9 +13,9 @@ import (
13 const ctrlV = 0x1613 const ctrlV = 0x16
14 14
15 // maxClipboardImagePush caps the image the client will send in one15 // maxClipboardImagePush caps the image the client will send in one
16-// MsgImagePush, mirroring the daemon's independent cap. A 16 MiB frame limit16+// MsgImagePush, mirroring the daemon's independent cap. The 1 MiB cap fits
17-// means one frame always suffices under this cap.17+// both sshstdio and the datagram transport's fragmented payload ceiling.
18-const maxClipboardImagePush = 10 << 20 // 10 MiB18+const maxClipboardImagePush = 1 << 20 // 1 MiB
19 19
20 // clipboardIntercept sits between the terminal's theme scanner and the paste20 // clipboardIntercept sits between the terminal's theme scanner and the paste
21 // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V21 // coalescer on a remote attach. It splits pass-through stdin bytes on Ctrl+V
M internal/usecase/client/clipboard_intercept_test.go +2 -1
@@ -119,6 +119,7 @@ func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testi
119119
120120 select {
121121 case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
122123 require.Equal(t, "image/png", ip.Mime)
123124 require.Equal(t, []byte("PNGDATA"), ip.Data)
124125 case <-time.After(2 * time.Second):
@@ -137,7 +138,7 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
137138 }
138139
139140 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
140- huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap
141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
141142 clip := portsmocks.NewMockClipboardReader(t)
142143 clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
143144
@@ -119,6 +119,7 @@ func TestRunRemoteClipboardCtrlVWithImageSendsImagePushNoCtrlVForwarded(t *testi
119 119
120 select {120 select {
121 case ip := <-gotImage:121 case ip := <-gotImage:
122+ require.Equal(t, uint64(2), ip.InputSeq)
122 require.Equal(t, "image/png", ip.Mime)123 require.Equal(t, "image/png", ip.Mime)
123 require.Equal(t, []byte("PNGDATA"), ip.Data)124 require.Equal(t, []byte("PNGDATA"), ip.Data)
124 case <-time.After(2 * time.Second):125 case <-time.After(2 * time.Second):
@@ -137,7 +138,7 @@ func TestRunRemoteClipboardCtrlVWithNoImageForwardsCtrlV(t *testing.T) {
137 }138 }
138 139
139 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {140 func TestRunRemoteClipboardOversizedImageForwardsCtrlV(t *testing.T) {
140- huge := bytes.Repeat([]byte{0xff}, 10<<20+1) // one byte over the 10 MiB cap141+ huge := bytes.Repeat([]byte{0xff}, 1<<20+1) // one byte over the image-push cap
141 clip := portsmocks.NewMockClipboardReader(t)142 clip := portsmocks.NewMockClipboardReader(t)
142 clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()143 clip.EXPECT().ReadImage(mock.Anything).Return("image/png", huge, nil).Once()
143 144
M internal/usecase/daemon/client.go +1 -1
@@ -488,7 +488,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
488488 }
489489 case ports.MsgImagePush:
490490 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
491- d.handleImagePush(sess, ip)
491+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
492492 }
493493 case ports.MsgDetach:
494494 d.clientGone(sess, ac, tr, true)
@@ -488,7 +488,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
488 }488 }
489 case ports.MsgImagePush:489 case ports.MsgImagePush:
490 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {490 if ip, derr := ports.UnmarshalImagePush(f.Payload); derr == nil {
491- d.handleImagePush(sess, ip)491+ d.handleSequencedImagePush(sess, ac, ip.InputSeq, ip)
492 }492 }
493 case ports.MsgDetach:493 case ports.MsgDetach:
494 d.clientGone(sess, ac, tr, true)494 d.clientGone(sess, ac, tr, true)
M internal/usecase/daemon/clipboard.go +23 -8
@@ -4,9 +4,7 @@
44 package daemon
55
66 import (
7- "fmt"
87 "os"
9- "path/filepath"
108
119 "github.com/bnema/vev/internal/ports"
1210 scopy "github.com/bnema/vev/internal/usecase/copy"
@@ -14,9 +12,9 @@ import (
1412
1513 // maxImagePushSize independently caps an accepted MsgImagePush payload,
1614 // defending against an old or foreign client even though the client is
17-// expected to enforce the same cap before sending
18-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
19-const maxImagePushSize = 10 << 20 // 10 MiB
15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
16+// ImagePush fits the datagram transport's fragmented payload ceiling.
17+const maxImagePushSize = 1 << 20 // 1 MiB
2018
2119 // Bracketed-paste markers used to wrap the injected clip path when the
2220 // focused pane has DEC private mode 2004 enabled. Mirrors the client's
@@ -27,6 +25,14 @@ var (
2725 clipPasteCloseMarker = []byte("\x1b[201~")
2826 )
2927
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
3036 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
3137 // file's path into the session's focused pane, as if typed/pasted there.
3238 // Oversized or empty payloads are rejected without panicking. A write
@@ -56,9 +62,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
5662 if dir == "" {
5763 dir = os.TempDir()
5864 }
59- name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime))
60- path := filepath.Join(dir, name)
61- if err := os.WriteFile(path, ip.Data, 0o600); err != nil {
65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
66+ if err != nil {
67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
6277 return "", err
6378 }
6479 sess.mu.Lock()
@@ -4,9 +4,7 @@
4 package daemon4 package daemon
5 5
6 import (6 import (
7- "fmt"
8 "os"7 "os"
9- "path/filepath"
10 8
11 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
12 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
@@ -14,9 +12,9 @@ import (
14 12
15 // maxImagePushSize independently caps an accepted MsgImagePush payload,13 // maxImagePushSize independently caps an accepted MsgImagePush payload,
16 // defending against an old or foreign client even though the client is14 // defending against an old or foreign client even though the client is
17-// expected to enforce the same cap before sending15+// expected to enforce the same cap before sending. Kept at 1 MiB so one
18-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).16+// ImagePush fits the datagram transport's fragmented payload ceiling.
19-const maxImagePushSize = 10 << 20 // 10 MiB17+const maxImagePushSize = 1 << 20 // 1 MiB
20 18
21 // Bracketed-paste markers used to wrap the injected clip path when the19 // Bracketed-paste markers used to wrap the injected clip path when the
22 // focused pane has DEC private mode 2004 enabled. Mirrors the client's20 // focused pane has DEC private mode 2004 enabled. Mirrors the client's
@@ -27,6 +25,14 @@ var (
27 clipPasteCloseMarker = []byte("\x1b[201~")25 clipPasteCloseMarker = []byte("\x1b[201~")
28 )26 )
29 27
28+// handleSequencedImagePush routes an ImagePush through the same input-sequence
29+// surface as ordinary keystrokes. Echo prediction is still conservative; the
30+// sequence is carried so resume/dedup plumbing can order image pushes with
31+// surrounding input when that state reader grows beyond ordinary input.
32+func (d *Daemon) handleSequencedImagePush(sess *session, _ *attachedClient, _ uint64, ip ports.ImagePush) {
33+ d.handleImagePush(sess, ip)
34+}
35+
30 // handleImagePush writes an ImagePush's bytes to a temp file and injects the36 // handleImagePush writes an ImagePush's bytes to a temp file and injects the
31 // file's path into the session's focused pane, as if typed/pasted there.37 // file's path into the session's focused pane, as if typed/pasted there.
32 // Oversized or empty payloads are rejected without panicking. A write38 // Oversized or empty payloads are rejected without panicking. A write
@@ -56,9 +62,18 @@ func (d *Daemon) writeClipboardImage(sess *session, ip ports.ImagePush) (string,
56 if dir == "" {62 if dir == "" {
57 dir = os.TempDir()63 dir = os.TempDir()
58 }64 }
59- name := fmt.Sprintf("vev-clip-%d.%s", d.clock.Now().UnixNano(), clipboardExt(ip.Mime))65+ f, err := os.CreateTemp(dir, "vev-clip-*."+clipboardExt(ip.Mime))
60- path := filepath.Join(dir, name)66+ if err != nil {
61- if err := os.WriteFile(path, ip.Data, 0o600); err != nil {67+ return "", err
68+ }
69+ path := f.Name()
70+ if _, err := f.Write(ip.Data); err != nil {
71+ _ = f.Close()
72+ _ = os.Remove(path)
73+ return "", err
74+ }
75+ if err := f.Close(); err != nil {
76+ _ = os.Remove(path)
62 return "", err77 return "", err
63 }78 }
64 sess.mu.Lock()79 sess.mu.Lock()

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