307 lines
8 KiB
Go
307 lines
8 KiB
Go
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
|
||
}
|