feat(client): send remote clipboard images from attached input
- commit
- 354d6b689dd79dd4e2ba0d293cf7f9680d06ca48
- author
- brice · · 2026-09-22 20:56 UTC
- parent
- 8a96e64
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +113 -11
M internal/app/broker_client.go +7 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "sync" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/adapters/clipboard" | |
| 10 | 11 | "github.com/bnema/vev/internal/adapters/clock" | |
| 11 | 12 | "github.com/bnema/vev/internal/adapters/term" | |
| 12 | 13 | "github.com/bnema/vev/internal/domain" | |
| @@ -42,6 +43,7 @@ type brokerClientConfig struct { | |||
| 42 | 43 | Connector ports.BrokerConnector | |
| 43 | 44 | Terminal ports.Terminal | |
| 44 | 45 | Clock ports.Clock | |
| 46 | + | Clipboard ports.ClipboardReader | |
| 45 | 47 | UI *client.UI | |
| 46 | 48 | // InitialNavigation is the one-shot intent decided before the connection. | |
| 47 | 49 | // Its zero value is InitialNavigationPicker, which opens nothing. | |
| @@ -218,12 +220,17 @@ func runBrokerClient(ctx context.Context, cfg brokerClientConfig) error { | |||
| 218 | 220 | if sessionEnv.Cwd != "" { | |
| 219 | 221 | attachmentEnv.Cwd = sessionEnv.Cwd | |
| 220 | 222 | } | |
| 223 | + | reader := cfg.Clipboard | |
| 224 | + | if reader == nil { | |
| 225 | + | reader = clipboard.New() | |
| 226 | + | } | |
| 221 | 227 | picker := client.NewPicker(clk, 0, attachmentEnv.TrueColor) | |
| 222 | 228 | presentation := &brokerClientPresentation{terminal: cfg.Terminal, picker: picker, ui: cfg.UI, onState: cfg.OnState} | |
| 223 | 229 | supervisor, err := client.NewSupervisor(client.SupervisorConfig{ | |
| 224 | 230 | Connector: cfg.Connector, | |
| 225 | 231 | Terminal: cfg.Terminal, | |
| 226 | 232 | Clock: clk, | |
| 233 | + | Clipboard: reader, | |
| 227 | 234 | UI: cfg.UI, | |
| 228 | 235 | Picker: picker, | |
| 229 | 236 | 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.BrokerConnector | 43 | Connector ports.BrokerConnector | ||
| 43 | Terminal ports.Terminal | 44 | Terminal ports.Terminal | ||
| 44 | Clock ports.Clock | 45 | Clock ports.Clock | ||
| 46 | + | Clipboard ports.ClipboardReader | |||
| 45 | UI *client.UI | 47 | 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.Cwd | 221 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "log/slog" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -41,10 +42,11 @@ type attachmentInput struct { | |||
| 41 | 42 | // included) is withheld before it is forwarded as ordinary input. | |
| 42 | 43 | markerTimer ports.Timer | |
| 43 | 44 | ||
| 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{} | |
| 48 | 50 | ||
| 49 | 51 | // palette is nil when detection is disabled: no retained theme or no | |
| 50 | 52 | // query seam on the foreground. Replies are stripped either way. | |
| @@ -97,7 +99,7 @@ func (d *paletteDeadline) fired(kind paletteGenerationEventKind) paletteGenerati | |||
| 97 | 99 | 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 | 103 | in := &attachmentInput{ | |
| 102 | 104 | worker: w, | |
| 103 | 105 | fg: fg, | |
| @@ -108,6 +110,16 @@ func newAttachmentInput(w *sessionAttachmentWorker, fg AttachmentForeground, str | |||
| 108 | 110 | themes: w.cfg.Theme, | |
| 109 | 111 | } | |
| 110 | 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 | 123 | if query, ok := fg.(attachmentQueryForeground); ok && in.themes != nil { | |
| 112 | 124 | in.query = query | |
| 113 | 125 | in.palette = newPaletteGenerationCoordinator() | |
| @@ -121,8 +133,20 @@ func (in *attachmentInput) enqueue(data []byte) { | |||
| 121 | 133 | if len(data) == 0 { | |
| 122 | 134 | 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 | 148 | in.mu.Lock() | |
| 125 | - | in.queued = append(in.queued, append([]byte(nil), data...)) | |
| 149 | + | in.queued = append(in.queued, message) | |
| 126 | 150 | in.mu.Unlock() | |
| 127 | 151 | select { | |
| 128 | 152 | case in.wake <- struct{}{}: | |
| @@ -261,7 +285,11 @@ func (in *attachmentInput) route(ctx context.Context, state outputApplyState, da | |||
| 261 | 285 | if err != nil || consumed { | |
| 262 | 286 | 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 | 293 | return in.flush(ctx) | |
| 266 | 294 | } | |
| 267 | 295 | ||
| @@ -271,8 +299,16 @@ func (in *attachmentInput) flush(ctx context.Context) error { | |||
| 271 | 299 | queued := in.queued | |
| 272 | 300 | in.queued = nil | |
| 273 | 301 | 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 { | |
| 276 | 312 | return err | |
| 277 | 313 | } | |
| 278 | 314 | } | |
| @@ -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.Timer | 43 | markerTimer ports.Timer | ||
| 43 | 44 | ||||
| 44 | - | paste *pasteCoalescer | 45 | + | paste *pasteCoalescer |
| 45 | - | mu sync.Mutex | 46 | + | clipboard *clipboardIntercept |
| 46 | - | queued [][]byte | 47 | + | 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 no | 51 | // 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 event | 99 | 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 = query | 124 | 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 | return | 134 | 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 err | 286 | 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.queued | 299 | queued := in.queued | ||
| 272 | in.queued = nil | 300 | 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 err | 312 | return err | ||
| 277 | } | 313 | } | ||
| 278 | } | 314 | } | ||
M internal/usecase/client/attachment_input_test.go +55 -1
| @@ -10,7 +10,10 @@ import ( | |||
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ||
| 12 | 12 | renderer "github.com/bnema/vev-vt" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 13 | 15 | "github.com/bnema/vev/internal/protocol" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 14 | 17 | ) | |
| 15 | 18 | ||
| 16 | 19 | // Ported from the old client's palette_attach_generation_test.go and paste | |
| @@ -40,6 +43,10 @@ type inputHarness struct { | |||
| 40 | 43 | // clock is separate from the host clock, so every timer on it belongs to the | |
| 41 | 44 | // input path. | |
| 42 | 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 | 50 | t.Helper() | |
| 44 | 51 | reader := &inputTestReader{chunks: make(chan []byte, 16)} | |
| 45 | 52 | pump := newTerminalInputPump(reader) | |
| @@ -56,9 +63,10 @@ func startInputHarness(t *testing.T, themes *terminalThemeState) *inputHarness { | |||
| 56 | 63 | events: make(chan AttachmentEvent, 1), | |
| 57 | 64 | } | |
| 58 | 65 | host := newAttachmentHost(attachmentHostConfig{Terminal: h.term, Clock: newSupervisorTestClock(), Input: pump}) | |
| 59 | - | cfg := sessionTestWorkerConfig(sessionTestRequest(true)) | |
| 66 | + | cfg := sessionTestWorkerConfig(request) | |
| 60 | 67 | cfg.Clock = h.clock | |
| 61 | 68 | cfg.Theme = themes | |
| 69 | + | cfg.Clipboard = clipboard | |
| 62 | 70 | cfg.TrueColor = true | |
| 63 | 71 | worker, err := newSessionAttachmentWorker(cfg) | |
| 64 | 72 | require.NoError(t, err) | |
| @@ -124,6 +132,52 @@ func (h *inputHarness) awaitQueries(t *testing.T, query string, n int) { | |||
| 124 | 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 | 181 | func paletteReply(foreground, background string, slot uint8, color string) string { | |
| 128 | 182 | return "\x1b]10;" + foreground + "\a\x1b]11;" + background + "\a\x1b]4;" + string(rune('0'+slot)) + ";" + color + "\a\x1b[?2031;1$y" | |
| 129 | 183 | } | |
| @@ -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 paste | 19 | // 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 the | 43 | // 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.clock | 67 | cfg.Clock = h.clock | ||
| 61 | cfg.Theme = themes | 68 | cfg.Theme = themes | ||
| 69 | + | cfg.Clipboard = clipboard | |||
| 62 | cfg.TrueColor = true | 70 | 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 { | |||
| 76 | 76 | // the worker queries the terminal palette and sends protocol.Theme; when | |
| 77 | 77 | // nil, replies are still stripped from input but no query is written. | |
| 78 | 78 | Theme *terminalThemeState | |
| 79 | + | // Clipboard reads images on remote attachments only. | |
| 80 | + | Clipboard ports.ClipboardReader | |
| 79 | 81 | // Tab is the exact tab a picker tab row committed; its zero value | |
| 80 | 82 | // attaches at session level. | |
| 81 | 83 | Tab attachmentTab | |
| @@ -302,7 +304,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||
| 302 | 304 | var samePeerRequests uint64 | |
| 303 | 305 | picker := &attachmentMovePicker{worker: w, fg: fg, overlay: overlay, stream: stream, size: w.cfg.Geometry.Size, move: newMovePickerOverlay(w.cfg.TrueColor)} | |
| 304 | 306 | defer picker.stopEscape() | |
| 305 | - | input := newAttachmentInput(w, fg, stream, picker) | |
| 307 | + | input := newAttachmentInput(ctx, w, fg, stream, picker) | |
| 306 | 308 | defer input.close() | |
| 307 | 309 | if err := input.start(ctx); err != nil { | |
| 308 | 310 | 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; when | 76 | // 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 *terminalThemeState | 78 | 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 value | 81 | // 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 attachmentTab | 83 | Tab attachmentTab | ||
| @@ -302,7 +304,7 @@ func (w *sessionAttachmentWorker) pumpAttached(ctx context.Context, fg Attachmen | |||||
| 302 | var samePeerRequests uint64 | 304 | 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 { | |||
| 289 | 289 | // Clock supplies the retry timers. Required; use ports.Clock, not the wall | |
| 290 | 290 | // clock. | |
| 291 | 291 | Clock ports.Clock | |
| 292 | + | // Clipboard is the client-side image clipboard for remote attachments. | |
| 293 | + | Clipboard ports.ClipboardReader | |
| 292 | 294 | // Render asks the composition to paint the current state. It is called for | |
| 293 | 295 | // visible transitions, after picker catalogue publications, and for every | |
| 294 | 296 | // 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 wall | 289 | // Clock supplies the retry timers. Required; use ports.Clock, not the wall | ||
| 290 | // clock. | 290 | // clock. | ||
| 291 | Clock ports.Clock | 291 | 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 for | 294 | // Render asks the composition to paint the current state. It is called for | ||
| 293 | // visible transitions, after picker catalogue publications, and for every | 295 | // visible transitions, after picker catalogue publications, and for every | ||
| 294 | // resize invalidation the supervisor's serialized waits consume. The | 296 | // 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, | |||
| 325 | 325 | BeforeAttached: beforeAttached, | |
| 326 | 326 | Clock: s.cfg.Clock, | |
| 327 | 327 | Theme: &s.theme, | |
| 328 | + | Clipboard: s.cfg.Clipboard, | |
| 328 | 329 | Tab: tab, | |
| 329 | 330 | }) | |
| 330 | 331 | 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 { | ||