filepicker.go

  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}