editor.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676
  1. package chat
  2. import (
  3. "encoding/base64"
  4. "fmt"
  5. "log/slog"
  6. "net/url"
  7. "os"
  8. "path/filepath"
  9. "strconv"
  10. "strings"
  11. "github.com/charmbracelet/bubbles/v2/spinner"
  12. tea "github.com/charmbracelet/bubbletea/v2"
  13. "github.com/charmbracelet/lipgloss/v2"
  14. "github.com/google/uuid"
  15. "github.com/sst/opencode-sdk-go"
  16. "github.com/sst/opencode/internal/app"
  17. "github.com/sst/opencode/internal/attachment"
  18. "github.com/sst/opencode/internal/clipboard"
  19. "github.com/sst/opencode/internal/commands"
  20. "github.com/sst/opencode/internal/components/dialog"
  21. "github.com/sst/opencode/internal/components/textarea"
  22. "github.com/sst/opencode/internal/styles"
  23. "github.com/sst/opencode/internal/theme"
  24. "github.com/sst/opencode/internal/util"
  25. )
  26. type EditorComponent interface {
  27. tea.Model
  28. tea.ViewModel
  29. Content() string
  30. Lines() int
  31. Value() string
  32. Length() int
  33. Focused() bool
  34. Focus() (tea.Model, tea.Cmd)
  35. Blur()
  36. Submit() (tea.Model, tea.Cmd)
  37. Clear() (tea.Model, tea.Cmd)
  38. Paste() (tea.Model, tea.Cmd)
  39. Newline() (tea.Model, tea.Cmd)
  40. SetValue(value string)
  41. SetValueWithAttachments(value string)
  42. SetInterruptKeyInDebounce(inDebounce bool)
  43. SetExitKeyInDebounce(inDebounce bool)
  44. RestoreFromHistory(index int)
  45. }
  46. type editorComponent struct {
  47. app *app.App
  48. width int
  49. textarea textarea.Model
  50. spinner spinner.Model
  51. interruptKeyInDebounce bool
  52. exitKeyInDebounce bool
  53. historyIndex int // -1 means current (not in history)
  54. currentText string // Store current text when navigating history
  55. }
  56. func (m *editorComponent) Init() tea.Cmd {
  57. return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus)
  58. }
  59. func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  60. var cmds []tea.Cmd
  61. var cmd tea.Cmd
  62. switch msg := msg.(type) {
  63. case tea.WindowSizeMsg:
  64. m.width = msg.Width - 4
  65. return m, nil
  66. case spinner.TickMsg:
  67. m.spinner, cmd = m.spinner.Update(msg)
  68. return m, cmd
  69. case tea.KeyPressMsg:
  70. // Handle up/down arrows for history navigation
  71. switch msg.String() {
  72. case "up":
  73. // Only navigate history if cursor is at the first line and column
  74. if m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0 && len(m.app.State.MessageHistory) > 0 {
  75. if m.historyIndex == -1 {
  76. // Save current text before entering history
  77. m.currentText = m.textarea.Value()
  78. m.textarea.CursorStart()
  79. }
  80. // Move up in history (older messages)
  81. if m.historyIndex < len(m.app.State.MessageHistory)-1 {
  82. m.historyIndex++
  83. m.RestoreFromHistory(m.historyIndex)
  84. m.textarea.CursorStart()
  85. }
  86. return m, nil
  87. }
  88. case "down":
  89. // Only navigate history if cursor is at the last line and we're in history navigation
  90. if m.textarea.IsCursorAtEnd() && m.historyIndex > -1 {
  91. // Move down in history (newer messages)
  92. m.historyIndex--
  93. if m.historyIndex == -1 {
  94. // Restore current text
  95. m.textarea.Reset()
  96. m.textarea.SetValue(m.currentText)
  97. m.currentText = ""
  98. } else {
  99. m.RestoreFromHistory(m.historyIndex)
  100. m.textarea.CursorEnd()
  101. }
  102. return m, nil
  103. } else if m.historyIndex > -1 {
  104. m.textarea.CursorEnd()
  105. return m, nil
  106. }
  107. }
  108. // Reset history navigation on any other input
  109. if m.historyIndex != -1 {
  110. m.historyIndex = -1
  111. m.currentText = ""
  112. }
  113. // Maximize editor responsiveness for printable characters
  114. if msg.Text != "" {
  115. m.textarea, cmd = m.textarea.Update(msg)
  116. cmds = append(cmds, cmd)
  117. return m, tea.Batch(cmds...)
  118. }
  119. case tea.PasteMsg:
  120. text := string(msg)
  121. text = strings.ReplaceAll(text, "\\", "")
  122. text, err := strconv.Unquote(`"` + text + `"`)
  123. if err != nil {
  124. slog.Error("Failed to unquote text", "error", err)
  125. m.textarea.InsertRunesFromUserInput([]rune(msg))
  126. return m, nil
  127. }
  128. if _, err := os.Stat(text); err != nil {
  129. slog.Error("Failed to paste file", "error", err)
  130. m.textarea.InsertRunesFromUserInput([]rune(msg))
  131. return m, nil
  132. }
  133. filePath := text
  134. attachment := m.createAttachmentFromFile(filePath)
  135. if attachment == nil {
  136. m.textarea.InsertRunesFromUserInput([]rune(msg))
  137. return m, nil
  138. }
  139. m.textarea.InsertAttachment(attachment)
  140. m.textarea.InsertString(" ")
  141. case tea.ClipboardMsg:
  142. text := string(msg)
  143. m.textarea.InsertRunesFromUserInput([]rune(text))
  144. case dialog.ThemeSelectedMsg:
  145. m.textarea = updateTextareaStyles(m.textarea)
  146. m.spinner = createSpinner()
  147. return m, m.textarea.Focus()
  148. case dialog.CompletionSelectedMsg:
  149. switch msg.Item.ProviderID {
  150. case "commands":
  151. commandName := strings.TrimPrefix(msg.Item.Value, "/")
  152. updated, cmd := m.Clear()
  153. m = updated.(*editorComponent)
  154. cmds = append(cmds, cmd)
  155. cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)])))
  156. return m, tea.Batch(cmds...)
  157. case "files":
  158. atIndex := m.textarea.LastRuneIndex('@')
  159. if atIndex == -1 {
  160. // Should not happen, but as a fallback, just insert.
  161. m.textarea.InsertString(msg.Item.Value + " ")
  162. return m, nil
  163. }
  164. // The range to replace is from the '@' up to the current cursor position.
  165. // Replace the search term (e.g., "@search") with an empty string first.
  166. cursorCol := m.textarea.CursorColumn()
  167. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  168. // Now, insert the attachment at the position where the '@' was.
  169. // The cursor is now at `atIndex` after the replacement.
  170. filePath := msg.Item.Value
  171. attachment := m.createAttachmentFromPath(filePath)
  172. m.textarea.InsertAttachment(attachment)
  173. m.textarea.InsertString(" ")
  174. return m, nil
  175. case "symbols":
  176. atIndex := m.textarea.LastRuneIndex('@')
  177. if atIndex == -1 {
  178. // Should not happen, but as a fallback, just insert.
  179. m.textarea.InsertString(msg.Item.Value + " ")
  180. return m, nil
  181. }
  182. cursorCol := m.textarea.CursorColumn()
  183. m.textarea.ReplaceRange(atIndex, cursorCol, "")
  184. symbol := msg.Item.RawData.(opencode.Symbol)
  185. parts := strings.Split(symbol.Name, ".")
  186. lastPart := parts[len(parts)-1]
  187. attachment := &attachment.Attachment{
  188. ID: uuid.NewString(),
  189. Type: "symbol",
  190. Display: "@" + lastPart,
  191. URL: msg.Item.Value,
  192. Filename: lastPart,
  193. MediaType: "text/plain",
  194. Source: &attachment.SymbolSource{
  195. Path: symbol.Location.Uri,
  196. Name: symbol.Name,
  197. Kind: int(symbol.Kind),
  198. Range: attachment.SymbolRange{
  199. Start: attachment.Position{
  200. Line: int(symbol.Location.Range.Start.Line),
  201. Char: int(symbol.Location.Range.Start.Character),
  202. },
  203. End: attachment.Position{
  204. Line: int(symbol.Location.Range.End.Line),
  205. Char: int(symbol.Location.Range.End.Character),
  206. },
  207. },
  208. },
  209. }
  210. m.textarea.InsertAttachment(attachment)
  211. m.textarea.InsertString(" ")
  212. return m, nil
  213. default:
  214. slog.Debug("Unknown provider", "provider", msg.Item.ProviderID)
  215. return m, nil
  216. }
  217. }
  218. m.spinner, cmd = m.spinner.Update(msg)
  219. cmds = append(cmds, cmd)
  220. m.textarea, cmd = m.textarea.Update(msg)
  221. cmds = append(cmds, cmd)
  222. return m, tea.Batch(cmds...)
  223. }
  224. func (m *editorComponent) Content() string {
  225. width := m.width
  226. if m.app.Session.ID == "" {
  227. width = min(width, 80)
  228. }
  229. t := theme.CurrentTheme()
  230. base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render
  231. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
  232. promptStyle := styles.NewStyle().Foreground(t.Primary()).
  233. Padding(0, 0, 0, 1).
  234. Bold(true)
  235. prompt := promptStyle.Render(">")
  236. m.textarea.SetWidth(width - 6)
  237. textarea := lipgloss.JoinHorizontal(
  238. lipgloss.Top,
  239. prompt,
  240. m.textarea.View(),
  241. )
  242. borderForeground := t.Border()
  243. if m.app.IsLeaderSequence {
  244. borderForeground = t.Accent()
  245. }
  246. textarea = styles.NewStyle().
  247. Background(t.BackgroundElement()).
  248. Width(width).
  249. PaddingTop(1).
  250. PaddingBottom(1).
  251. BorderStyle(lipgloss.ThickBorder()).
  252. BorderForeground(borderForeground).
  253. BorderBackground(t.Background()).
  254. BorderLeft(true).
  255. BorderRight(true).
  256. Render(textarea)
  257. hint := base(m.getSubmitKeyText()) + muted(" send ")
  258. if m.exitKeyInDebounce {
  259. keyText := m.getExitKeyText()
  260. hint = base(keyText+" again") + muted(" to exit")
  261. } else if m.app.IsBusy() {
  262. keyText := m.getInterruptKeyText()
  263. if m.interruptKeyInDebounce {
  264. hint = muted(
  265. "working",
  266. ) + m.spinner.View() + muted(
  267. " ",
  268. ) + base(
  269. keyText+" again",
  270. ) + muted(
  271. " interrupt",
  272. )
  273. } else {
  274. hint = muted("working") + m.spinner.View() + muted(" ") + base(keyText) + muted(" interrupt")
  275. }
  276. }
  277. model := ""
  278. if m.app.Model != nil {
  279. model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name)
  280. }
  281. space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint)
  282. spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("")
  283. info := hint + spacer + model
  284. info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info)
  285. content := strings.Join([]string{"", textarea, info}, "\n")
  286. return content
  287. }
  288. func (m *editorComponent) View() string {
  289. width := m.width
  290. if m.app.Session.ID == "" {
  291. width = min(width, 80)
  292. }
  293. if m.Lines() > 1 {
  294. return lipgloss.Place(
  295. width,
  296. 5,
  297. lipgloss.Center,
  298. lipgloss.Center,
  299. "",
  300. styles.WhitespaceStyle(theme.CurrentTheme().Background()),
  301. )
  302. }
  303. return m.Content()
  304. }
  305. func (m *editorComponent) Focused() bool {
  306. return m.textarea.Focused()
  307. }
  308. func (m *editorComponent) Focus() (tea.Model, tea.Cmd) {
  309. return m, m.textarea.Focus()
  310. }
  311. func (m *editorComponent) Blur() {
  312. m.textarea.Blur()
  313. }
  314. func (m *editorComponent) Lines() int {
  315. return m.textarea.LineCount()
  316. }
  317. func (m *editorComponent) Value() string {
  318. return m.textarea.Value()
  319. }
  320. func (m *editorComponent) Length() int {
  321. return m.textarea.Length()
  322. }
  323. func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
  324. value := strings.TrimSpace(m.Value())
  325. if value == "" {
  326. return m, nil
  327. }
  328. switch value {
  329. case "exit", "quit", "q", ":q":
  330. return m, tea.Quit
  331. }
  332. if len(value) > 0 && value[len(value)-1] == '\\' {
  333. // If the last character is a backslash, remove it and add a newline
  334. backslashCol := m.textarea.CurrentRowLength() - 1
  335. m.textarea.ReplaceRange(backslashCol, backslashCol+1, "")
  336. m.textarea.InsertString("\n")
  337. return m, nil
  338. }
  339. var cmds []tea.Cmd
  340. attachments := m.textarea.GetAttachments()
  341. prompt := app.Prompt{Text: value, Attachments: attachments}
  342. m.app.State.AddPromptToHistory(prompt)
  343. cmds = append(cmds, m.app.SaveState())
  344. updated, cmd := m.Clear()
  345. m = updated.(*editorComponent)
  346. cmds = append(cmds, cmd)
  347. cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt)))
  348. return m, tea.Batch(cmds...)
  349. }
  350. func (m *editorComponent) Clear() (tea.Model, tea.Cmd) {
  351. m.textarea.Reset()
  352. m.historyIndex = -1
  353. m.currentText = ""
  354. return m, nil
  355. }
  356. func (m *editorComponent) Paste() (tea.Model, tea.Cmd) {
  357. imageBytes := clipboard.Read(clipboard.FmtImage)
  358. if imageBytes != nil {
  359. attachmentCount := len(m.textarea.GetAttachments())
  360. attachmentIndex := attachmentCount + 1
  361. base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes)
  362. attachment := &attachment.Attachment{
  363. ID: uuid.NewString(),
  364. Type: "file",
  365. MediaType: "image/png",
  366. Display: fmt.Sprintf("[Image #%d]", attachmentIndex),
  367. Filename: fmt.Sprintf("image-%d.png", attachmentIndex),
  368. URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile),
  369. Source: &attachment.FileSource{
  370. Path: fmt.Sprintf("image-%d.png", attachmentIndex),
  371. Mime: "image/png",
  372. Data: imageBytes,
  373. },
  374. }
  375. m.textarea.InsertAttachment(attachment)
  376. m.textarea.InsertString(" ")
  377. return m, nil
  378. }
  379. textBytes := clipboard.Read(clipboard.FmtText)
  380. if textBytes != nil {
  381. m.textarea.InsertRunesFromUserInput([]rune(string(textBytes)))
  382. return m, nil
  383. }
  384. // fallback to reading the clipboard using OSC52
  385. return m, tea.ReadClipboard
  386. }
  387. func (m *editorComponent) Newline() (tea.Model, tea.Cmd) {
  388. m.textarea.Newline()
  389. return m, nil
  390. }
  391. func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) {
  392. m.interruptKeyInDebounce = inDebounce
  393. }
  394. func (m *editorComponent) SetValue(value string) {
  395. m.textarea.SetValue(value)
  396. }
  397. func (m *editorComponent) SetValueWithAttachments(value string) {
  398. m.textarea.Reset()
  399. i := 0
  400. for i < len(value) {
  401. // Check if filepath and add attachment
  402. if value[i] == '@' {
  403. start := i + 1
  404. end := start
  405. for end < len(value) && value[end] != ' ' && value[end] != '\t' && value[end] != '\n' && value[end] != '\r' {
  406. end++
  407. }
  408. if end > start {
  409. filePath := value[start:end]
  410. if _, err := os.Stat(filePath); err == nil {
  411. attachment := m.createAttachmentFromFile(filePath)
  412. if attachment != nil {
  413. m.textarea.InsertAttachment(attachment)
  414. i = end
  415. continue
  416. }
  417. }
  418. }
  419. }
  420. // Not a valid file path, insert the character normally
  421. m.textarea.InsertRune(rune(value[i]))
  422. i++
  423. }
  424. }
  425. func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) {
  426. m.exitKeyInDebounce = inDebounce
  427. }
  428. func (m *editorComponent) getInterruptKeyText() string {
  429. return m.app.Commands[commands.SessionInterruptCommand].Keys()[0]
  430. }
  431. func (m *editorComponent) getSubmitKeyText() string {
  432. return m.app.Commands[commands.InputSubmitCommand].Keys()[0]
  433. }
  434. func (m *editorComponent) getExitKeyText() string {
  435. return m.app.Commands[commands.AppExitCommand].Keys()[0]
  436. }
  437. func updateTextareaStyles(ta textarea.Model) textarea.Model {
  438. t := theme.CurrentTheme()
  439. bgColor := t.BackgroundElement()
  440. textColor := t.Text()
  441. textMutedColor := t.TextMuted()
  442. ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  443. ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  444. ta.Styles.Blurred.Placeholder = styles.NewStyle().
  445. Foreground(textMutedColor).
  446. Background(bgColor).
  447. Lipgloss()
  448. ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  449. ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  450. ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss()
  451. ta.Styles.Focused.Placeholder = styles.NewStyle().
  452. Foreground(textMutedColor).
  453. Background(bgColor).
  454. Lipgloss()
  455. ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss()
  456. ta.Styles.Attachment = styles.NewStyle().
  457. Foreground(t.Secondary()).
  458. Background(bgColor).
  459. Lipgloss()
  460. ta.Styles.SelectedAttachment = styles.NewStyle().
  461. Foreground(t.Text()).
  462. Background(t.Secondary()).
  463. Lipgloss()
  464. ta.Styles.Cursor.Color = t.Primary()
  465. return ta
  466. }
  467. func createSpinner() spinner.Model {
  468. t := theme.CurrentTheme()
  469. return spinner.New(
  470. spinner.WithSpinner(spinner.Ellipsis),
  471. spinner.WithStyle(
  472. styles.NewStyle().
  473. Background(t.Background()).
  474. Foreground(t.TextMuted()).
  475. Width(3).
  476. Lipgloss(),
  477. ),
  478. )
  479. }
  480. func NewEditorComponent(app *app.App) EditorComponent {
  481. s := createSpinner()
  482. ta := textarea.New()
  483. ta.Prompt = " "
  484. ta.ShowLineNumbers = false
  485. ta.CharLimit = -1
  486. ta = updateTextareaStyles(ta)
  487. m := &editorComponent{
  488. app: app,
  489. textarea: ta,
  490. spinner: s,
  491. interruptKeyInDebounce: false,
  492. historyIndex: -1,
  493. }
  494. return m
  495. }
  496. // RestoreFromHistory restores a message from history at the given index
  497. func (m *editorComponent) RestoreFromHistory(index int) {
  498. if index < 0 || index >= len(m.app.State.MessageHistory) {
  499. return
  500. }
  501. entry := m.app.State.MessageHistory[index]
  502. m.textarea.Reset()
  503. m.textarea.SetValue(entry.Text)
  504. // Sort attachments by start index in reverse order (process from end to beginning)
  505. // This prevents index shifting issues
  506. attachmentsCopy := make([]*attachment.Attachment, len(entry.Attachments))
  507. copy(attachmentsCopy, entry.Attachments)
  508. for i := 0; i < len(attachmentsCopy)-1; i++ {
  509. for j := i + 1; j < len(attachmentsCopy); j++ {
  510. if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex {
  511. attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i]
  512. }
  513. }
  514. }
  515. for _, att := range attachmentsCopy {
  516. m.textarea.SetCursorColumn(att.StartIndex)
  517. m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "")
  518. m.textarea.InsertAttachment(att)
  519. }
  520. }
  521. func getMediaTypeFromExtension(ext string) string {
  522. switch strings.ToLower(ext) {
  523. case ".jpg":
  524. return "image/jpeg"
  525. case ".png", ".jpeg", ".gif", ".webp":
  526. return "image/" + ext[1:]
  527. case ".pdf":
  528. return "application/pdf"
  529. default:
  530. return "text/plain"
  531. }
  532. }
  533. func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment {
  534. ext := strings.ToLower(filepath.Ext(filePath))
  535. mediaType := getMediaTypeFromExtension(ext)
  536. absolutePath := filePath
  537. if !filepath.IsAbs(filePath) {
  538. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  539. }
  540. // For text files, create a simple file reference
  541. if mediaType == "text/plain" {
  542. return &attachment.Attachment{
  543. ID: uuid.NewString(),
  544. Type: "file",
  545. Display: "@" + filePath,
  546. URL: fmt.Sprintf("file://./%s", filePath),
  547. Filename: filePath,
  548. MediaType: mediaType,
  549. Source: &attachment.FileSource{
  550. Path: absolutePath,
  551. Mime: mediaType,
  552. },
  553. }
  554. }
  555. // For binary files (images, PDFs), read and encode
  556. fileBytes, err := os.ReadFile(filePath)
  557. if err != nil {
  558. slog.Error("Failed to read file", "error", err)
  559. return nil
  560. }
  561. base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes)
  562. url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile)
  563. attachmentCount := len(m.textarea.GetAttachments())
  564. attachmentIndex := attachmentCount + 1
  565. label := "File"
  566. if strings.HasPrefix(mediaType, "image/") {
  567. label = "Image"
  568. }
  569. return &attachment.Attachment{
  570. ID: uuid.NewString(),
  571. Type: "file",
  572. MediaType: mediaType,
  573. Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex),
  574. URL: url,
  575. Filename: filePath,
  576. Source: &attachment.FileSource{
  577. Path: absolutePath,
  578. Mime: mediaType,
  579. Data: fileBytes,
  580. },
  581. }
  582. }
  583. func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment {
  584. extension := filepath.Ext(filePath)
  585. mediaType := getMediaTypeFromExtension(extension)
  586. absolutePath := filePath
  587. if !filepath.IsAbs(filePath) {
  588. absolutePath = filepath.Join(m.app.Info.Path.Cwd, filePath)
  589. }
  590. return &attachment.Attachment{
  591. ID: uuid.NewString(),
  592. Type: "file",
  593. Display: "@" + filePath,
  594. URL: fmt.Sprintf("file://./%s", url.PathEscape(filePath)),
  595. Filename: filePath,
  596. MediaType: mediaType,
  597. Source: &attachment.FileSource{
  598. Path: absolutePath,
  599. Mime: mediaType,
  600. },
  601. }
  602. }