From b94495230b21203935ad3b043fcaa789dd49d7d7 Mon Sep 17 00:00:00 2001 From: Andrey Nering Date: Mon, 26 Jan 2026 18:07:12 -0300 Subject: [PATCH] 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. --- internal/fsext/paste.go | 111 ++++++++++++++++++++++++++ internal/fsext/paste_test.go | 149 +++++++++++++++++++++++++++++++++++ internal/ui/model/ui.go | 49 ++++++++---- 3 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 internal/fsext/paste.go create mode 100644 internal/fsext/paste_test.go diff --git a/internal/fsext/paste.go b/internal/fsext/paste.go new file mode 100644 index 0000000000000000000000000000000000000000..7e89a6443e09a2c5831ce8a072945cf7d1c4fd95 --- /dev/null +++ b/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 +} diff --git a/internal/fsext/paste_test.go b/internal/fsext/paste_test.go new file mode 100644 index 0000000000000000000000000000000000000000..09f8ad4d5bebbc993193d38a7ebbb31778aba7f6 --- /dev/null +++ b/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) + }) + } + }) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index cd1ad42a0dc473c31b3ff280a7a224d64d0094c2..9a0cb92aa4e96a6bf4c2aa914ba3a826420f56b0 100644 --- a/internal/ui/model/ui.go +++ b/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 {