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