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