1package dialog
2
3import (
4 "fmt"
5 "net/http"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/charmbracelet/bubbles/key"
13 "github.com/charmbracelet/bubbles/textinput"
14 "github.com/charmbracelet/bubbles/viewport"
15 tea "github.com/charmbracelet/bubbletea"
16 "github.com/charmbracelet/lipgloss"
17 "github.com/opencode-ai/opencode/internal/app"
18 "github.com/opencode-ai/opencode/internal/config"
19 "github.com/opencode-ai/opencode/internal/logging"
20 "github.com/opencode-ai/opencode/internal/message"
21 "github.com/opencode-ai/opencode/internal/tui/image"
22 "github.com/opencode-ai/opencode/internal/tui/styles"
23 "github.com/opencode-ai/opencode/internal/tui/theme"
24 "github.com/opencode-ai/opencode/internal/tui/util"
25)
26
27const (
28 maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
29 downArrow = "down"
30 upArrow = "up"
31)
32
33type FilePrickerKeyMap struct {
34 Enter key.Binding
35 Down key.Binding
36 Up key.Binding
37 Forward key.Binding
38 Backward key.Binding
39 OpenFilePicker key.Binding
40 Esc key.Binding
41 InsertCWD key.Binding
42}
43
44var filePickerKeyMap = FilePrickerKeyMap{
45 Enter: key.NewBinding(
46 key.WithKeys("enter"),
47 key.WithHelp("enter", "select file/enter directory"),
48 ),
49 Down: key.NewBinding(
50 key.WithKeys("j", downArrow),
51 key.WithHelp("↓/j", "down"),
52 ),
53 Up: key.NewBinding(
54 key.WithKeys("k", upArrow),
55 key.WithHelp("↑/k", "up"),
56 ),
57 Forward: key.NewBinding(
58 key.WithKeys("l"),
59 key.WithHelp("l", "enter directory"),
60 ),
61 Backward: key.NewBinding(
62 key.WithKeys("h", "backspace"),
63 key.WithHelp("h/backspace", "go back"),
64 ),
65 OpenFilePicker: key.NewBinding(
66 key.WithKeys("ctrl+f"),
67 key.WithHelp("ctrl+f", "open file picker"),
68 ),
69 Esc: key.NewBinding(
70 key.WithKeys("esc"),
71 key.WithHelp("esc", "close/exit"),
72 ),
73 InsertCWD: key.NewBinding(
74 key.WithKeys("i"),
75 key.WithHelp("i", "manual path input"),
76 ),
77}
78
79type filepickerCmp struct {
80 basePath string
81 width int
82 height int
83 cursor int
84 err error
85 cursorChain stack
86 viewport viewport.Model
87 dirs []os.DirEntry
88 cwdDetails *DirNode
89 selectedFile string
90 cwd textinput.Model
91 ShowFilePicker bool
92 app *app.App
93}
94
95type DirNode struct {
96 parent *DirNode
97 child *DirNode
98 directory string
99}
100type stack []int
101
102func (s stack) Push(v int) stack {
103 return append(s, v)
104}
105
106func (s stack) Pop() (stack, int) {
107 l := len(s)
108 return s[:l-1], s[l-1]
109}
110
111type AttachmentAddedMsg struct {
112 Attachment message.Attachment
113}
114
115func (f *filepickerCmp) Init() tea.Cmd {
116 return nil
117}
118
119func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
120 var cmd tea.Cmd
121 switch msg := msg.(type) {
122 case tea.WindowSizeMsg:
123 f.width = 60
124 f.height = 20
125 f.viewport.Width = 80
126 f.viewport.Height = 22
127 f.cursor = 0
128 f.getCurrentFileBelowCursor()
129 case tea.KeyMsg:
130 switch {
131 case key.Matches(msg, filePickerKeyMap.InsertCWD):
132 f.cwd.Focus()
133 return f, cmd
134 case key.Matches(msg, filePickerKeyMap.Esc):
135 if f.cwd.Focused() {
136 f.cwd.Blur()
137 }
138 case key.Matches(msg, filePickerKeyMap.Down):
139 if !f.cwd.Focused() || msg.String() == downArrow {
140 if f.cursor < len(f.dirs)-1 {
141 f.cursor++
142 f.getCurrentFileBelowCursor()
143 }
144 }
145 case key.Matches(msg, filePickerKeyMap.Up):
146 if !f.cwd.Focused() || msg.String() == upArrow {
147 if f.cursor > 0 {
148 f.cursor--
149 f.getCurrentFileBelowCursor()
150 }
151 }
152 case key.Matches(msg, filePickerKeyMap.Enter):
153 var path string
154 var isPathDir bool
155 if f.cwd.Focused() {
156 path = f.cwd.Value()
157 fileInfo, err := os.Stat(path)
158 if err != nil {
159 logging.ErrorPersist("Invalid path")
160 return f, cmd
161 }
162 isPathDir = fileInfo.IsDir()
163 } else {
164 path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
165 isPathDir = f.dirs[f.cursor].IsDir()
166 }
167 if isPathDir {
168 path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
169 newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
170 f.cwdDetails.child = &newWorkingDir
171 f.cwdDetails = f.cwdDetails.child
172 f.cursorChain = f.cursorChain.Push(f.cursor)
173 f.dirs = readDir(f.cwdDetails.directory, false)
174 f.cursor = 0
175 f.cwd.SetValue(f.cwdDetails.directory)
176 f.getCurrentFileBelowCursor()
177 } else {
178 f.selectedFile = path
179 return f.addAttachmentToMessage()
180 }
181 case key.Matches(msg, filePickerKeyMap.Esc):
182 if !f.cwd.Focused() {
183 f.cursorChain = make(stack, 0)
184 f.cursor = 0
185 } else {
186 f.cwd.Blur()
187 }
188 case key.Matches(msg, filePickerKeyMap.Forward):
189 if !f.cwd.Focused() {
190 if f.dirs[f.cursor].IsDir() {
191 path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
192 newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
193 f.cwdDetails.child = &newWorkingDir
194 f.cwdDetails = f.cwdDetails.child
195 f.cursorChain = f.cursorChain.Push(f.cursor)
196 f.dirs = readDir(f.cwdDetails.directory, false)
197 f.cursor = 0
198 f.cwd.SetValue(f.cwdDetails.directory)
199 f.getCurrentFileBelowCursor()
200 }
201 }
202 case key.Matches(msg, filePickerKeyMap.Backward):
203 if !f.cwd.Focused() {
204 if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
205 f.cursorChain, f.cursor = f.cursorChain.Pop()
206 f.cwdDetails = f.cwdDetails.parent
207 f.cwdDetails.child = nil
208 f.dirs = readDir(f.cwdDetails.directory, false)
209 f.cwd.SetValue(f.cwdDetails.directory)
210 f.getCurrentFileBelowCursor()
211 }
212 }
213 case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
214 f.dirs = readDir(f.cwdDetails.directory, false)
215 f.cursor = 0
216 f.getCurrentFileBelowCursor()
217 }
218 }
219 if f.cwd.Focused() {
220 f.cwd, cmd = f.cwd.Update(msg)
221 }
222 return f, cmd
223}
224
225func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
226 modeInfo := GetSelectedModel(config.Get())
227 if !modeInfo.SupportsAttachments {
228 logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
229 return f, nil
230 }
231 if isExtSupported(f.dirs[f.cursor].Name()) {
232 f.selectedFile = f.dirs[f.cursor].Name()
233 selectedFilePath := filepath.Join(f.cwdDetails.directory, "/", f.selectedFile)
234 isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
235 if err != nil {
236 logging.ErrorPersist("unable to read the image")
237 return f, nil
238 }
239 if isFileLarge {
240 logging.ErrorPersist("file too large, max 5MB")
241 return f, nil
242 }
243
244 content, err := os.ReadFile(selectedFilePath)
245 if err != nil {
246 logging.ErrorPersist("Unable read selected file")
247 return f, nil
248 }
249
250 mimeBufferSize := min(512, len(content))
251 mimeType := http.DetectContentType(content[:mimeBufferSize])
252 fileName := f.selectedFile
253 attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
254 f.selectedFile = ""
255 return f, util.CmdHandler(AttachmentAddedMsg{attachment})
256 }
257 if !isExtSupported(f.selectedFile) {
258 logging.ErrorPersist("Unsupported file")
259 return f, nil
260 }
261 return f, nil
262}
263
264func (f *filepickerCmp) View() string {
265 t := theme.CurrentTheme()
266 const maxVisibleDirs = 20
267 const maxWidth = 80
268
269 adjustedWidth := maxWidth
270 for _, file := range f.dirs {
271 if len(file.Name()) > adjustedWidth-4 { // Account for padding
272 adjustedWidth = len(file.Name()) + 4
273 }
274 }
275 adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
276
277 files := make([]string, 0, maxVisibleDirs)
278 startIdx := 0
279
280 if len(f.dirs) > maxVisibleDirs {
281 halfVisible := maxVisibleDirs / 2
282 if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
283 startIdx = f.cursor - halfVisible
284 } else if f.cursor >= len(f.dirs)-halfVisible {
285 startIdx = len(f.dirs) - maxVisibleDirs
286 }
287 }
288
289 endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
290
291 for i := startIdx; i < endIdx; i++ {
292 file := f.dirs[i]
293 itemStyle := styles.BaseStyle().Width(adjustedWidth)
294
295 if i == f.cursor {
296 itemStyle = itemStyle.
297 Background(t.Primary()).
298 Foreground(t.Background()).
299 Bold(true)
300 }
301 filename := file.Name()
302
303 if len(filename) > adjustedWidth-4 {
304 filename = filename[:adjustedWidth-7] + "..."
305 }
306 if file.IsDir() {
307 filename = filename + "/"
308 } else if isExtSupported(file.Name()) {
309 filename = filename
310 } else {
311 filename = filename
312 }
313
314 files = append(files, itemStyle.Padding(0, 1).Render(filename))
315 }
316
317 // Pad to always show exactly 21 lines
318 for len(files) < maxVisibleDirs {
319 files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
320 }
321
322 currentPath := styles.BaseStyle().
323 Height(1).
324 Width(adjustedWidth).
325 Render(f.cwd.View())
326
327 viewportstyle := lipgloss.NewStyle().
328 Width(f.viewport.Width).
329 Background(t.Background()).
330 Border(lipgloss.RoundedBorder()).
331 BorderForeground(t.TextMuted()).
332 BorderBackground(t.Background()).
333 Padding(2).
334 Render(f.viewport.View())
335 var insertExitText string
336 if f.IsCWDFocused() {
337 insertExitText = "Press esc to exit typing path"
338 } else {
339 insertExitText = "Press i to start typing path"
340 }
341
342 content := lipgloss.JoinVertical(
343 lipgloss.Left,
344 currentPath,
345 styles.BaseStyle().Width(adjustedWidth).Render(""),
346 styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
347 styles.BaseStyle().Width(adjustedWidth).Render(""),
348 styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
349 )
350
351 f.cwd.SetValue(f.cwd.Value())
352 contentStyle := styles.BaseStyle().Padding(1, 2).
353 Border(lipgloss.RoundedBorder()).
354 BorderBackground(t.Background()).
355 BorderForeground(t.TextMuted()).
356 Width(lipgloss.Width(content) + 4)
357
358 return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
359}
360
361type FilepickerCmp interface {
362 tea.Model
363 ToggleFilepicker(showFilepicker bool)
364 IsCWDFocused() bool
365}
366
367func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
368 f.ShowFilePicker = showFilepicker
369}
370
371func (f *filepickerCmp) IsCWDFocused() bool {
372 return f.cwd.Focused()
373}
374
375func NewFilepickerCmp(app *app.App) FilepickerCmp {
376 homepath, err := os.UserHomeDir()
377 if err != nil {
378 logging.Error("error loading user files")
379 return nil
380 }
381 baseDir := DirNode{parent: nil, directory: homepath}
382 dirs := readDir(homepath, false)
383 viewport := viewport.New(0, 0)
384 currentDirectory := textinput.New()
385 currentDirectory.CharLimit = 200
386 currentDirectory.Width = 44
387 currentDirectory.Cursor.Blink = true
388 currentDirectory.SetValue(baseDir.directory)
389 return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
390}
391
392func (f *filepickerCmp) getCurrentFileBelowCursor() {
393 if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
394 logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
395 f.viewport.SetContent("Preview unavailable")
396 return
397 }
398
399 dir := f.dirs[f.cursor]
400 filename := dir.Name()
401 if !dir.IsDir() && isExtSupported(filename) {
402 fullPath := f.cwdDetails.directory + "/" + dir.Name()
403
404 go func() {
405 imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
406 if err != nil {
407 logging.Error(err.Error())
408 f.viewport.SetContent("Preview unavailable")
409 return
410 }
411
412 f.viewport.SetContent(imageString)
413 }()
414 } else {
415 f.viewport.SetContent("Preview unavailable")
416 }
417}
418
419func readDir(path string, showHidden bool) []os.DirEntry {
420 logging.Info(fmt.Sprintf("Reading directory: %s", path))
421
422 entriesChan := make(chan []os.DirEntry, 1)
423 errChan := make(chan error, 1)
424
425 go func() {
426 dirEntries, err := os.ReadDir(path)
427 if err != nil {
428 logging.ErrorPersist(err.Error())
429 errChan <- err
430 return
431 }
432 entriesChan <- dirEntries
433 }()
434
435 select {
436 case dirEntries := <-entriesChan:
437 sort.Slice(dirEntries, func(i, j int) bool {
438 if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
439 return dirEntries[i].Name() < dirEntries[j].Name()
440 }
441 return dirEntries[i].IsDir()
442 })
443
444 if showHidden {
445 return dirEntries
446 }
447
448 var sanitizedDirEntries []os.DirEntry
449 for _, dirEntry := range dirEntries {
450 isHidden, _ := IsHidden(dirEntry.Name())
451 if !isHidden {
452 if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
453 sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
454 }
455 }
456 }
457
458 return sanitizedDirEntries
459
460 case err := <-errChan:
461 logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
462 return []os.DirEntry{}
463
464 case <-time.After(5 * time.Second):
465 logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
466 return []os.DirEntry{}
467 }
468}
469
470func IsHidden(file string) (bool, error) {
471 return strings.HasPrefix(file, "."), nil
472}
473
474func isExtSupported(path string) bool {
475 ext := strings.ToLower(filepath.Ext(path))
476 return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
477}