| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- package filepicker
- import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "github.com/charmbracelet/bubbles/v2/filepicker"
- "github.com/charmbracelet/bubbles/v2/help"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/home"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/components/image"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/lipgloss/v2"
- )
- const (
- MaxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
- FilePickerID = "filepicker"
- fileSelectionHeight = 10
- previewHeight = 20
- )
- type FilePickedMsg struct {
- Attachment message.Attachment
- }
- type FilePicker interface {
- dialogs.DialogModel
- }
- type model struct {
- wWidth int
- wHeight int
- width int
- filePicker filepicker.Model
- highlightedFile string
- image image.Model
- keyMap KeyMap
- help help.Model
- }
- var AllowedTypes = []string{".jpg", ".jpeg", ".png"}
- func NewFilePickerCmp(workingDir string) FilePicker {
- t := styles.CurrentTheme()
- fp := filepicker.New()
- fp.AllowedTypes = AllowedTypes
- if workingDir != "" {
- fp.CurrentDirectory = workingDir
- } else {
- // Fallback to current working directory, then home directory
- if cwd, err := os.Getwd(); err == nil {
- fp.CurrentDirectory = cwd
- } else {
- fp.CurrentDirectory = home.Dir()
- }
- }
- fp.ShowPermissions = false
- fp.ShowSize = false
- fp.AutoHeight = false
- fp.Styles = t.S().FilePicker
- fp.Cursor = ""
- fp.SetHeight(fileSelectionHeight)
- image := image.New(1, 1, "")
- help := help.New()
- help.Styles = t.S().Help
- return &model{
- filePicker: fp,
- image: image,
- keyMap: DefaultKeyMap(),
- help: help,
- }
- }
- func (m *model) Init() tea.Cmd {
- return m.filePicker.Init()
- }
- func (m *model) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.wWidth = msg.Width
- m.wHeight = msg.Height
- m.width = min(70, m.wWidth)
- styles := m.filePicker.Styles
- styles.Directory = styles.Directory.Width(m.width - 4)
- styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
- styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
- styles.File = styles.File.Width(m.width)
- m.filePicker.Styles = styles
- return m, nil
- case tea.KeyPressMsg:
- if key.Matches(msg, m.keyMap.Close) {
- return m, util.CmdHandler(dialogs.CloseDialogMsg{})
- }
- if key.Matches(msg, m.filePicker.KeyMap.Back) {
- // make sure we don't go back if we are at the home directory
- if m.filePicker.CurrentDirectory == home.Dir() {
- return m, nil
- }
- }
- }
- var cmd tea.Cmd
- var cmds []tea.Cmd
- m.filePicker, cmd = m.filePicker.Update(msg)
- cmds = append(cmds, cmd)
- if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
- w, h := m.imagePreviewSize()
- cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
- cmds = append(cmds, cmd)
- }
- m.highlightedFile = m.currentImage()
- // Did the user select a file?
- if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
- // Get the path of the selected file.
- return m, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- func() tea.Msg {
- isFileLarge, err := IsFileTooBig(path, MaxAttachmentSize)
- if err != nil {
- return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
- }
- if isFileLarge {
- return util.ReportError(fmt.Errorf("file too large, max 5MB"))
- }
- content, err := os.ReadFile(path)
- if err != nil {
- return util.ReportError(fmt.Errorf("unable to read the image: %w", err))
- }
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(path)
- attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
- return FilePickedMsg{
- Attachment: attachment,
- }
- },
- )
- }
- m.image, cmd = m.image.Update(msg)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
- }
- func (m *model) View() string {
- t := styles.CurrentTheme()
- strs := []string{
- t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
- }
- // hide image preview if the terminal is too small
- if x, y := m.imagePreviewSize(); x > 0 && y > 0 {
- strs = append(strs, m.imagePreview())
- }
- strs = append(
- strs,
- m.filePicker.View(),
- t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
- )
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- strs...,
- )
- return m.style().Render(content)
- }
- func (m *model) currentImage() string {
- for _, ext := range m.filePicker.AllowedTypes {
- if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
- return m.filePicker.HighlightedPath()
- }
- }
- return ""
- }
- func (m *model) imagePreview() string {
- const padding = 2
- t := styles.CurrentTheme()
- w, h := m.imagePreviewSize()
- if m.currentImage() == "" {
- imgPreview := t.S().Base.
- Width(w - padding).
- Height(h - padding).
- Background(t.BgOverlay)
- return m.imagePreviewStyle().Render(imgPreview.Render())
- }
- return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
- }
- func (m *model) imagePreviewStyle() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.Padding(1, 1, 1, 1)
- }
- func (m *model) imagePreviewSize() (int, int) {
- if m.wHeight-fileSelectionHeight-8 > previewHeight {
- return m.width - 4, previewHeight
- }
- return 0, 0
- }
- func (m *model) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(m.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
- }
- // ID implements FilePicker.
- func (m *model) ID() dialogs.DialogID {
- return FilePickerID
- }
- // Position implements FilePicker.
- func (m *model) Position() (int, int) {
- _, imageHeight := m.imagePreviewSize()
- dialogHeight := fileSelectionHeight + imageHeight + 4
- row := (m.wHeight - dialogHeight) / 2
- col := m.wWidth / 2
- col -= m.width / 2
- return row, col
- }
- func IsFileTooBig(filePath string, sizeLimit int64) (bool, error) {
- fileInfo, err := os.Stat(filePath)
- if err != nil {
- return false, fmt.Errorf("error getting file info: %w", err)
- }
- if fileInfo.Size() > sizeLimit {
- return true, nil
- }
- return false, nil
- }
|