filepicker.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. package dialog
  2. import (
  3. "fmt"
  4. "net/http"
  5. "os"
  6. "path/filepath"
  7. "sort"
  8. "strings"
  9. "time"
  10. "log/slog"
  11. "github.com/atotto/clipboard"
  12. "github.com/charmbracelet/bubbles/key"
  13. "github.com/charmbracelet/bubbles/textinput"
  14. "github.com/charmbracelet/bubbles/viewport"
  15. tea "github.com/charmbracelet/bubbletea"
  16. "github.com/charmbracelet/lipgloss"
  17. "github.com/sst/opencode/internal/app"
  18. "github.com/sst/opencode/internal/image"
  19. "github.com/sst/opencode/internal/status"
  20. "github.com/sst/opencode/internal/styles"
  21. "github.com/sst/opencode/internal/theme"
  22. "github.com/sst/opencode/internal/util"
  23. )
  24. const (
  25. maxAttachmentSize = int64(5 * 1024 * 1024) // 5MB
  26. downArrow = "down"
  27. upArrow = "up"
  28. )
  29. type FilePrickerKeyMap struct {
  30. Enter key.Binding
  31. Down key.Binding
  32. Up key.Binding
  33. Forward key.Binding
  34. Backward key.Binding
  35. OpenFilePicker key.Binding
  36. Esc key.Binding
  37. InsertCWD key.Binding
  38. Paste key.Binding
  39. }
  40. var filePickerKeyMap = FilePrickerKeyMap{
  41. Enter: key.NewBinding(
  42. key.WithKeys("enter"),
  43. key.WithHelp("enter", "select file/enter directory"),
  44. ),
  45. Down: key.NewBinding(
  46. key.WithKeys("j", downArrow),
  47. key.WithHelp("↓/j", "down"),
  48. ),
  49. Up: key.NewBinding(
  50. key.WithKeys("k", upArrow),
  51. key.WithHelp("↑/k", "up"),
  52. ),
  53. Forward: key.NewBinding(
  54. key.WithKeys("l"),
  55. key.WithHelp("l", "enter directory"),
  56. ),
  57. Backward: key.NewBinding(
  58. key.WithKeys("h", "backspace"),
  59. key.WithHelp("h/backspace", "go back"),
  60. ),
  61. OpenFilePicker: key.NewBinding(
  62. key.WithKeys("ctrl+f"),
  63. key.WithHelp("ctrl+f", "open file picker"),
  64. ),
  65. Esc: key.NewBinding(
  66. key.WithKeys("esc"),
  67. key.WithHelp("esc", "close/exit"),
  68. ),
  69. InsertCWD: key.NewBinding(
  70. key.WithKeys("i"),
  71. key.WithHelp("i", "manual path input"),
  72. ),
  73. Paste: key.NewBinding(
  74. key.WithKeys("ctrl+v"),
  75. key.WithHelp("ctrl+v", "paste file/directory path"),
  76. ),
  77. }
  78. type filepickerCmp struct {
  79. basePath string
  80. width int
  81. height int
  82. cursor int
  83. err error
  84. cursorChain stack
  85. viewport viewport.Model
  86. dirs []os.DirEntry
  87. cwdDetails *DirNode
  88. selectedFile string
  89. cwd textinput.Model
  90. ShowFilePicker bool
  91. app *app.App
  92. }
  93. type DirNode struct {
  94. parent *DirNode
  95. child *DirNode
  96. directory string
  97. }
  98. type stack []int
  99. func (s stack) Push(v int) stack {
  100. return append(s, v)
  101. }
  102. func (s stack) Pop() (stack, int) {
  103. l := len(s)
  104. return s[:l-1], s[l-1]
  105. }
  106. type AttachmentAddedMsg struct {
  107. Attachment app.Attachment
  108. }
  109. func (f *filepickerCmp) Init() tea.Cmd {
  110. return nil
  111. }
  112. func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  113. var cmd tea.Cmd
  114. switch msg := msg.(type) {
  115. case tea.WindowSizeMsg:
  116. f.width = 60
  117. f.height = 20
  118. f.viewport.Width = 80
  119. f.viewport.Height = 22
  120. f.cursor = 0
  121. f.getCurrentFileBelowCursor()
  122. case tea.KeyMsg:
  123. if f.cwd.Focused() {
  124. f.cwd, cmd = f.cwd.Update(msg)
  125. }
  126. switch {
  127. case key.Matches(msg, filePickerKeyMap.InsertCWD):
  128. f.cwd.Focus()
  129. return f, cmd
  130. case key.Matches(msg, filePickerKeyMap.Esc):
  131. if f.cwd.Focused() {
  132. f.cwd.Blur()
  133. }
  134. case key.Matches(msg, filePickerKeyMap.Down):
  135. if !f.cwd.Focused() || msg.String() == downArrow {
  136. if f.cursor < len(f.dirs)-1 {
  137. f.cursor++
  138. f.getCurrentFileBelowCursor()
  139. }
  140. }
  141. case key.Matches(msg, filePickerKeyMap.Up):
  142. if !f.cwd.Focused() || msg.String() == upArrow {
  143. if f.cursor > 0 {
  144. f.cursor--
  145. f.getCurrentFileBelowCursor()
  146. }
  147. }
  148. case key.Matches(msg, filePickerKeyMap.Enter):
  149. var path string
  150. var isPathDir bool
  151. if f.cwd.Focused() {
  152. path = f.cwd.Value()
  153. fileInfo, err := os.Stat(path)
  154. if err != nil {
  155. status.Error("Invalid path")
  156. return f, cmd
  157. }
  158. isPathDir = fileInfo.IsDir()
  159. } else {
  160. path = filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
  161. isPathDir = f.dirs[f.cursor].IsDir()
  162. }
  163. if isPathDir {
  164. newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
  165. f.cwdDetails.child = &newWorkingDir
  166. f.cwdDetails = f.cwdDetails.child
  167. f.cursorChain = f.cursorChain.Push(f.cursor)
  168. f.dirs = readDir(f.cwdDetails.directory, false)
  169. f.cursor = 0
  170. f.cwd.SetValue(f.cwdDetails.directory)
  171. f.getCurrentFileBelowCursor()
  172. } else {
  173. f.selectedFile = path
  174. return f.addAttachmentToMessage()
  175. }
  176. case key.Matches(msg, filePickerKeyMap.Esc):
  177. if !f.cwd.Focused() {
  178. f.cursorChain = make(stack, 0)
  179. f.cursor = 0
  180. } else {
  181. f.cwd.Blur()
  182. }
  183. case key.Matches(msg, filePickerKeyMap.Forward):
  184. if !f.cwd.Focused() {
  185. if f.dirs[f.cursor].IsDir() {
  186. path := filepath.Join(f.cwdDetails.directory, "/", f.dirs[f.cursor].Name())
  187. newWorkingDir := DirNode{parent: f.cwdDetails, directory: path}
  188. f.cwdDetails.child = &newWorkingDir
  189. f.cwdDetails = f.cwdDetails.child
  190. f.cursorChain = f.cursorChain.Push(f.cursor)
  191. f.dirs = readDir(f.cwdDetails.directory, false)
  192. f.cursor = 0
  193. f.cwd.SetValue(f.cwdDetails.directory)
  194. f.getCurrentFileBelowCursor()
  195. }
  196. }
  197. case key.Matches(msg, filePickerKeyMap.Backward):
  198. if !f.cwd.Focused() {
  199. if len(f.cursorChain) != 0 && f.cwdDetails.parent != nil {
  200. f.cursorChain, f.cursor = f.cursorChain.Pop()
  201. f.cwdDetails = f.cwdDetails.parent
  202. f.cwdDetails.child = nil
  203. f.dirs = readDir(f.cwdDetails.directory, false)
  204. f.cwd.SetValue(f.cwdDetails.directory)
  205. f.getCurrentFileBelowCursor()
  206. }
  207. }
  208. case key.Matches(msg, filePickerKeyMap.Paste):
  209. if f.cwd.Focused() {
  210. val, err := clipboard.ReadAll()
  211. if err != nil {
  212. slog.Error("failed to read clipboard")
  213. return f, cmd
  214. }
  215. f.cwd.SetValue(f.cwd.Value() + val)
  216. }
  217. case key.Matches(msg, filePickerKeyMap.OpenFilePicker):
  218. f.dirs = readDir(f.cwdDetails.directory, false)
  219. f.cursor = 0
  220. f.getCurrentFileBelowCursor()
  221. }
  222. }
  223. return f, cmd
  224. }
  225. func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
  226. // modeInfo := GetSelectedModel(config.Get())
  227. // if !modeInfo.SupportsAttachments {
  228. // status.Error(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
  229. // return f, nil
  230. // }
  231. selectedFilePath := f.selectedFile
  232. if !isExtSupported(selectedFilePath) {
  233. status.Error("Unsupported file")
  234. return f, nil
  235. }
  236. isFileLarge, err := image.ValidateFileSize(selectedFilePath, maxAttachmentSize)
  237. if err != nil {
  238. status.Error("unable to read the image")
  239. return f, nil
  240. }
  241. if isFileLarge {
  242. status.Error("file too large, max 5MB")
  243. return f, nil
  244. }
  245. content, err := os.ReadFile(selectedFilePath)
  246. if err != nil {
  247. status.Error("Unable read selected file")
  248. return f, nil
  249. }
  250. mimeBufferSize := min(512, len(content))
  251. mimeType := http.DetectContentType(content[:mimeBufferSize])
  252. fileName := filepath.Base(selectedFilePath)
  253. attachment := app.Attachment{FilePath: selectedFilePath, FileName: fileName, MimeType: mimeType, Content: content}
  254. f.selectedFile = ""
  255. return f, util.CmdHandler(AttachmentAddedMsg{attachment})
  256. }
  257. func (f *filepickerCmp) View() string {
  258. t := theme.CurrentTheme()
  259. const maxVisibleDirs = 20
  260. const maxWidth = 80
  261. adjustedWidth := maxWidth
  262. for _, file := range f.dirs {
  263. if len(file.Name()) > adjustedWidth-4 { // Account for padding
  264. adjustedWidth = len(file.Name()) + 4
  265. }
  266. }
  267. adjustedWidth = max(30, min(adjustedWidth, f.width-15)) + 1
  268. files := make([]string, 0, maxVisibleDirs)
  269. startIdx := 0
  270. if len(f.dirs) > maxVisibleDirs {
  271. halfVisible := maxVisibleDirs / 2
  272. if f.cursor >= halfVisible && f.cursor < len(f.dirs)-halfVisible {
  273. startIdx = f.cursor - halfVisible
  274. } else if f.cursor >= len(f.dirs)-halfVisible {
  275. startIdx = len(f.dirs) - maxVisibleDirs
  276. }
  277. }
  278. endIdx := min(startIdx+maxVisibleDirs, len(f.dirs))
  279. for i := startIdx; i < endIdx; i++ {
  280. file := f.dirs[i]
  281. itemStyle := styles.BaseStyle().Width(adjustedWidth)
  282. if i == f.cursor {
  283. itemStyle = itemStyle.
  284. Background(t.Primary()).
  285. Foreground(t.Background()).
  286. Bold(true)
  287. }
  288. filename := file.Name()
  289. if len(filename) > adjustedWidth-4 {
  290. filename = filename[:adjustedWidth-7] + "..."
  291. }
  292. if file.IsDir() {
  293. filename = filename + "/"
  294. }
  295. files = append(files, itemStyle.Padding(0, 1).Render(filename))
  296. }
  297. // Pad to always show exactly 21 lines
  298. for len(files) < maxVisibleDirs {
  299. files = append(files, styles.BaseStyle().Width(adjustedWidth).Render(""))
  300. }
  301. currentPath := styles.BaseStyle().
  302. Height(1).
  303. Width(adjustedWidth).
  304. Render(f.cwd.View())
  305. viewportstyle := lipgloss.NewStyle().
  306. Width(f.viewport.Width).
  307. Background(t.Background()).
  308. Border(lipgloss.RoundedBorder()).
  309. BorderForeground(t.TextMuted()).
  310. BorderBackground(t.Background()).
  311. Padding(2).
  312. Render(f.viewport.View())
  313. var insertExitText string
  314. if f.IsCWDFocused() {
  315. insertExitText = "Press esc to exit typing path"
  316. } else {
  317. insertExitText = "Press i to start typing path"
  318. }
  319. content := lipgloss.JoinVertical(
  320. lipgloss.Left,
  321. currentPath,
  322. styles.BaseStyle().Width(adjustedWidth).Render(""),
  323. styles.BaseStyle().Width(adjustedWidth).Render(lipgloss.JoinVertical(lipgloss.Left, files...)),
  324. styles.BaseStyle().Width(adjustedWidth).Render(""),
  325. styles.BaseStyle().Foreground(t.TextMuted()).Width(adjustedWidth).Render(insertExitText),
  326. )
  327. f.cwd.SetValue(f.cwd.Value())
  328. contentStyle := styles.BaseStyle().Padding(1, 2).
  329. Border(lipgloss.RoundedBorder()).
  330. BorderBackground(t.Background()).
  331. BorderForeground(t.TextMuted()).
  332. Width(lipgloss.Width(content) + 4)
  333. return lipgloss.JoinHorizontal(lipgloss.Center, contentStyle.Render(content), viewportstyle)
  334. }
  335. type FilepickerCmp interface {
  336. tea.Model
  337. ToggleFilepicker(showFilepicker bool)
  338. IsCWDFocused() bool
  339. }
  340. func (f *filepickerCmp) ToggleFilepicker(showFilepicker bool) {
  341. f.ShowFilePicker = showFilepicker
  342. }
  343. func (f *filepickerCmp) IsCWDFocused() bool {
  344. return f.cwd.Focused()
  345. }
  346. func NewFilepickerCmp(app *app.App) FilepickerCmp {
  347. homepath, err := os.UserHomeDir()
  348. if err != nil {
  349. slog.Error("error loading user files")
  350. return nil
  351. }
  352. baseDir := DirNode{parent: nil, directory: homepath}
  353. dirs := readDir(homepath, false)
  354. viewport := viewport.New(0, 0)
  355. currentDirectory := textinput.New()
  356. currentDirectory.CharLimit = 200
  357. currentDirectory.Width = 44
  358. currentDirectory.Cursor.Blink = true
  359. currentDirectory.SetValue(baseDir.directory)
  360. return &filepickerCmp{cwdDetails: &baseDir, dirs: dirs, cursorChain: make(stack, 0), viewport: viewport, cwd: currentDirectory, app: app}
  361. }
  362. func (f *filepickerCmp) getCurrentFileBelowCursor() {
  363. if len(f.dirs) == 0 || f.cursor < 0 || f.cursor >= len(f.dirs) {
  364. slog.Error(fmt.Sprintf("Invalid cursor position. Dirs length: %d, Cursor: %d", len(f.dirs), f.cursor))
  365. f.viewport.SetContent("Preview unavailable")
  366. return
  367. }
  368. dir := f.dirs[f.cursor]
  369. filename := dir.Name()
  370. if !dir.IsDir() && isExtSupported(filename) {
  371. fullPath := f.cwdDetails.directory + "/" + dir.Name()
  372. go func() {
  373. imageString, err := image.ImagePreview(f.viewport.Width-4, fullPath)
  374. if err != nil {
  375. slog.Error(err.Error())
  376. f.viewport.SetContent("Preview unavailable")
  377. return
  378. }
  379. f.viewport.SetContent(imageString)
  380. }()
  381. } else {
  382. f.viewport.SetContent("Preview unavailable")
  383. }
  384. }
  385. func readDir(path string, showHidden bool) []os.DirEntry {
  386. slog.Info(fmt.Sprintf("Reading directory: %s", path))
  387. entriesChan := make(chan []os.DirEntry, 1)
  388. errChan := make(chan error, 1)
  389. go func() {
  390. dirEntries, err := os.ReadDir(path)
  391. if err != nil {
  392. status.Error(err.Error())
  393. errChan <- err
  394. return
  395. }
  396. entriesChan <- dirEntries
  397. }()
  398. select {
  399. case dirEntries := <-entriesChan:
  400. sort.Slice(dirEntries, func(i, j int) bool {
  401. if dirEntries[i].IsDir() == dirEntries[j].IsDir() {
  402. return dirEntries[i].Name() < dirEntries[j].Name()
  403. }
  404. return dirEntries[i].IsDir()
  405. })
  406. if showHidden {
  407. return dirEntries
  408. }
  409. var sanitizedDirEntries []os.DirEntry
  410. for _, dirEntry := range dirEntries {
  411. isHidden, _ := IsHidden(dirEntry.Name())
  412. if !isHidden {
  413. if dirEntry.IsDir() || isExtSupported(dirEntry.Name()) {
  414. sanitizedDirEntries = append(sanitizedDirEntries, dirEntry)
  415. }
  416. }
  417. }
  418. return sanitizedDirEntries
  419. case <-errChan:
  420. status.Error(fmt.Sprintf("Error reading directory %s", path))
  421. return []os.DirEntry{}
  422. case <-time.After(5 * time.Second):
  423. status.Error(fmt.Sprintf("Timeout reading directory %s", path))
  424. return []os.DirEntry{}
  425. }
  426. }
  427. func IsHidden(file string) (bool, error) {
  428. return strings.HasPrefix(file, "."), nil
  429. }
  430. func isExtSupported(path string) bool {
  431. ext := strings.ToLower(filepath.Ext(path))
  432. return (ext == ".jpg" || ext == ".jpeg" || ext == ".webp" || ext == ".png")
  433. }