editor.go 20 KB

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