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 }