GoSetup-bubble/setup/fields/fileSelect.go

266 lines
9.1 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"
"bubble/types"
"fmt"
"github.com/charmbracelet/bubbles/key"
"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 {
case key.Matches(msg, types.MainKeys.Close):
//Обработчик клавиши закрытия списка опций
m.open = false
case key.Matches(msg, types.MainKeys.Up):
//Обработчик клавиши перехода на прошлую опцию
m.focus--
if m.focus < 0 {
m.focus = len(m.options) - 1
}
case key.Matches(msg, types.MainKeys.Down):
//Обработчик клавиши перехода на следующую опцию
m.focus++
if m.focus == len(m.options) {
m.focus = 0
}
case key.Matches(msg, types.MainKeys.Confirm):
//Обработчик клавиши выбора опции. Если список пуст, ничего не делаем.
//Получаем имя файла.
//Если имя файла ".." возвращаемся в родительскую папку.
//Если файл является директорией, то переходим в эту директорию.
//Если у файла совпадают расширения, то записываем его и закрываем список.
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 {
//Если список не открыт и была нажата клавиша подтверждения, открываем его и рисуем опции списка.
//Иначе просто передаем сообщение нажатия клавиш в input.
switch {
case key.Matches(msg, types.MainKeys.Confirm):
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 }