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