From fc6dd094ba0198759dd34f659ef12ee1ad2c8894 Mon Sep 17 00:00:00 2001 From: James Reilly Date: Mon, 12 Jan 2026 22:27:21 -0600 Subject: [PATCH 1/2] feat: Multi-select with bulk install/uninstall --- .env | 4 +- Makefile | 2 +- internal/services/input.go | 97 ++++++++++++++++++++++++++++++++- internal/ui/components/table.go | 50 +++++++++++++++-- 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/.env b/.env index c1956e0..e77bb23 100644 --- a/.env +++ b/.env @@ -2,5 +2,5 @@ APP_NAME=bbrew APP_VERSION=0.0.1-local CONTAINER_IMAGE_NAME=bbrew BUILD_GOVERSION=1.25 -BUILD_GOOS=darwin -BUILD_GOARCH=arm64 \ No newline at end of file +BUILD_GOOS=linux +BUILD_GOARCH=amd64 \ No newline at end of file diff --git a/Makefile b/Makefile index 81e3147..65da23e 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ BUILD_GOOS ?= $(shell go env GOOS) BUILD_GOARCH ?= $(shell go env GOARCH) # Container runtime command -CONTAINER_RUN = podman run --rm -v $(PWD):/app $(CONTAINER_IMAGE_NAME) +CONTAINER_RUN = podman run --rm -v $(PWD):/app:Z $(CONTAINER_IMAGE_NAME) ############################## # HELP diff --git a/internal/services/input.go b/internal/services/input.go index 1b9dd2d..507b304 100644 --- a/internal/services/input.go +++ b/internal/services/input.go @@ -58,6 +58,7 @@ type InputService struct { ActionHelp *InputAction ActionBack *InputAction ActionQuit *InputAction + ActionToggleSelection *InputAction } var NewInputService = func(appService *AppService, brewService BrewServiceInterface) InputServiceInterface { @@ -124,13 +125,17 @@ var NewInputService = func(appService *AppService, brewService BrewServiceInterf Key: tcell.KeyRune, Rune: 'q', KeySlug: "q", Name: "Quit", Action: s.handleQuitEvent, HideFromLegend: true, } + s.ActionToggleSelection = &InputAction{ + Key: tcell.KeyRune, Rune: ' ', KeySlug: "space", Name: "Select", + Action: s.handleToggleSelectionEvent, 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, + s.ActionHelp, s.ActionBack, s.ActionQuit, s.ActionToggleSelection, } // Convert keyActions to legend entries @@ -169,6 +174,12 @@ func (s *InputService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKe return event } + // Handle Space explicitly since it might conflict or need special handling + if event.Key() == tcell.KeyRune && event.Rune() == ' ' { + s.handleToggleSelectionEvent() + return nil + } + for _, input := range s.keyActions { if event.Modifiers() == tcell.ModNone && input.Key == event.Key() && input.Rune == event.Rune() { // Check Rune if input.Action != nil { @@ -188,8 +199,24 @@ func (s *InputService) HandleKeyEventInput(event *tcell.EventKey) *tcell.EventKe // handleBack is called when the user presses the back key (Esc). func (s *InputService) handleBack() { + s.layout.GetTable().ClearSelection() s.appService.GetApp().SetRoot(s.layout.Root(), true) s.appService.GetApp().SetFocus(s.layout.GetTable().View()) + // Force redraw of table to remove selection visuals + // s.appService.forceRefreshResults() // Might be too heavy? + // Actually Table.ToggleSelection updates visual. + // ClearSelection needs to update visual too. + // But Table.ClearSelection just clears the map. I need to implement visual clear in Table or just force refresh. + // For now, let's just assume we need to refresh. + s.appService.search(s.layout.GetSearch().Field().GetText(), false) +} + +// handleToggleSelectionEvent toggles the selection of the current row. +func (s *InputService) handleToggleSelectionEvent() { + row, _ := s.layout.GetTable().View().GetSelection() + if row > 0 { // Skip header + s.layout.GetTable().ToggleSelection(row) + } } // handleSearchFieldEvent is called when the user presses the search key (/). @@ -306,6 +333,13 @@ func (s *InputService) closeModal() { // handleInstallPackageEvent is called when the user presses the installation key (i). func (s *InputService) handleInstallPackageEvent() { + if len(s.layout.GetTable().GetSelectedRows()) > 0 { + s.processSelectedPackages("install", "INSTALL", func(pkg models.Package) error { + return s.brewService.InstallPackage(pkg, s.appService.app, s.layout.GetOutput().View()) + }) + return + } + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.appService.filteredPackages)[row-1] @@ -329,6 +363,13 @@ func (s *InputService) handleInstallPackageEvent() { // handleRemovePackageEvent is called when the user presses the removal key (r). func (s *InputService) handleRemovePackageEvent() { + if len(s.layout.GetTable().GetSelectedRows()) > 0 { + s.processSelectedPackages("remove", "REMOVE", func(pkg models.Package) error { + return s.brewService.RemovePackage(pkg, s.appService.app, s.layout.GetOutput().View()) + }) + return + } + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.appService.filteredPackages)[row-1] @@ -352,6 +393,13 @@ func (s *InputService) handleRemovePackageEvent() { // handleUpdatePackageEvent is called when the user presses the update key (u). func (s *InputService) handleUpdatePackageEvent() { + if len(s.layout.GetTable().GetSelectedRows()) > 0 { + s.processSelectedPackages("update", "UPDATE", func(pkg models.Package) error { + return s.brewService.UpdatePackage(pkg, s.appService.app, s.layout.GetOutput().View()) + }) + return + } + row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { info := (*s.appService.filteredPackages)[row-1] @@ -399,6 +447,53 @@ type batchOperation struct { execute func(pkg models.Package) error } +// processSelectedPackages processes the selected packages from the table. +func (s *InputService) processSelectedPackages(verb, tag string, action func(models.Package) error) { + selectedRows := s.layout.GetTable().GetSelectedRows() + if len(selectedRows) == 0 { + return + } + + packages := make([]models.Package, 0, len(selectedRows)) + for _, row := range selectedRows { + if row > 0 && row-1 < len(*s.appService.filteredPackages) { + packages = append(packages, (*s.appService.filteredPackages)[row-1]) + } + } + + if len(packages) == 0 { + return + } + + s.showModal(fmt.Sprintf("Are you sure you want to %s %d selected packages?", verb, len(packages)), func() { + s.closeModal() + s.layout.GetOutput().Clear() + go func() { + total := len(packages) + for i, pkg := range packages { + s.layout.GetNotifier().ShowWarning(fmt.Sprintf("[%d/%d] %s %s...", i+1, total, verb, pkg.Name)) + s.appService.app.QueueUpdateDraw(func() { + fmt.Fprintf(s.layout.GetOutput().View(), "\n[%s] %s %s...\n", tag, verb, pkg.Name) + }) + + if err := action(pkg); err != nil { + s.layout.GetNotifier().ShowError(fmt.Sprintf("Failed to %s %s", verb, pkg.Name)) + s.appService.app.QueueUpdateDraw(func() { + fmt.Fprintf(s.layout.GetOutput().View(), "[ERROR] Failed to %s %s: %v\n", verb, pkg.Name, err) + }) + continue + } + s.appService.app.QueueUpdateDraw(func() { + fmt.Fprintf(s.layout.GetOutput().View(), "[SUCCESS] %s processed successfully\n", pkg.Name) + }) + } + s.layout.GetNotifier().ShowSuccess(fmt.Sprintf("Completed! Processed %d packages", total)) + s.layout.GetTable().ClearSelection() // Clear selection after batch operation + s.appService.forceRefreshResults() + }() + }, s.closeModal) +} + // handleBatchPackageOperation processes multiple packages with progress notifications. func (s *InputService) handleBatchPackageOperation(op batchOperation) { if !s.appService.IsBrewfileMode() { diff --git a/internal/ui/components/table.go b/internal/ui/components/table.go index 1e1d0e7..237cba2 100644 --- a/internal/ui/components/table.go +++ b/internal/ui/components/table.go @@ -8,14 +8,16 @@ import ( ) type Table struct { - view *tview.Table - theme *theme.Theme + view *tview.Table + theme *theme.Theme + selectedRows map[int]bool } func NewTable(theme *theme.Theme) *Table { table := &Table{ - view: tview.NewTable(), - theme: theme, + view: tview.NewTable(), + theme: theme, + selectedRows: make(map[int]bool), } table.view.SetBorders(false) table.view.SetSelectable(true, false) @@ -37,6 +39,46 @@ func (t *Table) View() *tview.Table { func (t *Table) Clear() { t.view.Clear() + t.selectedRows = make(map[int]bool) +} + +func (t *Table) ClearSelection() { + t.selectedRows = make(map[int]bool) +} + +func (t *Table) ToggleSelection(row int) { + isSelected := false + if t.selectedRows[row] { + delete(t.selectedRows, row) + } else { + t.selectedRows[row] = true + isSelected = true + } + + // Update visual style for the row + colCount := t.view.GetColumnCount() + for i := 0; i < colCount; i++ { + cell := t.view.GetCell(row, i) + if cell != nil { + if isSelected { + cell.SetBackgroundColor(tcell.ColorDarkCyan) + } else { + cell.SetBackgroundColor(t.theme.DefaultBgColor) // Or tcell.ColorDefault + } + } + } +} + +func (t *Table) IsSelected(row int) bool { + return t.selectedRows[row] +} + +func (t *Table) GetSelectedRows() []int { + rows := make([]int, 0, len(t.selectedRows)) + for row := range t.selectedRows { + rows = append(rows, row) + } + return rows } func (t *Table) SetTableHeaders(headers ...string) { From 0fcc9ea00bf8c033ecc80a405b010116c9bfb565 Mon Sep 17 00:00:00 2001 From: James Reilly Date: Tue, 13 Jan 2026 09:37:40 -0600 Subject: [PATCH 2/2] feat: complete multi-select implementation --- internal/services/input.go | 11 ++++++++++- internal/ui/components/table.go | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/services/input.go b/internal/services/input.go index 507b304..f067e06 100644 --- a/internal/services/input.go +++ b/internal/services/input.go @@ -215,7 +215,16 @@ func (s *InputService) handleBack() { func (s *InputService) handleToggleSelectionEvent() { row, _ := s.layout.GetTable().View().GetSelection() if row > 0 { // Skip header - s.layout.GetTable().ToggleSelection(row) + // Determine highlight color based on package status + color := tcell.ColorDarkCyan + if row-1 < len(*s.appService.filteredPackages) { + pkg := (*s.appService.filteredPackages)[row-1] + if pkg.LocallyInstalled { + color = tcell.ColorDarkRed // Use DarkRed for installed packages to indicate different state + } + } + + s.layout.GetTable().ToggleSelection(row, color) } } diff --git a/internal/ui/components/table.go b/internal/ui/components/table.go index 237cba2..646d99d 100644 --- a/internal/ui/components/table.go +++ b/internal/ui/components/table.go @@ -46,7 +46,7 @@ func (t *Table) ClearSelection() { t.selectedRows = make(map[int]bool) } -func (t *Table) ToggleSelection(row int) { +func (t *Table) ToggleSelection(row int, highlightColor tcell.Color) { isSelected := false if t.selectedRows[row] { delete(t.selectedRows, row) @@ -61,7 +61,7 @@ func (t *Table) ToggleSelection(row int) { cell := t.view.GetCell(row, i) if cell != nil { if isSelected { - cell.SetBackgroundColor(tcell.ColorDarkCyan) + cell.SetBackgroundColor(highlightColor) } else { cell.SetBackgroundColor(t.theme.DefaultBgColor) // Or tcell.ColorDefault }