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