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