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 itemSizes map[string]string
27 width int
28 height int
29 showHidden bool
30 pathInput textinput.Model
31 editingPath bool
32}
33
34func NewFilePicker(startPath string) *FilePicker {
35 pi := textinput.New()
36 pi.Placeholder = "Type a path and press Enter..."
37 pi.Prompt = "Go to: "
38 pi.CharLimit = 512
39 pi.SetStyles(ThemedTextInputStyles())
40
41 fp := &FilePicker{
42 currentPath: startPath,
43 itemSizes: make(map[string]string),
44 pathInput: pi,
45 }
46 fp.readDir()
47 return fp
48}
49
50func (m *FilePicker) readDir() {
51 files, err := os.ReadDir(m.currentPath)
52 if err != nil {
53 m.items = []fs.DirEntry{}
54 m.itemSizes = make(map[string]string)
55 return
56 }
57 if !m.showHidden {
58 filtered := files[:0]
59 for _, f := range files {
60 if !strings.HasPrefix(f.Name(), ".") {
61 filtered = append(filtered, f)
62 }
63 }
64 files = filtered
65 }
66 m.items = files
67 m.itemSizes = make(map[string]string, len(files))
68 for _, f := range files {
69 if f.IsDir() {
70 continue
71 }
72 if info, err := f.Info(); err == nil {
73 m.itemSizes[filepath.Join(m.currentPath, f.Name())] = tfs(info.Size())
74 }
75 }
76 m.cursor = 0
77}
78
79func (m *FilePicker) Init() tea.Cmd {
80 return nil
81}
82
83func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
84 switch msg := msg.(type) {
85 case tea.WindowSizeMsg:
86 m.width = msg.Width
87 m.height = msg.Height
88
89 case tea.KeyPressMsg:
90 // Path input mode
91 if m.editingPath {
92 switch msg.String() {
93 case keyEnter:
94 path := m.pathInput.Value()
95 if path == "" {
96 m.editingPath = false
97 m.pathInput.Blur()
98 return m, nil
99 }
100 // Expand ~ to home dir
101 if strings.HasPrefix(path, "~") {
102 if home, err := os.UserHomeDir(); err == nil {
103 path = filepath.Join(home, path[1:])
104 }
105 }
106 info, err := os.Stat(path)
107 if err == nil {
108 if info.IsDir() {
109 m.currentPath = path
110 m.readDir()
111 } else {
112 // It's a file — navigate to its parent and select it
113 m.currentPath = filepath.Dir(path)
114 m.readDir()
115 }
116 }
117 m.editingPath = false
118 m.pathInput.Blur()
119 m.pathInput.SetValue("")
120 return m, nil
121 case "esc":
122 m.editingPath = false
123 m.pathInput.Blur()
124 m.pathInput.SetValue("")
125 return m, nil
126 }
127 var cmd tea.Cmd
128 m.pathInput, cmd = m.pathInput.Update(msg)
129 return m, cmd
130 }
131
132 // Normal browsing mode
133 switch msg.String() {
134 case "up", "k":
135 if len(m.items) > 0 {
136 m.cursor = (m.cursor - 1 + len(m.items)) % len(m.items)
137 }
138 case keyDown, "j":
139 if len(m.items) > 0 {
140 m.cursor = (m.cursor + 1) % len(m.items)
141 }
142 case "/":
143 m.editingPath = true
144 m.pathInput.Focus()
145 return m, nil
146 case "~":
147 if home, err := os.UserHomeDir(); err == nil {
148 m.currentPath = home
149 m.readDir()
150 }
151 case "h":
152 m.showHidden = !m.showHidden
153 m.readDir()
154 case keyEnter:
155 if len(m.items) == 0 {
156 return m, nil
157 }
158 selectedItem := m.items[m.cursor]
159 newPath := filepath.Join(m.currentPath, selectedItem.Name())
160
161 if selectedItem.IsDir() {
162 m.currentPath = newPath
163 m.readDir()
164 } else {
165 return m, func() tea.Msg {
166 return FileSelectedMsg{Paths: []string{newPath}}
167 }
168 }
169 case "backspace":
170 parentDir := filepath.Dir(m.currentPath)
171 if parentDir != m.currentPath {
172 m.currentPath = parentDir
173 m.readDir()
174 }
175 case "esc", "q":
176 return m, func() tea.Msg { return CancelFilePickerMsg{} }
177 }
178 }
179 return m, nil
180}
181
182func (m *FilePicker) View() tea.View {
183 var b strings.Builder
184
185 b.WriteString(titleStyle.Render("Select a File") + "\n")
186 fmt.Fprintf(&b, " %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 size, ok := m.itemSizes[filepath.Join(m.currentPath, item.Name())]; ok {
228 sizeStr = fileSizeStyle.Render(" " + 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}