diff --git a/pkg/tui/bubbles/filepicker/model.go b/pkg/tui/bubbles/filepicker/model.go new file mode 100644 index 0000000..324ae8e --- /dev/null +++ b/pkg/tui/bubbles/filepicker/model.go @@ -0,0 +1,274 @@ +package filepicker + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type Node struct { + path string + name string + isDir bool + expanded bool + children []*Node +} + +type Model struct { + root *Node + cursor int + showHidden bool + displayLines []*Node // Flattened view of visible nodes + rootPath string + + SelectedPaths []string +} + +func New(path string) Model { + root := &Node{ + path: path, + name: filepath.Base(path), + isDir: true, + expanded: true, + } + + m := Model{ + root: root, + rootPath: path, + SelectedPaths: make([]string, 0), + showHidden: false, + } + + m.loadDirectory(root) + m.updateDisplayLines() + return m +} + +func (m *Model) loadDirectory(node *Node) error { + entries, err := os.ReadDir(node.path) + if err != nil { + return err + } + + node.children = make([]*Node, 0) + for _, entry := range entries { + if !m.showHidden && strings.HasPrefix(entry.Name(), ".") { + continue + } + + child := &Node{ + path: filepath.Join(node.path, entry.Name()), + name: entry.Name(), + isDir: entry.IsDir(), + expanded: false, + } + node.children = append(node.children, child) + } + return nil +} + +func (m *Model) updateDisplayLines() { + m.displayLines = make([]*Node, 0) + m.flattenTree(m.root, 0) +} + +func (m *Model) flattenTree(node *Node, depth int) { + m.displayLines = append(m.displayLines, node) + if node.isDir && node.expanded { + for _, child := range node.children { + m.flattenTree(child, depth+1) + } + } +} + +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "j": + if m.cursor < len(m.displayLines)-1 { + m.cursor++ + } + case "k": + if m.cursor > 0 { + m.cursor-- + } + case "l": + current := m.displayLines[m.cursor] + if current.isDir { + current.expanded = true + m.loadDirectory(current) + m.updateDisplayLines() + } + case "h": + current := m.displayLines[m.cursor] + if current.isDir && current.expanded { + current.expanded = false + m.updateDisplayLines() + } else { + // Find and collapse parent + parent := m.findParent(current) + if parent != nil && parent.path != m.rootPath { + parent.expanded = false + m.updateDisplayLines() + // Move cursor to parent + for i, node := range m.displayLines { + if node == parent { + m.cursor = i + break + } + } + } + } + case " ": + current := m.displayLines[m.cursor] + if m.isSelected(current.path) { + m.SelectedPaths = removeString(m.SelectedPaths, current.path) + } else { + m.SelectedPaths = append(m.SelectedPaths, current.path) + } + case "H": + m.showHidden = !m.showHidden + m.reloadTree() + } + } + return m, nil +} + +func (m Model) Init() tea.Cmd { + return nil +} + +func (m Model) View() string { + var sb strings.Builder + + // Define styles + dirStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Blue for directories + fileStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("7")) // White for files + cursorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("yellow")) + selectedStyle := lipgloss.NewStyle().Bold(true) + + // Track the parent-child relationships for proper indentation + levelIsLast := make(map[int]bool) + + for i, node := range m.displayLines { + // Calculate depth and prefix + depth := 0 + if node != m.root { + depth = strings.Count(node.path[len(m.rootPath):], string(os.PathSeparator)) + } + + // Build the prefix (tree structure) + var prefix strings.Builder + + if node != m.root { + // For each level up to current depth, add the appropriate prefix + for d := 0; d < depth; d++ { + if levelIsLast[d] { + prefix.WriteString(" ") + } else { + prefix.WriteString("│ ") + } + } + + // Find if this node is the last among its siblings + parent := m.findParent(node) + if parent != nil { + isLast := parent.children[len(parent.children)-1] == node + levelIsLast[depth] = isLast + + if isLast { + prefix.WriteString("└── ") + } else { + prefix.WriteString("├── ") + } + } + } + + // Cursor indicator + cursor := " " + if i == m.cursor { + cursor = cursorStyle.Render(">") + } + + // Node name with optional caret for directories + var nodeName string + if node.isDir { + caret := "▶ " + if node.expanded { + caret = "▼ " + } + nodeName = caret + node.name + if node != m.root { + nodeName += "/" + } + nodeName = dirStyle.Render(nodeName) + } else { + nodeName = fileStyle.Render(node.name) + } + + // Apply bold style if selected + if m.isSelected(node.path) { + nodeName = selectedStyle.Render(nodeName) + } + + // Build the line + line := fmt.Sprintf("%s %s%s\n", + cursor, + prefix.String(), + nodeName) + + sb.WriteString(line) + } + + return sb.String() +} + +func (m *Model) reloadTree() { + m.root.children = nil + m.loadDirectory(m.root) + m.updateDisplayLines() +} + +func (m *Model) findParent(node *Node) *Node { + var findParentRec func(*Node) *Node + + findParentRec = func(current *Node) *Node { + if current == nil { + return nil + } + for _, child := range current.children { + if child == node { + return current + } + if result := findParentRec(child); result != nil { + return result + } + } + return nil + } + + return findParentRec(m.root) +} + +func (m Model) isSelected(path string) bool { + for _, selected := range m.SelectedPaths { + if selected == path { + return true + } + } + return false +} + +func removeString(slice []string, s string) []string { + for i, v := range slice { + if v == s { + return append(slice[:i], slice[i+1:]...) + } + } + return slice +}