feat: io service refactoring (#18)
Some checks are pending
Quality / golangci-lint (push) Waiting to run
Quality / Build (push) Waiting to run

* refactored io legend and handler

* implmented dedicated IOService

* refactored and fixed io service

* fixed quality issues

* fix general and copilot issues
This commit is contained in:
Vito 2025-06-25 17:26:35 +02:00 committed by GitHub
commit f514bc3a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 207 additions and 125 deletions

View file

@ -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:

View file

@ -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
default: none
enable:
- gosec
- govet
- revive
- staticcheck
- unused
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
formatters:
enable:
- gofmt

View file

@ -33,8 +33,8 @@ run: build
##############################
# QUALITY
##############################
.PHONY: lint
lint:
.PHONY: quality
quality:
@golangci-lint run
##############################

View file

@ -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())

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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()
}
})

View file

@ -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),