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}