Skip to content

Commit d7860a9

Browse files
committed
feat: add file drag-and-drop support for images and PDFs
- Add ParsePastedFiles() to detect file paths from paste events - Support Unix (space-separated with escaping) and Windows (quoted) formats - Validate file types (PNG, JPG, GIF, WebP, BMP, SVG, PDF) - Enforce 5MB size limit per file - Add visual file type indicators with emoji icons - Integrate with existing attachment system - Handle edge cases (null chars, trailing backslash, etc.) - Add comprehensive test coverage Security hardening: - Add validateFilePath() to reject path traversal (..) and symlinks - Use os.Lstat() instead of os.Stat() in all file validation paths - Return errors from addFileAttachment() and AttachFile() - Move success notification to InsertFileRefMsg handler where outcome is known - Strip trailing backslash as malformed input - Add TestValidateFilePath covering symlinks, traversal, regular files Assisted-By: cagent
1 parent d7f1f6e commit d7860a9

File tree

5 files changed

+565
-25
lines changed

5 files changed

+565
-25
lines changed

pkg/tui/components/editor/editor.go

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ type Editor interface {
7373
// InsertText inserts text at the current cursor position
7474
InsertText(text string)
7575
// AttachFile adds a file as an attachment and inserts @filepath into the editor
76-
AttachFile(filePath string)
76+
AttachFile(filePath string) error
7777
Cleanup()
7878
GetSize() (width, height int)
7979
BannerHeight() int
@@ -690,7 +690,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
690690
}
691691
// Track file references when using @ completion (but not paste placeholders)
692692
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") {
693-
e.addFileAttachment(msg.Value)
693+
if err := e.addFileAttachment(msg.Value); err != nil {
694+
slog.Warn("failed to add file attachment from completion", "value", msg.Value, "error", err)
695+
}
694696
}
695697
e.clearSuggestion()
696698
return e, nil
@@ -1287,14 +1289,17 @@ func (e *editor) InsertText(text string) {
12871289
}
12881290

12891291
// AttachFile adds a file as an attachment and inserts @filepath into the editor
1290-
func (e *editor) AttachFile(filePath string) {
1292+
func (e *editor) AttachFile(filePath string) error {
12911293
placeholder := "@" + filePath
1292-
e.addFileAttachment(placeholder)
1294+
if err := e.addFileAttachment(placeholder); err != nil {
1295+
return fmt.Errorf("failed to attach %s: %w", filePath, err)
1296+
}
12931297
currentValue := e.textarea.Value()
12941298
e.textarea.SetValue(currentValue + placeholder + " ")
12951299
e.textarea.MoveToEnd()
12961300
e.userTyped = true
12971301
e.updateAttachmentBanner()
1302+
return nil
12981303
}
12991304

13001305
// tryAddFileRef checks if word is a valid @filepath and adds it as attachment.
@@ -1315,33 +1320,36 @@ func (e *editor) tryAddFileRef(word string) {
13151320
return // not a path-like reference (e.g., @username)
13161321
}
13171322

1318-
e.addFileAttachment(word)
1323+
if err := e.addFileAttachment(word); err != nil {
1324+
slog.Debug("speculative file ref not valid", "word", word, "error", err)
1325+
}
13191326
}
13201327

