-
Notifications
You must be signed in to change notification settings - Fork 260
feat: add file drag-and-drop support for images and PDFs #1658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,7 +73,7 @@ type Editor interface { | |
| // InsertText inserts text at the current cursor position | ||
| InsertText(text string) | ||
| // AttachFile adds a file as an attachment and inserts @filepath into the editor | ||
| AttachFile(filePath string) | ||
| AttachFile(filePath string) error | ||
| Cleanup() | ||
| GetSize() (width, height int) | ||
| BannerHeight() int | ||
|
|
@@ -690,7 +690,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { | |
| } | ||
| // Track file references when using @ completion (but not paste placeholders) | ||
| if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") { | ||
| e.addFileAttachment(msg.Value) | ||
| if err := e.addFileAttachment(msg.Value); err != nil { | ||
| slog.Warn("failed to add file attachment from completion", "value", msg.Value, "error", err) | ||
| } | ||
| } | ||
| e.clearSuggestion() | ||
| return e, nil | ||
|
|
@@ -1287,14 +1289,17 @@ func (e *editor) InsertText(text string) { | |
| } | ||
|
|
||
| // AttachFile adds a file as an attachment and inserts @filepath into the editor | ||
| func (e *editor) AttachFile(filePath string) { | ||
| func (e *editor) AttachFile(filePath string) error { | ||
| placeholder := "@" + filePath | ||
| e.addFileAttachment(placeholder) | ||
| if err := e.addFileAttachment(placeholder); err != nil { | ||
| return fmt.Errorf("failed to attach %s: %w", filePath, err) | ||
| } | ||
| currentValue := e.textarea.Value() | ||
| e.textarea.SetValue(currentValue + placeholder + " ") | ||
| e.textarea.MoveToEnd() | ||
| e.userTyped = true | ||
| e.updateAttachmentBanner() | ||
| return nil | ||
| } | ||
|
|
||
| // tryAddFileRef checks if word is a valid @filepath and adds it as attachment. | ||
|
|
@@ -1315,33 +1320,41 @@ func (e *editor) tryAddFileRef(word string) { | |
| return // not a path-like reference (e.g., @username) | ||
| } | ||
|
|
||
| e.addFileAttachment(word) | ||
| if err := e.addFileAttachment(word); err != nil { | ||
| slog.Debug("speculative file ref not valid", "word", word, "error", err) | ||
| } | ||
| } | ||
|
|
||
| // addFileAttachment adds a file reference as an attachment if valid. | ||
| // The path is resolved to an absolute path so downstream consumers | ||
| // (e.g. processFileAttachment) always receive a fully qualified path. | ||
| func (e *editor) addFileAttachment(placeholder string) { | ||
| func (e *editor) addFileAttachment(placeholder string) error { | ||
| path := strings.TrimPrefix(placeholder, "@") | ||
|
|
||
| // Resolve to absolute path so the attachment carries a fully qualified | ||
| // path regardless of the working directory at send time. | ||
| absPath, err := filepath.Abs(path) | ||
| if err != nil { | ||
| slog.Warn("skipping attachment: cannot resolve path", "path", path, "error", err) | ||
| return | ||
| return fmt.Errorf("cannot resolve path %s: %w", path, err) | ||
| } | ||
|
|
||
| // Check if it's an existing file (not directory) | ||
| info, err := os.Stat(absPath) | ||
| if err != nil || info.IsDir() { | ||
| return | ||
| info, err := validateFilePath(absPath) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid file path %s: %w", absPath, err) | ||
| } | ||
| if info.IsDir() { | ||
| return fmt.Errorf("path is a directory: %s", absPath) | ||
| } | ||
|
|
||
| const maxFileSize = 5 * 1024 * 1024 | ||
| if info.Size() >= maxFileSize { | ||
| return fmt.Errorf("file too large: %s (%s)", absPath, units.HumanSize(float64(info.Size()))) | ||
| } | ||
|
|
||
| // Avoid duplicates | ||
| for _, att := range e.attachments { | ||
| if att.placeholder == placeholder { | ||
| return | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1352,6 +1365,7 @@ func (e *editor) addFileAttachment(placeholder string) { | |
| sizeBytes: int(info.Size()), | ||
| isTemp: false, | ||
| }) | ||
| return nil | ||
| } | ||
|
|
||
| // collectAttachments returns structured attachments for all items referenced in | ||
|
|
@@ -1451,6 +1465,28 @@ func (e *editor) SendContent() tea.Cmd { | |
| } | ||
|
|
||
| func (e *editor) handlePaste(content string) bool { | ||
| // First, try to parse as file paths (drag-and-drop) | ||
| filePaths := ParsePastedFiles(content) | ||
| if len(filePaths) > 0 { | ||
| var attached int | ||
| for _, path := range filePaths { | ||
| if !IsSupportedFileType(path) { | ||
| break | ||
| } | ||
| if err := e.AttachFile(path); err != nil { | ||
| slog.Debug("paste path not attachable, treating as text", "path", path, "error", err) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BUG: Rollback leaves orphaned placeholder text in textarea When The problem: Example scenario:
Fix: |
||
| break | ||
| } | ||
| attached++ | ||
| } | ||
| if attached == len(filePaths) { | ||
| return true | ||
| } | ||
| // Not all files could be attached; undo partial attachments and fall through to text paste | ||
| e.removeLastNAttachments(attached) | ||
| } | ||
|
|
||
| // Not file paths, handle as text paste | ||
| // Count lines (newlines + 1 for content without trailing newline) | ||
| lines := strings.Count(content, "\n") + 1 | ||
| if strings.HasSuffix(content, "\n") { | ||
|
|
@@ -1477,6 +1513,27 @@ func (e *editor) handlePaste(content string) bool { | |
| return true | ||
| } | ||
|
|
||
| // removeLastNAttachments removes the last n non-temp attachments and their | ||
| // placeholder text from the textarea. Used to roll back partial file-drop | ||
| // attachments when not all files in a paste are valid. | ||
| func (e *editor) removeLastNAttachments(n int) { | ||
| if n <= 0 { | ||
| return | ||
| } | ||
| value := e.textarea.Value() | ||
| removed := 0 | ||
| for i := len(e.attachments) - 1; i >= 0 && removed < n; i-- { | ||
| if !e.attachments[i].isTemp { | ||
| // Strip the placeholder text ("@/path/file.png ") that AttachFile inserted | ||
| value = strings.Replace(value, e.attachments[i].placeholder+" ", "", 1) | ||
| e.attachments = append(e.attachments[:i], e.attachments[i+1:]...) | ||
| removed++ | ||
| } | ||
| } | ||
| e.textarea.SetValue(value) | ||
| e.textarea.MoveToEnd() | ||
| } | ||
|
|
||
| func (e *editor) updateAttachmentBanner() { | ||
| if e.banner == nil { | ||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| package editor | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "slices" | ||
| "strings" | ||
| ) | ||
|
|
||
| // validateFilePath checks that a path is safe: no path traversal, no symlinks. | ||
| func validateFilePath(path string) (os.FileInfo, error) { | ||
| if strings.Contains(path, "..") { | ||
| return nil, os.ErrPermission | ||
| } | ||
|
|
||
| clean := filepath.Clean(path) | ||
|
|
||
| info, err := os.Lstat(clean) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if info.Mode()&os.ModeSymlink != 0 { | ||
| return nil, os.ErrPermission | ||
| } | ||
| return info, nil | ||
| } | ||
|
|
||
| // Supported file extensions for drag-and-drop attachments | ||
| var supportedFileExtensions = []string{ | ||
| // Images | ||
| ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", | ||
| // PDFs | ||
| ".pdf", | ||
| // Text files (future) | ||
| // ".txt", ".md", ".json", ".yaml", ".yml", ".toml", | ||
| } | ||
|
|
||
| // ParsePastedFiles attempts to parse pasted content as file paths. | ||
| // It handles different terminal formats: | ||
| // - Unix: space-separated with backslash escaping | ||
| // - Windows Terminal: quote-wrapped paths | ||
| // - Single file: just the path | ||
| // | ||
| // Returns nil if the content doesn't look like file paths. | ||
| func ParsePastedFiles(s string) []string { | ||
| s = strings.TrimSpace(s) | ||
| if s == "" { | ||
| return nil | ||
| } | ||
|
|
||
| // NOTE: Rio terminal on Windows adds NULL chars for some reason. | ||
| s = strings.ReplaceAll(s, "\x00", "") | ||
|
|
||
| // Try simple stat first - if all lines are valid files, use them | ||
| if attemptStatAll(s) { | ||
| return strings.Split(s, "\n") | ||
| } | ||
|
|
||
| // Detect Windows Terminal format (quote-wrapped) | ||
| if os.Getenv("WT_SESSION") != "" { | ||
| return windowsTerminalParsePastedFiles(s) | ||
| } | ||
|
|
||
| // Default to Unix format (space-separated with backslash escaping) | ||
| return unixParsePastedFiles(s) | ||
| } | ||
|
|
||
| // attemptStatAll tries to stat each line as a file path. | ||
| // Returns true if ALL lines exist as regular files (not directories or symlinks). | ||
| func attemptStatAll(s string) bool { | ||
| lines := strings.Split(s, "\n") | ||
| if len(lines) == 0 { | ||
| return false | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 HIGH: Path Traversal & Symlink Attack Vulnerability The
Attack scenario: # Attacker creates symlink
ln -s /etc/passwd /tmp/secret.png
# Then pastes: /tmp/secret.png
# Extension check passes (.png), os.Stat follows symlink, file gets attachedFix: info, err := os.Lstat(line) // Use Lstat instead of Stat
if err != nil || info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return false
}
// Also add path validation:
cleanPath := filepath.Clean(line)
if strings.Contains(cleanPath, "..") {
return false // Reject path traversal attempts
} |
||
| for _, line := range lines { | ||
| line = strings.TrimSpace(line) | ||
| if line == "" { | ||
| continue | ||
| } | ||
| info, err := validateFilePath(line) | ||
| if err != nil || info.IsDir() { | ||
| return false | ||
| } | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| // windowsTerminalParsePastedFiles parses Windows Terminal format. | ||
| // Windows Terminal wraps file paths in quotes: "C:\path\to\file.png" | ||
| func windowsTerminalParsePastedFiles(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) | ||
| case ch != ' ' && ch != '\n' && ch != '\r': | ||
| // Text outside quotes is not allowed | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| // 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 nil (malformed input) | ||
| if inQuotes { | ||
| return nil | ||
| } | ||
|
|
||
| return paths | ||
| } | ||
|
|
||
| // unixParsePastedFiles parses Unix terminal format. | ||
| // Unix terminals use space-separated paths with backslash escaping. | ||
| // Example: /path/to/file1.png /path/to/my\ file\ with\ spaces.jpg | ||
| func unixParsePastedFiles(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 == '\\': | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 MEDIUM: Incorrect Trailing Backslash Handling When a path ends with a single backslash, it's treated as a literal character in the filename. On Unix, backslash is an escape character, never part of filenames. This creates invalid paths. Problem:
Why this is wrong: Fix: case ch == '\\':
if i == len(s)-1 {
// Trailing backslash = malformed input, strip it
break
} else {
escaped = true
}Also update the test to expect the backslash to be stripped. |
||
| if i == len(s)-1 { | ||
| // Trailing backslash is malformed input; strip it | ||
| break | ||
| } | ||
| escaped = true | ||
| case ch == ' ' || ch == '\n' || ch == '\r': | ||
| // Space/newline 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 | ||
| } | ||
|
|
||
| // IsSupportedFileType checks if a file has a supported extension. | ||
| func IsSupportedFileType(path string) bool { | ||
| ext := strings.ToLower(filepath.Ext(path)) | ||
| return slices.Contains(supportedFileExtensions, ext) | ||
| } | ||
|
|
||
| // GetFileType returns a human-readable file type for display. | ||
| func GetFileType(path string) string { | ||
| ext := strings.ToLower(filepath.Ext(path)) | ||
| switch ext { | ||
| case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg": | ||
| return "image" | ||
| case ".pdf": | ||
| return "pdf" | ||
| case ".txt", ".md": | ||
| return "text" | ||
| case ".json", ".yaml", ".yml", ".toml": | ||
| return "config" | ||
| default: | ||
| return "file" | ||
| } | ||
| } | ||
|
|
||
| // GetFileIcon returns an emoji icon for a file type. | ||
| func GetFileIcon(fileType string) string { | ||
| switch fileType { | ||
| case "image": | ||
| return "🖼️" | ||
| case "pdf": | ||
| return "📄" | ||
| case "text": | ||
| return "📝" | ||
| case "config": | ||
| return "⚙️" | ||
| default: | ||
| return "📎" | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 MEDIUM: TOCTOU Race Condition
Files are validated at line 1462 (
allFilesValid()callsos.Lstat()), but between that check and the actual attachment at line 1464, another process could delete/move/modify the file.Race window:
Impact: The existing error handling (line 1465) catches this gracefully, so it won't crash. However, the redundant validation at line 1462 creates an unnecessary race window.
Recommendation: Consider removing the
allFilesValid()call and rely solely onAttachFile()to validate, since it callsvalidateFilePath()internally anyway. This eliminates the redundant check and the TOCTOU window: