From f514bc3a30bd428a764a4c51d3504f08d1237888 Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 25 Jun 2025 17:26:35 +0200 Subject: [PATCH] feat: io service refactoring (#18) * refactored io legend and handler * implmented dedicated IOService * refactored and fixed io service * fixed quality issues * fix general and copilot issues --- .github/workflows/quality.yml | 4 +- .golangci.yaml | 43 ++++--- Makefile | 4 +- internal/services/app.go | 29 ++--- internal/services/brew.go | 2 +- internal/services/command.go | 4 +- internal/services/io.go | 203 ++++++++++++++++++++----------- internal/ui/components/legend.go | 36 ++++-- internal/ui/components/modal.go | 5 +- internal/ui/layout.go | 2 +- 10 files changed, 207 insertions(+), 125 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b42181e..b9850e0 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -18,9 +18,9 @@ jobs: with: go-version: stable - name: golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@v8 with: - version: v1.63.4 + version: v2.1 skip-cache: true build: diff --git a/.golangci.yaml b/.golangci.yaml index 81a825d..ee95e17 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,27 +1,30 @@ +version: "2" run: - timeout: 5m + concurrency: 4 + go: "1.20" tests: false allow-parallel-runners: true - go: '1.20' - concurrency: 4 - output: formats: - - format: colored-line-number + text: path: stdout - print-issued-lines: true - print-linter-name: true - sort-results: true - show-stats: true - + print-linter-name: true + print-issued-lines: true linters: - disable-all: true - enable: - - gosimple - - govet - - staticcheck - - unused - - gofmt - - gosec - - stylecheck - - revive \ No newline at end of file + default: none + enable: + - gosec + - govet + - revive + - staticcheck + - unused + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling +formatters: + enable: + - gofmt diff --git a/Makefile b/Makefile index 8e2eba4..001108c 100644 --- a/Makefile +++ b/Makefile @@ -33,8 +33,8 @@ run: build ############################## # QUALITY ############################## -.PHONY: lint -lint: +.PHONY: quality +quality: @golangci-lint run ############################## diff --git a/internal/services/app.go b/internal/services/app.go index f9d791e..0f5b6be 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -21,14 +21,15 @@ var ( type AppServiceInterface interface { GetApp() *tview.Application + GetLayout() ui.LayoutInterface Boot() (err error) BuildApp() } type AppService struct { app *tview.Application - layout ui.LayoutInterface theme *theme.Theme + layout ui.LayoutInterface packages *[]models.Formula filteredPackages *[]models.Formula @@ -37,37 +38,37 @@ type AppService struct { brewVersion string BrewService BrewServiceInterface - CommandService CommandServiceInterface SelfUpdateService SelfUpdateServiceInterface + IOService IOServiceInterface } func NewAppService() AppServiceInterface { app := tview.NewApplication() themeService := theme.NewTheme() - brewService := NewBrewService() + layout := ui.NewLayout(themeService) - appService := &AppService{ + s := &AppService{ app: app, theme: themeService, - layout: ui.NewLayout(themeService), + layout: layout, packages: new([]models.Formula), filteredPackages: new([]models.Formula), showOnlyInstalled: false, showOnlyOutdated: false, brewVersion: "-", - - BrewService: brewService, - CommandService: NewCommandService(), - SelfUpdateService: NewSelfUpdateService(), } - return appService + // Initialize services + s.IOService = NewIOService(s) + s.BrewService = NewBrewService() + s.SelfUpdateService = NewSelfUpdateService() + + return s } -func (s *AppService) GetApp() *tview.Application { - return s.app -} +func (s *AppService) GetApp() *tview.Application { return s.app } +func (s *AppService) GetLayout() ui.LayoutInterface { return s.layout } func (s *AppService) Boot() (err error) { if s.brewVersion, err = s.BrewService.GetBrewVersion(); err != nil { @@ -259,7 +260,7 @@ func (s *AppService) BuildApp() { s.layout.GetSearch().SetHandlers(inputDoneFunc, changedFunc) // Add key event handler and set the root view - s.app.SetInputCapture(s.handleKeyEventInput) + s.app.SetInputCapture(s.IOService.HandleKeyEventInput) 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 e67ba2c..0b050e2 100644 --- a/internal/services/brew.go +++ b/internal/services/brew.go @@ -123,7 +123,7 @@ func (s *BrewService) loadInstalled() (err error) { return err } - // Mark all installed packages as locally installed and set LocalPath + // Mark all installed Packages as locally installed and set LocalPath prefix := s.GetPrefixPath() for i := range *s.installed { (*s.installed)[i].LocallyInstalled = true diff --git a/internal/services/command.go b/internal/services/command.go index 3bb3d65..ae3b874 100644 --- a/internal/services/command.go +++ b/internal/services/command.go @@ -86,7 +86,7 @@ func (s *CommandService) executeCommand( if err != nil { if err != io.EOF { app.QueueUpdateDraw(func() { - outputView.Write([]byte(fmt.Sprintf("\nError: %v\n", err))) + fmt.Fprintf(outputView, "\nError: %v\n", err) }) } break @@ -112,7 +112,7 @@ func (s *CommandService) executeCommand( if err != nil { if err != io.EOF { app.QueueUpdateDraw(func() { - outputView.Write([]byte(fmt.Sprintf("\nError: %v\n", err))) + fmt.Fprintf(outputView, "\nError: %v\n", err) }) } break diff --git a/internal/services/io.go b/internal/services/io.go index ac94de3..0acb460 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -1,107 +1,174 @@ package services import ( + "bbrew/internal/ui" "fmt" "github.com/gdamore/tcell/v2" ) -func (s *AppService) handleKeyEventInput(event *tcell.EventKey) *tcell.EventKey { +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 IOAction struct { + Key tcell.Key + Rune rune + Name string + KeySlug string + Action func() +} + +func (k *IOAction) SetAction(action func()) { + k.Action = action +} + +type IOServiceInterface interface { + HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey +} + +type IOService struct { + appService *AppService + layout ui.LayoutInterface + commandService CommandServiceInterface + keyActions []*IOAction + legendEntries []struct{ KeySlug, Name string } +} + +var NewIOService = func(appService *AppService) IOServiceInterface { + s := &IOService{ + appService: appService, + layout: appService.GetLayout(), + commandService: NewCommandService(), + } + + // 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) + + // Convert IOMap to a map for easier access + 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 + s.layout.GetLegend().SetLegend(s.legendEntries, "") + return s +} + +func (s *IOService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKey { if s.layout.GetSearch().Field().HasFocus() { return event } - keyActions := map[tcell.Key]func(){ - tcell.KeyRune: func() { - runeActions := map[rune]func(){ - 'q': s.handleQuitEvent, - 'u': s.handleUpdatePackageEvent, - 'r': s.handleRemovePackageEvent, - 'i': s.handleInstallPackageEvent, - '/': s.handleSearchFieldEvent, - 'f': s.handleFilterPackagesEvent, - 'o': s.handleFilterOutdatedPackagesEvent, // New key binding for filtering outdated packages + 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 } - if action, exists := runeActions[event.Rune()]; exists { - action() + } else if event.Modifiers() != tcell.ModNone && input.Key == event.Key() { // Check Key only + if input.Action != nil { + input.Action() + return nil } - }, - tcell.KeyCtrlU: s.handleUpdateAllPackagesEvent, - tcell.KeyEsc: func() { - s.app.SetRoot(s.layout.Root(), true) - s.app.SetFocus(s.layout.GetTable().View()) - }, - } - - if action, exists := keyActions[event.Key()]; exists { - action() - return nil + } } return event } -func (s *AppService) handleSearchFieldEvent() { - s.app.SetFocus(s.layout.GetSearch().Field()) +func (s *IOService) handleBack() { + s.appService.GetApp().SetRoot(s.layout.Root(), true) + s.appService.GetApp().SetFocus(s.layout.GetTable().View()) } -func (s *AppService) handleQuitEvent() { - s.app.Stop() +func (s *IOService) handleSearchFieldEvent() { + s.appService.GetApp().SetFocus(s.layout.GetSearch().Field()) } -func (s *AppService) handleFilterPackagesEvent() { - if s.showOnlyOutdated { - s.showOnlyOutdated = false - s.showOnlyInstalled = true +func (s *IOService) handleQuitEvent() { + s.appService.GetApp().Stop() +} + +func (s *IOService) handleFilterPackagesEvent() { + s.layout.GetLegend().SetLegend(s.legendEntries, "") + + if s.appService.showOnlyOutdated { + s.appService.showOnlyOutdated = false + s.appService.showOnlyInstalled = true } else { - s.showOnlyInstalled = !s.showOnlyInstalled + s.appService.showOnlyInstalled = !s.appService.showOnlyInstalled } // Update the search field label - if s.showOnlyOutdated { + if s.appService.showOnlyOutdated { s.layout.GetSearch().Field().SetLabel("Search (Outdated): ") - } else if s.showOnlyInstalled { + 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.search(s.layout.GetSearch().Field().GetText(), true) + s.appService.search(s.layout.GetSearch().Field().GetText(), true) } -func (s *AppService) handleFilterOutdatedPackagesEvent() { - if s.showOnlyInstalled { - s.showOnlyInstalled = false - s.showOnlyOutdated = true +func (s *IOService) handleFilterOutdatedPackagesEvent() { + s.layout.GetLegend().SetLegend(s.legendEntries, "") + + if s.appService.showOnlyInstalled { + s.appService.showOnlyInstalled = false + s.appService.showOnlyOutdated = true } else { - s.showOnlyOutdated = !s.showOnlyOutdated + s.appService.showOnlyOutdated = !s.appService.showOnlyOutdated } // Update the search field label - if s.showOnlyOutdated { + if s.appService.showOnlyOutdated { s.layout.GetSearch().Field().SetLabel("Search (Outdated): ") - } else if s.showOnlyInstalled { + 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.search(s.layout.GetSearch().Field().GetText(), true) + s.appService.search(s.layout.GetSearch().Field().GetText(), true) } -func (s *AppService) showModal(text string, confirmFunc func(), cancelFunc func()) { +func (s *IOService) showModal(text string, confirmFunc func(), cancelFunc func()) { modal := s.layout.GetModal().Build(text, confirmFunc, cancelFunc) - s.app.SetRoot(modal, true) + s.appService.app.SetRoot(modal, true) } -func (s *AppService) closeModal() { - s.app.SetRoot(s.layout.Root(), true) - s.app.SetFocus(s.layout.GetTable().View()) +func (s *IOService) closeModal() { + s.appService.app.SetRoot(s.layout.Root(), true) + s.appService.app.SetFocus(s.layout.GetTable().View()) } -func (s *AppService) handleInstallPackageEvent() { +func (s *IOService) handleInstallPackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { - info := (*s.filteredPackages)[row-1] + info := (*s.appService.filteredPackages)[row-1] s.showModal( fmt.Sprintf("Are you sure you want to install the package: %s?", info.Name), func() { @@ -109,21 +176,21 @@ func (s *AppService) handleInstallPackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Installing %s...", info.Name)) - if err := s.CommandService.InstallPackage(info, s.app, s.layout.GetOutput().View()); err != nil { + if err := s.commandService.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.forceRefreshResults() + s.appService.forceRefreshResults() }() }, s.closeModal) } } -func (s *AppService) handleRemovePackageEvent() { +func (s *IOService) handleRemovePackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { - info := (*s.filteredPackages)[row-1] + info := (*s.appService.filteredPackages)[row-1] s.showModal( fmt.Sprintf("Are you sure you want to remove the package: %s?", info.Name), func() { @@ -131,21 +198,21 @@ func (s *AppService) handleRemovePackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Removing %s...", info.Name)) - if err := s.CommandService.RemovePackage(info, s.app, s.layout.GetOutput().View()); err != nil { + if err := s.commandService.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.forceRefreshResults() + s.appService.forceRefreshResults() }() }, s.closeModal) } } -func (s *AppService) handleUpdatePackageEvent() { +func (s *IOService) handleUpdatePackageEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { - info := (*s.filteredPackages)[row-1] + info := (*s.appService.filteredPackages)[row-1] s.showModal( fmt.Sprintf("Are you sure you want to update the package: %s?", info.Name), func() { @@ -153,29 +220,29 @@ func (s *AppService) handleUpdatePackageEvent() { s.layout.GetOutput().Clear() go func() { s.layout.GetNotifier().ShowWarning(fmt.Sprintf("Updating %s...", info.Name)) - if err := s.CommandService.UpdatePackage(info, s.app, s.layout.GetOutput().View()); err != nil { + if err := s.commandService.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.forceRefreshResults() + s.appService.forceRefreshResults() }() }, s.closeModal) } } -func (s *AppService) handleUpdateAllPackagesEvent() { - s.showModal("Are you sure you want to update all packages?", func() { +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.app, s.layout.GetOutput().View()); err != nil { - s.layout.GetNotifier().ShowError("Failed to update all packages") + s.layout.GetNotifier().ShowWarning("Updating all Packages...") + if err := s.commandService.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.forceRefreshResults() + s.layout.GetNotifier().ShowSuccess("Updated all Packages") + s.appService.forceRefreshResults() }() }, s.closeModal) } diff --git a/internal/ui/components/legend.go b/internal/ui/components/legend.go index 3af03af..8f02283 100644 --- a/internal/ui/components/legend.go +++ b/internal/ui/components/legend.go @@ -2,7 +2,9 @@ package components import ( "bbrew/internal/ui/theme" + "fmt" "github.com/rivo/tview" + "strings" ) type Legend struct { @@ -11,20 +13,7 @@ type Legend struct { } func NewLegend(theme *theme.Theme) *Legend { - legendText := tview.Escape( - "[/] Search | " + - "[f] Filter Installed | " + - "[o] Filter Outdated | " + - "[i] Install | " + - "[u] Update | " + - "[ctrl+u] Update All | " + - "[r] Remove | " + - "[Esc] Back to Table | " + - "[q] Quit", - ) - legendView := tview.NewTextView(). - SetText(legendText). SetDynamicColors(true). SetTextAlign(tview.AlignCenter). SetTextColor(theme.LegendColor) @@ -39,6 +28,27 @@ func (l *Legend) View() *tview.TextView { return l.view } +func (l *Legend) GetFormattedLabel(keySlug, label string, active bool) string { + if active { + return fmt.Sprintf("[yellow::b]%s[-]", tview.Escape(fmt.Sprintf("[%s] %s", keySlug, label))) + } + + return tview.Escape(fmt.Sprintf("[%s] %s", keySlug, label)) +} + +func (l *Legend) SetLegend(legend []struct{ KeySlug, Name string }, activeKey string) { + var builder strings.Builder + for i, item := range legend { + active := item.KeySlug == activeKey + builder.WriteString(l.GetFormattedLabel(item.KeySlug, item.Name, active)) + if i < len(legend)-1 { + builder.WriteString(" | ") + } + } + + l.SetText(builder.String()) +} + func (l *Legend) SetText(text string) { l.view.SetText(text) } diff --git a/internal/ui/components/modal.go b/internal/ui/components/modal.go index 8641b4b..b9ad816 100644 --- a/internal/ui/components/modal.go +++ b/internal/ui/components/modal.go @@ -33,9 +33,10 @@ func (m *Modal) Build(text string, confirmFunc func(), cancelFunc func()) *tview SetText(text). AddButtons([]string{"Confirm", "Cancel"}). SetDoneFunc(func(buttonIndex int, _ string) { - if buttonIndex == 0 { + switch buttonIndex { + case 0: confirmFunc() - } else if buttonIndex == 1 { + case 1: cancelFunc() } }) diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 3240e2e..18e3902 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -34,7 +34,7 @@ type Layout struct { theme *theme.Theme } -func NewLayout(theme *theme.Theme) *Layout { +func NewLayout(theme *theme.Theme) LayoutInterface { return &Layout{ mainContent: tview.NewGrid(), header: components.NewHeader(theme),