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