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