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/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}