filepicker.go

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