on main help sign in
feat: add ability to drag & drop multiple file at once + support windows (#1992)

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.

commitb94495230b21203935ad3b043fcaa789dd49d7d7 authorAndrey Nering <andreynering@users.noreply.github.com> dateMon 26 Jan 2026, 18:07 (9 months ago) parent668adcc
3 files changed+293−16
internal/fsext/paste.go+111 internal/fsext/paste_test.go+149 internal/ui/model/ui.go+33−16
internal/fsext/paste.gonew file+111
@@ -0,0 +1,111 @@
1+package fsext
2+
3+import (
4+ "runtime"
5+ "strings"
6+)
7+
8+func PasteStringToPaths(s string) []string {
9+ switch runtime.GOOS {
10+ case "windows":
11+ return windowsPasteStringToPaths(s)
12+ default:
13+ return unixPasteStringToPaths(s)
14+ }
15+}
16+
17+func windowsPasteStringToPaths(s string) []string {
18+ if strings.TrimSpace(s) == "" {
19+ return nil
20+ }
21+
22+ var (
23+ paths []string
24+ current strings.Builder
25+ inQuotes = false
26+ )
27+ for i := range len(s) {
28+ ch := s[i]
29+
30+ switch {
31+ case ch == '"':
32+ if inQuotes {
33+ // End of quoted section
34+ if current.Len() > 0 {
35+ paths = append(paths, current.String())
36+ current.Reset()
37+ }
38+ inQuotes = false
39+ } else {
40+ // Start of quoted section
41+ inQuotes = true
42+ }
43+ case inQuotes:
44+ current.WriteByte(ch)
45+ }
46+ // Skip characters outside quotes and spaces between quoted sections
47+ }
48+
49+ // Add any remaining content if quotes were properly closed
50+ if current.Len() > 0 && !inQuotes {
51+ paths = append(paths, current.String())
52+ }
53+
54+ // If quotes were not closed, return empty (malformed input)
55+ if inQuotes {
56+ return nil
57+ }
58+
59+ return paths
60+}
61+
62+func unixPasteStringToPaths(s string) []string {
63+ if strings.TrimSpace(s) == "" {
64+ return nil
65+ }
66+
67+ var (
68+ paths []string
69+ current strings.Builder
70+ escaped = false
71+ )
72+ for i := range len(s) {
73+ ch := s[i]
74+
75+ switch {
76+ case escaped:
77+ // After a backslash, add the character as-is (including space)
78+ current.WriteByte(ch)
79+ escaped = false
80+ case ch == '\\':
81+ // Check if this backslash is at the end of the string
82+ if i == len(s)-1 {
83+ // Trailing backslash, treat as literal
84+ current.WriteByte(ch)
85+ } else {
86+ // Start of escape sequence
87+ escaped = true
88+ }
89+ case ch == ' ':
90+ // Space separates paths (unless escaped)
91+ if current.Len() > 0 {
92+ paths = append(paths, current.String())
93+ current.Reset()
94+ }
95+ default:
96+ current.WriteByte(ch)
97+ }
98+ }
99+
100+ // Handle trailing backslash if present
101+ if escaped {
102+ current.WriteByte('\\')
103+ }
104+
105+ // Add the last path if any
106+ if current.Len() > 0 {
107+ paths = append(paths, current.String())
108+ }
109+
110+ return paths
111+}
internal/fsext/paste_test.gonew file+149
@@ -0,0 +1,149 @@
1+package fsext
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestPasteStringToPaths(t *testing.T) {
10+ t.Run("Windows", func(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ input string
14+ expected []string
15+ }{
16+ {
17+ name: "single path",
18+ input: `"C:\path\my-screenshot-one.png"`,
19+ expected: []string{`C:\path\my-screenshot-one.png`},
20+ },
21+ {
22+ name: "multiple paths no spaces",
23+ input: `"C:\path\my-screenshot-one.png" "C:\path\my-screenshot-two.png" "C:\path\my-screenshot-three.png"`,
24+ expected: []string{`C:\path\my-screenshot-one.png`, `C:\path\my-screenshot-two.png`, `C:\path\my-screenshot-three.png`},
25+ },
26+ {
27+ name: "sigle with spaces",
28+ input: `"C:\path\my screenshot one.png"`,
29+ expected: []string{`C:\path\my screenshot one.png`},
30+ },
31+ {
32+ name: "multiple paths with spaces",
33+ input: `"C:\path\my screenshot one.png" "C:\path\my screenshot two.png" "C:\path\my screenshot three.png"`,
34+ expected: []string{`C:\path\my screenshot one.png`, `C:\path\my screenshot two.png`, `C:\path\my screenshot three.png`},
35+ },
36+ {
37+ name: "empty string",
38+ input: "",
39+ expected: nil,
40+ },
41+ {
42+ name: "unclosed quotes",
43+ input: `"C:\path\file.png`,
44+ expected: nil,
45+ },
46+ {
47+ name: "text outside quotes",
48+ input: `"C:\path\file.png" some random text "C:\path\file2.png"`,
49+ expected: []string{`C:\path\file.png`, `C:\path\file2.png`},
50+ },
51+ {
52+ name: "multiple spaces between paths",
53+ input: `"C:\path\file1.png" "C:\path\file2.png"`,
54+ expected: []string{`C:\path\file1.png`, `C:\path\file2.png`},
55+ },
56+ {
57+ name: "just whitespace",
58+ input: " ",
59+ expected: nil,
60+ },
61+ {
62+ name: "consecutive quoted sections",
63+ input: `"C:\path1""C:\path2"`,
64+ expected: []string{`C:\path1`, `C:\path2`},
65+ },
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ result := windowsPasteStringToPaths(tt.input)
70+ require.Equal(t, tt.expected, result)
71+ })
72+ }
73+ })
74+
75+ t.Run("Unix", func(t *testing.T) {
76+ tests := []struct {
77+ name string
78+ input string
79+ expected []string
80+ }{
81+ {
82+ name: "single path",
83+ input: `/path/my-screenshot.png`,
84+ expected: []string{"/path/my-screenshot.png"},
85+ },
86+ {
87+ name: "multiple paths no spaces",
88+ input: `/path/screenshot-one.png /path/screenshot-two.png /path/screenshot-three.png`,
89+ expected: []string{"/path/screenshot-one.png", "/path/screenshot-two.png", "/path/screenshot-three.png"},
90+ },
91+ {
92+ name: "sigle with spaces",
93+ input: `/path/my\ screenshot\ one.png`,
94+ expected: []string{"/path/my screenshot one.png"},
95+ },
96+ {
97+ name: "multiple paths with spaces",
98+ input: `/path/my\ screenshot\ one.png /path/my\ screenshot\ two.png /path/my\ screenshot\ three.png`,
99+ expected: []string{"/path/my screenshot one.png", "/path/my screenshot two.png", "/path/my screenshot three.png"},
100+ },
101+ {
102+ name: "empty string",
103+ input: "",
104+ expected: nil,
105+ },
106+ {
107+ name: "double backslash escapes",
108+ input: `/path/my\\file.png`,
109+ expected: []string{"/path/my\\file.png"},
110+ },
111+ {
112+ name: "trailing backslash",
113+ input: `/path/file\`,
114+ expected: []string{`/path/file\`},
115+ },
116+ {
117+ name: "multiple consecutive escaped spaces",
118+ input: `/path/file\ \ with\ \ many\ \ spaces.png`,
119+ expected: []string{"/path/file with many spaces.png"},
120+ },
121+ {
122+ name: "multiple unescaped spaces",
123+ input: `/path/file1.png /path/file2.png`,
124+ expected: []string{"/path/file1.png", "/path/file2.png"},
125+ },
126+ {
127+ name: "just whitespace",
128+ input: " ",
129+ expected: nil,
130+ },
131+ {
132+ name: "tab characters",
133+ input: "/path/file1.png\t/path/file2.png",
134+ expected: []string{"/path/file1.png\t/path/file2.png"},
135+ },
136+ {
137+ name: "newlines in input",
138+ input: "/path/file1.png\n/path/file2.png",
139+ expected: []string{"/path/file1.png\n/path/file2.png"},
140+ },
141+ }
142+ for _, tt := range tests {
143+ t.Run(tt.name, func(t *testing.T) {
144+ result := unixPasteStringToPaths(tt.input)
145+ require.Equal(t, tt.expected, result)
146+ })
147+ }
148+ })
149+}
internal/ui/model/ui.go+33−16
@@ -29,6 +29,7 @@ import (
2929 "github.com/charmbracelet/crush/internal/commands"
3030 "github.com/charmbracelet/crush/internal/config"
3131 "github.com/charmbracelet/crush/internal/filetracker"
32+ "github.com/charmbracelet/crush/internal/fsext"
3233 "github.com/charmbracelet/crush/internal/history"
3334 "github.com/charmbracelet/crush/internal/home"
3435 "github.com/charmbracelet/crush/internal/message"
@@ -2817,29 +2818,45 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
28172818 }
28182819 }
28192820
2820− var cmd tea.Cmd
2821− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
2822− // Try to get an image.
2823− path, err := filepath.Abs(strings.TrimSpace(path))
2824− if err != nil {
2825− m.textarea, cmd = m.textarea.Update(msg)
2826− return cmd
2827− }
2821+ // Attempt to parse pasted content as file paths. If possible to parse,
2822+ // all files exist and are valid, add as attachments.
2823+ // Otherwise, paste as text.
2824+ paths := fsext.PasteStringToPaths(msg.Content)
2825+ allExistsAndValid := func() bool {
2826+ for _, path := range paths {
2827+ if _, err := os.Stat(path); os.IsNotExist(err) {
2828+ return false
2829+ }
28282830
2829− // Check if file has an allowed image extension.
2830− isAllowedType := false
2831− lowerPath := strings.ToLower(path)
2832− for _, ext := range common.AllowedImageTypes {
2833− if strings.HasSuffix(lowerPath, ext) {
2834− isAllowedType = true
2835− break
2831+ lowerPath := strings.ToLower(path)
2832+ isValid := false
2833+ for _, ext := range common.AllowedImageTypes {
2834+ if strings.HasSuffix(lowerPath, ext) {
2835+ isValid = true
2836+ break
2837+ }
2838+ }
2839+ if !isValid {
2840+ return false
2841+ }
28362842 }
2843+ return true
28372844 }
2838− if !isAllowedType {
2845+ if !allExistsAndValid() {
2846+ var cmd tea.Cmd
28392847 m.textarea, cmd = m.textarea.Update(msg)
28402848 return cmd
28412849 }
28422850
2851+ var cmds []tea.Cmd
2852+ for _, path := range paths {
2853+ cmds = append(cmds, m.handleFilePathPaste(path))
2854+ }
2855+ return tea.Batch(cmds...)
2856+}
2857+
2858+// handleFilePathPaste handles a pasted file path.
2859+func (m *UI) handleFilePathPaste(path string) tea.Cmd {
28432860 return func() tea.Msg {
28442861 fileInfo, err := os.Stat(path)
28452862 if err != nil {