editor.go

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