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