detached f7637c3

feat(ui): improve password flow with confirmation and conditional disk encryption

commit
f7637c329d8fc298230e9bfdf54957c3e5c84122
author
bnema · · 2025-10-26 12:14 UTC
parent
a85170f
signature
unsigned
7 files · +116 -111
M internal/config/config.go +19 -14
@@ -200,6 +200,10 @@ type Config struct {
200200 EncryptionType string // "none", "luks", or "luks-lvm"
201201 EncryptPassword string // Separate encryption password if different from user password
202202
203+ // Form-only fields (not persisted)
204+ ConfirmPassword string // Temporary field for password confirmation
205+ UseSamePasswordForEncryption bool // Checkbox state for using same password for encryption
206+
203207 // Partitioning
204208 TargetDisk string
205209 BootPartition string
@@ -233,20 +237,21 @@ type Config struct {
233237 // NewConfig creates a new Config with sensible defaults
234238 func NewConfig() *Config {
235239 return &Config{
236- Locale: "en_US.UTF-8",
237- Timezone: "UTC",
238- Keymap: "us",
239- Bootloader: "limine",
240- EncryptionType: "none",
241- KernelChoice: "linux",
242- NetworkManager: "NetworkManager",
243- AURHelper: "paru",
244- EnableMultilib: true,
245- EnableChaotic: true,
246- ConfigPath: DefaultConfigPath,
247- LogPath: DefaultLogPath,
248- RepoURL: "https://github.com/bnema/archup",
249- RawURL: "https://raw.githubusercontent.com/bnema/archup/main",
240+ Locale: "en_US.UTF-8",
241+ Timezone: "UTC",
242+ Keymap: "us",
243+ Bootloader: "limine",
244+ EncryptionType: "none",
245+ KernelChoice: "linux",
246+ NetworkManager: "NetworkManager",
247+ AURHelper: "paru",
248+ EnableMultilib: true,
249+ EnableChaotic: true,
250+ UseSamePasswordForEncryption: true, // Default to using same password for encryption
251+ ConfigPath: DefaultConfigPath,
252+ LogPath: DefaultLogPath,
253+ RepoURL: "https://github.com/bnema/archup",
254+ RawURL: "https://raw.githubusercontent.com/bnema/archup/main",
250255 }
251256 }
252257
@@ -200,6 +200,10 @@ type Config struct {
200 EncryptionType string // "none", "luks", or "luks-lvm"200 EncryptionType string // "none", "luks", or "luks-lvm"
201 EncryptPassword string // Separate encryption password if different from user password201 EncryptPassword string // Separate encryption password if different from user password
202 202
203+ // Form-only fields (not persisted)
204+ ConfirmPassword string // Temporary field for password confirmation
205+ UseSamePasswordForEncryption bool // Checkbox state for using same password for encryption
206+
203 // Partitioning207 // Partitioning
204 TargetDisk string208 TargetDisk string
205 BootPartition string209 BootPartition string
@@ -233,20 +237,21 @@ type Config struct {
233 // NewConfig creates a new Config with sensible defaults237 // NewConfig creates a new Config with sensible defaults
234 func NewConfig() *Config {238 func NewConfig() *Config {
235 return &Config{239 return &Config{
236- Locale: "en_US.UTF-8",240+ Locale: "en_US.UTF-8",
237- Timezone: "UTC",241+ Timezone: "UTC",
238- Keymap: "us",242+ Keymap: "us",
239- Bootloader: "limine",243+ Bootloader: "limine",
240- EncryptionType: "none",244+ EncryptionType: "none",
241- KernelChoice: "linux",245+ KernelChoice: "linux",
242- NetworkManager: "NetworkManager",246+ NetworkManager: "NetworkManager",
243- AURHelper: "paru",247+ AURHelper: "paru",
244- EnableMultilib: true,248+ EnableMultilib: true,
245- EnableChaotic: true,249+ EnableChaotic: true,
246- ConfigPath: DefaultConfigPath,250+ UseSamePasswordForEncryption: true, // Default to using same password for encryption
247- LogPath: DefaultLogPath,251+ ConfigPath: DefaultConfigPath,
248- RepoURL: "https://github.com/bnema/archup",252+ LogPath: DefaultLogPath,
249- RawURL: "https://raw.githubusercontent.com/bnema/archup/main",253+ RepoURL: "https://github.com/bnema/archup",
254+ RawURL: "https://raw.githubusercontent.com/bnema/archup/main",
250 }255 }
251 }256 }
252 257
M internal/phases/systemconfig.go +1 -8
@@ -182,13 +182,6 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error
182182 func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
183183 p.SendOutput(progressChan, "Creating user account...")
184184
185- // Set root password using stdin (secure - not visible in process list)
186- rootPassInput := fmt.Sprintf("root:%s", p.config.UserPassword)
187- switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", rootPassInput); {
188- case err != nil:
189- return fmt.Errorf("failed to set root password: %w", err)
190- }
191-
192185 // Create user with home directory
193186 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
194187 switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
@@ -203,7 +196,7 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
203196 return fmt.Errorf("failed to set user password: %w", err)
204197 }
205198
206- // Enable sudo for wheel group
199+ // Enable sudo for wheel group (passwordless for convenience)
207200 switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
208201 case err != nil:
209202 return fmt.Errorf("failed to write sudoers config: %w", err)
@@ -182,13 +182,6 @@ func (p *ConfigPhase) configureNetwork(progressChan chan<- ProgressUpdate) error
182 func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {182 func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
183 p.SendOutput(progressChan, "Creating user account...")183 p.SendOutput(progressChan, "Creating user account...")
184 184
185- // Set root password using stdin (secure - not visible in process list)
186- rootPassInput := fmt.Sprintf("root:%s", p.config.UserPassword)
187- switch err := system.ChrootExecWithStdin(p.logger.LogPath(), config.PathMnt, "chpasswd", rootPassInput); {
188- case err != nil:
189- return fmt.Errorf("failed to set root password: %w", err)
190- }
191-
192 // Create user with home directory185 // Create user with home directory
193 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)186 userAddCmd := fmt.Sprintf("useradd -m -G %s -s %s %s", config.GroupWheel, config.ShellBash, p.config.Username)
194 switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {187 switch err := system.ChrootExec(p.logger.LogPath(),config.PathMnt, userAddCmd); {
@@ -203,7 +196,7 @@ func (p *ConfigPhase) createUser(progressChan chan<- ProgressUpdate) error {
203 return fmt.Errorf("failed to set user password: %w", err)196 return fmt.Errorf("failed to set user password: %w", err)
204 }197 }
205 198
206- // Enable sudo for wheel group199+ // Enable sudo for wheel group (passwordless for convenience)
207 switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {200 switch err := os.WriteFile(config.PathMntEtcSudoersD, []byte(config.SudoersWheelContent), config.SudoersWheelPerms); {
208 case err != nil:201 case err != nil:
209 return fmt.Errorf("failed to write sudoers config: %w", err)202 return fmt.Errorf("failed to write sudoers config: %w", err)
M internal/ui/components/forms.go +7 -3
@@ -63,19 +63,23 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri
6363 return ms
6464 }
6565
66-// Confirm creates a yes/no confirmation
66+// Confirm creates a yes/no confirmation with inline button alignment
6767 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
6868 return huh.NewConfirm().
6969 Title(title).
7070 Affirmative(affirmative).
7171 Negative(negative).
72- Value(value)
72+ Value(value).
73+ Inline(true)
7374 }
7475
7576 // CreateForm creates a form with accessibility settings and custom theme
7677 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
78+ const maxFormWidth = 80 // Maximum width for better readability
79+
7780 form := huh.NewForm(groups...).
78- WithTheme(styles.HuhTheme())
81+ WithTheme(styles.HuhTheme()).
82+ WithWidth(maxFormWidth)
7983
8084 if fb.accessible {
8185 form.WithAccessible(true)
@@ -63,19 +63,23 @@ func (fb *FormBuilder) MultiSelect(title string, options []string, value *[]stri
63 return ms63 return ms
64 }64 }
65 65
66-// Confirm creates a yes/no confirmation66+// Confirm creates a yes/no confirmation with inline button alignment
67 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {67 func (fb *FormBuilder) Confirm(title, affirmative, negative string, value *bool) *huh.Confirm {
68 return huh.NewConfirm().68 return huh.NewConfirm().
69 Title(title).69 Title(title).
70 Affirmative(affirmative).70 Affirmative(affirmative).
71 Negative(negative).71 Negative(negative).
72- Value(value)72+ Value(value).
73+ Inline(true)
73 }74 }
74 75
75 // CreateForm creates a form with accessibility settings and custom theme76 // CreateForm creates a form with accessibility settings and custom theme
76 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {77 func (fb *FormBuilder) CreateForm(groups ...*huh.Group) *huh.Form {
78+ const maxFormWidth = 80 // Maximum width for better readability
79+
77 form := huh.NewForm(groups...).80 form := huh.NewForm(groups...).
78- WithTheme(styles.HuhTheme())81+ WithTheme(styles.HuhTheme()).
82+ WithWidth(maxFormWidth)
79 83
80 if fb.accessible {84 if fb.accessible {
81 form.WithAccessible(true)85 form.WithAccessible(true)
M internal/ui/installer_forms.go +26 -42
@@ -28,19 +28,25 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form {
2828 huh.NewGroup(
2929 fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname),
3030 fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername),
31+ fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).
32+ Description(DescEmail),
3133 ).Title(SectionUserIdentity),
3234
3335 // Passwords
3436 huh.NewGroup(
3537 fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword),
36- fb.PasswordInput("Root Password", "> ", &cfg.RootPassword, validation.ValidatePassword),
38+ fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)),
39+
40+ fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption),
3741 ),
3842
39- // Email (optional)
43+ // Disk Encryption Password (only shown when NOT using same password)
4044 huh.NewGroup(
41- fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).
42- Description(DescEmail),
43- ),
45+ fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword).
46+ Description("Enter a separate password to unlock your encrypted disk at boot"),
47+ ).WithHideFunc(func() bool {
48+ return cfg.UseSamePasswordForEncryption // Hide when using same password
49+ }),
4450
4551 // Localization
4652 huh.NewGroup(
@@ -113,47 +119,38 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
113119 cfg.Microcode = cpuInfo.Microcode
114120 }
115121
116- // Kernel selection (always shown)
117- kernelGroup := huh.NewGroup(
118- fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
119- ).Title(SectionKernel)
120-
121- // Encryption options
122+ // Page 1: Kernel and Encryption (system base configuration)
122123 encryptionOptions := []huh.Option[string]{
123124 huh.NewOption("No encryption", config.EncryptionNone),
124125 huh.NewOption("LUKS encryption", config.EncryptionLUKS),
125126 }
126127
127- encryptionGroup := huh.NewGroup(
128- fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType),
129- ).Title(SectionDiskEncryption).Description(DescEncryption)
130-
131- // Repository options
132- repoGroup := huh.NewGroup(
133- fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
134- Description(DescMultilib),
135- fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
136- Description(DescChaoticAUR),
137- )
128+ systemBaseGroup := huh.NewGroup(
129+ fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
130+ fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType).
131+ Description(DescEncryption),
132+ ).Title(SectionKernel)
138133
139- // AUR helper options
134+ // Page 2: Repository and package management options
140135 aurOptions := []huh.Option[string]{
141136 huh.NewOption("None", ""),
142137 components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),
143138 components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),
144139 }
145140
146- aurGroup := huh.NewGroup(
141+ packageManagementGroup := huh.NewGroup(
142+ fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
143+ Description(DescMultilib),
144+ fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
145+ Description(DescChaoticAUR),
147146 fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper).
148147 Description(DescAURHelper),
149148 )
150149
151150 // Build form groups list
152151 groups := []*huh.Group{
153- kernelGroup,
154- encryptionGroup,
155- repoGroup,
156- aurGroup,
152+ systemBaseGroup,
153+ packageManagementGroup,
157154 }
158155
159156 // Add AMD P-State tuning group (conditional - only for AMD CPUs)
@@ -176,7 +173,7 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
176173 &cfg.AMDPState),
177174 ).Title(SectionAMDTuning).Description(DescAMDPState)
178175
179- // Insert AMD group after kernel group
176+ // Insert AMD group after system base group (between kernel/encryption and package management)
180177 groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...)
181178 }
182179 }
@@ -184,19 +181,6 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
184181 return fb.CreateForm(groups...)
185182 }
186183
187-// CreateEncryptionPasswordForm creates encryption password form
188-// User can leave empty to use same password as user account
189-func CreateEncryptionPasswordForm(cfg *config.Config) *huh.Form {
190- fb := components.NewFormBuilder(false)
191-
192- return fb.CreateForm(
193- huh.NewGroup(
194- fb.PasswordInput("Encryption Password", "> ", &cfg.EncryptPassword, nil).
195- Description(DescEncryptionPassword),
196- ).Title(SectionDiskEncryption).Description(DescEncryption),
197- )
198-}
199-
200184 // FormatDiskOption formats a disk into a user-friendly option string (DRY helper)
201185 func FormatDiskOption(disk system.Disk) string {
202186 label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size)
@@ -28,19 +28,25 @@ func CreatePreflightForm(cfg *config.Config) *huh.Form {
28 huh.NewGroup(28 huh.NewGroup(
29 fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname),29 fb.TextInput("Hostname", "> ", &cfg.Hostname, validation.ValidateHostname),
30 fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername),30 fb.TextInput("Username", "> ", &cfg.Username, validation.ValidateUsername),
31+ fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).
32+ Description(DescEmail),
31 ).Title(SectionUserIdentity),33 ).Title(SectionUserIdentity),
32 34
33 // Passwords35 // Passwords
34 huh.NewGroup(36 huh.NewGroup(
35 fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword),37 fb.PasswordInput("User Password", "> ", &cfg.UserPassword, validation.ValidatePassword),
36- fb.PasswordInput("Root Password", "> ", &cfg.RootPassword, validation.ValidatePassword),38+ fb.PasswordInput("Confirm Password", "> ", &cfg.ConfirmPassword, validation.ValidatePasswordConfirmation(&cfg.UserPassword)),
39+
40+ fb.Confirm("Use same password for disk encryption?", "Yes", "No", &cfg.UseSamePasswordForEncryption),
37 ),41 ),
38 42
39- // Email (optional)43+ // Disk Encryption Password (only shown when NOT using same password)
40 huh.NewGroup(44 huh.NewGroup(
41- fb.TextInput("Email (optional)", "> ", &cfg.Email, nil).45+ fb.PasswordInput("Disk Encryption Password", "> ", &cfg.EncryptPassword, validation.ValidatePassword).
42- Description(DescEmail),46+ Description("Enter a separate password to unlock your encrypted disk at boot"),
43- ),47+ ).WithHideFunc(func() bool {
48+ return cfg.UseSamePasswordForEncryption // Hide when using same password
49+ }),
44 50
45 // Localization51 // Localization
46 huh.NewGroup(52 huh.NewGroup(
@@ -113,47 +119,38 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
113 cfg.Microcode = cpuInfo.Microcode119 cfg.Microcode = cpuInfo.Microcode
114 }120 }
115 121
116- // Kernel selection (always shown)122+ // Page 1: Kernel and Encryption (system base configuration)
117- kernelGroup := huh.NewGroup(
118- fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
119- ).Title(SectionKernel)
120-
121- // Encryption options
122 encryptionOptions := []huh.Option[string]{123 encryptionOptions := []huh.Option[string]{
123 huh.NewOption("No encryption", config.EncryptionNone),124 huh.NewOption("No encryption", config.EncryptionNone),
124 huh.NewOption("LUKS encryption", config.EncryptionLUKS),125 huh.NewOption("LUKS encryption", config.EncryptionLUKS),
125 }126 }
126 127
127- encryptionGroup := huh.NewGroup(128+ systemBaseGroup := huh.NewGroup(
128- fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType),129+ fb.SelectWithOptions("Kernel", components.CreateKernelOptions(), &cfg.KernelChoice),
129- ).Title(SectionDiskEncryption).Description(DescEncryption)130+ fb.SelectWithOptions("Encryption", encryptionOptions, &cfg.EncryptionType).
130-131+ Description(DescEncryption),
131- // Repository options132+ ).Title(SectionKernel)
132- repoGroup := huh.NewGroup(
133- fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
134- Description(DescMultilib),
135- fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
136- Description(DescChaoticAUR),
137- )
138 133
139- // AUR helper options134+ // Page 2: Repository and package management options
140 aurOptions := []huh.Option[string]{135 aurOptions := []huh.Option[string]{
141 huh.NewOption("None", ""),136 huh.NewOption("None", ""),
142 components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),137 components.CreateOption("Paru", DescAURHelperParu, config.AURHelperParu),
143 components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),138 components.CreateOption("Yay", DescAURHelperYay, config.AURHelperYay),
144 }139 }
145 140
146- aurGroup := huh.NewGroup(141+ packageManagementGroup := huh.NewGroup(
142+ fb.Confirm("Enable multilib repository?", "Yes", "No", &cfg.EnableMultilib).
143+ Description(DescMultilib),
144+ fb.Confirm("Enable Chaotic-AUR?", "Yes", "No", &cfg.EnableChaotic).
145+ Description(DescChaoticAUR),
147 fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper).146 fb.SelectWithOptions("AUR Helper", aurOptions, &cfg.AURHelper).
148 Description(DescAURHelper),147 Description(DescAURHelper),
149 )148 )
150 149
151 // Build form groups list150 // Build form groups list
152 groups := []*huh.Group{151 groups := []*huh.Group{
153- kernelGroup,152+ systemBaseGroup,
154- encryptionGroup,153+ packageManagementGroup,
155- repoGroup,
156- aurGroup,
157 }154 }
158 155
159 // Add AMD P-State tuning group (conditional - only for AMD CPUs)156 // Add AMD P-State tuning group (conditional - only for AMD CPUs)
@@ -176,7 +173,7 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
176 &cfg.AMDPState),173 &cfg.AMDPState),
177 ).Title(SectionAMDTuning).Description(DescAMDPState)174 ).Title(SectionAMDTuning).Description(DescAMDPState)
178 175
179- // Insert AMD group after kernel group176+ // Insert AMD group after system base group (between kernel/encryption and package management)
180 groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...)177 groups = append(groups[:1], append([]*huh.Group{amdGroup}, groups[1:]...)...)
181 }178 }
182 }179 }
@@ -184,19 +181,6 @@ func CreateOptionsForm(cfg *config.Config) *huh.Form {
184 return fb.CreateForm(groups...)181 return fb.CreateForm(groups...)
185 }182 }
186 183
187-// CreateEncryptionPasswordForm creates encryption password form
188-// User can leave empty to use same password as user account
189-func CreateEncryptionPasswordForm(cfg *config.Config) *huh.Form {
190- fb := components.NewFormBuilder(false)
191-
192- return fb.CreateForm(
193- huh.NewGroup(
194- fb.PasswordInput("Encryption Password", "> ", &cfg.EncryptPassword, nil).
195- Description(DescEncryptionPassword),
196- ).Title(SectionDiskEncryption).Description(DescEncryption),
197- )
198-}
199-
200 // FormatDiskOption formats a disk into a user-friendly option string (DRY helper)184 // FormatDiskOption formats a disk into a user-friendly option string (DRY helper)
201 func FormatDiskOption(disk system.Disk) string {185 func FormatDiskOption(disk system.Disk) string {
202 label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size)186 label := fmt.Sprintf("%s (%s)", disk.Path, disk.Size)
M internal/ui/logo.go +7 -16
@@ -72,28 +72,19 @@ func (l *Logo) Render() string {
7272 return rendered.String()
7373 }
7474
75-// RenderCentered renders the logo centered horizontally within the terminal
75+// RenderCentered renders the logo centered horizontally within the terminal using lipgloss
7676 func (l *Logo) RenderCentered(termWidth int) string {
7777 logo := l.Render()
7878 lines := strings.Split(logo, "\n")
7979
80- var centered strings.Builder
81- paddingLeft := (termWidth - l.width) / 2
82-
83- if paddingLeft < 0 {
84- paddingLeft = 0
85- }
86-
87- padding := strings.Repeat(" ", paddingLeft)
80+ centerStyle := lipgloss.NewStyle().
81+ Width(termWidth).
82+ AlignHorizontal(lipgloss.Center)
8883
84+ var centered strings.Builder
8985 for _, line := range lines {
90- if strings.TrimSpace(line) != "" {
91- centered.WriteString(padding)
92- }
93- centered.WriteString(line)
94- if !strings.HasSuffix(line, "\n") {
95- centered.WriteString("\n")
96- }
86+ centered.WriteString(centerStyle.Render(line))
87+ centered.WriteString("\n")
9788 }
9889
9990 return centered.String()
@@ -72,28 +72,19 @@ func (l *Logo) Render() string {
72 return rendered.String()72 return rendered.String()
73 }73 }
74 74
75-// RenderCentered renders the logo centered horizontally within the terminal75+// RenderCentered renders the logo centered horizontally within the terminal using lipgloss
76 func (l *Logo) RenderCentered(termWidth int) string {76 func (l *Logo) RenderCentered(termWidth int) string {
77 logo := l.Render()77 logo := l.Render()
78 lines := strings.Split(logo, "\n")78 lines := strings.Split(logo, "\n")
79 79
80- var centered strings.Builder80+ centerStyle := lipgloss.NewStyle().
81- paddingLeft := (termWidth - l.width) / 281+ Width(termWidth).
82-82+ AlignHorizontal(lipgloss.Center)
83- if paddingLeft < 0 {
84- paddingLeft = 0
85- }
86-
87- padding := strings.Repeat(" ", paddingLeft)
88 83
84+ var centered strings.Builder
89 for _, line := range lines {85 for _, line := range lines {
90- if strings.TrimSpace(line) != "" {86+ centered.WriteString(centerStyle.Render(line))
91- centered.WriteString(padding)87+ centered.WriteString("\n")
92- }
93- centered.WriteString(line)
94- if !strings.HasSuffix(line, "\n") {
95- centered.WriteString("\n")
96- }
97 }88 }
98 89
99 return centered.String()90 return centered.String()
M internal/ui/model.go +46 -28
@@ -2,15 +2,14 @@ package ui
22
33 import (
44 "fmt"
5- "strings"
65
7- tea "github.com/charmbracelet/bubbletea"
8- "github.com/charmbracelet/huh"
9- "github.com/charmbracelet/lipgloss"
106 "github.com/bnema/archup/internal/config"
117 "github.com/bnema/archup/internal/phases"
128 "github.com/bnema/archup/internal/ui/components"
139 "github.com/bnema/archup/internal/ui/styles"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/huh"
12+ "github.com/charmbracelet/lipgloss"
1413 )
1514
1615 // ModelState represents the current UI state
@@ -119,6 +118,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119118 case StatePreflightForm, StateDiskForm, StateOptionsForm:
120119 switch {
121120 case m.currentForm != nil:
121+ // Don't pass WindowSizeMsg to form to preserve our max width
122+ if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize {
123+ return m, nil
124+ }
125+
122126 form, cmd := m.currentForm.Update(msg)
123127 if f, ok := form.(*huh.Form); ok {
124128 m.currentForm = f
@@ -144,14 +148,15 @@ func (m *Model) View() string {
144148 switch m.state {
145149 case StateShowLogo:
146150 logo := NewLogo(m.version)
147- return logo.RenderCentered(m.width) + "\n\n" +
151+ return logo.RenderCentered(m.width) + "\n\n\n" +
148152 centerText(WelcomeTagline, m.width) + "\n\n" +
149153 centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width)
150154
151155 case StatePreflightForm, StateDiskForm, StateOptionsForm:
152156 switch {
153157 case m.currentForm != nil:
154- return m.currentForm.View()
158+ header := centerText(m.renderPhaseHeader(), m.width)
159+ return header + "\n\n" + m.currentForm.View()
155160 }
156161 return "Loading form..."
157162
@@ -176,6 +181,12 @@ func (m *Model) View() string {
176181 func (m *Model) handleFormComplete() tea.Cmd {
177182 switch m.state {
178183 case StatePreflightForm:
184+ // Handle encryption password setting based on checkbox
185+ switch {
186+ case m.config.UseSamePasswordForEncryption:
187+ m.config.EncryptPassword = m.config.UserPassword
188+ }
189+
179190 m.state = StateDiskForm
180191 m.currentForm = CreateDiskSelectionForm(m.config)
181192 return m.currentForm.Init()
@@ -186,25 +197,12 @@ func (m *Model) handleFormComplete() tea.Cmd {
186197 return m.currentForm.Init()
187198
188199 case StateOptionsForm:
189- // Check if encryption password needed
190- switch m.config.EncryptionType {
191- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
192- m.currentForm = CreateEncryptionPasswordForm(m.config)
193- return m.currentForm.Init()
194- }
195-
196- // Move to confirmation
200+ // Move directly to confirmation
201+ // Encryption password is already handled in preflight form
197202 m.state = StateConfirmation
198203 return nil
199204
200205 default:
201- // Encryption password form completed
202- // If password is empty, use user password
203- switch {
204- case m.config.EncryptPassword == "":
205- m.config.EncryptPassword = m.config.UserPassword
206- }
207-
208206 // Move to confirmation
209207 m.state = StateConfirmation
210208 return nil
@@ -245,13 +243,33 @@ type phaseCompleteMsg struct {
245243 err error
246244 }
247245
248-// centerText centers text within a given width
246+// centerText centers text within a given width using lipgloss
249247 func centerText(text string, width int) string {
250- textLen := lipgloss.Width(text)
251- switch {
252- case textLen >= width:
253- return text
248+ return lipgloss.NewStyle().
249+ Width(width).
250+ AlignHorizontal(lipgloss.Center).
251+ Render(text)
252+}
253+
254+
255+// renderPhaseHeader renders phase progress header
256+func (m *Model) renderPhaseHeader() string {
257+ var stepNum, stepName string
258+
259+ switch m.state {
260+ case StatePreflightForm:
261+ stepNum = "1"
262+ stepName = "System Configuration"
263+ case StateDiskForm:
264+ stepNum = "2"
265+ stepName = "Disk Selection"
266+ case StateOptionsForm:
267+ stepNum = "3"
268+ stepName = "Installation Options"
269+ default:
270+ return ""
254271 }
255- padding := (width - textLen) / 2
256- return strings.Repeat(" ", padding) + text
272+
273+ header := fmt.Sprintf("ArchUp Install - Step %s/3: %s", stepNum, stepName)
274+ return styles.TitleStyle.Render(header)
257275 }
@@ -2,15 +2,14 @@ package ui
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "strings"
6 5
7- tea "github.com/charmbracelet/bubbletea"
8- "github.com/charmbracelet/huh"
9- "github.com/charmbracelet/lipgloss"
10 "github.com/bnema/archup/internal/config"6 "github.com/bnema/archup/internal/config"
11 "github.com/bnema/archup/internal/phases"7 "github.com/bnema/archup/internal/phases"
12 "github.com/bnema/archup/internal/ui/components"8 "github.com/bnema/archup/internal/ui/components"
13 "github.com/bnema/archup/internal/ui/styles"9 "github.com/bnema/archup/internal/ui/styles"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/huh"
12+ "github.com/charmbracelet/lipgloss"
14 )13 )
15 14
16 // ModelState represents the current UI state15 // ModelState represents the current UI state
@@ -119,6 +118,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119 case StatePreflightForm, StateDiskForm, StateOptionsForm:118 case StatePreflightForm, StateDiskForm, StateOptionsForm:
120 switch {119 switch {
121 case m.currentForm != nil:120 case m.currentForm != nil:
121+ // Don't pass WindowSizeMsg to form to preserve our max width
122+ if _, isWindowSize := msg.(tea.WindowSizeMsg); isWindowSize {
123+ return m, nil
124+ }
125+
122 form, cmd := m.currentForm.Update(msg)126 form, cmd := m.currentForm.Update(msg)
123 if f, ok := form.(*huh.Form); ok {127 if f, ok := form.(*huh.Form); ok {
124 m.currentForm = f128 m.currentForm = f
@@ -144,14 +148,15 @@ func (m *Model) View() string {
144 switch m.state {148 switch m.state {
145 case StateShowLogo:149 case StateShowLogo:
146 logo := NewLogo(m.version)150 logo := NewLogo(m.version)
147- return logo.RenderCentered(m.width) + "\n\n" +151+ return logo.RenderCentered(m.width) + "\n\n\n" +
148 centerText(WelcomeTagline, m.width) + "\n\n" +152 centerText(WelcomeTagline, m.width) + "\n\n" +
149 centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width)153 centerText(styles.HelpStyle.Render("Press ENTER to start installation"), m.width)
150 154
151 case StatePreflightForm, StateDiskForm, StateOptionsForm:155 case StatePreflightForm, StateDiskForm, StateOptionsForm:
152 switch {156 switch {
153 case m.currentForm != nil:157 case m.currentForm != nil:
154- return m.currentForm.View()158+ header := centerText(m.renderPhaseHeader(), m.width)
159+ return header + "\n\n" + m.currentForm.View()
155 }160 }
156 return "Loading form..."161 return "Loading form..."
157 162
@@ -176,6 +181,12 @@ func (m *Model) View() string {
176 func (m *Model) handleFormComplete() tea.Cmd {181 func (m *Model) handleFormComplete() tea.Cmd {
177 switch m.state {182 switch m.state {
178 case StatePreflightForm:183 case StatePreflightForm:
184+ // Handle encryption password setting based on checkbox
185+ switch {
186+ case m.config.UseSamePasswordForEncryption:
187+ m.config.EncryptPassword = m.config.UserPassword
188+ }
189+
179 m.state = StateDiskForm190 m.state = StateDiskForm
180 m.currentForm = CreateDiskSelectionForm(m.config)191 m.currentForm = CreateDiskSelectionForm(m.config)
181 return m.currentForm.Init()192 return m.currentForm.Init()
@@ -186,25 +197,12 @@ func (m *Model) handleFormComplete() tea.Cmd {
186 return m.currentForm.Init()197 return m.currentForm.Init()
187 198
188 case StateOptionsForm:199 case StateOptionsForm:
189- // Check if encryption password needed200+ // Move directly to confirmation
190- switch m.config.EncryptionType {201+ // Encryption password is already handled in preflight form
191- case config.EncryptionLUKS, config.EncryptionLUKSLVM:
192- m.currentForm = CreateEncryptionPasswordForm(m.config)
193- return m.currentForm.Init()
194- }
195-
196- // Move to confirmation
197 m.state = StateConfirmation202 m.state = StateConfirmation
198 return nil203 return nil
199 204
200 default:205 default:
201- // Encryption password form completed
202- // If password is empty, use user password
203- switch {
204- case m.config.EncryptPassword == "":
205- m.config.EncryptPassword = m.config.UserPassword
206- }
207-
208 // Move to confirmation206 // Move to confirmation
209 m.state = StateConfirmation207 m.state = StateConfirmation
210 return nil208 return nil
@@ -245,13 +243,33 @@ type phaseCompleteMsg struct {
245 err error243 err error
246 }244 }
247 245
248-// centerText centers text within a given width246+// centerText centers text within a given width using lipgloss
249 func centerText(text string, width int) string {247 func centerText(text string, width int) string {
250- textLen := lipgloss.Width(text)248+ return lipgloss.NewStyle().
251- switch {249+ Width(width).
252- case textLen >= width:250+ AlignHorizontal(lipgloss.Center).
253- return text251+ Render(text)
252+}
253+
254+
255+// renderPhaseHeader renders phase progress header
256+func (m *Model) renderPhaseHeader() string {
257+ var stepNum, stepName string
258+
259+ switch m.state {
260+ case StatePreflightForm:
261+ stepNum = "1"
262+ stepName = "System Configuration"
263+ case StateDiskForm:
264+ stepNum = "2"
265+ stepName = "Disk Selection"
266+ case StateOptionsForm:
267+ stepNum = "3"
268+ stepName = "Installation Options"
269+ default:
270+ return ""
254 }271 }
255- padding := (width - textLen) / 2272+
256- return strings.Repeat(" ", padding) + text273+ header := fmt.Sprintf("ArchUp Install - Step %s/3: %s", stepNum, stepName)
274+ return styles.TitleStyle.Render(header)
257 }275 }
M internal/validation/validators.go +10 -0
@@ -79,3 +79,13 @@ func ValidateLocale(s string) error {
7979 // Basic validation - should match pattern like en_US.UTF-8
8080 return nil
8181 }
82+
83+// ValidatePasswordConfirmation validates that password and confirmation match
84+func ValidatePasswordConfirmation(password *string) func(string) error {
85+ return func(confirm string) error {
86+ if confirm != *password {
87+ return errors.New("passwords do not match")
88+ }
89+ return nil
90+ }
91+}
@@ -79,3 +79,13 @@ func ValidateLocale(s string) error {
79 // Basic validation - should match pattern like en_US.UTF-879 // Basic validation - should match pattern like en_US.UTF-8
80 return nil80 return nil
81 }81 }
82+
83+// ValidatePasswordConfirmation validates that password and confirmation match
84+func ValidatePasswordConfirmation(password *string) func(string) error {
85+ return func(confirm string) error {
86+ if confirm != *password {
87+ return errors.New("passwords do not match")
88+ }
89+ return nil
90+ }
91+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close