editor.go

  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}