13211328
// addFileAttachment adds a file reference as an attachment if valid.
13221329
// The path is resolved to an absolute path so downstream consumers
13231330
// (e.g. processFileAttachment) always receive a fully qualified path.
1324-
func (e *editor) addFileAttachment(placeholder string) {
1331+
func (e *editor) addFileAttachment(placeholder string) error {
13251332
path := strings.TrimPrefix(placeholder, "@")
13261333

13271334
// Resolve to absolute path so the attachment carries a fully qualified
13281335
// path regardless of the working directory at send time.
13291336
absPath, err := filepath.Abs(path)
13301337
if err != nil {
1331-
slog.Warn("skipping attachment: cannot resolve path", "path", path, "error", err)
1332-
return
1338+
return fmt.Errorf("cannot resolve path %s: %w", path, err)
13331339
}
13341340

1335-
// Check if it's an existing file (not directory)
1336-
info, err := os.Stat(absPath)
1337-
if err != nil || info.IsDir() {
1338-
return
1341+
info, err := validateFilePath(absPath)
1342+
if err != nil {
1343+
return fmt.Errorf("invalid file path %s: %w", absPath, err)
1344+
}
1345+
if info.IsDir() {
1346+
return fmt.Errorf("path is a directory: %s", absPath)
13391347
}
13401348

13411349
// Avoid duplicates
13421350
for _, att := range e.attachments {
13431351
if att.placeholder == placeholder {
1344-
return
1352+
return nil
13451353
}
13461354
}
13471355

@@ -1352,6 +1360,7 @@ func (e *editor) addFileAttachment(placeholder string) {
13521360
sizeBytes: int(info.Size()),
13531361
isTemp: false,
13541362
})
1363+
return nil
13551364
}
13561365

13571366
// collectAttachments returns structured attachments for all items referenced in
@@ -1451,6 +1460,18 @@ func (e *editor) SendContent() tea.Cmd {
14511460
}
14521461

14531462
func (e *editor) handlePaste(content string) bool {
1463+
// First, try to parse as file paths (drag-and-drop)
1464+
filePaths := ParsePastedFiles(content)
1465+
if len(filePaths) > 0 && e.allFilesValid(filePaths) {
1466+
for _, path := range filePaths {
1467+
if err := e.AttachFile(path); err != nil {
1468+
slog.Warn("failed to attach dropped file", "path", path, "error", err)
1469+
}
1470+
}
1471+
return true
1472+
}
1473+
1474+
// Not file paths, handle as text paste
14541475
// Count lines (newlines + 1 for content without trailing newline)
14551476
lines := strings.Count(content, "\n") + 1
14561477
if strings.HasSuffix(content, "\n") {
@@ -1477,6 +1498,35 @@ func (e *editor) handlePaste(content string) bool {
14771498
return true
14781499
}
14791500

1501+
// allFilesValid checks if all paths exist, are regular files (not dirs/symlinks), and have supported types.
1502+
func (e *editor) allFilesValid(filePaths []string) bool {
1503+
if len(filePaths) == 0 {
1504+
return false
1505+
}
1506+
1507+
for _, path := range filePaths {
1508+
info, err := validateFilePath(path)
1509+
if err != nil {
1510+
return false
1511+
}
1512+
1513+
if info.IsDir() {
1514+
return false
1515+
}
1516+
1517+
if !IsSupportedFileType(path) {
1518+
return false
1519+
}
1520+
1521+
if info.Size() > 5*1024*1024 {
1522+
slog.Warn("file too large for attachment", "path", path, "size", info.Size())
1523+
return false
1524+
}
1525+
}
1526+
1527+
return true
1528+
}
1529+
14801530
func (e *editor) updateAttachmentBanner() {
14811531
if e.banner == nil {
14821532
return

pkg/tui/components/editor/paste.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package editor
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"slices"
7+
"strings"
8+
)
9+
10+
// validateFilePath checks that a path is safe: no path traversal, no symlinks.
11+
func validateFilePath(path string) (os.FileInfo, error) {
12+
clean := filepath.Clean(path)
13+
if strings.Contains(clean, "..") {
14+
return nil, os.ErrPermission
15+
}
16+
17+
info, err := os.Lstat(clean)
18+
if err != nil {
19+
return nil, err
20+
}
21+
if info.Mode()&os.ModeSymlink != 0 {
22+
return nil, os.ErrPermission
23+
}
24+
return info, nil
25+
}
26+
27+
// Supported file extensions for drag-and-drop attachments
28+
var supportedFileExtensions = []string{
29+
// Images
30+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
31+
// PDFs
32+
".pdf",
33+
// Text files (future)
34+
// ".txt", ".md", ".json", ".yaml", ".yml", ".toml",
35+
}
36+
37+
// ParsePastedFiles attempts to parse pasted content as file paths.
38+
// It handles different terminal formats:
39+
// - Unix: space-separated with backslash escaping
40+
// - Windows Terminal: quote-wrapped paths
41+
// - Single file: just the path
42+
//
43+
// Returns nil if the content doesn't look like file paths.
44+
func ParsePastedFiles(s string) []string {
45+
s = strings.TrimSpace(s)
46+
if s == "" {
47+
return nil
48+
}
49+
50+
// NOTE: Rio terminal on Windows adds NULL chars for some reason.
51+
s = strings.ReplaceAll(s, "\x00", "")
52+
53+
// Try simple stat first - if all lines are valid files, use them
54+
if attemptStatAll(s) {
55+
return strings.Split(s, "\n")
56+
}
57+
58+
// Detect Windows Terminal format (quote-wrapped)
59+
if os.Getenv("WT_SESSION") != "" {
60+
return windowsTerminalParsePastedFiles(s)
61+
}
62+
63+
// Default to Unix format (space-separated with backslash escaping)
64+
return unixParsePastedFiles(s)
65+
}
66+
67+
// attemptStatAll tries to stat each line as a file path.
68+
// Returns true if ALL lines exist as regular files (not directories or symlinks).
69+
func attemptStatAll(s string) bool {
70+
lines := strings.Split(s, "\n")
71+
if len(lines) == 0 {
72+
return false
73+
}
74+
75+
for _, line := range lines {
76+
line = strings.TrimSpace(line)
77+
if line == "" {
78+
continue
79+
}
80+
info, err := validateFilePath(line)
81+
if err != nil || info.IsDir() {
82+
return false
83+
}
84+
}
85+
return true
86+
}
87+
88+
// windowsTerminalParsePastedFiles parses Windows Terminal format.
89+
// Windows Terminal wraps file paths in quotes: "C:\path\to\file.png"
90+
func windowsTerminalParsePastedFiles(s string) []string {
91+
if strings.TrimSpace(s) == "" {
92+
return nil
93+
}
94+
95+
var (
96+
paths []string
97+
current strings.Builder
98+
inQuotes = false
99+
)
100+
101+
for i := range len(s) {
102+
ch := s[i]
103+
104+
switch {
105+
case ch == '"':
106+
if inQuotes {
107+
// End of quoted section
108+
if current.Len() > 0 {
109+
paths = append(paths, current.String())
110+
current.Reset()
111+
}
112+
inQuotes = false
113+
} else {
114+
// Start of quoted section
115+
inQuotes = true
116+
}
117+
case inQuotes:
118+
current.WriteByte(ch)
119+
case ch != ' ' && ch != '\n' && ch != '\r':
120+
// Text outside quotes is not allowed
121+
return nil
122+
}
123+
}
124+
125+
// Add any remaining content if quotes were properly closed
126+
if current.Len() > 0 && !inQuotes {
127+
paths = append(paths, current.String())
128+
}
129+
130+
// If quotes were not closed, return nil (malformed input)
131+
if inQuotes {
132+
return nil
133+
}
134+
135+
return paths
136+
}
137+
138+
// unixParsePastedFiles parses Unix terminal format.
139+
// Unix terminals use space-separated paths with backslash escaping.
140+
// Example: /path/to/file1.png /path/to/my\ file\ with\ spaces.jpg
141+
func unixParsePastedFiles(s string) []string {
142+
if strings.TrimSpace(s) == "" {
143+
return nil
144+
}
145+
146+
var (
147+
paths []string
148+
current strings.Builder
149+
escaped = false
150+
)
151+
152+
for i := range len(s) {
153+
ch := s[i]
154+
155+
switch {
156+
case escaped:
157+
// After a backslash, add the character as-is (including space)
158+
current.WriteByte(ch)
159+
escaped = false
160+
case ch == '\\':
161+
if i == len(s)-1 {
162+
// Trailing backslash is malformed input; strip it
163+
break
164+
}
165+
escaped = true
166+
case ch == ' ' || ch == '\n' || ch == '\r':
167+
// Space/newline separates paths (unless escaped)
168+
if current.Len() > 0 {
169+
paths = append(paths, current.String())
170+
current.Reset()
171+
}
172+
default:
173+
current.WriteByte(ch)
174+
}
175+
}
176+
177+
// Handle trailing backslash if present
178+
if escaped {
179+
current.WriteByte('\\')
180+
}
181+
182+
// Add the last path if any
183+
if current.Len() > 0 {
184+
paths = append(paths, current.String())
185+
}
186+
187+
return paths
188+
}
189+
190+
// IsSupportedFileType checks if a file has a supported extension.
191+
func IsSupportedFileType(path string) bool {
192+
ext := strings.ToLower(filepath.Ext(path))
193+
return slices.Contains(supportedFileExtensions, ext)
194+
}
195+
196+
// GetFileType returns a human-readable file type for display.
197+
func GetFileType(path string) string {
198+
ext := strings.ToLower(filepath.Ext(path))
199+
switch ext {
200+
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
201+
return "image"
202+
case ".pdf":
203+
return "pdf"
204+
case ".txt", ".md":
205+
return "text"
206+
case ".json", ".yaml", ".yml", ".toml":
207+
return "config"
208+
default:
209+
return "file"
210+
}
211+
}
212+
213+
// GetFileIcon returns an emoji icon for a file type.
214+
func GetFileIcon(fileType string) string {
215+
switch fileType {
216+
case "image":
217+
return "🖼️"
218+
case "pdf":
219+
return "📄"
220+
case "text":
221+
return "📝"
222+
case "config":
223+
return "⚙️"
224+
default:
225+
return "📎"
226+
}
227+
}

0 commit comments

Comments
 (0)