main 6af0a61

app.go RawHistoryFolder

1package tui
2
3import (
4	"context"
5	"os"
6
7	apphandlers "github.com/bnema/archup/internal/application/handlers"
8	"github.com/bnema/archup/internal/application/services"
9	"github.com/bnema/archup/internal/domain/ports"
10	"github.com/bnema/archup/internal/domain/system"
11	"github.com/bnema/archup/internal/interfaces/tui/handlers"
12	"github.com/bnema/archup/internal/interfaces/tui/models"
13	"github.com/bnema/archup/internal/interfaces/tui/views"
14	legacysystem "github.com/bnema/archup/internal/system"
15	tea "github.com/charmbracelet/bubbletea"
16)
17
18// App is the main TUI application coordinator
19// It manages the installation workflow by coordinating models, views, and handlers
20// with the application layer services
21type App struct {
22	// Core services
23	installService  *services.InstallationService
24	progressTracker *services.ProgressTracker
25	gpuHandler      *apphandlers.GPUHandler
26
27	// Infrastructure ports
28	logger ports.Logger
29
30	// UI models (using concrete types for compatibility with views/handlers)
31	formModel         *models.FormModelImpl
32	diskModel         *models.DiskModelImpl
33	encryptionModel   *models.EncryptionModelImpl
34	kernelModel       *models.KernelModelImpl
35	amdPstateModel    *models.AMDPStateModelImpl
36	gpuModel          *models.GPUModelImpl
37	reposModel        *models.ReposModelImpl
38	dankLinuxModel    *models.DankLinuxModelImpl
39	installationModel *models.InstallationModelImpl
40	progressModel     *models.ProgressModelImpl
41
42	// Application state
43	currentScreen Screen
44	version       string
45	ctx           context.Context
46	cancel        context.CancelFunc
47
48	// Validated form data - stored after form validation passes
49	formData models.FormData
50
51	// Program reference for sending messages from goroutines
52	program *tea.Program
53}
54
55// Screen represents the current TUI screen
56type Screen string
57
58const (
59	ScreenForm       Screen = "form"
60	ScreenDisk       Screen = "disk"
61	ScreenEncryption Screen = "encryption"
62	ScreenKernel     Screen = "kernel"
63	ScreenAMDPState  Screen = "amd-pstate"
64	ScreenGPU        Screen = "gpu"
65	ScreenRepos      Screen = "repos"
66	ScreenDankLinux  Screen = "danklinux"
67	ScreenInstalling Screen = "installing"
68	ScreenProgress   Screen = "progress"
69	ScreenSummary    Screen = "summary"
70	ScreenError      Screen = "error"
71)
72
73// NewApp creates a new TUI application
74func NewApp(
75	installService *services.InstallationService,
76	progressTracker *services.ProgressTracker,
77	gpuHandler *apphandlers.GPUHandler,
78	logger ports.Logger,
79	version string,
80) *App {
81	ctx, cancel := context.WithCancel(context.Background())
82
83	return &App{
84		installService:    installService,
85		progressTracker:   progressTracker,
86		gpuHandler:        gpuHandler,
87		logger:            logger,
88		version:           version,
89		formModel:         models.NewFormModel(),
90		diskModel:         models.NewDiskModel(),
91		encryptionModel:   models.NewEncryptionModel(),
92		kernelModel:       models.NewKernelModel(),
93		amdPstateModel:    models.NewAMDPStateModel(),
94		gpuModel:          models.NewGPUModel(),
95		reposModel:        models.NewReposModel(),
96		dankLinuxModel:    models.NewDankLinuxModel(),
97		installationModel: models.NewInstallationModel(),
98		progressModel:     models.NewProgressModel(),
99		currentScreen:     ScreenForm,
100		ctx:               ctx,
101		cancel:            cancel,
102	}
103}
104
105// Getter methods for handlers to access app resources
106
107// GetLogger returns the logger
108func (a *App) GetLogger() ports.Logger {
109	return a.logger
110}
111
112// GetInstallService returns the installation service
113func (a *App) GetInstallService() *services.InstallationService {
114	return a.installService
115}
116
117// GetProgressTracker returns the progress tracker
118func (a *App) GetProgressTracker() *services.ProgressTracker {
119	return a.progressTracker
120}
121
122// GetContext returns the app context
123func (a *App) GetContext() context.Context {
124	return a.ctx
125}
126
127// Init is called when the program starts
128func (a *App) Init() tea.Cmd {
129	a.logger.Debug("TUI app initialized")
130	return tea.Batch(
131		a.formModel.Init(),
132		a.detectTimezoneCmd(),
133	)
134}
135
136// Update handles messages and updates the model
137func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138	switch msg := msg.(type) {
139	case tea.KeyMsg:
140		return a.handleKeyMsg(msg)
141	case handlers.ProgressUpdateMsg:
142		return a.handleProgressUpdate(msg)
143	case handlers.InstallationErrorMsg:
144		return a.handleInstallationError(msg)
145	case handlers.InstallationCompleteMsg:
146		return a.handleInstallationComplete(msg)
147	case CPUDetectedMsg:
148		return a.handleCPUDetected(msg)
149	case GPUDetectedMsg:
150		return a.handleGPUDetected(msg)
151	case TimezoneDetectedMsg:
152		return a.handleTimezoneDetected(msg)
153	case DisksDetectedMsg:
154		return a.handleDisksDetected(msg)
155	}
156
157	return a, nil
158}
159
160// View renders the current screen using the views package
161func (a *App) View() string {
162	switch a.currentScreen {
163	case ScreenForm:
164		return views.RenderForm(a.formModel, a.version)
165	case ScreenDisk:
166		return views.RenderDiskSelection(a.diskModel)
167	case ScreenEncryption:
168		return views.RenderEncryptionSelection(a.encryptionModel)
169	case ScreenKernel:
170		return views.RenderKernelSelection(a.kernelModel)
171	case ScreenAMDPState:
172		return views.RenderAMDPStateSelection(a.amdPstateModel)
173	case ScreenGPU:
174		return views.RenderGPUSelection(a.gpuModel)
175	case ScreenRepos:
176		return views.RenderReposSelection(a.reposModel)
177	case ScreenDankLinux:
178		return views.RenderDankLinuxSelection(a.dankLinuxModel)
179	case ScreenInstalling, ScreenProgress:
180		return views.RenderProgress(a.progressModel)
181	case ScreenSummary:
182		return views.RenderSummary(a.installationModel)
183	case ScreenError:
184		return views.RenderError(a.installationModel.GetError())
185	default:
186		return views.RenderForm(a.formModel, a.version)
187	}
188}
189
190// handleKeyMsg handles keyboard input
191func (a *App) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
192	switch a.currentScreen {
193	case ScreenForm:
194		return a.handleFormInput(msg)
195	case ScreenDisk:
196		return a.handleDiskInput(msg)
197	case ScreenEncryption:
198		return a.handleEncryptionInput(msg)
199	case ScreenKernel:
200		return a.handleKernelInput(msg)
201	case ScreenAMDPState:
202		return a.handleAMDPStateInput(msg)
203	case ScreenGPU:
204		return a.handleGPUInput(msg)
205	case ScreenRepos:
206		return a.handleReposInput(msg)
207	case ScreenDankLinux:
208		return a.handleDankLinuxInput(msg)
209	case ScreenProgress:
210		// Limited input during installation
211		if msg.String() == "ctrl+c" {
212			return a.handleCancel()
213		}
214	case ScreenSummary:
215		switch msg.String() {
216		case "q", "ctrl+c":
217			return a, tea.Quit
218		case "r":
219			a.unmountAndReboot()
220			return a, nil
221		}
222	case ScreenError:
223		if msg.String() == "q" || msg.String() == "ctrl+c" {
224			return a, tea.Quit
225		}
226	}
227
228	return a, nil
229}
230
231// handleFormInput processes form screen input
232func (a *App) handleFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
233	switch msg.String() {
234	case "ctrl+c":
235		return a, tea.Quit
236	case "enter":
237		a.formModel.ExtractData()
238		// Validate form and go to disk selection
239		if err := a.validateForm(); err != nil {
240			a.logger.Warn("Form validation failed", "error", err)
241			// Show error on form, don't leave screen
242			a.formModel.SetError(err)
243			return a, nil
244		}
245		// Clear any previous error and store validated data in App
246		a.formModel.SetError(nil)
247		a.formData = a.formModel.GetData()
248		a.logger.Debug("Form data stored", "password_len", len(a.formData.UserPassword))
249		return a.startDiskSelection()
250	default:
251		// Delegate to form handler
252		updatedForm, cmd := handlers.HandleFormUpdate(a.formModel, msg)
253		a.formModel = updatedForm
254		return a, cmd
255	}
256}
257
258// handleProgressUpdate processes progress events
259func (a *App) handleProgressUpdate(msg handlers.ProgressUpdateMsg) (tea.Model, tea.Cmd) {
260	updatedProgress, cmd := handlers.HandleProgressUpdate(a, msg, a.progressModel)
261	a.progressModel = updatedProgress
262	// Don't override summary or error screens with late progress messages
263	if a.currentScreen != ScreenSummary && a.currentScreen != ScreenError {
264		a.currentScreen = ScreenProgress
265	}
266	return a, cmd
267}
268
269// handleInstallationError processes installation errors
270func (a *App) handleInstallationError(msg handlers.InstallationErrorMsg) (tea.Model, tea.Cmd) {
271	updatedInstall, cmd := handlers.HandleInstallationError(a, msg, a.installationModel)
272	a.installationModel = updatedInstall
273	a.currentScreen = ScreenError
274	return a, cmd
275}
276
277// handleInstallationComplete processes installation completion
278func (a *App) handleInstallationComplete(msg handlers.InstallationCompleteMsg) (tea.Model, tea.Cmd) {
279	updatedInstall, cmd := handlers.HandleInstallationComplete(a, msg, a.installationModel)
280	a.installationModel = updatedInstall
281	a.currentScreen = ScreenSummary
282	return a, cmd
283}
284
285// handleGPUDetected updates GPU model with detection result.
286func (a *App) handleGPUDetected(msg GPUDetectedMsg) (tea.Model, tea.Cmd) {
287	a.gpuModel.SetDetectedGPU(msg.GPU)
288	return a, nil
289}
290
291// handleCancel cancels the installation
292func (a *App) handleCancel() (tea.Model, tea.Cmd) {
293	a.logger.Info("Installation cancelled by user")
294	a.cancel()
295	a.currentScreen = ScreenError
296	a.installationModel.SetError("Installation cancelled by user")
297	return a, nil
298}
299
300func (a *App) unmountAndReboot() {
301	mountPoint := "/mnt"
302	check := legacysystem.RunSimple("mountpoint", "-q", mountPoint)
303	if check.ExitCode == 0 {
304		if err := legacysystem.Unmount(a.logger.LogPath(), mountPoint); err != nil {
305			a.logger.Warn("Failed to unmount before reboot", "error", err)
306			a.installationModel.SetNotice("Failed to unmount /mnt. Please run: umount -R /mnt")
307			return
308		}
309	}
310
311	if _, err := os.Stat("/dev/mapper/cryptroot"); err == nil {
312		result := legacysystem.RunLogged(a.logger.LogPath(), "cryptsetup", "close", "cryptroot")
313		if result.Error != nil {
314			a.logger.Warn("Failed to close encrypted volume", "error", result.Error)
315		}
316	}
317
318	a.logger.Info("Rebooting system")
319	_ = legacysystem.RunLogged(a.logger.LogPath(), "reboot")
320}
321
322func (a *App) handleGPUInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
323	switch msg.String() {
324	case "ctrl+c":
325		return a, tea.Quit
326	case "esc", "backspace":
327		return a.startAMDPStateSelection()
328	case "up", "shift+tab":
329		a.gpuModel.MoveUp()
330		return a, nil
331	case "down", "tab":
332		a.gpuModel.MoveDown()
333		return a, nil
334	case "enter":
335		selected := a.gpuModel.SelectedOption()
336		// Update stored form data directly
337		a.formData.GPUVendor = string(selected.Vendor)
338		a.formData.GPUDrivers = append([]string{}, selected.Drivers...)
339		return a.startReposSelection()
340	}
341
342	return a, nil
343}
344
345func (a *App) handleDankLinuxInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
346	switch msg.String() {
347	case "esc", "backspace":
348		return a.startReposSelection()
349	case "up", "shift+tab":
350		a.dankLinuxModel.MoveUp()
351		return a, nil
352	case "down", "tab":
353		a.dankLinuxModel.MoveDown()
354		return a, nil
355	case "enter":
356		a.formData.InstallDankLinux = a.dankLinuxModel.SelectedOption().Value
357		return a.startInstallation()
358	case "q", "ctrl+c":
359		return a, tea.Quit
360	}
361	return a, nil
362}
363
364func (a *App) startReposSelection() (tea.Model, tea.Cmd) {
365	a.currentScreen = ScreenRepos
366	return a, nil
367}
368
369func (a *App) handleReposInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
370	switch msg.String() {
371	case "ctrl+c", "q":
372		return a, tea.Quit
373	case "esc", "backspace":
374		return a.startGPUSelection()
375	case "up":
376		a.reposModel.MoveUp()
377		return a, nil
378	case "down":
379		a.reposModel.MoveDown()
380		return a, nil
381	case "left", "right", " ":
382		// Select the item under the cursor (radio within its section)
383		a.reposModel.Select()
384		return a, nil
385	case "enter":
386		a.formData.AURHelper = a.reposModel.SelectedAURHelper()
387		a.currentScreen = ScreenDankLinux
388		return a, nil
389	}
390	return a, nil
391}
392
393func (a *App) handleKernelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
394	switch msg.String() {
395	case "ctrl+c":
396		return a, tea.Quit
397	case "esc", "backspace":
398		return a.startDiskSelection()
399	case "up", "shift+tab":
400		a.kernelModel.MoveUp()
401		return a, nil
402	case "down", "tab":
403		a.kernelModel.MoveDown()
404		return a, nil
405	case "enter":
406		selected := a.kernelModel.SelectedOption()
407		a.formData.KernelVariant = selected.Package
408		return a.startAMDPStateSelection()
409	}
410
411	return a, nil
412}
413
414func (a *App) handleCPUDetected(msg CPUDetectedMsg) (tea.Model, tea.Cmd) {
415	if msg.Err != nil {
416		a.logger.Warn("CPU detection reported error", "error", msg.Err)
417	}
418
419	a.amdPstateModel.SetCPUInfo(msg.CPU)
420
421	// Always include microcode when CPU vendor is known
422	if msg.CPU != nil && msg.CPU.Vendor != "" && msg.CPU.Vendor != legacysystem.CPUVendorUnknown {
423		a.formData.Microcode = true
424	}
425
426	if !a.amdPstateModel.ShouldPrompt() {
427		a.formData.AMDPState = ""
428		a.formData.KernelParamsExtra = ""
429		return a.startGPUSelection()
430	}
431
432	return a, nil
433}
434
435func (a *App) handleAMDPStateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
436	switch msg.String() {
437	case "ctrl+c":
438		return a, tea.Quit
439	case "esc", "backspace":
440		return a.startKernelSelection()
441	case "up", "shift+tab":
442		a.amdPstateModel.MoveUp()
443		return a, nil
444	case "down", "tab":
445		a.amdPstateModel.MoveDown()
446		return a, nil
447	case "enter":
448		selected := a.amdPstateModel.SelectedOption()
449		mode := string(selected.Mode)
450		a.formData.AMDPState = mode
451		a.formData.KernelParamsExtra = ""
452
453		cpuInfo := a.amdPstateModel.CPUInfo()
454		switch {
455		case mode == "":
456			// No selection
457		case cpuInfo != nil && cpuInfo.AMDZenGen != nil:
458			a.formData.KernelParamsExtra = legacysystem.GetAMDPStateKernelParams(cpuInfo.AMDZenGen, selected.Mode)
459		default:
460			a.formData.KernelParamsExtra = "amd_pstate=" + mode
461		}
462
463		return a.startGPUSelection()
464	}
465
466	return a, nil
467}
468
469// validateForm validates the form data using domain validation
470func (a *App) validateForm() error {
471	formData := a.formModel.GetData()
472
473	// Use domain validation for all fields
474	if err := system.ValidateHostname(formData.Hostname); err != nil {
475		return err
476	}
477
478	if err := system.ValidateUsername(formData.Username); err != nil {
479		return err
480	}
481
482	if err := system.ValidatePassword(formData.UserPassword); err != nil {
483		return err
484	}
485
486	if err := system.ValidateTimezone(formData.Timezone); err != nil {
487		return err
488	}
489
490	if err := system.ValidateLocale(formData.Locale); err != nil {
491		return err
492	}
493
494	if err := system.ValidateKeymap(formData.Keymap); err != nil {
495		return err
496	}
497
498	// TargetDisk is selected in disk selection screen, not validated here
499	return nil
500}
501
502// startInstallation starts the installation process
503func (a *App) startInstallation() (tea.Model, tea.Cmd) {
504	a.currentScreen = ScreenInstalling
505	a.logger.Debug("Starting installation", "password_len", len(a.formData.UserPassword))
506
507	// Create installation command using stored form data
508	cmd := handlers.CreateInstallationCommand(a, a.formData)
509
510	return a, tea.Batch(cmd)
511}
512
513func (a *App) startAMDPStateSelection() (tea.Model, tea.Cmd) {
514	a.currentScreen = ScreenAMDPState
515	return a, a.detectCPUCmd()
516}
517
518func (a *App) startKernelSelection() (tea.Model, tea.Cmd) {
519	a.currentScreen = ScreenKernel
520	a.kernelModel.SetSelectedPackage(a.formData.KernelVariant)
521	return a, nil
522}
523
524func (a *App) detectCPUCmd() tea.Cmd {
525	return func() tea.Msg {
526		info, err := legacysystem.DetectCPUInfo()
527		if err != nil {
528			a.logger.Warn("CPU detection failed", "error", err)
529		}
530		return CPUDetectedMsg{CPU: info, Err: err}
531	}
532}
533
534func (a *App) startGPUSelection() (tea.Model, tea.Cmd) {
535	a.currentScreen = ScreenGPU
536	return a, a.detectGPUCmd()
537}
538
539func (a *App) detectGPUCmd() tea.Cmd {
540	return func() tea.Msg {
541		gpu, err := a.gpuHandler.Detect(a.ctx)
542		if err != nil {
543			a.logger.Warn("GPU detection failed", "error", err)
544		}
545		return GPUDetectedMsg{GPU: gpu}
546	}
547}
548
549func (a *App) startDiskSelection() (tea.Model, tea.Cmd) {
550	a.currentScreen = ScreenDisk
551	return a, a.detectDisksCmd()
552}
553
554func (a *App) detectDisksCmd() tea.Cmd {
555	return func() tea.Msg {
556		disks, err := legacysystem.ListDisks()
557		return DisksDetectedMsg{Disks: disks, Err: err}
558	}
559}
560
561func (a *App) detectTimezoneCmd() tea.Cmd {
562	return func() tea.Msg {
563		tz := legacysystem.DetectTimezoneSimple()
564		return TimezoneDetectedMsg{Timezone: tz}
565	}
566}
567
568func (a *App) handleDiskInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
569	switch msg.String() {
570	case "ctrl+c":
571		return a, tea.Quit
572	case "esc", "backspace":
573		a.currentScreen = ScreenForm
574		return a, nil
575	case "up", "shift+tab":
576		a.diskModel.MoveUp()
577		return a, nil
578	case "down", "tab":
579		a.diskModel.MoveDown()
580		return a, nil
581	case "enter":
582		selected := a.diskModel.SelectedOption()
583		if selected.Path == "" {
584			// No disk selected
585			a.currentScreen = ScreenError
586			a.installationModel.SetError("No disk selected")
587			return a, nil
588		}
589		// Update stored form data directly
590		a.formData.TargetDisk = selected.Path
591		return a.startEncryptionSelection()
592	}
593	return a, nil
594}
595
596func (a *App) startEncryptionSelection() (tea.Model, tea.Cmd) {
597	a.currentScreen = ScreenEncryption
598	return a, nil
599}
600
601func (a *App) handleEncryptionInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
602	switch msg.String() {
603	case "ctrl+c":
604		return a, tea.Quit
605	case "esc", "backspace":
606		return a.startDiskSelection()
607	case "up", "shift+tab":
608		a.encryptionModel.MoveUp()
609		return a, nil
610	case "down", "tab":
611		a.encryptionModel.MoveDown()
612		return a, nil
613	case "enter":
614		a.formData.EncryptionType = a.encryptionModel.SelectedOption().Value
615		return a.startKernelSelection()
616	}
617	return a, nil
618}
619
620func (a *App) handleTimezoneDetected(msg TimezoneDetectedMsg) (tea.Model, tea.Cmd) {
621	if msg.Timezone != "" {
622		a.logger.Info("Timezone detected", "timezone", msg.Timezone)
623		a.formModel.SetTimezone(msg.Timezone)
624	}
625	return a, nil
626}
627
628func (a *App) handleDisksDetected(msg DisksDetectedMsg) (tea.Model, tea.Cmd) {
629	if msg.Err != nil {
630		a.logger.Warn("Disk detection failed", "error", msg.Err)
631		a.diskModel.SetError(msg.Err)
632	} else {
633		a.diskModel.SetDisks(msg.Disks)
634	}
635	return a, nil
636}
637
638// Close cleans up resources
639func (a *App) Close() error {
640	a.cancel()
641	return a.installService.Close()
642}
643
644// SetProgram sets the tea.Program reference for sending messages from goroutines
645func (a *App) SetProgram(p *tea.Program) {
646	a.program = p
647}
648
649// GetProgram returns the tea.Program reference
650func (a *App) GetProgram() *tea.Program {
651	return a.program
652}

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