filepicker.go

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