@@ -1,468 +1,468 @@
package dialog
-import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- "github.com/charmbracelet/bubbles/v2/viewport"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/logging"
- "github.com/opencode-ai/opencode/internal/message"
- "github.com/opencode-ai/opencode/internal/tui/image"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const (
- maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
- downArrow = "down"
- upArrow = "up"
-)
-
-type FilePrickerKeyMap struct {
- Enter key.Binding
- Down key.Binding
- Up key.Binding
- Forward key.Binding
- Backward key.Binding
- OpenFilePicker key.Binding
- Esc key.Binding
- InsertCWD key.Binding
-}
-
-var filePickerKeyMap = FilePrickerKeyMap{
- Enter: key.NewBinding(
- key.WithKeys("enter"),
- key.WithHelp("enter", "select file/enter directory"),
- ),
- Down: key.NewBinding(
- key.WithKeys("j", downArrow),
- key.WithHelp("↓/j", "down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("k", upArrow),
- key.WithHelp("↑/k", "up"),
- ),
- Forward: key.NewBinding(
- key.WithKeys("l"),
- key.WithHelp("l", "enter directory"),
- ),
- Backward: key.NewBinding(
- key.WithKeys("h", "backspace"),
- key.WithHelp("h/backspace", "go back"),
- ),
- OpenFilePicker: key.NewBinding(
- key.WithKeys("ctrl+f"),
- key.WithHelp("ctrl+f", "open file picker"),
- ),
- Esc: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "close/exit"),
- ),
- InsertCWD: key.NewBinding(
- key.WithKeys("i"),
- key.WithHelp("i", "manual path input"),
- ),
-}
-
-type filepickerCmp struct {
- basePath string
- width int
- height int
- cursor int
- err error
- cursorChain stack
- viewport viewport.Model
- dirs []os.DirEntry
- cwdDetails *DirNode
- selectedFile string
- cwd textinput.Model
- ShowFilePicker bool
- app *app.App
-}
-
-type DirNode struct {
- parent *DirNode
- child *DirNode
- directory string
-}
-type stack []int
-
-func (s stack) Push(v int) stack {
- return append(s, v)
-}
-
-func (s stack) Pop() (stack, int) {
- l := len(s)
- return s[:l-1], s[l-1]
-}
-
-type AttachmentAddedMsg struct {
- Attachment message.Attachment
-}
-
-func (f *filepickerCmp) Init() tea.Cmd {
- return nil
-}
-
-func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmd tea.Cmd
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- f.width = 60
- f.height = 20
- f.viewport.SetWidth(80)
- f.viewport.SetHeight(22)
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- case tea.KeyPressMsg:
- if f.cwd.Focused() {
- f.cwd, cmd = f.cwd.Update(msg)
- }
- switch {
- case key.Matches(msg, filePickerKeyMap.InsertCWD):
- f.cwd.Focus()
- return f, cmd
- case key.Matches(msg, filePickerKeyMap.Esc):
- if f.cwd.Focused() {
- f.cwd.Blur()
- }
- case key.Matches(msg, filePickerKeyMap.Down):
- if !f.cwd.Focused() || msg.String() == downArrow {
- if f.cursor < len(f.dirs)-1 {
- f.cursor++
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Up):
- if !f.cwd.Focused() || msg.String() == upArrow {
- if f.cursor > 0 {
- f.cursor--
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Enter):
- var path string
- var isPathDir bool
- if f.cwd.Focused() {
- path = f.cwd.Value()
- fileInfo, err := os.Stat(path)
- if err != nil {
- logging.ErrorPersist("Invalid path")
- return f, cmd
- }
- isPathDir = fileInfo.IsDir()
- } else {
- path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
- isPathDir = f.dirs[f.cursor].IsDir()
- }
- if isPathDir {
- newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
- f.cwdDetails.child = &newWorkingDir
- f.cwdDetails = f.cwdDetails.child
- f.cursorChain = f.cursorChain.Push(f.cursor)
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- } else {
- f.selectedFile = path
- return f.addAttachmentToMessage()
- }
- case key.Matches(msg, filePickerKeyMap.Esc):
- if !f.cwd.Focused() {
- f.cursorChain = make(stack, 0)
- f.cursor = 0
- } else {
- f.cwd.Blur()
- }
- case key.Matches(msg, filePickerKeyMap.Forward):
- if !f.cwd.Focused() {
- if f.dirs[f.cursor].IsDir() {
- path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
- newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
- f.cwdDetails.child = &newWorkingDir
- f.cwdDetails = f.cwdDetails.child
- f.cursorChain = f.cursorChain.Push(f.cursor)
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.Backward):
- if !f.cwd.Focused() {
- if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
- f.cursorChain, f.cursor = f.cursorChain.Pop()
- f.cwdDetails = f.cwdDetails.parent
- f.cwdDetails.child = nil
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cwd.SetValue(f.cwdDetails.directory)
- f.getCurrentFileBelowCursor()
- }
- }
- case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
- f.dirs = readDir(f.cwdDetails.directory, false)
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- }
- }
- return f, cmd
-}
-
-func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
- // modeInfo := GetSelectedModel(config.Get())
- // if !modeInfo.SupportsAttachments {
- // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
- // return f, nil
- // }
-
- selectedFilePath := f.selectedFile
- if !isExtSupported(selectedFilePath) {
- logging.ErrorPersist("Unsupported file")
- return f, nil
- }
-
- isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
- if err != nil {
- logging.ErrorPersist("unable to read the image")
- return f, nil
- }
- if isFileLarge {
- logging.ErrorPersist("file too large, max 5MB")
- return f, nil
- }
-
- content, err := os.ReadFile(selectedFilePath)
- if err != nil {
- logging.ErrorPersist("Unable read selected file")
- return f, nil
- }
-
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(selectedFilePath)
- attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
- f.selectedFile = ""
- return f, util.CmdHandler(AttachmentAddedMsg{attachment})
-}
-
-func (f *filepickerCmp) View() tea.View {
- t := styles.CurrentTheme()
- baseStyle := t.S().Base
- const maxVisibleDirs = 20
- const maxWidth = 80
-
- adjustedWidth := maxWidth
- for _, file := range f.dirs {
- if len(file.Name()) > adjustedWidth-4 { // Account for padding
- adjustedWidth = len(file.Name()) + 4
- }
- }
- adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
-
- files := make([]string, 0, maxVisibleDirs)
- startIdx := 0
-
- if len(f.dirs) > maxVisibleDirs {
- halfVisible := maxVisibleDirs / 2
- if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
- startIdx = f.cursor - halfVisible
- } else if f.cursor >= len(f.dirs)-halfVisible {
- startIdx = len(f.dirs) - maxVisibleDirs
- }
- }
-
- endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
-
- for i := startIdx; i < endIdx; i++ {
- file := f.dirs[i]
- itemStyle := t.S().Text.Width(adjustedWidth)
-
- if i == f.cursor {
- itemStyle = itemStyle.
- Background(t.Primary).
- Bold(true)
- }
- filename := file.Name()
-
- if len(filename) > adjustedWidth-4 {
- filename = filename[:adjustedWidth-7] + "..."
- }
- if file.IsDir() {
- filename = filename + "/"
- }
- // No need to reassign filename if it's not changing
-
- files = append(files, itemStyle.Padding(0, 1).Render(filename))
- }
-
- // Pad to always show exactly 21 lines
- for len(files) < maxVisibleDirs {
- files = append(files, baseStyle.Width(adjustedWidth).Render(""))
- }
-
- currentPath := baseStyle.
- Height(1).
- Width(adjustedWidth).
- Render(f.cwd.View())
-
- viewportstyle := baseStyle.
- Width(f.viewport.Width()).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- Padding(2).
- Render(f.viewport.View())
- var insertExitText string
- if f.IsCWDFocused() {
- insertExitText = "Press esc to exit typing path"
- } else {
- insertExitText = "Press i to start typing path"
- }
-
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- currentPath,
- baseStyle.Width(adjustedWidth).Render(""),
- baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
- baseStyle.Width(adjustedWidth).Render(""),
- t.S().Muted.Width(adjustedWidth).Render(insertExitText),
- )
-
- f.cwd.SetValue(f.cwd.Value())
- contentStyle := baseStyle.Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus).
- Width(lipgloss.Width(content) + 4)
-
- return tea.NewView(
- lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
- )
-}
-
-type FilepickerCmp interface {
- util.Model
- ToggleFilepicker(showFilepicker bool)
- IsCWDFocused() bool
-}
-
-func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
- f.ShowFilePicker = showFilepicker
-}
-
-func (f *filepickerCmp) IsCWDFocused() bool {
- return f.cwd.Focused()
-}
-
-func NewFilepickerCmp(app *app.App) FilepickerCmp {
- homepath, err := os.UserHomeDir()
- if err != nil {
- logging.Error("error loading user files")
- return nil
- }
- baseDir := DirNode{parent: nil, directory: homepath}
- dirs := readDir(homepath, false)
- viewport := viewport.New()
- currentDirectory := textinput.New()
- currentDirectory.CharLimit = 200
- currentDirectory.SetWidth(44)
- currentDirectory.Cursor().Blink = true
- currentDirectory.SetValue(baseDir.directory)
- return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
-}
-
-func (f *filepickerCmp) getCurrentFileBelowCursor() {
- if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
- logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
- f.viewport.SetContent("Preview unavailable")
- return
- }
-
- dir := f.dirs[f.cursor]
- filename := dir.Name()
- if !dir.IsDir() && isExtSupported(filename) {
- fullPath := f.cwdDetails.directory + "/" + dir.Name()
-
- go func() {
- imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
- if err != nil {
- logging.Error(err.Error())
- f.viewport.SetContent("Preview unavailable")
- return
- }
-
- f.viewport.SetContent(imageString)
- }()
- } else {
- f.viewport.SetContent("Preview unavailable")
- }
-}
-
-func readDir(path string, showHidden bool) []os.DirEntry {
- logging.Info(fmt.Sprintf("Reading directory: %s", path))
-
- entriesChan := make(chan []os.DirEntry, 1)
- errChan := make(chan error, 1)
-
- go func() {
- dirEntries, err := os.ReadDir(path)
- if err != nil {
- logging.ErrorPersist(err.Error())
- errChan <- err
- return
- }
- entriesChan <- dirEntries
- }()
-
- select {
- case dirEntries := <-entriesChan:
- sort.Slice(dirEntries, func(i, j int) bool {
- if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
- return dirEntries[i].Name() < dirEntries[j].Name()
- }
- return dirEntries[i].IsDir()
- })
-
- if showHidden {
- return dirEntries
- }
-
- var sanitizedDirEntries []os.DirEntry
- for _, dirEntry := range dirEntries {
- isHidden, _ := IsHidden(dirEntry.Name())
- if !isHidden {
- if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
- sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
- }
- }
- }
-
- return sanitizedDirEntries
-
- case err := <-errChan:
- logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
- return []os.DirEntry{}
-
- case <-time.After(5 * time.Second):
- logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
- return []os.DirEntry{}
- }
-}
-
-func IsHidden(file string) (bool, error) {
- return strings.HasPrefix(file, "."), nil
-}
-
-func isExtSupported(path string) bool {
- ext := strings.ToLower(filepath.Ext(path))
- return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
-}
+// import (
+// "fmt"
+// "net/http"
+// "os"
+// "path/filepath"
+// "sort"
+// "strings"
+// "time"
+//
+// "github.com/charmbracelet/bubbles/v2/key"
+// "github.com/charmbracelet/bubbles/v2/textinput"
+// "github.com/charmbracelet/bubbles/v2/viewport"
+// tea "github.com/charmbracelet/bubbletea/v2"
+// "github.com/charmbracelet/lipgloss/v2"
+// "github.com/opencode-ai/opencode/internal/app"
+// "github.com/opencode-ai/opencode/internal/logging"
+// "github.com/opencode-ai/opencode/internal/message"
+// "github.com/opencode-ai/opencode/internal/tui/image"
+// "github.com/opencode-ai/opencode/internal/tui/styles"
+// "github.com/opencode-ai/opencode/internal/tui/util"
+// )
+//
+// const (
+// maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
+// downArrow = "down"
+// upArrow = "up"
+// )
+//
+// type FilePrickerKeyMap struct {
+// Enter key.Binding
+// Down key.Binding
+// Up key.Binding
+// Forward key.Binding
+// Backward key.Binding
+// OpenFilePicker key.Binding
+// Esc key.Binding
+// InsertCWD key.Binding
+// }
+//
+// var filePickerKeyMap = FilePrickerKeyMap{
+// Enter: key.NewBinding(
+// key.WithKeys("enter"),
+// key.WithHelp("enter", "select file/enter directory"),
+// ),
+// Down: key.NewBinding(
+// key.WithKeys("j", downArrow),
+// key.WithHelp("↓/j", "down"),
+// ),
+// Up: key.NewBinding(
+// key.WithKeys("k", upArrow),
+// key.WithHelp("↑/k", "up"),
+// ),
+// Forward: key.NewBinding(
+// key.WithKeys("l"),
+// key.WithHelp("l", "enter directory"),
+// ),
+// Backward: key.NewBinding(
+// key.WithKeys("h", "backspace"),
+// key.WithHelp("h/backspace", "go back"),
+// ),
+// OpenFilePicker: key.NewBinding(
+// key.WithKeys("ctrl+f"),
+// key.WithHelp("ctrl+f", "open file picker"),
+// ),
+// Esc: key.NewBinding(
+// key.WithKeys("esc"),
+// key.WithHelp("esc", "close/exit"),
+// ),
+// InsertCWD: key.NewBinding(
+// key.WithKeys("i"),
+// key.WithHelp("i", "manual path input"),
+// ),
+// }
+//
+// type filepickerCmp struct {
+// basePath string
+// width int
+// height int
+// cursor int
+// err error
+// cursorChain stack
+// viewport viewport.Model
+// dirs []os.DirEntry
+// cwdDetails *DirNode
+// selectedFile string
+// cwd textinput.Model
+// ShowFilePicker bool
+// app *app.App
+// }
+//
+// type DirNode struct {
+// parent *DirNode
+// child *DirNode
+// directory string
+// }
+// type stack []int
+//
+// func (s stack) Push(v int) stack {
+// return append(s, v)
+// }
+//
+// func (s stack) Pop() (stack, int) {
+// l := len(s)
+// return s[:l-1], s[l-1]
+// }
+//
+// type AttachmentAddedMsg struct {
+// Attachment message.Attachment
+// }
+//
+// func (f *filepickerCmp) Init() tea.Cmd {
+// return nil
+// }
+//
+// func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+// var cmd tea.Cmd
+// switch msg := msg.(type) {
+// case tea.WindowSizeMsg:
+// f.width = 60
+// f.height = 20
+// f.viewport.SetWidth(80)
+// f.viewport.SetHeight(22)
+// f.cursor = 0
+// f.getCurrentFileBelowCursor()
+// case tea.KeyPressMsg:
+// if f.cwd.Focused() {
+// f.cwd, cmd = f.cwd.Update(msg)
+// }
+// switch {
+// case key.Matches(msg, filePickerKeyMap.InsertCWD):
+// f.cwd.Focus()
+// return f, cmd
+// case key.Matches(msg, filePickerKeyMap.Esc):
+// if f.cwd.Focused() {
+// f.cwd.Blur()
+// }
+// case key.Matches(msg, filePickerKeyMap.Down):
+// if !f.cwd.Focused() || msg.String() == downArrow {
+// if f.cursor < len(f.dirs)-1 {
+// f.cursor++
+// f.getCurrentFileBelowCursor()
+// }
+// }
+// case key.Matches(msg, filePickerKeyMap.Up):
+// if !f.cwd.Focused() || msg.String() == upArrow {
+// if f.cursor > 0 {
+// f.cursor--
+// f.getCurrentFileBelowCursor()
+// }
+// }
+// case key.Matches(msg, filePickerKeyMap.Enter):
+// var path string
+// var isPathDir bool
+// if f.cwd.Focused() {
+// path = f.cwd.Value()
+// fileInfo, err := os.Stat(path)
+// if err != nil {
+// logging.ErrorPersist("Invalid path")
+// return f, cmd
+// }
+// isPathDir = fileInfo.IsDir()
+// } else {
+// path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
+// isPathDir = f.dirs[f.cursor].IsDir()
+// }
+// if isPathDir {
+// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
+// f.cwdDetails.child = &newWorkingDir
+// f.cwdDetails = f.cwdDetails.child
+// f.cursorChain = f.cursorChain.Push(f.cursor)
+// f.dirs = readDir(f.cwdDetails.directory, false)
+// f.cursor = 0
+// f.cwd.SetValue(f.cwdDetails.directory)
+// f.getCurrentFileBelowCursor()
+// } else {
+// f.selectedFile = path
+// return f.addAttachmentToMessage()
+// }
+// case key.Matches(msg, filePickerKeyMap.Esc):
+// if !f.cwd.Focused() {
+// f.cursorChain = make(stack, 0)
+// f.cursor = 0
+// } else {
+// f.cwd.Blur()
+// }
+// case key.Matches(msg, filePickerKeyMap.Forward):
+// if !f.cwd.Focused() {
+// if f.dirs[f.cursor].IsDir() {
+// path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
+// newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
+// f.cwdDetails.child = &newWorkingDir
+// f.cwdDetails = f.cwdDetails.child
+// f.cursorChain = f.cursorChain.Push(f.cursor)
+// f.dirs = readDir(f.cwdDetails.directory, false)
+// f.cursor = 0
+// f.cwd.SetValue(f.cwdDetails.directory)
+// f.getCurrentFileBelowCursor()
+// }
+// }
+// case key.Matches(msg, filePickerKeyMap.Backward):
+// if !f.cwd.Focused() {
+// if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
+// f.cursorChain, f.cursor = f.cursorChain.Pop()
+// f.cwdDetails = f.cwdDetails.parent
+// f.cwdDetails.child = nil
+// f.dirs = readDir(f.cwdDetails.directory, false)
+// f.cwd.SetValue(f.cwdDetails.directory)
+// f.getCurrentFileBelowCursor()
+// }
+// }
+// case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
+// f.dirs = readDir(f.cwdDetails.directory, false)
+// f.cursor = 0
+// f.getCurrentFileBelowCursor()
+// }
+// }
+// return f, cmd
+// }
+//
+// func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
+// // modeInfo := GetSelectedModel(config.Get())
+// // if !modeInfo.SupportsAttachments {
+// // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
+// // return f, nil
+// // }
+//
+// selectedFilePath := f.selectedFile
+// if !isExtSupported(selectedFilePath) {
+// logging.ErrorPersist("Unsupported file")
+// return f, nil
+// }
+//
+// isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
+// if err != nil {
+// logging.ErrorPersist("unable to read the image")
+// return f, nil
+// }
+// if isFileLarge {
+// logging.ErrorPersist("file too large, max 5MB")
+// return f, nil
+// }
+//
+// content, err := os.ReadFile(selectedFilePath)
+// if err != nil {
+// logging.ErrorPersist("Unable read selected file")
+// return f, nil
+// }
+//
+// mimeBufferSize := min(512, len(content))
+// mimeType := http.DetectContentType(content[:mimeBufferSize])
+// fileName := filepath.Base(selectedFilePath)
+// attachment := message.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
+// f.selectedFile = ""
+// return f, util.CmdHandler(AttachmentAddedMsg{attachment})
+// }
+//
+// func (f *filepickerCmp) View() tea.View {
+// t := styles.CurrentTheme()
+// baseStyle := t.S().Base
+// const maxVisibleDirs = 20
+// const maxWidth = 80
+//
+// adjustedWidth := maxWidth
+// for _, file := range f.dirs {
+// if len(file.Name()) > adjustedWidth-4 { // Account for padding
+// adjustedWidth = len(file.Name()) + 4
+// }
+// }
+// adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
+//
+// files := make([]string, 0, maxVisibleDirs)
+// startIdx := 0
+//
+// if len(f.dirs) > maxVisibleDirs {
+// halfVisible := maxVisibleDirs / 2
+// if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
+// startIdx = f.cursor - halfVisible
+// } else if f.cursor >= len(f.dirs)-halfVisible {
+// startIdx = len(f.dirs) - maxVisibleDirs
+// }
+// }
+//
+// endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
+//
+// for i := startIdx; i < endIdx; i++ {
+// file := f.dirs[i]
+// itemStyle := t.S().Text.Width(adjustedWidth)
+//
+// if i == f.cursor {
+// itemStyle = itemStyle.
+// Background(t.Primary).
+// Bold(true)
+// }
+// filename := file.Name()
+//
+// if len(filename) > adjustedWidth-4 {
+// filename = filename[:adjustedWidth-7] + "..."
+// }
+// if file.IsDir() {
+// filename = filename + "/"
+// }
+// // No need to reassign filename if it's not changing
+//
+// files = append(files, itemStyle.Padding(0, 1).Render(filename))
+// }
+//
+// // Pad to always show exactly 21 lines
+// for len(files) < maxVisibleDirs {
+// files = append(files, baseStyle.Width(adjustedWidth).Render(""))
+// }
+//
+// currentPath := baseStyle.
+// Height(1).
+// Width(adjustedWidth).
+// Render(f.cwd.View())
+//
+// viewportstyle := baseStyle.
+// Width(f.viewport.Width()).
+// Border(lipgloss.RoundedBorder()).
+// BorderForeground(t.BorderFocus).
+// Padding(2).
+// Render(f.viewport.View())
+// var insertExitText string
+// if f.IsCWDFocused() {
+// insertExitText = "Press esc to exit typing path"
+// } else {
+// insertExitText = "Press i to start typing path"
+// }
+//
+// content := lipgloss.JoinVertical(
+// lipgloss.Left,
+// currentPath,
+// baseStyle.Width(adjustedWidth).Render(""),
+// baseStyle.Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
+// baseStyle.Width(adjustedWidth).Render(""),
+// t.S().Muted.Width(adjustedWidth).Render(insertExitText),
+// )
+//
+// f.cwd.SetValue(f.cwd.Value())
+// contentStyle := baseStyle.Padding(1, 2).
+// Border(lipgloss.RoundedBorder()).
+// BorderForeground(t.BorderFocus).
+// Width(lipgloss.Width(content) + 4)
+//
+// return tea.NewView(
+// lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle),
+// )
+// }
+//
+// type FilepickerCmp interface {
+// util.Model
+// ToggleFilepicker(showFilepicker bool)
+// IsCWDFocused() bool
+// }
+//
+// func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
+// f.ShowFilePicker = showFilepicker
+// }
+//
+// func (f *filepickerCmp) IsCWDFocused() bool {
+// return f.cwd.Focused()
+// }
+//
+// func NewFilepickerCmp(app *app.App) FilepickerCmp {
+// homepath, err := os.UserHomeDir()
+// if err != nil {
+// logging.Error("error loading user files")
+// return nil
+// }
+// baseDir := DirNode{parent: nil, directory: homepath}
+// dirs := readDir(homepath, false)
+// viewport := viewport.New()
+// currentDirectory := textinput.New()
+// currentDirectory.CharLimit = 200
+// currentDirectory.SetWidth(44)
+// currentDirectory.Cursor().Blink = true
+// currentDirectory.SetValue(baseDir.directory)
+// return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
+// }
+//
+// func (f *filepickerCmp) getCurrentFileBelowCursor() {
+// if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
+// logging.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
+// f.viewport.SetContent("Preview unavailable")
+// return
+// }
+//
+// dir := f.dirs[f.cursor]
+// filename := dir.Name()
+// if !dir.IsDir() && isExtSupported(filename) {
+// fullPath := f.cwdDetails.directory + "/" + dir.Name()
+//
+// go func() {
+// imageString, err := image.ImagePreview(f.viewport.Width()-4, fullPath)
+// if err != nil {
+// logging.Error(err.Error())
+// f.viewport.SetContent("Preview unavailable")
+// return
+// }
+//
+// f.viewport.SetContent(imageString)
+// }()
+// } else {
+// f.viewport.SetContent("Preview unavailable")
+// }
+// }
+//
+// func readDir(path string, showHidden bool) []os.DirEntry {
+// logging.Info(fmt.Sprintf("Reading directory: %s", path))
+//
+// entriesChan := make(chan []os.DirEntry, 1)
+// errChan := make(chan error, 1)
+//
+// go func() {
+// dirEntries, err := os.ReadDir(path)
+// if err != nil {
+// logging.ErrorPersist(err.Error())
+// errChan <- err
+// return
+// }
+// entriesChan <- dirEntries
+// }()
+//
+// select {
+// case dirEntries := <-entriesChan:
+// sort.Slice(dirEntries, func(i, j int) bool {
+// if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
+// return dirEntries[i].Name() < dirEntries[j].Name()
+// }
+// return dirEntries[i].IsDir()
+// })
+//
+// if showHidden {
+// return dirEntries
+// }
+//
+// var sanitizedDirEntries []os.DirEntry
+// for _, dirEntry := range dirEntries {
+// isHidden, _ := IsHidden(dirEntry.Name())
+// if !isHidden {
+// if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
+// sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
+// }
+// }
+// }
+//
+// return sanitizedDirEntries
+//
+// case err := <-errChan:
+// logging.ErrorPersist(fmt.Sprintf("Error reading directory %s", path), err)
+// return []os.DirEntry{}
+//
+// case <-time.After(5 * time.Second):
+// logging.ErrorPersist(fmt.Sprintf("Timeout reading directory %s", path), nil)
+// return []os.DirEntry{}
+// }
+// }
+//
+// func IsHidden(file string) (bool, error) {
+// return strings.HasPrefix(file, "."), nil
+// }
+//
+// func isExtSupported(path string) bool {
+// ext := strings.ToLower(filepath.Ext(path))
+// return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
+// }
@@ -0,0 +1,157 @@
+// Based on the implementation by @trashhalo at:
+// https://github.com/trashhalo/imgcat
+package image
+
+import (
+ "image"
+ "image/png"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/disintegration/imageorient"
+ "github.com/lucasb-eyer/go-colorful"
+ "github.com/muesli/termenv"
+ "github.com/nfnt/resize"
+ "github.com/srwiley/oksvg"
+ "github.com/srwiley/rasterx"
+)
+
+type loadMsg struct {
+ io.ReadCloser
+}
+
+func loadUrl(url string) tea.Cmd {
+ var r io.ReadCloser
+ var err error
+
+ if strings.HasPrefix(url, "http") {
+ var resp *http.Response
+ resp, err = http.Get(url)
+ r = resp.Body
+ } else {
+ r, err = os.Open(url)
+ }
+
+ if err != nil {
+ return func() tea.Msg {
+ return errMsg{err}
+ }
+ }
+
+ return load(r)
+}
+
+func load(r io.ReadCloser) tea.Cmd {
+ return func() tea.Msg {
+ return loadMsg{r}
+ }
+}
+
+func handleLoadMsg(m Model, msg loadMsg) (Model, tea.Cmd) {
+ if m.cancelAnimation != nil {
+ m.cancelAnimation()
+ }
+
+ // blank out image so it says "loading..."
+ m.image = ""
+
+ return handleLoadMsgStatic(m, msg)
+}
+
+func handleLoadMsgStatic(m Model, msg loadMsg) (Model, tea.Cmd) {
+ defer msg.Close()
+
+ img, err := readerToimage(m.width, m.height, m.url, msg)
+ if err != nil {
+ return m, func() tea.Msg { return errMsg{err} }
+ }
+ m.image = img
+ return m, nil
+}
+
+func imageToString(width, height uint, url string, img image.Image) (string, error) {
+ img = resize.Thumbnail(width, height*2-4, img, resize.Lanczos3)
+ b := img.Bounds()
+ w := b.Max.X
+ h := b.Max.Y
+ p := termenv.ColorProfile()
+ str := strings.Builder{}
+ for y := 0; y < h; y += 2 {
+ for x := w; x < int(width); x = x + 2 {
+ str.WriteString(" ")
+ }
+ for x := 0; x < w; x++ {
+ c1, _ := colorful.MakeColor(img.At(x, y))
+ color1 := p.Color(c1.Hex())
+ c2, _ := colorful.MakeColor(img.At(x, y+1))
+ color2 := p.Color(c2.Hex())
+ str.WriteString(termenv.String("▀").
+ Foreground(color1).
+ Background(color2).
+ String())
+ }
+ str.WriteString("\n")
+ }
+ return str.String(), nil
+}
+
+func readerToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+ if strings.HasSuffix(strings.ToLower(url), ".svg") {
+ return svgToimage(width, height, url, r)
+ }
+
+ img, _, err := imageorient.Decode(r)
+ if err != nil {
+ return "", err
+ }
+
+ return imageToString(width, height, url, img)
+}
+
+func svgToimage(width uint, height uint, url string, r io.Reader) (string, error) {
+ // Original author: https://stackoverflow.com/users/10826783/usual-human
+ // https://stackoverflow.com/questions/42993407/how-to-create-and-export-svg-to-png-jpeg-in-golang
+ // Adapted to use size from SVG, and to use temp file.
+
+ tmpPngFile, err := ioutil.TempFile("", "imgcat.*.png")
+ if err != nil {
+ return "", err
+ }
+ tmpPngPath := tmpPngFile.Name()
+ defer os.Remove(tmpPngPath)
+ defer tmpPngFile.Close()
+
+ // Rasterize the SVG:
+ icon, err := oksvg.ReadIconStream(r)
+ if err != nil {
+ return "", err
+ }
+ w := int(icon.ViewBox.W)
+ h := int(icon.ViewBox.H)
+ icon.SetTarget(0, 0, float64(w), float64(h))
+ rgba := image.NewRGBA(image.Rect(0, 0, w, h))
+ icon.Draw(rasterx.NewDasher(w, h, rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())), 1)
+ // Write rasterized image as PNG:
+ err = png.Encode(tmpPngFile, rgba)
+ if err != nil {
+ tmpPngFile.Close()
+ return "", err
+ }
+ tmpPngFile.Close()
+
+ rPng, err := os.Open(tmpPngPath)
+ if err != nil {
+ return "", err
+ }
+ defer rPng.Close()
+
+ img, _, err := imageorient.Decode(rPng)
+ if err != nil {
+ return "", err
+ }
+ return imageToString(width, height, url, img)
+}