grimoir/filetree.go
2026-05-07 09:52:06 +03:00

307 lines
8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"os"
"path/filepath"
"sort"
"strings"
"github.com/charmbracelet/lipgloss"
)
// === Стили файлового дерева ===
var (
styleTreeDir = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true)
styleTreeFile = lipgloss.NewStyle().Foreground(lipgloss.Color("252"))
styleTreeSelected = lipgloss.NewStyle().Background(lipgloss.Color("237")).Foreground(lipgloss.Color("15")).Bold(true)
styleTreeHeader = lipgloss.NewStyle().Background(lipgloss.Color("8")).Foreground(lipgloss.Color("15")).Bold(true)
styleTreeCurrent = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
)
// FileNode — узел файлового дерева
type FileNode struct {
name string
path string
isDir bool
expanded bool
depth int
children []*FileNode
}
// FileTree — состояние панели файлового дерева
type FileTree struct {
root string // корневая директория
nodes []*FileNode // плоский список видимых узлов
cursor int // индекс выбранного узла
scroll int // смещение прокрутки
focused bool // фокус на этой панели
}
// NewFileTree создаёт новое дерево для директории rootDir
func NewFileTree(rootDir string) FileTree {
ft := FileTree{
root: rootDir,
cursor: 0,
scroll: 0,
focused: false,
}
ft.reload()
return ft
}
// reload перечитывает директорию и обновляет плоский список
func (ft *FileTree) reload() {
rootNode := &FileNode{
name: filepath.Base(ft.root),
path: ft.root,
isDir: true,
expanded: true,
depth: 0,
}
loadChildren(rootNode)
ft.nodes = flattenTree(rootNode)
}
// loadChildren рекурсивно загружает дочерние узлы
func loadChildren(node *FileNode) {
if !node.isDir {
return
}
entries, err := os.ReadDir(node.path)
if err != nil {
return
}
// Сортируем: сначала папки, потом файлы, алфавитно
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir() != entries[j].IsDir() {
return entries[i].IsDir()
}
return entries[i].Name() < entries[j].Name()
})
node.children = nil
for _, e := range entries {
// Пропускаем скрытые файлы и типичный мусор
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
if name == "node_modules" || name == "vendor" || name == "__pycache__" {
continue
}
child := &FileNode{
name: name,
path: filepath.Join(node.path, name),
isDir: e.IsDir(),
depth: node.depth + 1,
}
node.children = append(node.children, child)
}
}
// flattenTree возвращает плоский список видимых узлов
func flattenTree(node *FileNode) []*FileNode {
result := []*FileNode{node}
if node.isDir && node.expanded {
for _, child := range node.children {
result = append(result, flattenTree(child)...)
}
}
return result
}
// toggle разворачивает/сворачивает директорию под курсором
func (ft *FileTree) toggle() {
if ft.cursor >= len(ft.nodes) {
return
}
node := ft.nodes[ft.cursor]
if !node.isDir {
return
}
node.expanded = !node.expanded
if node.expanded && len(node.children) == 0 {
loadChildren(node)
}
ft.rebuildFlat()
}
// rebuildFlat пересобирает плоский список из корня
func (ft *FileTree) rebuildFlat() {
if len(ft.nodes) == 0 {
return
}
root := ft.nodes[0]
ft.nodes = flattenTree(root)
if ft.cursor >= len(ft.nodes) {
ft.cursor = len(ft.nodes) - 1
}
}
// moveUp перемещает курсор вверх
func (ft *FileTree) moveUp(n int) {
ft.cursor -= n
if ft.cursor < 0 {
ft.cursor = 0
}
ft.adjustScroll(0)
}
// moveDown перемещает курсор вниз
func (ft *FileTree) moveDown(n int) {
ft.cursor += n
if ft.cursor >= len(ft.nodes) {
ft.cursor = len(ft.nodes) - 1
}
ft.adjustScroll(0)
}
// adjustScroll корректирует scroll чтобы cursor был виден
func (ft *FileTree) adjustScroll(height int) {
if height <= 0 {
return
}
if ft.cursor < ft.scroll {
ft.scroll = ft.cursor
}
if ft.cursor >= ft.scroll+height {
ft.scroll = ft.cursor - height + 1
}
}
// selectedPath возвращает путь выбранного узла (или "" если курсор вне границ)
func (ft *FileTree) selectedPath() string {
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
return ""
}
return ft.nodes[ft.cursor].path
}
// selectedIsDir возвращает true если выбранный узел — директория
func (ft *FileTree) selectedIsDir() bool {
if ft.cursor < 0 || ft.cursor >= len(ft.nodes) {
return false
}
return ft.nodes[ft.cursor].isDir
}
// renderFileTree рендерит панель файлового дерева
func renderFileTree(m *EditorModel) string {
ft := &m.fileTree
width := m.treeWidth
height := m.height - 3
var output strings.Builder
// Заголовок
title := filepath.Base(ft.root)
if len(title) > width-4 {
title = title[:width-4]
}
headerStyle := styleTreeHeader.Copy().Width(width).Padding(0, 1)
if ft.focused {
headerStyle = headerStyle.Background(lipgloss.Color("4"))
}
output.WriteString(headerStyle.Render(" 📁 " + title))
output.WriteString("\n")
ft.adjustScroll(height)
// Список файлов
for i := ft.scroll; i < ft.scroll+height && i < len(ft.nodes); i++ {
node := ft.nodes[i]
line := renderFileNode(node, m.filepath, width)
if i == ft.cursor && ft.focused {
line = styleTreeSelected.Width(width).Render(line)
} else if i == ft.cursor {
// Курсор без фокуса — подчёркиваем текущий элемент
line = lipgloss.NewStyle().Width(width).Foreground(lipgloss.Color("245")).Render(line)
} else {
line = lipgloss.NewStyle().Width(width).Render(line)
}
output.WriteString(line)
output.WriteString("\n")
}
// Заполнить пустые строки
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
for i := rendered; i < height; i++ {
output.WriteString(strings.Repeat(" ", width) + "\n")
}
// Нижняя строка с подсказкой
hint := " Enter:открыть Tab:фокус </>:ширина"
if len(hint) > width {
hint = hint[:width]
}
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
output.WriteString(hintStyle.Render(hint))
return output.String()
}
// renderFileNode рендерит одну строку узла
func renderFileNode(node *FileNode, currentFile string, width int) string {
// Отступ по глубине (корень не отступаем)
indent := ""
if node.depth > 0 {
indent = strings.Repeat(" ", node.depth-1)
}
var icon, name string
if node.isDir {
if node.expanded {
icon = "▾ "
} else {
icon = "▸ "
}
name = styleTreeDir.Render(node.name)
} else {
icon = " "
// Выделяем текущий открытый файл
if node.path == currentFile {
name = styleTreeCurrent.Render("● " + node.name)
} else {
name = styleTreeFile.Render(node.name)
}
}
line := indent + icon + name
// Обрезаем если не влезает (с учётом ANSI-кодов это приблизительно)
visibleLen := len(indent) + len(icon) + len(node.name) + 2
if visibleLen > width {
// Обрезаем имя
maxName := width - len(indent) - len(icon) - 1
if maxName < 3 {
maxName = 3
}
truncated := node.name
if len(truncated) > maxName {
truncated = truncated[:maxName-1] + "…"
}
if node.isDir {
name = styleTreeDir.Render(truncated)
} else if node.path == currentFile {
name = styleTreeCurrent.Render("● " + truncated)
} else {
name = styleTreeFile.Render(truncated)
}
line = indent + icon + name
}
return line
}
func min(a, b int) int {
if a < b {
return a
}
return b
}