1package tui
2
3import (
4 "fmt"
5 "io/fs"
6 "os"
7 "path/filepath"
8 "strings"
9
10 "charm.land/bubbles/v2/textinput"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13)
14
15var (
16 filePickerItemStyle = lipgloss.NewStyle().PaddingLeft(2)
17 filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
18 directoryStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
19 fileSizeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
20)
21
22type FilePicker struct {
23 cursor int
24 currentPath string
25 items []fs.DirEntry
26 width int
27 height int
28 showHidden bool
29 pathInput textinput.Model
30 editingPath bool
31}
32
33func NewFilePicker(startPath string) *FilePicker {
34 pi := textinput.New()
35 pi.Placeholder = "Type a path and press Enter..."
36 pi.Prompt = "Go to: "
37 pi.CharLimit = 512
38 pi.SetStyles(ThemedTextInputStyles())
39
40 fp := &FilePicker{
41 currentPath: startPath,
42 pathInput: pi,
43 }
44 fp.readDir()
45 return fp
46}
47
48func (m *FilePicker) readDir() {
49 files, err := os.ReadDir(m.currentPath)
50 if err != nil {
51 m.items = []fs.DirEntry{}
52 return
53 }
54 if !m.showHidden {
55 filtered := files[:0]
56 for _, f := range files {
57 if !strings.HasPrefix(f.Name(), ".") {
58 filtered = append(filtered, f)
59 }
60 }
61 files = filtered
62 }
63 m.items = files
64 m.cursor = 0
65}
66
67func (m *FilePicker) Init() tea.Cmd {
68 return nil
69}
70
71func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
72 switch msg := msg.(type) {
73 case tea.WindowSizeMsg:
74 m.width = msg.Width
75 m.height = msg.Height
76
77 case tea.KeyPressMsg:
78 // Path input mode
79 if m.editingPath {
80 switch msg.String() {
81 case "enter":
82 path := m.pathInput.Value()
83 if path == "" {
84 m.editingPath = false
85 m.pathInput.Blur()
86 return m, nil
87 }
88 // Expand ~ to home dir
89 if strings.HasPrefix(path, "~") {
90 if home, err := os.UserHomeDir(); err == nil {
91 path = filepath.Join(home, path[1:])
92 }
93 }
94 info, err := os.Stat(path)
95 if err == nil {
96 if info.IsDir() {
97 m.currentPath = path
98 m.readDir()
99 } else {
100 // It's a file — navigate to its parent and select it
101 m.currentPath = filepath.Dir(path)
102 m.readDir()
103 }
104 }
105 m.editingPath = false
106 m.pathInput.Blur()
107 m.pathInput.SetValue("")
108 return m, nil
109 case "esc":
110 m.editingPath = false
111 m.pathInput.Blur()
112 m.pathInput.SetValue("")
113 return m, nil
114 }
115 var cmd tea.Cmd
116 m.pathInput, cmd = m.pathInput.Update(msg)
117 return m, cmd
118 }
119
120 // Normal browsing mode
121 switch msg.String() {
122 case "up", "k":
123 if m.cursor > 0 {
124 m.cursor--
125 }
126 case "down", "j":
127 if m.cursor < len(m.items)-1 {
128 m.cursor++
129 }
130 case "/":
131 m.editingPath = true
132 m.pathInput.Focus()
133 return m, nil
134 case "~":
135 if home, err := os.UserHomeDir(); err == nil {
136 m.currentPath = home
137 m.readDir()
138 }
139 case "h":
140 m.showHidden = !m.showHidden
141 m.readDir()
142 case "enter":
143 if len(m.items) == 0 {
144 return m, nil
145 }
146 selectedItem := m.items[m.cursor]
147 newPath := filepath.Join(m.currentPath, selectedItem.Name())
148
149 if selectedItem.IsDir() {
150 m.currentPath = newPath
151 m.readDir()
152 } else {
153 return m, func() tea.Msg {
154 return FileSelectedMsg{Paths: []string{newPath}}
155 }
156 }
157 case "backspace":
158 parentDir := filepath.Dir(m.currentPath)
159 if parentDir != m.currentPath {
160 m.currentPath = parentDir
161 m.readDir()
162 }
163 case "esc", "q":
164 return m, func() tea.Msg { return CancelFilePickerMsg{} }
165 }
166 }
167 return m, nil
168}
169
170func formatFileSize(size int64) string {
171 switch {
172 case size < 1024:
173 return fmt.Sprintf("%dB", size)
174 case size < 1024*1024:
175 return fmt.Sprintf("%.1fK", float64(size)/1024)
176 case size < 1024*1024*1024:
177 return fmt.Sprintf("%.1fM", float64(size)/(1024*1024))
178 default:
179 return fmt.Sprintf("%.1fG", float64(size)/(1024*1024*1024))
180 }
181}
182
183func (m *FilePicker) View() tea.View {
184 var b strings.Builder
185
186 b.WriteString(titleStyle.Render("Select a File") + "\n")
187 b.WriteString(fmt.Sprintf(" %s\n", m.currentPath))
188
189 if m.editingPath {
190 b.WriteString(m.pathInput.View() + "\n")
191 }
192
193 b.WriteString("\n")
194
195 // Calculate how many items we can show (reserve lines for header + help)
196 headerLines := 3
197 if m.editingPath {
198 headerLines++
199 }
200 helpLines := 2
201 visibleItems := m.height - headerLines - helpLines
202 if visibleItems < 3 {
203 visibleItems = 3
204 }
205
206 // Calculate scroll window
207 start := 0
208 if m.cursor >= visibleItems {
209 start = m.cursor - visibleItems + 1
210 }
211 end := start + visibleItems
212 if end > len(m.items) {
213 end = len(m.items)
214 }
215
216 for i := start; i < end; i++ {
217 item := m.items[i]
218 cursor := " "
219 if m.cursor == i {
220 cursor = "> "
221 }
222
223 itemName := item.Name()
224 sizeStr := ""
225 if item.IsDir() {
226 itemName = directoryStyle.Render(itemName + "/")
227 } else {
228 if info, err := item.Info(); err == nil {
229 sizeStr = fileSizeStyle.Render(" " + formatFileSize(info.Size()))
230 }
231 }
232
233 line := fmt.Sprintf("%s%s%s", cursor, itemName, sizeStr)
234
235 if m.cursor == i {
236 b.WriteString(filePickerSelectedItemStyle.Render(line))
237 } else {
238 b.WriteString(filePickerItemStyle.Render(line))
239 }
240 b.WriteString("\n")
241 }
242
243 if len(m.items) == 0 {
244 b.WriteString(fileSizeStyle.Render(" (empty directory)") + "\n")
245 }
246
247 hiddenLabel := "show"
248 if m.showHidden {
249 hiddenLabel = "hide"
250 }
251 b.WriteString("\n" + helpStyle.Render(fmt.Sprintf("↑/↓: navigate • enter: select • backspace: up • /: go to path • ~: home • h: %s hidden • esc: cancel", hiddenLabel)))
252
253 return tea.NewView(docStyle.Render(b.String()))
254}