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