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