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