769 lines
20 KiB
Go
769 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
type tuiTheme struct {
|
|
outer lipgloss.Style
|
|
headerBar lipgloss.Style
|
|
badge lipgloss.Style
|
|
chrome lipgloss.Style
|
|
title lipgloss.Style
|
|
subtitle lipgloss.Style
|
|
sectionTitle lipgloss.Style
|
|
body lipgloss.Style
|
|
muted lipgloss.Style
|
|
footer lipgloss.Style
|
|
panel lipgloss.Style
|
|
panelAccent lipgloss.Style
|
|
row lipgloss.Style
|
|
rowSelected lipgloss.Style
|
|
rowNumber lipgloss.Style
|
|
rowNumberOn lipgloss.Style
|
|
rowKey lipgloss.Style
|
|
rowKeySelected lipgloss.Style
|
|
rowTitle lipgloss.Style
|
|
rowTitleOn lipgloss.Style
|
|
rowHint lipgloss.Style
|
|
rowHintActive lipgloss.Style
|
|
statusInfo lipgloss.Style
|
|
statusWarn lipgloss.Style
|
|
statusSuccess lipgloss.Style
|
|
inputLabel lipgloss.Style
|
|
inputBox lipgloss.Style
|
|
}
|
|
|
|
func newTUITheme() tuiTheme {
|
|
border := lipgloss.Color("#5A6772")
|
|
borderSoft := lipgloss.Color("#31414A")
|
|
text := lipgloss.Color("#ECE4D8")
|
|
muted := lipgloss.Color("#A49788")
|
|
accent := lipgloss.Color("#D9733F")
|
|
accentSoft := lipgloss.Color("#F2D2B5")
|
|
panel := lipgloss.Color("#161C20")
|
|
panelAlt := lipgloss.Color("#212A30")
|
|
panelLift := lipgloss.Color("#2B363D")
|
|
info := lipgloss.Color("#86B7C6")
|
|
warn := lipgloss.Color("#D8AB63")
|
|
success := lipgloss.Color("#91B57F")
|
|
|
|
return tuiTheme{
|
|
outer: lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(border).
|
|
Padding(1, 2),
|
|
headerBar: lipgloss.NewStyle().
|
|
Foreground(borderSoft),
|
|
chrome: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
badge: lipgloss.NewStyle().
|
|
Foreground(panel).
|
|
Background(accentSoft).
|
|
Bold(true).
|
|
Padding(0, 1),
|
|
subtitle: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
title: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
Bold(true),
|
|
sectionTitle: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Bold(true).
|
|
Border(lipgloss.NormalBorder(), false, false, true, false).
|
|
BorderForeground(borderSoft).
|
|
PaddingBottom(0).
|
|
MarginBottom(1),
|
|
body: lipgloss.NewStyle().
|
|
Foreground(text),
|
|
muted: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
footer: lipgloss.NewStyle().
|
|
Foreground(muted).
|
|
Border(lipgloss.NormalBorder(), true, false, false, false).
|
|
BorderForeground(borderSoft).
|
|
PaddingTop(1).
|
|
MarginTop(1),
|
|
panel: lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(borderSoft).
|
|
PaddingLeft(1).
|
|
MarginBottom(1),
|
|
panelAccent: lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder(), false, false, false, true).
|
|
BorderForeground(accent).
|
|
PaddingLeft(1).
|
|
MarginBottom(1),
|
|
row: lipgloss.NewStyle().
|
|
Border(lipgloss.NormalBorder(), false, false, false, true).
|
|
BorderForeground(panelLift).
|
|
Padding(0, 1).
|
|
MarginBottom(1),
|
|
rowSelected: lipgloss.NewStyle().
|
|
Border(lipgloss.ThickBorder(), false, false, false, true).
|
|
BorderForeground(accent).
|
|
Background(panel).
|
|
Padding(0, 1).
|
|
MarginBottom(1),
|
|
rowNumber: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
rowNumberOn: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Bold(true),
|
|
rowKey: lipgloss.NewStyle().
|
|
Foreground(accent).
|
|
Bold(true).
|
|
Background(panelAlt).
|
|
Padding(0, 1),
|
|
rowKeySelected: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Background(panelLift).
|
|
Bold(true).
|
|
Padding(0, 1),
|
|
rowTitle: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
Bold(true),
|
|
rowTitleOn: lipgloss.NewStyle().
|
|
Foreground(text).
|
|
Bold(true),
|
|
rowHint: lipgloss.NewStyle().
|
|
Foreground(muted),
|
|
rowHintActive: lipgloss.NewStyle().
|
|
Foreground(text),
|
|
statusInfo: lipgloss.NewStyle().
|
|
Foreground(info).
|
|
Bold(true),
|
|
statusWarn: lipgloss.NewStyle().
|
|
Foreground(warn).
|
|
Bold(true),
|
|
statusSuccess: lipgloss.NewStyle().
|
|
Foreground(success).
|
|
Bold(true),
|
|
inputLabel: lipgloss.NewStyle().
|
|
Foreground(accentSoft).
|
|
Bold(true),
|
|
inputBox: lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(border).
|
|
Padding(0, 1).
|
|
Background(panelAlt),
|
|
}
|
|
}
|
|
|
|
func clampFrameWidth(windowWidth int) int {
|
|
const fallback = 84
|
|
if windowWidth <= 0 {
|
|
return fallback
|
|
}
|
|
if windowWidth <= 44 {
|
|
return windowWidth
|
|
}
|
|
if windowWidth > 92 {
|
|
return 92
|
|
}
|
|
return windowWidth - 2
|
|
}
|
|
|
|
func innerWidth(frameWidth int) int {
|
|
if frameWidth <= 10 {
|
|
return frameWidth
|
|
}
|
|
return frameWidth - 6
|
|
}
|
|
|
|
func (t tuiTheme) renderFrame(width int, title, subtitle, body, footer string) string {
|
|
frameWidth := clampFrameWidth(width)
|
|
contentWidth := innerWidth(frameWidth)
|
|
placeWidth := width
|
|
if placeWidth <= 0 {
|
|
placeWidth = frameWidth
|
|
}
|
|
|
|
headerLeft := lipgloss.JoinHorizontal(lipgloss.Top, t.badge.Render("SAMBA CONFIGER"), " ", t.chrome.Render("share editor"))
|
|
headerRule := t.headerBar.Render(strings.Repeat("─", max(0, contentWidth-lipgloss.Width(headerLeft)-1)))
|
|
header := lipgloss.JoinHorizontal(lipgloss.Center, headerLeft, " ", headerRule)
|
|
|
|
sections := []string{header, t.title.Width(contentWidth).Render(title)}
|
|
if subtitle != "" {
|
|
sections = append(sections, t.subtitle.Width(contentWidth).Render(subtitle))
|
|
}
|
|
if body != "" {
|
|
sections = append(sections, body)
|
|
}
|
|
if footer != "" {
|
|
sections = append(sections, t.footer.Width(contentWidth).Render(footer))
|
|
}
|
|
|
|
return lipgloss.PlaceHorizontal(placeWidth, lipgloss.Center, t.outer.Width(frameWidth).Render(strings.Join(sections, "\n\n")))
|
|
}
|
|
|
|
func (t tuiTheme) renderSection(width int, title string, lines []string) string {
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
|
|
contentWidth := innerWidth(clampFrameWidth(width))
|
|
rendered := make([]string, 0, len(lines)+1)
|
|
if title != "" {
|
|
rendered = append(rendered, t.sectionTitle.Width(contentWidth).Render(title))
|
|
}
|
|
for _, line := range lines {
|
|
rendered = append(rendered, t.panel.Width(contentWidth).Render(t.body.Width(max(1, contentWidth-2)).Render(line)))
|
|
}
|
|
return strings.Join(rendered, "\n")
|
|
}
|
|
|
|
func (t tuiTheme) renderMessage(width int, kind, text string) string {
|
|
label := t.statusInfo
|
|
switch kind {
|
|
case "warn":
|
|
label = t.statusWarn
|
|
case "success":
|
|
label = t.statusSuccess
|
|
}
|
|
return t.panel.Width(innerWidth(clampFrameWidth(width))).Render(
|
|
label.Render(strings.ToUpper(kind)) + " " + t.body.Render(text),
|
|
)
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
type menuOption struct {
|
|
Key string
|
|
Value string
|
|
Label string
|
|
Description string
|
|
}
|
|
|
|
type menuModel struct {
|
|
theme tuiTheme
|
|
title string
|
|
subtitle string
|
|
intro []string
|
|
options []menuOption
|
|
cursor int
|
|
choice string
|
|
cancel bool
|
|
width int
|
|
}
|
|
|
|
func (m menuModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
case tea.KeyMsg:
|
|
key := strings.ToLower(msg.String())
|
|
switch key {
|
|
case "ctrl+c", "esc":
|
|
m.cancel = true
|
|
return m, tea.Quit
|
|
case "up", "k":
|
|
if len(m.options) > 0 {
|
|
m.cursor = (m.cursor - 1 + len(m.options)) % len(m.options)
|
|
}
|
|
case "down", "j":
|
|
if len(m.options) > 0 {
|
|
m.cursor = (m.cursor + 1) % len(m.options)
|
|
}
|
|
case "enter":
|
|
if len(m.options) > 0 {
|
|
m.choice = m.options[m.cursor].Value
|
|
return m, tea.Quit
|
|
}
|
|
default:
|
|
for i, option := range m.options {
|
|
if key == strings.ToLower(option.Key) || key == fmt.Sprintf("%d", i+1) {
|
|
m.cursor = i
|
|
m.choice = option.Value
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m menuModel) View() string {
|
|
width := clampFrameWidth(m.width)
|
|
if width == 0 {
|
|
width = clampFrameWidth(84)
|
|
}
|
|
contentWidth := innerWidth(width)
|
|
|
|
rows := make([]string, 0, len(m.options))
|
|
for i, option := range m.options {
|
|
rowStyle := m.theme.row
|
|
keyStyle := m.theme.rowKey
|
|
titleStyle := m.theme.rowTitle
|
|
hintStyle := m.theme.rowHint
|
|
numberStyle := m.theme.rowNumber
|
|
prefix := numberStyle.Render(fmt.Sprintf("%02d", i+1))
|
|
if i == m.cursor {
|
|
rowStyle = m.theme.rowSelected
|
|
keyStyle = m.theme.rowKeySelected
|
|
titleStyle = m.theme.rowTitleOn
|
|
hintStyle = m.theme.rowHintActive
|
|
numberStyle = m.theme.rowNumberOn
|
|
prefix = numberStyle.Render(">>")
|
|
}
|
|
|
|
top := lipgloss.JoinHorizontal(
|
|
lipgloss.Center,
|
|
prefix,
|
|
" ",
|
|
keyStyle.Render(strings.ToUpper(option.Key)),
|
|
" ",
|
|
titleStyle.Render(option.Label),
|
|
)
|
|
|
|
rowLines := []string{top}
|
|
if option.Description != "" && width >= 72 {
|
|
rowLines = append(rowLines, hintStyle.Render(option.Description))
|
|
}
|
|
rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n")))
|
|
}
|
|
|
|
bodyParts := []string{}
|
|
if len(m.intro) > 0 {
|
|
bodyParts = append(bodyParts, m.theme.renderSection(width, "Context", m.intro))
|
|
}
|
|
bodyParts = append(bodyParts, m.theme.sectionTitle.Width(contentWidth).Render("Actions"))
|
|
bodyParts = append(bodyParts, strings.Join(rows, "\n"))
|
|
|
|
return m.theme.renderFrame(
|
|
m.width,
|
|
m.title,
|
|
m.subtitle,
|
|
strings.Join(bodyParts, "\n\n"),
|
|
"enter select • j/k or arrows move • letter/number picks instantly • esc cancel",
|
|
)
|
|
}
|
|
|
|
type textPromptModel struct {
|
|
theme tuiTheme
|
|
title string
|
|
subtitle string
|
|
label string
|
|
defaultValue string
|
|
input textinput.Model
|
|
value string
|
|
cancel bool
|
|
width int
|
|
}
|
|
|
|
func newTextPromptModel(theme tuiTheme, title, subtitle, label, defaultValue string) textPromptModel {
|
|
input := textinput.New()
|
|
input.Prompt = ""
|
|
input.Placeholder = defaultValue
|
|
input.Focus()
|
|
input.CharLimit = 512
|
|
input.Width = 48
|
|
|
|
return textPromptModel{
|
|
theme: theme,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
label: label,
|
|
defaultValue: defaultValue,
|
|
input: input,
|
|
}
|
|
}
|
|
|
|
func (m textPromptModel) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (m textPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
available := innerWidth(clampFrameWidth(m.width)) - 4
|
|
if available < 16 {
|
|
available = 16
|
|
}
|
|
m.input.Width = available
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.cancel = true
|
|
return m, tea.Quit
|
|
case "enter":
|
|
value := strings.TrimSpace(m.input.Value())
|
|
if value == "" {
|
|
value = m.defaultValue
|
|
}
|
|
m.value = value
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m textPromptModel) View() string {
|
|
width := clampFrameWidth(m.width)
|
|
contentWidth := innerWidth(width)
|
|
|
|
lines := []string{m.theme.inputLabel.Width(contentWidth).Render(m.label)}
|
|
lines = append(lines, m.theme.panelAccent.Width(contentWidth).Render(m.theme.inputBox.Width(max(8, contentWidth-2)).Render(m.input.View())))
|
|
if m.defaultValue != "" {
|
|
lines = append(lines, m.theme.muted.Width(contentWidth).Render("Enter keeps: "+m.defaultValue))
|
|
}
|
|
|
|
return m.theme.renderFrame(
|
|
m.width,
|
|
m.title,
|
|
m.subtitle,
|
|
strings.Join(lines, "\n\n"),
|
|
"enter confirm • esc cancel",
|
|
)
|
|
}
|
|
|
|
func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, intro []string, options []menuOption) (string, error) {
|
|
model := menuModel{
|
|
theme: theme,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
intro: intro,
|
|
options: options,
|
|
}
|
|
|
|
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
final := result.(menuModel)
|
|
if final.cancel {
|
|
return "", ErrCancelled
|
|
}
|
|
return final.choice, nil
|
|
}
|
|
|
|
func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) {
|
|
model := newTextPromptModel(theme, title, subtitle, label, defaultValue)
|
|
|
|
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
final := result.(textPromptModel)
|
|
if final.cancel {
|
|
return "", ErrCancelled
|
|
}
|
|
return final.value, nil
|
|
}
|
|
|
|
type cifsFormTextField struct {
|
|
key string
|
|
label string
|
|
required bool
|
|
defaultValue string
|
|
input textinput.Model
|
|
}
|
|
|
|
type cifsFormToggle struct {
|
|
label string
|
|
value bool
|
|
}
|
|
|
|
type cifsMountFormModel struct {
|
|
theme tuiTheme
|
|
title string
|
|
subtitle string
|
|
help []string
|
|
fields []cifsFormTextField
|
|
toggles []cifsFormToggle
|
|
cursor int
|
|
submitted bool
|
|
cancel bool
|
|
width int
|
|
errMessage string
|
|
result CIFSMountConfig
|
|
}
|
|
|
|
func newCIFSMountFormModel(theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) cifsMountFormModel {
|
|
makeInput := func(value string, password bool) textinput.Model {
|
|
input := textinput.New()
|
|
input.Prompt = ""
|
|
input.SetValue(value)
|
|
input.Focus()
|
|
input.CharLimit = 512
|
|
input.Width = 48
|
|
if password {
|
|
input.EchoMode = textinput.EchoPassword
|
|
input.EchoCharacter = '•'
|
|
}
|
|
return input
|
|
}
|
|
|
|
fields := []cifsFormTextField{
|
|
{key: "server", label: "Server name or IP", required: true, defaultValue: defaults.Server, input: makeInput(defaults.Server, false)},
|
|
{key: "share", label: "Share name on that server", required: true, defaultValue: defaults.Share, input: makeInput(defaults.Share, false)},
|
|
{key: "mount", label: "Local mount folder", required: true, defaultValue: defaults.MountPoint, input: makeInput(defaults.MountPoint, false)},
|
|
{key: "username", label: "Username for the remote share", required: true, defaultValue: defaults.Username, input: makeInput(defaults.Username, false)},
|
|
{key: "password", label: "Password for the remote share", required: true, defaultValue: defaults.Password, input: makeInput(defaults.Password, true)},
|
|
{key: "domain", label: "Domain or workgroup", defaultValue: defaults.Domain, input: makeInput(defaults.Domain, false)},
|
|
{key: "uid", label: "Local owner username or uid", defaultValue: defaults.UID, input: makeInput(defaults.UID, false)},
|
|
{key: "gid", label: "Local group name or gid", defaultValue: defaults.GID, input: makeInput(defaults.GID, false)},
|
|
{key: "filemode", label: "File permissions", defaultValue: defaultString(defaults.FileMode, "0664"), input: makeInput(defaultString(defaults.FileMode, "0664"), false)},
|
|
{key: "dirmode", label: "Folder permissions", defaultValue: defaultString(defaults.DirMode, "0775"), input: makeInput(defaultString(defaults.DirMode, "0775"), false)},
|
|
}
|
|
|
|
model := cifsMountFormModel{
|
|
theme: theme,
|
|
title: title,
|
|
subtitle: subtitle,
|
|
help: []string{
|
|
"Tab or arrows move between fields.",
|
|
"Enter toggles switches or submits when Save is selected.",
|
|
},
|
|
fields: fields,
|
|
toggles: []cifsFormToggle{
|
|
{label: "Mount automatically at startup", value: defaults.AutoMount || (defaults.Server == "" && defaults.Share == "" && defaults.MountPoint == "")},
|
|
{label: "Make this mount read-only", value: defaults.ReadOnly},
|
|
},
|
|
}
|
|
model.focusCursor()
|
|
return model
|
|
}
|
|
|
|
func (m *cifsMountFormModel) totalItems() int {
|
|
return len(m.fields) + len(m.toggles) + 2
|
|
}
|
|
|
|
func (m *cifsMountFormModel) focusCursor() {
|
|
for i := range m.fields {
|
|
if i == m.cursor {
|
|
m.fields[i].input.Focus()
|
|
} else {
|
|
m.fields[i].input.Blur()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *cifsMountFormModel) moveCursor(delta int) {
|
|
total := m.totalItems()
|
|
if total == 0 {
|
|
return
|
|
}
|
|
m.cursor = (m.cursor + delta + total) % total
|
|
m.focusCursor()
|
|
m.errMessage = ""
|
|
}
|
|
|
|
func (m *cifsMountFormModel) currentConfig() CIFSMountConfig {
|
|
values := map[string]string{}
|
|
for _, field := range m.fields {
|
|
value := strings.TrimSpace(field.input.Value())
|
|
if value == "" {
|
|
value = strings.TrimSpace(field.defaultValue)
|
|
}
|
|
values[field.key] = value
|
|
}
|
|
|
|
return CIFSMountConfig{
|
|
Server: values["server"],
|
|
Share: values["share"],
|
|
MountPoint: values["mount"],
|
|
Username: values["username"],
|
|
Password: values["password"],
|
|
Domain: values["domain"],
|
|
UID: values["uid"],
|
|
GID: values["gid"],
|
|
FileMode: values["filemode"],
|
|
DirMode: values["dirmode"],
|
|
AutoMount: m.toggles[0].value,
|
|
ReadOnly: m.toggles[1].value,
|
|
}
|
|
}
|
|
|
|
func (m *cifsMountFormModel) validate() string {
|
|
for _, field := range m.fields {
|
|
if !field.required {
|
|
continue
|
|
}
|
|
value := strings.TrimSpace(field.input.Value())
|
|
if value == "" {
|
|
value = strings.TrimSpace(field.defaultValue)
|
|
}
|
|
if value == "" {
|
|
return field.label + " is required."
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m cifsMountFormModel) Init() tea.Cmd {
|
|
return textinput.Blink
|
|
}
|
|
|
|
func (m cifsMountFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
available := innerWidth(clampFrameWidth(m.width)) - 8
|
|
if available < 16 {
|
|
available = 16
|
|
}
|
|
for i := range m.fields {
|
|
m.fields[i].input.Width = available
|
|
}
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
m.cancel = true
|
|
return m, tea.Quit
|
|
case "shift+tab", "up", "k":
|
|
m.moveCursor(-1)
|
|
return m, nil
|
|
case "tab", "down", "j":
|
|
m.moveCursor(1)
|
|
return m, nil
|
|
case "enter":
|
|
textCount := len(m.fields)
|
|
toggleStart := textCount
|
|
saveIndex := textCount + len(m.toggles)
|
|
cancelIndex := saveIndex + 1
|
|
|
|
switch {
|
|
case m.cursor < textCount:
|
|
m.moveCursor(1)
|
|
return m, nil
|
|
case m.cursor >= toggleStart && m.cursor < saveIndex:
|
|
idx := m.cursor - toggleStart
|
|
m.toggles[idx].value = !m.toggles[idx].value
|
|
return m, nil
|
|
case m.cursor == saveIndex:
|
|
if errMessage := m.validate(); errMessage != "" {
|
|
m.errMessage = errMessage
|
|
return m, nil
|
|
}
|
|
m.result = m.currentConfig()
|
|
m.submitted = true
|
|
return m, tea.Quit
|
|
case m.cursor == cancelIndex:
|
|
m.cancel = true
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
|
|
if m.cursor < len(m.fields) {
|
|
var cmd tea.Cmd
|
|
m.fields[m.cursor].input, cmd = m.fields[m.cursor].input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m cifsMountFormModel) View() string {
|
|
width := clampFrameWidth(m.width)
|
|
if width == 0 {
|
|
width = clampFrameWidth(84)
|
|
}
|
|
contentWidth := innerWidth(width)
|
|
|
|
formRows := make([]string, 0, len(m.fields)+len(m.toggles)+3)
|
|
for i, field := range m.fields {
|
|
active := i == m.cursor
|
|
boxStyle := m.theme.panel
|
|
if active {
|
|
boxStyle = m.theme.panelAccent
|
|
}
|
|
|
|
label := field.label
|
|
if field.required {
|
|
label += " *"
|
|
}
|
|
fieldLines := []string{m.theme.inputLabel.Render(label)}
|
|
fieldLines = append(fieldLines, m.theme.inputBox.Width(max(12, contentWidth-6)).Render(field.input.View()))
|
|
formRows = append(formRows, boxStyle.Width(contentWidth).Render(strings.Join(fieldLines, "\n")))
|
|
}
|
|
|
|
toggleStart := len(m.fields)
|
|
for i, toggle := range m.toggles {
|
|
active := toggleStart+i == m.cursor
|
|
rowStyle := m.theme.row
|
|
marker := "[ ]"
|
|
if toggle.value {
|
|
marker = "[x]"
|
|
}
|
|
if active {
|
|
rowStyle = m.theme.rowSelected
|
|
}
|
|
formRows = append(formRows, rowStyle.Width(contentWidth).Render(
|
|
m.theme.rowTitle.Render(marker+" "+toggle.label),
|
|
))
|
|
}
|
|
|
|
saveIndex := len(m.fields) + len(m.toggles)
|
|
saveStyle := m.theme.row
|
|
cancelStyle := m.theme.row
|
|
if m.cursor == saveIndex {
|
|
saveStyle = m.theme.rowSelected
|
|
}
|
|
if m.cursor == saveIndex+1 {
|
|
cancelStyle = m.theme.rowSelected
|
|
}
|
|
formRows = append(formRows, saveStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Save mount")))
|
|
formRows = append(formRows, cancelStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Cancel")))
|
|
|
|
previewCfg := m.currentConfig()
|
|
previewLines := []string{
|
|
buildFstabLine(previewCfg),
|
|
"Passwords are written directly into /etc/fstab by this workflow.",
|
|
}
|
|
bodyParts := []string{
|
|
m.theme.renderSection(width, "Workflow", m.help),
|
|
m.theme.sectionTitle.Width(contentWidth).Render("Mount Details"),
|
|
strings.Join(formRows, "\n"),
|
|
m.theme.renderSection(width, "Preview", previewLines),
|
|
}
|
|
if m.errMessage != "" {
|
|
bodyParts = append(bodyParts, m.theme.renderMessage(width, "warn", m.errMessage))
|
|
}
|
|
|
|
return m.theme.renderFrame(
|
|
m.width,
|
|
m.title,
|
|
m.subtitle,
|
|
strings.Join(bodyParts, "\n\n"),
|
|
"tab move • enter next/toggle/save • esc cancel",
|
|
)
|
|
}
|
|
|
|
func runCIFSMountForm(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) (CIFSMountConfig, error) {
|
|
model := newCIFSMountFormModel(theme, title, subtitle, defaults)
|
|
|
|
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
|
|
if err != nil {
|
|
return CIFSMountConfig{}, err
|
|
}
|
|
|
|
final := result.(cifsMountFormModel)
|
|
if final.cancel {
|
|
return CIFSMountConfig{}, ErrCancelled
|
|
}
|
|
return final.result, nil
|
|
}
|