返回 DeepSeek-Reasonix
chat_tui_paste.go
根目录 / internal / cli / chat_tui_paste.go
1 package cli
2
3 import (
4 "errors"
5 "fmt"
6 "math"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "regexp"
11 "runtime"
12 "slices"
13 "strconv"
14 "strings"
15
16 tea "charm.land/bubbletea/v2"
17 "github.com/atotto/clipboard"
18
19 "reasonix/internal/agent"
20 "reasonix/internal/control"
21 "reasonix/internal/i18n"
22 "reasonix/internal/provider"
23 "reasonix/internal/secrets"
24 "reasonix/internal/shellparse"
25 )
26
27 // This file holds the chat TUI's paste & image-attachment input layer: folding
28 // long pasted text into a deletable [Pasted text #N] token, turning
29 // dragged/pasted images and file paths into @references, and the clipboard
30 // commands behind them. The composer state it operates on (pastedBlocks /
31 // nextPasteID / pendingPastes) lives on chatTUI; these are split out of
32 // chat_tui.go as a self-contained concern.
33
34 func pastedLineCount(s string) int {
35 if s == "" {
36 return 0
37 }
38 return strings.Count(strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n"), "\n") + 1
39 }
40
41 func foldedPasteLabel(id, lines int) string {
42 return fmt.Sprintf("[Pasted text #%d · %d lines]", id, lines)
43 }
44
45 func renderFoldedPasteBlock(block pastedBlock) string {
46 return fmt.Sprintf("%s\n\n--- Begin %s ---\n%s\n--- End %s ---", block.label, block.label, block.text, block.label)
47 }
48
49 func shouldFoldPastedText(s string) bool {
50 return len([]rune(s)) >= foldedPasteMinChars || pastedLineCount(s) >= foldedPasteMinLines
51 }
52
53 func (m *chatTUI) shouldFoldPaste(s string) bool {
54 return shouldFoldPastedText(s)
55 }
56
57 func (m *chatTUI) insertFoldedPaste(s string) {
58 m.deleteComposerSelection()
59 label := foldedPasteLabel(m.takeNextPasteID(), pastedLineCount(s))
60 m.pastedBlocks = append(m.pastedBlocks, pastedBlock{label: label, text: s})
61 m.input.InsertString(label + " ")
62 }
63
64 // insertImageRef puts a deletable [image #N] token in the input box (mapped to
65 // the saved attachment's @ref, expanded on submit) so a dragged/pasted image is
66 // edited and removed like any other text, not stranded in a separate tray.
67 func (m *chatTUI) insertImageRef(path string) {
68 m.deleteComposerSelection()
69 label := fmt.Sprintf("[image #%d]", m.takeNextPasteID())
70 m.pastedBlocks = append(m.pastedBlocks, pastedBlock{label: label, text: "@" + path, image: true})
71 m.input.InsertString(label + " ")
72 m.growInputToFit()
73 m.updateCompletion()
74 }
75
76 func (m *chatTUI) expandPastedBlocks(displayed string) string {
77 sent := displayed
78 for _, block := range m.pastedBlocks {
79 if !strings.Contains(sent, block.label) {
80 continue
81 }
82 repl := renderFoldedPasteBlock(block)
83 if block.image {
84 repl = block.text
85 }
86 sent = strings.ReplaceAll(sent, block.label, repl)
87 }
88 // Recover orphaned paste labels that lost their block entries during a
89 // session reload. Each label follows the format
90 // [Pasted text #N · M lines]; the original content was expanded into the
91 // transcript the last time it was submitted. Scan the conversation
92 // history for a prior user message carrying the matching Begin/End block
93 // and re-expand from there. Labels with no verified content remain literal.
94 sent = m.recoverOrphanedPasteLabels(sent)
95 return sent
96 }
97
98 var foldedPasteLabelRe = regexp.MustCompile(`\[Pasted text #(\d+) · (\d+) lines\]`)
99
100 // recoverOrphanedPasteLabels restores paste content only at the explicit
101 // resubmission boundary. Persisted history remains byte-stable for prefix-cache
102 // reuse; labels that cannot be proven to belong to a prior expanded user
103 // message are left unchanged rather than rewriting literal user text.
104 func (m *chatTUI) recoverOrphanedPasteLabels(sent string) string {
105 var history []provider.Message
106 if m.ctrl != nil {
107 history = m.ctrl.History()
108 }
109 return recoverOrphanedPasteLabelsFromHistory(sent, m.pastedBlocks, history)
110 }
111
112 func recoverOrphanedPasteLabelsFromHistory(sent string, knownBlocks []pastedBlock, history []provider.Message) string {
113 // Fast path: no label-like tokens at all.
114 if !strings.Contains(sent, "[Pasted text #") {
115 return sent
116 }
117 matches := foldedPasteLabelRe.FindAllString(sent, -1)
118 if len(matches) == 0 {
119 return sent
120 }
121 // Collect the labels we already know about so we only attempt recovery
122 // for genuinely orphaned ones.
123 known := make(map[string]bool, len(knownBlocks))
124 for _, b := range knownBlocks {
125 known[b.label] = true
126 }
127 seen := make(map[string]bool, len(matches))
128 for _, label := range matches {
129 if known[label] || seen[label] || strings.Contains(sent, "--- Begin "+label+" ---") {
130 continue
131 }
132 seen[label] = true
133 var recovered string
134 found := false
135 ambiguous := false
136 for _, v := range slices.Backward(history) {
137 if v.Role != provider.RoleUser || agent.IsPinnedContextRevision(v) {
138 continue
139 }
140 for _, body := range expandedPasteBodies(v.Content, label) {
141 if !found {
142 recovered = body
143 found = true
144 continue
145 }
146 if recovered != body {
147 ambiguous = true
148 break
149 }
150 }
151 if ambiguous {
152 break
153 }
154 }
155 if found && !ambiguous {
156 sent = strings.ReplaceAll(sent, label, renderFoldedPasteBlock(pastedBlock{
157 label: label,
158 text: recovered,
159 }))
160 }
161 }
162 return sent
163 }
164
165 func expandedPasteBodies(content, label string) []string {
166 expectedLines, ok := foldedPasteLineCount(label)
167 if !ok {
168 return nil
169 }
170 beginMarker := "--- Begin " + label + " ---"
171 endMarker := "\n--- End " + label + " ---"
172 var bodies []string
173 for searchFrom := 0; searchFrom < len(content); {
174 beginOffset := strings.Index(content[searchFrom:], beginMarker)
175 if beginOffset < 0 {
176 break
177 }
178 bodyStart := searchFrom + beginOffset + len(beginMarker)
179 if bodyStart >= len(content) || content[bodyStart] != '\n' {
180 searchFrom = bodyStart
181 continue
182 }
183 bodyStart++
184 endSearchFrom := bodyStart
185 foundEnd := false
186 for endSearchFrom < len(content) {
187 endOffset := strings.Index(content[endSearchFrom:], endMarker)
188 if endOffset < 0 {
189 break
190 }
191 bodyEnd := endSearchFrom + endOffset
192 searchFrom = bodyEnd + len(endMarker)
193 body := content[bodyStart:bodyEnd]
194 if body != "" && pastedLineCount(body) == expectedLines {
195 bodies = append(bodies, body)
196 foundEnd = true
197 break
198 }
199 endSearchFrom = searchFrom
200 }
201 if !foundEnd {
202 break
203 }
204 }
205 return bodies
206 }
207
208 func foldedPasteLineCount(label string) (int, bool) {
209 match := foldedPasteLabelRe.FindStringSubmatch(label)
210 if len(match) < 3 || match[0] != label {
211 return 0, false
212 }
213 lines, err := strconv.Atoi(match[2])
214 if err != nil || lines < 1 {
215 return 0, false
216 }
217 return lines, true
218 }
219
220 // nextPasteIDForHistory prevents labels from being reused after resume or
221 // restart. Older sessions may contain duplicate legacy IDs; starting above the
222 // maximum keeps every newly created label unambiguous.
223 func nextPasteIDForHistory(history []provider.Message) int {
224 next, _ := pasteIDStateForHistory(history)
225 return next
226 }
227
228 func pasteIDStateForHistory(history []provider.Message) (int, map[int]struct{}) {
229 next := 1
230 used := make(map[int]struct{})
231 for _, msg := range history {
232 for _, match := range foldedPasteLabelRe.FindAllStringSubmatch(msg.Content, -1) {
233 if len(match) < 2 {
234 continue
235 }
236 id, err := strconv.Atoi(match[1])
237 if err != nil || id < 1 {
238 continue
239 }
240 used[id] = struct{}{}
241 if id >= next && id < math.MaxInt {
242 next = id + 1
243 }
244 }
245 }
246 return next, used
247 }
248
249 func (m *chatTUI) takeNextPasteID() int {
250 if m.ctrl != nil {
251 m.syncPasteIDStateFromHistory(m.ctrl.History())
252 }
253 candidate := max(m.nextPasteID, 1)
254 if m.usedPasteIDs == nil {
255 m.usedPasteIDs = make(map[int]struct{})
256 }
257 for {
258 if _, used := m.usedPasteIDs[candidate]; !used {
259 m.usedPasteIDs[candidate] = struct{}{}
260 if candidate == math.MaxInt {
261 m.nextPasteID = 1
262 } else {
263 m.nextPasteID = candidate + 1
264 }
265 return candidate
266 }
267 if candidate == math.MaxInt {
268 candidate = 1
269 } else {
270 candidate++
271 }
272 }
273 }
274
275 func (m *chatTUI) syncPasteIDStateFromHistory(history []provider.Message) {
276 next, used := pasteIDStateForHistory(history)
277 if m.usedPasteIDs == nil {
278 m.usedPasteIDs = make(map[int]struct{}, len(used))
279 }
280 for id := range used {
281 m.usedPasteIDs[id] = struct{}{}
282 }
283 if m.nextPasteID < 1 || next > m.nextPasteID {
284 m.nextPasteID = next
285 }
286 }
287
288 func (m *chatTUI) pasteLabelsIn(s string) []string {
289 var labels []string
290 for _, block := range m.pastedBlocks {
291 if strings.Contains(s, block.label) {
292 labels = append(labels, block.label)
293 }
294 }
295 return labels
296 }
297
298 func (m *chatTUI) clearSubmittedPastes() {
299 if len(m.pendingPastes) == 0 {
300 return
301 }
302 submitted := make(map[string]bool, len(m.pendingPastes))
303 for _, label := range m.pendingPastes {
304 submitted[label] = true
305 }
306 kept := m.pastedBlocks[:0]
307 for _, block := range m.pastedBlocks {
308 if !submitted[block.label] {
309 kept = append(kept, block)
310 }
311 }
312 m.pastedBlocks = kept
313 m.pendingPastes = nil
314 }
315
316 // applyComposerPaste owns both terminal bracketed pastes and text read from the
317 // native clipboard. Only terminal events advance terminalPasteSeq: replaying an
318 // internal clipboard result must not make another in-flight Ctrl+V look as if
319 // the terminal already handled it.
320 func (m chatTUI) applyComposerPaste(msg tea.PasteMsg, terminal bool) (tea.Model, tea.Cmd) {
321 return m.applyComposerPasteCount(msg, terminal, 1)
322 }
323
324 func (m chatTUI) applyComposerPasteCount(msg tea.PasteMsg, terminal bool, count int) (tea.Model, tea.Cmd) {
325 if terminal {
326 m.terminalPasteSeq++
327 }
328 // Credential input owns all paste delivery, including native clipboard
329 // completions. Never let a secret fall through into the hidden composer.
330 if m.setup != nil {
331 if !m.setup.saving && !strings.ContainsAny(msg.Content, "\r\n") {
332 m.setup.invalidateTest()
333 m.setup.key += strings.Repeat(msg.Content, count)
334 }
335 return m, nil
336 }
337 var cmds []tea.Cmd
338 for range count {
339 cmds = append(cmds, m.applyComposerPasteOnce(msg)...)
340 }
341 return m, finalize(m, cmds)
342 }
343
344 func (m *chatTUI) applyComposerPasteOnce(msg tea.PasteMsg) []tea.Cmd {
345 m.followComposerCursor()
346 pasteBefore := m.input.Value()
347 var cmds []tea.Cmd
348 if m.state != tuiRunning && m.attachPastedImages(msg.Content) {
349 if shouldClearWideInputChange(pasteBefore, m.input.Value()) {
350 cmds = append(cmds, tea.ClearScreen)
351 }
352 return cmds
353 }
354 if m.validComposerSelection() && !m.composerSel.empty() {
355 m.deleteComposerSelection()
356 }
357 if ref, ok := pastedFileRef(msg.Content); ok {
358 m.input.InsertString(ref + " ")
359 m.growInputToFit()
360 m.updateCompletion()
361 if shouldClearWideInputChange(pasteBefore, m.input.Value()) {
362 cmds = append(cmds, tea.ClearScreen)
363 }
364 return cmds
365 }
366 if !m.chooserTyping() && m.pendingApproval == nil && m.rewind == nil && m.resumePick == nil && m.mcp == nil && m.clearConfirm == nil && m.mcpImport == nil && m.skillPick == nil && m.shouldFoldPaste(msg.Content) {
367 m.insertFoldedPaste(msg.Content)
368 m.growInputToFit()
369 m.updateCompletion()
370 if shouldClearWideInputChange(pasteBefore, m.input.Value()) {
371 cmds = append(cmds, tea.ClearScreen)
372 }
373 return cmds
374 }
375
376 var inputCmd tea.Cmd
377 m.input, inputCmd = m.input.Update(msg)
378 cmds = append(cmds, inputCmd)
379 m.growInputToFit()
380 if shouldClearWideInputChange(pasteBefore, m.input.Value()) {
381 cmds = append(cmds, tea.ClearScreen)
382 }
383 return cmds
384 }
385
386 var readClipboardImage = control.SaveClipboardImage
387
388 func pasteClipboardImage() tea.Cmd {
389 return func() tea.Msg {
390 path, err := readClipboardImage()
391 return clipboardImageMsg{path: path, err: err}
392 }
393 }
394
395 type clipboardTextPasteMsg struct {
396 text string
397 err error
398 imageErr error
399 remote bool
400 terminalPasteSeq uint64
401 pending int
402 }
403
404 // handleClipboardTextPaste applies the guarded native text read that backs a
405 // keyboard paste on terminals without bracketed-paste delivery, including the
406 // image-probe fallback. A fallback that reads neither text nor a supported
407 // image surfaces a notice instead of failing silently (#8377).
408 func (m chatTUI) handleClipboardTextPaste(msg clipboardTextPasteMsg) (tea.Model, tea.Cmd) {
409 count := 1
410 if msg.pending > 0 {
411 count = pendingClipboardTextPastes(msg.pending, msg.terminalPasteSeq, m.terminalPasteSeq)
412 if count == 0 {
413 return m, nil
414 }
415 }
416 if msg.remote {
417 m.notice(i18n.M.ClipboardTextPasteRemoteHint)
418 return m, nil
419 }
420 if msg.err != nil {
421 m.notice(fmt.Sprintf(i18n.M.ClipboardTextPasteFailedFmt, sanitizeExternalDisplayText(msg.err.Error())))
422 return m, nil
423 }
424 if msg.text == "" {
425 if msg.pending > 0 {
426 if errors.Is(msg.imageErr, control.ErrUnsupportedClipboardImage) {
427 m.notice(fmt.Sprintf(i18n.M.ClipboardImagePasteFailedFmt, sanitizeExternalDisplayText(msg.imageErr.Error())))
428 } else {
429 m.notice(i18n.M.ClipboardPasteEmptyNotice)
430 }
431 }
432 return m, nil
433 }
434 return m.applyComposerPasteCount(tea.PasteMsg{Content: msg.text}, false, count)
435 }
436
437 var readNativeClipboardText = clipboard.ReadAll
438
439 // pasteClipboardText backs the captured-mouse right-click path. Keyboard text
440 // paste still arrives from the terminal as a bracketed tea.PasteMsg; this read
441 // is deliberately text-only so right-click never probes for an image first.
442 func pasteClipboardText() tea.Cmd {
443 return pasteClipboardTextGuarded(0, 0, nil)
444 }
445
446 func pasteClipboardTextGuarded(terminalPasteSeq uint64, pending int, imageErr error) tea.Cmd {
447 return func() tea.Msg {
448 msg := clipboardTextPasteMsg{imageErr: imageErr, terminalPasteSeq: terminalPasteSeq, pending: pending}
449 if remoteClipboardSession() {
450 msg.remote = true
451 return msg
452 }
453 msg.text, msg.err = readNativeClipboardText()
454 return msg
455 }
456 }
457
458 func imagePasteShortcut(keyName, goos string) bool {
459 if goos == "windows" {
460 return keyName == "alt+v"
461 }
462 return keyName == "ctrl+v"
463 }
464
465 func (m *chatTUI) beginClipboardImagePaste() tea.Cmd {
466 m.clipboardImageRequests++
467 if m.clipboardImagePending {
468 return nil
469 }
470 m.clipboardImagePending = true
471 m.clipboardImageTerminalPasteSeq = m.terminalPasteSeq
472 return pasteClipboardImage()
473 }
474
475 func pendingClipboardTextPastes(requests int, startedAt, current uint64) int {
476 if requests <= 0 {
477 return 0
478 }
479 delivered := current - startedAt
480 if delivered >= uint64(requests) {
481 return 0
482 }
483 return requests - int(delivered)
484 }
485
486 var (
487 readTmuxPasteBuffer = readTmuxBuffer
488 readPrimaryPasteSelection = readPrimarySelection
489 newPasteCommand = exec.Command
490 )
491
492 // pasteMiddleClick returns a tea.Cmd that reads from the selection owner for the
493 // current terminal environment and sends the result through the canonical paste
494 // path. tmux normally owns middle-click and pastes its current buffer, but forwards
495 // the event when an application enables mouse reporting; honor that same contract
496 // here instead of unexpectedly switching to the desktop PRIMARY selection.
497 func pasteMiddleClick() tea.Cmd {
498 return func() tea.Msg {
499 if os.Getenv("TMUX") != "" {
500 text, err := readTmuxPasteBuffer()
501 if err != nil || text == "" {
502 return nil
503 }
504 return tea.PasteMsg{Content: text}
505 }
506 if remoteClipboardSession() {
507 return clipboardTextPasteMsg{remote: true}
508 }
509 text, err := readPrimaryPasteSelection()
510 if err != nil || text == "" {
511 return nil
512 }
513 return tea.PasteMsg{Content: text}
514 }
515 }
516
517 // readTmuxBuffer retrieves the current tmux buffer verbatim. The inherited TMUX
518 // environment variable identifies the correct server socket, just as the tmux
519 // client command does for an interactive shell inside the pane.
520 func readTmuxBuffer() (string, error) {
521 cmd := newPasteCommand("tmux", "save-buffer", "-")
522 cmd.Env = secrets.ProcessEnv()
523 out, err := cmd.Output()
524 if err != nil {
525 return "", fmt.Errorf("read tmux paste buffer: %w", err)
526 }
527 return string(out), nil
528 }
529
530 // readPrimarySelection attempts to retrieve text from the PRIMARY selection by
531 // trying wl-paste (Wayland), xclip (X11), and xsel (X11) in order.
532 func readPrimarySelection() (string, error) {
533 // Match the order used by SaveClipboardImage: Wayland tool first, then X11.
534 for _, args := range [][]string{
535 {"wl-paste", "--primary", "--type", "text", "--no-newline"},
536 {"xclip", "-selection", "primary", "-o"},
537 {"xsel", "--output", "--primary"},
538 } {
539 cmd := newPasteCommand(args[0], args[1:]...)
540 cmd.Env = secrets.ProcessEnv()
541 out, err := cmd.Output()
542 if err == nil {
543 return string(out), nil
544 }
545 }
546 return "", fmt.Errorf("no primary selection tool found (need wl-paste, xclip, or xsel)")
547 }
548
549 func (m *chatTUI) attachPastedImages(text string) bool {
550 sources, ok := pastedImageSources(text)
551 if !ok {
552 return false
553 }
554 attached := false
555 for _, src := range sources {
556 path, err := savePastedImageSource(src)
557 if err != nil {
558 m.notice("paste image: " + err.Error())
559 continue
560 }
561 m.insertImageRef(path)
562 attached = true
563 }
564 if !attached && m.validComposerSelection() && !m.composerSel.empty() {
565 // A failed attachment must not replace an active selection. The notice
566 // above explains the failure; callers otherwise fall back to text paste.
567 return true
568 }
569 return attached
570 }
571
572 var markdownImageSourceRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)]+)\)`)
573
574 type pastedImageSource struct {
575 value string
576 shellDecoded bool
577 }
578
579 func pastedImageSources(text string) ([]pastedImageSource, bool) {
580 return pastedImageSourcesForOS(text, runtime.GOOS)
581 }
582
583 func pastedImageSourcesForOS(text, goos string) ([]pastedImageSource, bool) {
584 trimmed := strings.TrimSpace(text)
585 if trimmed == "" {
586 return nil, false
587 }
588 if isDataImage(trimmed) {
589 return []pastedImageSource{{value: trimmed}}, true
590 }
591 if matches := markdownImageSourceRe.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 {
592 rest := strings.TrimSpace(markdownImageSourceRe.ReplaceAllString(trimmed, ""))
593 if rest == "" {
594 sources := make([]pastedImageSource, 0, len(matches))
595 for _, m := range matches {
596 sources = append(sources, pastedImageSource{value: m[1]})
597 }
598 return sources, true
599 }
600 }
601
602 lines := nonEmptyPasteLines(trimmed)
603 lineSources := rawPastedImageSources(lines)
604 if len(lines) > 0 && allImageSources(lineSources, goos) {
605 return lineSources, true
606 }
607 fields := splitPastePathTokens(trimmed)
608 fieldSources := rawPastedImageSources(fields)
609 if len(fields) > 1 && allImageSources(fieldSources, goos) {
610 return fieldSources, true
611 }
612 if staticFields, malformed := shellparse.StaticFields(trimmed); malformed == "" && len(staticFields) > 1 {
613 sources := make([]pastedImageSource, 0, len(staticFields))
614 for _, field := range staticFields {
615 sources = append(sources, pastedImageSource{value: field, shellDecoded: true})
616 }
617 if allImageSources(sources, goos) {
618 return sources, true
619 }
620 }
621 return nil, false
622 }
623
624 func rawPastedImageSources(values []string) []pastedImageSource {
625 sources := make([]pastedImageSource, 0, len(values))
626 for _, value := range values {
627 sources = append(sources, pastedImageSource{value: value})
628 }
629 return sources
630 }
631
632 // splitPastePathTokens splits pasted text into path tokens the way a shell
633 // would hand them to a program: unescaped, unquoted whitespace separates
634 // tokens, while backslash escapes and token-leading quotes keep a path with
635 // spaces together. Tokens keep their original escapes/quotes so each one
636 // round-trips through pastedImagePath. Quotes only open at the start of a
637 // token, so an apostrophe inside a word ("it's") never swallows the rest of
638 // the text.
639 func splitPastePathTokens(s string) []string {
640 var tokens []string
641 var b strings.Builder
642 var quote byte
643 escaped := false
644 flush := func() {
645 if b.Len() > 0 {
646 tokens = append(tokens, b.String())
647 b.Reset()
648 }
649 }
650 for i := range len(s) {
651 ch := s[i]
652 switch {
653 case escaped:
654 b.WriteByte(ch)
655 escaped = false
656 case quote != 0:
657 b.WriteByte(ch)
658 if ch == quote {
659 quote = 0
660 }
661 case ch == '\\':
662 b.WriteByte(ch)
663 escaped = true
664 case (ch == '\'' || ch == '"') && b.Len() == 0:
665 b.WriteByte(ch)
666 quote = ch
667 case ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n':
668 flush()
669 default:
670 b.WriteByte(ch)
671 }
672 }
673 flush()
674 return tokens
675 }
676
677 func nonEmptyPasteLines(text string) []string {
678 var out []string
679 for line := range strings.SplitSeq(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
680 line = strings.TrimSpace(line)
681 if line != "" {
682 out = append(out, line)
683 }
684 }
685 return out
686 }
687
688 func allImageSources(sources []pastedImageSource, goos string) bool {
689 if len(sources) == 0 {
690 return false
691 }
692 for _, src := range sources {
693 if !looksLikeImageSource(src, goos) {
694 return false
695 }
696 }
697 return true
698 }
699
700 func looksLikeImageSource(src pastedImageSource, goos string) bool {
701 if isDataImage(strings.TrimSpace(src.value)) {
702 return true
703 }
704 for _, path := range pastedPathCandidates(src.value, goos, src.shellDecoded) {
705 switch strings.ToLower(filepath.Ext(path)) {
706 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
707 return true
708 }
709 }
710 return false
711 }
712
713 func savePastedImageSource(src pastedImageSource) (string, error) {
714 value := strings.TrimSpace(src.value)
715 if isDataImage(value) {
716 return control.SaveImageDataURL(value)
717 }
718 var lastErr error
719 for _, path := range pastedPathCandidates(value, runtime.GOOS, src.shellDecoded) {
720 if !looksLikeImagePath(path) {
721 continue
722 }
723 saved, err := control.SaveImageFile(path)
724 if err == nil {
725 return saved, nil
726 }
727 lastErr = err
728 }
729 if lastErr != nil {
730 return "", lastErr
731 }
732 return "", fmt.Errorf("unsupported pasted image source")
733 }
734
735 func looksLikeImagePath(path string) bool {
736 switch strings.ToLower(filepath.Ext(path)) {
737 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
738 return true
739 default:
740 return false
741 }
742 }
743
744 func isDataImage(src string) bool {
745 return strings.HasPrefix(strings.ToLower(strings.TrimSpace(src)), "data:image/")
746 }
747
748 // pastedImagePathForOS returns the preferred syntactic candidate with the OS
749 // injected so platform-specific path handling is testable everywhere.
750 func pastedImagePathForOS(src, goos string) (string, bool) {
751 candidates := pastedPathCandidates(src, goos, false)
752 if len(candidates) == 0 {
753 return "", false
754 }
755 return candidates[0], true
756 }
757
758 func hasUnescapedPathWhitespace(s string) bool {
759 escaped := false
760 for i := range len(s) {
761 ch := s[i]
762 if escaped {
763 escaped = false
764 continue
765 }
766 if ch == '\\' {
767 escaped = true
768 continue
769 }
770 if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
771 return true
772 }
773 }
774 return false
775 }
776
777 // unescapeShellPath applies POSIX backslash semantics to an unquoted pasted
778 // path: a backslash makes the next byte literal, whatever it is — zsh and
779 // bash escape any byte they consider special that way (space, parens, ^,
780 // comma, $, ...), so a whitelist would always lag behind. A trailing
781 // backslash stays literal. Quoted paths and Windows paths never reach here.
782 func unescapeShellPath(s string) string {
783 var b strings.Builder
784 b.Grow(len(s))
785 for i := 0; i < len(s); i++ {
786 if s[i] == '\\' && i+1 < len(s) {
787 i++
788 }
789 b.WriteByte(s[i])
790 }
791 return b.String()
792 }
793
794 // pastedFileRef turns a dragged/pasted non-image file path into an @reference so
795 // it attaches instead of landing as literal text (and, for a POSIX path, being
796 // misread as a slash command). Images are handled earlier; only path-shaped
797 // content (a separator) that points at a real file qualifies, so an ordinary
798 // pasted word is left alone. Whitespace in the path is escaped so the ref
799 // survives @-token parsing on submit.
800 func pastedFileRef(content string) (string, bool) {
801 path, ok := resolveExistingPastedPath(content, runtime.GOOS, false, pastedPathExists)
802 if !ok || !strings.ContainsAny(path, `/\`) {
803 return "", false
804 }
805 return "@" + control.EscapeRefPath(path), true
806 }
807
807 lines GO