charm bracelet init, brb
This commit is contained in:
407
app.go
407
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
|
||||
}
|
||||
|
||||
|
||||
28
go.mod
28
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
|
||||
)
|
||||
|
||||
49
go.sum
Normal file
49
go.sum
Normal 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=
|
||||
BIN
samba-configer
BIN
samba-configer
Binary file not shown.
414
tui.go
Normal file
414
tui.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user