first commit
This commit is contained in:
commit
5203032b83
25 changed files with 2825 additions and 0 deletions
336
setup/condition.go
Normal file
336
setup/condition.go
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"bubble/types"
|
||||
"fmt"
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/sirupsen/logrus"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000:
|
||||
var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`)
|
||||
|
||||
// ParamsForCondition — собирает все текущие значения переменных приложения
|
||||
// для использования в expr-условиях (condition)
|
||||
func ParamsForCondition(app *types.PackageApplication) map[string]interface{} {
|
||||
params := make(map[string]interface{})
|
||||
|
||||
for _, variable := range app.Variables {
|
||||
value := variable.Value
|
||||
if value == nil {
|
||||
value = variable.Default
|
||||
}
|
||||
|
||||
// Приводим к нужному типу, как в твоём старом коде
|
||||
switch variable.Type {
|
||||
case "bool":
|
||||
if s, ok := value.(string); ok {
|
||||
value = strings.ToLower(s) == "true"
|
||||
}
|
||||
case "number", "int", "float":
|
||||
if s, ok := value.(string); ok {
|
||||
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
value = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params[variable.Name] = value
|
||||
}
|
||||
|
||||
// Также добавляем значения из полей секций (на случай, если переменная используется только в поле)
|
||||
for _, section := range app.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Variable == nil || !field.Active {
|
||||
continue
|
||||
}
|
||||
value := field.Variable.Value
|
||||
if value == nil {
|
||||
value = field.Variable.Default
|
||||
}
|
||||
|
||||
switch field.Variable.Type {
|
||||
case "bool":
|
||||
if s, ok := value.(string); ok {
|
||||
value = strings.ToLower(s) == "true"
|
||||
}
|
||||
case "number", "int", "float":
|
||||
if s, ok := value.(string); ok {
|
||||
if n, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
value = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params[field.VariableName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// CheckCondition — проверяет условие visibility (condition) для секции или поля
|
||||
func CheckCondition(condition string, app *types.PackageApplication) bool {
|
||||
if condition == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
result, err := expr.Eval(condition, ParamsForCondition(app))
|
||||
|
||||
if err != nil {
|
||||
logrus.Errorf("Ошибка в условии '%s': %v", condition, err)
|
||||
return false
|
||||
}
|
||||
|
||||
boolResult, ok := result.(bool)
|
||||
if !ok {
|
||||
logrus.Warnf("Условие '%s' не вернуло bool, а %T: %v", condition, result, result)
|
||||
return false
|
||||
}
|
||||
|
||||
return boolResult
|
||||
}
|
||||
|
||||
// ParamsForCompute — аналогично, но только по переменным (как в твоём старом коде)
|
||||
func ParamsForCompute(app *types.PackageApplication) map[string]interface{} {
|
||||
params := make(map[string]interface{})
|
||||
|
||||
for i := range app.Variables {
|
||||
v := &app.Variables[i]
|
||||
value := v.Value
|
||||
if value == nil {
|
||||
value = v.Default
|
||||
}
|
||||
params[v.Name] = value
|
||||
}
|
||||
|
||||
// Добавляем значения из активных полей
|
||||
for _, section := range app.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Variable == nil || !field.Active {
|
||||
continue
|
||||
}
|
||||
value := field.Variable.Value
|
||||
if value == nil {
|
||||
value = field.Variable.Default
|
||||
}
|
||||
params[field.VariableName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// ComputeValue — вычисляет значение для compute-поля
|
||||
func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) {
|
||||
if computeExpr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
env := ParamsForCompute(app)
|
||||
options := append([]expr.Option{
|
||||
expr.Env(env),
|
||||
expr.AsKind(reflect.String), // по умолчанию строка, если не указано иное
|
||||
expr.AllowUndefinedVariables(),
|
||||
}, DefineExprCustomFunctions()...)
|
||||
|
||||
program, err := expr.Compile(computeExpr, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка компиляции compute: %v", err)
|
||||
}
|
||||
|
||||
result, err := expr.Run(program, env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ошибка выполнения compute: %v", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func Recompute(app *types.PackageApplication) {
|
||||
for i := range app.Sections {
|
||||
section := &app.Sections[i]
|
||||
for j := range section.Fields {
|
||||
field := §ion.Fields[j]
|
||||
if field.FieldType == "compute" && field.Compute != "" {
|
||||
if field.Variable != nil {
|
||||
continue //?почему мы пропускаем
|
||||
}
|
||||
value, err := ComputeValue(field.Compute, app)
|
||||
if err != nil {
|
||||
value = fmt.Sprintf("Ошибка вычисления: %v", err)
|
||||
}
|
||||
field.Variable.Value = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DefineExprCustomFunctions() []expr.Option {
|
||||
return []expr.Option{
|
||||
expr.Function("regex",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) != 3 {
|
||||
return "", fmt.Errorf("regex() ожидает 3 аргумента")
|
||||
}
|
||||
input := fmt.Sprintf("%v", args[0])
|
||||
pattern := fmt.Sprintf("%v", args[1])
|
||||
repl := fmt.Sprintf("%v", args[2])
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return input, nil
|
||||
}
|
||||
return re.ReplaceAllString(input, repl), nil
|
||||
}, new(func(string, string, string) string),
|
||||
),
|
||||
expr.Function("regex_match",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) != 2 {
|
||||
return false, fmt.Errorf("regex_match() ожидает 2 аргумента")
|
||||
}
|
||||
input := fmt.Sprintf("%v", args[0])
|
||||
pattern := fmt.Sprintf("%v", args[1])
|
||||
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("некоректный regex %s, err:%v", pattern, err)
|
||||
}
|
||||
return re.MatchString(input), nil
|
||||
|
||||
}, new(func(string, string) bool),
|
||||
),
|
||||
expr.Function("error",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("error() требует сообщения")
|
||||
}
|
||||
return nil, fmt.Errorf("%v", args[0])
|
||||
},
|
||||
new(func(string) error),
|
||||
),
|
||||
expr.Function("http_get",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
url := fmt.Sprintf("%v", args[0])
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return string(body), nil
|
||||
},
|
||||
new(func(string) string),
|
||||
),
|
||||
expr.Function("shell",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
cmd := fmt.Sprintf("%v", args[0])
|
||||
parts := strings.Fields(cmd)
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
out, err := exec.Command(parts[0], parts[1:]...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
},
|
||||
new(func(string) error),
|
||||
),
|
||||
expr.Function("env",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return os.Getenv(fmt.Sprintf("%v", args[0])), nil
|
||||
}, new(func(string) error),
|
||||
),
|
||||
expr.Function("file_exists",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
path := fmt.Sprintf("%v", args[0])
|
||||
_, err := os.Stat(path)
|
||||
return err == nil, nil
|
||||
}, new(func(string) bool),
|
||||
),
|
||||
expr.Function("dir_exists",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
path := fmt.Sprintf("%v", args[0])
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}, new(func(string) bool),
|
||||
),
|
||||
expr.Function("port_free",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
port := fmt.Sprintf("%v", args[0])
|
||||
cmd := exec.Command("ss", "-ltn", "sport =: "+port)
|
||||
return cmd.Run() != nil, nil
|
||||
}, new(func(string) error),
|
||||
),
|
||||
expr.Function("user_exists",
|
||||
func(args ...interface{}) (interface{}, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
name := fmt.Sprintf("%v", args[0])
|
||||
cmd := exec.Command("id", "-u", name)
|
||||
return cmd.Run() == nil, nil
|
||||
}, new(func(string) bool),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func SpecialValueHandle(special string) interface{} {
|
||||
specialValue := strings.Split(special, "code:")[1]
|
||||
switch specialValue {
|
||||
case "users":
|
||||
return GetSystemUsers()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetSystemUsers получение списка обычных пользователей в системе
|
||||
func GetSystemUsers() []string {
|
||||
//результат выполнения команды читающей файл passwd
|
||||
output, _ := exec.Command("getent", "passwd").Output()
|
||||
//массив строк разделенных новой строкой
|
||||
lines := strings.Split(string(output), "\n")
|
||||
var users []string
|
||||
for _, line := range lines {
|
||||
//линия совпадает с регулярным выражением
|
||||
if matches := userRE.FindStringSubmatch(line); len(matches) == 3 {
|
||||
//идентификатор больше или равен 1000, uid обычного пользователя в системе
|
||||
if uid, _ := strconv.Atoi(matches[2]); uid >= 1000 {
|
||||
users = append(users, matches[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
64
setup/fields/checkbox.go
Normal file
64
setup/fields/checkbox.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package fields
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type CheckboxModel struct {
|
||||
Label string
|
||||
Checked bool
|
||||
}
|
||||
|
||||
func NewCheckboxModel(label string, checked bool) *CheckboxModel {
|
||||
return &CheckboxModel{
|
||||
Label: label,
|
||||
Checked: checked,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case " ", "enter":
|
||||
m.Checked = !m.Checked
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) View() string {
|
||||
res := ""
|
||||
res += m.Label + ": "
|
||||
if m.Checked {
|
||||
res += "[x]"
|
||||
} else {
|
||||
res += "[ ]"
|
||||
}
|
||||
return res
|
||||
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) Focus() {
|
||||
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) Blur() {
|
||||
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) CaptureKeys() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) GetLabel() string {
|
||||
return m.Label
|
||||
}
|
||||
|
||||
func (m *CheckboxModel) GetValue() interface{} {
|
||||
return m.Checked
|
||||
}
|
||||
48
setup/fields/compute.go
Normal file
48
setup/fields/compute.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package fields
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type ComputeModel struct {
|
||||
Label string
|
||||
value string
|
||||
}
|
||||
|
||||
func NewComputeModel(label, value string) *ComputeModel {
|
||||
return &ComputeModel{
|
||||
Label: label,
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ComputeModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ComputeModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *ComputeModel) View() string {
|
||||
return fmt.Sprintf("%s: %s", m.Label, m.value)
|
||||
}
|
||||
|
||||
func (m *ComputeModel) Focus() {}
|
||||
|
||||
func (m *ComputeModel) Blur() {
|
||||
|
||||
}
|
||||
|
||||
func (m *ComputeModel) CaptureKeys() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ComputeModel) GetLabel() string {
|
||||
return m.Label
|
||||
}
|
||||
|
||||
func (m *ComputeModel) GetValue() interface{} {
|
||||
return m.value
|
||||
}
|
||||
236
setup/fields/dirSelect.go
Normal file
236
setup/fields/dirSelect.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
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"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DirSelectModel struct {
|
||||
input textinput.Model
|
||||
label string
|
||||
focus int
|
||||
open bool
|
||||
dir string
|
||||
currentDirectory string
|
||||
options []os.FileInfo
|
||||
optionsCount int
|
||||
}
|
||||
|
||||
func NewDirSelectModel(label, dir string) *DirSelectModel {
|
||||
inputModel := textinput.New()
|
||||
inputModel.SetValue(dir)
|
||||
return &DirSelectModel{
|
||||
input: inputModel,
|
||||
label: label,
|
||||
currentDirectory: filepath.Dir(dir),
|
||||
open: false,
|
||||
dir: dir,
|
||||
optionsCount: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) 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.dir = newValue
|
||||
m.input.CursorEnd()
|
||||
return m, nil
|
||||
}
|
||||
if file.IsDir() {
|
||||
m.currentDirectory = filepath.Join(m.currentDirectory, fileName)
|
||||
newValue := m.currentDirectory + string(filepath.Separator)
|
||||
m.input.SetValue(newValue)
|
||||
m.dir = newValue
|
||||
m.input.CursorEnd()
|
||||
m.open = false
|
||||
selected := types.SelectedDirMsg{
|
||||
DirectoryName: fileName,
|
||||
Index: m.focus,
|
||||
}
|
||||
m.focus = 0
|
||||
return m, func() tea.Msg {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
m.open = true
|
||||
newDir := strings.TrimSpace(m.input.Value())
|
||||
if len(newDir) > 1 && strings.HasSuffix(newDir, string(filepath.Separator)) {
|
||||
newDir = strings.TrimSuffix(newDir, string(filepath.Separator))
|
||||
}
|
||||
if info, err := os.Stat(newDir); err == nil && info.IsDir() {
|
||||
m.currentDirectory = newDir
|
||||
m.dir = newDir + string(filepath.Separator)
|
||||
m.input.SetValue(m.dir)
|
||||
m.input.CursorEnd()
|
||||
} else {
|
||||
m.currentDirectory = filepath.Dir(newDir)
|
||||
}
|
||||
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.dir {
|
||||
m.dir = m.input.Value()
|
||||
}
|
||||
}
|
||||
}
|
||||
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 *DirSelectModel) 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 {
|
||||
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 {
|
||||
res += prefix + ui.SelectFileDirDeniedStyle.Render(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) Focus() {
|
||||
m.input.Focus()
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) Blur() {
|
||||
m.input.Blur()
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) CaptureKeys() bool {
|
||||
return m.open
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) GetLabel() string {
|
||||
return m.label
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) GetValue() interface{} {
|
||||
return m.dir
|
||||
}
|
||||
|
||||
func (m *DirSelectModel) 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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
14
setup/fields/fieldModel.go
Normal file
14
setup/fields/fieldModel.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package fields
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type FieldModel interface {
|
||||
Init() tea.Cmd
|
||||
Update(msg tea.Msg) (FieldModel, tea.Cmd)
|
||||
View() string
|
||||
Focus()
|
||||
Blur()
|
||||
CaptureKeys() bool
|
||||
GetLabel() string
|
||||
GetValue() interface{}
|
||||
}
|
||||
257
setup/fields/fileSelect.go
Normal file
257
setup/fields/fileSelect.go
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
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 }
|
||||
59
setup/fields/input.go
Normal file
59
setup/fields/input.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package fields
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/cursor"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type InputModel struct {
|
||||
label string
|
||||
input textinput.Model
|
||||
}
|
||||
|
||||
func NewInputModel(label, value string) *InputModel {
|
||||
t := textinput.New()
|
||||
t.SetValue(value)
|
||||
t.Cursor.SetMode(cursor.CursorBlink)
|
||||
return &InputModel{
|
||||
label: label,
|
||||
input: t,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *InputModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *InputModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.input, cmd = m.input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *InputModel) View() string {
|
||||
res := fmt.Sprintf("%s: %v", m.label, m.input.View())
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *InputModel) Focus() {
|
||||
m.input.Focus()
|
||||
}
|
||||
|
||||
func (m *InputModel) Blur() {
|
||||
m.input.Blur()
|
||||
}
|
||||
|
||||
func (m *InputModel) CaptureKeys() bool {
|
||||
//return m.input.Focused()
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *InputModel) GetLabel() string {
|
||||
return m.label
|
||||
}
|
||||
|
||||
func (m *InputModel) GetValue() interface{} {
|
||||
return m.input.Value()
|
||||
}
|
||||
128
setup/fields/select.go
Normal file
128
setup/fields/select.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package fields
|
||||
|
||||
import (
|
||||
"bubble/types"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type SelectModel struct {
|
||||
label string
|
||||
options []string
|
||||
focus int
|
||||
open bool
|
||||
chosen string
|
||||
}
|
||||
|
||||
func NewSelectModel(label, value string, options []string) *SelectModel {
|
||||
found := false
|
||||
for i := range options {
|
||||
if options[i] == value {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
value = ""
|
||||
}
|
||||
return &SelectModel{
|
||||
options: options,
|
||||
label: label,
|
||||
focus: len(options),
|
||||
chosen: value,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SelectModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m *SelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) {
|
||||
var cmd 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
|
||||
}
|
||||
m.chosen = m.options[m.focus]
|
||||
selected := types.SelectedOptionMsg{
|
||||
FieldIndex: m.focus,
|
||||
Value: m.chosen,
|
||||
}
|
||||
m.open = false
|
||||
m.focus = len(m.options)
|
||||
return m, func() tea.Msg {
|
||||
return selected
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch msg.String() {
|
||||
case "enter":
|
||||
m.open = true
|
||||
for optionIdx := range m.options {
|
||||
if m.options[optionIdx] == m.chosen {
|
||||
m.focus = optionIdx
|
||||
}
|
||||
}
|
||||
if m.chosen == "" && len(m.options) > 0 {
|
||||
m.focus = 0
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *SelectModel) View() string {
|
||||
res := fmt.Sprintf("%s: %s", m.label, m.chosen)
|
||||
if m.open && len(m.options) > 0 {
|
||||
for i := range m.options {
|
||||
if m.focus == i {
|
||||
res += "\n\t-> " + m.options[i]
|
||||
} else {
|
||||
res += "\n\t " + m.options[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (m *SelectModel) Close() {
|
||||
m.open = false
|
||||
}
|
||||
|
||||
func (m *SelectModel) Focus() {
|
||||
}
|
||||
|
||||
func (m *SelectModel) Blur() {
|
||||
m.open = false
|
||||
}
|
||||
|
||||
func (m *SelectModel) CaptureKeys() bool {
|
||||
return m.open
|
||||
}
|
||||
|
||||
func (m *SelectModel) GetLabel() string {
|
||||
return m.label
|
||||
}
|
||||
|
||||
func (m *SelectModel) GetValue() interface{} {
|
||||
return m.chosen
|
||||
}
|
||||
150
setup/final.go
Normal file
150
setup/final.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"bubble/setup/ui"
|
||||
"bubble/types"
|
||||
"fmt"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
)
|
||||
|
||||
type FinalModel struct {
|
||||
variables []types.Variable
|
||||
table *table.Table
|
||||
viewport viewport.Model
|
||||
ready bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewFinalModel(variables []types.Variable, width, height int) *FinalModel {
|
||||
m := &FinalModel{
|
||||
variables: variables,
|
||||
width: width,
|
||||
height: height,
|
||||
ready: false,
|
||||
}
|
||||
if width > 0 && height > 0 {
|
||||
m.initViewport(width, height)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *FinalModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *FinalModel) initViewport(width, height int) {
|
||||
m.viewport = viewport.New(width, height-4)
|
||||
m.viewport.Width = width - 4
|
||||
m.viewport.Height = height - 6
|
||||
m.ready = true
|
||||
m.rebuildTable(m.viewport.Width)
|
||||
m.viewport.SetContent(m.table.Render())
|
||||
}
|
||||
|
||||
func (m *FinalModel) rebuildTable(width int) {
|
||||
if len(m.variables) == 0 {
|
||||
m.table = table.New().Headers("Нет переменных")
|
||||
return
|
||||
}
|
||||
availableWidth := width - 8 // примерно: 2 (отступы) + 4 (бордер) + запас
|
||||
if availableWidth < 60 {
|
||||
availableWidth = 60
|
||||
}
|
||||
t := table.New().
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderStyle(ui.TableBorderStyle).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
var style lipgloss.Style
|
||||
switch {
|
||||
case row == table.HeaderRow:
|
||||
return ui.TableHeaderStyle
|
||||
case row%2 == 0:
|
||||
style = ui.TableEvenRowStyle
|
||||
default:
|
||||
style = ui.TableOddRowStyle
|
||||
}
|
||||
switch col {
|
||||
case 0, 1:
|
||||
style = style.Width(25)
|
||||
case 2:
|
||||
style = style.Width(availableWidth - 50 - 10)
|
||||
}
|
||||
return style
|
||||
}).
|
||||
Headers("Variable name", "Default value", "Used value")
|
||||
for _, v := range m.variables {
|
||||
used := fmt.Sprintf("%v", v.Value)
|
||||
if used == "" {
|
||||
used = "<пусто>"
|
||||
}
|
||||
t.Row(v.Name, v.Default, used)
|
||||
}
|
||||
m.table = t
|
||||
}
|
||||
|
||||
func (m *FinalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "up":
|
||||
m.viewport.ScrollUp(1)
|
||||
case "down":
|
||||
m.viewport.ScrollDown(1)
|
||||
case "pageup":
|
||||
m.viewport.HalfPageUp()
|
||||
case "pagedown":
|
||||
m.viewport.HalfPageDown()
|
||||
case "home":
|
||||
m.viewport.GotoTop()
|
||||
case "end":
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
if !m.ready {
|
||||
m.initViewport(msg.Width, msg.Height)
|
||||
} else {
|
||||
m.viewport.Width = msg.Width - 4
|
||||
m.viewport.Height = msg.Height - 6
|
||||
}
|
||||
m.rebuildTable(m.viewport.Width)
|
||||
rendered := m.table.Render()
|
||||
m.viewport.SetContent(rendered)
|
||||
}
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *FinalModel) View() string {
|
||||
if !m.ready {
|
||||
return "Загрузка таблицы"
|
||||
}
|
||||
header := lipgloss.NewStyle().
|
||||
Width(m.viewport.Width).
|
||||
Align(lipgloss.Center).
|
||||
Bold(true).
|
||||
Background(ui.ColorPurple).
|
||||
Foreground(ui.ColorWhite).
|
||||
Padding(1).
|
||||
Render("Итоговые значения переменных")
|
||||
|
||||
footer := lipgloss.NewStyle().
|
||||
Width(m.viewport.Width).
|
||||
Align(lipgloss.Center).
|
||||
Foreground(ui.ColorGrey).
|
||||
Padding(0, 1).
|
||||
Render(fmt.Sprintf("Строк: %d • q — выход • ↑↓ / jk — скролл", len(m.variables)))
|
||||
|
||||
body := m.viewport.View()
|
||||
return lipgloss.JoinVertical(lipgloss.Center,
|
||||
header,
|
||||
body,
|
||||
footer,
|
||||
)
|
||||
}
|
||||
205
setup/form.go
Normal file
205
setup/form.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"bubble/setup/fields"
|
||||
"bubble/setup/ui"
|
||||
"bubble/types"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/sirupsen/logrus"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FormModel struct {
|
||||
CurrentSection *types.Section
|
||||
FieldModels []fields.FieldModel
|
||||
focus int
|
||||
renderedContent string
|
||||
}
|
||||
|
||||
func NewFormModel(application *types.PackageApplication, sectionIdx, focus int) *FormModel {
|
||||
currentSection := &application.Sections[sectionIdx]
|
||||
var fieldModels []fields.FieldModel
|
||||
for _, sectionField := range currentSection.Fields {
|
||||
if !CheckCondition(sectionField.Condition, application) {
|
||||
continue
|
||||
}
|
||||
switch sectionField.FieldType {
|
||||
case "select":
|
||||
var options []string
|
||||
for j := range sectionField.Options {
|
||||
options = append(options, fmt.Sprintf("%v", sectionField.Options[j]))
|
||||
}
|
||||
if sectionField.SpecialValue != "" {
|
||||
specialOptions := SpecialValueHandle(sectionField.SpecialValue)
|
||||
if strSpecial, ok := specialOptions.([]string); ok {
|
||||
options = strSpecial
|
||||
}
|
||||
}
|
||||
selected := fmt.Sprintf("%v", sectionField.Variable.Value)
|
||||
fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, options))
|
||||
case "check":
|
||||
checked := false
|
||||
if value, ok := sectionField.Variable.Value.(bool); ok {
|
||||
checked = value
|
||||
} else {
|
||||
checked = sectionField.Variable.Value == "true"
|
||||
}
|
||||
fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, checked))
|
||||
case "compute":
|
||||
value, err := ComputeValue(sectionField.Compute, application)
|
||||
if err != nil {
|
||||
value = fmt.Sprintf("Ошибка вычисления: %v", err)
|
||||
}
|
||||
fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value)))
|
||||
//compute расчет
|
||||
case "file":
|
||||
fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.FileFieldExtensions))
|
||||
case "directory":
|
||||
fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value)))
|
||||
default:
|
||||
fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value)))
|
||||
}
|
||||
}
|
||||
if len(fieldModels) > focus {
|
||||
fieldModels[focus].Focus()
|
||||
}
|
||||
m := &FormModel{
|
||||
FieldModels: fieldModels,
|
||||
focus: focus,
|
||||
CurrentSection: currentSection,
|
||||
}
|
||||
if currentSection.Type == "abouta" { //"about" {
|
||||
md := strings.Join(currentSection.Paragraphs, "\n\n")
|
||||
renderer, _ := glamour.NewTermRenderer(
|
||||
glamour.WithAutoStyle(),
|
||||
glamour.WithWordWrap(80))
|
||||
rendered, _ := renderer.Render(md)
|
||||
m.renderedContent = rendered
|
||||
m.focus = 0
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *FormModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
//вернуться к списку приложений, чтобы выбрать другое приложение.
|
||||
case "enter":
|
||||
logrus.Infof("sections %s, focus %d, fields len %d", m.CurrentSection.Name, m.focus, len(m.FieldModels))
|
||||
if len(m.FieldModels) == m.focus {
|
||||
return m, func() tea.Msg {
|
||||
return types.NextSectionMsg{}
|
||||
}
|
||||
}
|
||||
case "tab", "down", "shift+tab", "up":
|
||||
key := msg.String()
|
||||
if m.focus != len(m.FieldModels) {
|
||||
logrus.Infof("1")
|
||||
//todo исправить фокус
|
||||
field := m.FieldModels[m.focus]
|
||||
logrus.Infof("field %s, capture keys %t", field.GetLabel(), field.CaptureKeys())
|
||||
if !field.CaptureKeys() {
|
||||
if key == "tab" || key == "down" {
|
||||
m.focus++
|
||||
} else if key == "shift+tab" || key == "up" {
|
||||
m.focus--
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logrus.Infof("2")
|
||||
if key == "tab" || key == "down" {
|
||||
m.focus++
|
||||
} else if key == "shift+tab" || key == "up" {
|
||||
m.focus--
|
||||
}
|
||||
}
|
||||
if m.focus == len(m.FieldModels)+1 {
|
||||
m.focus = 0
|
||||
} else if m.focus < 0 {
|
||||
m.focus = len(m.FieldModels)
|
||||
}
|
||||
}
|
||||
case types.SelectedOptionMsg:
|
||||
logrus.Infof("chose %+v", msg)
|
||||
}
|
||||
cmd := m.updateFields(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *FormModel) View() string {
|
||||
res := ""
|
||||
|
||||
if len(m.FieldModels) > 0 {
|
||||
for i := range m.FieldModels {
|
||||
inputView := m.FieldModels[i].View()
|
||||
if m.focus == i {
|
||||
res += ui.FormFocusedStyle.Render(inputView)
|
||||
} else {
|
||||
res += ui.FormUnfocusedStyle.Render(inputView)
|
||||
}
|
||||
res += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
button := "\n[ next section ]"
|
||||
if m.CurrentSection.Type == "about" {
|
||||
res += m.renderedContent
|
||||
}
|
||||
if m.focus == len(m.FieldModels) {
|
||||
res += ui.FormFocusedStyle.Render(button)
|
||||
} else {
|
||||
res += ui.FormUnfocusedStyle.Render(button)
|
||||
}
|
||||
return ui.FormStyle.Render(res)
|
||||
}
|
||||
|
||||
func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
for i, fieldModel := range m.FieldModels {
|
||||
var fieldCmd tea.Cmd
|
||||
var updated fields.FieldModel
|
||||
if i == m.focus { //если фокус установлен на текущем поле, то текущее поле активируем и передаем ему сообщения для обновления
|
||||
fieldModel.Focus()
|
||||
updated, fieldCmd = fieldModel.Update(msg)
|
||||
} else { //если фокус не установлен на текущем поле, то убираем с неё фокус, передаем сообщения, но только те, что не содержат действий клавиатуры
|
||||
fieldModel.Blur()
|
||||
if _, ok := msg.(tea.KeyMsg); !ok {
|
||||
updated, fieldCmd = fieldModel.Update(msg)
|
||||
}
|
||||
}
|
||||
if updated != nil {
|
||||
m.FieldModels[i] = updated
|
||||
for _, sectionField := range m.CurrentSection.Fields {
|
||||
if sectionField.Name == fieldModel.GetLabel() {
|
||||
oldValue := sectionField.Variable.Value
|
||||
newValue := fieldModel.GetValue()
|
||||
if oldValue != newValue {
|
||||
cmds = append(cmds, func() tea.Msg {
|
||||
return types.SugarMsg{
|
||||
VariableName: sectionField.VariableName,
|
||||
FieldName: sectionField.Name,
|
||||
Compute: sectionField.Compute,
|
||||
Focus: m.focus,
|
||||
}
|
||||
})
|
||||
sectionField.Variable.Value = newValue //recompute вызывать.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fieldCmd != nil {
|
||||
cmds = append(cmds, fieldCmd)
|
||||
}
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
69
setup/list.go
Normal file
69
setup/list.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"bubble/setup/ui"
|
||||
"bubble/types"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type ListModel struct {
|
||||
options []string
|
||||
focus int
|
||||
}
|
||||
|
||||
func NewListModel(applications []types.PackageApplication) *ListModel {
|
||||
options := make([]string, len(applications))
|
||||
for i, app := range applications {
|
||||
options[i] = app.Name
|
||||
}
|
||||
return &ListModel{
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ListModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "up", "w", "shift+tab":
|
||||
m.focus--
|
||||
if m.focus < 0 {
|
||||
m.focus = len(m.options) - 1
|
||||
}
|
||||
case "down", "s", "tab":
|
||||
m.focus++
|
||||
if m.focus == len(m.options) {
|
||||
m.focus = 0
|
||||
}
|
||||
case "enter":
|
||||
return m, func() tea.Msg {
|
||||
return types.SelectedAppMsg{Index: m.focus}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *ListModel) View() string {
|
||||
res := ""
|
||||
listStyle := lipgloss.NewStyle().Padding(1, 1).Foreground(ui.ColorPurple)
|
||||
for i := range m.options {
|
||||
if i == m.focus {
|
||||
res += "[x] " + m.options[i]
|
||||
} else {
|
||||
res += "[ ] " + m.options[i]
|
||||
}
|
||||
if i != len(m.options)-1 {
|
||||
res += "\n"
|
||||
}
|
||||
}
|
||||
return listStyle.Render(res)
|
||||
}
|
||||
183
setup/main.go
Normal file
183
setup/main.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package setup
|
||||
|
||||
import (
|
||||
"bubble/bootstrap"
|
||||
"bubble/types"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func Start() {
|
||||
p := tea.NewProgram(NewMain(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
type Main struct {
|
||||
current tea.Model
|
||||
Applications []types.PackageApplication
|
||||
CurrentApplication *types.PackageApplication
|
||||
CurrentSection *types.Section
|
||||
CurrentSectionIdx int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func NewMain() *Main {
|
||||
apps := bootstrap.LoadApplications()
|
||||
return &Main{
|
||||
Applications: apps,
|
||||
current: NewListModel(apps),
|
||||
CurrentSectionIdx: 0,
|
||||
}
|
||||
}
|
||||
func (m *Main) Init() tea.Cmd {
|
||||
return m.current.Init()
|
||||
}
|
||||
|
||||
func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.current, cmd = m.current.Update(msg)
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.current, cmd = m.current.Update(msg)
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
case types.NextSectionMsg:
|
||||
m.CurrentSectionIdx++
|
||||
if m.CurrentSectionIdx == len(m.CurrentApplication.Sections) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
for sectionIdx := m.CurrentSectionIdx; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ {
|
||||
//todo переделать. Учесть динамические индексы.
|
||||
section := m.CurrentApplication.Sections[sectionIdx]
|
||||
if CheckCondition(section.Condition, m.CurrentApplication) {
|
||||
m.CurrentSectionIdx = sectionIdx
|
||||
m.CurrentSection = §ion
|
||||
break
|
||||
}
|
||||
}
|
||||
if m.CurrentSection == nil {
|
||||
logrus.Infof("next section, eq nil, out, idx %d", m.CurrentSectionIdx)
|
||||
return m, tea.Quit
|
||||
}
|
||||
//todo переделать. Учесть динамические индексы.
|
||||
if m.CurrentSectionIdx == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == "final" {
|
||||
m.current = NewFinalModel(m.CurrentApplication.Variables, m.width, m.height)
|
||||
} else {
|
||||
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0)
|
||||
|
||||
}
|
||||
case types.SugarMsg:
|
||||
m.Sugar(msg)
|
||||
case types.SelectedAppMsg:
|
||||
//получаем сообщение о выборе приложения
|
||||
m.CurrentApplication = &m.Applications[msg.Index]
|
||||
|
||||
//ищем доступную секцию
|
||||
for sectionIdx := 0; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ {
|
||||
section := m.CurrentApplication.Sections[sectionIdx]
|
||||
if CheckCondition(section.Condition, m.CurrentApplication) {
|
||||
m.CurrentSectionIdx = sectionIdx
|
||||
m.CurrentSection = §ion
|
||||
break
|
||||
}
|
||||
}
|
||||
//если секция не была найдена, то выходим
|
||||
if m.CurrentSection == nil {
|
||||
logrus.Infof("select app, eq nil, out")
|
||||
return m, tea.Quit
|
||||
}
|
||||
//создаем новую форму
|
||||
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Main) View() string {
|
||||
return m.current.View()
|
||||
}
|
||||
|
||||
func (m *Main) Sugar(sugar types.SugarMsg) {
|
||||
if m.IsMultiField(sugar.VariableName) {
|
||||
m.DeactivateField(sugar.VariableName, sugar.FieldName)
|
||||
}
|
||||
if IsParent(sugar.VariableName, m.CurrentApplication.Sections) {
|
||||
if sugar.Compute != "" {
|
||||
Recompute(m.CurrentApplication)
|
||||
}
|
||||
m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, sugar.Focus)
|
||||
}
|
||||
}
|
||||
|
||||
func IsParent(variableName string, sections []types.Section) bool {
|
||||
for _, section := range sections {
|
||||
for _, parent := range section.Parent {
|
||||
if parent == variableName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, field := range section.Fields {
|
||||
for _, parent := range field.Parent {
|
||||
if parent == variableName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Main) IsMultiField(variableName string) bool {
|
||||
var count int
|
||||
for _, section := range m.CurrentApplication.Sections {
|
||||
for _, field := range section.Fields {
|
||||
if field.Name == variableName {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count > 1
|
||||
}
|
||||
|
||||
func (m *Main) DeactivateField(variableName, fieldName string) {
|
||||
for sectionIdx, section := range m.CurrentApplication.Sections {
|
||||
for fieldIdx, field := range section.Fields {
|
||||
if field.Variable.Name == variableName {
|
||||
if field.Name != fieldName {
|
||||
m.CurrentApplication.Sections[sectionIdx].Fields[fieldIdx].Active = false
|
||||
} else {
|
||||
m.CurrentApplication.Sections[sectionIdx].Fields[fieldIdx].Active = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Main) RecomputeFields() {
|
||||
for i := range m.CurrentApplication.Sections {
|
||||
section := &m.CurrentApplication.Sections[i]
|
||||
for j := range section.Fields {
|
||||
field := §ion.Fields[j]
|
||||
if field.FieldType == "compute" && field.Compute != "" {
|
||||
if field.Variable != nil {
|
||||
continue
|
||||
}
|
||||
value, err := ComputeValue(field.Compute, m.CurrentApplication)
|
||||
if err != nil {
|
||||
value = fmt.Sprintf("Ошибка вычисления: %v", err)
|
||||
}
|
||||
field.Variable.Value = value
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
setup/ui/style.go
Normal file
36
setup/ui/style.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package ui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
ColorWhite = lipgloss.Color("#ffffff")
|
||||
ColorGrey = lipgloss.Color("#626262")
|
||||
ColorBlack = lipgloss.Color("#000000")
|
||||
ColorRed = lipgloss.Color("#FF0000")
|
||||
ColorGreen = lipgloss.Color("#00FF00")
|
||||
ColorBlue = lipgloss.Color("#0000FF")
|
||||
ColorPurple = lipgloss.Color("#6f03fc")
|
||||
ColorGreenCyan = lipgloss.Color("#11CAAA")
|
||||
|
||||
TableHeaderStyle = lipgloss.NewStyle().Foreground(ColorPurple).Bold(true).Align(lipgloss.Center)
|
||||
TableCellStyle = lipgloss.NewStyle().Padding(0, 1).Width(14)
|
||||
TableOddRowStyle = TableCellStyle.Foreground(ColorWhite).Background(ColorGrey)
|
||||
TableEvenRowStyle = TableCellStyle.Foreground(ColorWhite)
|
||||
TableBorderStyle = lipgloss.NewStyle().Foreground(ColorPurple)
|
||||
|
||||
SelectFileDirDeniedStyle = lipgloss.NewStyle().Foreground(ColorGrey)
|
||||
SelectFileDirParentStyle = lipgloss.NewStyle().Foreground(ColorPurple)
|
||||
SelectFileStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
|
||||
SelectDirStyle = lipgloss.NewStyle().Foreground(ColorBlue)
|
||||
|
||||
SelectStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
|
||||
InputStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
|
||||
ComputeStyle = lipgloss.NewStyle().Foreground(ColorGrey)
|
||||
CheckboxStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
|
||||
|
||||
FormFocusedStyle = lipgloss.NewStyle().Foreground(ColorRed).Bold(true).
|
||||
Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(ColorRed)
|
||||
FormUnfocusedStyle = lipgloss.NewStyle().Foreground(ColorGreenCyan)
|
||||
FormStyle = lipgloss.NewStyle().Padding(1, 2).Foreground(ColorBlack).
|
||||
Border(lipgloss.RoundedBorder()).BorderForeground(ColorRed)
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue