From 5546ad1b331f5381b718abfee02352bc821791d8 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 26 Jun 2025 15:43:26 +0200 Subject: [PATCH] feat: general improvement and removed command service (#20) * improved quality code and add general logic fix * feat: removed command service --- internal/models/formula.go | 4 +- internal/services/app.go | 72 ++++++------ internal/services/brew.go | 126 ++++++++++++++++++++- internal/services/command.go | 125 -------------------- internal/services/io.go | 194 +++++++++++++++++++------------- internal/services/selfupdate.go | 1 + 6 files changed, 282 insertions(+), 240 deletions(-) delete mode 100644 internal/services/command.go diff --git a/internal/models/formula.go b/internal/models/formula.go index 958a5c8..da068ce 100644 --- a/internal/models/formula.go +++ b/internal/models/formula.go @@ -52,8 +52,8 @@ type Formula struct { RubySourceChecksum RubySourceChecksum `json:"ruby_source_checksum"` Analytics90dRank int Analytics90dDownloads int - LocallyInstalled bool `json:"-"` - LocalPath string `json:"-"` // Local installation path + LocallyInstalled bool `json:"-"` // Internal flag to indicate if the formula is installed locally [internal use] + LocalPath string `json:"-"` // Internal path to the formula in the local Homebrew Cellar [internal use] } type Analytics struct { diff --git a/internal/services/app.go b/internal/services/app.go index 0f5b6be..ce91d26 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -26,6 +26,7 @@ type AppServiceInterface interface { BuildApp() } +// AppService manages the application state, Homebrew integration, and UI components. type AppService struct { app *tview.Application theme *theme.Theme @@ -37,12 +38,13 @@ type AppService struct { showOnlyOutdated bool brewVersion string - BrewService BrewServiceInterface - SelfUpdateService SelfUpdateServiceInterface - IOService IOServiceInterface + brewService BrewServiceInterface + selfUpdateService SelfUpdateServiceInterface + ioService IOServiceInterface } -func NewAppService() AppServiceInterface { +// NewAppService creates a new instance of AppService with initialized components. +var NewAppService = func() AppServiceInterface { app := tview.NewApplication() themeService := theme.NewTheme() layout := ui.NewLayout(themeService) @@ -60,9 +62,9 @@ func NewAppService() AppServiceInterface { } // Initialize services - s.IOService = NewIOService(s) - s.BrewService = NewBrewService() - s.SelfUpdateService = NewSelfUpdateService() + s.ioService = NewIOService(s) + s.brewService = NewBrewService() + s.selfUpdateService = NewSelfUpdateService() return s } @@ -70,26 +72,28 @@ func NewAppService() AppServiceInterface { func (s *AppService) GetApp() *tview.Application { return s.app } func (s *AppService) GetLayout() ui.LayoutInterface { return s.layout } +// Boot initializes the application by setting up Homebrew and loading formulae data. func (s *AppService) Boot() (err error) { - if s.brewVersion, err = s.BrewService.GetBrewVersion(); err != nil { + if s.brewVersion, err = s.brewService.GetBrewVersion(); err != nil { // This error is critical, as we need Homebrew to function return fmt.Errorf("failed to get Homebrew version: %v", err) } // Download and parse Homebrew formulae data - if err = s.BrewService.SetupData(false); err != nil { + if err = s.brewService.SetupData(false); err != nil { return fmt.Errorf("failed to load Homebrew formulae: %v", err) } - s.packages = s.BrewService.GetFormulae() + // Initialize packages and filteredPackages + s.packages = s.brewService.GetFormulae() *s.filteredPackages = *s.packages - return nil } +// updateHomeBrew updates the Homebrew formulae and refreshes the results in the UI. func (s *AppService) updateHomeBrew() { s.layout.GetNotifier().ShowWarning("Updating Homebrew formulae...") - if err := s.BrewService.UpdateHomebrew(); err != nil { + if err := s.brewService.UpdateHomebrew(); err != nil { s.layout.GetNotifier().ShowError("Could not update Homebrew formulae") return } @@ -98,6 +102,7 @@ func (s *AppService) updateHomeBrew() { s.forceRefreshResults() } +// search filters the packages based on the search text and the current filter state. func (s *AppService) search(searchText string, scrollToTop bool) { var filteredList []models.Formula uniquePackages := make(map[string]bool) @@ -154,18 +159,10 @@ func (s *AppService) search(searchText string, scrollToTop bool) { s.setResults(s.filteredPackages, scrollToTop) } -func (s *AppService) setDetails(info *models.Formula) { - if info == nil { - s.layout.GetDetails().SetContent(nil) - return - } - - s.layout.GetDetails().SetContent(info) -} - +// forceRefreshResults forces a refresh of the Homebrew formulae data and updates the results in the UI. func (s *AppService) forceRefreshResults() { - _ = s.BrewService.SetupData(true) - s.packages = s.BrewService.GetFormulae() + _ = s.brewService.SetupData(true) + s.packages = s.brewService.GetFormulae() *s.filteredPackages = *s.packages s.app.QueueUpdateDraw(func() { @@ -173,6 +170,7 @@ func (s *AppService) forceRefreshResults() { }) } +// setResults updates the results table with the provided data and optionally scrolls to the top. func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { s.layout.GetTable().Clear() s.layout.GetTable().SetTableHeaders("Name", "Version", "Description", "↓ (90d)") @@ -211,7 +209,7 @@ func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { if scrollToTop { s.layout.GetTable().View().Select(1, 0) s.layout.GetTable().View().ScrollToBeginning() - s.setDetails(&(*data)[0]) + s.layout.GetDetails().SetContent(&(*data)[0]) } // Update the filter counter @@ -219,20 +217,24 @@ func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { return } - s.setDetails(nil) + s.layout.GetDetails().SetContent(nil) // Clear details if no results } +// BuildApp builds the application layout, sets up event handlers, and initializes the UI components. func (s *AppService) BuildApp() { // Build the layout s.layout.Setup() s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion) // Evaluate if there is a new version available + // This is done in a goroutine to avoid blocking the UI during startup + // In the future, this could be replaced with a more sophisticated update check, and update + // the user if a new version is available instantly instead of waiting for the next app start go func() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - if latestVersion, err := s.SelfUpdateService.CheckForUpdates(ctx); err == nil && latestVersion != AppVersion { + if latestVersion, err := s.selfUpdateService.CheckForUpdates(ctx); err == nil && latestVersion != AppVersion { s.app.QueueUpdateDraw(func() { AppVersion = fmt.Sprintf("%s ([orange]New Version Available: %s[-])", AppVersion, latestVersion) s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion) @@ -240,27 +242,29 @@ func (s *AppService) BuildApp() { } }() - // Result table section + // Table handler to update the details view when a table row is selected tableSelectionChangedFunc := func(row, _ int) { if row > 0 && row-1 < len(*s.filteredPackages) { - s.setDetails(&(*s.filteredPackages)[row-1]) + s.layout.GetDetails().SetContent(&(*s.filteredPackages)[row-1]) } } s.layout.GetTable().View().SetSelectionChangedFunc(tableSelectionChangedFunc) - // Search field section + // Search input handlers inputDoneFunc := func(key tcell.Key) { if key == tcell.KeyEnter || key == tcell.KeyEscape { - s.app.SetFocus(s.layout.GetTable().View()) + s.app.SetFocus(s.layout.GetTable().View()) // Set focus back to the table on Enter or Escape } } - changedFunc := func(text string) { - s.search(text, true) + changedFunc := func(text string) { // Each time the search input changes + s.search(text, true) // Perform search and scroll to top } s.layout.GetSearch().SetHandlers(inputDoneFunc, changedFunc) - // Add key event handler and set the root view - s.app.SetInputCapture(s.IOService.HandleKeyEventInput) + // Add key event handler + s.app.SetInputCapture(s.ioService.HandleKeyEventInput) + + // Set the root of the application to the layout's root and focus on the table view s.app.SetRoot(s.layout.Root(), true) s.app.SetFocus(s.layout.GetTable().View()) diff --git a/internal/services/brew.go b/internal/services/brew.go index 0b050e2..d42f070 100644 --- a/internal/services/brew.go +++ b/internal/services/brew.go @@ -3,6 +3,8 @@ package services import ( "bbrew/internal/models" "encoding/json" + "fmt" + "github.com/rivo/tview" "io" "net/http" "os" @@ -11,6 +13,7 @@ import ( "sort" "strconv" "strings" + "sync" ) const FormulaeAPIURL = "https://formulae.brew.sh/api/formula.json" @@ -21,9 +24,16 @@ type BrewServiceInterface interface { GetFormulae() (formulae *[]models.Formula) SetupData(forceDownload bool) (err error) GetBrewVersion() (version string, err error) + UpdateHomebrew() error + UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error + UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error + RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error + InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error } +// BrewService provides methods to interact with Homebrew, including +// retrieving formulae, managing packages, and handling analytics. type BrewService struct { // Package lists all *[]models.Formula @@ -35,6 +45,7 @@ type BrewService struct { prefixPath string } +// NewBrewService creates a new instance of BrewService with initialized package lists. var NewBrewService = func() BrewServiceInterface { return &BrewService{ all: new([]models.Formula), @@ -43,6 +54,7 @@ var NewBrewService = func() BrewServiceInterface { } } +// GetPrefixPath retrieves the Homebrew prefix path, caching it for future calls. func (s *BrewService) GetPrefixPath() (path string) { if s.prefixPath != "" { return s.prefixPath @@ -59,17 +71,18 @@ func (s *BrewService) GetPrefixPath() (path string) { return s.prefixPath } +// GetFormulae retrieves all formulae, merging remote and installed packages, func (s *BrewService) GetFormulae() (formulae *[]models.Formula) { packageMap := make(map[string]models.Formula) - // Add remote packages to the map if they don't already exist + // Add REMOTE packages to the map if they don't already exist for _, formula := range *s.remote { if _, exists := packageMap[formula.Name]; !exists { packageMap[formula.Name] = formula } } - // Add installed packages to the map + // Add INSTALLED packages to the map for _, formula := range *s.installed { packageMap[formula.Name] = formula } @@ -94,6 +107,7 @@ func (s *BrewService) GetFormulae() (formulae *[]models.Formula) { return s.all } +// SetupData initializes the BrewService by loading installed packages, remote formulae, and analytics data. func (s *BrewService) SetupData(forceDownload bool) (err error) { if err = s.loadInstalled(); err != nil { return err @@ -110,6 +124,7 @@ func (s *BrewService) SetupData(forceDownload bool) (err error) { return nil } +// loadInstalled retrieves the list of installed Homebrew formulae and updates their local paths. func (s *BrewService) loadInstalled() (err error) { cmd := exec.Command("brew", "info", "--json=v1", "--installed") output, err := cmd.Output() @@ -133,6 +148,7 @@ func (s *BrewService) loadInstalled() (err error) { return nil } +// loadRemote retrieves the list of remote Homebrew formulae from the API and caches them locally. func (s *BrewService) loadRemote(forceDownload bool) (err error) { homeDir, err := os.UserHomeDir() if err != nil { @@ -182,6 +198,7 @@ func (s *BrewService) loadRemote(forceDownload bool) (err error) { return nil } +// loadAnalytics retrieves the analytics data for Homebrew formulae from the API. func (s *BrewService) loadAnalytics() (err error) { resp, err := http.Get(AnalyticsAPIURL) if err != nil { @@ -204,6 +221,7 @@ func (s *BrewService) loadAnalytics() (err error) { return nil } +// GetBrewVersion retrieves the version of Homebrew installed on the system, caching it for future calls. func (s *BrewService) GetBrewVersion() (version string, err error) { if s.brewVersion != "" { return s.brewVersion, nil @@ -219,6 +237,7 @@ func (s *BrewService) GetBrewVersion() (version string, err error) { return s.brewVersion, nil } +// UpdateHomebrew updates the Homebrew package manager by running the `brew update` command. func (s *BrewService) UpdateHomebrew() error { cmd := exec.Command("brew", "update") if err := cmd.Run(); err != nil { @@ -226,3 +245,106 @@ func (s *BrewService) UpdateHomebrew() error { } return nil } + +func (s *BrewService) UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error { + cmd := exec.Command("brew", "upgrade") // #nosec G204 + return s.executeCommand(app, cmd, outputView) +} + +func (s *BrewService) UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { + cmd := exec.Command("brew", "upgrade", info.Name) // #nosec G204 + return s.executeCommand(app, cmd, outputView) +} + +func (s *BrewService) RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { + cmd := exec.Command("brew", "remove", info.Name) // #nosec G204 + return s.executeCommand(app, cmd, outputView) +} + +func (s *BrewService) InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { + cmd := exec.Command("brew", "install", info.Name) // #nosec G204 + return s.executeCommand(app, cmd, outputView) +} + +// executeCommand runs a command and captures its output, updating the provided TextView in the application. +func (s *BrewService) executeCommand( + app *tview.Application, + cmd *exec.Cmd, + outputView *tview.TextView, +) error { + stdoutPipe, stdoutWriter := io.Pipe() + stderrPipe, stderrWriter := io.Pipe() + cmd.Stdout = stdoutWriter + cmd.Stderr = stderrWriter + + if err := cmd.Start(); err != nil { + return err + } + + // Add a WaitGroup to wait for all goroutines to finish + var wg sync.WaitGroup + wg.Add(3) + + // Goroutine to wait for the command to finish + go func() { + defer wg.Done() + defer stdoutWriter.Close() + defer stderrWriter.Close() + cmd.Wait() + }() + + // Stdout handler + go func() { + defer wg.Done() + defer stdoutPipe.Close() + buf := make([]byte, 1024) + for { + n, err := stdoutPipe.Read(buf) + if n > 0 { + output := make([]byte, n) + copy(output, buf[:n]) + app.QueueUpdateDraw(func() { + outputView.Write(output) + outputView.ScrollToEnd() + }) + } + if err != nil { + if err != io.EOF { + app.QueueUpdateDraw(func() { + fmt.Fprintf(outputView, "\nError: %v\n", err) + }) + } + break + } + } + }() + + // Stderr handler + go func() { + defer wg.Done() + defer stderrPipe.Close() + buf := make([]byte, 1024) + for { + n, err := stderrPipe.Read(buf) + if n > 0 { + output := make([]byte, n) + copy(output, buf[:n]) + app.QueueUpdateDraw(func() { + outputView.Write(output) + outputView.ScrollToEnd() + }) + } + if err != nil { + if err != io.EOF { + app.QueueUpdateDraw(func() { + fmt.Fprintf(outputView, "\nError: %v\n", err) + }) + } + break + } + } + }() + + wg.Wait() + return nil +} diff --git a/internal/services/command.go b/internal/services/command.go deleted file mode 100644 index ae3b874..0000000 --- a/internal/services/command.go +++ /dev/null @@ -1,125 +0,0 @@ -package services - -import ( - "bbrew/internal/models" - "fmt" - "github.com/rivo/tview" - "io" - "os/exec" - "sync" -) - -type CommandServiceInterface interface { - UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error - UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error - RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error - InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error -} - -type CommandService struct{} - -var NewCommandService = func() CommandServiceInterface { - return &CommandService{} -} - -func (s *CommandService) UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "upgrade") // #nosec G204 - return s.executeCommand(app, cmd, outputView) -} - -func (s *CommandService) UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "upgrade", info.Name) // #nosec G204 - return s.executeCommand(app, cmd, outputView) -} - -func (s *CommandService) RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "remove", info.Name) // #nosec G204 - return s.executeCommand(app, cmd, outputView) -} - -func (s *CommandService) InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "install", info.Name) // #nosec G204 - return s.executeCommand(app, cmd, outputView) -} - -func (s *CommandService) executeCommand( - app *tview.Application, - cmd *exec.Cmd, - outputView *tview.TextView, -) error { - stdoutPipe, stdoutWriter := io.Pipe() - stderrPipe, stderrWriter := io.Pipe() - cmd.Stdout = stdoutWriter - cmd.Stderr = stderrWriter - - if err := cmd.Start(); err != nil { - return err - } - - // Add a WaitGroup to wait for all goroutines to finish - var wg sync.WaitGroup - wg.Add(3) - - // Wait for the command to finish - go func() { - defer wg.Done() - defer stdoutWriter.Close() - defer stderrWriter.Close() - cmd.Wait() - }() - - // Stdout handler - go func() { - defer wg.Done() - defer stdoutPipe.Close() - buf := make([]byte, 1024) - for { - n, err := stdoutPipe.Read(buf) - if n > 0 { - output := make([]byte, n) - copy(output, buf[:n]) - app.QueueUpdateDraw(func() { - outputView.Write(output) - outputView.ScrollToEnd() - }) - } - if err != nil { - if err != io.EOF { - app.QueueUpdateDraw(func() { - fmt.Fprintf(outputView, "\nError: %v\n", err) - }) - } - break - } - } - }() - - // Stderr handler - go func() { - defer wg.Done() - defer stderrPipe.Close() - buf := make([]byte, 1024) - for { - n, err := stderrPipe.Read(buf) - if n > 0 { - output := make([]byte, n) - copy(output, buf[:n]) - app.QueueUpdateDraw(func() { - outputView.Write(output) - outputView.ScrollToEnd() - }) - } - if err != nil { - if err != io.EOF { - app.QueueUpdateDraw(func() { - fmt.Fprintf(outputView, "\nError: %v\n", err) - }) - } - break - } - } - }() - - wg.Wait() - return nil -} diff --git a/internal/services/io.go b/internal/services/io.go index 0acb460..6933555 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -6,18 +6,14 @@ import ( "github.com/gdamore/tcell/v2" ) -var ( - IoSearch = IOAction{Key: tcell.KeyRune, Rune: '/', KeySlug: "/", Name: "Search"} - IoFilterInstalled = IOAction{Key: tcell.KeyRune, Rune: 'f', KeySlug: "f", Name: "Filter Installed"} - IoFilterOutdated = IOAction{Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Filter Outdated"} - IoInstall = IOAction{Key: tcell.KeyRune, Rune: 'i', KeySlug: "i", Name: "Install"} - IoUpdate = IOAction{Key: tcell.KeyRune, Rune: 'u', KeySlug: "u", Name: "Update"} - IoRemove = IOAction{Key: tcell.KeyRune, Rune: 'r', KeySlug: "r", Name: "Remove"} - IoUpdateAll = IOAction{Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All"} - IoBack = IOAction{Key: tcell.KeyEsc, Rune: 0, KeySlug: "esc", Name: "Back to Table"} - IoQuit = IOAction{Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit"} +type FilterType int + +const ( + FilterInstalled FilterType = iota + FilterOutdated ) +// IOAction represents an input/output action that can be triggered by a key event. type IOAction struct { Key tcell.Key Rune rune @@ -30,48 +26,85 @@ func (k *IOAction) SetAction(action func()) { k.Action = action } +// IOServiceInterface defines the interface for handling input/output actions in the application. type IOServiceInterface interface { HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey } +// IOService implements the IOServiceInterface and handles key events for the application. type IOService struct { - appService *AppService - layout ui.LayoutInterface - commandService CommandServiceInterface - keyActions []*IOAction - legendEntries []struct{ KeySlug, Name string } + appService *AppService + layout ui.LayoutInterface + brewService BrewServiceInterface + keyActions []*IOAction + legendEntries []struct{ KeySlug, Name string } + + // Actions for each key input + ActionSearch *IOAction + ActionFilterInstalled *IOAction + ActionFilterOutdated *IOAction + ActionInstall *IOAction + ActionUpdate *IOAction + ActionRemove *IOAction + ActionUpdateAll *IOAction + ActionBack *IOAction + ActionQuit *IOAction } var NewIOService = func(appService *AppService) IOServiceInterface { s := &IOService{ - appService: appService, - layout: appService.GetLayout(), - commandService: NewCommandService(), + appService: appService, + layout: appService.GetLayout(), + brewService: NewBrewService(), } - // Define actions for each key input - s.keyActions = []*IOAction{&IoSearch, &IoFilterInstalled, &IoFilterOutdated, &IoInstall, &IoUpdate, &IoUpdateAll, &IoRemove, &IoBack, &IoQuit} - IoQuit.SetAction(s.handleQuitEvent) - IoUpdate.SetAction(s.handleUpdatePackageEvent) - IoUpdateAll.SetAction(s.handleUpdateAllPackagesEvent) - IoRemove.SetAction(s.handleRemovePackageEvent) - IoInstall.SetAction(s.handleInstallPackageEvent) - IoSearch.SetAction(s.handleSearchFieldEvent) - IoFilterInstalled.SetAction(s.handleFilterPackagesEvent) - IoFilterOutdated.SetAction(s.handleFilterOutdatedPackagesEvent) - IoBack.SetAction(s.handleBack) + // Initialize key actions with their respective keys, runes, and names. + s.ActionSearch = &IOAction{Key: tcell.KeyRune, Rune: '/', KeySlug: "/", Name: "Search"} + s.ActionFilterInstalled = &IOAction{Key: tcell.KeyRune, Rune: 'f', KeySlug: "f", Name: "Filter Installed"} + s.ActionFilterOutdated = &IOAction{Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Filter Outdated"} + s.ActionInstall = &IOAction{Key: tcell.KeyRune, Rune: 'i', KeySlug: "i", Name: "Install"} + s.ActionUpdate = &IOAction{Key: tcell.KeyRune, Rune: 'u', KeySlug: "u", Name: "Update"} + s.ActionRemove = &IOAction{Key: tcell.KeyRune, Rune: 'r', KeySlug: "r", Name: "Remove"} + s.ActionUpdateAll = &IOAction{Key: tcell.KeyCtrlU, Rune: 0, KeySlug: "ctrl+u", Name: "Update All"} + s.ActionBack = &IOAction{Key: tcell.KeyEsc, Rune: 0, KeySlug: "esc", Name: "Back to Table"} + s.ActionQuit = &IOAction{Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit"} - // Convert IOMap to a map for easier access + // Define actions for each key input, + s.ActionSearch.SetAction(s.handleSearchFieldEvent) + s.ActionFilterInstalled.SetAction(s.handleFilterPackagesEvent) + s.ActionFilterOutdated.SetAction(s.handleFilterOutdatedPackagesEvent) + s.ActionInstall.SetAction(s.handleInstallPackageEvent) + s.ActionUpdate.SetAction(s.handleUpdatePackageEvent) + s.ActionRemove.SetAction(s.handleRemovePackageEvent) + s.ActionUpdateAll.SetAction(s.handleUpdateAllPackagesEvent) + s.ActionBack.SetAction(s.handleBack) + s.ActionQuit.SetAction(s.handleQuitEvent) + + // Add all actions to the keyActions slice + s.keyActions = []*IOAction{ + s.ActionSearch, + s.ActionFilterInstalled, + s.ActionFilterOutdated, + s.ActionInstall, + s.ActionUpdate, + s.ActionRemove, + s.ActionUpdateAll, + s.ActionBack, + s.ActionQuit, + } + + // Convert keyActions to legend entries s.legendEntries = make([]struct{ KeySlug, Name string }, len(s.keyActions)) for i, input := range s.keyActions { s.legendEntries[i] = struct{ KeySlug, Name string }{KeySlug: input.KeySlug, Name: input.Name} } - // Initialize the legend text + // Initialize the legend text, literally the UI component that displays the key bindings s.layout.GetLegend().SetLegend(s.legendEntries, "") return s } +// HandleKeyEventInput processes key events and triggers the corresponding actions. func (s *IOService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey { if s.layout.GetSearch().Field().HasFocus() { return event @@ -94,77 +127,81 @@ func (s *IOService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey { return event } +// handleBack is called when the user presses the back key (Esc). func (s *IOService) 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 *IOService) handleSearchFieldEvent() { s.appService.GetApp().SetFocus(s.layout.GetSearch().Field()) } +// handleQuitEvent is called when the user presses the quit key (q). func (s *IOService) handleQuitEvent() { s.appService.GetApp().Stop() } +// handleFilterEvent toggles the filter for installed or outdated packages based on the provided filter type. +func (s *IOService) handleFilterEvent(filterType FilterType) { + s.layout.GetLegend().SetLegend(s.legendEntries, "") + + switch filterType { + case FilterInstalled: + if s.appService.showOnlyOutdated { + s.appService.showOnlyOutdated = false + s.appService.showOnlyInstalled = true + } else { + s.appService.showOnlyInstalled = !s.appService.showOnlyInstalled + } + case FilterOutdated: + if s.appService.showOnlyInstalled { + s.appService.showOnlyInstalled = false + s.appService.showOnlyOutdated = true + } else { + s.appService.showOnlyOutdated = !s.appService.showOnlyOutdated + } + } + + // Update the search field label and legend based on the current filter state + if s.appService.showOnlyOutdated { + s.layout.GetSearch().Field().SetLabel("Search (Outdated): ") + s.layout.GetLegend().SetLegend(s.legendEntries, s.ActionFilterOutdated.KeySlug) + } else if s.appService.showOnlyInstalled { + s.layout.GetSearch().Field().SetLabel("Search (Installed): ") + s.layout.GetLegend().SetLegend(s.legendEntries, s.ActionFilterInstalled.KeySlug) + } else { + s.layout.GetSearch().Field().SetLabel("Search (All): ") + } + + s.appService.search(s.layout.GetSearch().Field().GetText(), true) +} + +// handleFilterPackagesEvent toggles the filter for installed packages func (s *IOService) handleFilterPackagesEvent() { - s.layout.GetLegend().SetLegend(s.legendEntries, "") - - if s.appService.showOnlyOutdated { - s.appService.showOnlyOutdated = false - s.appService.showOnlyInstalled = true - } else { - s.appService.showOnlyInstalled = !s.appService.showOnlyInstalled - } - - // Update the search field label - if s.appService.showOnlyOutdated { - s.layout.GetSearch().Field().SetLabel("Search (Outdated): ") - s.layout.GetLegend().SetLegend(s.legendEntries, IoFilterOutdated.KeySlug) - } else if s.appService.showOnlyInstalled { - s.layout.GetSearch().Field().SetLabel("Search (Installed): ") - s.layout.GetLegend().SetLegend(s.legendEntries, IoFilterInstalled.KeySlug) - } else { - s.layout.GetSearch().Field().SetLabel("Search (All): ") - } - - s.appService.search(s.layout.GetSearch().Field().GetText(), true) + s.handleFilterEvent(FilterInstalled) } +// handleFilterOutdatedPackagesEvent toggles the filter for outdated packages func (s *IOService) handleFilterOutdatedPackagesEvent() { - s.layout.GetLegend().SetLegend(s.legendEntries, "") - - if s.appService.showOnlyInstalled { - s.appService.showOnlyInstalled = false - s.appService.showOnlyOutdated = true - } else { - s.appService.showOnlyOutdated = !s.appService.showOnlyOutdated - } - - // Update the search field label - if s.appService.showOnlyOutdated { - s.layout.GetSearch().Field().SetLabel("Search (Outdated): ") - s.layout.GetLegend().SetLegend(s.legendEntries, IoFilterOutdated.KeySlug) - } else if s.appService.showOnlyInstalled { - s.layout.GetSearch().Field().SetLabel("Search (Installed): ") - s.layout.GetLegend().SetLegend(s.legendEntries, IoFilterInstalled.KeySlug) - } else { - s.layout.GetSearch().Field().SetLabel("Search (All): ") - } - - s.appService.search(s.layout.GetSearch().Field().GetText(), true) + s.handleFilterEvent(FilterOutdated) } +// 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 *IOService) 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 *IOService) 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 *IOService) handleInstallPackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { @@ -176,7 +213,7 @@ func (s *IOService) handleInstallPackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Installing %s...", info.Name)) - if err := s.commandService.InstallPackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil { + 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 } @@ -187,6 +224,7 @@ func (s *IOService) handleInstallPackageEvent() { } } +// handleRemovePackageEvent is called when the user presses the removal key (r). func (s *IOService) handleRemovePackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { @@ -198,7 +236,7 @@ func (s *IOService) handleRemovePackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Removing %s...", info.Name)) - if err := s.commandService.RemovePackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil { + 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 } @@ -209,6 +247,7 @@ func (s *IOService) handleRemovePackageEvent() { } } +// handleUpdatePackageEvent is called when the user presses the update key (u). func (s *IOService) handleUpdatePackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { @@ -220,7 +259,7 @@ func (s *IOService) handleUpdatePackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Updating %s...", info.Name)) - if err := s.commandService.UpdatePackage(info, s.appService.app, s.layout.GetOutput().View()); err != nil { + 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 } @@ -231,13 +270,14 @@ func (s *IOService) handleUpdatePackageEvent() { } } +// handleUpdateAllPackagesEvent is called when the user presses the update all key (Ctrl+U). func (s *IOService) 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.commandService.UpdateAllPackages(s.appService.app, s.layout.GetOutput().View()); err != nil { + if err := s.brewService.UpdateAllPackages(s.appService.app, s.layout.GetOutput().View()); err != nil { s.layout.GetNotifier().ShowError("Failed to update all Packages") return } diff --git a/internal/services/selfupdate.go b/internal/services/selfupdate.go index 4344aeb..71fb150 100644 --- a/internal/services/selfupdate.go +++ b/internal/services/selfupdate.go @@ -23,6 +23,7 @@ var NewSelfUpdateService = func() SelfUpdateServiceInterface { return &SelfUpdateService{} } +// CheckForUpdates checks for the latest version of the Bold Brew package using Homebrew. func (s *SelfUpdateService) CheckForUpdates(ctx context.Context) (string, error) { cmd := exec.CommandContext(ctx, "brew", "info", "--json=v1", "valkyrie00/bbrew/bbrew") output, err := cmd.CombinedOutput()