filepicker.go

  1package filepicker
  2
  3import (
  4	"os"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/filepicker"
  8	"github.com/charmbracelet/bubbles/v2/key"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	"github.com/charmbracelet/lipgloss/v2"
 11	"github.com/opencode-ai/opencode/internal/tui/components/core"
 12	"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
 13	"github.com/opencode-ai/opencode/internal/tui/components/image"
 14	"github.com/opencode-ai/opencode/internal/tui/styles"
 15	"github.com/opencode-ai/opencode/internal/tui/util"
 16)
 17
 18const (
 19	maxAttachmentSize  = int64(5 * 1024 * 1024) // 5MB
 20	FilePickerID       = "filepicker"
 21	fileSelectionHight = 10
 22)
 23
 24type FilePickedMsg struct {
 25	FilePath string
 26}
 27
 28type FilePicker interface {
 29	dialogs.DialogModel
 30}
 31
 32type model struct {
 33	wWidth          int
 34	wHeight         int
 35	width           int
 36	filePicker      filepicker.Model
 37	highlightedFile string
 38	image           image.Model
 39}
 40
 41func NewFilePickerCmp() FilePicker {
 42	t := styles.CurrentTheme()
 43	fp := filepicker.New()
 44	fp.AllowedTypes = []string{".jpg", ".jpeg", ".png"}
 45	fp.CurrentDirectory, _ = os.UserHomeDir()
 46	fp.ShowPermissions = false
 47	fp.ShowSize = false
 48	fp.AutoHeight = false
 49	fp.Styles = t.S().FilePicker
 50	fp.Cursor = ""
 51	fp.SetHeight(fileSelectionHight)
 52
 53	image := image.New(1, 1, "")
 54	return &model{filePicker: fp, image: image}
 55}
 56
 57func (m *model) Init() tea.Cmd {
 58	return m.filePicker.Init()
 59}
 60
 61func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 62	switch msg := msg.(type) {
 63	case tea.WindowSizeMsg:
 64		m.wWidth = msg.Width
 65		m.wHeight = msg.Height
 66		m.width = min(70, m.wWidth)
 67		styles := m.filePicker.Styles
 68		styles.Directory = styles.Directory.Width(m.width - 4)
 69		styles.Selected = styles.Selected.PaddingLeft(1).Width(m.width - 4)
 70		styles.DisabledSelected = styles.DisabledSelected.PaddingLeft(1).Width(m.width - 4)
 71		styles.File = styles.File.Width(m.width)
 72		m.filePicker.Styles = styles
 73		return m, nil
 74
 75	case tea.KeyPressMsg:
 76		if key.Matches(msg, m.filePicker.KeyMap.Back) {
 77			// make sure we don't go back if we are at the home directory
 78			homeDir, _ := os.UserHomeDir()
 79			if m.filePicker.CurrentDirectory == homeDir {
 80				return m, nil
 81			}
 82		}
 83	}
 84
 85	var cmd tea.Cmd
 86	var cmds []tea.Cmd
 87	m.filePicker, cmd = m.filePicker.Update(msg)
 88	cmds = append(cmds, cmd)
 89	if m.highlightedFile != m.currentImage() && m.currentImage() != "" {
 90		w, h := m.imagePreviewSize()
 91		cmd = m.image.Redraw(uint(w-2), uint(h-2), m.currentImage())
 92		cmds = append(cmds, cmd)
 93	}
 94	m.highlightedFile = m.currentImage()
 95
 96	// Did the user select a file?
 97	if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect {
 98		// Get the path of the selected file.
 99		return m, tea.Sequence(
100			util.CmdHandler(dialogs.CloseDialogMsg{}),
101			util.CmdHandler(FilePickedMsg{FilePath: path}),
102		)
103	}
104	m.image, cmd = m.image.Update(msg)
105	cmds = append(cmds, cmd)
106	return m, tea.Batch(cmds...)
107}
108
109func (m *model) View() tea.View {
110	t := styles.CurrentTheme()
111
112	content := lipgloss.JoinVertical(
113		lipgloss.Left,
114		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add Image", m.width-4)),
115		m.imagePreview(),
116		m.filePicker.View(),
117	)
118	return tea.NewView(m.style().Render(content))
119}
120
121func (m *model) currentImage() string {
122	for _, ext := range m.filePicker.AllowedTypes {
123		if strings.HasSuffix(m.filePicker.HighlightedPath(), ext) {
124			return m.filePicker.HighlightedPath()
125		}
126	}
127	return ""
128}
129
130func (m *model) imagePreview() string {
131	t := styles.CurrentTheme()
132	w, h := m.imagePreviewSize()
133	if m.currentImage() == "" {
134		imgPreview := t.S().Base.
135			Width(w).
136			Height(h).
137			Background(t.BgOverlay)
138
139		return m.imagePreviewStyle().Render(imgPreview.Render())
140	}
141
142	return m.imagePreviewStyle().Width(w).Height(h).Render(m.image.View())
143}
144
145func (m *model) imagePreviewStyle() lipgloss.Style {
146	t := styles.CurrentTheme()
147	return t.S().Base.Padding(1, 1, 1, 1)
148}
149
150func (m *model) imagePreviewSize() (int, int) {
151	return m.width - 4, min(20, m.wHeight/2)
152}
153
154func (m *model) style() lipgloss.Style {
155	t := styles.CurrentTheme()
156	return t.S().Base.
157		Width(m.width).
158		Border(lipgloss.RoundedBorder()).
159		BorderForeground(t.BorderFocus)
160}
161
162// ID implements FilePicker.
163func (m *model) ID() dialogs.DialogID {
164	return FilePickerID
165}
166
167// Position implements FilePicker.
168func (m *model) Position() (int, int) {
169	row := m.wHeight/4 - 2 // just a bit above the center
170	col := m.wWidth / 2
171	col -= m.width / 2
172	return row, col
173}