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