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/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}