feat: add ability to drag & drop multiple file at once + support windows (#1992)

Andrey Nering created

Before, only dragging a single file was working. If you tried to drag &
drop multiple, it would fail.

Also, because Windows paste in a totally different format, it wasn't
working at all before.

Change summary

internal/fsext/paste.go      | 111 ++++++++++++++++++++++++++++
internal/fsext/paste_test.go | 149 ++++++++++++++++++++++++++++++++++++++
internal/ui/model/ui.go      |  49 ++++++++----
3 files changed, 293 insertions(+), 16 deletions(-)

Detailed changes

internal/fsext/paste.go 🔗

@@ -0,0 +1,111 @@
+package fsext
+
+import (
+	"runtime"
+	"strings"
+)
+
+func PasteStringToPaths(s string) []string {
+	switch runtime.GOOS {
+	case "windows":
+		return windowsPasteStringToPaths(s)
+	default:
+		return unixPasteStringToPaths(s)
+	}
+}
+
+func windowsPasteStringToPaths(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+
+	var (
+		paths    []string
+		current  strings.Builder
+		inQuotes = false
+	)
+	for i := range len(s) {
+		ch := s[i]
+
+		switch {
+		case ch == '"':
+			if inQuotes {
+				// End of quoted section
+				if current.Len() > 0 {
+					paths = append(paths, current.String())
+					current.Reset()
+				}
+				inQuotes = false
+			} else {
+				// Start of quoted section
+				inQuotes = true
+			}
+		case inQuotes:
+			current.WriteByte(ch)
+		}
+		// Skip characters outside quotes and spaces between quoted sections
+	}
+
+	// Add any remaining content if quotes were properly closed
+	if current.Len() > 0 && !inQuotes {
+		paths = append(paths, current.String())
+	}
+
+	// If quotes were not closed, return empty (malformed input)
+	if inQuotes {
+		return nil
+	}
+
+	return paths
+}
+
+func unixPasteStringToPaths(s string) []string {
+	if strings.TrimSpace(s) == "" {
+		return nil
+	}
+
+	var (
+		paths   []string
+		current strings.Builder
+		escaped = false
+	)
+	for i := range len(s) {
+		ch := s[i]
+
+		switch {
+		case escaped:
+			// After a backslash, add the character as-is (including space)
+			current.WriteByte(ch)
+			escaped = false
+		case ch == '\\':
+			// Check if this backslash is at the end of the string
+			if i == len(s)-1 {
+				// Trailing backslash, treat as literal
+				current.WriteByte(ch)
+			} else {
+				// Start of escape sequence
+				escaped = true
+			}
+		case ch == ' ':
+			// Space separates paths (unless escaped)
+			if current.Len() > 0 {
+				paths = append(paths, current.String())
+				current.Reset()
+			}
+		default:
+			current.WriteByte(ch)
+		}
+	}
+
+	// Handle trailing backslash if present
+	if escaped {
+		current.WriteByte('\\')
+	}
+
+	// Add the last path if any
+	if current.Len() > 0 {
+		paths = append(paths, current.String())
+	}
+
+	return paths
+}

internal/fsext/paste_test.go 🔗

@@ -0,0 +1,149 @@
+package fsext
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestPasteStringToPaths(t *testing.T) {
+	t.Run("Windows", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected []string
+		}{
+			{
+				name:     "single path",
+				input:    `"C:\path\my-screenshot-one.png"`,
+				expected: []string{`C:\path\my-screenshot-one.png`},
+			},
+			{
+				name:     "multiple paths no spaces",
+				input:    `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`,
+				expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
+			},
+			{
+				name:     "sigle with spaces",
+				input:    `"C:\path\my screenshot one.png"`,
+				expected: []string{`C:\path\my screenshot one.png`},
+			},
+			{
+				name:     "multiple paths with spaces",
+				input:    `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`,
+				expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`},
+			},
+			{
+				name:     "empty string",
+				input:    "",
+				expected: nil,
+			},
+			{
+				name:     "unclosed quotes",
+				input:    `"C:\path\file.png`,
+				expected: nil,
+			},
+			{
+				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`},
+			},
+			{
+				name:     "multiple spaces between paths",
+				input:    `"C:\path\file1.png"    "C:\path\file2.png"`,
+				expected: []string{`C:\path\file1.png`, `C:\path\file2.png`},
+			},
+			{
+				name:     "just whitespace",
+				input:    "   ",
+				expected: nil,
+			},
+			{
+				name:     "consecutive quoted sections",
+				input:    `"C:\path1""C:\path2"`,
+				expected: []string{`C:\path1`, `C:\path2`},
+			},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := windowsPasteStringToPaths(tt.input)
+				require.Equal(t, tt.expected, result)
+			})
+		}
+	})
+
+	t.Run("Unix", func(t *testing.T) {
+		tests := []struct {
+			name     string
+			input    string
+			expected []string
+		}{
+			{
+				name:     "single path",
+				input:    `/path/my-screenshot.png`,
+				expected: []string{"/path/my-screenshot.png"},
+			},
+			{
+				name:     "multiple paths no spaces",
+				input:    `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`,
+				expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"},
+			},
+			{
+				name:     "sigle with spaces",
+				input:    `/path/my\ screenshot\ one.png`,
+				expected: []string{"/path/my screenshot one.png"},
+			},
+			{
+				name:     "multiple paths with spaces",
+				input:    `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`,
+				expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"},
+			},
+			{
+				name:     "empty string",
+				input:    "",
+				expected: nil,
+			},
+			{
+				name:     "double backslash escapes",
+				input:    `/path/my\\file.png`,
+				expected: []string{"/path/my\\file.png"},
+			},
+			{
+				name:     "trailing backslash",
+				input:    `/path/file\`,
+				expected: []string{`/path/file\`},
+			},
+			{
+				name:     "multiple consecutive escaped spaces",
+				input:    `/path/file\ \ with\ \ many\ \ spaces.png`,
+				expected: []string{"/path/file  with  many  spaces.png"},
+			},
+			{
+				name:     "multiple unescaped spaces",
+				input:    `/path/file1.png   /path/file2.png`,
+				expected: []string{"/path/file1.png", "/path/file2.png"},
+			},
+			{
+				name:     "just whitespace",
+				input:    "   ",
+				expected: nil,
+			},
+			{
+				name:     "tab characters",
+				input:    "/path/file1.png\t/path/file2.png",
+				expected: []string{"/path/file1.png\t/path/file2.png"},
+			},
+			{
+				name:     "newlines in input",
+				input:    "/path/file1.png\n/path/file2.png",
+				expected: []string{"/path/file1.png\n/path/file2.png"},
+			},
+		}
+		for _, tt := range tests {
+			t.Run(tt.name, func(t *testing.T) {
+				result := unixPasteStringToPaths(tt.input)
+				require.Equal(t, tt.expected, result)
+			})
+		}
+	})
+}

internal/ui/model/ui.go 🔗

@@ -29,6 +29,7 @@ import (
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/filetracker"
+	"github.com/charmbracelet/crush/internal/fsext"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/home"
 	"github.com/charmbracelet/crush/internal/message"
@@ -2817,29 +2818,45 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		}
 	}
 
-	var cmd tea.Cmd
-	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
-	// Try to get an image.
-	path, err := filepath.Abs(strings.TrimSpace(path))
-	if err != nil {
-		m.textarea, cmd = m.textarea.Update(msg)
-		return 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)
+	allExistsAndValid := func() bool {
+		for _, path := range paths {
+			if _, err := os.Stat(path); os.IsNotExist(err) {
+				return false
+			}
 
-	// Check if file has an allowed image extension.
-	isAllowedType := false
-	lowerPath := strings.ToLower(path)
-	for _, ext := range common.AllowedImageTypes {
-		if strings.HasSuffix(lowerPath, ext) {
-			isAllowedType = true
-			break
+			lowerPath := strings.ToLower(path)
+			isValid := false
+			for _, ext := range common.AllowedImageTypes {
+				if strings.HasSuffix(lowerPath, ext) {
+					isValid = true
+					break
+				}
+			}
+			if !isValid {
+				return false
+			}
 		}
+		return true
 	}
-	if !isAllowedType {
+	if !allExistsAndValid() {
+		var cmd tea.Cmd
 		m.textarea, cmd = m.textarea.Update(msg)
 		return cmd
 	}
 
+	var cmds []tea.Cmd
+	for _, path := range paths {
+		cmds = append(cmds, m.handleFilePathPaste(path))
+	}
+	return tea.Batch(cmds...)
+}
+
+// handleFilePathPaste handles a pasted file path.
+func (m *UI) handleFilePathPaste(path string) tea.Cmd {
 	return func() tea.Msg {
 		fileInfo, err := os.Stat(path)
 		if err != nil {