first commit

This commit is contained in:
XeRo 2025-12-26 17:45:37 +05:00
commit 5203032b83
25 changed files with 2825 additions and 0 deletions

336
setup/condition.go Normal file
View 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 := &section.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
View 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
View 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
View 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
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 = &section
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 = &section
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 := &section.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
View 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)
)