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
 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}