| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- package dialog
- import (
- "fmt"
- "net/http"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
- "log/slog"
- "github.com/atotto/clipboard"
- "github.com/charmbracelet/bubbles/key"
- "github.com/charmbracelet/bubbles/textinput"
- "github.com/charmbracelet/bubbles/viewport"
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/sst/opencode/internal/app"
- "github.com/sst/opencode/internal/image"
- "github.com/sst/opencode/internal/status"
- "github.com/sst/opencode/internal/styles"
- "github.com/sst/opencode/internal/theme"
- "github.com/sst/opencode/internal/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
- Paste 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"),
- ),
- Paste: key.NewBinding(
- key.WithKeys("ctrl+v"),
- key.WithHelp("ctrl+v", "paste file/directory path"),
- ),
- }
- 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 app.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.Width = 80
- f.viewport.Height = 22
- f.cursor = 0
- f.getCurrentFileBelowCursor()
- case tea.KeyMsg:
- 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 {
- status.Error("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.Paste):
- if f.cwd.Focused() {
- val, err := clipboard.ReadAll()
- if err != nil {
- slog.Error("failed to read clipboard")
- return f, cmd
- }
- f.cwd.SetValue(f.cwd.Value() + val)
- }
- 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 {
- // status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
- // return f, nil
- // }
- selectedFilePath := f.selectedFile
- if !isExtSupported(selectedFilePath) {
- status.Error("Unsupported file")
- return f, nil
- }
- isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
- if err != nil {
- status.Error("unable to read the image")
- return f, nil
- }
- if isFileLarge {
- status.Error("file too large, max 5MB")
- return f, nil
- }
- content, err := os.ReadFile(selectedFilePath)
- if err != nil {
- status.Error("Unable read selected file")
- return f, nil
- }
- mimeBufferSize := min(512, len(content))
- mimeType := http.DetectContentType(content[:mimeBufferSize])
- fileName := filepath.Base(selectedFilePath)
- attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
- f.selectedFile = ""
- return f, util.CmdHandler(AttachmentAddedMsg{attachment})
- }
- func (f *filepickerCmp) View() string {
- t := theme.CurrentTheme()
- 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 := styles.BaseStyle().Width(adjustedWidth)
- if i == f.cursor {
- itemStyle = itemStyle.
- Background(t.Primary()).
- Foreground(t.Background()).
- Bold(true)
- }
- filename := file.Name()
- if len(filename) > adjustedWidth-4 {
- filename = filename[:adjustedWidth-7] + "..."
- }
- if file.IsDir() {
- filename = filename + "/"
- }
- files = append(files, itemStyle.Padding(0, 1).Render(filename))
- }
- // Pad to always show exactly 21 lines
- for len(files) < maxVisibleDirs {
- files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
- }
- currentPath := styles.BaseStyle().
- Height(1).
- Width(adjustedWidth).
- Render(f.cwd.View())
- viewportstyle := lipgloss.NewStyle().
- Width(f.viewport.Width).
- Background(t.Background()).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.TextMuted()).
- BorderBackground(t.Background()).
- 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,
- styles.BaseStyle().Width(adjustedWidth).Render(""),
- styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
- styles.BaseStyle().Width(adjustedWidth).Render(""),
- styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
- )
- f.cwd.SetValue(f.cwd.Value())
- contentStyle := styles.BaseStyle().Padding(1, 2).
- Border(lipgloss.RoundedBorder()).
- BorderBackground(t.Background()).
- BorderForeground(t.TextMuted()).
- Width(lipgloss.Width(content) + 4)
- return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
- }
- type FilepickerCmp interface {
- tea.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 {
- slog.Error("error loading user files")
- return nil
- }
- baseDir := DirNode{parent: nil, directory: homepath}
- dirs := readDir(homepath, false)
- viewport := viewport.New(0, 0)
- currentDirectory := textinput.New()
- currentDirectory.CharLimit = 200
- currentDirectory.Width = 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) {
- slog.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 {
- slog.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 {
- slog.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 {
- status.Error(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 <-errChan:
- status.Error(fmt.Sprintf("Error reading directory %s", path))
- return []os.DirEntry{}
- case <-time.After(5 * time.Second):
- status.Error(fmt.Sprintf("Timeout reading directory %s", path))
- 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")
- }
|