filepicker.go 13 KB

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