filepicker.go 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. package filepicker
  2. import (
  3. "fmt"
  4. "net/http"
  5. "os"
  6. "path/filepath"
  7. "strings"
  8. "github.com/charmbracelet/bubbles/v2/filepicker"
  9. "github.com/charmbracelet/bubbles/v2/help"
  10. "github.com/charmbracelet/bubbles/v2/key"
  11. tea "github.com/charmbracelet/bubbletea/v2"
  12. "github.com/charmbracelet/crush/internal/home"
  13. "github.com/charmbracelet/crush/internal/message"
  14. "github.com/charmbracelet/crush/internal/tui/components/core"
  15. "github.com/charmbracelet/crush/internal/tui/components/dialogs"
  16. "github.com/charmbracelet/crush/internal/tui/components/image"
  17. "github.com/charmbracelet/crush/internal/tui/styles"
  18. "github.com/charmbracelet/crush/internal/tui/util"
  19. "github.com/charmbracelet/lipgloss/v2"
  20. )
  21. const (
  22. MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
  23. FilePickerID = "filepicker"
  24. fileSelectionHeight = 10
  25. previewHeight = 20
  26. )
  27. type FilePickedMsg struct {
  28. Attachment message.Attachment
  29. }
  30. type FilePicker interface {
  31. dialogs.DialogModel
  32. }
  33. type model struct {
  34. wWidth int
  35. wHeight int
  36. width int
  37. filePicker filepicker.Model
  38. highlightedFile string
  39. image image.Model
  40. keyMap KeyMap
  41. help help.Model
  42. }
  43. var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
  44. func NewFilePickerCmp(workingDir string) FilePicker {
  45. t := styles.CurrentTheme()
  46. fp := filepicker.New()
  47. fp.AllowedTypes = AllowedTypes
  48. if workingDir != "" {
  49. fp.CurrentDirectory = workingDir
  50. } else {
  51. // Fallback to current working directory, then home directory
  52. if cwd, err := os.Getwd(); err == nil {
  53. fp.CurrentDirectory = cwd
  54. } else {
  55. fp.CurrentDirectory = home.Dir()
  56. }
  57. }
  58. fp.ShowPermissions = false
  59. fp.ShowSize = false
  60. fp.AutoHeight = false
  61. fp.Styles = t.S().FilePicker
  62. fp.Cursor = ""
  63. fp.SetHeight(fileSelectionHeight)
  64. image := image.New(1, 1, "")
  65. help := help.New()
  66. help.Styles = t.S().Help
  67. return &model{
  68. filePicker: fp,
  69. image: image,
  70. keyMap: DefaultKeyMap(),
  71. help: help,
  72. }
  73. }
  74. func (m *model) Init() tea.Cmd {
  75. return m.filePicker.Init()
  76. }
  77. func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
  78. switch msg := msg.(type) {
  79. case tea.WindowSizeMsg:
  80. m.wWidth = msg.Width
  81. m.wHeight = msg.Height
  82. m.width = min(70, m.wWidth)
  83. styles := m.filePicker.Styles
  84. styles.Directory = styles.Directory.Width(m.width - 4)
  85. styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
  86. styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
  87. styles.File = styles.File.Width(m.width)
  88. m.filePicker.Styles = styles
  89. return m, nil
  90. case tea.KeyPressMsg:
  91. if key.Matches(msg, m.keyMap.Close) {
  92. return m, util.CmdHandler(dialogs.CloseDialogMsg{})
  93. }
  94. if key.Matches(msg, m.filePicker.KeyMap.Back) {
  95. // make sure we don't go back if we are at the home directory
  96. if m.filePicker.CurrentDirectory == home.Dir() {
  97. return m, nil
  98. }
  99. }
  100. }
  101. var cmd tea.Cmd
  102. var cmds []tea.Cmd
  103. m.filePicker, cmd = m.filePicker.Update(msg)
  104. cmds = append(cmds, cmd)
  105. if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
  106. w, h := m.imagePreviewSize()
  107. cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
  108. cmds = append(cmds, cmd)
  109. }
  110. m.highlightedFile = m.currentImage()
  111. // Did the user select a file?
  112. if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
  113. // Get the path of the selected file.
  114. return m, tea.Sequence(
  115. util.CmdHandler(dialogs.CloseDialogMsg{}),
  116. func() tea.Msg {
  117. isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
  118. if err != nil {
  119. return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
  120. }
  121. if isFileLarge {
  122. return util.ReportError(fmt.Errorf("file too large, max 5MB"))
  123. }
  124. content, err := os.ReadFile(path)
  125. if err != nil {
  126. return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
  127. }
  128. mimeBufferSize := min(512, len(content))
  129. mimeType := http.DetectContentType(content[:mimeBufferSize])
  130. fileName := filepath.Base(path)
  131. attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
  132. return FilePickedMsg{
  133. Attachment: attachment,
  134. }
  135. },
  136. )
  137. }
  138. m.image, cmd = m.image.Update(msg)
  139. cmds = append(cmds, cmd)
  140. return m, tea.Batch(cmds...)
  141. }
  142. func (m *model) View() string {
  143. t := styles.CurrentTheme()
  144. strs := []string{
  145. t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
  146. }
  147. // hide image preview if the terminal is too small
  148. if x, y := m.imagePreviewSize(); x > 0 && y > 0 {
  149. strs = append(strs, m.imagePreview())
  150. }
  151. strs = append(
  152. strs,
  153. m.filePicker.View(),
  154. t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
  155. )
  156. content := lipgloss.JoinVertical(
  157. lipgloss.Left,
  158. strs...,
  159. )
  160. return m.style().Render(content)
  161. }
  162. func (m *model) currentImage() string {
  163. for _, ext := range m.filePicker.AllowedTypes {
  164. if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
  165. return m.filePicker.HighlightedPath()
  166. }
  167. }
  168. return ""
  169. }
  170. func (m *model) imagePreview() string {
  171. const padding = 2
  172. t := styles.CurrentTheme()
  173. w, h := m.imagePreviewSize()
  174. if m.currentImage() == "" {
  175. imgPreview := t.S().Base.
  176. Width(w - padding).
  177. Height(h - padding).
  178. Background(t.BgOverlay)
  179. return m.imagePreviewStyle().Render(imgPreview.Render())
  180. }
  181. return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
  182. }
  183. func (m *model) imagePreviewStyle() lipgloss.Style {
  184. t := styles.CurrentTheme()
  185. return t.S().Base.Padding(1, 1, 1, 1)
  186. }
  187. func (m *model) imagePreviewSize() (int, int) {
  188. if m.wHeight-fileSelectionHeight-8 > previewHeight {
  189. return m.width - 4, previewHeight
  190. }
  191. return 0, 0
  192. }
  193. func (m *model) style() lipgloss.Style {
  194. t := styles.CurrentTheme()
  195. return t.S().Base.
  196. Width(m.width).
  197. Border(lipgloss.RoundedBorder()).
  198. BorderForeground(t.BorderFocus)
  199. }
  200. // ID implements FilePicker.
  201. func (m *model) ID() dialogs.DialogID {
  202. return FilePickerID
  203. }
  204. // Position implements FilePicker.
  205. func (m *model) Position() (int, int) {
  206. _, imageHeight := m.imagePreviewSize()
  207. dialogHeight := fileSelectionHeight + imageHeight + 4
  208. row := (m.wHeight - dialogHeight) / 2
  209. col := m.wWidth / 2
  210. col -= m.width / 2
  211. return row, col
  212. }
  213. func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
  214. fileInfo, err := os.Stat(filePath)
  215. if err != nil {
  216. return false, fmt.Errorf("error getting file info: %w", err)
  217. }
  218. if fileInfo.Size() > sizeLimit {
  219. return true, nil
  220. }
  221. return false, nil
  222. }