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/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/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.SetWidth(80)
126		f.viewport.SetHeight(22)
127		f.cursor = 0
128		f.getCurrentFileBelowCursor()
129	case tea.KeyPressMsg:
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() tea.View {
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 tea.NewView(
353		lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
354	)
355}
356
357type FilepickerCmp interface {
358	util.Model
359	ToggleFilepicker(showFilepicker bool)
360	IsCWDFocused() bool
361}
362
363func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
364	f.ShowFilePicker = showFilepicker
365}
366
367func (f *filepickerCmp) IsCWDFocused() bool {
368	return f.cwd.Focused()
369}
370
371func NewFilepickerCmp(app *app.App) FilepickerCmp {
372	homepath, err := os.UserHomeDir()
373	if err != nil {
374		logging.Error("error loading user files")
375		return nil
376	}
377	baseDir := DirNode{parent: nil, directory: homepath}
378	dirs := readDir(homepath, false)
379	viewport := viewport.New()
380	currentDirectory := textinput.New()
381	currentDirectory.CharLimit = 200
382	currentDirectory.SetWidth(44)
383	currentDirectory.Cursor().Blink = true
384	currentDirectory.SetValue(baseDir.directory)
385	return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
386}
387
388func (f *filepickerCmp) getCurrentFileBelowCursor() {
389	if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
390		logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
391		f.viewport.SetContent("Preview unavailable")
392		return
393	}
394
395	dir := f.dirs[f.cursor]
396	filename := dir.Name()
397	if !dir.IsDir() && isExtSupported(filename) {
398		fullPath := f.cwdDetails.directory + "/" + dir.Name()
399
400		go func() {
401			imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
402			if err != nil {
403				logging.Error(err.Error())
404				f.viewport.SetContent("Preview unavailable")
405				return
406			}
407
408			f.viewport.SetContent(imageString)
409		}()
410	} else {
411		f.viewport.SetContent("Preview unavailable")
412	}
413}
414
415func readDir(path string, showHidden bool) []os.DirEntry {
416	logging.Info(fmt.Sprintf("Reading directory: %s", path))
417
418	entriesChan := make(chan []os.DirEntry, 1)
419	errChan := make(chan error, 1)
420
421	go func() {
422		dirEntries, err := os.ReadDir(path)
423		if err != nil {
424			logging.ErrorPersist(err.Error())
425			errChan <- err
426			return
427		}
428		entriesChan <- dirEntries
429	}()
430
431	select {
432	case dirEntries := <-entriesChan:
433		sort.Slice(dirEntries, func(i, j int) bool {
434			if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
435				return dirEntries[i].Name() < dirEntries[j].Name()
436			}
437			return dirEntries[i].IsDir()
438		})
439
440		if showHidden {
441			return dirEntries
442		}
443
444		var sanitizedDirEntries []os.DirEntry
445		for _, dirEntry := range dirEntries {
446			isHidden, _ := IsHidden(dirEntry.Name())
447			if !isHidden {
448				if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
449					sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
450				}
451			}
452		}
453
454		return sanitizedDirEntries
455
456	case err := <-errChan:
457		logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
458		return []os.DirEntry{}
459
460	case <-time.After(5 * time.Second):
461		logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
462		return []os.DirEntry{}
463	}
464}
465
466func IsHidden(file string) (bool, error) {
467	return strings.HasPrefix(file, "."), nil
468}
469
470func isExtSupported(path string) bool {
471	ext := strings.ToLower(filepath.Ext(path))
472	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
473}