fix: fix pasting files on some terminal emulators (#2106)

Andrey Nering created

* Check `WT_SESSION` instead of `GOOS` for Windows Terminal.
* Be more strict on Windows Terminal: do not allow chars outside quotes
  (prevents false positives).
* Some terminals just paste the literal paths (Rio as separate events,
  Kitty separated by a line break). If it contains valid path(s) for
  existing file(s), just use that.
* Workaround Rio on Windows that adds NULL chars to the string.

Change summary

internal/fsext/paste.go      | 36 +++++++++++++++++++++++++++---------
internal/fsext/paste_test.go | 12 ++++++------
internal/ui/model/ui.go      |  5 ++++-
3 files changed, 37 insertions(+), 16 deletions(-)

Detailed changes

internal/fsext/paste.go 🔗

@@ -1,20 +1,36 @@
 package fsext
 
 import (
-	"runtime"
+	"os"
 	"strings"
 )
 
-func PasteStringToPaths(s string) []string {
-	switch runtime.GOOS {
-	case "windows":
-		return windowsPasteStringToPaths(s)
+func ParsePastedFiles(s string) []string {
+	s = strings.TrimSpace(s)
+
+	// NOTE: Rio on Windows adds NULL chars for some reason.
+	s = strings.ReplaceAll(s, "\x00", "")
+
+	switch {
+	case attemptStat(s):
+		return strings.Split(s, "\n")
+	case os.Getenv("WT_SESSION") != "":
+		return windowsTerminalParsePastedFiles(s)
 	default:
-		return unixPasteStringToPaths(s)
+		return unixParsePastedFiles(s)
+	}
+}
+
+func attemptStat(s string) bool {
+	for path := range strings.SplitSeq(s, "\n") {
+		if info, err := os.Stat(path); err != nil || info.IsDir() {
+			return false
+		}
 	}
+	return true
 }
 
-func windowsPasteStringToPaths(s string) []string {
+func windowsTerminalParsePastedFiles(s string) []string {
 	if strings.TrimSpace(s) == "" {
 		return nil
 	}
@@ -42,8 +58,10 @@ func windowsPasteStringToPaths(s string) []string {
 			}
 		case inQuotes:
 			current.WriteByte(ch)
+		case ch != ' ':
+			// Text outside quotes is not allowed
+			return nil
 		}
-		// Skip characters outside quotes and spaces between quoted sections
 	}
 
 	// Add any remaining content if quotes were properly closed
@@ -59,7 +77,7 @@ func windowsPasteStringToPaths(s string) []string {
 	return paths
 }
 
-func unixPasteStringToPaths(s string) []string {
+func unixParsePastedFiles(s string) []string {
 	if strings.TrimSpace(s) == "" {
 		return nil
 	}

internal/fsext/paste_test.go 🔗

@@ -6,8 +6,8 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestPasteStringToPaths(t *testing.T) {
-	t.Run("Windows", func(t *testing.T) {
+func TestParsePastedFiles(t *testing.T) {
+	t.Run("WindowsTerminal", func(t *testing.T) {
 		tests := []struct {
 			name     string
 			input    string
@@ -24,7 +24,7 @@ func TestPasteStringToPaths(t *testing.T) {
 				expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
 			},
 			{
-				name:     "sigle with spaces",
+				name:     "single with spaces",
 				input:    `"C:\path\my screenshot one.png"`,
 				expected: []string{`C:\path\my screenshot one.png`},
 			},
@@ -46,7 +46,7 @@ func TestPasteStringToPaths(t *testing.T) {
 			{
 				name:     "text outside quotes",
 				input:    `"C:\path\file.png" some random text "C:\path\file2.png"`,
-				expected: []string{`C:\path\file.png`, `C:\path\file2.png`},
+				expected: nil,
 			},
 			{
 				name:     "multiple spaces between paths",
@@ -66,7 +66,7 @@ func TestPasteStringToPaths(t *testing.T) {
 		}
 		for _, tt := range tests {
 			t.Run(tt.name, func(t *testing.T) {
-				result := windowsPasteStringToPaths(tt.input)
+				result := windowsTerminalParsePastedFiles(tt.input)
 				require.Equal(t, tt.expected, result)
 			})
 		}
@@ -141,7 +141,7 @@ func TestPasteStringToPaths(t *testing.T) {
 		}
 		for _, tt := range tests {
 			t.Run(tt.name, func(t *testing.T) {
-				result := unixPasteStringToPaths(tt.input)
+				result := unixParsePastedFiles(tt.input)
 				require.Equal(t, tt.expected, result)
 			})
 		}

internal/ui/model/ui.go 🔗

@@ -2915,7 +2915,7 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 	// Attempt to parse pasted content as file paths. If possible to parse,
 	// all files exist and are valid, add as attachments.
 	// Otherwise, paste as text.
-	paths := fsext.PasteStringToPaths(msg.Content)
+	paths := fsext.ParsePastedFiles(msg.Content)
 	allExistsAndValid := func() bool {
 		for _, path := range paths {
 			if _, err := os.Stat(path); os.IsNotExist(err) {
@@ -2956,6 +2956,9 @@ func (m *UI) handleFilePathPaste(path string) tea.Cmd {
 		if err != nil {
 			return uiutil.ReportError(err)
 		}
+		if fileInfo.IsDir() {
+			return uiutil.ReportWarn("Cannot attach a directory")
+		}
 		if fileInfo.Size() > common.MaxAttachmentSize {
 			return uiutil.ReportWarn("File is too big (>5mb)")
 		}