275 lines
7.0 KiB
Go
275 lines
7.0 KiB
Go
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
|
|
}
|