filepicker.go

  1package dialog
  2
  3// import (
  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//
 25// const (
 26// 	maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
 27// 	downArrow         = "down"
 28// 	upArrow           = "up"
 29// )
 30//
 31// type 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//
 42// var 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//
 77// type 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//
 93// type DirNode struct {
 94// 	parent    *DirNode
 95// 	child     *DirNode
 96// 	directory string
 97// }
 98// type stack []int
 99//
100// func (s stack) Push(v int) stack {
101// 	return append(s, v)
102// }
103//
104// func (s stack) Pop() (stack, int) {
105// 	l := len(s)
106// 	return s[:l-1], s[l-1]
107// }
108//
109// type AttachmentAddedMsg struct {
110// 	Attachment message.Attachment
111// }
112//
113// func (f *filepickerCmp) Init() tea.Cmd {
114// 	return nil
115// }
116//
117// func (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//
222// func (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//
259// func (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//
352// type FilepickerCmp interface {
353// 	util.Model
354// 	ToggleFilepicker(showFilepicker bool)
355// 	IsCWDFocused() bool
356// }
357//
358// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
359// 	f.ShowFilePicker = showFilepicker
360// }
361//
362// func (f *filepickerCmp) IsCWDFocused() bool {
363// 	return f.cwd.Focused()
364// }
365//
366// func 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//
383// func (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//
410// func 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//
461// func IsHidden(file string) (bool, error) {
462// 	return strings.HasPrefix(file, "."), nil
463// }
464//
465// func isExtSupported(path string) bool {
466// 	ext := strings.ToLower(filepath.Ext(path))
467// 	return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
468// }