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.Render(line) } else if i == ft.cursor { line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line) } // plain строки — без Width() рендера, joinColumns дополнит пробелами output.WriteString(line) output.WriteString("\n") } // Заполнить пустые строки rendered := min(height, max(0, len(ft.nodes)-ft.scroll)) for i := rendered; i < height; i++ { output.WriteString("\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 }