1package editor
2
3import (
4 "context"
5 "fmt"
6 "math/rand"
7 "net/http"
8 "os"
9 "path/filepath"
10 "regexp"
11 "slices"
12 "strconv"
13 "strings"
14 "unicode"
15
16 "charm.land/bubbles/v2/key"
17 "charm.land/bubbles/v2/textarea"
18 tea "charm.land/bubbletea/v2"
19 "charm.land/lipgloss/v2"
20 "github.com/charmbracelet/crush/internal/app"
21 "github.com/charmbracelet/crush/internal/fsext"
22 "github.com/charmbracelet/crush/internal/message"
23 "github.com/charmbracelet/crush/internal/session"
24 "github.com/charmbracelet/crush/internal/tui/components/chat"
25 "github.com/charmbracelet/crush/internal/tui/components/completions"
26 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
28 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
31 "github.com/charmbracelet/crush/internal/tui/styles"
32 "github.com/charmbracelet/crush/internal/tui/util"
33 "github.com/charmbracelet/x/ansi"
34 "github.com/charmbracelet/x/editor"
35)
36
37var (
38 errClipboardPlatformUnsupported = fmt.Errorf("clipboard operations are not supported on this platform")
39 errClipboardUnknownFormat = fmt.Errorf("unknown clipboard format")
40)
41
42// If pasted text has more than 10 newlines, treat it as a file attachment.
43const pasteLinesThreshold = 10
44
45type Editor interface {
46 util.Model
47 layout.Sizeable
48 layout.Focusable
49 layout.Help
50 layout.Positional
51
52 SetSession(session session.Session) tea.Cmd
53 IsCompletionsOpen() bool
54 HasAttachments() bool
55 IsEmpty() bool
56 Cursor() *tea.Cursor
57}
58
59type FileCompletionItem struct {
60 Path string // The file path
61}
62
63type editorCmp struct {
64 width int
65 height int
66 x, y int
67 app *app.App
68 session session.Session
69 sessionFileReads []string
70 textarea textarea.Model
71 attachments []message.Attachment
72 deleteMode bool
73 readyPlaceholder string
74 workingPlaceholder string
75
76 keyMap EditorKeyMap
77
78 // File path completions
79 currentQuery string
80 completionsStartIndex int
81 isCompletionsOpen bool
82}
83
84var DeleteKeyMaps = DeleteAttachmentKeyMaps{
85 AttachmentDeleteMode: key.NewBinding(
86 key.WithKeys("ctrl+r"),
87 key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
88 ),
89 Escape: key.NewBinding(
90 key.WithKeys("esc", "alt+esc"),
91 key.WithHelp("esc", "cancel delete mode"),
92 ),
93 DeleteAllAttachments: key.NewBinding(
94 key.WithKeys("r"),
95 key.WithHelp("ctrl+r+r", "delete all attachments"),
96 ),
97}
98
99const maxFileResults = 25
100
101type OpenEditorMsg struct {
102 Text string
103}
104
105func (m *editorCmp) openEditor(value string) tea.Cmd {
106 tmpfile, err := os.CreateTemp("", "msg_*.md")
107 if err != nil {
108 return util.ReportError(err)
109 }
110 defer tmpfile.Close() //nolint:errcheck
111 if _, err := tmpfile.WriteString(value); err != nil {
112 return util.ReportError(err)
113 }
114 cmd, err := editor.Command(
115 "crush",
116 tmpfile.Name(),
117 editor.AtPosition(
118 m.textarea.Line()+1,
119 m.textarea.Column()+1,
120 ),
121 )
122 if err != nil {
123 return util.ReportError(err)
124 }
125 return tea.ExecProcess(cmd, func(err error) tea.Msg {
126 if err != nil {
127 return util.ReportError(err)
128 }
129 content, err := os.ReadFile(tmpfile.Name())
130 if err != nil {
131 return util.ReportError(err)
132 }
133 if len(content) == 0 {
134 return util.ReportWarn("Message is empty")
135 }
136 os.Remove(tmpfile.Name())
137 return OpenEditorMsg{
138 Text: strings.TrimSpace(string(content)),
139 }
140 })
141}
142
143func (m *editorCmp) Init() tea.Cmd {
144 return nil
145}
146
147func (m *editorCmp) send() tea.Cmd {
148 value := m.textarea.Value()
149 value = strings.TrimSpace(value)
150
151 switch value {
152 case "exit", "quit":
153 m.textarea.Reset()
154 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
155 }
156
157 attachments := m.attachments
158
159 if value == "" && !message.ContainsTextAttachment(attachments) {
160 return nil
161 }
162
163 m.textarea.Reset()
164 m.attachments = nil
165 // Change the placeholder when sending a new message.
166 m.randomizePlaceholders()
167
168 return tea.Batch(
169 util.CmdHandler(chat.SendMsg{
170 Text: value,
171 Attachments: attachments,
172 }),
173 )
174}
175
176func (m *editorCmp) repositionCompletions() tea.Msg {
177 x, y := m.completionsPosition()
178 return completions.RepositionCompletionsMsg{X: x, Y: y}
179}
180
181func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
182 var cmd tea.Cmd
183 var cmds []tea.Cmd
184 switch msg := msg.(type) {
185 case chat.SessionClearedMsg:
186 m.session = session.Session{}
187 m.sessionFileReads = nil
188 case tea.WindowSizeMsg:
189 return m, m.repositionCompletions
190 case filepicker.FilePickedMsg:
191 m.attachments = append(m.attachments, msg.Attachment)
192 return m, nil
193 case completions.CompletionsOpenedMsg:
194 m.isCompletionsOpen = true
195 case completions.CompletionsClosedMsg:
196 m.isCompletionsOpen = false
197 m.currentQuery = ""
198 m.completionsStartIndex = 0
199 case completions.SelectCompletionMsg:
200 if !m.isCompletionsOpen {
201 return m, nil
202 }
203 if item, ok := msg.Value.(FileCompletionItem); ok {
204 word := m.textarea.Word()
205 // If the selected item is a file, insert its path into the textarea
206 value := m.textarea.Value()
207 value = value[:m.completionsStartIndex] + // Remove the current query
208 item.Path + // Insert the file path
209 value[m.completionsStartIndex+len(word):] // Append the rest of the value
210 // XXX: This will always move the cursor to the end of the textarea.
211 m.textarea.SetValue(value)
212 m.textarea.MoveToEnd()
213 if !msg.Insert {
214 m.isCompletionsOpen = false
215 m.currentQuery = ""
216 m.completionsStartIndex = 0
217 }
218 absPath, _ := filepath.Abs(item.Path)
219
220 ctx := context.Background()
221
222 // Skip attachment if file was already read and hasn't been modified.
223 if m.session.ID != "" {
224 lastRead := m.app.FileTracker.LastReadTime(ctx, m.session.ID, absPath)
225 if !lastRead.IsZero() {
226 if info, err := os.Stat(item.Path); err == nil && !info.ModTime().After(lastRead) {
227 return m, nil
228 }
229 }
230 } else if slices.Contains(m.sessionFileReads, absPath) {
231 return m, nil
232 }
233
234 m.sessionFileReads = append(m.sessionFileReads, absPath)
235 content, err := os.ReadFile(item.Path)
236 if err != nil {
237 // if it fails, let the LLM handle it later.
238 return m, nil
239 }
240 m.attachments = append(m.attachments, message.Attachment{
241 FilePath: item.Path,
242 FileName: filepath.Base(item.Path),
243 MimeType: mimeOf(content),
244 Content: content,
245 })
246 }
247
248 case commands.OpenExternalEditorMsg:
249 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
250 return m, util.ReportWarn("Agent is working, please wait...")
251 }
252 return m, m.openEditor(m.textarea.Value())
253 case OpenEditorMsg:
254 m.textarea.SetValue(msg.Text)
255 m.textarea.MoveToEnd()
256 case tea.PasteMsg:
257 if strings.Count(msg.Content, "\n") > pasteLinesThreshold {
258 content := []byte(msg.Content)
259 if len(content) > maxAttachmentSize {
260 return m, util.ReportWarn("Paste is too big (>5mb)")
261 }
262 name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
263 mimeType := mimeOf(content)
264 attachment := message.Attachment{
265 FileName: name,
266 FilePath: name,
267 MimeType: mimeType,
268 Content: content,
269 }
270 return m, util.CmdHandler(filepicker.FilePickedMsg{
271 Attachment: attachment,
272 })
273 }
274
275 // Try to parse as a file path.
276 content, path, err := filepathToFile(msg.Content)
277 if err != nil {
278 // Not a file path, just update the textarea normally.
279 m.textarea, cmd = m.textarea.Update(msg)
280 return m, cmd
281 }
282
283 if len(content) > maxAttachmentSize {
284 return m, util.ReportWarn("File is too big (>5mb)")
285 }
286
287 mimeType := mimeOf(content)
288 attachment := message.Attachment{
289 FilePath: path,
290 FileName: filepath.Base(path),
291 MimeType: mimeType,
292 Content: content,
293 }
294 if !attachment.IsText() && !attachment.IsImage() {
295 return m, util.ReportWarn("Invalid file content type: " + mimeType)
296 }
297 return m, util.CmdHandler(filepicker.FilePickedMsg{
298 Attachment: attachment,
299 })
300
301 case commands.ToggleYoloModeMsg:
302 m.setEditorPrompt()
303 return m, nil
304 case tea.KeyPressMsg:
305 cur := m.textarea.Cursor()
306 curIdx := m.textarea.Width()*cur.Y + cur.X
307 switch {
308 // Open command palette when "/" is pressed on empty prompt
309 case msg.String() == "/" && m.IsEmpty():
310 return m, util.CmdHandler(dialogs.OpenDialogMsg{
311 Model: commands.NewCommandDialog(m.session.ID),
312 })
313 // Completions
314 case msg.String() == "@" && !m.isCompletionsOpen &&
315 // only show if beginning of prompt, or if previous char is a space or newline:
316 (len(m.textarea.Value()) == 0 || unicode.IsSpace(rune(m.textarea.Value()[len(m.textarea.Value())-1]))):
317 m.isCompletionsOpen = true
318 m.currentQuery = ""
319 m.completionsStartIndex = curIdx
320 cmds = append(cmds, m.startCompletions)
321 case m.isCompletionsOpen && curIdx <= m.completionsStartIndex:
322 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
323 }
324 if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
325 m.deleteMode = true
326 return m, nil
327 }
328 if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
329 m.deleteMode = false
330 m.attachments = nil
331 return m, nil
332 }
333 rune := msg.Code
334 if m.deleteMode && unicode.IsDigit(rune) {
335 num := int(rune - '0')
336 m.deleteMode = false
337 if num < 10 && len(m.attachments) > num {
338 if num == 0 {
339 m.attachments = m.attachments[num+1:]
340 } else {
341 m.attachments = slices.Delete(m.attachments, num, num+1)
342 }
343 return m, nil
344 }
345 }
346 if key.Matches(msg, m.keyMap.OpenEditor) {
347 if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
348 return m, util.ReportWarn("Agent is working, please wait...")
349 }
350 return m, m.openEditor(m.textarea.Value())
351 }
352 if key.Matches(msg, DeleteKeyMaps.Escape) {
353 m.deleteMode = false
354 return m, nil
355 }
356 if key.Matches(msg, m.keyMap.Newline) {
357 m.textarea.InsertRune('\n')
358 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
359 }
360 // Handle image paste from clipboard
361 if key.Matches(msg, m.keyMap.PasteImage) {
362 imageData, err := readClipboard(clipboardFormatImage)
363
364 if err != nil || len(imageData) == 0 {
365 // If no image data found, try to get text data (could be file path)
366 var textData []byte
367 textData, err = readClipboard(clipboardFormatText)
368 if err != nil || len(textData) == 0 {
369 // If clipboard is empty, show a warning
370 return m, util.ReportWarn("No data found in clipboard. Note: Some terminals may not support reading image data from clipboard directly.")
371 }
372
373 // Check if the text data is a file path
374 textStr := string(textData)
375 // First, try to interpret as a file path (existing functionality)
376 path := strings.ReplaceAll(textStr, "\\ ", " ")
377 path, err = filepath.Abs(strings.TrimSpace(path))
378 if err == nil {
379 isAllowedType := false
380 for _, ext := range filepicker.AllowedTypes {
381 if strings.HasSuffix(path, ext) {
382 isAllowedType = true
383 break
384 }
385 }
386 if isAllowedType {
387 tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
388 if !tooBig {
389 content, err := os.ReadFile(path)
390 if err == nil {
391 mimeBufferSize := min(512, len(content))
392 mimeType := http.DetectContentType(content[:mimeBufferSize])
393 fileName := filepath.Base(path)
394 attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
395 return m, util.CmdHandler(filepicker.FilePickedMsg{
396 Attachment: attachment,
397 })
398 }
399 }
400 }
401 }
402
403 // If not a valid file path, show a warning
404 return m, util.ReportWarn("No image found in clipboard")
405 } else {
406 // We have image data from the clipboard
407 // Create a temporary file to store the clipboard image data
408 tempFile, err := os.CreateTemp("", "clipboard_image_crush_*")
409 if err != nil {
410 return m, util.ReportError(err)
411 }
412 defer tempFile.Close()
413
414 // Write clipboard content to the temporary file
415 _, err = tempFile.Write(imageData)
416 if err != nil {
417 return m, util.ReportError(err)
418 }
419
420 // Determine the file extension based on the image data
421 mimeBufferSize := min(512, len(imageData))
422 mimeType := http.DetectContentType(imageData[:mimeBufferSize])
423
424 // Create an attachment from the temporary file
425 fileName := filepath.Base(tempFile.Name())
426 attachment := message.Attachment{
427 FilePath: tempFile.Name(),
428 FileName: fileName,
429 MimeType: mimeType,
430 Content: imageData,
431 }
432
433 return m, util.CmdHandler(filepicker.FilePickedMsg{
434 Attachment: attachment,
435 })
436 }
437 }
438 // Handle Enter key
439 if m.textarea.Focused() && key.Matches(msg, m.keyMap.SendMessage) {
440 value := m.textarea.Value()
441 if strings.HasSuffix(value, "\\") {
442 // If the last character is a backslash, remove it and add a newline.
443 m.textarea.SetValue(strings.TrimSuffix(value, "\\"))
444 } else {
445 // Otherwise, send the message
446 return m, m.send()
447 }
448 }
449 }
450
451 m.textarea, cmd = m.textarea.Update(msg)
452 cmds = append(cmds, cmd)
453
454 if m.textarea.Focused() {
455 kp, ok := msg.(tea.KeyPressMsg)
456 if ok {
457 if kp.String() == "space" || m.textarea.Value() == "" {
458 m.isCompletionsOpen = false
459 m.currentQuery = ""
460 m.completionsStartIndex = 0
461 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
462 } else {
463 word := m.textarea.Word()
464 if strings.HasPrefix(word, "@") {
465 // XXX: wont' work if editing in the middle of the field.
466 m.completionsStartIndex = strings.LastIndex(m.textarea.Value(), word)
467 m.currentQuery = word[1:]
468 x, y := m.completionsPosition()
469 x -= len(m.currentQuery)
470 m.isCompletionsOpen = true
471 cmds = append(cmds,
472 util.CmdHandler(completions.FilterCompletionsMsg{
473 Query: m.currentQuery,
474 Reopen: m.isCompletionsOpen,
475 X: x,
476 Y: y,
477 }),
478 )
479 } else if m.isCompletionsOpen {
480 m.isCompletionsOpen = false
481 m.currentQuery = ""
482 m.completionsStartIndex = 0
483 cmds = append(cmds, util.CmdHandler(completions.CloseCompletionsMsg{}))
484 }
485 }
486 }
487 }
488
489 return m, tea.Batch(cmds...)
490}
491
492func (m *editorCmp) setEditorPrompt() {
493 if m.app.Permissions.SkipRequests() {
494 m.textarea.SetPromptFunc(4, yoloPromptFunc)
495 return
496 }
497 m.textarea.SetPromptFunc(4, normalPromptFunc)
498}
499
500func (m *editorCmp) completionsPosition() (int, int) {
501 cur := m.textarea.Cursor()
502 if cur == nil {
503 return m.x, m.y + 1 // adjust for padding
504 }
505 x := cur.X + m.x
506 y := cur.Y + m.y + 1 // adjust for padding
507 return x, y
508}
509
510func (m *editorCmp) Cursor() *tea.Cursor {
511 cursor := m.textarea.Cursor()
512 if cursor != nil {
513 cursor.X = cursor.X + m.x + 1
514 cursor.Y = cursor.Y + m.y + 1 // adjust for padding
515 }
516 return cursor
517}
518
519var readyPlaceholders = [...]string{
520 "Ready!",
521 "Ready...",
522 "Ready?",
523 "Ready for instructions",
524}
525
526var workingPlaceholders = [...]string{
527 "Working!",
528 "Working...",
529 "Brrrrr...",
530 "Prrrrrrrr...",
531 "Processing...",
532 "Thinking...",
533}
534
535func (m *editorCmp) randomizePlaceholders() {
536 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
537 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
538}
539
540func (m *editorCmp) View() string {
541 t := styles.CurrentTheme()
542 // Update placeholder
543 if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.IsBusy() {
544 m.textarea.Placeholder = m.workingPlaceholder
545 } else {
546 m.textarea.Placeholder = m.readyPlaceholder
547 }
548 if m.app.Permissions.SkipRequests() {
549 m.textarea.Placeholder = "Yolo mode!"
550 }
551 if len(m.attachments) == 0 {
552 return t.S().Base.Padding(1).Render(
553 m.textarea.View(),
554 )
555 }
556 return t.S().Base.Padding(0, 1, 1, 1).Render(
557 lipgloss.JoinVertical(
558 lipgloss.Top,
559 m.attachmentsContent(),
560 m.textarea.View(),
561 ),
562 )
563}
564
565func (m *editorCmp) SetSize(width, height int) tea.Cmd {
566 m.width = width
567 m.height = height
568 m.textarea.SetWidth(width - 2) // adjust for padding
569 m.textarea.SetHeight(height - 2) // adjust for padding
570 return nil
571}
572
573func (m *editorCmp) GetSize() (int, int) {
574 return m.textarea.Width(), m.textarea.Height()
575}
576
577func (m *editorCmp) attachmentsContent() string {
578 var styledAttachments []string
579 t := styles.CurrentTheme()
580 attachmentStyle := t.S().Base.
581 Padding(0, 1).
582 MarginRight(1).
583 Background(t.FgMuted).
584 Foreground(t.FgBase).
585 Render
586 iconStyle := t.S().Base.
587 Foreground(t.BgSubtle).
588 Background(t.Green).
589 Padding(0, 1).
590 Bold(true).
591 Render
592 rmStyle := t.S().Base.
593 Padding(0, 1).
594 Bold(true).
595 Background(t.Red).
596 Foreground(t.FgBase).
597 Render
598 for i, attachment := range m.attachments {
599 filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
600 icon := styles.ImageIcon
601 if attachment.IsText() {
602 icon = styles.TextIcon
603 }
604 if m.deleteMode {
605 styledAttachments = append(
606 styledAttachments,
607 rmStyle(fmt.Sprintf("%d", i)),
608 attachmentStyle(filename),
609 )
610 continue
611 }
612 styledAttachments = append(
613 styledAttachments,
614 iconStyle(icon),
615 attachmentStyle(filename),
616 )
617 }
618 return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
619}
620
621func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
622 m.x = x
623 m.y = y
624 return nil
625}
626
627func (m *editorCmp) startCompletions() tea.Msg {
628 ls := m.app.Config().Options.TUI.Completions
629 depth, limit := ls.Limits()
630 files, _, _ := fsext.ListDirectory(".", nil, depth, limit)
631 slices.Sort(files)
632 completionItems := make([]completions.Completion, 0, len(files))
633 for _, file := range files {
634 file = strings.TrimPrefix(file, "./")
635 completionItems = append(completionItems, completions.Completion{
636 Title: file,
637 Value: FileCompletionItem{
638 Path: file,
639 },
640 })
641 }
642
643 x, y := m.completionsPosition()
644 return completions.OpenCompletionsMsg{
645 Completions: completionItems,
646 X: x,
647 Y: y,
648 MaxResults: maxFileResults,
649 }
650}
651
652// Blur implements Container.
653func (c *editorCmp) Blur() tea.Cmd {
654 c.textarea.Blur()
655 return nil
656}
657
658// Focus implements Container.
659func (c *editorCmp) Focus() tea.Cmd {
660 return c.textarea.Focus()
661}
662
663// IsFocused implements Container.
664func (c *editorCmp) IsFocused() bool {
665 return c.textarea.Focused()
666}
667
668// Bindings implements Container.
669func (c *editorCmp) Bindings() []key.Binding {
670 return c.keyMap.KeyBindings()
671}
672
673// TODO: most likely we do not need to have the session here
674// we need to move some functionality to the page level
675func (c *editorCmp) SetSession(session session.Session) tea.Cmd {
676 c.session = session
677 for _, path := range c.sessionFileReads {
678 c.app.FileTracker.RecordRead(context.Background(), session.ID, path)
679 }
680 return nil
681}
682
683func (c *editorCmp) IsCompletionsOpen() bool {
684 return c.isCompletionsOpen
685}
686
687func (c *editorCmp) HasAttachments() bool {
688 return len(c.attachments) > 0
689}
690
691func (c *editorCmp) IsEmpty() bool {
692 return strings.TrimSpace(c.textarea.Value()) == ""
693}
694
695func normalPromptFunc(info textarea.PromptInfo) string {
696 t := styles.CurrentTheme()
697 if info.LineNumber == 0 {
698 if info.Focused {
699 return " > "
700 }
701 return "::: "
702 }
703 if info.Focused {
704 return t.S().Base.Foreground(t.GreenDark).Render("::: ")
705 }
706 return t.S().Muted.Render("::: ")
707}
708
709func yoloPromptFunc(info textarea.PromptInfo) string {
710 t := styles.CurrentTheme()
711 if info.LineNumber == 0 {
712 if info.Focused {
713 return fmt.Sprintf("%s ", t.YoloIconFocused)
714 } else {
715 return fmt.Sprintf("%s ", t.YoloIconBlurred)
716 }
717 }
718 if info.Focused {
719 return fmt.Sprintf("%s ", t.YoloDotsFocused)
720 }
721 return fmt.Sprintf("%s ", t.YoloDotsBlurred)
722}
723
724func New(app *app.App) Editor {
725 t := styles.CurrentTheme()
726 ta := textarea.New()
727 ta.SetStyles(t.S().TextArea)
728 ta.ShowLineNumbers = false
729 ta.CharLimit = -1
730 ta.SetVirtualCursor(false)
731 ta.Focus()
732 e := &editorCmp{
733 // TODO: remove the app instance from here
734 app: app,
735 textarea: ta,
736 keyMap: DefaultEditorKeyMap(),
737 }
738 e.setEditorPrompt()
739
740 e.randomizePlaceholders()
741 e.textarea.Placeholder = e.readyPlaceholder
742
743 return e
744}
745
746var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
747
748var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
749
750func (m *editorCmp) pasteIdx() int {
751 result := 0
752 for _, at := range m.attachments {
753 found := pasteRE.FindStringSubmatch(at.FileName)
754 if len(found) == 0 {
755 continue
756 }
757 idx, err := strconv.Atoi(found[1])
758 if err == nil {
759 result = max(result, idx)
760 }
761 }
762 return result + 1
763}
764
765func filepathToFile(name string) ([]byte, string, error) {
766 path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
767 if err != nil {
768 return nil, "", err
769 }
770 content, err := os.ReadFile(path)
771 if err != nil {
772 return nil, "", err
773 }
774 return content, path, nil
775}
776
777func mimeOf(content []byte) string {
778 mimeBufferSize := min(512, len(content))
779 return http.DetectContentType(content[:mimeBufferSize])
780}