package filetree import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" ) // Styles (will be set from parent) var ( DirectoryStyle = lipgloss.NewStyle() FileStyle = lipgloss.NewStyle() SelectedStyle = lipgloss.NewStyle() ) // View renders the file tree func (m Model) View(width, height int, focused bool) string { var b strings.Builder // Calculate visible range for scrolling visibleHeight := height - 2 // Account for potential padding if visibleHeight < 1 { visibleHeight = 1 } startIdx := 0 if m.Cursor >= visibleHeight { startIdx = m.Cursor - visibleHeight + 1 } endIdx := startIdx + visibleHeight if endIdx > len(m.Nodes) { endIdx = len(m.Nodes) } for i := startIdx; i < endIdx; i++ { node := m.Nodes[i] line := m.renderNode(node, i == m.Cursor, focused) // Truncate line if too long if lipgloss.Width(line) > width-2 { line = truncateString(line, width-2) } // Pad to width padding := width - 2 - lipgloss.Width(line) if padding > 0 { line += strings.Repeat(" ", padding) } b.WriteString(line) if i < endIdx-1 { b.WriteString("\n") } } return b.String() } func (m Model) renderNode(node *Node, selected, focused bool) string { // Build the prefix with tree characters prefix := m.buildPrefix(node) // Get icon icon := getIcon(node) // Format name name := node.Name if node.IsDir { name += "/" } line := fmt.Sprintf("%s%s %s", prefix, icon, name) // Apply style if selected && focused { return SelectedStyle.Render(line) } else if selected { return lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Render(line) } else if node.IsDir { return DirectoryStyle.Render(line) } return FileStyle.Render(line) } func (m Model) buildPrefix(node *Node) string { if node.Level == 0 { return "" } // Build prefix by traversing parents prefixParts := make([]string, node.Level) current := node for i := node.Level - 1; i >= 0; i-- { parent := current.Parent if parent == nil { prefixParts[i] = " " continue } isLast := true for j, sibling := range parent.Children { if sibling == current { isLast = j == len(parent.Children)-1 break } } if i == node.Level-1 { if isLast { prefixParts[i] = "└─" } else { prefixParts[i] = "├─" } } else { if isLast { prefixParts[i] = " " } else { prefixParts[i] = "│ " } } current = parent } return strings.Join(prefixParts, "") } func getIcon(node *Node) string { if node.IsDir { if node.IsOpen { return "▼" } return "▶" } // Minimal icons using Unicode block/geometric characters // Grouped by file type category ext := node.Ext() // Documents docs := map[string]bool{".md": true, ".markdown": true, ".txt": true, ".pdf": true, ".doc": true, ".docx": true} if docs[ext] { return "◔" } // Code files code := map[string]bool{ ".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true, ".rs": true, ".c": true, ".cpp": true, ".h": true, ".hpp": true, ".java": true, ".rb": true, ".php": true, ".swift": true, ".kt": true, ".scala": true, ".ex": true, ".exs": true, ".clj": true, ".zig": true, ".nim": true, ".lua": true, ".vim": true, ".el": true, } if code[ext] { return "◇" } // Shell/scripts shell := map[string]bool{".sh": true, ".bash": true, ".zsh": true, ".fish": true} if shell[ext] { return "▷" } // Data/config data := map[string]bool{ ".json": true, ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".csv": true, ".sql": true, ".env": true, } if data[ext] { return "◫" } // Web web := map[string]bool{".html": true, ".css": true, ".scss": true, ".sass": true, ".less": true} if web[ext] { return "◈" } // Images images := map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true} if images[ext] { return "◐" } // Default return "○" } func truncateString(s string, maxWidth int) string { if lipgloss.Width(s) <= maxWidth { return s } // Simple truncation - could be improved for unicode runes := []rune(s) for i := len(runes) - 1; i >= 0; i-- { truncated := string(runes[:i]) + "…" if lipgloss.Width(truncated) <= maxWidth { return truncated } } return "…" }