'more bubbletea, less look bad plz'

This commit is contained in:
2026-03-19 22:51:41 +00:00
parent a79666f5a6
commit db6b693e53
3 changed files with 327 additions and 32 deletions

58
app.go
View File

@@ -132,9 +132,9 @@ func (a *App) chooseStartupWorkflow() (string, error) {
"Use the client tools to add or maintain CIFS mounts in /etc/fstab.", "Use the client tools to add or maintain CIFS mounts in /etc/fstab.",
}, },
[]menuOption{ []menuOption{
{Key: "s", Value: "server", Label: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."}, {Key: "s", Value: "server", Label: "Server shares"},
{Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."}, {Key: "c", Value: "client", Label: "Client mounts"},
{Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."}, {Key: "q", Value: "quit", Label: "Quit"},
}, },
) )
} }
@@ -170,13 +170,13 @@ loaded:
fmt.Sprintf("Working against %s", a.configPath), fmt.Sprintf("Working against %s", a.configPath),
intro, intro,
[]menuOption{ []menuOption{
{Key: "a", Value: "add", Label: "Add share", Description: "Create a new share definition."}, {Key: "a", Value: "add", Label: "Add share"},
{Key: "e", Value: "edit", Label: "Edit share", Description: "Update an existing share."}, {Key: "e", Value: "edit", Label: "Edit share"},
{Key: "d", Value: "delete", Label: "Delete share", Description: "Remove a share definition."}, {Key: "d", Value: "delete", Label: "Delete share"},
{Key: "m", Value: "mount", Label: "Set up client mount", Description: "Jump to the remote-mount workflow."}, {Key: "m", Value: "mount", Label: "Client mounts"},
{Key: "u", Value: "users", Label: "Manage users", Description: "Check accounts, passwords, and cleanup."}, {Key: "u", Value: "users", Label: "Users"},
{Key: "w", Value: "write", Label: "Write config and exit", Description: "Save smb.conf and leave the app."}, {Key: "w", Value: "write", Label: "Write and exit"},
{Key: "q", Value: "quit", Label: "Quit without saving", Description: "Leave the editor immediately."}, {Key: "q", Value: "quit", Label: "Quit"},
}, },
) )
if err != nil { if err != nil {
@@ -239,10 +239,10 @@ func (a *App) setupClientMount() error {
fmt.Sprintf("Editing %s", a.fstabPath), fmt.Sprintf("Editing %s", a.fstabPath),
intro, intro,
[]menuOption{ []menuOption{
{Key: "a", Value: "add", Label: "Add client mount", Description: "Create a new CIFS mount entry."}, {Key: "a", Value: "add", Label: "Add mount"},
{Key: "e", Value: "edit", Label: "Edit client mount", Description: "Change an existing mount definition."}, {Key: "e", Value: "edit", Label: "Edit mount"},
{Key: "d", Value: "delete", Label: "Delete client mount", Description: "Remove a saved mount entry."}, {Key: "d", Value: "delete", Label: "Delete mount"},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the previous menu."}, {Key: "b", Value: "back", Label: "Back"},
}, },
) )
if err != nil { if err != nil {
@@ -280,10 +280,10 @@ func (a *App) manageUsers(doc *Document) error {
fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))), fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))),
}, },
[]menuOption{ []menuOption{
{Key: "c", Value: "check", Label: "Check share accounts", Description: "Create missing local users referenced by shares."}, {Key: "c", Value: "check", Label: "Check accounts"},
{Key: "p", Value: "password", Label: "Change a Samba password", Description: "Set or update a Samba credential."}, {Key: "p", Value: "password", Label: "Change password"},
{Key: "x", Value: "delete", Label: "Delete an unused account", Description: "Remove unused share-style accounts."}, {Key: "x", Value: "delete", Label: "Delete account"},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the share editor."}, {Key: "b", Value: "back", Label: "Back"},
}, },
) )
if err != nil { if err != nil {
@@ -370,7 +370,7 @@ func (a *App) deleteUnusedAccount(doc *Document) error {
Description: "shell: " + strings.TrimSpace(entry.Shell), Description: "shell: " + strings.TrimSpace(entry.Shell),
}) })
} }
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."}) options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
choice, err := a.chooseMenu( choice, err := a.chooseMenu(
"Delete An Unused Account", "Delete An Unused Account",
@@ -435,7 +435,7 @@ func (a *App) changeSambaPassword(doc *Document) error {
Description: "used by: " + strings.Join(ref.Shares, ", "), Description: "used by: " + strings.Join(ref.Shares, ", "),
}) })
} }
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return without changing a password."}) options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
choice, err := a.chooseMenu( choice, err := a.chooseMenu(
"Choose An Account", "Choose An Account",
@@ -667,7 +667,7 @@ func (a *App) collectCIFSMountConfig(defaults CIFSMountConfig) (CIFSMountConfig,
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
share, err := a.promptDefault("Share name on that server", defaults.Share) share, err := a.promptDefault("Share name", defaults.Share)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
@@ -679,23 +679,23 @@ func (a *App) collectCIFSMountConfig(defaults CIFSMountConfig) (CIFSMountConfig,
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
username, err := a.promptDefault("Username for the remote share", defaults.Username) username, err := a.promptDefault("Remote username", defaults.Username)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
password, err := a.promptDefault("Password for the remote share", defaults.Password) password, err := a.promptDefault("Remote password", defaults.Password)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
domain, err := a.promptDefault("Domain or workgroup (optional)", defaults.Domain) domain, err := a.promptDefault("Domain or workgroup", defaults.Domain)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
uid, err := a.promptDefault("Local owner username or uid (optional)", defaults.UID) uid, err := a.promptDefault("Local owner username or uid", defaults.UID)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
gid, err := a.promptDefault("Local group name or gid (optional)", defaults.GID) gid, err := a.promptDefault("Local group name or gid", defaults.GID)
if err != nil { if err != nil {
return CIFSMountConfig{}, err return CIFSMountConfig{}, err
} }
@@ -1143,8 +1143,8 @@ func (a *App) promptDefault(label, defaultValue string) (string, error) {
func (a *App) confirm(label string, defaultYes bool) (bool, error) { func (a *App) confirm(label string, defaultYes bool) (bool, error) {
options := []menuOption{ options := []menuOption{
{Key: "y", Value: "yes", Label: "Yes", Description: "Continue with this action."}, {Key: "y", Value: "yes", Label: "Yes"},
{Key: "n", Value: "no", Label: "No", Description: "Leave things as they are."}, {Key: "n", Value: "no", Label: "No"},
} }
if !defaultYes { if !defaultYes {
options[0], options[1] = options[1], options[0] options[0], options[1] = options[1], options[0]
@@ -1681,7 +1681,7 @@ func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, erro
Description: entry.MountPoint, Description: entry.MountPoint,
}) })
} }
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."}) options = append(options, menuOption{Key: "b", Value: "", Label: "Back"})
selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options) selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options)
if err != nil { if err != nil {

Binary file not shown.

301
tui.go
View File

@@ -332,7 +332,7 @@ func (m menuModel) View() string {
) )
rowLines := []string{top} rowLines := []string{top}
if option.Description != "" { if option.Description != "" && width >= 72 {
rowLines = append(rowLines, hintStyle.Render(option.Description)) rowLines = append(rowLines, hintStyle.Render(option.Description))
} }
rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n"))) rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n")))
@@ -445,7 +445,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle
options: options, options: options,
} }
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -460,7 +460,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle
func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) { func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) {
model := newTextPromptModel(theme, title, subtitle, label, defaultValue) model := newTextPromptModel(theme, title, subtitle, label, defaultValue)
result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -471,3 +471,298 @@ func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle,
} }
return final.value, nil 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
}