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