filepicker.go

  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{Path: 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}