GoSetup-bubble/setup/fields/fileSelect.go

264 lines
8.7 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 fields
import (
"bubble/setup/ui"
"bubble/setup/utils"
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/sirupsen/logrus"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
// FileSelectModel структура описывающая модель поля выбора файла
type FileSelectModel struct {
input textinput.Model //Готовый компонент input для ввода данных
label string //Имя поля
description string //Описание поля
focus int //Фокус для управления опциями
open bool //Открыт ли список
file string //Имя выбранного файла
currentDirectory string //Директория текущего файла
options []os.FileInfo //Элементы выбора файла
extensions []string //Допустимые расширения файла
optionsCount int //Количество элементов к показу
}
// NewFileSelectModel создаем новую модель поля выбора файла
func NewFileSelectModel(label, file, description string, extensions []string, validator textinput.ValidateFunc) *FileSelectModel {
inputModel := textinput.New()
inputModel.Validate = validator
inputModel.SetValue(file)
return &FileSelectModel{
input: inputModel,
label: label,
description: description,
extensions: extensions,
currentDirectory: filepath.Dir(file),
open: false,
file: file,
optionsCount: 10,
}
}
func (m *FileSelectModel) Init() tea.Cmd {
return textinput.Blink
}
// Update отслеживает действия с полем
func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
//Отслеживает нажатия клавиш.
//Если список открыт, то отслеживаем клавиши отдельно
if m.open {
switch msg.String() {
case "esc":
//Закрываем список
m.open = false
case "up":
//Уменьшаем фокус, поднимаем его.
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case "down":
//Увеличиваем фокус, опускаем его.
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case "enter":
//Выбираем файл из списка. Если список пуст ничего не делаем.
//Получаем имя файла.
//Если имя файла ".." возвращаемся в родительскую папку.
//Если файл является директорией, то переходим в эту директорию.
//Если у файла совпадают расширения, то записываем его и закрываем список.
if len(m.options) == 0 || m.focus >= len(m.options) {
return m, nil
}
file := m.options[m.focus]
fileName := file.Name()
if fileName == ".." {
m.currentDirectory = filepath.Dir(m.currentDirectory)
m.focus = 0
m.RenderOptions()
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.file = newValue
m.input.CursorEnd()
return m, nil
}
if file.IsDir() {
m.currentDirectory = filepath.Join(m.currentDirectory, fileName)
m.focus = 0
newValue := m.currentDirectory + string(filepath.Separator)
m.input.SetValue(newValue)
m.file = newValue
m.input.CursorEnd()
m.RenderOptions()
} else if slices.Contains(m.extensions, filepath.Ext(fileName)) {
fullPath := filepath.Join(m.currentDirectory, fileName)
m.file = fullPath
m.input.SetValue(fullPath)
m.open = false
m.input.CursorEnd()
m.focus = 0
return m, nil
}
}
} else {
//Если список не открыт и был нажат enter, открываем его и рисуем опции списка.
//Иначе просто передаем сообщение нажатия клавиш в input.
switch msg.String() {
case "enter":
m.open = true
m.RenderOptions()
return m, nil
default:
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
if m.input.Value() != m.file {
m.file = m.input.Value()
m.currentDirectory = filepath.Dir(m.file)
}
}
}
default:
//Все прочие виды сообщений передаем в input если список закрыт
if !m.open {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Sequence(cmds...)
}
func (m *FileSelectModel) View() string {
res := fmt.Sprintf("%s: %s", m.label, m.input.View())
if m.input.Err != nil && !m.open {
res += fmt.Sprintf("\nОшибка валидации: %s", m.input.Err)
} else if m.description != "" {
res += fmt.Sprintf("\n%s", m.description)
}
if m.open && len(m.options) > 0 {
start, end := utils.CalculateSelectOptionsIndex(m.focus, m.optionsCount, len(m.options))
for i := start; i < end; i++ {
file := m.options[i]
name := file.Name()
prefix := "\n\t"
if m.focus == i {
prefix += "-> "
} else {
prefix += " "
}
if name == ".." {
res += prefix + ui.SelectFileDirParentStyle.Render(name)
} else if file.IsDir() {
res += prefix + ui.SelectDirStyle.Render(name)
} else if !slices.Contains(m.extensions, filepath.Ext(name)) {
res += prefix + ui.SelectFileDirDeniedStyle.Render(name)
} else {
res += prefix + ui.SelectFileStyle.Render(name)
}
}
}
return res
}
func (m *FileSelectModel) Focus() {
m.input.Focus()
}
func (m *FileSelectModel) Blur() {
m.input.Blur()
}
// CaptureKeys возвращает открыт ли список опций
func (m *FileSelectModel) CaptureKeys() bool {
return m.open
}
func (m *FileSelectModel) GetLabel() string {
return m.label
}
func (m *FileSelectModel) GetValue() interface{} {
return m.file
}
// RenderOptions заполняем список опций на основе директорий
func (m *FileSelectModel) RenderOptions() {
//Читаем файлы в текущей директории
filesInDir, err := os.ReadDir(m.currentDirectory)
if err != nil {
logrus.Errorf("Ошибка чтения директории: %v", err)
return
}
m.options = nil
var allOptions []os.FileInfo
//Если это не корень файловой системы, добавляем переход на родительскую директорию
if m.currentDirectory != "/" && filepath.Clean(m.currentDirectory) != "/" {
allOptions = append(allOptions, virtualDirInfo{name: ".."})
}
//Заполняем прочие опции
for _, entry := range filesInDir {
info, _ := entry.Info()
allOptions = append(allOptions, info)
}
//Заполняем фильтр для опций на основе значения поля ввода
filter := ""
if m.open {
inputValue := strings.TrimSpace(m.input.Value())
if inputValue != "" {
lastPart := inputValue[strings.LastIndex(inputValue, "/")+1:]
if lastPart != "" {
filter = strings.ToLower(lastPart)
}
}
}
//Чистим опции и заполняем их правильными значениями
m.options = m.options[:0]
for _, opt := range allOptions {
name := opt.Name()
if filter == "" || strings.HasPrefix(strings.ToLower(name), filter) || name == ".." {
m.options = append(m.options, opt)
}
}
//если у нас нет опций, то и фокус нельзя куда-либо поставить, то есть надо быть осторожным,
//но после этой функции у нас задействуется view, где есть условие, отрисовка только если опций больше 0,
//так что у нас стоит защита на использование m.options[m.focus] при фокусе равном 0 и отсутствии опций.
if len(m.options) == 0 {
m.focus = 0
return
}
if m.focus >= len(m.options) {
m.focus = len(m.options) - 1
}
if m.focus < 0 {
m.focus = 0
}
}
type virtualDirInfo struct {
name string
}
func (v virtualDirInfo) Name() string { return v.name }
func (v virtualDirInfo) Size() int64 {
return 0
}
func (v virtualDirInfo) Mode() os.FileMode { return os.ModeDir }
func (v virtualDirInfo) ModTime() time.Time { return time.Time{} }
func (v virtualDirInfo) IsDir() bool { return true }
func (v virtualDirInfo) Sys() interface{} { return nil }