Files
samba-configer/tui.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
}