editor_test.go 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. package chat
  2. import (
  3. "os"
  4. "path/filepath"
  5. "strings"
  6. "testing"
  7. "github.com/charmbracelet/bubbles/v2/spinner"
  8. tea "github.com/charmbracelet/bubbletea/v2"
  9. "github.com/sst/opencode/internal/app"
  10. "github.com/sst/opencode/internal/completions"
  11. "github.com/sst/opencode/internal/components/dialog"
  12. "github.com/sst/opencode/internal/components/textarea"
  13. "github.com/sst/opencode/internal/styles"
  14. )
  15. func newTestEditor() *editorComponent {
  16. m := &editorComponent{
  17. app: &app.App{},
  18. textarea: textarea.New(),
  19. spinner: spinner.New(),
  20. }
  21. return m
  22. }
  23. func TestPasteAtPathWithTrailingComma_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
  24. m := newTestEditor()
  25. p := createTempTextFile(t, "", "pc.txt", "x")
  26. paste := "See @" + p + ", next"
  27. _, cmd := m.Update(tea.PasteMsg(paste))
  28. if cmd == nil {
  29. t.Fatalf("expected command to be returned for comma punctuation paste")
  30. }
  31. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  32. t.Fatalf("expected AttachmentInsertedMsg for comma punctuation paste")
  33. }
  34. if len(m.textarea.GetAttachments()) != 1 {
  35. t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
  36. }
  37. v := m.Value()
  38. if !strings.Contains(v, ", next") {
  39. t.Fatalf("expected comma and following text to be preserved, got: %q", v)
  40. }
  41. if strings.Contains(v, ", next") {
  42. t.Fatalf("did not expect double space after comma, got: %q", v)
  43. }
  44. }
  45. func TestPasteAtPathWithTrailingQuestion_PreservesPunctuation_NoDoubleSpace(t *testing.T) {
  46. m := newTestEditor()
  47. p := createTempTextFile(t, "", "pq.txt", "x")
  48. paste := "Check @" + p + "? Done"
  49. _, cmd := m.Update(tea.PasteMsg(paste))
  50. if cmd == nil {
  51. t.Fatalf("expected command to be returned for question punctuation paste")
  52. }
  53. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  54. t.Fatalf("expected AttachmentInsertedMsg for question punctuation paste")
  55. }
  56. if len(m.textarea.GetAttachments()) != 1 {
  57. t.Fatalf("expected 1 attachment, got %d", len(m.textarea.GetAttachments()))
  58. }
  59. v := m.Value()
  60. if !strings.Contains(v, "? Done") {
  61. t.Fatalf("expected question mark and following text to be preserved, got: %q", v)
  62. }
  63. if strings.Contains(v, "? Done") {
  64. t.Fatalf("did not expect double space after question mark, got: %q", v)
  65. }
  66. }
  67. func TestPasteMultipleInlineAtPaths_AttachesEach(t *testing.T) {
  68. m := newTestEditor()
  69. dir := t.TempDir()
  70. p1 := createTempTextFile(t, dir, "m1.txt", "one")
  71. p2 := createTempTextFile(t, dir, "m2.txt", "two")
  72. // Build a paste with text around, two @paths, and punctuation after the first
  73. paste := "Please check @" + p1 + ", and also @" + p2 + " thanks"
  74. _, cmd := m.Update(tea.PasteMsg(paste))
  75. if cmd == nil {
  76. t.Fatalf("expected command to be returned for multi inline paste")
  77. }
  78. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  79. t.Fatalf("expected AttachmentInsertedMsg for multi inline paste")
  80. }
  81. atts := m.textarea.GetAttachments()
  82. if len(atts) != 2 {
  83. t.Fatalf("expected 2 attachments, got %d", len(atts))
  84. }
  85. v := m.Value()
  86. if !strings.Contains(v, "Please check") || !strings.Contains(v, "and also") || !strings.Contains(v, "thanks") {
  87. t.Fatalf("expected surrounding text to be preserved, got: %q", v)
  88. }
  89. }
  90. func createTempTextFile(t *testing.T, dir, name, content string) string {
  91. t.Helper()
  92. if dir == "" {
  93. td, err := os.MkdirTemp("", "editor-test-*")
  94. if err != nil {
  95. t.Fatalf("failed to make temp dir: %v", err)
  96. }
  97. dir = td
  98. }
  99. p := filepath.Join(dir, name)
  100. if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
  101. t.Fatalf("failed to write temp file: %v", err)
  102. }
  103. abs, err := filepath.Abs(p)
  104. if err != nil {
  105. t.Fatalf("failed to get abs path: %v", err)
  106. }
  107. return abs
  108. }
  109. func createTempBinFile(t *testing.T, dir, name string, data []byte) string {
  110. t.Helper()
  111. if dir == "" {
  112. td, err := os.MkdirTemp("", "editor-test-*")
  113. if err != nil {
  114. t.Fatalf("failed to make temp dir: %v", err)
  115. }
  116. dir = td
  117. }
  118. p := filepath.Join(dir, name)
  119. if err := os.WriteFile(p, data, 0o600); err != nil {
  120. t.Fatalf("failed to write temp bin file: %v", err)
  121. }
  122. abs, err := filepath.Abs(p)
  123. if err != nil {
  124. t.Fatalf("failed to get abs path: %v", err)
  125. }
  126. return abs
  127. }
  128. func TestPasteStartsWithAt_AttachesAndEmitsMsg(t *testing.T) {
  129. m := newTestEditor()
  130. p := createTempTextFile(t, "", "a.txt", "hello")
  131. _, cmd := m.Update(tea.PasteMsg("@" + p))
  132. if cmd == nil {
  133. t.Fatalf("expected command to be returned")
  134. }
  135. msg := cmd()
  136. if _, ok := msg.(AttachmentInsertedMsg); !ok {
  137. t.Fatalf("expected AttachmentInsertedMsg, got %T", msg)
  138. }
  139. atts := m.textarea.GetAttachments()
  140. if len(atts) != 1 {
  141. t.Fatalf("expected 1 attachment, got %d", len(atts))
  142. }
  143. if v := m.Value(); !strings.HasSuffix(v, " ") {
  144. t.Fatalf("expected trailing space after attachment, got value: %q", v)
  145. }
  146. }
  147. func TestPasteAfterAt_ReplacesAtWithAttachment(t *testing.T) {
  148. m := newTestEditor()
  149. p := createTempTextFile(t, "", "b.txt", "hello")
  150. m.textarea.SetValue("@")
  151. // Cursor should be at the end after SetValue; paste absolute path
  152. _, cmd := m.Update(tea.PasteMsg(p))
  153. if cmd == nil {
  154. t.Fatalf("expected command to be returned")
  155. }
  156. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  157. t.Fatalf("expected AttachmentInsertedMsg from paste after '@'")
  158. }
  159. // Ensure the raw '@' rune was removed (attachment inserted in its place)
  160. if m.textarea.LastRuneIndex('@') != -1 {
  161. t.Fatalf("'@' rune should have been removed from the text slice")
  162. }
  163. if len(m.textarea.GetAttachments()) != 1 {
  164. t.Fatalf("expected 1 attachment inserted")
  165. }
  166. if v := m.Value(); !strings.HasSuffix(v, " ") {
  167. t.Fatalf("expected trailing space after attachment, got value: %q", v)
  168. }
  169. }
  170. func TestPlainTextPaste_NoAttachment_NoMsg(t *testing.T) {
  171. m := newTestEditor()
  172. _, cmd := m.Update(tea.PasteMsg("hello"))
  173. if cmd != nil {
  174. t.Fatalf("expected no command for plain text paste")
  175. }
  176. if got := m.Value(); got != "hello" {
  177. t.Fatalf("expected value 'hello', got %q", got)
  178. }
  179. if len(m.textarea.GetAttachments()) != 0 {
  180. t.Fatalf("expected no attachments for plain text paste")
  181. }
  182. }
  183. func TestPlainPathPng_AttachesImage(t *testing.T) {
  184. m := newTestEditor()
  185. // Minimal bytes; content isn't validated, extension determines mime
  186. p := createTempBinFile(t, "", "img.png", []byte{0x89, 'P', 'N', 'G'})
  187. _, cmd := m.Update(tea.PasteMsg(p))
  188. if cmd == nil {
  189. t.Fatalf("expected command to be returned for image path paste")
  190. }
  191. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  192. t.Fatalf("expected AttachmentInsertedMsg for image path paste")
  193. }
  194. atts := m.textarea.GetAttachments()
  195. if len(atts) != 1 {
  196. t.Fatalf("expected 1 attachment, got %d", len(atts))
  197. }
  198. if atts[0].MediaType != "image/png" {
  199. t.Fatalf("expected image/png mime, got %q", atts[0].MediaType)
  200. }
  201. if v := m.Value(); !strings.HasSuffix(v, " ") {
  202. t.Fatalf("expected trailing space after attachment, got value: %q", v)
  203. }
  204. }
  205. func TestPlainPathPdf_AttachesPDF(t *testing.T) {
  206. m := newTestEditor()
  207. p := createTempBinFile(t, "", "doc.pdf", []byte("%PDF-1.4"))
  208. _, cmd := m.Update(tea.PasteMsg(p))
  209. if cmd == nil {
  210. t.Fatalf("expected command to be returned for pdf path paste")
  211. }
  212. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  213. t.Fatalf("expected AttachmentInsertedMsg for pdf path paste")
  214. }
  215. atts := m.textarea.GetAttachments()
  216. if len(atts) != 1 {
  217. t.Fatalf("expected 1 attachment, got %d", len(atts))
  218. }
  219. if atts[0].MediaType != "application/pdf" {
  220. t.Fatalf("expected application/pdf mime, got %q", atts[0].MediaType)
  221. }
  222. if v := m.Value(); !strings.HasSuffix(v, " ") {
  223. t.Fatalf("expected trailing space after attachment, got value: %q", v)
  224. }
  225. }
  226. func TestCompletionFiles_InsertsAttachment_EmitsMsg(t *testing.T) {
  227. m := newTestEditor()
  228. p := createTempTextFile(t, "", "c.txt", "hello")
  229. m.textarea.SetValue("@")
  230. item := completions.CompletionSuggestion{
  231. ProviderID: "files",
  232. Value: p,
  233. Display: func(_ styles.Style) string { return p },
  234. }
  235. // Build the completion selected message as if the user selected from the dialog
  236. msg := dialog.CompletionSelectedMsg{Item: item, SearchString: "@"}
  237. _, cmd := m.Update(msg)
  238. if cmd == nil {
  239. t.Fatalf("expected command to be returned")
  240. }
  241. if _, ok := cmd().(AttachmentInsertedMsg); !ok {
  242. t.Fatalf("expected AttachmentInsertedMsg from files completion selection")
  243. }
  244. if len(m.textarea.GetAttachments()) != 1 {
  245. t.Fatalf("expected 1 attachment inserted from completion selection")
  246. }
  247. if v := m.Value(); !strings.HasSuffix(v, " ") {
  248. t.Fatalf("expected trailing space after attachment, got value: %q", v)
  249. }
  250. }