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}