Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 95 additions & 28 deletions pkg/tui/components/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package editor
import (
"context"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
Expand Down Expand Up @@ -1286,21 +1287,45 @@ func (e *editor) InsertText(text string) {
e.refreshSuggestion()
}

// AttachFile adds a file as an attachment and inserts @filepath into the editor
// AttachFile safely adds a file as an attachment and inserts @filepath into the editor.
// If the file does not exist or is not accessible, the reference is ignored gracefully.
func (e *editor) AttachFile(filePath string) {
// Validate the file exists before attempting to attach it
if info, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
// fully qualify log package to avoid shadowing
log.Println("AttachFile skipped: file '" + filePath + "' does not exist.")
} else {
log.Println("AttachFile skipped: cannot access file '"+filePath+"':", err)
}
return
} else if info.IsDir() {
log.Println("AttachFile skipped: '" + filePath + "' is a directory, not a file.")
return
}

// Build the placeholder reference
placeholder := "@" + filePath

// Add the file as an attachment
e.addFileAttachment(placeholder)

// Insert the placeholder into the editor textarea
currentValue := e.textarea.Value()
e.textarea.SetValue(currentValue + placeholder + " ")

// Move cursor to the end and mark user activity
e.textarea.MoveToEnd()
e.userTyped = true

// Update any UI indicators or banners
e.updateAttachmentBanner()
}

// tryAddFileRef checks if word is a valid @filepath and adds it as attachment.
// Called when cursor leaves a word to detect manually-typed file references.
// tryAddFileRef safely checks if word is a valid @filepath and adds it as an attachment.
// Skips non-existent files, directories, or paste placeholders with logging.
func (e *editor) tryAddFileRef(word string) {
// Must start with @ and look like a path (contains / or .)
// Must start with @ and be long enough to be meaningful
if !strings.HasPrefix(word, "@") || len(word) < 2 {
return
}
Expand All @@ -1310,11 +1335,29 @@ func (e *editor) tryAddFileRef(word string) {
return
}

path := word[1:] // strip @
if !strings.ContainsAny(path, "/.") {
return // not a path-like reference (e.g., @username)
filePath := word[1:] // remove '@'

// Must look like a path (contains '/' or '.')
if !strings.ContainsAny(filePath, "/.") {
return // e.g., @username, ignore
}

// Validate the file exists and is not a directory
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
log.Println("tryAddFileRef skipped: file does not exist:", filePath)
} else {
log.Println("tryAddFileRef skipped: cannot access file:", filePath, err)
}
return
}
if info.IsDir() {
log.Println("tryAddFileRef skipped: path is a directory:", filePath)
return
}

// File is valid, add as attachment
e.addFileAttachment(word)
}

Expand Down Expand Up @@ -1354,46 +1397,70 @@ func (e *editor) addFileAttachment(placeholder string) {
})
}

// collectAttachments returns structured attachments for all items referenced in
// content. For paste attachments the content is read into memory (the backing
// temp file is removed). For file-reference attachments the path is preserved
// so the consumer can read and classify the file (e.g. detect MIME type).
// Unreferenced attachments are cleaned up.
func (e *editor) collectAttachments(content string) []messages.Attachment {
// collectAttachments returns a map of placeholder -> file content
// for all attachments referenced in the provided content.
func (e *editor) collectAttachments(content string) map[string]string {
// Fast path: nothing to process
if len(e.attachments) == 0 {
return nil
}

var result []messages.Attachment
attachments := make(map[string]string)

// Reuse underlying slice memory to avoid allocations
// This keeps only attachments that should persist.
remaining := e.attachments[:0]

for _, att := range e.attachments {
// If placeholder is not referenced in content
// remove temp files and optionally preserve non-temp ones
if !strings.Contains(content, att.placeholder) {
if att.isTemp {
_ = os.Remove(att.path)
} else {
remaining = append(remaining, att)
}
continue
}

// Attempt to read attachment file
data, err := os.ReadFile(att.path)
if err != nil {
// Log warning but do NOT break rendering flow
slog.Warn("failed to read attachment",
"path", att.path,
"error", err,
)

// Ensure temp files are cleaned even on failure
if att.isTemp {
_ = os.Remove(att.path)
}

continue
}

// Store successfully read attachment
attachments[att.placeholder] = string(data)

// Remove temp files after successful read
if att.isTemp {
// Paste attachment: read into memory and remove the temp file.
data, err := os.ReadFile(att.path)
_ = os.Remove(att.path)
if err != nil {
slog.Warn("failed to read paste attachment", "path", att.path, "error", err)
continue
}
result = append(result, messages.Attachment{
Name: strings.TrimPrefix(att.placeholder, "@"),
Content: string(data),
})
} else {
// File-reference attachment: keep the path for later processing.
result = append(result, messages.Attachment{
Name: filepath.Base(att.path),
FilePath: att.path,
})
// Preserve non-temp attachments if needed later
remaining = append(remaining, att)
}
}
e.attachments = nil

// Update editor state with only remaining attachments
e.attachments = remaining

// Return nil if nothing valid was collected
if len(attachments) == 0 {
return nil
}

return result
}
Expand Down