detached 354d6b6

feat(client): send remote clipboard images from attached input

commit
354d6b689dd79dd4e2ba0d293cf7f9680d06ca48
author
brice · · 2026-09-22 20:56 UTC
parent
8a96e64
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +113 -11
M internal/app/broker_client.go +7 -0
@@ -7,6 +7,7 @@ import (
77 "os"
88 "sync"
99
10+ "github.com/bnema/vev/internal/adapters/clipboard"
1011 "github.com/bnema/vev/internal/adapters/clock"
1112 "github.com/bnema/vev/internal/adapters/term"
1213 "github.com/bnema/vev/internal/domain"
@@ -42,6 +43,7 @@ type brokerClientConfig struct {
4243 Connector ports.BrokerConnector
4344 Terminal ports.Terminal
4445 Clock ports.Clock
46+ Clipboard ports.ClipboardReader
4547 UI *client.UI
4648 // InitialNavigation is the one-shot intent decided before the connection.
4749 // Its zero value is InitialNavigationPicker, which opens nothing.
@@ -218,12 +220,17 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
218220 if sessionEnv.Cwd != "" {
219221 attachmentEnv.Cwd = sessionEnv.Cwd
220222 }
223+ reader := cfg.Clipboard
224+ if reader == nil {
225+ reader = clipboard.New()
226+ }
221227 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
222228 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
223229 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
224230 Connector: cfg.Connector,
225231 Terminal: cfg.Terminal,
226232 Clock: clk,
233+ Clipboard: reader,
227234 UI: cfg.UI,
228235 Picker: picker,
229236 InitialNavigation: cfg.InitialNavigation,
@@ -7,6 +7,7 @@ import (
7 "os"7 "os"
8 "sync"8 "sync"
9 9
10+ "github.com/bnema/vev/internal/adapters/clipboard"
10 "github.com/bnema/vev/internal/adapters/clock"11 "github.com/bnema/vev/internal/adapters/clock"
11 "github.com/bnema/vev/internal/adapters/term"12 "github.com/bnema/vev/internal/adapters/term"
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
@@ -42,6 +43,7 @@ type brokerClientConfig struct {
42 Connector ports.BrokerConnector43 Connector ports.BrokerConnector
43 Terminal ports.Terminal44 Terminal ports.Terminal
44 Clock ports.Clock45 Clock ports.Clock
46+ Clipboard ports.ClipboardReader
45 UI *client.UI47 UI *client.UI
46 // InitialNavigation is the one-shot intent decided before the connection.48 // InitialNavigation is the one-shot intent decided before the connection.
47 // Its zero value is InitialNavigationPicker, which opens nothing.49 // Its zero value is InitialNavigationPicker, which opens nothing.
@@ -218,12 +220,17 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error {
218 if sessionEnv.Cwd != "" {220 if sessionEnv.Cwd != "" {
219 attachmentEnv.Cwd = sessionEnv.Cwd221 attachmentEnv.Cwd = sessionEnv.Cwd
220 }222 }
223+ reader := cfg.Clipboard
224+ if reader == nil {
225+ reader = clipboard.New()
226+ }
221 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)227 picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor)
222 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}228 presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState}
223 supervisor, err := client.NewSupervisor(client.SupervisorConfig{229 supervisor, err := client.NewSupervisor(client.SupervisorConfig{
224 Connector: cfg.Connector,230 Connector: cfg.Connector,
225 Terminal: cfg.Terminal,231 Terminal: cfg.Terminal,
226 Clock: clk,232 Clock: clk,
233+ Clipboard: reader,
227 UI: cfg.UI,234 UI: cfg.UI,
228 Picker: picker,235 Picker: picker,
229 InitialNavigation: cfg.InitialNavigation,236 InitialNavigation: cfg.InitialNavigation,
M internal/usecase/client/attachment_input.go +45 -9
@@ -3,6 +3,7 @@ package client
33 import (
44 "context"
55 "fmt"
6+ "log/slog"
67 "sync"
78 "time"
89
@@ -41,10 +42,11 @@ type attachmentInput struct {
4142 // included) is withheld before it is forwarded as ordinary input.
4243 markerTimer ports.Timer
4344
44- paste *pasteCoalescer
45- mu sync.Mutex
46- queued [][]byte
47- wake chan struct{}
45+ paste *pasteCoalescer
46+ clipboard *clipboardIntercept
47+ mu sync.Mutex
48+ queued []protocol.ClientMessage
49+ wake chan struct{}
4850
4951 // palette is nil when detection is disabled: no retained theme or no
5052 // query seam on the foreground. Replies are stripped either way.
@@ -97,7 +99,7 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati
9799 return event
98100 }
99101
100-func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
102+func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
101103 in := &attachmentInput{
102104 worker: w,
103105 fg: fg,
@@ -108,6 +110,16 @@ func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, str
108110 themes: w.cfg.Theme,
109111 }
110112 in.paste = newPasteCoalescer(in.clock, in.enqueue)
113+ if !w.cfg.Request.Local && w.cfg.Clipboard != nil {
114+ in.clipboard = &clipboardIntercept{
115+ ctx: ctx, coalescer: in.paste, reader: w.cfg.Clipboard,
116+ log: slog.Default(), next: in.paste.Scan,
117+ sendImage: func(mime string, data []byte) {
118+ in.enqueueImage(mime, data)
119+ },
120+ sendNotice: func(action uint8) { in.enqueueNotice(action) },
121+ }
122+ }
111123 if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
112124 in.query = query
113125 in.palette = newPaletteGenerationCoordinator()
@@ -121,8 +133,20 @@ func (in *attachmentInput) enqueue(data []byte) {
121133 if len(data) == 0 {
122134 return
123135 }
136+ in.queue(protocol.Input{Data: append([]byte(nil), data...)})
137+}
138+
139+func (in *attachmentInput) enqueueImage(mime string, data []byte) {
140+ in.queue(protocol.ImagePush{Mime: mime, Data: append([]byte(nil), data...)})
141+}
142+
143+func (in *attachmentInput) enqueueNotice(action uint8) {
144+ in.queue(protocol.ClientNotice{Action: action})
145+}
146+
147+func (in *attachmentInput) queue(message protocol.ClientMessage) {
124148 in.mu.Lock()
125- in.queued = append(in.queued, append([]byte(nil), data...))
149+ in.queued = append(in.queued, message)
126150 in.mu.Unlock()
127151 select {
128152 case in.wake <- struct{}{}:
@@ -261,7 +285,11 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da
261285 if err != nil || consumed {
262286 return err
263287 }
264- in.paste.Scan(data)
288+ if in.clipboard != nil {
289+ in.clipboard.Scan(data)
290+ } else {
291+ in.paste.Scan(data)
292+ }
265293 return in.flush(ctx)
266294 }
267295
@@ -271,8 +299,16 @@ func (in *attachmentInput) flush(ctx context.Context) error {
271299 queued := in.queued
272300 in.queued = nil
273301 in.mu.Unlock()
274- for _, data := range queued {
275- if err := in.send(ctx, protocol.Input{InputSeq: in.nextSeq(), Data: data}); err != nil {
302+ for _, message := range queued {
303+ switch m := message.(type) {
304+ case protocol.Input:
305+ m.InputSeq = in.nextSeq()
306+ message = m
307+ case protocol.ImagePush:
308+ m.InputSeq = in.nextSeq()
309+ message = m
310+ }
311+ if err := in.send(ctx, message); err != nil {
276312 return err
277313 }
278314 }
@@ -3,6 +3,7 @@ package client
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "log/slog"
6 "sync"7 "sync"
7 "time"8 "time"
8 9
@@ -41,10 +42,11 @@ type attachmentInput struct {
41 // included) is withheld before it is forwarded as ordinary input.42 // included) is withheld before it is forwarded as ordinary input.
42 markerTimer ports.Timer43 markerTimer ports.Timer
43 44
44- paste *pasteCoalescer45+ paste *pasteCoalescer
45- mu sync.Mutex46+ clipboard *clipboardIntercept
46- queued [][]byte47+ mu sync.Mutex
47- wake chan struct{}48+ queued []protocol.ClientMessage
49+ wake chan struct{}
48 50
49 // palette is nil when detection is disabled: no retained theme or no51 // palette is nil when detection is disabled: no retained theme or no
50 // query seam on the foreground. Replies are stripped either way.52 // query seam on the foreground. Replies are stripped either way.
@@ -97,7 +99,7 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati
97 return event99 return event
98 }100 }
99 101
100-func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {102+func newAttachmentInput(ctx context.Context, w *sessionAttachmentWorker, fg AttachmentForeground, stream ports.BrokerLogicalConnection, picker *attachmentMovePicker) *attachmentInput {
101 in := &attachmentInput{103 in := &attachmentInput{
102 worker: w,104 worker: w,
103 fg: fg,105 fg: fg,
@@ -108,6 +110,16 @@ func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, str
108 themes: w.cfg.Theme,110 themes: w.cfg.Theme,
109 }111 }
110 in.paste = newPasteCoalescer(in.clock, in.enqueue)112 in.paste = newPasteCoalescer(in.clock, in.enqueue)
113+ if !w.cfg.Request.Local && w.cfg.Clipboard != nil {
114+ in.clipboard = &clipboardIntercept{
115+ ctx: ctx, coalescer: in.paste, reader: w.cfg.Clipboard,
116+ log: slog.Default(), next: in.paste.Scan,
117+ sendImage: func(mime string, data []byte) {
118+ in.enqueueImage(mime, data)
119+ },
120+ sendNotice: func(action uint8) { in.enqueueNotice(action) },
121+ }
122+ }
111 if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {123 if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil {
112 in.query = query124 in.query = query
113 in.palette = newPaletteGenerationCoordinator()125 in.palette = newPaletteGenerationCoordinator()
@@ -121,8 +133,20 @@ func (in *attachmentInput) enqueue(data []byte) {
121 if len(data) == 0 {133 if len(data) == 0 {
122 return134 return
123 }135 }
136+ in.queue(protocol.Input{Data: append([]byte(nil), data...)})
137+}
138+
139+func (in *attachmentInput) enqueueImage(mime string, data []byte) {
140+ in.queue(protocol.ImagePush{Mime: mime, Data: append([]byte(nil), data...)})
141+}
142+
143+func (in *attachmentInput) enqueueNotice(action uint8) {
144+ in.queue(protocol.ClientNotice{Action: action})
145+}
146+
147+func (in *attachmentInput) queue(message protocol.ClientMessage) {
124 in.mu.Lock()148 in.mu.Lock()
125- in.queued = append(in.queued, append([]byte(nil), data...))149+ in.queued = append(in.queued, message)
126 in.mu.Unlock()150 in.mu.Unlock()
127 select {151 select {
128 case in.wake <- struct{}{}:152 case in.wake <- struct{}{}:
@@ -261,7 +285,11 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da
261 if err != nil || consumed {285 if err != nil || consumed {
262 return err286 return err
263 }287 }
264- in.paste.Scan(data)288+ if in.clipboard != nil {
289+ in.clipboard.Scan(data)
290+ } else {
291+ in.paste.Scan(data)
292+ }
265 return in.flush(ctx)293 return in.flush(ctx)
266 }294 }
267 295
@@ -271,8 +299,16 @@ func (in *attachmentInput) flush(ctx context.Context) error {
271 queued := in.queued299 queued := in.queued
272 in.queued = nil300 in.queued = nil
273 in.mu.Unlock()301 in.mu.Unlock()
274- for _, data := range queued {302+ for _, message := range queued {
275- if err := in.send(ctx, protocol.Input{InputSeq: in.nextSeq(), Data: data}); err != nil {303+ switch m := message.(type) {
304+ case protocol.Input:
305+ m.InputSeq = in.nextSeq()
306+ message = m
307+ case protocol.ImagePush:
308+ m.InputSeq = in.nextSeq()
309+ message = m
310+ }
311+ if err := in.send(ctx, message); err != nil {
276 return err312 return err
277 }313 }
278 }314 }
M internal/usecase/client/attachment_input_test.go +55 -1
@@ -10,7 +10,10 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1315 "github.com/bnema/vev/internal/protocol"
16+ "github.com/stretchr/testify/mock"
1417 )
1518
1619 // Ported from the old client's palette_attach_generation_test.go and paste
@@ -40,6 +43,10 @@ type inputHarness struct {
4043 // clock is separate from the host clock, so every timer on it belongs to the
4144 // input path.
4245 func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
46+ return startInputHarnessWithConfig(t, themes, sessionTestRequest(true), nil)
47+}
48+
49+func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {
4350 t.Helper()
4451 reader := &inputTestReader{chunks: make(chan []byte, 16)}
4552 pump := newTerminalInputPump(reader)
@@ -56,9 +63,10 @@ func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
5663 events: make(chan AttachmentEvent, 1),
5764 }
5865 host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})
59- cfg := sessionTestWorkerConfig(sessionTestRequest(true))
66+ cfg := sessionTestWorkerConfig(request)
6067 cfg.Clock = h.clock
6168 cfg.Theme = themes
69+ cfg.Clipboard = clipboard
6270 cfg.TrueColor = true
6371 worker, err := newSessionAttachmentWorker(cfg)
6472 require.NoError(t, err)
@@ -124,6 +132,52 @@ func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) {
124132 require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)
125133 }
126134
135+func TestAttachmentInputRemoteClipboardImageAndFallback(t *testing.T) {
136+ for _, tt := range []struct {
137+ name string
138+ mime string
139+ data []byte
140+ err error
141+ want protocol.ClientMessage
142+ }{
143+ {name: "image", mime: "image/png", data: []byte{1, 2}, want: protocol.ImagePush{Mime: "image/png", Data: []byte{1, 2}}},
144+ {name: "no image forwards key", err: ports.ErrNoClipboardImage, want: protocol.Input{Data: []byte{ctrlV}}},
145+ {name: "oversized image forwards key", mime: "image/png", data: make([]byte, maxClipboardImagePush+1), want: protocol.Input{Data: []byte{ctrlV}}},
146+ } {
147+ t.Run(tt.name, func(t *testing.T) {
148+ reader := portsmocks.NewMockClipboardReader(t)
149+ reader.EXPECT().ReadImage(mock.Anything).Return(tt.mime, tt.data, tt.err).Once()
150+ h := startInputHarnessWithConfig(t, nil, sessionTestRequest(false), reader)
151+ h.send("before\x16after")
152+ require.Eventually(t, func() bool {
153+ return len(h.stream.messages()) >= 4
154+ }, 5*time.Second, time.Millisecond)
155+ h.end(t)
156+ var got []protocol.ClientMessage
157+ for _, msg := range h.stream.messages() {
158+ switch msg.(type) {
159+ case protocol.Input, protocol.ImagePush, protocol.ClientNotice:
160+ got = append(got, msg)
161+ }
162+ }
163+ require.IsType(t, protocol.Input{}, got[0])
164+ require.Equal(t, []byte("before"), got[0].(protocol.Input).Data)
165+ if tt.name == "oversized image forwards key" {
166+ require.Equal(t, protocol.ClientNotice{Action: protocol.ClientNoticeClipboardTooLarge}, got[1])
167+ got = append(got[:1], got[2:]...)
168+ }
169+ switch want := tt.want.(type) {
170+ case protocol.Input:
171+ require.Equal(t, want.Data, got[1].(protocol.Input).Data)
172+ case protocol.ImagePush:
173+ require.Equal(t, want.Mime, got[1].(protocol.ImagePush).Mime)
174+ require.Equal(t, want.Data, got[1].(protocol.ImagePush).Data)
175+ }
176+ require.Equal(t, []byte("after"), got[2].(protocol.Input).Data)
177+ })
178+ }
179+}
180+
127181 func paletteReply(foreground, background string, slot uint8, color string) string {
128182 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
129183 }
@@ -10,7 +10,10 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 renderer "github.com/bnema/vev-vt"12 renderer "github.com/bnema/vev-vt"
13+ "github.com/bnema/vev/internal/ports"
14+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 "github.com/bnema/vev/internal/protocol"15 "github.com/bnema/vev/internal/protocol"
16+ "github.com/stretchr/testify/mock"
14 )17 )
15 18
16 // Ported from the old client's palette_attach_generation_test.go and paste19 // Ported from the old client's palette_attach_generation_test.go and paste
@@ -40,6 +43,10 @@ type inputHarness struct {
40 // clock is separate from the host clock, so every timer on it belongs to the43 // clock is separate from the host clock, so every timer on it belongs to the
41 // input path.44 // input path.
42 func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {45 func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
46+ return startInputHarnessWithConfig(t, themes, sessionTestRequest(true), nil)
47+}
48+
49+func startInputHarnessWithConfig(t *testing.T, themes *terminalThemeState, request ports.BrokerOpenStreamRequest, clipboard ports.ClipboardReader) *inputHarness {
43 t.Helper()50 t.Helper()
44 reader := &inputTestReader{chunks: make(chan []byte, 16)}51 reader := &inputTestReader{chunks: make(chan []byte, 16)}
45 pump := newTerminalInputPump(reader)52 pump := newTerminalInputPump(reader)
@@ -56,9 +63,10 @@ func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness {
56 events: make(chan AttachmentEvent, 1),63 events: make(chan AttachmentEvent, 1),
57 }64 }
58 host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})65 host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump})
59- cfg := sessionTestWorkerConfig(sessionTestRequest(true))66+ cfg := sessionTestWorkerConfig(request)
60 cfg.Clock = h.clock67 cfg.Clock = h.clock
61 cfg.Theme = themes68 cfg.Theme = themes
69+ cfg.Clipboard = clipboard
62 cfg.TrueColor = true70 cfg.TrueColor = true
63 worker, err := newSessionAttachmentWorker(cfg)71 worker, err := newSessionAttachmentWorker(cfg)
64 require.NoError(t, err)72 require.NoError(t, err)
@@ -124,6 +132,52 @@ func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) {
124 require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)132 require.Eventually(t, func() bool { return strings.Count(h.term.written(), query) >= n }, 5*time.Second, time.Millisecond, "worker never wrote query #%d %q", n, query)
125 }133 }
126 134
135+func TestAttachmentInputRemoteClipboardImageAndFallback(t *testing.T) {
136+ for _, tt := range []struct {
137+ name string
138+ mime string
139+ data []byte
140+ err error
141+ want protocol.ClientMessage
142+ }{
143+ {name: "image", mime: "image/png", data: []byte{1, 2}, want: protocol.ImagePush{Mime: "image/png", Data: []byte{1, 2}}},
144+ {name: "no image forwards key", err: ports.ErrNoClipboardImage, want: protocol.Input{Data: []byte{ctrlV}}},
145+ {name: "oversized image forwards key", mime: "image/png", data: make([]byte, maxClipboardImagePush+1), want: protocol.Input{Data: []byte{ctrlV}}},
146+ } {
147+ t.Run(tt.name, func(t *testing.T) {
148+ reader := portsmocks.NewMockClipboardReader(t)
149+ reader.EXPECT().ReadImage(mock.Anything).Return(tt.mime, tt.data, tt.err).Once()
150+ h := startInputHarnessWithConfig(t, nil, sessionTestRequest(false), reader)
151+ h.send("before\x16after")
152+ require.Eventually(t, func() bool {
153+ return len(h.stream.messages()) >= 4
154+ }, 5*time.Second, time.Millisecond)
155+ h.end(t)
156+ var got []protocol.ClientMessage
157+ for _, msg := range h.stream.messages() {
158+ switch msg.(type) {
159+ case protocol.Input, protocol.ImagePush, protocol.ClientNotice:
160+ got = append(got, msg)
161+ }
162+ }
163+ require.IsType(t, protocol.Input{}, got[0])
164+ require.Equal(t, []byte("before"), got[0].(protocol.Input).Data)
165+ if tt.name == "oversized image forwards key" {
166+ require.Equal(t, protocol.ClientNotice{Action: protocol.ClientNoticeClipboardTooLarge}, got[1])
167+ got = append(got[:1], got[2:]...)
168+ }
169+ switch want := tt.want.(type) {
170+ case protocol.Input:
171+ require.Equal(t, want.Data, got[1].(protocol.Input).Data)
172+ case protocol.ImagePush:
173+ require.Equal(t, want.Mime, got[1].(protocol.ImagePush).Mime)
174+ require.Equal(t, want.Data, got[1].(protocol.ImagePush).Data)
175+ }
176+ require.Equal(t, []byte("after"), got[2].(protocol.Input).Data)
177+ })
178+ }
179+}
180+
127 func paletteReply(foreground, background string, slot uint8, color string) string {181 func paletteReply(foreground, background string, slot uint8, color string) string {
128 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"182 return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y"
129 }183 }
M internal/usecase/client/attachment_session.go +3 -1
@@ -76,6 +76,8 @@ type sessionAttachmentConfig struct {
7676 // the worker queries the terminal palette and sends protocol.Theme; when
7777 // nil, replies are still stripped from input but no query is written.
7878 Theme *terminalThemeState
79+ // Clipboard reads images on remote attachments only.
80+ Clipboard ports.ClipboardReader
7981 // Tab is the exact tab a picker tab row committed; its zero value
8082 // attaches at session level.
8183 Tab attachmentTab
@@ -302,7 +304,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
302304 var samePeerRequests uint64
303305 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
304306 defer picker.stopEscape()
305- input := newAttachmentInput(w, fg, stream, picker)
307+ input := newAttachmentInput(ctx, w, fg, stream, picker)
306308 defer input.close()
307309 if err := input.start(ctx); err != nil {
308310 return w.settle(ctx, fg, stream, token, err)
@@ -76,6 +76,8 @@ type sessionAttachmentConfig struct {
76 // the worker queries the terminal palette and sends protocol.Theme; when76 // the worker queries the terminal palette and sends protocol.Theme; when
77 // nil, replies are still stripped from input but no query is written.77 // nil, replies are still stripped from input but no query is written.
78 Theme *terminalThemeState78 Theme *terminalThemeState
79+ // Clipboard reads images on remote attachments only.
80+ Clipboard ports.ClipboardReader
79 // Tab is the exact tab a picker tab row committed; its zero value81 // Tab is the exact tab a picker tab row committed; its zero value
80 // attaches at session level.82 // attaches at session level.
81 Tab attachmentTab83 Tab attachmentTab
@@ -302,7 +304,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen
302 var samePeerRequests uint64304 var samePeerRequests uint64
303 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}305 picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)}
304 defer picker.stopEscape()306 defer picker.stopEscape()
305- input := newAttachmentInput(w, fg, stream, picker)307+ input := newAttachmentInput(ctx, w, fg, stream, picker)
306 defer input.close()308 defer input.close()
307 if err := input.start(ctx); err != nil {309 if err := input.start(ctx); err != nil {
308 return w.settle(ctx, fg, stream, token, err)310 return w.settle(ctx, fg, stream, token, err)
M internal/usecase/client/supervisor.go +2 -0
@@ -289,6 +289,8 @@ type SupervisorConfig struct {
289289 // Clock supplies the retry timers. Required; use ports.Clock, not the wall
290290 // clock.
291291 Clock ports.Clock
292+ // Clipboard is the client-side image clipboard for remote attachments.
293+ Clipboard ports.ClipboardReader
292294 // Render asks the composition to paint the current state. It is called for
293295 // visible transitions, after picker catalogue publications, and for every
294296 // resize invalidation the supervisor's serialized waits consume. The
@@ -289,6 +289,8 @@ type SupervisorConfig struct {
289 // Clock supplies the retry timers. Required; use ports.Clock, not the wall289 // Clock supplies the retry timers. Required; use ports.Clock, not the wall
290 // clock.290 // clock.
291 Clock ports.Clock291 Clock ports.Clock
292+ // Clipboard is the client-side image clipboard for remote attachments.
293+ Clipboard ports.ClipboardReader
292 // Render asks the composition to paint the current state. It is called for294 // Render asks the composition to paint the current state. It is called for
293 // visible transitions, after picker catalogue publications, and for every295 // visible transitions, after picker catalogue publications, and for every
294 // resize invalidation the supervisor's serialized waits consume. The296 // resize invalidation the supervisor's serialized waits consume. The
M internal/usecase/client/supervisor_attach.go +1 -0
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325325 BeforeAttached: beforeAttached,
326326 Clock: s.cfg.Clock,
327327 Theme: &s.theme,
328+ Clipboard: s.cfg.Clipboard,
328329 Tab: tab,
329330 })
330331 if err != nil {
@@ -325,6 +325,7 @@ func (s *Supervisor) newAttachmentWorker(request ports.BrokerOpenStreamRequest,
325 BeforeAttached: beforeAttached,325 BeforeAttached: beforeAttached,
326 Clock: s.cfg.Clock,326 Clock: s.cfg.Clock,
327 Theme: &s.theme,327 Theme: &s.theme,
328+ Clipboard: s.cfg.Clipboard,
328 Tab: tab,329 Tab: tab,
329 })330 })
330 if err != nil {331 if err != nil {

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