package fields import ( "bubble/setup/ui" "bubble/types" "fmt" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/sirupsen/logrus" "os" "path/filepath" "slices" "strings" "time" ) type FileSelectModel struct { input textinput.Model label string focus int open bool file string currentDirectory string options []os.FileInfo extensions []string optionsCount int } func NewFileSelectModel(label, file string, extensions []string) *FileSelectModel { inputModel := textinput.NewModel() inputModel.SetValue(file) return &FileSelectModel{ input: inputModel, label: label, extensions: extensions, currentDirectory: filepath.Dir(file), open: false, file: file, optionsCount: 3, } } func (m *FileSelectModel) Init() tea.Cmd { return textinput.Blink } 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 selected := types.SelectedFileMsg{ Filename: fileName, Index: m.focus, } m.input.CursorEnd() m.focus = 0 return m, func() tea.Msg { return selected } } } } else { 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: if !m.open { var cmd tea.Cmd m.input, cmd = m.input.Update(msg) cmds = append(cmds, cmd) } } return m, tea.Batch(cmds...) } func (m *FileSelectModel) View() string { res := fmt.Sprintf("%s: %s", m.label, m.input.View()) if m.open && len(m.options) > 0 { visibleCount := m.optionsCount //количество отображаемых if visibleCount > len(m.options) { //если количество отображаемых больше, чем есть опций на самом деле visibleCount = len(m.options) //то меняем количество видимых на количество опций } half := visibleCount / 2 //количество объектов сверху и снизу start := m.focus - half //стартовая позиция if start < 0 { //если стартовая позиция меньше 0, то меняем на 0 start = 0 } end := start + visibleCount //последний элемент if end > len(m.options) { //если последний элемент вне опций end = len(m.options) //меняем на количество опций start = end - visibleCount //перерасчет стартовой позиции if start < 0 { start = 0 } } 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() } func (m *FileSelectModel) CaptureKeys() bool { return m.open } func (m *FileSelectModel) GetLabel() string { return m.label } func (m *FileSelectModel) GetValue() interface{} { return m.file } 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: ".."}) //m.options = append(m.options, virtualDirInfo{name: ".."}) } for _, entry := range filesInDir { info, _ := entry.Info() allOptions = append(allOptions, info) //m.options = append(m.options, 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 }