diff --git a/app.go b/app.go index 2018823..b777fb1 100644 --- a/app.go +++ b/app.go @@ -132,9 +132,9 @@ func (a *App) chooseStartupWorkflow() (string, error) { "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."}, + {Key: "s", Value: "server", Label: "Server shares"}, + {Key: "c", Value: "client", Label: "Client mounts"}, + {Key: "q", Value: "quit", Label: "Quit"}, }, ) } @@ -170,13 +170,13 @@ loaded: 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."}, + {Key: "a", Value: "add", Label: "Add share"}, + {Key: "e", Value: "edit", Label: "Edit share"}, + {Key: "d", Value: "delete", Label: "Delete share"}, + {Key: "m", Value: "mount", Label: "Client mounts"}, + {Key: "u", Value: "users", Label: "Users"}, + {Key: "w", Value: "write", Label: "Write and exit"}, + {Key: "q", Value: "quit", Label: "Quit"}, }, ) if err != nil { @@ -239,10 +239,10 @@ func (a *App) setupClientMount() error { 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."}, + {Key: "a", Value: "add", Label: "Add mount"}, + {Key: "e", Value: "edit", Label: "Edit mount"}, + {Key: "d", Value: "delete", Label: "Delete mount"}, + {Key: "b", Value: "back", Label: "Back"}, }, ) if err != nil { @@ -280,10 +280,10 @@ func (a *App) manageUsers(doc *Document) error { fmt.Sprintf("%d share accounts referenced in the current config.", len(shareUserReferences(doc))), }, []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."}, + {Key: "c", Value: "check", Label: "Check accounts"}, + {Key: "p", Value: "password", Label: "Change password"}, + {Key: "x", Value: "delete", Label: "Delete account"}, + {Key: "b", Value: "back", Label: "Back"}, }, ) if err != nil { @@ -370,7 +370,7 @@ func (a *App) deleteUnusedAccount(doc *Document) error { Description: "shell: " + strings.TrimSpace(entry.Shell), }) } - options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Leave accounts unchanged."}) + options = append(options, menuOption{Key: "b", Value: "", Label: "Back"}) choice, err := a.chooseMenu( "Delete An Unused Account", @@ -435,7 +435,7 @@ func (a *App) changeSambaPassword(doc *Document) error { Description: "used by: " + strings.Join(ref.Shares, ", "), }) } - options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return without changing a password."}) + options = append(options, menuOption{Key: "b", Value: "", Label: "Back"}) choice, err := a.chooseMenu( "Choose An Account", @@ -667,7 +667,7 @@ func (a *App) collectCIFSMountConfig(defaults CIFSMountConfig) (CIFSMountConfig, if err != nil { return CIFSMountConfig{}, err } - share, err := a.promptDefault("Share name on that server", defaults.Share) + share, err := a.promptDefault("Share name", defaults.Share) if err != nil { return CIFSMountConfig{}, err } @@ -679,23 +679,23 @@ func (a *App) collectCIFSMountConfig(defaults CIFSMountConfig) (CIFSMountConfig, if err != nil { return CIFSMountConfig{}, err } - username, err := a.promptDefault("Username for the remote share", defaults.Username) + username, err := a.promptDefault("Remote username", defaults.Username) if err != nil { return CIFSMountConfig{}, err } - password, err := a.promptDefault("Password for the remote share", defaults.Password) + password, err := a.promptDefault("Remote password", defaults.Password) if err != nil { return CIFSMountConfig{}, err } - domain, err := a.promptDefault("Domain or workgroup (optional)", defaults.Domain) + domain, err := a.promptDefault("Domain or workgroup", defaults.Domain) if err != nil { return CIFSMountConfig{}, err } - uid, err := a.promptDefault("Local owner username or uid (optional)", defaults.UID) + uid, err := a.promptDefault("Local owner username or uid", defaults.UID) if err != nil { return CIFSMountConfig{}, err } - gid, err := a.promptDefault("Local group name or gid (optional)", defaults.GID) + gid, err := a.promptDefault("Local group name or gid", defaults.GID) if err != nil { return CIFSMountConfig{}, err } @@ -1143,8 +1143,8 @@ func (a *App) promptDefault(label, defaultValue string) (string, error) { func (a *App) confirm(label string, defaultYes bool) (bool, error) { 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."}, + {Key: "y", Value: "yes", Label: "Yes"}, + {Key: "n", Value: "no", Label: "No"}, } if !defaultYes { options[0], options[1] = options[1], options[0] @@ -1681,7 +1681,7 @@ func (a *App) selectCIFSMount(entries []FstabMountEntry) (*FstabMountEntry, erro Description: entry.MountPoint, }) } - options = append(options, menuOption{Key: "b", Value: "", Label: "Back", Description: "Return to the previous menu."}) + options = append(options, menuOption{Key: "b", Value: "", Label: "Back"}) selection, err := a.chooseMenu("Choose A Client Mount", "Select the saved mount entry you want to change.", nil, options) if err != nil { diff --git a/samba-configer b/samba-configer index 5fa600d..51dea16 100755 Binary files a/samba-configer and b/samba-configer differ diff --git a/tui.go b/tui.go index 16d6389..fa30ea7 100644 --- a/tui.go +++ b/tui.go @@ -332,7 +332,7 @@ func (m menuModel) View() string { ) rowLines := []string{top} - if option.Description != "" { + if option.Description != "" && width >= 72 { rowLines = append(rowLines, hintStyle.Render(option.Description)) } rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n"))) @@ -445,7 +445,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle options: options, } - result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() + result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run() if err != nil { return "", err } @@ -460,7 +460,7 @@ func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) { model := newTextPromptModel(theme, title, subtitle, label, defaultValue) - result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() + result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run() if err != nil { return "", err } @@ -471,3 +471,298 @@ func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, } return final.value, nil } + +type cifsFormTextField struct { + key string + label string + required bool + defaultValue string + input textinput.Model +} + +type cifsFormToggle struct { + label string + value bool +} + +type cifsMountFormModel struct { + theme tuiTheme + title string + subtitle string + help []string + fields []cifsFormTextField + toggles []cifsFormToggle + cursor int + submitted bool + cancel bool + width int + errMessage string + result CIFSMountConfig +} + +func newCIFSMountFormModel(theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) cifsMountFormModel { + makeInput := func(value string, password bool) textinput.Model { + input := textinput.New() + input.Prompt = "" + input.SetValue(value) + input.Focus() + input.CharLimit = 512 + input.Width = 48 + if password { + input.EchoMode = textinput.EchoPassword + input.EchoCharacter = '•' + } + return input + } + + fields := []cifsFormTextField{ + {key: "server", label: "Server name or IP", required: true, defaultValue: defaults.Server, input: makeInput(defaults.Server, false)}, + {key: "share", label: "Share name on that server", required: true, defaultValue: defaults.Share, input: makeInput(defaults.Share, false)}, + {key: "mount", label: "Local mount folder", required: true, defaultValue: defaults.MountPoint, input: makeInput(defaults.MountPoint, false)}, + {key: "username", label: "Username for the remote share", required: true, defaultValue: defaults.Username, input: makeInput(defaults.Username, false)}, + {key: "password", label: "Password for the remote share", required: true, defaultValue: defaults.Password, input: makeInput(defaults.Password, true)}, + {key: "domain", label: "Domain or workgroup", defaultValue: defaults.Domain, input: makeInput(defaults.Domain, false)}, + {key: "uid", label: "Local owner username or uid", defaultValue: defaults.UID, input: makeInput(defaults.UID, false)}, + {key: "gid", label: "Local group name or gid", defaultValue: defaults.GID, input: makeInput(defaults.GID, false)}, + {key: "filemode", label: "File permissions", defaultValue: defaultString(defaults.FileMode, "0664"), input: makeInput(defaultString(defaults.FileMode, "0664"), false)}, + {key: "dirmode", label: "Folder permissions", defaultValue: defaultString(defaults.DirMode, "0775"), input: makeInput(defaultString(defaults.DirMode, "0775"), false)}, + } + + model := cifsMountFormModel{ + theme: theme, + title: title, + subtitle: subtitle, + help: []string{ + "Tab or arrows move between fields.", + "Enter toggles switches or submits when Save is selected.", + }, + fields: fields, + toggles: []cifsFormToggle{ + {label: "Mount automatically at startup", value: defaults.AutoMount || (defaults.Server == "" && defaults.Share == "" && defaults.MountPoint == "")}, + {label: "Make this mount read-only", value: defaults.ReadOnly}, + }, + } + model.focusCursor() + return model +} + +func (m *cifsMountFormModel) totalItems() int { + return len(m.fields) + len(m.toggles) + 2 +} + +func (m *cifsMountFormModel) focusCursor() { + for i := range m.fields { + if i == m.cursor { + m.fields[i].input.Focus() + } else { + m.fields[i].input.Blur() + } + } +} + +func (m *cifsMountFormModel) moveCursor(delta int) { + total := m.totalItems() + if total == 0 { + return + } + m.cursor = (m.cursor + delta + total) % total + m.focusCursor() + m.errMessage = "" +} + +func (m *cifsMountFormModel) currentConfig() CIFSMountConfig { + values := map[string]string{} + for _, field := range m.fields { + value := strings.TrimSpace(field.input.Value()) + if value == "" { + value = strings.TrimSpace(field.defaultValue) + } + values[field.key] = value + } + + return CIFSMountConfig{ + Server: values["server"], + Share: values["share"], + MountPoint: values["mount"], + Username: values["username"], + Password: values["password"], + Domain: values["domain"], + UID: values["uid"], + GID: values["gid"], + FileMode: values["filemode"], + DirMode: values["dirmode"], + AutoMount: m.toggles[0].value, + ReadOnly: m.toggles[1].value, + } +} + +func (m *cifsMountFormModel) validate() string { + for _, field := range m.fields { + if !field.required { + continue + } + value := strings.TrimSpace(field.input.Value()) + if value == "" { + value = strings.TrimSpace(field.defaultValue) + } + if value == "" { + return field.label + " is required." + } + } + return "" +} + +func (m cifsMountFormModel) Init() tea.Cmd { + return textinput.Blink +} + +func (m cifsMountFormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + available := innerWidth(clampFrameWidth(m.width)) - 8 + if available < 16 { + available = 16 + } + for i := range m.fields { + m.fields[i].input.Width = available + } + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.cancel = true + return m, tea.Quit + case "shift+tab", "up", "k": + m.moveCursor(-1) + return m, nil + case "tab", "down", "j": + m.moveCursor(1) + return m, nil + case "enter": + textCount := len(m.fields) + toggleStart := textCount + saveIndex := textCount + len(m.toggles) + cancelIndex := saveIndex + 1 + + switch { + case m.cursor < textCount: + m.moveCursor(1) + return m, nil + case m.cursor >= toggleStart && m.cursor < saveIndex: + idx := m.cursor - toggleStart + m.toggles[idx].value = !m.toggles[idx].value + return m, nil + case m.cursor == saveIndex: + if errMessage := m.validate(); errMessage != "" { + m.errMessage = errMessage + return m, nil + } + m.result = m.currentConfig() + m.submitted = true + return m, tea.Quit + case m.cursor == cancelIndex: + m.cancel = true + return m, tea.Quit + } + } + } + + if m.cursor < len(m.fields) { + var cmd tea.Cmd + m.fields[m.cursor].input, cmd = m.fields[m.cursor].input.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m cifsMountFormModel) View() string { + width := clampFrameWidth(m.width) + if width == 0 { + width = clampFrameWidth(84) + } + contentWidth := innerWidth(width) + + formRows := make([]string, 0, len(m.fields)+len(m.toggles)+3) + for i, field := range m.fields { + active := i == m.cursor + boxStyle := m.theme.panel + if active { + boxStyle = m.theme.panelAccent + } + + label := field.label + if field.required { + label += " *" + } + fieldLines := []string{m.theme.inputLabel.Render(label)} + fieldLines = append(fieldLines, m.theme.inputBox.Width(max(12, contentWidth-6)).Render(field.input.View())) + formRows = append(formRows, boxStyle.Width(contentWidth).Render(strings.Join(fieldLines, "\n"))) + } + + toggleStart := len(m.fields) + for i, toggle := range m.toggles { + active := toggleStart+i == m.cursor + rowStyle := m.theme.row + marker := "[ ]" + if toggle.value { + marker = "[x]" + } + if active { + rowStyle = m.theme.rowSelected + } + formRows = append(formRows, rowStyle.Width(contentWidth).Render( + m.theme.rowTitle.Render(marker+" "+toggle.label), + )) + } + + saveIndex := len(m.fields) + len(m.toggles) + saveStyle := m.theme.row + cancelStyle := m.theme.row + if m.cursor == saveIndex { + saveStyle = m.theme.rowSelected + } + if m.cursor == saveIndex+1 { + cancelStyle = m.theme.rowSelected + } + formRows = append(formRows, saveStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Save mount"))) + formRows = append(formRows, cancelStyle.Width(contentWidth).Render(m.theme.rowTitle.Render("Cancel"))) + + previewCfg := m.currentConfig() + previewLines := []string{ + buildFstabLine(previewCfg), + "Passwords are written directly into /etc/fstab by this workflow.", + } + bodyParts := []string{ + m.theme.renderSection(width, "Workflow", m.help), + m.theme.sectionTitle.Width(contentWidth).Render("Mount Details"), + strings.Join(formRows, "\n"), + m.theme.renderSection(width, "Preview", previewLines), + } + if m.errMessage != "" { + bodyParts = append(bodyParts, m.theme.renderMessage(width, "warn", m.errMessage)) + } + + return m.theme.renderFrame( + m.width, + m.title, + m.subtitle, + strings.Join(bodyParts, "\n\n"), + "tab move • enter next/toggle/save • esc cancel", + ) +} + +func runCIFSMountForm(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, defaults CIFSMountConfig) (CIFSMountConfig, error) { + model := newCIFSMountFormModel(theme, title, subtitle, defaults) + + result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out), tea.WithAltScreen()).Run() + if err != nil { + return CIFSMountConfig{}, err + } + + final := result.(cifsMountFormModel) + if final.cancel { + return CIFSMountConfig{}, ErrCancelled + } + return final.result, nil +}