From 4c62ad453be173d373fbf019c8d6bddfffbfdad1 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Sun, 2 Aug 2026 01:30:27 +0800 Subject: [PATCH 1/2] refactor(tui): remove legacy install selectors --- AGENTS.md | 2 +- go.mod | 1 - go.sum | 2 - internal/cli/install.go | 49 +- internal/cli/wizard_routing_test.go | 37 +- internal/config/types.go | 2 +- internal/installer/installer.go | 6 +- internal/installer/installer_test.go | 5 - internal/installer/plan.go | 39 +- internal/installer/plan_test.go | 11 + internal/macos/categories.go | 18 +- internal/macos/categories_test.go | 43 +- internal/ui/AGENTS.md | 5 +- internal/ui/tui/macos_selector.go | 435 ------------------ internal/ui/tui/macos_selector_test.go | 428 ----------------- internal/ui/tui/selector.go | 418 ----------------- internal/ui/tui/selector_test.go | 388 ---------------- internal/ui/tui/selector_view.go | 614 ------------------------- internal/ui/tui/shared.go | 105 +++++ internal/ui/tui/wizard/confirm.go | 14 +- internal/ui/tui/wizard/status.go | 15 +- internal/ui/tui/wizard/wizard.go | 5 +- internal/ui/tui/wizard/wizard_test.go | 21 + internal/ui/ui.go | 25 - 24 files changed, 247 insertions(+), 2441 deletions(-) delete mode 100644 internal/ui/tui/macos_selector.go delete mode 100644 internal/ui/tui/macos_selector_test.go delete mode 100644 internal/ui/tui/selector.go delete mode 100644 internal/ui/tui/selector_test.go delete mode 100644 internal/ui/tui/selector_view.go create mode 100644 internal/ui/tui/shared.go diff --git a/AGENTS.md b/AGENTS.md index 0e8da81..fe64898 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Core flow: `openboot install` orchestrates plan -> apply in `internal/installer/ **The wizard plans; the apply is always linear.** On a TTY the planning phase runs as a full-screen TUI in `internal/ui/tui/wizard/` (boot probe -> select -> git -> review), then the wizard *exits* and hands its `InstallPlan` to `installer.ApplyReviewedPlan`, which applies it on the normal terminal with `ConsoleReporter` + `ui.StickyProgress`. This split is deliberate: a TUI is right for browsing a 100+ package catalog, and wrong for the apply — an alt-screen install discards its own output when it exits, so twenty minutes of package results and failures vanish. Streamed into the scrollback they stay where the user can scroll back, copy an error, and pipe it. Don't move the apply back inside the alt-screen. -Entry points: bare `install` -> `wizard.Run`; `-p ` -> same, loadout preselected; slug/`-u`/`--from`/alias -> `wizard.RunForConfig` (config mode: the config's own packages on the select screen, preselected). Sync-source installs keep their linear diff pre-flight and apply linearly. `--silent`, `--dry-run`, `--update`, `--pick`, and non-TTY runs never enter the wizard. +Entry points: bare `install` -> `wizard.Run`; `-p ` -> same, loadout preselected; slug/`-u`/`--from`/alias -> `wizard.RunForConfig` (config mode: the config's own packages on the select screen, preselected). TTY `--dry-run` uses the same wizard and carries preview-only semantics into apply. Sync-source installs keep their linear diff pre-flight and apply linearly. `--silent`, `--update`, `--pick`, and non-TTY runs never enter the wizard. ## Working in parallel diff --git a/go.mod b/go.mod index cb94540..0ca4303 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/charmbracelet/bubbletea v1.3.0 github.com/charmbracelet/huh v0.6.0 github.com/charmbracelet/lipgloss v1.0.0 - github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.11.1 golang.org/x/term v0.39.0 diff --git a/go.sum b/go.sum index 69790f6..21ed235 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/internal/cli/install.go b/internal/cli/install.go index e67afc7..b1bae75 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -77,7 +77,7 @@ func init() { installCmd.Flags().StringVar(&installCfg.Dotfiles, "dotfiles", "", "dotfiles: clone, link, skip") installCmd.Flags().StringVar(&installCfg.PostInstall, "post-install", "", "post-install script: skip") - installCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew before installing") + installCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew and exit") installCmd.Flags().BoolVar(&installCfg.AllowPostInstall, "allow-post-install", false, "allow post-install scripts in silent mode") } @@ -101,9 +101,16 @@ func applyEnvOverrides(cfg *config.Config) { } } -func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo // top-level install dispatch: routes source (sync / wizard / RemoteConfig) × mode (pick / silent / dry-run / TTY-pipeline / linear); splitting the branch table scatters the flow +func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo // top-level install dispatch: routes source (sync / wizard / RemoteConfig) × mode (pick / silent / dry-run / update / linear); splitting the branch table scatters the flow applyEnvOverrides(installCfg) + // --update is a standalone Homebrew maintenance mode. It does not consume + // an install source or make package-selection decisions, so keep it out of + // every wizard/customizer branch (including a saved sync source). + if installCfg.Update { + return installer.RunContext(cmd.Context(), installCfg) + } + if installCfg.RemoteConfig == nil { src, err := resolveInstallSource(cmd, args) if err != nil { @@ -135,11 +142,11 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / return perr } installCfg.RemoteConfig = rc - } else if !installCfg.Silent && !installCfg.DryRun && !installCfg.Update && system.HasTTY() { + } else if wizardMode(installCfg, system.HasTTY()) { // The full-screen config wizard owns the whole interactive flow - // (select the config's packages → review → live install) — + // (select the config's packages → review → linear apply) — // replacing the linear 3-way prompt + customizer that used to - // precede the pipeline screen for slug / -u / --from / alias. + // handle slug / -u / --from / alias, including dry-run previews. return runConfigWizard(cmd.Context(), installCfg.RemoteConfig) } else if !installCfg.Silent && (!installCfg.DryRun || system.HasTTY()) { rc, proceed, err := promptCustomizeAndApply(installCfg.RemoteConfig) @@ -153,13 +160,10 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / installCfg.RemoteConfig = rc } } else if pickRaw != "" { - return fmt.Errorf("--pick requires a remote config; use the preset selector instead") + return fmt.Errorf("--pick requires a remote config; use the interactive wizard instead") } err := installer.RunContext(cmd.Context(), installCfg) - if errors.Is(err, installer.ErrUserCancelled) { - return nil - } if err == nil && !installCfg.DryRun { saveSyncSourceIfRemote(installCfg) } @@ -167,14 +171,20 @@ func runInstallCmd(cmd *cobra.Command, args []string) error { //nolint:gocyclo / } // shouldLaunchWizard reports whether this run gets the full-screen wizard -// (boot probe → select → live install) on a TTY: a bare `openboot install`, +// (boot probe → select → git → review) on a TTY: a bare `openboot install`, // or a valid preset (-p / OPENBOOT_PRESET / positional), which enters the // wizard with that loadout preselected on the select screen. Remote sources -// (--from, -u, slug, sync), --silent, --dry-run, and --update keep their -// existing flows. +// (--from, -u, slug, sync), --silent, --update, and non-TTY runs keep their +// dedicated flows. Dry-run is still interactive on a TTY; the returned plan +// carries DryRun through the normal preview-only apply. func shouldLaunchWizard(src *installSource) bool { - return wizardSource(src) && !installCfg.Silent && !installCfg.DryRun && - !installCfg.Update && system.HasTTY() + return wizardSource(src) && wizardMode(installCfg, system.HasTTY()) +} + +// wizardMode is the mode half of wizard routing. DryRun is intentionally not +// checked: previewing changes changes apply semantics, not the planning UI. +func wizardMode(cfg *config.Config, hasTTY bool) bool { + return !cfg.Silent && !cfg.Update && hasTTY } // wizardSource is the source-kind half of the wizard-routing decision, split @@ -184,8 +194,8 @@ func wizardSource(src *installSource) bool { case sourceNone: return true case sourcePreset: - // An unknown preset must keep the linear path, which rejects it with a - // clear error instead of silently opening an empty wizard. + // Keep this check defensive even though applyInstallSource validates the + // flag before routing. _, ok := config.GetPreset(installCfg.Preset) return ok default: @@ -236,7 +246,9 @@ func runConfigWizard(ctx context.Context, rc *config.RemoteConfig) error { if err := applyReviewedPlan(ctx, plan); err != nil { return err } - saveSyncSourceIfRemote(installCfg) + if !installCfg.DryRun { + saveSyncSourceIfRemote(installCfg) + } return nil } @@ -356,6 +368,9 @@ func applyInstallSource(src *installSource) error { case sourcePreset: // installCfg.Preset is already set (by flag or resolvePositionalArg). + if _, ok := config.GetPreset(installCfg.Preset); !ok { + return fmt.Errorf("unknown preset %q (available: %s)", installCfg.Preset, strings.Join(config.GetPresetNames(), ", ")) + } return nil case sourceSyncSource: diff --git a/internal/cli/wizard_routing_test.go b/internal/cli/wizard_routing_test.go index b0df77d..30ed223 100644 --- a/internal/cli/wizard_routing_test.go +++ b/internal/cli/wizard_routing_test.go @@ -4,11 +4,13 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/openbootdotdev/openboot/internal/config" ) // wizardSource is the source-kind half of shouldLaunchWizard, split out so the // routing is testable without a TTY: bare installs and valid presets get the -// full wizard; unknown presets and remote sources keep the linear path. +// full wizard; unknown presets and remote sources use their dedicated paths. func TestWizardSource(t *testing.T) { oldPreset := installCfg.Preset defer func() { installCfg.Preset = oldPreset }() @@ -19,9 +21,40 @@ func TestWizardSource(t *testing.T) { assert.True(t, wizardSource(&installSource{kind: sourcePreset}), "valid preset") installCfg.Preset = "not-a-preset" - assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset keeps the linear error path") + assert.False(t, wizardSource(&installSource{kind: sourcePreset}), "unknown preset is rejected before wizard launch") assert.False(t, wizardSource(&installSource{kind: sourceCloud}), "cloud config") assert.False(t, wizardSource(&installSource{kind: sourceSyncSource}), "sync source") assert.False(t, wizardSource(&installSource{kind: sourceFile}), "local file") } + +func TestWizardMode(t *testing.T) { + tests := []struct { + name string + cfg config.Config + hasTTY bool + want bool + }{ + {name: "interactive install", hasTTY: true, want: true}, + {name: "dry run uses the same mode", cfg: config.Config{InstallOptions: config.InstallOptions{DryRun: true}}, hasTTY: true, want: true}, + {name: "silent", cfg: config.Config{InstallOptions: config.InstallOptions{Silent: true}}, hasTTY: true}, + {name: "update", cfg: config.Config{InstallOptions: config.InstallOptions{Update: true}}, hasTTY: true}, + {name: "non tty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, wizardMode(&tt.cfg, tt.hasTTY)) + }) + } +} + +func TestApplyInstallSourceRejectsUnknownPreset(t *testing.T) { + oldPreset := installCfg.Preset + defer func() { installCfg.Preset = oldPreset }() + + installCfg.Preset = "not-a-preset" + err := applyInstallSource(&installSource{kind: sourcePreset}) + + assert.EqualError(t, err, `unknown preset "not-a-preset" (available: minimal, developer, full)`) +} diff --git a/internal/config/types.go b/internal/config/types.go index b2c9319..9c2db17 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -28,7 +28,7 @@ type InstallOptions struct { // InstallState holds runtime values populated during installation. // Fields are written by installer steps and read by subsequent steps. type InstallState struct { - SelectedPkgs map[string]bool // set by UI package selector + SelectedPkgs map[string]bool // set by the wizard or preset planner OnlinePkgs []Package // fetched from packages API SnapshotTaps []string // from snapshot capture RemoteConfig *RemoteConfig // fetched from openboot.dev at startup diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 7cd08ab..00b674b 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -15,8 +15,6 @@ import ( "github.com/openbootdotdev/openboot/internal/ui" ) -var ErrUserCancelled = errors.New("user cancelled") - const ( estimatedSecondsPerFormula = 15 estimatedSecondsPerCask = 30 @@ -104,6 +102,10 @@ func ApplyReviewedPlan(ctx context.Context, plan InstallPlan) error { ui.Println() ui.Header(fmt.Sprintf("OpenBoot Installer v%s", plan.Version)) ui.Println() + if plan.DryRun { + ui.Muted("[DRY-RUN MODE - No changes will be made]") + ui.Println() + } return ApplyContext(ctx, plan, ConsoleReporter{}) } diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go index 1d6e56e..3ce506e 100644 --- a/internal/installer/installer_test.go +++ b/internal/installer/installer_test.go @@ -270,11 +270,6 @@ func TestGetStatePath(t *testing.T) { assert.True(t, filepath.IsAbs(path)) } -func TestErrUserCancelled(t *testing.T) { - assert.Error(t, ErrUserCancelled) - assert.Equal(t, "user cancelled", ErrUserCancelled.Error()) -} - func TestInstallState_OnlySuccessfulPackagesMarked(t *testing.T) { tmpDir := t.TempDir() t.Setenv("HOME", tmpDir) diff --git a/internal/installer/plan.go b/internal/installer/plan.go index 6aec411..ce284ef 100644 --- a/internal/installer/plan.go +++ b/internal/installer/plan.go @@ -9,7 +9,6 @@ import ( "github.com/openbootdotdev/openboot/internal/shell" "github.com/openbootdotdev/openboot/internal/system" "github.com/openbootdotdev/openboot/internal/ui" - "github.com/openbootdotdev/openboot/internal/ui/tui" ) // InstallPlan captures all resolved decisions from the interactive planning phase. @@ -204,30 +203,12 @@ func planGitConfig(opts *config.InstallOptions) (name, email string, err error) func planPackages(opts *config.InstallOptions, st *config.InstallState, plan *InstallPlan) error { if opts.Preset == "" { - if opts.Silent || (opts.DryRun && !system.HasTTY()) { - opts.Preset = "minimal" - } else { - var err error - opts.Preset, err = ui.SelectPreset() - if err != nil { - return fmt.Errorf("select preset: %w", err) - } - } + opts.Preset = "minimal" } - - if opts.Silent || (opts.DryRun && !system.HasTTY()) { - st.SelectedPkgs = config.GetPackagesForPreset(opts.Preset) - } else { - selected, onlinePkgs, confirmed, err := tui.RunSelector(opts.Preset) - if err != nil { - return fmt.Errorf("run package selector: %w", err) - } - if !confirmed { - return ErrUserCancelled - } - st.SelectedPkgs = selected - st.OnlinePkgs = onlinePkgs + if _, ok := config.GetPreset(opts.Preset); !ok { + return fmt.Errorf("unknown preset %q", opts.Preset) } + st.SelectedPkgs = config.GetPackagesForPreset(opts.Preset) plan.SelectedPkgs = st.SelectedPkgs plan.OnlinePkgs = st.OnlinePkgs @@ -295,17 +276,7 @@ func planMacOSDecision(opts *config.InstallOptions) ([]macos.Preference, error) if opts.Macos == "skip" { return nil, nil } - if opts.Macos == "configure" || opts.Silent || (opts.DryRun && !system.HasTTY()) { - return macos.DefaultPreferences, nil - } - selected, confirmed, err := tui.RunMacOSSelector() - if err != nil { - return nil, fmt.Errorf("macOS selector: %w", err) - } - if !confirmed { - return nil, nil - } - return selected, nil + return macos.DefaultPreferences, nil } // PlanFromSelection builds a ready-to-Apply InstallPlan from an explicit diff --git a/internal/installer/plan_test.go b/internal/installer/plan_test.go index eae28b5..2c7f007 100644 --- a/internal/installer/plan_test.go +++ b/internal/installer/plan_test.go @@ -304,6 +304,17 @@ func TestPlanInteractive_DryRun_Minimal_AllSkipped(t *testing.T) { assert.False(t, plan.InstallOhMyZsh) } +func TestPlanPackages_UnknownPresetRejected(t *testing.T) { + opts := &config.InstallOptions{Preset: "not-a-preset"} + st := &config.InstallState{} + plan := InstallPlan{} + + err := planPackages(opts, st, &plan) + require.Error(t, err) + assert.Contains(t, err.Error(), `unknown preset "not-a-preset"`) + assert.Nil(t, st.SelectedPkgs) +} + // --------------------------------------------------------------------------- // PlanFromSnapshot // --------------------------------------------------------------------------- diff --git a/internal/macos/categories.go b/internal/macos/categories.go index 9f51b05..ab77fdf 100644 --- a/internal/macos/categories.go +++ b/internal/macos/categories.go @@ -1,17 +1,12 @@ package macos -// PrefCategory groups related macOS preferences for display in the TUI selector. +// PrefCategory groups related macOS preferences in the default catalog. type PrefCategory struct { Name string Icon string Prefs []Preference } -// PrefKey returns a unique identifier for a preference, used as the selection map key. -func PrefKey(p Preference) string { - return p.Domain + "/" + p.Key -} - // DefaultCategories groups DefaultPreferences by logical category. var DefaultCategories = []PrefCategory{ { @@ -156,14 +151,3 @@ var DefaultCategories = []PrefCategory{ }, }, } - -// AllPrefsSelected returns a map with all default preferences set to true. -func AllPrefsSelected() map[string]bool { - selected := make(map[string]bool) - for _, cat := range DefaultCategories { - for _, p := range cat.Prefs { - selected[PrefKey(p)] = true - } - } - return selected -} diff --git a/internal/macos/categories_test.go b/internal/macos/categories_test.go index 6840576..b61b089 100644 --- a/internal/macos/categories_test.go +++ b/internal/macos/categories_test.go @@ -42,51 +42,12 @@ func TestDefaultPreferences_DerivedFromCategories(t *testing.T) { func TestDefaultPreferences_NoDuplicateKeys(t *testing.T) { seen := make(map[string]bool) for _, p := range DefaultPreferences { - k := PrefKey(p) - assert.False(t, seen[k], "duplicate PrefKey %q", k) + k := p.Domain + "/" + p.Key + assert.False(t, seen[k], "duplicate preference key %q", k) seen[k] = true } } -func TestPrefKey_Format(t *testing.T) { - p := Preference{Domain: "com.apple.finder", Key: "ShowPathbar"} - assert.Equal(t, "com.apple.finder/ShowPathbar", PrefKey(p)) -} - -func TestPrefKey_UniqueAcrossCategories(t *testing.T) { - keys := make(map[string]bool) - for _, cat := range DefaultCategories { - for _, p := range cat.Prefs { - k := PrefKey(p) - assert.False(t, keys[k], "duplicate PrefKey %q in categories", k) - keys[k] = true - } - } -} - -func TestAllPrefsSelected_CountMatchesDefaultPreferences(t *testing.T) { - selected := AllPrefsSelected() - assert.Equal(t, len(DefaultPreferences), len(selected)) -} - -func TestAllPrefsSelected_AllTrue(t *testing.T) { - selected := AllPrefsSelected() - for k, v := range selected { - assert.True(t, v, "expected preference %q to be selected", k) - } -} - -func TestAllPrefsSelected_KeysMatchDefaultCategories(t *testing.T) { - selected := AllPrefsSelected() - for _, cat := range DefaultCategories { - for _, p := range cat.Prefs { - k := PrefKey(p) - _, ok := selected[k] - assert.True(t, ok, "expected key %q to be present in AllPrefsSelected", k) - } - } -} - func TestDefaultCategories_PrefsHaveRequiredFields(t *testing.T) { validTypes := map[string]bool{"bool": true, "int": true, "float": true, "string": true} for _, cat := range DefaultCategories { diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index 2fa900f..fb865c1 100644 --- a/internal/ui/AGENTS.md +++ b/internal/ui/AGENTS.md @@ -7,7 +7,7 @@ live in the `tui/` subpackage. | File | Lines | Purpose | |------|-------|---------| -| `ui.go` | 247 | Base styles, color helpers, output helpers (Header/Success/Error/Info/Warn/Muted/DryRun*), huh form wrappers (InputGitConfig, SelectPreset, Confirm, SelectOption, Input) | +| `ui.go` | output + forms | Base styles, output helpers, and small huh form wrappers | | `progress.go` | 245 | StickyProgress: per-package timing, succeeded/failed/skipped counters, in-place status line | | `scanprogress.go` | 221 | ScanProgress: step timing, overall counter `[3/8]` | @@ -15,10 +15,9 @@ live in the `tui/` subpackage. | File | Lines | Purpose | |------|-------|---------| -| `selector.go` + `selector_view.go` | ~1,026 | Package selector: tabs, fuzzy search, online search, multi-select | | `snapshot_editor.go` + `snapshot_editor_search.go` | ~1,022 | Snapshot editing: diff view, toggle packages, add online, confirm | | `config_customizer.go` | 253 | Remote-config customizer: toggle which packages to install | -| `macos_selector.go` | 435 | macOS preferences selector: category tabs, toggle, confirm | +| `wizard/` | install planner | Boot probe, package selection, git identity, and plan review | ## PATTERNS diff --git a/internal/ui/tui/macos_selector.go b/internal/ui/tui/macos_selector.go deleted file mode 100644 index 99a0a89..0000000 --- a/internal/ui/tui/macos_selector.go +++ /dev/null @@ -1,435 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/openbootdotdev/openboot/internal/macos" -) - -// MacOSSelectorModel is a bubbletea model for selecting macOS preferences by category. -type MacOSSelectorModel struct { - categories []macos.PrefCategory - selected map[string]bool // key: macos.PrefKey(pref) - activeTab int - cursor int - scrollOffset int - cursorPositions map[int]int - showConfirmation bool - confirmed bool - toastMessage string - toastIsAdd bool - width int - height int -} - -func NewMacOSSelector() MacOSSelectorModel { - return MacOSSelectorModel{ - categories: macos.DefaultCategories, - selected: macos.AllPrefsSelected(), - cursorPositions: make(map[int]int), - } -} - -func (m MacOSSelectorModel) Init() tea.Cmd { - return nil -} - -func (m MacOSSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // bubbletea Update dispatches on all message types; splitting breaks the tea.Model contract - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - case toastClearMsg: - m.toastMessage = "" - return m, nil - - case tea.KeyMsg: - if m.showConfirmation { - switch msg.String() { - case "enter": - m.confirmed = true - return m, tea.Quit - case "esc": - m.showConfirmation = false - return m, nil - } - return m, nil - } - - switch { - case key.Matches(msg, keys.Quit): - return m, tea.Quit - - case key.Matches(msg, keys.Tab), key.Matches(msg, keys.Right): - m.cursorPositions[m.activeTab] = m.cursor - m.activeTab = (m.activeTab + 1) % len(m.categories) - m.cursor = m.cursorPositions[m.activeTab] - if m.cursor >= len(m.categories[m.activeTab].Prefs) { - m.cursor = 0 - } - m.scrollOffset = 0 - - case key.Matches(msg, keys.ShiftTab), key.Matches(msg, keys.Left): - m.cursorPositions[m.activeTab] = m.cursor - m.activeTab = (m.activeTab - 1 + len(m.categories)) % len(m.categories) - m.cursor = m.cursorPositions[m.activeTab] - if m.cursor >= len(m.categories[m.activeTab].Prefs) { - m.cursor = 0 - } - m.scrollOffset = 0 - - case key.Matches(msg, keys.Up): - if m.cursor > 0 { - m.cursor-- - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - } - - case key.Matches(msg, keys.Down): - prefs := m.categories[m.activeTab].Prefs - if m.cursor < len(prefs)-1 { - m.cursor++ - visibleItems := m.macosVisibleItems() - if m.cursor >= m.scrollOffset+visibleItems { - m.scrollOffset = m.cursor - visibleItems + 1 - } - } - - case key.Matches(msg, keys.Space): - prefs := m.categories[m.activeTab].Prefs - if m.cursor < len(prefs) { - pref := prefs[m.cursor] - k := macos.PrefKey(pref) - m.selected[k] = !m.selected[k] - if m.selected[k] { - m.toastMessage = "+ Enabled " + pref.Desc - m.toastIsAdd = true - } else { - m.toastMessage = "- Disabled " + pref.Desc - m.toastIsAdd = false - } - return m, toastClearCmd() - } - - case key.Matches(msg, keys.Enter): - m.showConfirmation = true - return m, nil - - case key.Matches(msg, keys.SelectAll): - cat := m.categories[m.activeTab] - allEnabled := true - for _, p := range cat.Prefs { - if !m.selected[macos.PrefKey(p)] { - allEnabled = false - break - } - } - for _, p := range cat.Prefs { - m.selected[macos.PrefKey(p)] = !allEnabled - } - if !allEnabled { - m.toastMessage = fmt.Sprintf("✔ Enabled all %s", cat.Name) - m.toastIsAdd = true - } else { - m.toastMessage = fmt.Sprintf("○ Disabled all %s", cat.Name) - m.toastIsAdd = false - } - return m, toastClearCmd() - } - } - - return m, nil -} - -func (m MacOSSelectorModel) macosVisibleItems() int { - if m.height == 0 { - return 15 - } - available := m.height - 8 - if available < 5 { - available = 5 - } - if available > 20 { - available = 20 - } - return available -} - -func (m MacOSSelectorModel) macosRenderTabBar() string { - totalTabs := len(m.categories) - - arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) - neighborStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666")) - sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444")) - posStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) - - cat := m.categories[m.activeTab] - count := 0 - for _, p := range cat.Prefs { - if m.selected[macos.PrefKey(p)] { - count++ - } - } - activeRendered := activeTabStyle.Render(fmt.Sprintf("%s %s (%d)", cat.Icon, cat.Name, count)) - posRendered := posStyle.Render(fmt.Sprintf(" %d/%d", m.activeTab+1, totalTabs)) - - hasLeft := m.activeTab > 0 - hasRight := m.activeTab < totalTabs-1 - leftArrow := " " - if hasLeft { - leftArrow = arrowStyle.Render("‹ ") - } - rightArrow := " " - if hasRight { - rightArrow = arrowStyle.Render(" ›") - } - - termWidth := m.width - if termWidth == 0 { - termWidth = 80 - } - - sep := sepStyle.Render(" │ ") - sepW := lipgloss.Width(sep) - baseWidth := lipgloss.Width(leftArrow) + lipgloss.Width(activeRendered) + lipgloss.Width(rightArrow) + lipgloss.Width(posRendered) - remaining := termWidth - baseWidth - - var leftNeighbors []string - var rightNeighbors []string - li := m.activeTab - 1 - ri := m.activeTab + 1 - - for remaining > 0 && (li >= 0 || ri < totalTabs) { - added := false - if li >= 0 { - rendered := neighborStyle.Render(m.categories[li].Name) - w := lipgloss.Width(rendered) + sepW - if w <= remaining { - leftNeighbors = append([]string{rendered}, leftNeighbors...) - remaining -= w - li-- - added = true - } else { - li = -1 - } - } - if ri < totalTabs { - rendered := neighborStyle.Render(m.categories[ri].Name) - w := lipgloss.Width(rendered) + sepW - if w <= remaining { - rightNeighbors = append(rightNeighbors, rendered) - remaining -= w - ri++ - added = true - } else { - ri = totalTabs - } - } - if !added { - break - } - } - - var result strings.Builder - result.WriteString(leftArrow) - for _, n := range leftNeighbors { - result.WriteString(n) - result.WriteString(sep) - } - result.WriteString(activeRendered) - for _, n := range rightNeighbors { - result.WriteString(sep) - result.WriteString(n) - } - result.WriteString(rightArrow) - result.WriteString(posRendered) - - return result.String() -} - -func (m MacOSSelectorModel) View() string { - if m.showConfirmation { - return m.macosConfirmationView() - } - - var lines []string - lines = append(lines, m.macosRenderTabBar()) - lines = append(lines, "") - - cat := m.categories[m.activeTab] - prefs := cat.Prefs - visibleItems := m.macosVisibleItems() - - scrollOffset := m.scrollOffset - if scrollOffset > len(prefs)-visibleItems { - scrollOffset = len(prefs) - visibleItems - } - if scrollOffset < 0 { - scrollOffset = 0 - } - - endIdx := scrollOffset + visibleItems - if endIdx > len(prefs) { - endIdx = len(prefs) - } - - for i := scrollOffset; i < endIdx; i++ { - pref := prefs[i] - cursor := " " - if i == m.cursor { - cursor = "> " - } - checkbox := "[ ]" - style := itemStyle - if m.selected[macos.PrefKey(pref)] { - checkbox = "[✓]" - style = selectedStyle - } - line := fmt.Sprintf("%s%s %s %s", cursor, checkbox, style.Render(pref.Key), descStyle.Render(pref.Desc)) - if m.width > 0 { - line = padLine(truncateLine(line, m.width-2), m.width) - } - lines = append(lines, line) - } - - clearWidth := m.width - if clearWidth <= 0 { - clearWidth = 80 - } - clearLine := strings.Repeat(" ", clearWidth) - for len(lines) < visibleItems+2 { - lines = append(lines, clearLine) - } - - totalSelected := 0 - for _, v := range m.selected { - if v { - totalSelected++ - } - } - - lines = append(lines, "") - if m.toastMessage != "" { - toastStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Italic(true) - if !m.toastIsAdd { - toastStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888")).Italic(true) - } - lines = append(lines, toastStyle.Render(m.toastMessage)) - } else { - lines = append(lines, countStyle.Render(fmt.Sprintf("Selected: %d preferences", totalSelected))) - } - lines = append(lines, "") - lines = append(lines, helpStyle.Render("Tab/←→: switch • ↑↓: navigate • Space: toggle • a: all • Enter: confirm • q: quit")) - - return padAllLines(strings.Join(lines, "\n"), m.width) -} - -func (m MacOSSelectorModel) macosConfirmationView() string { - boxWidth := 60 - if m.width > 0 && m.width < 70 { - boxWidth = m.width - 10 - if boxWidth < 40 { - boxWidth = 40 - } - } - - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#22c55e")). - Padding(1, 2). - Width(boxWidth) - - headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). - Bold(true) - - sectionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#fff")). - Bold(true) - - listStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888")) - - instructionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666")). - Italic(true) - - var content strings.Builder - content.WriteString(headerStyle.Render("macOS Preferences Summary")) - content.WriteString("\n\n") - - total := 0 - for _, v := range m.selected { - if v { - total++ - } - } - fmt.Fprintf(&content, "Total: %d preferences\n\n", total) - - for _, cat := range m.categories { - var enabled []string - for _, p := range cat.Prefs { - if m.selected[macos.PrefKey(p)] { - enabled = append(enabled, p.Desc) - } - } - if len(enabled) == 0 { - continue - } - content.WriteString(sectionStyle.Render(fmt.Sprintf("%s %s (%d)", cat.Icon, cat.Name, len(enabled)))) - content.WriteString("\n") - for _, desc := range enabled { - content.WriteString(listStyle.Render(" • " + desc)) - content.WriteString("\n") - } - content.WriteString("\n") - } - - content.WriteString(instructionStyle.Render("[Enter] Apply Preferences")) - content.WriteString("\n") - content.WriteString(instructionStyle.Render("[Esc] Go Back")) - - return padAllLines(boxStyle.Render(content.String()), m.width) -} - -// SelectedPreferences returns the list of preferences the user enabled. -func (m MacOSSelectorModel) SelectedPreferences() []macos.Preference { - var result []macos.Preference - for _, cat := range m.categories { - for _, p := range cat.Prefs { - if m.selected[macos.PrefKey(p)] { - result = append(result, p) - } - } - } - return result -} - -func (m MacOSSelectorModel) Confirmed() bool { - return m.confirmed -} - -// RunMacOSSelector runs the interactive macOS preferences TUI and returns the -// selected preferences, whether the user confirmed, and any error. -func RunMacOSSelector() ([]macos.Preference, bool, error) { - model := NewMacOSSelector() - p := tea.NewProgram(model, tea.WithAltScreen()) - - finalModel, err := p.Run() - if err != nil { - return nil, false, err - } - - m, ok := finalModel.(MacOSSelectorModel) - if !ok { - return nil, false, fmt.Errorf("unexpected model type returned from macOS selector") - } - return m.SelectedPreferences(), m.Confirmed(), nil -} diff --git a/internal/ui/tui/macos_selector_test.go b/internal/ui/tui/macos_selector_test.go deleted file mode 100644 index 827a8ee..0000000 --- a/internal/ui/tui/macos_selector_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openbootdotdev/openboot/internal/macos" -) - -func TestNewMacOSSelector_InitialState(t *testing.T) { - m := NewMacOSSelector() - - assert.Equal(t, macos.DefaultCategories, m.categories) - assert.Equal(t, 0, m.activeTab) - assert.Equal(t, 0, m.cursor) - assert.False(t, m.confirmed) - assert.False(t, m.showConfirmation) - assert.NotNil(t, m.selected) - assert.NotNil(t, m.cursorPositions) -} - -func TestNewMacOSSelector_AllPrefsSelectedByDefault(t *testing.T) { - m := NewMacOSSelector() - - for _, cat := range macos.DefaultCategories { - for _, p := range cat.Prefs { - k := macos.PrefKey(p) - assert.True(t, m.selected[k], "expected pref %q to be selected by default", k) - } - } -} - -func TestMacOSSelectorModel_VisibleItems(t *testing.T) { - tests := []struct { - name string - height int - expected int - }{ - {"no height defaults to 15", 0, 15}, - {"small terminal clamps to 5", 10, 5}, - {"normal terminal", 23, 15}, - {"large terminal clamps to 20", 80, 20}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewMacOSSelector() - m.height = tt.height - assert.Equal(t, tt.expected, m.macosVisibleItems()) - }) - } -} - -func TestMacOSSelectorModel_NavigateDown(t *testing.T) { - m := NewMacOSSelector() - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 1, updated.cursor) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - updated = result.(MacOSSelectorModel) - assert.Equal(t, 2, updated.cursor) -} - -func TestMacOSSelectorModel_NavigateUp(t *testing.T) { - m := NewMacOSSelector() - m.cursor = 2 - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 1, updated.cursor) -} - -func TestMacOSSelectorModel_NavigateUpAtTop(t *testing.T) { - m := NewMacOSSelector() - m.cursor = 0 - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 0, updated.cursor, "cursor should not go below 0") -} - -func TestMacOSSelectorModel_NavigateDownAtBottom(t *testing.T) { - m := NewMacOSSelector() - lastIdx := len(m.categories[0].Prefs) - 1 - m.cursor = lastIdx - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, lastIdx, updated.cursor, "cursor should not go past last item") -} - -func TestMacOSSelectorModel_TogglePref(t *testing.T) { - m := NewMacOSSelector() - m.cursor = 0 - pref := m.categories[0].Prefs[0] - k := macos.PrefKey(pref) - - // starts selected — toggle off - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) - updated := result.(MacOSSelectorModel) - assert.False(t, updated.selected[k]) - assert.Contains(t, updated.toastMessage, "Disabled") - - // toggle back on - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) - updated = result.(MacOSSelectorModel) - assert.True(t, updated.selected[k]) - assert.Contains(t, updated.toastMessage, "Enabled") -} - -func TestMacOSSelectorModel_SelectAllToggle(t *testing.T) { - m := NewMacOSSelector() - // all start selected; pressing 'a' should deselect all in active category - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) - updated := result.(MacOSSelectorModel) - - for _, p := range updated.categories[0].Prefs { - assert.False(t, updated.selected[macos.PrefKey(p)], "expected all prefs in category to be deselected") - } - assert.Contains(t, updated.toastMessage, "Disabled all") - - // press 'a' again to re-select all - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) - updated = result.(MacOSSelectorModel) - for _, p := range updated.categories[0].Prefs { - assert.True(t, updated.selected[macos.PrefKey(p)], "expected all prefs in category to be re-enabled") - } - assert.Contains(t, updated.toastMessage, "Enabled all") -} - -func TestMacOSSelectorModel_TabSwitching(t *testing.T) { - m := NewMacOSSelector() - assert.Equal(t, 0, m.activeTab) - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 1, updated.activeTab) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) - updated = result.(MacOSSelectorModel) - assert.Equal(t, 0, updated.activeTab) -} - -func TestMacOSSelectorModel_TabWrap(t *testing.T) { - m := NewMacOSSelector() - last := len(m.categories) - 1 - m.activeTab = last - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 0, updated.activeTab, "tab should wrap around to first") - - m.activeTab = 0 - result, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) - updated = result.(MacOSSelectorModel) - assert.Equal(t, last, updated.activeTab, "tab should wrap around to last") -} - -func TestMacOSSelectorModel_CursorMemoryOnTabSwitch(t *testing.T) { - m := NewMacOSSelector() - m.cursor = 2 - - // switch tab, cursor for tab 0 should be remembered - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 0, updated.cursor, "new tab starts at cursor 0") - assert.Equal(t, 2, updated.cursorPositions[0], "cursor position saved for previous tab") - - // switch back, cursor should be restored - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")}) - updated = result.(MacOSSelectorModel) - assert.Equal(t, 2, updated.cursor, "cursor restored when returning to previous tab") -} - -func TestMacOSSelectorModel_EnterShowsConfirmation(t *testing.T) { - m := NewMacOSSelector() - assert.False(t, m.showConfirmation) - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated := result.(MacOSSelectorModel) - assert.True(t, updated.showConfirmation) -} - -func TestMacOSSelectorModel_EscFromConfirmationGoesBack(t *testing.T) { - m := NewMacOSSelector() - m.showConfirmation = true - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated := result.(MacOSSelectorModel) - assert.False(t, updated.showConfirmation) - assert.False(t, updated.confirmed) -} - -func TestMacOSSelectorModel_EnterOnConfirmationConfirms(t *testing.T) { - m := NewMacOSSelector() - m.showConfirmation = true - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated := result.(MacOSSelectorModel) - assert.True(t, updated.confirmed) -} - -func TestMacOSSelectorModel_WindowSizeMsg(t *testing.T) { - m := NewMacOSSelector() - result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - updated := result.(MacOSSelectorModel) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 40, updated.height) -} - -func TestMacOSSelectorModel_SelectedPreferences_AllSelected(t *testing.T) { - m := NewMacOSSelector() - prefs := m.SelectedPreferences() - assert.Equal(t, len(macos.DefaultPreferences), len(prefs)) -} - -func TestMacOSSelectorModel_SelectedPreferences_NoneSelected(t *testing.T) { - m := NewMacOSSelector() - for k := range m.selected { - m.selected[k] = false - } - prefs := m.SelectedPreferences() - assert.Empty(t, prefs) -} - -func TestMacOSSelectorModel_SelectedPreferences_PartialSelection(t *testing.T) { - m := NewMacOSSelector() - // deselect everything, then select only first pref of first category - for k := range m.selected { - m.selected[k] = false - } - firstPref := m.categories[0].Prefs[0] - m.selected[macos.PrefKey(firstPref)] = true - - prefs := m.SelectedPreferences() - require.Len(t, prefs, 1) - assert.Equal(t, firstPref.Domain, prefs[0].Domain) - assert.Equal(t, firstPref.Key, prefs[0].Key) -} - -func TestMacOSSelectorModel_SelectedPreferences_OrderMatchesCategories(t *testing.T) { - m := NewMacOSSelector() - prefs := m.SelectedPreferences() - - // build expected order from categories - var expected []macos.Preference - for _, cat := range m.categories { - for _, p := range cat.Prefs { - if m.selected[macos.PrefKey(p)] { - expected = append(expected, p) - } - } - } - assert.Equal(t, expected, prefs) -} - -func TestMacOSSelectorModel_ToastClearMsg(t *testing.T) { - m := NewMacOSSelector() - m.toastMessage = "some toast" - - result, _ := m.Update(toastClearMsg{}) - updated := result.(MacOSSelectorModel) - assert.Empty(t, updated.toastMessage) -} - -func TestMacOSSelectorModel_Confirmed(t *testing.T) { - m := NewMacOSSelector() - assert.False(t, m.Confirmed()) - m.confirmed = true - assert.True(t, m.Confirmed()) -} - -func TestMacOSSelectorModel_ViewRendersTabBar(t *testing.T) { - m := NewMacOSSelector() - m.width = 80 - m.height = 30 - - view := m.View() - assert.NotEmpty(t, view) - // active tab name should appear - assert.Contains(t, view, m.categories[0].Name) -} - -func TestMacOSSelectorModel_ViewRendersCheckboxes(t *testing.T) { - m := NewMacOSSelector() - m.width = 100 - m.height = 30 - - view := m.View() - assert.Contains(t, view, "[✓]", "selected prefs should show checked checkbox") -} - -func TestMacOSSelectorModel_ViewRendersHelpText(t *testing.T) { - m := NewMacOSSelector() - m.width = 100 - m.height = 30 - - view := m.View() - assert.Contains(t, view, "Tab") - assert.Contains(t, view, "Space") - assert.Contains(t, view, "Enter") -} - -func TestMacOSSelectorModel_ViewRendersUncheckedWhenDeselected(t *testing.T) { - m := NewMacOSSelector() - // deselect all prefs in first category - for _, p := range m.categories[0].Prefs { - m.selected[macos.PrefKey(p)] = false - } - m.width = 100 - m.height = 30 - - view := m.View() - assert.Contains(t, view, "[ ]", "deselected prefs should show unchecked checkbox") -} - -func TestMacOSSelectorModel_ViewShowsConfirmation(t *testing.T) { - m := NewMacOSSelector() - m.showConfirmation = true - m.width = 100 - m.height = 40 - - view := m.View() - assert.Contains(t, view, "macOS Preferences Summary") - assert.Contains(t, view, "[Enter] Apply Preferences") - assert.Contains(t, view, "[Esc] Go Back") -} - -func TestMacOSSelectorModel_ConfirmationView_ListsSelectedByCategory(t *testing.T) { - m := NewMacOSSelector() - // deselect everything except first pref of Finder category - for k := range m.selected { - m.selected[k] = false - } - var finderCat *macos.PrefCategory - for i := range m.categories { - if m.categories[i].Name == "Finder" { - finderCat = &m.categories[i] - break - } - } - require.NotNil(t, finderCat) - selectedPref := finderCat.Prefs[0] - m.selected[macos.PrefKey(selectedPref)] = true - m.showConfirmation = true - m.width = 100 - m.height = 40 - - view := m.View() - assert.Contains(t, view, "Finder") - assert.Contains(t, view, selectedPref.Desc) -} - -func TestMacOSSelectorModel_ConfirmationView_HidesEmptyCategories(t *testing.T) { - m := NewMacOSSelector() - // deselect all - for k := range m.selected { - m.selected[k] = false - } - m.showConfirmation = true - m.width = 100 - m.height = 40 - - view := m.View() - // no category names should appear in body since all are deselected - assert.NotContains(t, view, "Finder (") - assert.NotContains(t, view, "Dock (") -} - -func TestMacOSSelectorModel_RenderTabBar_NoWidth(t *testing.T) { - m := NewMacOSSelector() - // zero width should not panic - assert.NotPanics(t, func() { - result := m.macosRenderTabBar() - assert.NotEmpty(t, result) - }) -} - -func TestMacOSSelectorModel_RenderTabBar_ShowsCount(t *testing.T) { - m := NewMacOSSelector() - m.width = 100 - // deselect all in first category so count shows 0 - for _, p := range m.categories[0].Prefs { - m.selected[macos.PrefKey(p)] = false - } - bar := m.macosRenderTabBar() - // strip ANSI and check for (0) - stripped := stripANSI(bar) - assert.Contains(t, stripped, "(0)") -} - -func TestMacOSSelectorModel_ViewPadsToWidth(t *testing.T) { - m := NewMacOSSelector() - m.width = 100 - m.height = 30 - - view := m.View() - for _, line := range strings.Split(view, "\n") { - w := lipgloss.Width(line) - assert.LessOrEqual(t, w, 100, "no line should exceed terminal width") - } -} - -// stripANSI removes ANSI escape sequences for plain-text assertions. -func stripANSI(s string) string { - var result strings.Builder - inEsc := false - for _, r := range s { - if r == '\x1b' { - inEsc = true - continue - } - if inEsc { - if r == 'm' { - inEsc = false - } - continue - } - result.WriteRune(r) - } - return result.String() -} diff --git a/internal/ui/tui/selector.go b/internal/ui/tui/selector.go deleted file mode 100644 index 66f4aa9..0000000 --- a/internal/ui/tui/selector.go +++ /dev/null @@ -1,418 +0,0 @@ -package tui - -import ( - "fmt" - "time" - - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/sahilm/fuzzy" - - "github.com/openbootdotdev/openboot/internal/config" - "github.com/openbootdotdev/openboot/internal/search" -) - -var searchSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} - -type onlineSearchResultMsg struct { - Results []config.Package - Query string - Err error -} - -type onlineSearchTickMsg struct{} - -type searchSpinnerTickMsg struct{} - -func searchSpinnerTickCmd() tea.Cmd { - return tea.Tick(80*time.Millisecond, func(time.Time) tea.Msg { - return searchSpinnerTickMsg{} - }) -} - -type toastClearMsg struct{} - -const onlineSearchDebounce = 500 * time.Millisecond -const toastDuration = 1500 * time.Millisecond - -func toastClearCmd() tea.Cmd { - return tea.Tick(toastDuration, func(time.Time) tea.Msg { - return toastClearMsg{} - }) -} - -func searchOnlineCmd(query string) tea.Cmd { - return func() tea.Msg { - results, err := search.SearchOnline(query) - return onlineSearchResultMsg{Results: results, Query: query, Err: err} - } -} - -func onlineSearchDebounceCmd() tea.Cmd { - return tea.Tick(onlineSearchDebounce, func(time.Time) tea.Msg { - return onlineSearchTickMsg{} - }) -} - -type SelectorModel struct { - categories []config.Category - selected map[string]bool - selectedOnline map[string]config.Package - activeTab int - cursor int - confirmed bool - width int - height int - scrollOffset int - searchMode bool - searchQuery string - filteredPkgs []config.Package - fuzzyMatches []fuzzy.Match - cursorPositions map[int]int - onlineResults []config.Package - onlineSearching bool - onlineSearchQuery string - onlineDebouncePending bool - showConfirmation bool - toastMessage string - toastTime time.Time - toastIsAdd bool - searchSpinnerIdx int -} - -func NewSelector(presetName string) SelectorModel { - return SelectorModel{ - categories: config.GetCategories(), - selected: config.GetPackagesForPreset(presetName), - selectedOnline: make(map[string]config.Package), - activeTab: 0, - cursor: 0, - cursorPositions: make(map[int]int), - } -} - -func (m SelectorModel) Init() tea.Cmd { - return nil -} - -func (m SelectorModel) totalSearchItems() int { - return len(m.filteredPkgs) + len(m.onlineResults) -} - -func (m SelectorModel) searchItemAt(index int) (config.Package, bool) { - if index < len(m.filteredPkgs) { - return m.filteredPkgs[index], false - } - onlineIdx := index - len(m.filteredPkgs) - if onlineIdx < len(m.onlineResults) { - return m.onlineResults[onlineIdx], true - } - return config.Package{}, false -} - -func (m SelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo // bubbletea Update dispatches on all message types; splitting breaks the tea.Model contract - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - - case onlineSearchResultMsg: - if msg.Query == m.searchQuery { - m.onlineSearching = false - m.onlineResults = msg.Results - if total := m.totalSearchItems(); total > 0 && m.cursor >= total { - m.cursor = total - 1 - } - } - return m, nil - - case toastClearMsg: - m.toastMessage = "" - return m, nil - - case searchSpinnerTickMsg: - if m.searchMode && m.onlineSearching { - m.searchSpinnerIdx = (m.searchSpinnerIdx + 1) % len(searchSpinnerFrames) - return m, searchSpinnerTickCmd() - } - return m, nil - - case onlineSearchTickMsg: - if m.onlineDebouncePending && m.searchQuery != "" && m.searchQuery == m.onlineSearchQuery { - m.onlineDebouncePending = false - m.onlineSearching = true - m.searchSpinnerIdx = 0 - return m, tea.Batch(searchOnlineCmd(m.searchQuery), searchSpinnerTickCmd()) - } - m.onlineDebouncePending = false - return m, nil - - case tea.KeyMsg: - if m.showConfirmation { - switch msg.String() { - case "enter": - m.confirmed = true - return m, tea.Quit - case "esc": - m.showConfirmation = false - return m, nil - } - return m, nil - } - - if m.searchMode { - switch msg.String() { - case "esc": - m.searchMode = false - m.searchQuery = "" - m.filteredPkgs = nil - m.onlineResults = nil - m.onlineSearching = false - m.onlineDebouncePending = false - m.cursor = 0 - m.scrollOffset = 0 - return m, nil - case " ": - total := m.totalSearchItems() - if total > 0 && m.cursor < total { - pkg, isOnline := m.searchItemAt(m.cursor) - m.selected[pkg.Name] = !m.selected[pkg.Name] - if isOnline { - if m.selected[pkg.Name] { - m.selectedOnline[pkg.Name] = pkg - } else { - delete(m.selectedOnline, pkg.Name) - } - } - if m.selected[pkg.Name] { - m.toastMessage = "+ Added " + pkg.Name - m.toastIsAdd = true - } else { - m.toastMessage = "- Removed " + pkg.Name - m.toastIsAdd = false - } - m.toastTime = time.Now() - return m, toastClearCmd() - } - return m, nil - case "enter": - m.showConfirmation = true - return m, nil - case "backspace": - if len(m.searchQuery) > 0 { - m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] - m.updateFilteredPackages() - m.cursor = 0 - m.scrollOffset = 0 - m.onlineSearchQuery = m.searchQuery - m.onlineDebouncePending = true - m.onlineResults = nil - if m.searchQuery == "" { - m.onlineDebouncePending = false - m.onlineSearching = false - } - return m, onlineSearchDebounceCmd() - } - return m, nil - case "up": - if m.cursor > 0 { - m.cursor-- - } - return m, nil - case "down": - if m.cursor < m.totalSearchItems()-1 { - m.cursor++ - } - return m, nil - default: - if len(msg.String()) == 1 && msg.String() >= " " { - m.searchQuery += msg.String() - m.updateFilteredPackages() - m.cursor = 0 - m.scrollOffset = 0 - m.onlineSearchQuery = m.searchQuery - m.onlineDebouncePending = true - m.onlineResults = nil - return m, onlineSearchDebounceCmd() - } - } - return m, nil - } - - switch { - case key.Matches(msg, keys.Quit): - return m, tea.Quit - - case msg.String() == "/": - m.searchMode = true - m.searchQuery = "" - m.cursor = 0 - m.updateFilteredPackages() - - case key.Matches(msg, keys.Tab), key.Matches(msg, keys.Right): - m.cursorPositions[m.activeTab] = m.cursor - m.activeTab = (m.activeTab + 1) % len(m.categories) - m.cursor = m.cursorPositions[m.activeTab] - if m.cursor >= len(m.categories[m.activeTab].Packages) { - m.cursor = 0 - } - m.scrollOffset = 0 - - case key.Matches(msg, keys.ShiftTab), key.Matches(msg, keys.Left): - m.cursorPositions[m.activeTab] = m.cursor - m.activeTab = (m.activeTab - 1 + len(m.categories)) % len(m.categories) - m.cursor = m.cursorPositions[m.activeTab] - if m.cursor >= len(m.categories[m.activeTab].Packages) { - m.cursor = 0 - } - m.scrollOffset = 0 - - case key.Matches(msg, keys.Up): - if m.cursor > 0 { - m.cursor-- - if m.cursor < m.scrollOffset { - m.scrollOffset = m.cursor - } - } - - case key.Matches(msg, keys.Down): - cat := m.categories[m.activeTab] - if m.cursor < len(cat.Packages)-1 { - m.cursor++ - visibleItems := m.getVisibleItems() - if m.cursor >= m.scrollOffset+visibleItems { - m.scrollOffset = m.cursor - visibleItems + 1 - } - } - - case key.Matches(msg, keys.Space): - cat := m.categories[m.activeTab] - if m.cursor < len(cat.Packages) { - pkg := cat.Packages[m.cursor] - m.selected[pkg.Name] = !m.selected[pkg.Name] - if m.selected[pkg.Name] { - m.toastMessage = "+ Added " + pkg.Name - m.toastIsAdd = true - } else { - m.toastMessage = "- Removed " + pkg.Name - m.toastIsAdd = false - } - m.toastTime = time.Now() - return m, toastClearCmd() - } - - case key.Matches(msg, keys.Enter): - m.showConfirmation = true - return m, nil - - case key.Matches(msg, keys.SelectAll): - cat := m.categories[m.activeTab] - allSelected := true - for _, pkg := range cat.Packages { - if !m.selected[pkg.Name] { - allSelected = false - break - } - } - for _, pkg := range cat.Packages { - m.selected[pkg.Name] = !allSelected - } - if !allSelected { - m.toastMessage = fmt.Sprintf("✔ Selected all %d %s", len(cat.Packages), cat.Name) - m.toastIsAdd = true - } else { - m.toastMessage = fmt.Sprintf("○ Deselected all %s", cat.Name) - m.toastIsAdd = false - } - m.toastTime = time.Now() - return m, toastClearCmd() - } - } - - return m, nil -} - -func (m *SelectorModel) updateFilteredPackages() { - if m.searchQuery == "" { - m.filteredPkgs = nil - m.fuzzyMatches = nil - return - } - - var allPackages []config.Package - var packageNames []string - - for _, cat := range m.categories { - for _, pkg := range cat.Packages { - allPackages = append(allPackages, pkg) - packageNames = append(packageNames, pkg.Name) - } - } - - matches := fuzzy.Find(m.searchQuery, packageNames) - - m.filteredPkgs = nil - m.fuzzyMatches = nil - - for _, match := range matches { - m.filteredPkgs = append(m.filteredPkgs, allPackages[match.Index]) - m.fuzzyMatches = append(m.fuzzyMatches, match) - } -} - -func (m SelectorModel) Selected() map[string]bool { - return m.selected -} - -func (m SelectorModel) OnlineSelected() []config.Package { - var result []config.Package - for _, pkg := range m.selectedOnline { - if m.selected[pkg.Name] { - result = append(result, pkg) - } - } - return result -} - -func (m SelectorModel) Confirmed() bool { - return m.confirmed -} - -type keyMap struct { - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Tab key.Binding - ShiftTab key.Binding - Space key.Binding - Enter key.Binding - SelectAll key.Binding - Quit key.Binding -} - -var keys = keyMap{ - Up: key.NewBinding(key.WithKeys("up", "k")), - Down: key.NewBinding(key.WithKeys("down", "j")), - Left: key.NewBinding(key.WithKeys("left", "h")), - Right: key.NewBinding(key.WithKeys("right", "l")), - Tab: key.NewBinding(key.WithKeys("tab")), - ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), - Space: key.NewBinding(key.WithKeys(" ")), - Enter: key.NewBinding(key.WithKeys("enter")), - SelectAll: key.NewBinding(key.WithKeys("a")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c")), -} - -func RunSelector(presetName string) (map[string]bool, []config.Package, bool, error) { - model := NewSelector(presetName) - p := tea.NewProgram(model, tea.WithAltScreen()) - - finalModel, err := p.Run() - if err != nil { - return nil, nil, false, err - } - - m := finalModel.(SelectorModel) - return m.Selected(), m.OnlineSelected(), m.Confirmed(), nil -} diff --git a/internal/ui/tui/selector_test.go b/internal/ui/tui/selector_test.go deleted file mode 100644 index b72a602..0000000 --- a/internal/ui/tui/selector_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/openbootdotdev/openboot/internal/config" -) - -func TestTruncateLine(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - wantLen int // visual width of result - wantSuffix string - wantPassthru bool - }{ - {"within limit", "hello", 20, 5, "", true}, - {"exact limit", "hello", 5, 5, "", true}, - {"zero width passthrough", "hello", 0, 5, "", true}, - {"negative width passthrough", "hello", -1, 5, "", true}, - {"maxWidth < 10 truncates without ellipsis", "hello world", 7, 7, "", false}, - {"maxWidth >= 10 truncates with ellipsis", "hello world foo", 12, 12, "...", false}, - {"maxWidth >= 10 exact boundary", "hello world!", 15, 12, "", true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := truncateLine(tt.input, tt.maxWidth) - if tt.wantPassthru { - assert.Equal(t, tt.input, result) - } else { - assert.Equal(t, tt.wantLen, lipgloss.Width(result)) - if tt.wantSuffix != "" { - assert.True(t, strings.HasSuffix(result, tt.wantSuffix)) - } - } - }) - } -} - -func TestPadLine(t *testing.T) { - tests := []struct { - name string - input string - width int - wantLen int - }{ - {"pads short line", "hi", 10, 10}, - {"no padding needed", "hello world", 5, 11}, // already wider, returned as-is - {"zero width passthrough", "hi", 0, 2}, - {"exact width no-op", "hello", 5, 5}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := padLine(tt.input, tt.width) - assert.Equal(t, tt.wantLen, lipgloss.Width(result)) - }) - } -} - -func TestHighlightMatchesNoMatchIndexes(t *testing.T) { - result := highlightMatches("hello", []int{}) - assert.Equal(t, "hello", result) -} - -func TestHighlightMatchesNilIndexes(t *testing.T) { - result := highlightMatches("hello", nil) - assert.Equal(t, "hello", result) -} - -func TestHighlightMatchesWithIndexesDoesNotPanic(t *testing.T) { - assert.NotPanics(t, func() { - result := highlightMatches("hello", []int{0, 2, 4}) - assert.NotEmpty(t, result) - }) -} - -func TestGetTypeBadge(t *testing.T) { - tests := []struct { - name string - pkg config.Package - contains string - }{ - {"npm package", config.Package{IsNpm: true}, "📦"}, - {"cask package", config.Package{IsCask: true}, "🖥"}, - {"formula", config.Package{}, "⚙"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getTypeBadge(tt.pkg) - assert.Contains(t, result, tt.contains) - }) - } -} - -func TestSelectorModelGetVisibleItems(t *testing.T) { - tests := []struct { - name string - height int - expected int - }{ - {"no height defaults to 15", 0, 15}, - {"small terminal clamps to 5", 10, 5}, - {"normal terminal", 23, 15}, - {"large terminal clamps to 20", 80, 20}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - m := NewSelector("scratch") - m.height = tt.height - assert.Equal(t, tt.expected, m.getVisibleItems()) - }) - } -} - -func TestSelectorModelTotalSearchItems(t *testing.T) { - m := NewSelector("scratch") - - assert.Equal(t, 0, m.totalSearchItems()) - - m.filteredPkgs = []config.Package{{Name: "git"}, {Name: "curl"}} - assert.Equal(t, 2, m.totalSearchItems()) - - m.onlineResults = []config.Package{{Name: "fzf"}} - assert.Equal(t, 3, m.totalSearchItems()) -} - -func TestSelectorModelSearchItemAt(t *testing.T) { - m := NewSelector("scratch") - m.filteredPkgs = []config.Package{{Name: "git"}, {Name: "curl"}} - m.onlineResults = []config.Package{{Name: "fzf"}} - - pkg, isOnline := m.searchItemAt(0) - assert.Equal(t, "git", pkg.Name) - assert.False(t, isOnline) - - pkg, isOnline = m.searchItemAt(1) - assert.Equal(t, "curl", pkg.Name) - assert.False(t, isOnline) - - pkg, isOnline = m.searchItemAt(2) - assert.Equal(t, "fzf", pkg.Name) - assert.True(t, isOnline) - - pkg, isOnline = m.searchItemAt(99) - assert.Equal(t, "", pkg.Name) - assert.False(t, isOnline) -} - -func TestSelectorModelUpdateFilteredPackagesEmptyQuery(t *testing.T) { - m := NewSelector("scratch") - m.filteredPkgs = []config.Package{{Name: "git"}} - m.searchQuery = "" - m.updateFilteredPackages() - - assert.Nil(t, m.filteredPkgs) - assert.Nil(t, m.fuzzyMatches) -} - -func TestSelectorModelUpdateFilteredPackagesWithQuery(t *testing.T) { - m := NewSelector("scratch") - m.searchQuery = "git" - m.updateFilteredPackages() - - require.NotEmpty(t, m.filteredPkgs, "expected at least one package matching 'git'") - for _, pkg := range m.filteredPkgs { - assert.NotEmpty(t, pkg.Name) - } -} - -func TestSelectorModelNavigateDown(t *testing.T) { - m := NewSelector("scratch") - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - updated := result.(SelectorModel) - assert.Equal(t, 1, updated.cursor) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) - updated = result.(SelectorModel) - assert.Equal(t, 2, updated.cursor) -} - -func TestSelectorModelNavigateUp(t *testing.T) { - m := NewSelector("scratch") - m.cursor = 2 - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - updated := result.(SelectorModel) - assert.Equal(t, 1, updated.cursor) -} - -func TestSelectorModelCursorDoesNotGoNegative(t *testing.T) { - m := NewSelector("scratch") - assert.Equal(t, 0, m.cursor) - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) - updated := result.(SelectorModel) - assert.Equal(t, 0, updated.cursor) -} - -func TestSelectorModelTabSwitching(t *testing.T) { - m := NewSelector("scratch") - require.NotEmpty(t, m.categories) - initialTab := m.activeTab - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) - updated := result.(SelectorModel) - assert.Equal(t, (initialTab+1)%len(m.categories), updated.activeTab) -} - -func TestSelectorModelEnterSearchMode(t *testing.T) { - m := NewSelector("scratch") - assert.False(t, m.searchMode) - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) - updated := result.(SelectorModel) - assert.True(t, updated.searchMode) - assert.Equal(t, "", updated.searchQuery) -} - -func TestSelectorModelSearchTypeAndBackspace(t *testing.T) { - m := NewSelector("scratch") - m.searchMode = true - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) - updated := result.(SelectorModel) - assert.Equal(t, "g", updated.searchQuery) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) - updated = result.(SelectorModel) - assert.Equal(t, "gi", updated.searchQuery) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyBackspace}) - updated = result.(SelectorModel) - assert.Equal(t, "g", updated.searchQuery) -} - -func TestSelectorModelExitSearchMode(t *testing.T) { - m := NewSelector("scratch") - m.searchMode = true - m.searchQuery = "git" - m.filteredPkgs = []config.Package{{Name: "git"}} - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated := result.(SelectorModel) - assert.False(t, updated.searchMode) - assert.Equal(t, "", updated.searchQuery) - assert.Nil(t, updated.filteredPkgs) -} - -func TestSelectorModelSpaceTogglesPackage(t *testing.T) { - m := NewSelector("scratch") - cat := m.categories[m.activeTab] - require.NotEmpty(t, cat.Packages, "active tab must have packages") - - firstPkg := cat.Packages[0] - initiallySelected := m.selected[firstPkg.Name] - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) - updated := result.(SelectorModel) - assert.Equal(t, !initiallySelected, updated.selected[firstPkg.Name]) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) - updated = result.(SelectorModel) - assert.Equal(t, initiallySelected, updated.selected[firstPkg.Name]) -} - -func TestSelectorModelEnterShowsConfirmation(t *testing.T) { - m := NewSelector("scratch") - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated := result.(SelectorModel) - assert.True(t, updated.showConfirmation) -} - -func TestSelectorModelConfirmationEnterConfirms(t *testing.T) { - m := NewSelector("scratch") - m.showConfirmation = true - - result, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) - updated := result.(SelectorModel) - assert.True(t, updated.confirmed) - assert.NotNil(t, cmd) -} - -func TestSelectorModelConfirmationEscGoesBack(t *testing.T) { - m := NewSelector("scratch") - m.showConfirmation = true - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) - updated := result.(SelectorModel) - assert.False(t, updated.showConfirmation) - assert.False(t, updated.confirmed) -} - -func TestSelectorModelSelectAll(t *testing.T) { - m := NewSelector("scratch") - cat := m.categories[m.activeTab] - require.NotEmpty(t, cat.Packages) - - for _, pkg := range cat.Packages { - m.selected[pkg.Name] = false - } - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) - updated := result.(SelectorModel) - for _, pkg := range cat.Packages { - assert.True(t, updated.selected[pkg.Name]) - } - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("a")}) - updated = result.(SelectorModel) - for _, pkg := range cat.Packages { - assert.False(t, updated.selected[pkg.Name]) - } -} - -func TestSelectorModelWindowResize(t *testing.T) { - m := NewSelector("scratch") - - result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - updated := result.(SelectorModel) - assert.Equal(t, 120, updated.width) - assert.Equal(t, 40, updated.height) -} - -func TestSelectorModelSelected(t *testing.T) { - m := NewSelector("scratch") - m.selected["git"] = true - m.selected["curl"] = false - - selected := m.Selected() - assert.True(t, selected["git"]) - assert.False(t, selected["curl"]) -} - -func TestSelectorModelConfirmed(t *testing.T) { - m := NewSelector("scratch") - assert.False(t, m.Confirmed()) - - m.confirmed = true - assert.True(t, m.Confirmed()) -} - -func TestSelectorModelToastOnToggle(t *testing.T) { - m := NewSelector("scratch") - cat := m.categories[m.activeTab] - require.NotEmpty(t, cat.Packages) - - m.selected[cat.Packages[0].Name] = false - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) - updated := result.(SelectorModel) - assert.Contains(t, updated.toastMessage, cat.Packages[0].Name) -} - -func TestSelectorModelSearchNavigateDownUp(t *testing.T) { - m := NewSelector("scratch") - m.searchMode = true - m.filteredPkgs = []config.Package{ - {Name: "git"}, - {Name: "curl"}, - {Name: "wget"}, - } - - result, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) - updated := result.(SelectorModel) - assert.Equal(t, 1, updated.cursor) - - result, _ = updated.Update(tea.KeyMsg{Type: tea.KeyUp}) - updated = result.(SelectorModel) - assert.Equal(t, 0, updated.cursor) -} - -func TestSelectorModelOnlineSelectedEmpty(t *testing.T) { - m := NewSelector("scratch") - result := m.OnlineSelected() - assert.Empty(t, result) -} diff --git a/internal/ui/tui/selector_view.go b/internal/ui/tui/selector_view.go deleted file mode 100644 index 80c5afc..0000000 --- a/internal/ui/tui/selector_view.go +++ /dev/null @@ -1,614 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - - "github.com/charmbracelet/lipgloss" - - "github.com/openbootdotdev/openboot/internal/config" -) - -var ( - tabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(lipgloss.Color("#666")) - - activeTabStyle = lipgloss.NewStyle(). - Padding(0, 2). - Foreground(lipgloss.Color("#22c55e")). - Bold(true). - Underline(true) - - itemStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#fff")) - - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")) - - descStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666")) - - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")). - MarginTop(1) - - countStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888")) - - badgeStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666")) - - boldStyle = lipgloss.NewStyle(). - Bold(true) - - onlineHeaderStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f59e0b")). - Bold(true) - - onlineSearchingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888")). - Italic(true) - - searchBarQueryStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#fff")). - Bold(true) - - searchBarSepStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#444")) - - searchBarStatsStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888")) - - searchBarIconStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#f59e0b")) - - searchBarHintStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#555")). - Italic(true) - - // scanActiveStyle is duplicated from the parent ui package (where it - // lives in scanprogress.go) to avoid a circular import. The canonical - // colour definition is lipgloss.Color("#06b6d4"). - scanActiveStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#06b6d4")) -) - -func (m SelectorModel) getVisibleItems() int { - if m.height == 0 { - return 15 - } - available := m.height - 8 - if available < 5 { - available = 5 - } - if available > 20 { - available = 20 - } - return available -} - -func (m SelectorModel) renderTabBar() string { - totalTabs := len(m.categories) - - arrowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) - sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#444")) - posStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555")) - - cat := m.categories[m.activeTab] - count := 0 - for _, pkg := range cat.Packages { - if m.selected[pkg.Name] { - count++ - } - } - activeRendered := activeTabStyle.Render(fmt.Sprintf("%s %s (%d)", cat.Icon, cat.Name, count)) - - posRendered := posStyle.Render(fmt.Sprintf(" %d/%d", m.activeTab+1, totalTabs)) - - hasLeft := m.activeTab > 0 - hasRight := m.activeTab < totalTabs-1 - leftArrow := " " - if hasLeft { - leftArrow = arrowStyle.Render("‹ ") - } - rightArrow := " " - if hasRight { - rightArrow = arrowStyle.Render(" ›") - } - - termWidth := m.width - if termWidth == 0 { - termWidth = 80 - } - - baseWidth := lipgloss.Width(leftArrow) + lipgloss.Width(activeRendered) + lipgloss.Width(rightArrow) + lipgloss.Width(posRendered) - remaining := termWidth - baseWidth - - sep := sepStyle.Render(" │ ") - sepW := lipgloss.Width(sep) - - var leftNeighbors []string - var rightNeighbors []string - li := m.activeTab - 1 - ri := m.activeTab + 1 - - for remaining > 0 && (li >= 0 || ri < totalTabs) { - added := false - if li >= 0 { - rendered := tabStyle.Render(m.categories[li].Name) - w := lipgloss.Width(rendered) + sepW - if w <= remaining { - leftNeighbors = append([]string{rendered}, leftNeighbors...) - remaining -= w - li-- - added = true - } else { - li = -1 - } - } - if ri < totalTabs { - rendered := tabStyle.Render(m.categories[ri].Name) - w := lipgloss.Width(rendered) + sepW - if w <= remaining { - rightNeighbors = append(rightNeighbors, rendered) - remaining -= w - ri++ - added = true - } else { - ri = totalTabs - } - } - if !added { - break - } - } - - var result strings.Builder - result.WriteString(leftArrow) - for _, n := range leftNeighbors { - result.WriteString(n) - result.WriteString(sep) - } - result.WriteString(activeRendered) - for _, n := range rightNeighbors { - result.WriteString(sep) - result.WriteString(n) - } - result.WriteString(rightArrow) - result.WriteString(posRendered) - - return result.String() -} - -func getTypeBadge(pkg config.Package) string { - if pkg.IsNpm { - return badgeStyle.Render("📦 ") - } - if pkg.IsCask { - return badgeStyle.Render("🖥 ") - } - return badgeStyle.Render("⚙ ") -} - -func highlightMatches(text string, matchedIndexes []int) string { - if len(matchedIndexes) == 0 { - return text - } - - var result strings.Builder - matchSet := make(map[int]bool) - for _, idx := range matchedIndexes { - matchSet[idx] = true - } - - for i, char := range text { - if matchSet[i] { - result.WriteString(boldStyle.Render(string(char))) - } else { - result.WriteRune(char) - } - } - - return result.String() -} - -func truncateLine(line string, maxWidth int) string { - if maxWidth <= 0 { - return line - } - visualWidth := lipgloss.Width(line) - if visualWidth <= maxWidth { - return line - } - if maxWidth < 10 { - return lipgloss.NewStyle().MaxWidth(maxWidth).Render(line) - } - return lipgloss.NewStyle().MaxWidth(maxWidth-3).Render(line) + "..." -} - -// padLine pads a rendered line with spaces to the given width, using visual -// width so that ANSI escape codes do not affect the calculation. This clears -// any ghost text left by a previously longer line in the same terminal row. -func padLine(line string, width int) string { - if width <= 0 { - return line - } - visualWidth := lipgloss.Width(line) - if visualWidth >= width { - return line - } - return line + strings.Repeat(" ", width-visualWidth) -} - -// padAllLines pads every line in a rendered view to the given terminal width. -// This is the root-cause fix for ghost text: instead of padding individual -// lines (easy to miss), call this once on the final View() output. -func padAllLines(s string, width int) string { - if width <= 0 { - return s - } - lines := strings.Split(s, "\n") - for i, line := range lines { - lines[i] = padLine(line, width) - } - return strings.Join(lines, "\n") -} - -func (m SelectorModel) View() string { - if m.showConfirmation { - return m.confirmationView() - } - - var lines []string - - if m.searchMode { - return m.viewSearch() - } - - lines = append(lines, m.renderTabBar()) - lines = append(lines, "") - - cat := m.categories[m.activeTab] - visibleItems := m.getVisibleItems() - - if m.scrollOffset > len(cat.Packages)-visibleItems { - m.scrollOffset = len(cat.Packages) - visibleItems - } - if m.scrollOffset < 0 { - m.scrollOffset = 0 - } - - endIdx := m.scrollOffset + visibleItems - if endIdx > len(cat.Packages) { - endIdx = len(cat.Packages) - } - - for i := m.scrollOffset; i < endIdx; i++ { - pkg := cat.Packages[i] - cursor := " " - if i == m.cursor { - cursor = "> " - } - - checkbox := "[ ]" - style := itemStyle - if m.selected[pkg.Name] { - checkbox = "[✓]" - style = selectedStyle - } - - line := fmt.Sprintf("%s%s %s %s", cursor, checkbox, style.Render(pkg.Name), descStyle.Render(pkg.Description)) - if m.width > 0 { - line = padLine(truncateLine(line, m.width-2), m.width) - } - lines = append(lines, line) - } - - clearWidth := m.width - if clearWidth <= 0 { - clearWidth = 80 - } - clearLine := strings.Repeat(" ", clearWidth) - for len(lines) < visibleItems+2 { - lines = append(lines, clearLine) - } - - totalSelected := 0 - for _, v := range m.selected { - if v { - totalSelected++ - } - } - - lines = append(lines, "") - if m.toastMessage != "" { - toastStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Italic(true) - if !m.toastIsAdd { - toastStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888")).Italic(true) - } - lines = append(lines, toastStyle.Render(m.toastMessage)) - } else { - lines = append(lines, countStyle.Render(fmt.Sprintf("Selected: %d packages", totalSelected))) - } - lines = append(lines, "") - lines = append(lines, helpStyle.Render("Tab/←→: switch • ↑↓: navigate • Space: toggle • /: search • a: all • Enter: confirm • q: quit")) - - return padAllLines(strings.Join(lines, "\n"), m.width) -} - -func (m SelectorModel) confirmationView() string { //nolint:gocyclo // renders multiple package categories with per-item formatting; inherent to confirmation UI - var formulae, casks, npm []string - for name, selected := range m.selected { - if !selected { - continue - } - - var pkg *config.Package - for _, cat := range m.categories { - for i := range cat.Packages { - if cat.Packages[i].Name == name { - pkg = &cat.Packages[i] - break - } - } - if pkg != nil { - break - } - } - - if pkg == nil { - if op, ok := m.selectedOnline[name]; ok { - pkg = &op - } - } - - if pkg != nil { - if pkg.IsNpm { - npm = append(npm, pkg.Name) - } else if pkg.IsCask { - casks = append(casks, pkg.Name) - } else { - formulae = append(formulae, pkg.Name) - } - } - } - - totalPackages := len(formulae) + len(casks) + len(npm) - - estimatedSeconds := len(formulae)*15 + len(casks)*30 + len(npm)*5 - estimatedMinutes := estimatedSeconds / 60 - - boxWidth := 60 - if m.width > 0 && m.width < 70 { - boxWidth = m.width - 10 - if boxWidth < 40 { - boxWidth = 40 - } - } - - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#22c55e")). - Padding(1, 2). - Width(boxWidth) - - headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#22c55e")). - Bold(true) - - sectionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#fff")). - Bold(true) - - listStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888")) - - instructionStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#666")). - Italic(true) - - var content strings.Builder - - content.WriteString(headerStyle.Render("Install Summary")) - content.WriteString("\n\n") - fmt.Fprintf(&content, "Total: %d packages\n\n", totalPackages) - - if len(formulae) > 0 { - content.WriteString(sectionStyle.Render(fmt.Sprintf("⚙ Formulae (%d)", len(formulae)))) - content.WriteString("\n") - if len(formulae) <= 10 { - content.WriteString(listStyle.Render(" " + strings.Join(formulae, ", "))) - } else { - content.WriteString(listStyle.Render(" " + strings.Join(formulae[:10], ", "))) - content.WriteString(listStyle.Render(fmt.Sprintf(" and %d more...", len(formulae)-10))) - } - content.WriteString("\n\n") - } - - if len(casks) > 0 { - content.WriteString(sectionStyle.Render(fmt.Sprintf("🖥 Applications (%d)", len(casks)))) - content.WriteString("\n") - if len(casks) <= 10 { - content.WriteString(listStyle.Render(" " + strings.Join(casks, ", "))) - } else { - content.WriteString(listStyle.Render(" " + strings.Join(casks[:10], ", "))) - content.WriteString(listStyle.Render(fmt.Sprintf(" and %d more...", len(casks)-10))) - } - content.WriteString("\n\n") - } - - if len(npm) > 0 { - content.WriteString(sectionStyle.Render(fmt.Sprintf("📦 NPM (%d)", len(npm)))) - content.WriteString("\n") - if len(npm) <= 10 { - content.WriteString(listStyle.Render(" " + strings.Join(npm, ", "))) - } else { - content.WriteString(listStyle.Render(" " + strings.Join(npm[:10], ", "))) - content.WriteString(listStyle.Render(fmt.Sprintf(" and %d more...", len(npm)-10))) - } - content.WriteString("\n\n") - } - - fmt.Fprintf(&content, "Estimated time: ~%d minutes\n\n", estimatedMinutes) - content.WriteString(instructionStyle.Render("[Enter] Confirm & Install")) - content.WriteString("\n") - content.WriteString(instructionStyle.Render("[Esc] Go Back")) - - return padAllLines(boxStyle.Render(content.String()), m.width) -} - -func (m SelectorModel) viewSearch() string { //nolint:gocyclo // renders search results across package categories with state-dependent formatting - var lines []string - - query := m.searchQuery + "▌" - searchBar := searchBarIconStyle.Render("🔍 ") + searchBarQueryStyle.Render(query) - - localCount := len(m.filteredPkgs) - onlineCount := len(m.onlineResults) - - var statsText string - if m.searchQuery == "" { - statsText = searchBarHintStyle.Render("Type to search all categories and online...") - } else if m.onlineSearching { - spinner := searchSpinnerFrames[m.searchSpinnerIdx] - statsText = searchBarStatsStyle.Render(fmt.Sprintf("%d local", localCount)) + - searchBarSepStyle.Render(" · ") + - scanActiveStyle.Render(spinner+" searching...") - } else if onlineCount > 0 { - statsText = searchBarStatsStyle.Render(fmt.Sprintf("%d local", localCount)) + - searchBarSepStyle.Render(" · ") + - searchBarStatsStyle.Render(fmt.Sprintf("%d online", onlineCount)) - } else if localCount > 0 { - statsText = searchBarStatsStyle.Render(fmt.Sprintf("%d found", localCount)) - } else { - statsText = searchBarStatsStyle.Render("no results") - } - - searchBar += " " + searchBarSepStyle.Render("│") + " " + statsText - - lines = append(lines, searchBar) - lines = append(lines, "") - - visibleItems := m.getVisibleItems() - itemsRendered := 0 - - if len(m.filteredPkgs) == 0 && len(m.onlineResults) == 0 && !m.onlineSearching { - if m.searchQuery == "" { - lines = append(lines, "") - lines = append(lines, descStyle.Render(" Search across all categories and discover new packages")) - } else { - lines = append(lines, descStyle.Render(" No matching packages")) - } - } else { - endIdx := visibleItems - if endIdx > len(m.filteredPkgs) { - endIdx = len(m.filteredPkgs) - } - - for i := 0; i < endIdx; i++ { - pkg := m.filteredPkgs[i] - cursor := " " - if i == m.cursor { - cursor = "> " - } - - checkbox := "[ ]" - style := itemStyle - if m.selected[pkg.Name] { - checkbox = "[✓]" - style = selectedStyle - } - - badge := getTypeBadge(pkg) - - var displayName string - if i < len(m.fuzzyMatches) { - displayName = highlightMatches(pkg.Name, m.fuzzyMatches[i].MatchedIndexes) - } else { - displayName = pkg.Name - } - - line := fmt.Sprintf("%s%s %s%s %s", cursor, checkbox, badge, style.Render(displayName), descStyle.Render(pkg.Description)) - if m.width > 0 { - line = padLine(truncateLine(line, m.width-2), m.width) - } - lines = append(lines, line) - itemsRendered++ - } - - if m.onlineSearching { - lines = append(lines, "") - lines = append(lines, descStyle.Render(" ── Loading online results ──")) - } else if len(m.onlineResults) > 0 { - lines = append(lines, "") - lines = append(lines, onlineHeaderStyle.Render("── Online Results ──")) - itemsRendered += 2 - - onlineVisibleLimit := visibleItems - itemsRendered - if onlineVisibleLimit < 1 { - onlineVisibleLimit = 1 - } - onlineEnd := onlineVisibleLimit - if onlineEnd > len(m.onlineResults) { - onlineEnd = len(m.onlineResults) - } - - offlineCount := len(m.filteredPkgs) - for i := 0; i < onlineEnd; i++ { - pkg := m.onlineResults[i] - globalIdx := offlineCount + i - cursor := " " - if globalIdx == m.cursor { - cursor = "> " - } - - checkbox := "[ ]" - style := itemStyle - if m.selected[pkg.Name] { - checkbox = "[✓]" - style = selectedStyle - } - - badge := getTypeBadge(pkg) - line := fmt.Sprintf("%s%s %s%s %s", cursor, checkbox, badge, style.Render(pkg.Name), descStyle.Render(pkg.Description)) - if m.width > 0 { - line = padLine(truncateLine(line, m.width-2), m.width) - } - lines = append(lines, line) - itemsRendered++ - } - } - } - - clearWidth := m.width - if clearWidth <= 0 { - clearWidth = 80 - } - clearLine := strings.Repeat(" ", clearWidth) - for len(lines) < visibleItems+2 { - lines = append(lines, clearLine) - } - - totalSelected := 0 - for _, v := range m.selected { - if v { - totalSelected++ - } - } - - lines = append(lines, "") - if m.toastMessage != "" { - toastStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#22c55e")).Italic(true) - if !m.toastIsAdd { - toastStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888")).Italic(true) - } - lines = append(lines, toastStyle.Render(m.toastMessage)) - } else { - lines = append(lines, countStyle.Render(fmt.Sprintf("Selected: %d packages", totalSelected))) - } - lines = append(lines, "") - lines = append(lines, helpStyle.Render("↑↓: navigate • Space: toggle • Esc: exit search • Enter: confirm")) - - return padAllLines(strings.Join(lines, "\n"), m.width) -} diff --git a/internal/ui/tui/shared.go b/internal/ui/tui/shared.go new file mode 100644 index 0000000..9919781 --- /dev/null +++ b/internal/ui/tui/shared.go @@ -0,0 +1,105 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/lipgloss" +) + +// Shared primitives for the remaining snapshot editor and remote-config +// customizer. These used to live in selector.go/selector_view.go even though +// both independent models consumed them, which made deleting the legacy +// install selector accidentally delete their UI foundation too. + +var searchSpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +type keyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Tab key.Binding + ShiftTab key.Binding + Space key.Binding + Enter key.Binding + SelectAll key.Binding + Quit key.Binding +} + +var keys = keyMap{ + Up: key.NewBinding(key.WithKeys("up", "k")), + Down: key.NewBinding(key.WithKeys("down", "j")), + Left: key.NewBinding(key.WithKeys("left", "h")), + Right: key.NewBinding(key.WithKeys("right", "l")), + Tab: key.NewBinding(key.WithKeys("tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab")), + Space: key.NewBinding(key.WithKeys(" ")), + Enter: key.NewBinding(key.WithKeys("enter")), + SelectAll: key.NewBinding(key.WithKeys("a")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c")), +} + +var ( + activeTabStyle = lipgloss.NewStyle(). + Padding(0, 2). + Foreground(lipgloss.Color("#22c55e")). + Bold(true). + Underline(true) + + itemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#fff")) + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#22c55e")) + + descStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#666")) + + helpStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#444")). + MarginTop(1) + + countStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888")) + + onlineHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#f59e0b")). + Bold(true) + + onlineSearchingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#888")). + Italic(true) +) + +func truncateLine(line string, maxWidth int) string { + if maxWidth <= 0 || lipgloss.Width(line) <= maxWidth { + return line + } + if maxWidth < 10 { + return lipgloss.NewStyle().MaxWidth(maxWidth).Render(line) + } + return lipgloss.NewStyle().MaxWidth(maxWidth-3).Render(line) + "..." +} + +func padLine(line string, width int) string { + if width <= 0 { + return line + } + visualWidth := lipgloss.Width(line) + if visualWidth >= width { + return line + } + return line + strings.Repeat(" ", width-visualWidth) +} + +func padAllLines(s string, width int) string { + if width <= 0 { + return s + } + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = padLine(line, width) + } + return strings.Join(lines, "\n") +} diff --git a/internal/ui/tui/wizard/confirm.go b/internal/ui/tui/wizard/confirm.go index c4d0d72..9a2304f 100644 --- a/internal/ui/tui/wizard/confirm.go +++ b/internal/ui/tui/wizard/confirm.go @@ -80,11 +80,19 @@ func (m Model) updateConfirm(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) confirmBody(_, _ int) string { const pad = " " + title := "Ready to install" + detail := "Everything below runs when you press ↵ — space toggles a step off." + action := "install" + if m.opts.DryRun { + title = "Dry-run preview" + detail = "No changes will be made — space toggles a step in the preview." + action = "preview" + } var b []string b = append(b, "") b = append(b, "") - b = append(b, pad+fg(cTextHi).Bold(true).Render("Ready to install")) - b = append(b, pad+fg(cDim3).Render("Everything below runs when you press ↵ — space toggles a step off.")) + b = append(b, pad+fg(cTextHi).Bold(true).Render(title)) + b = append(b, pad+fg(cDim3).Render(detail)) b = append(b, "") // Packages summary (informational, not toggleable). @@ -131,7 +139,7 @@ func (m Model) confirmBody(_, _ int) string { if len(rows) > 0 { b = append(b, "") } - b = append(b, pad+fg(cDim3).Render("↑↓ move · space toggle · ↵ install · esc back")) + b = append(b, pad+fg(cDim3).Render("↑↓ move · space toggle · ↵ "+action+" · esc back")) return strings.Join(b, "\n") } diff --git a/internal/ui/tui/wizard/status.go b/internal/ui/tui/wizard/status.go index 8a38703..dfbdd03 100644 --- a/internal/ui/tui/wizard/status.go +++ b/internal/ui/tui/wizard/status.go @@ -15,21 +15,32 @@ func (m Model) statusContent() (mode string, color lipgloss.Color, keys, right s if m.srcLabel != "" { cmdline += " " + m.srcLabel } + if m.opts.DryRun { + cmdline += " --dry-run" + } if m.probeIdx < len(m.probes) { return "BOOT", cInfo, "probing this Mac…", cmdline } return "BOOT", cInfo, "1 / 2 / 3 pick a loadout · c hand-pick from scratch · ↵ select", cmdline case scrSelect: + action := "install" + if m.opts.DryRun { + action = "preview" + } return "SELECT", cAccent, - "←→ pane · ↑↓ move · space toggle · / filter · a all · x clear · ↵ install", + "←→ pane · ↑↓ move · space toggle · / filter · a all · x clear · ↵ " + action, fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) case scrGit: return "GIT", cAccent, "↑↓/tab switch field · ↵ continue · esc back", "identity for your commits" default: // scrConfirm - return "REVIEW", cAccent, "↑↓ move · space toggle · ↵ install · esc back", + action := "install" + if m.opts.DryRun { + action = "preview" + } + return "REVIEW", cAccent, "↑↓ move · space toggle · ↵ " + action + " · esc back", fmt.Sprintf("%d pkgs · ~%d min", m.selCount(), m.estMin()) } } diff --git a/internal/ui/tui/wizard/wizard.go b/internal/ui/tui/wizard/wizard.go index 91c0940..4e9dba9 100644 --- a/internal/ui/tui/wizard/wizard.go +++ b/internal/ui/tui/wizard/wizard.go @@ -10,8 +10,9 @@ // // Preset installs (-p) enter with the loadout preselected; remote-config // installs (slug, -u, --from, alias) enter config mode via RunForConfig, with -// the config's own packages on the select screen. Non-interactive paths -// (--silent, --dry-run, --update, no TTY) never reach the wizard. +// the config's own packages on the select screen. TTY dry-runs use the same +// planner and carry DryRun into apply; --silent, --update, and no-TTY paths do +// not reach the wizard. package wizard import ( diff --git a/internal/ui/tui/wizard/wizard_test.go b/internal/ui/tui/wizard/wizard_test.go index 093bb25..a39cb13 100644 --- a/internal/ui/tui/wizard/wizard_test.go +++ b/internal/ui/tui/wizard/wizard_test.go @@ -401,6 +401,27 @@ func TestConfirmtogglesGateThePlan(t *testing.T) { assert.Empty(t, m.plan.MacOSPrefs, "prefs toggled off") } +func TestDryRunReviewMakesPreviewSemanticsExplicit(t *testing.T) { + defer stubGitConfig("Ada", "ada@ex.io")() + + m := New("1.4.0", &config.InstallOptions{Version: "1.4.0", DryRun: true}) + m = send(m, tea.WindowSizeMsg{Width: 96, Height: 30}) + m = finishProbes(m) + m = send(m, key("2")) + m.installed = map[string]bool{} + m = send(m, key("enter")) + require.Equal(t, scrConfirm, m.screen) + + view := m.View() + assert.Contains(t, view, "Dry-run preview") + assert.Contains(t, view, "No changes will be made") + assert.Contains(t, view, "↵ preview") + + m = send(m, key("enter")) + require.True(t, m.confirmed) + assert.True(t, m.plan.DryRun) +} + func TestGitScreenEscReturnsToSelect(t *testing.T) { defer stubGitConfig("", "")() m := finishProbes(sized(96, 30)) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c85c123..395d240 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" - "github.com/openbootdotdev/openboot/internal/config" "github.com/openbootdotdev/openboot/internal/system" ) @@ -176,30 +175,6 @@ func InputGitConfig() (name, email string, err error) { return } -func SelectPreset() (string, error) { - var preset string - - options := make([]huh.Option[string], 0) - for _, name := range config.GetPresetNames() { - p, _ := config.GetPreset(name) - label := fmt.Sprintf("%s - %s", name, p.Description) - options = append(options, huh.NewOption(label, name)) - } - options = append(options, huh.NewOption("scratch - Start from scratch (select individual packages)", "scratch")) - - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title("Choose your preset"). - Options(options...). - Value(&preset), - ), - ) - - err := form.Run() - return preset, err -} - func Confirm(question string, defaultVal bool) (bool, error) { result := defaultVal From 1f80360b76ade31d63db1a6ea09c6dfb95f6e07a Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Sun, 2 Aug 2026 01:33:25 +0800 Subject: [PATCH 2/2] build: tidy Go module sums --- go.sum | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.sum b/go.sum index 21ed235..69ddaae 100644 --- a/go.sum +++ b/go.sum @@ -29,8 +29,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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=