charm bracelet init, brb

This commit is contained in:
2026-03-19 22:14:42 +00:00
parent e88e3a4bb0
commit a997b184f5
5 changed files with 713 additions and 185 deletions

407
app.go
View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
@@ -49,8 +50,10 @@ type App struct {
users UserManager users UserManager
runner CommandRunner runner CommandRunner
lookPath LookPathFunc lookPath LookPathFunc
reader *bufio.Reader input io.Reader
output io.Writer
writer *bufio.Writer writer *bufio.Writer
theme tuiTheme
} }
type PasswdEntry struct { type PasswdEntry struct {
@@ -95,8 +98,10 @@ func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath
users: users, users: users,
runner: runner, runner: runner,
lookPath: lookPath, lookPath: lookPath,
reader: bufio.NewReader(os.Stdin), input: os.Stdin,
output: os.Stdout,
writer: bufio.NewWriter(os.Stdout), writer: bufio.NewWriter(os.Stdout),
theme: newTUITheme(),
} }
} }
@@ -119,30 +124,19 @@ func (a *App) Run() error {
} }
func (a *App) chooseStartupWorkflow() (string, error) { func (a *App) chooseStartupWorkflow() (string, error) {
a.println("") return a.chooseMenu(
a.println("Samba setup assistant") "Choose A Workflow",
a.println("=====================") "Server shares and client mounts in one place.",
a.println("[s] set up or edit shares on this computer") []string{
a.println("[c] connect this computer to a share on another server") "Use the server tools to edit smb.conf and manage share accounts.",
a.println("[q] quit") "Use the client tools to add or maintain CIFS mounts in /etc/fstab.",
a.flush() },
[]menuOption{
choice, err := a.prompt("What would you like to do") {Key: "s", Value: "server", Label: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."},
if err != nil { {Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."},
return "", err {Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."},
} },
)
switch strings.ToLower(choice) {
case "s", "server":
return "server", nil
case "c", "client":
return "client", nil
case "q", "quit":
return "quit", nil
default:
a.println("Unknown choice.")
return a.chooseStartupWorkflow()
}
} }
func (a *App) runServerWorkflow() error { func (a *App) runServerWorkflow() error {
@@ -161,60 +155,62 @@ func (a *App) runServerWorkflow() error {
loaded: loaded:
for { for {
shareSections := doc.ShareSections() shareSections := doc.ShareSections()
a.println("") intro := []string{"Current shares:"}
a.println("Samba share editor")
a.println("==================")
a.println("Current shares:")
if len(shareSections) == 0 { if len(shareSections) == 0 {
a.println(" (none)") intro = append(intro, " none yet")
} else { } else {
for i, section := range shareSections { for _, section := range shareSections {
cfg := ShareFromSection(section) cfg := ShareFromSection(section)
a.printf(" %d. %s -> %s\n", i+1, cfg.Name, cfg.Path) intro = append(intro, fmt.Sprintf(" %s -> %s", cfg.Name, cfg.Path))
} }
} }
a.println("")
a.println("[a] add share")
a.println("[e] edit share")
a.println("[d] delete share")
a.println("[m] set up client mount")
a.println("[u] manage users")
a.println("[w] write config and exit")
a.println("[q] quit without saving")
a.flush()
choice, err := a.prompt("Select an action") choice, err := a.chooseMenu(
"Server Share Editor",
fmt.Sprintf("Working against %s", a.configPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add share", Description: "Create a new share definition."},
{Key: "e", Value: "edit", Label: "Edit share", Description: "Update an existing share."},
{Key: "d", Value: "delete", Label: "Delete share", Description: "Remove a share definition."},
{Key: "m", Value: "mount", Label: "Set up client mount", Description: "Jump to the remote-mount workflow."},
{Key: "u", Value: "users", Label: "Manage users", Description: "Check accounts, passwords, and cleanup."},
{Key: "w", Value: "write", Label: "Write config and exit", Description: "Save smb.conf and leave the app."},
{Key: "q", Value: "quit", Label: "Quit without saving", Description: "Leave the editor immediately."},
},
)
if err != nil { if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err return err
} }
switch strings.ToLower(choice) { switch choice {
case "a", "add": case "add":
if err := a.addShare(doc); err != nil { if err := a.addShare(doc); err != nil {
return err return err
} }
case "e", "edit": case "edit":
if err := a.editShare(doc); err != nil { if err := a.editShare(doc); err != nil {
return err return err
} }
case "d", "delete": case "delete":
if err := a.deleteShare(doc); err != nil { if err := a.deleteShare(doc); err != nil {
return err return err
} }
case "m", "mount": case "mount":
if err := a.setupClientMount(); err != nil { if err := a.setupClientMount(); err != nil {
return err return err
} }
case "u", "users": case "users":
if err := a.manageUsers(doc); err != nil { if err := a.manageUsers(doc); err != nil {
return err return err
} }
case "w", "write": case "write":
return a.writeConfig(doc) return a.writeConfig(doc)
case "q", "quit": case "quit":
return nil return nil
default:
a.println("Unknown choice.")
} }
} }
} }
@@ -226,84 +222,92 @@ func (a *App) setupClientMount() error {
return err return err
} }
a.println("") intro := []string{
a.println("Client mount setup") "Remote shares are persisted in /etc/fstab so they can mount cleanly on boot.",
a.println("==================") "Current client mounts:",
a.println("This computer can connect to Samba or CIFS shares listed in /etc/fstab.") }
a.println("Current client mounts:")
if len(entries) == 0 { if len(entries) == 0 {
a.println(" (none)") intro = append(intro, " none yet")
} else { } else {
for i, entry := range entries { for _, entry := range entries {
a.printf(" %d. %s -> %s\n", i+1, entry.DisplayName, entry.MountPoint) intro = append(intro, fmt.Sprintf(" %s -> %s", entry.DisplayName, entry.MountPoint))
} }
} }
a.println("")
a.println("[a] add client mount")
a.println("[e] edit client mount")
a.println("[d] delete client mount")
a.println("[b] back")
a.flush()
choice, err := a.prompt("Select an action") choice, err := a.chooseMenu(
"Client Mount Setup",
fmt.Sprintf("Editing %s", a.fstabPath),
intro,
[]menuOption{
{Key: "a", Value: "add", Label: "Add client mount", Description: "Create a new CIFS mount entry."},
{Key: "e", Value: "edit", Label: "Edit client mount", Description: "Change an existing mount definition."},
{Key: "d", Value: "delete", Label: "Delete client mount", Description: "Remove a saved mount entry."},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the previous menu."},
},
)
if err != nil { if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err return err
} }
switch strings.ToLower(choice) { switch choice {
case "a", "add": case "add":
if err := a.addClientMount(); err != nil { if err := a.addClientMount(); err != nil {
return err return err
} }
case "e", "edit": case "edit":
if err := a.editClientMount(entries); err != nil { if err := a.editClientMount(entries); err != nil {
return err return err
} }
case "d", "delete": case "delete":
if err := a.deleteClientMount(entries); err != nil { if err := a.deleteClientMount(entries); err != nil {
return err return err
} }
case "b", "back": case "back":
return nil return nil
default:
a.println("Unknown choice.")
} }
} }
} }
func (a *App) manageUsers(doc *Document) error { func (a *App) manageUsers(doc *Document) error {
for { for {
a.println("") choice, err := a.chooseMenu(
a.println("User management") "User Management",
a.println("===============") "Keep local Linux accounts and Samba credentials aligned with your shares.",
a.println("[c] check share accounts") []string{
a.println("[p] change a Samba password") fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))),
a.println("[x] delete an unused account") },
a.println("[b] back") []menuOption{
a.flush() {Key: "c", Value: "check", Label: "Check share accounts", Description: "Create missing local users referenced by shares."},
{Key: "p", Value: "password", Label: "Change a Samba password", Description: "Set or update a Samba credential."},
choice, err := a.prompt("Select an action") {Key: "x", Value: "delete", Label: "Delete an unused account", Description: "Remove unused share-style accounts."},
{Key: "b", Value: "back", Label: "Back", Description: "Return to the share editor."},
},
)
if err != nil { if err != nil {
if errors.Is(err, ErrCancelled) {
return nil
}
return err return err
} }
switch strings.ToLower(choice) { switch choice {
case "c", "check": case "check":
if err := a.checkShareAccounts(doc); err != nil { if err := a.checkShareAccounts(doc); err != nil {
return err return err
} }
case "p", "password": case "password":
if err := a.changeSambaPassword(doc); err != nil { if err := a.changeSambaPassword(doc); err != nil {
return err return err
} }
case "x", "delete": case "delete":
if err := a.deleteUnusedAccount(doc); err != nil { if err := a.deleteUnusedAccount(doc); err != nil {
return err return err
} }
case "b", "back": case "back":
return nil return nil
default:
a.println("Unknown choice.")
} }
} }
} }
@@ -311,12 +315,11 @@ func (a *App) manageUsers(doc *Document) error {
func (a *App) checkShareAccounts(doc *Document) error { func (a *App) checkShareAccounts(doc *Document) error {
references := shareUserReferences(doc) references := shareUserReferences(doc)
if len(references) == 0 { if len(references) == 0 {
a.println("No accounts are listed in any share.") a.showMessage("info", "No accounts are listed in any share.")
return nil return nil
} }
a.println("") a.showPanel("Accounts Used By Current Shares", "", nil)
a.println("Accounts used by the current shares:")
for _, ref := range references { for _, ref := range references {
status := "missing" status := "missing"
if a.users.UserExists(ref.User) { if a.users.UserExists(ref.User) {
@@ -333,13 +336,11 @@ func (a *App) checkShareAccounts(doc *Document) error {
} }
if len(missing) == 0 { if len(missing) == 0 {
a.println("") a.showMessage("success", "All listed share accounts already exist.")
a.println("All listed share accounts already exist.")
return nil return nil
} }
a.println("") a.showMessage("warn", "Some accounts are missing and those users may not be able to sign in.")
a.println("Some accounts are missing and those users may not be able to sign in.")
if err := a.ensureUsers(missing); err != nil { if err := a.ensureUsers(missing); err != nil {
return err return err
} }
@@ -354,50 +355,55 @@ func (a *App) deleteUnusedAccount(doc *Document) error {
candidates := unusedAccountCandidates(entries, shareUsers(doc)) candidates := unusedAccountCandidates(entries, shareUsers(doc))
if len(candidates) == 0 { if len(candidates) == 0 {
a.println("I couldn't find any obvious unused share accounts to delete.") a.showPanel("Unused Account Cleanup", "No obvious removable share accounts were found.", []string{
a.println("For safety, system accounts are excluded from this list.") "For safety, system accounts are excluded from this list.",
})
return nil return nil
} }
a.println("") options := make([]menuOption, 0, len(candidates)+1)
a.println("Accounts that look unused by the current shares:")
for i, entry := range candidates { for i, entry := range candidates {
a.printf(" %d. %s (shell: %s)\n", i+1, entry.Name, entry.Shell) options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: entry.Name,
Label: entry.Name,
Description: "shell: " + strings.TrimSpace(entry.Shell),
})
} }
a.println("Only accounts that are not listed in any share are shown here.") options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."})
a.flush()
raw, err := a.prompt("Account number to delete") choice, err := a.chooseMenu(
"Delete An Unused Account",
"Only accounts that are not listed in any share are shown here.",
nil,
options,
)
if err != nil { if err != nil {
return err return err
} }
if choice == "" {
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(candidates) {
a.println("Invalid selection.")
return nil return nil
} }
entry := candidates[index-1] a.showPanel("Delete Account", "", []string{
a.println("") fmt.Sprintf("This will remove the local account %q.", choice),
a.printf("This will remove the local account %q.\n", entry.Name) "This is only safe if nobody needs this account for Samba or anything else.",
a.println("This is only safe if nobody needs this account for Samba or anything else.") })
a.flush()
confirm, err := a.confirm("Delete this account now", false) confirm, err := a.confirm("Delete this account now", false)
if err != nil { if err != nil {
return err return err
} }
if !confirm { if !confirm {
a.println("Delete cancelled.") a.showMessage("info", "Delete cancelled.")
return nil return nil
} }
if err := a.deleteUser(entry.Name); err != nil { if err := a.deleteUser(choice); err != nil {
return err return err
} }
a.printf("Deleted account %s\n", entry.Name) a.showMessage("success", "Deleted account "+choice)
return nil return nil
} }
@@ -412,38 +418,50 @@ func (a *App) changeSambaPassword(doc *Document) error {
references := shareUserReferences(doc) references := shareUserReferences(doc)
if len(references) == 0 { if len(references) == 0 {
a.println("No accounts are listed in any share.") a.showMessage("info", "No accounts are listed in any share.")
return nil return nil
} }
a.println("") options := make([]menuOption, 0, len(references)+1)
a.println("Accounts used by the current shares:")
for i, ref := range references { for i, ref := range references {
status := "missing" status := "missing"
if a.users.UserExists(ref.User) { if a.users.UserExists(ref.User) {
status = "present" status = "present"
} }
a.printf(" %d. %s [%s] used by: %s\n", i+1, ref.User, status, strings.Join(ref.Shares, ", ")) options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: ref.User,
Label: ref.User + " [" + status + "]",
Description: "used by: " + strings.Join(ref.Shares, ", "),
})
} }
a.flush() options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return without changing a password."})
raw, err := a.prompt("Account number to update") choice, err := a.chooseMenu(
"Choose An Account",
"Select the local user whose Samba password you want to change.",
nil,
options,
)
if err != nil { if err != nil {
return err return err
} }
if choice == "" {
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(references) {
a.println("Invalid selection.")
return nil return nil
} }
ref := references[index-1] var ref ShareUserReference
for _, candidate := range references {
if candidate.User == choice {
ref = candidate
break
}
}
if !a.users.UserExists(ref.User) { if !a.users.UserExists(ref.User) {
a.println("") a.showPanel("Local User Missing", "", []string{
a.printf("The local account %q does not exist yet.\n", ref.User) fmt.Sprintf("The local account %q does not exist yet.", ref.User),
a.println("I can create it first so a Samba password can be set.") "I can create it first so a Samba password can be set.",
a.flush() })
create, promptErr := a.confirm("Create this account now", true) create, promptErr := a.confirm("Create this account now", true)
if promptErr != nil { if promptErr != nil {
@@ -458,10 +476,10 @@ func (a *App) changeSambaPassword(doc *Document) error {
} }
} }
a.println("") a.showPanel("Password Update", "", []string{
a.printf("Ill open the password setup for %q now.\n", ref.User) fmt.Sprintf("I will open the password setup for %q now.", ref.User),
a.println("Youll be asked to type the new Samba password.") "You will be asked to type the new Samba password.",
a.flush() })
return a.setSambaPassword(ref.User) return a.setSambaPassword(ref.User)
} }
@@ -836,22 +854,30 @@ func (a *App) ensureShareDirectories(doc *Document) error {
func (a *App) selectShare(doc *Document) (*Section, error) { func (a *App) selectShare(doc *Document) (*Section, error) {
shares := doc.ShareSections() shares := doc.ShareSections()
if len(shares) == 0 { if len(shares) == 0 {
a.println("There are no shares to select.") a.showMessage("info", "There are no shares to select.")
return nil, nil return nil, nil
} }
raw, err := a.prompt("Share number") options := make([]menuOption, 0, len(shares)+1)
for i, share := range shares {
cfg := ShareFromSection(share)
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: share.Name,
Label: cfg.Name,
Description: cfg.Path,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."})
raw, err := a.chooseMenu("Choose A Share", "Select the share you want to work with.", nil, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if raw == "" {
index, err := strconv.Atoi(raw)
if err != nil || index < 1 || index > len(shares) {
a.println("Invalid selection.")
return nil, nil return nil, nil
} }
return doc.Section(raw), nil
return shares[index-1], nil
} }
func (a *App) collectShareConfig(existing ShareConfig) (ShareConfig, error) { func (a *App) collectShareConfig(existing ShareConfig) (ShareConfig, error) {
@@ -1060,54 +1086,35 @@ func (a *App) deleteUser(name string) error {
} }
func (a *App) prompt(label string) (string, error) { func (a *App) prompt(label string) (string, error) {
a.printf("%s: ", label)
a.flush() a.flush()
text, err := a.reader.ReadString('\n') return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, "")
if err != nil {
return "", err
}
return strings.TrimSpace(text), nil
} }
func (a *App) promptDefault(label, defaultValue string) (string, error) { func (a *App) promptDefault(label, defaultValue string) (string, error) {
if defaultValue == "" {
return a.prompt(label)
}
a.printf("%s [%s]: ", label, defaultValue)
a.flush() a.flush()
text, err := a.reader.ReadString('\n') return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, defaultValue)
if err != nil {
return "", err
}
text = strings.TrimSpace(text)
if text == "" {
return defaultValue, nil
}
return text, nil
} }
func (a *App) confirm(label string, defaultYes bool) (bool, error) { func (a *App) confirm(label string, defaultYes bool) (bool, error) {
suffix := "y/N" options := []menuOption{
if defaultYes { {Key: "y", Value: "yes", Label: "Yes", Description: "Continue with this action."},
suffix = "Y/n" {Key: "n", Value: "no", Label: "No", Description: "Leave things as they are."},
}
if !defaultYes {
options[0], options[1] = options[1], options[0]
} }
answer, err := a.prompt(fmt.Sprintf("%s [%s]", label, suffix)) answer, err := a.chooseMenu("Confirm Action", label, nil, options)
if err != nil { if err != nil {
return false, err return false, err
} }
if answer == "" { switch answer {
return defaultYes, nil case "yes":
}
switch strings.ToLower(answer) {
case "y", "yes":
return true, nil return true, nil
case "n", "no": case "no":
return false, nil return false, nil
default: default:
return false, errors.New("expected yes or no") return defaultYes, nil
} }
} }
@@ -1123,6 +1130,24 @@ func (a *App) flush() {
_ = a.writer.Flush() _ = a.writer.Flush()
} }
func (a *App) chooseMenu(title, subtitle string, intro []string, options []menuOption) (string, error) {
a.flush()
choice, err := runMenuPrompt(a.input, a.output, a.theme, title, subtitle, intro, options)
a.flush()
return choice, err
}
func (a *App) showPanel(title, subtitle string, lines []string) {
body := a.theme.renderSection(84, "", lines)
fmt.Fprintln(a.writer, a.theme.renderFrame(84, title, subtitle, body, ""))
a.flush()
}
func (a *App) showMessage(kind, message string) {
fmt.Fprintln(a.writer, a.theme.renderFrame(84, "Status", "", a.theme.renderMessage(84, kind, message), ""))
a.flush()
}
func defaultString(value, fallback string) string { func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
return fallback return fallback
@@ -1597,22 +1622,34 @@ func (a *App) deleteClientMount(entries []FstabMountEntry) error {
func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) { func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) {
if len(entries) == 0 { if len(entries) == 0 {
a.println("There are no client mounts to select.") a.showMessage("info", "There are no client mounts to select.")
return nil, nil return nil, nil
} }
selection, err := a.prompt("Select mount number") options := make([]menuOption, 0, len(entries)+1)
for i, entry := range entries {
options = append(options, menuOption{
Key: fmt.Sprintf("%d", i+1),
Value: strconv.Itoa(i),
Label: entry.DisplayName,
Description: entry.MountPoint,
})
}
options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."})
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 {
return nil, err return nil, err
} }
if selection == "" {
index, err := strconv.Atoi(selection)
if err != nil || index < 1 || index > len(entries) {
a.println("Invalid selection.")
return nil, nil return nil, nil
} }
entry := entries[index-1] index, err := strconv.Atoi(selection)
if err != nil || index < 0 || index >= len(entries) {
return nil, nil
}
entry := entries[index]
return &entry, nil return &entry, nil
} }

28
go.mod
View File

@@ -1,3 +1,31 @@
module samba-configer module samba-configer
go 1.25.0 go 1.25.0
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.3.8 // indirect
)

