mirror of
https://github.com/Valkyrie00/bold-brew.git
synced 2026-03-16 07:15:53 +01:00
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.
497 lines
17 KiB
Go
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())
|
|
},
|
|
})
|
|
}
|