Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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
BUILD_GOOS=linux
BUILD_GOARCH=amd64
Comment on lines +5 to +6
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes to BUILD_GOOS and BUILD_GOARCH appear unrelated to the multi-select feature. These settings change the build target from macOS ARM64 (darwin/arm64) to Linux AMD64 (linux/amd64). If this change is intentional, it should be documented in the PR description or split into a separate commit. If unintentional, it should be reverted to the original values.

Suggested change
BUILD_GOOS=linux
BUILD_GOARCH=amd64
BUILD_GOOS=darwin
BUILD_GOARCH=arm64

Copilot uses AI. Check for mistakes.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 105 additions & 1 deletion internal/services/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type InputService struct {
ActionHelp *InputAction
ActionBack *InputAction
ActionQuit *InputAction
ActionToggleSelection *InputAction
}

var NewInputService = func(appService *AppService, brewService BrewServiceInterface) InputServiceInterface {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spacebar key is handled explicitly before the general key action loop (lines 177-181), but it will also match in the loop (lines 183-195) if ActionToggleSelection is in keyActions. This creates redundant handling. Consider either removing the explicit handling at lines 177-181 or ensuring ActionToggleSelection is not added to keyActions (it's currently added at line 138 but marked with HideFromLegend=true).

Suggested change
for _, input := range s.keyActions {
for _, input := range s.keyActions {
// Skip ActionToggleSelection here because space is already handled explicitly above.
if input == s.ActionToggleSelection {
continue
}

Copilot uses AI. Check for mistakes.
if event.Modifiers() == tcell.ModNone && input.Key == event.Key() && input.Rune == event.Rune() { // Check Rune
if input.Action != nil {
Expand All @@ -188,8 +199,33 @@ 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.
Comment on lines +205 to +210
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove commented code and TODO comments. This appears to be debugging/development notes that should be cleaned up before merging. The actual implementation at line 211 (s.appService.search(...)) addresses the visual refresh concern mentioned in the comments.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
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
// 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)
}
}

// handleSearchFieldEvent is called when the user presses the search key (/).
Expand Down Expand Up @@ -306,6 +342,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]
Expand All @@ -329,6 +372,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]
Expand All @@ -352,6 +402,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]
Expand Down Expand Up @@ -399,6 +456,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() {
Expand Down
50 changes: 46 additions & 4 deletions internal/ui/components/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,16 @@ import (
)

type Table struct {
view *tview.Table
theme *theme.Theme
view *tview.Table
theme *theme.Theme
selectedRows map[int]bool
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selectedRows map is accessed without synchronization from both the UI thread (in ToggleSelection, GetSelectedRows) and from goroutines (ClearSelection is called from a goroutine at line 500). This creates a potential race condition. Consider using a sync.RWMutex to protect concurrent access to the selectedRows map, or ensure ClearSelection is called on the main thread using QueueUpdateDraw.

Copilot uses AI. Check for mistakes.
}

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)
Expand All @@ -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() {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ClearSelection method only resets the selectedRows map but doesn't update the visual state of the table cells. This means previously selected rows will still appear highlighted until the table is redrawn. Consider iterating through the current selectedRows before clearing and resetting their background colors, or update the method to accept a theme parameter and properly reset cell backgrounds.

Suggested change
func (t *Table) ClearSelection() {
func (t *Table) ClearSelection() {
// Reset visual state for all currently selected rows
for row := range t.selectedRows {
colCount := t.view.GetColumnCount()
for col := 0; col < colCount; col++ {
cell := t.view.GetCell(row, col)
if cell != nil {
cell.SetBackgroundColor(t.theme.DefaultBgColor)
}
}
}
// Clear logical selection state

Copilot uses AI. Check for mistakes.
t.selectedRows = make(map[int]bool)
}

func (t *Table) ToggleSelection(row int, highlightColor tcell.Color) {
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(highlightColor)
} 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
}
Comment on lines +76 to 82
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GetSelectedRows method returns rows in non-deterministic order since it iterates over a map. This could lead to unpredictable behavior when processing selected packages. Consider sorting the returned slice to ensure consistent ordering (e.g., ascending by row number).

Copilot uses AI. Check for mistakes.

func (t *Table) SetTableHeaders(headers ...string) {
Expand Down
Loading