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