49
go.sum Normal file
View File

@@ -0,0 +1,49 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=

Binary file not shown.

414
tui.go Normal file
View File

@@ -0,0 +1,414 @@
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
badge lipgloss.Style
title lipgloss.Style
subtitle lipgloss.Style
sectionTitle lipgloss.Style
body lipgloss.Style
muted lipgloss.Style
footer lipgloss.Style
panel lipgloss.Style
row lipgloss.Style
rowSelected lipgloss.Style
rowKey lipgloss.Style
rowKeySelected 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("#4B5D6B")
text := lipgloss.Color("#E9E1D4")
muted := lipgloss.Color("#9E9385")
accent := lipgloss.Color("#D97745")
accentSoft := lipgloss.Color("#F4D8BD")
panel := lipgloss.Color("#192126")
panelAlt := lipgloss.Color("#232E36")
info := lipgloss.Color("#7FB0C2")
warn := lipgloss.Color("#D7A55A")
success := lipgloss.Color("#8AAC83")
return tuiTheme{
outer: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(border).
Padding(1, 2),
badge: lipgloss.NewStyle().
Foreground(panel).
Background(accentSoft).
Bold(true).
Padding(0, 1),
title: lipgloss.NewStyle().
Foreground(text).
Bold(true),
subtitle: lipgloss.NewStyle().
Foreground(muted),
sectionTitle: lipgloss.NewStyle().
Foreground(accentSoft).
Bold(true).
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(border).
PaddingTop(1),
panel: lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, false, true).
BorderForeground(border).
PaddingLeft(1),
row: lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(panelAlt).
PaddingLeft(1),
rowSelected: lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(accent).
Background(panel).
PaddingLeft(1),
rowKey: lipgloss.NewStyle().
Foreground(accent).
Bold(true),
rowKeySelected: lipgloss.NewStyle().
Foreground(accentSoft).
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),
}
}
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
}
sections := []string{
t.badge.Render("SAMBA CONFIGER"),
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(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),
)
}
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
hintStyle := m.theme.rowHint
prefix := m.theme.muted.Render(fmt.Sprintf("%02d", i+1))
if i == m.cursor {
rowStyle = m.theme.rowSelected
keyStyle = m.theme.rowKeySelected
hintStyle = m.theme.rowHintActive
prefix = keyStyle.Render(">>")
}
label := fmt.Sprintf("%s %s %s", prefix, keyStyle.Render("["+option.Key+"]"), option.Label)
rowLines := []string{label}
if option.Description != "" {
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),
m.theme.inputBox.Width(contentWidth).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)).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)).Run()
if err != nil {
return "", err
}
final := result.(textPromptModel)
if final.cancel {
return "", ErrCancelled
}
return final.value, nil
}