diff --git a/app.go b/app.go index bbd449e..a2af313 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "bufio" "errors" "fmt" + "io" "os" "os/exec" "os/user" @@ -49,8 +50,10 @@ type App struct { users UserManager runner CommandRunner lookPath LookPathFunc - reader *bufio.Reader + input io.Reader + output io.Writer writer *bufio.Writer + theme tuiTheme } type PasswdEntry struct { @@ -95,8 +98,10 @@ func NewApp(configPath string, users UserManager, runner CommandRunner, lookPath users: users, runner: runner, lookPath: lookPath, - reader: bufio.NewReader(os.Stdin), + input: os.Stdin, + output: os.Stdout, writer: bufio.NewWriter(os.Stdout), + theme: newTUITheme(), } } @@ -119,30 +124,19 @@ func (a *App) Run() error { } func (a *App) chooseStartupWorkflow() (string, error) { - a.println("") - a.println("Samba setup assistant") - a.println("=====================") - a.println("[s] set up or edit shares on this computer") - a.println("[c] connect this computer to a share on another server") - a.println("[q] quit") - a.flush() - - choice, err := a.prompt("What would you like to do") - if err != nil { - return "", err - } - - 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() - } + return a.chooseMenu( + "Choose A Workflow", + "Server shares and client mounts in one place.", + []string{ + "Use the server tools to edit smb.conf and manage share accounts.", + "Use the client tools to add or maintain CIFS mounts in /etc/fstab.", + }, + []menuOption{ + {Key: "s", Value: "server", Label: "Set up or edit shares on this computer", Description: "Create, edit, and save Samba share definitions."}, + {Key: "c", Value: "client", Label: "Connect this computer to a remote share", Description: "Manage CIFS client mounts and mount points."}, + {Key: "q", Value: "quit", Label: "Quit", Description: "Exit without changing anything."}, + }, + ) } func (a *App) runServerWorkflow() error { @@ -161,60 +155,62 @@ func (a *App) runServerWorkflow() error { loaded: for { shareSections := doc.ShareSections() - a.println("") - a.println("Samba share editor") - a.println("==================") - a.println("Current shares:") + intro := []string{"Current shares:"} if len(shareSections) == 0 { - a.println(" (none)") + intro = append(intro, " none yet") } else { - for i, section := range shareSections { + for _, section := range shareSections { 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 errors.Is(err, ErrCancelled) { + return nil + } return err } - switch strings.ToLower(choice) { - case "a", "add": + switch choice { + case "add": if err := a.addShare(doc); err != nil { return err } - case "e", "edit": + case "edit": if err := a.editShare(doc); err != nil { return err } - case "d", "delete": + case "delete": if err := a.deleteShare(doc); err != nil { return err } - case "m", "mount": + case "mount": if err := a.setupClientMount(); err != nil { return err } - case "u", "users": + case "users": if err := a.manageUsers(doc); err != nil { return err } - case "w", "write": + case "write": return a.writeConfig(doc) - case "q", "quit": + case "quit": return nil - default: - a.println("Unknown choice.") } } } @@ -226,84 +222,92 @@ func (a *App) setupClientMount() error { return err } - a.println("") - a.println("Client mount setup") - a.println("==================") - a.println("This computer can connect to Samba or CIFS shares listed in /etc/fstab.") - a.println("Current client mounts:") + intro := []string{ + "Remote shares are persisted in /etc/fstab so they can mount cleanly on boot.", + "Current client mounts:", + } if len(entries) == 0 { - a.println(" (none)") + intro = append(intro, " none yet") } else { - for i, entry := range entries { - a.printf(" %d. %s -> %s\n", i+1, entry.DisplayName, entry.MountPoint) + for _, entry := range entries { + 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 errors.Is(err, ErrCancelled) { + return nil + } return err } - switch strings.ToLower(choice) { - case "a", "add": + switch choice { + case "add": if err := a.addClientMount(); err != nil { return err } - case "e", "edit": + case "edit": if err := a.editClientMount(entries); err != nil { return err } - case "d", "delete": + case "delete": if err := a.deleteClientMount(entries); err != nil { return err } - case "b", "back": + case "back": return nil - default: - a.println("Unknown choice.") } } } func (a *App) manageUsers(doc *Document) error { for { - a.println("") - a.println("User management") - a.println("===============") - a.println("[c] check share accounts") - a.println("[p] change a Samba password") - a.println("[x] delete an unused account") - a.println("[b] back") - a.flush() - - choice, err := a.prompt("Select an action") + choice, err := a.chooseMenu( + "User Management", + "Keep local Linux accounts and Samba credentials aligned with your shares.", + []string{ + fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))), + }, + []menuOption{ + {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."}, + {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 errors.Is(err, ErrCancelled) { + return nil + } return err } - switch strings.ToLower(choice) { - case "c", "check": + switch choice { + case "check": if err := a.checkShareAccounts(doc); err != nil { return err } - case "p", "password": + case "password": if err := a.changeSambaPassword(doc); err != nil { return err } - case "x", "delete": + case "delete": if err := a.deleteUnusedAccount(doc); err != nil { return err } - case "b", "back": + case "back": 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 { references := shareUserReferences(doc) 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 } - a.println("") - a.println("Accounts used by the current shares:") + a.showPanel("Accounts Used By Current Shares", "", nil) for _, ref := range references { status := "missing" if a.users.UserExists(ref.User) { @@ -333,13 +336,11 @@ func (a *App) checkShareAccounts(doc *Document) error { } if len(missing) == 0 { - a.println("") - a.println("All listed share accounts already exist.") + a.showMessage("success", "All listed share accounts already exist.") return nil } - a.println("") - a.println("Some accounts are missing and those users may not be able to sign in.") + a.showMessage("warn", "Some accounts are missing and those users may not be able to sign in.") if err := a.ensureUsers(missing); err != nil { return err } @@ -354,50 +355,55 @@ func (a *App) deleteUnusedAccount(doc *Document) error { candidates := unusedAccountCandidates(entries, shareUsers(doc)) if len(candidates) == 0 { - a.println("I couldn't find any obvious unused share accounts to delete.") - a.println("For safety, system accounts are excluded from this list.") + a.showPanel("Unused Account Cleanup", "No obvious removable share accounts were found.", []string{ + "For safety, system accounts are excluded from this list.", + }) return nil } - a.println("") - a.println("Accounts that look unused by the current shares:") + options := make([]menuOption, 0, len(candidates)+1) 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.") - a.flush() + options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."}) - 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 { return err } - - index, err := strconv.Atoi(raw) - if err != nil || index < 1 || index > len(candidates) { - a.println("Invalid selection.") + if choice == "" { return nil } - entry := candidates[index-1] - a.println("") - a.printf("This will remove the local account %q.\n", entry.Name) - a.println("This is only safe if nobody needs this account for Samba or anything else.") - a.flush() + a.showPanel("Delete Account", "", []string{ + fmt.Sprintf("This will remove the local account %q.", choice), + "This is only safe if nobody needs this account for Samba or anything else.", + }) confirm, err := a.confirm("Delete this account now", false) if err != nil { return err } if !confirm { - a.println("Delete cancelled.") + a.showMessage("info", "Delete cancelled.") return nil } - if err := a.deleteUser(entry.Name); err != nil { + if err := a.deleteUser(choice); err != nil { return err } - a.printf("Deleted account %s\n", entry.Name) + a.showMessage("success", "Deleted account "+choice) return nil } @@ -412,38 +418,50 @@ func (a *App) changeSambaPassword(doc *Document) error { references := shareUserReferences(doc) 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 } - a.println("") - a.println("Accounts used by the current shares:") + options := make([]menuOption, 0, len(references)+1) for i, ref := range references { status := "missing" if a.users.UserExists(ref.User) { 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 { return err } - - index, err := strconv.Atoi(raw) - if err != nil || index < 1 || index > len(references) { - a.println("Invalid selection.") + if choice == "" { 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) { - a.println("") - a.printf("The local account %q does not exist yet.\n", ref.User) - a.println("I can create it first so a Samba password can be set.") - a.flush() + a.showPanel("Local User Missing", "", []string{ + fmt.Sprintf("The local account %q does not exist yet.", ref.User), + "I can create it first so a Samba password can be set.", + }) create, promptErr := a.confirm("Create this account now", true) if promptErr != nil { @@ -458,10 +476,10 @@ func (a *App) changeSambaPassword(doc *Document) error { } } - a.println("") - a.printf("I’ll open the password setup for %q now.\n", ref.User) - a.println("You’ll be asked to type the new Samba password.") - a.flush() + a.showPanel("Password Update", "", []string{ + fmt.Sprintf("I will open the password setup for %q now.", ref.User), + "You will be asked to type the new Samba password.", + }) return a.setSambaPassword(ref.User) } @@ -836,22 +854,30 @@ func (a *App) ensureShareDirectories(doc *Document) error { func (a *App) selectShare(doc *Document) (*Section, error) { shares := doc.ShareSections() if len(shares) == 0 { - a.println("There are no shares to select.") + a.showMessage("info", "There are no shares to select.") 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 { return nil, err } - - index, err := strconv.Atoi(raw) - if err != nil || index < 1 || index > len(shares) { - a.println("Invalid selection.") + if raw == "" { return nil, nil } - - return shares[index-1], nil + return doc.Section(raw), nil } 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) { - a.printf("%s: ", label) a.flush() - text, err := a.reader.ReadString('\n') - if err != nil { - return "", err - } - return strings.TrimSpace(text), nil + return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, "") } func (a *App) promptDefault(label, defaultValue string) (string, error) { - if defaultValue == "" { - return a.prompt(label) - } - - a.printf("%s [%s]: ", label, defaultValue) a.flush() - text, err := a.reader.ReadString('\n') - if err != nil { - return "", err - } - text = strings.TrimSpace(text) - if text == "" { - return defaultValue, nil - } - return text, nil + return runTextPrompt(a.input, a.output, a.theme, "Enter A Value", "Field-by-field configuration", label, defaultValue) } func (a *App) confirm(label string, defaultYes bool) (bool, error) { - suffix := "y/N" - if defaultYes { - suffix = "Y/n" + options := []menuOption{ + {Key: "y", Value: "yes", Label: "Yes", Description: "Continue with this action."}, + {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 { return false, err } - if answer == "" { - return defaultYes, nil - } - - switch strings.ToLower(answer) { - case "y", "yes": + switch answer { + case "yes": return true, nil - case "n", "no": + case "no": return false, nil default: - return false, errors.New("expected yes or no") + return defaultYes, nil } } @@ -1123,6 +1130,24 @@ func (a *App) 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 { if strings.TrimSpace(value) == "" { return fallback @@ -1597,22 +1622,34 @@ func (a *App) deleteClientMount(entries []FstabMountEntry) error { func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, error) { 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 } - 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 { return nil, err } - - index, err := strconv.Atoi(selection) - if err != nil || index < 1 || index > len(entries) { - a.println("Invalid selection.") + if selection == "" { 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 } diff --git a/go.mod b/go.mod index f5562f0..79d8317 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,31 @@ module samba-configer 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..48560c5 --- /dev/null +++ b/go.sum @@ -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= diff --git a/samba-configer b/samba-configer index 8dfec29..fe5ad31 100755 Binary files a/samba-configer and b/samba-configer differ diff --git a/tui.go b/tui.go new file mode 100644 index 0000000..4780095 --- /dev/null +++ b/tui.go @@ -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 +}