package tree import ( "fmt" "path/filepath" "sort" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/lipgloss" ) // FileTree holds the state of the entire file graph. type FileTree struct { Root *Node } // Node represents a file or directory in the tree. type Node struct { Name string FullPath string IsDir bool Children map[string]*Node Expanded bool Depth int } // TreeItem represents a file or folder for the Bubble Tea list. type TreeItem struct { Name string FullPath string IsDir bool Depth int Expanded bool Icon string Flat bool } // Implement list.Item interface func (i TreeItem) FilterValue() string { return i.Name } func (i TreeItem) Description() string { return "" } func (i TreeItem) Title() string { indent := strings.Repeat(" ", i.Depth) disclosure := " " if i.IsDir { if i.Expanded { disclosure = "▾" } else { disclosure = "▸" } } // Icon spacing handled in formatting return fmt.Sprintf("%s%s %s %s", indent, disclosure, i.Icon, i.Name) } // New creates a new FileTree from a list of changed file paths. func New(paths []string) *FileTree { root := &Node{ Name: "root", IsDir: true, Children: make(map[string]*Node), Expanded: true, // Root always expanded Depth: -1, // Root is hidden } for _, path := range paths { addPath(root, path) } return &FileTree{Root: root} } // addPath inserts a path into the tree, creating directory nodes as needed. func addPath(root *Node, path string) { cleanPath := filepath.ToSlash(filepath.Clean(path)) parts := strings.Split(cleanPath, "/") current := root for i, name := range parts { if _, exists := current.Children[name]; !exists { isFile := i == len(parts)-1 nodePath := name if current.FullPath != "" { nodePath = current.FullPath + "/" + name } // Directories default to expanded for visibility, or collapsed if preferred // GitHub usually auto-expands to show changed files. Here we auto-expand. current.Children[name] = &Node{ Name: name, FullPath: nodePath, IsDir: !isFile, Children: make(map[string]*Node), Expanded: true, Depth: current.Depth + 1, } } current = current.Children[name] } } // Items returns the flattened, visible list items based on expansion state. func (t *FileTree) Items() []list.Item { var items []list.Item flatten(t.Root, &items) return items } // FlatItems returns all leaf (file) nodes as a flat list with full paths. func (t *FileTree) FlatItems() []list.Item { var items []list.Item collectLeaves(t.Root, &items) return items } // collectLeaves recursively collects all file (non-directory) nodes. func collectLeaves(node *Node, items *[]list.Item) { children := make([]*Node, 0, len(node.Children)) for _, child := range node.Children { children = append(children, child) } sort.Slice(children, func(i, j int) bool { return strings.ToLower(children[i].FullPath) < strings.ToLower(children[j].FullPath) }) for _, child := range children { if child.IsDir { collectLeaves(child, items) } else { *items = append(*items, TreeItem{ Name: child.Name, FullPath: child.FullPath, IsDir: false, Depth: 0, Icon: getIcon(child.Name, false), Flat: true, }) } } } // flatten recursively builds the list, respecting expansion state. func flatten(node *Node, items *[]list.Item) { // Collect children to sort children := make([]*Node, 0, len(node.Children)) for _, child := range node.Children { children = append(children, child) } // Sort: Directories first, then alphabetical sort.Slice(children, func(i, j int) bool { if children[i].IsDir != children[j].IsDir { return children[i].IsDir } return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name) }) for _, child := range children { *items = append(*items, TreeItem{ Name: child.Name, FullPath: child.FullPath, IsDir: child.IsDir, Depth: child.Depth, Expanded: child.Expanded, Icon: getIcon(child.Name, child.IsDir), }) // Only traverse children if expanded if child.IsDir && child.Expanded { flatten(child, items) } } } // ToggleExpand toggles the expansion state of a specific node. func (t *FileTree) ToggleExpand(fullPath string) { node := findNode(t.Root, fullPath) if node != nil && node.IsDir { node.Expanded = !node.Expanded } } func findNode(node *Node, fullPath string) *Node { if node.FullPath == fullPath { return node } // Simple traversal. For very large trees, a map cache in FileTree might be faster. for _, child := range node.Children { if strings.HasPrefix(fullPath, child.FullPath) { if child.FullPath == fullPath { return child } if found := findNode(child, fullPath); found != nil { return found } } } return nil } func getIcon(name string, isDir bool) string { if isDir { return "" } ext := strings.ToLower(filepath.Ext(name)) switch ext { case ".go": return "" case ".js", ".ts", ".tsx": return "" case ".css", ".scss": return "" case ".html": return "" case ".json", ".yaml", ".yml", ".toml": return "" case ".md": return "" case ".png", ".jpg", ".jpeg", ".svg": return "" case ".gitignore", ".gitmodules": return "" default: return "" } } // ShortenPath truncates a path from the left to fit within maxWidth display columns. // "internal/tree/tree.go" → "…nal/tree/tree.go" func ShortenPath(path string, maxWidth int) string { if lipgloss.Width(path) <= maxWidth { return path } ellipsis := "…" avail := maxWidth - lipgloss.Width(ellipsis) if avail <= 0 { return ellipsis } // Trim runes from the left until the remainder fits runes := []rune(path) for len(runes) > 0 && lipgloss.Width(string(runes)) > avail { runes = runes[1:] } return ellipsis + string(runes) }