filepicker.go

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