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}