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