bold-brew/internal/services/input.go
Vito Castellano e234487ac9
refactor(services): restructure and simplify service layer (#46)
* refactor(services): split brew.go into focused modules

Reorganize the services package for better maintainability:
- brew.go: Interface and struct definitions
- data.go: Data loading, caching, and tap package management
- cache.go: Low-level cache I/O helpers
- packages.go: Package retrieval (GetFormulae, GetPackages)
- operations.go: Package operations (install, update, remove)
- parser.go: Brewfile parsing
- brewfile.go: Brewfile-specific app logic (extracted from app.go)

This reduces the largest file from 1124 to 322 lines and improves
separation of concerns across the codebase.

* refactor(services): introduce DataProvider pattern for data loading

Extract data loading logic into a dedicated DataProvider service that
handles all data retrieval operations (formulae, casks, analytics, cache).
BrewService now delegates to DataProvider via interface for better
testability and separation of concerns.

Also centralizes cache file name constants in dataprovider.go for
maintainability.

* refactor(services): centralize data in DataProvider and improve naming consistency

Move all data-related fields and retrieval methods from BrewService to DataProvider, making BrewService focused solely on brew operations. Rename formula fields (all, installed, remote, analytics) to be consistent with cask naming convention (allFormulae, installedFormulae, etc.). Remove redundant data.go and packages.go files.

* refactor(services): consolidate code and fix shared dependencies

- IOService now receives BrewService as parameter instead of creating new instance
- Move API URL constants from brew.go to dataprovider.go (now private)
- Move getCacheDir to cache.go where it belongs
- Remove unused GetPrefixPath from BrewService interface
- Consolidate Brewfile parsing into brewfile.go as standalone function
- Delete parser.go (logic merged into brewfile.go)

* fix(brewfile): prevent duplicate packages in list after refresh

Add duplicate detection checks in loadBrewfilePackages to ensure each package appears only once in the Brewfile package list, even when refreshing after install/remove operations.

* refactor(services): extract search methods to dedicated file

Move search(), setResults(), and forceRefreshResults() from app.go
to search.go for better code organization and separation of concerns.

* refactor(services): remove dead code and unify helpers

Remove unused methods and fields:
- GetFormulae(), IsPackageInstalled() from DataProvider
- InstallAllPackages(), RemoveAllPackages() from BrewService
- allFormulae, allCasks fields from DataProvider

Unify GetInstalledCaskNames and GetInstalledFormulaNames with
a common getInstalledNames helper to reduce code duplication.

* docs(brewfile): add package documentation with execution sequence

Document Brewfile mode functionality including parsing, tap installation,
and package loading. Add execution sequence diagram and note that methods
are only active in Brewfile mode (bbrew -f <file>).

* refactor(services): move fetchFromAPI to dataprovider

Move HTTP fetch function from cache.go to dataprovider.go where it
belongs semantically. cache.go now contains only cache I/O operations.

* refactor(dataprovider): improve method naming consistency

Rename Load* methods to Get* with forceRefresh parameter for clarity:
- LoadInstalledFormulae → GetInstalledFormulae
- LoadRemoteFormulae → GetRemoteFormulae
- LoadTapPackages → GetTapPackages (and similar)

Rename Get* methods that execute commands to Fetch* for accuracy:
- GetInstalledCaskNames → FetchInstalledCaskNames
- GetInstalledFormulaNames → FetchInstalledFormulaNames

Replace forceDownload parameter with forceRefresh throughout.

* refactor(input): rename io.go to input.go and simplify code

Rename IOService/IOAction to InputService/InputAction for semantic
correctness (io in Go refers to file I/O, not keyboard input).

Simplify handleFilterEvent with helper methods: isFilterActive(),
setFilterActive(), updateFilterUI() reducing ~30 lines of repetitive code.

Extract handleBatchPackageOperation() helper for InstallAll/RemoveAll
operations, reducing ~90 lines of duplicate code with a configurable
batchOperation struct.

* refactor(filters): replace 4 booleans with FilterType enum

Replace showOnlyInstalled, showOnlyOutdated, showOnlyLeaves, showOnlyCasks
with a single activeFilter field of type FilterType.

Add FilterNone constant for no active filter state.
Simplify handleFilterEvent to simple toggle logic.
Extract applyFilter method with clean switch statement.
Remove redundant isFilterActive and setFilterActive helpers.

* refactor(brew): merge operations.go into brew.go

Consolidate all BrewService methods into a single file.
Add section comments to organize code into logical groups:
- Core info (GetBrewVersion)
- Package operations (Update/Install/Remove)
- Tap support (InstallTap/IsTapInstalled)
- Internal helpers (executeCommand)

* fix(dataprovider): suppress gosec G107 false positive

The URL passed to http.Get comes from internal constants (Homebrew API URLs),
not from user input.
2025-12-29 14:32:25 +01:00

497 lines
17 KiB
Go

package services
import (
"bbrew/internal/models"
"bbrew/internal/ui"
"fmt"
"github.com/gdamore/tcell/v2"
)
// FilterType represents the active package filter state.
type FilterType int
const (
FilterNone FilterType = iota
FilterInstalled
FilterOutdated
FilterLeaves
FilterCasks
)
// InputAction represents a user action that can be triggered by a key event.
type InputAction struct {
Key tcell.Key
Rune rune
Name string
KeySlug string
Action func()
HideFromLegend bool // If true, this action won't appear in the legend bar
}
// InputServiceInterface defines the interface for handling user input actions.
type InputServiceInterface interface {
HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey
EnableBrewfileMode()
}
// InputService implements the InputServiceInterface and handles key events for the application.
type InputService struct {
appService *AppService
layout ui.LayoutInterface
brewService BrewServiceInterface
keyActions []*InputAction
legendEntries []struct{ KeySlug, Name string }
// Actions for each key input
ActionSearch *InputAction
ActionFilterInstalled *InputAction
ActionFilterOutdated *InputAction
ActionFilterLeaves *InputAction
ActionFilterCasks *InputAction
ActionInstall *InputAction
ActionUpdate *InputAction
ActionRemove *InputAction
ActionUpdateAll *InputAction
ActionInstallAll *InputAction
ActionRemoveAll *InputAction
ActionHelp *InputAction
ActionBack *InputAction
ActionQuit *InputAction
}
var NewInputService = func(appService *AppService, brewService BrewServiceInterface) InputServiceInterface {
s := &InputService{
appService: appService,
layout: appService.GetLayout(),
brewService: brewService,
}
// Initialize actions with key bindings and handlers
s.ActionSearch = &InputAction{
Key: tcell.KeyRune, Rune: '/', KeySlug: "/", Name: "Search",
Action: s.handleSearchFieldEvent,
}
s.ActionFilterInstalled = &InputAction{
Key: tcell.KeyRune, Rune: 'f', KeySlug: "f", Name: "Installed",
Action: s.handleFilterPackagesEvent,
}
s.ActionFilterOutdated = &InputAction{
Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Outdated",
Action: s.handleFilterOutdatedPackagesEvent, HideFromLegend: true,
}
s.ActionFilterLeaves = &InputAction{
Key: tcell.KeyRune, Rune: 'l', KeySlug: "l", Name: "Leaves",
Action: s.handleFilterLeavesEvent, HideFromLegend: true,
}
s.ActionFilterCasks = &InputAction{
Key: tcell.KeyRune, Rune: 'c', KeySlug: "c", Name: "Casks",
Action: s.handleFilterCasksEvent, HideFromLegend: true,
}
s.ActionInstall = &InputAction{
Key: tcell.KeyRune, Rune: 'i', KeySlug: "i", Name: "Install",
Action: s.handleInstallPackageEvent,
}
s.ActionUpdate = &InputAction{
Key: tcell.KeyRune, Rune: 'u', KeySlug: "u", Name: "Update",
Action: s.handleUpdatePackageEvent,
}
s.ActionRemove = &InputAction{
Key: tcell.KeyRune, Rune: 'r', KeySlug: "r", Name: "Remove",
Action: s.handleRemovePackageEvent,
}
s.ActionUpdateAll = &InputAction{
Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All",
Action: s.handleUpdateAllPackagesEvent, HideFromLegend: true,
}
s.ActionInstallAll = &InputAction{
Key: tcell.KeyCtrlA, Rune: 0, KeySlug: "ctrl+a", Name: "Install All (Brewfile)",
Action: s.handleInstallAllPackagesEvent,
}
s.ActionRemoveAll = &InputAction{
Key: tcell.KeyCtrlR, Rune: 0, KeySlug: "ctrl+r", Name: "Remove All (Brewfile)",
Action: s.handleRemoveAllPackagesEvent,
}
s.ActionHelp = &InputAction{
Key: tcell.KeyRune, Rune: '?', KeySlug: "?", Name: "Help",
Action: s.handleHelpEvent,
}
s.ActionBack = &InputAction{
Key: tcell.KeyEsc, Rune: 0, KeySlug: "esc", Name: "Back to Table",
Action: s.handleBack, HideFromLegend: true,
}
s.ActionQuit = &InputAction{
Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit",
Action: s.handleQuitEvent, HideFromLegend: true,
}
// Build keyActions slice (InstallAll/RemoveAll added dynamically in Brewfile mode)
s.keyActions = []*InputAction{
s.ActionSearch, s.ActionFilterInstalled, s.ActionFilterOutdated,
s.ActionFilterLeaves, s.ActionFilterCasks, s.ActionInstall,
s.ActionUpdate, s.ActionRemove, s.ActionUpdateAll,
s.ActionHelp, s.ActionBack, s.ActionQuit,
}
// Convert keyActions to legend entries
s.updateLegendEntries()
return s
}
// updateLegendEntries updates the legend entries based on current keyActions
func (s *InputService) updateLegendEntries() {
s.legendEntries = make([]struct{ KeySlug, Name string }, 0, len(s.keyActions))
for _, input := range s.keyActions {
if !input.HideFromLegend {
s.legendEntries = append(s.legendEntries, struct{ KeySlug, Name string }{KeySlug: input.KeySlug, Name: input.Name})
}
}
s.layout.GetLegend().SetLegend(s.legendEntries, "")
}
// EnableBrewfileMode enables Brewfile mode, adding Install All and Remove All actions to the legend
func (s *InputService) EnableBrewfileMode() {
// Add Install All and Remove All actions after Update All
newActions := []*InputAction{}
for _, action := range s.keyActions {
newActions = append(newActions, action)
if action == s.ActionUpdateAll {
newActions = append(newActions, s.ActionInstallAll, s.ActionRemoveAll)
}
}
s.keyActions = newActions
s.updateLegendEntries()
}
// HandleKeyEventInput processes key events and triggers the corresponding actions.
func (s *InputService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey {
if s.layout.GetSearch().Field().HasFocus() {
return event
}
for _, input := range s.keyActions {
if event.Modifiers() == tcell.ModNone && input.Key == event.Key() && input.Rune == event.Rune() { // Check Rune
if input.Action != nil {
input.Action()
return nil
}
} else if event.Modifiers() != tcell.ModNone && input.Key == event.Key() { // Check Key only
if input.Action != nil {
input.Action()
return nil
}
}
}
return event
}
// handleBack is called when the user presses the back key (Esc).
func (s *InputService) handleBack() {
s.appService.GetApp().SetRoot(s.layout.Root(), true)
s.appService.GetApp().SetFocus(s.layout.GetTable().View())
}
// handleSearchFieldEvent is called when the user presses the search key (/).
func (s *InputService) handleSearchFieldEvent() {
s.appService.GetApp().SetFocus(s.layout.GetSearch().Field())
}
// handleQuitEvent is called when the user presses the quit key (q).
func (s *InputService) handleQuitEvent() {
s.appService.GetApp().Stop()
}
// handleHelpEvent shows the help screen with all keyboard shortcuts.
func (s *InputService) handleHelpEvent() {
helpScreen := s.layout.GetHelpScreen()
helpScreen.SetBrewfileMode(s.appService.IsBrewfileMode())
helpPages := helpScreen.Build(s.layout.Root())
// Set up key handler to close help on any key press
helpPages.SetInputCapture(func(_ *tcell.EventKey) *tcell.EventKey {
// Close help and return to main view
s.appService.GetApp().SetRoot(s.layout.Root(), true)
s.appService.GetApp().SetFocus(s.layout.GetTable().View())
return nil
})
s.appService.GetApp().SetRoot(helpPages, true)
}
// handleFilterEvent toggles the filter for packages based on the provided filter type.
func (s *InputService) handleFilterEvent(filterType FilterType) {
// Toggle: if same filter is active, turn it off; otherwise switch to new filter
if s.appService.activeFilter == filterType {
s.appService.activeFilter = FilterNone
} else {
s.appService.activeFilter = filterType
}
// Update UI based on active filter
s.updateFilterUI()
s.appService.search(s.layout.GetSearch().Field().GetText(), true)
}
// updateFilterUI updates the search label and legend based on the current filter state.
func (s *InputService) updateFilterUI() {
s.layout.GetLegend().SetLegend(s.legendEntries, "")
// Map filter types to their display config
filterConfig := map[FilterType]struct {
suffix string
keySlug string
}{
FilterInstalled: {"Installed", s.ActionFilterInstalled.KeySlug},
FilterOutdated: {"Outdated", s.ActionFilterOutdated.KeySlug},
FilterLeaves: {"Leaves", s.ActionFilterLeaves.KeySlug},
FilterCasks: {"Casks", s.ActionFilterCasks.KeySlug},
}
baseLabel := "Search"
if s.appService.IsBrewfileMode() {
baseLabel = "Search (Brewfile"
}
if cfg, exists := filterConfig[s.appService.activeFilter]; exists {
if s.appService.IsBrewfileMode() {
s.layout.GetSearch().Field().SetLabel(baseLabel + " - " + cfg.suffix + "): ")
} else {
s.layout.GetSearch().Field().SetLabel("Search (" + cfg.suffix + "): ")
}
s.layout.GetLegend().SetLegend(s.legendEntries, cfg.keySlug)
return
}
// No filter active (FilterNone)
if s.appService.IsBrewfileMode() {
s.layout.GetSearch().Field().SetLabel(baseLabel + "): ")
} else {
s.layout.GetSearch().Field().SetLabel("Search (All): ")
}
}
// handleFilterPackagesEvent toggles the filter for installed packages
func (s *InputService) handleFilterPackagesEvent() {
s.handleFilterEvent(FilterInstalled)
}
// handleFilterOutdatedPackagesEvent toggles the filter for outdated packages
func (s *InputService) handleFilterOutdatedPackagesEvent() {
s.handleFilterEvent(FilterOutdated)
}
// handleFilterLeavesEvent toggles the filter for leaf packages (installed on request)
func (s *InputService) handleFilterLeavesEvent() {
s.handleFilterEvent(FilterLeaves)
}
// handleFilterCasksEvent toggles the filter for cask packages only
func (s *InputService) handleFilterCasksEvent() {
s.handleFilterEvent(FilterCasks)
}
// showModal displays a modal dialog with the specified text and confirmation/cancellation actions.
// This is used for actions like installing, removing, or updating packages, invoking user confirmation.
func (s *InputService) showModal(text string, confirmFunc func(), cancelFunc func()) {
modal := s.layout.GetModal().Build(text, confirmFunc, cancelFunc)
s.appService.app.SetRoot(modal, true)
}
// closeModal closes the currently displayed modal dialog and returns focus to the main table view.
func (s *InputService) closeModal() {
s.appService.app.SetRoot(s.layout.Root(), true)
s.appService.app.SetFocus(s.layout.GetTable().View())
}
// handleInstallPackageEvent is called when the user presses the installation key (i).
func (s *InputService) handleInstallPackageEvent() {
row, _ := s.layout.GetTable().View().GetSelection()
if row > 0 {
info := (*s.appService.filteredPackages)[row-1]
s.showModal(
fmt.Sprintf("Are you sure you want to install the package: %s?", info.Name),
func() {
s.closeModal()
s.layout.GetOutput().Clear()
go func() {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Installing %s...", info.Name))
if err := s.brewService.InstallPackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil {
s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to install %s", info.Name))
return
}
s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Installed %s", info.Name))
s.appService.forceRefreshResults()
}()
}, s.closeModal)
}
}
// handleRemovePackageEvent is called when the user presses the removal key (r).
func (s *InputService) handleRemovePackageEvent() {
row, _ := s.layout.GetTable().View().GetSelection()
if row > 0 {
info := (*s.appService.filteredPackages)[row-1]
s.showModal(
fmt.Sprintf("Are you sure you want to remove the package: %s?", info.Name),
func() {
s.closeModal()
s.layout.GetOutput().Clear()
go func() {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Removing %s...", info.Name))
if err := s.brewService.RemovePackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil {
s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to remove %s", info.Name))
return
}
s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Removed %s", info.Name))
s.appService.forceRefreshResults()
}()
}, s.closeModal)
}
}
// handleUpdatePackageEvent is called when the user presses the update key (u).
func (s *InputService) handleUpdatePackageEvent() {
row, _ := s.layout.GetTable().View().GetSelection()
if row > 0 {
info := (*s.appService.filteredPackages)[row-1]
s.showModal(
fmt.Sprintf("Are you sure you want to update the package: %s?", info.Name),
func() {
s.closeModal()
s.layout.GetOutput().Clear()
go func() {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Updating %s...", info.Name))
if err := s.brewService.UpdatePackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil {
s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to update %s", info.Name))
return
}
s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Updated %s", info.Name))
s.appService.forceRefreshResults()
}()
}, s.closeModal)
}
}
// handleUpdateAllPackagesEvent is called when the user presses the update all key (Ctrl+U).
func (s *InputService) handleUpdateAllPackagesEvent() {
s.showModal("Are you sure you want to update all Packages?", func() {
s.closeModal()
s.layout.GetOutput().Clear()
go func() {
s.layout.GetNotifier().ShowWarning("Updating all Packages...")
if err := s.brewService.UpdateAllPackages(s.appService.app, s.layout.GetOutput().View()); err != nil {
s.layout.GetNotifier().ShowError("Failed to update all Packages")
return
}
s.layout.GetNotifier().ShowSuccess("Updated all Packages")
s.appService.forceRefreshResults()
}()
}, s.closeModal)
}
// batchOperation defines the configuration for a batch package operation.
type batchOperation struct {
actionVerb string // "Installing" or "Removing"
actionTag string // "INSTALL" or "REMOVE"
skipCondition func(pkg models.Package) bool
skipReason string
execute func(pkg models.Package) error
}
// handleBatchPackageOperation processes multiple packages with progress notifications.
func (s *InputService) handleBatchPackageOperation(op batchOperation) {
if !s.appService.IsBrewfileMode() {
return
}
packages := *s.appService.GetBrewfilePackages()
if len(packages) == 0 {
s.layout.GetNotifier().ShowError("No packages found in Brewfile")
return
}
// Count relevant packages
actionable := 0
for _, pkg := range packages {
if !op.skipCondition(pkg) {
actionable++
}
}
if actionable == 0 {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("No packages to process (%s)", op.skipReason))
return
}
message := fmt.Sprintf("%s all packages from Brewfile?\n\nTotal: %d packages\nTo process: %d",
op.actionVerb, len(packages), actionable)
s.showModal(message, func() {
s.closeModal()
s.layout.GetOutput().Clear()
go func() {
current := 0
total := len(packages)
for _, pkg := range packages {
current++
pkgName := pkg.Name // Capture for closures
if op.skipCondition(pkg) {
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("[%d/%d] Skipping %s (%s)", current, total, pkgName, op.skipReason))
s.appService.app.QueueUpdateDraw(func() {
fmt.Fprintf(s.layout.GetOutput().View(), "[SKIP] %s (%s)\n", pkgName, op.skipReason)
})
continue
}
s.layout.GetNotifier().ShowWarning(fmt.Sprintf("[%d/%d] %s %s...", current, total, op.actionVerb, pkgName))
s.appService.app.QueueUpdateDraw(func() {
fmt.Fprintf(s.layout.GetOutput().View(), "\n[%s] %s %s...\n", op.actionTag, op.actionVerb, pkgName)
})
if err := op.execute(pkg); err != nil {
s.layout.GetNotifier().ShowError(fmt.Sprintf("[%d/%d] Failed to process %s", current, total, pkgName))
s.appService.app.QueueUpdateDraw(func() {
fmt.Fprintf(s.layout.GetOutput().View(), "[ERROR] Failed to process %s: %v\n", pkgName, err)
})
continue
}
s.appService.app.QueueUpdateDraw(func() {
fmt.Fprintf(s.layout.GetOutput().View(), "[SUCCESS] %s processed successfully\n", pkgName)
})
}
s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Completed! Processed %d packages", total))
s.appService.forceRefreshResults()
}()
}, s.closeModal)
}
// handleInstallAllPackagesEvent is called when the user presses the install all key (Ctrl+A).
func (s *InputService) handleInstallAllPackagesEvent() {
s.handleBatchPackageOperation(batchOperation{
actionVerb: "Installing",
actionTag: "INSTALL",
skipCondition: func(pkg models.Package) bool { return pkg.LocallyInstalled },
skipReason: "already installed",
execute: func(pkg models.Package) error {
return s.brewService.InstallPackage(pkg, s.appService.app, s.layout.GetOutput().View())
},
})
}
// handleRemoveAllPackagesEvent is called when the user presses the remove all key (Ctrl+R).
func (s *InputService) handleRemoveAllPackagesEvent() {
s.handleBatchPackageOperation(batchOperation{
actionVerb: "Removing",
actionTag: "REMOVE",
skipCondition: func(pkg models.Package) bool { return !pkg.LocallyInstalled },
skipReason: "not installed",
execute: func(pkg models.Package) error {
return s.brewService.RemovePackage(pkg, s.appService.app, s.layout.GetOutput().View())
},
})
}