Skip to content

Commit aeee3b9

Browse files
authored
feat: add Brewfile mode for curated package collections (#37)
* feat: add Brewfile mode for curated package collections This commit introduces Brewfile mode, allowing users to launch bbrew with a curated list of packages using the -f flag. When a Brewfile is provided, the application displays only those packages, enabling themed collections like IDE choosers or developer tool sets. The implementation includes a Brewfile parser for brew and cask entries, automatic filtering of the package catalog, and a refactored API with the IsBrewfileMode() method for cleaner code. Critical bugs were fixed including a synchronization issue between the displayed table and the filtered packages array that caused incorrect package selection. This feature is designed for Project Bluefin integration, providing curated package experiences where users can browse predefined collections. Includes example Brewfiles and comprehensive documentation. * feat: add Install All functionality for Brewfile mode Add ctrl+a keybinding to install all packages from a Brewfile at once, available exclusively in Brewfile mode. * feat: add Remove All for Brewfile mode Add ctrl+r keybinding to batch remove all installed packages from Brewfile with real-time progress counter. Validates packages are installed before proceeding and skips non-installed packages. Available only in Brewfile mode
1 parent e22b82c commit aeee3b9

File tree

7 files changed

+502
-35
lines changed

7 files changed

+502
-35
lines changed

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Bold Brew is the **official Terminal UI** for managing Homebrew in [**Project Bl
4343

4444
- 🚀 **Modern TUI Interface** - Clean and responsive terminal user interface
4545
- 📦 **Complete Package Management** - Manage both Homebrew formulae and casks
46+
- 📋 **Brewfile Mode** - Curated package collections from Brewfiles (perfect for themed installers)
4647
- 🔍 **Advanced Search** - Fast fuzzy search across all packages
4748
- 🎯 **Smart Filters** - Filter by installed, outdated, leaves, or casks
4849
- 📊 **Analytics Integration** - See popular packages based on 90-day download stats
@@ -64,11 +65,27 @@ Download the latest version from the [releases page](https://github.com/Valkyrie
6465

6566
## 📖 Quick Start
6667

67-
Launch the application:
68+
### Standard Mode
69+
Launch the application to browse all Homebrew packages:
6870
```sh
6971
bbrew
7072
```
7173

74+
### Brewfile Mode (New!)
75+
Launch with a curated Brewfile to show only specific packages:
76+
```sh
77+
bbrew -f /path/to/Brewfile
78+
```
79+
80+
In Brewfile mode, you can:
81+
- View only packages from the Brewfile
82+
- Pick and choose what to install individually
83+
- Use all standard features (search, filters, etc.)
84+
85+
Perfect for creating themed collections like IDE choosers, dev tools, AI tools, K8s tools, etc.
86+
87+
See the `examples/` directory for ready-to-use Brewfiles.
88+
7289
### Keyboard Shortcuts
7390

7491
#### Navigation & Search

cmd/bbrew/main.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,43 @@ package main
22

33
import (
44
"bbrew/internal/services"
5+
"flag"
6+
"fmt"
57
"log"
8+
"os"
69
)
710

811
func main() {
12+
// Parse command line flags
13+
brewfilePath := flag.String("f", "", "Path to Brewfile (show only packages from this Brewfile)")
14+
flag.Parse()
15+
16+
// Validate Brewfile path if provided
17+
if *brewfilePath != "" {
18+
if _, err := os.Stat(*brewfilePath); os.IsNotExist(err) {
19+
fmt.Fprintf(os.Stderr, "Error: Brewfile not found: %s\n", *brewfilePath)
20+
os.Exit(1)
21+
} else if err != nil {
22+
fmt.Fprintf(os.Stderr, "Error: Cannot access Brewfile: %v\n", err)
23+
os.Exit(1)
24+
}
25+
}
26+
27+
// Initialize app service
928
appService := services.NewAppService()
29+
// Configure Brewfile mode if path was provided
30+
if *brewfilePath != "" {
31+
appService.SetBrewfilePath(*brewfilePath)
32+
}
33+
34+
// Boot the application (load Homebrew data)
1035
if err := appService.Boot(); err != nil {
11-
log.Fatalf("Error initializing data: %v", err)
36+
log.Fatalf("Failed to initialize: %v", err)
1237
}
1338

39+
// Build and run the TUI
1440
appService.BuildApp()
15-
1641
if err := appService.GetApp().Run(); err != nil {
17-
log.Fatalf("Error running app: %v", err)
42+
log.Fatalf("Application error: %v", err)
1843
}
1944
}

examples/test.brewfile

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Test Brewfile for bbrew
2+
# A simple example to test Brewfile mode functionality
3+
# Usage: bbrew -f test.brewfile
4+
5+
# Command-line utilities (lightweight and common)
6+
brew "wget"
7+
brew "curl"
8+
brew "tree"
9+
brew "htop"
10+
brew "jq"
11+
12+
# Development tools
13+
brew "git"
14+
brew "node"
15+
16+
# Popular editors and IDEs
17+
cask "visual-studio-code"
18+
cask "sublime-text"
19+
20+
# Alternative browsers (for testing)
21+
cask "firefox"
22+
cask "brave-browser"
23+

internal/models/brewfile.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package models
2+
3+
// BrewfileEntry represents a single entry from a Brewfile
4+
type BrewfileEntry struct {
5+
Name string
6+
IsCask bool
7+
}

internal/services/app.go

Lines changed: 104 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type AppServiceInterface interface {
2424
GetLayout() ui.LayoutInterface
2525
Boot() (err error)
2626
BuildApp()
27+
SetBrewfilePath(path string)
28+
IsBrewfileMode() bool
29+
GetBrewfilePackages() *[]models.Package
2730
}
2831

2932
// AppService manages the application state, Homebrew integration, and UI components.
@@ -40,6 +43,10 @@ type AppService struct {
4043
showOnlyCasks bool
4144
brewVersion string
4245

46+
// Brewfile support
47+
brewfilePath string
48+
brewfilePackages *[]models.Package
49+
4350
brewService BrewServiceInterface
4451
selfUpdateService SelfUpdateServiceInterface
4552
ioService IOServiceInterface
@@ -63,6 +70,9 @@ var NewAppService = func() AppServiceInterface {
6370
showOnlyLeaves: false,
6471
showOnlyCasks: false,
6572
brewVersion: "-",
73+
74+
brewfilePath: "",
75+
brewfilePackages: new([]models.Package),
6676
}
6777

6878
// Initialize services
@@ -73,8 +83,11 @@ var NewAppService = func() AppServiceInterface {
7383
return s
7484
}
7585

76-
func (s *AppService) GetApp() *tview.Application { return s.app }
77-
func (s *AppService) GetLayout() ui.LayoutInterface { return s.layout }
86+
func (s *AppService) GetApp() *tview.Application { return s.app }
87+
func (s *AppService) GetLayout() ui.LayoutInterface { return s.layout }
88+
func (s *AppService) SetBrewfilePath(path string) { s.brewfilePath = path }
89+
func (s *AppService) IsBrewfileMode() bool { return s.brewfilePath != "" }
90+
func (s *AppService) GetBrewfilePackages() *[]models.Package { return s.brewfilePackages }
7891

7992
// Boot initializes the application by setting up Homebrew and loading formulae data.
8093
func (s *AppService) Boot() (err error) {
@@ -91,6 +104,42 @@ func (s *AppService) Boot() (err error) {
91104
// Initialize packages and filteredPackages
92105
s.packages = s.brewService.GetPackages()
93106
*s.filteredPackages = *s.packages
107+
108+
// If Brewfile is specified, parse it and filter packages
109+
if s.IsBrewfileMode() {
110+
if err = s.loadBrewfilePackages(); err != nil {
111+
return fmt.Errorf("failed to load Brewfile: %v", err)
112+
}
113+
}
114+
115+
return nil
116+
}
117+
118+
// loadBrewfilePackages parses the Brewfile and creates a filtered package list
119+
func (s *AppService) loadBrewfilePackages() error {
120+
entries, err := s.brewService.ParseBrewfile(s.brewfilePath)
121+
if err != nil {
122+
return err
123+
}
124+
125+
// Create a map for quick lookup
126+
packageMap := make(map[string]models.PackageType)
127+
for _, entry := range entries {
128+
if entry.IsCask {
129+
packageMap[entry.Name] = models.PackageTypeCask
130+
} else {
131+
packageMap[entry.Name] = models.PackageTypeFormula
132+
}
133+
}
134+
135+
// Filter packages to only include those in the Brewfile
136+
*s.brewfilePackages = []models.Package{}
137+
for _, pkg := range *s.packages {
138+
if pkgType, exists := packageMap[pkg.Name]; exists && pkgType == pkg.Type {
139+
*s.brewfilePackages = append(*s.brewfilePackages, pkg)
140+
}
141+
}
142+
94143
return nil
95144
}
96145

@@ -112,41 +161,51 @@ func (s *AppService) search(searchText string, scrollToTop bool) {
112161
uniquePackages := make(map[string]bool)
113162

114163
// Determine the source list based on the current filter state
164+
// If Brewfile mode is active, use brewfilePackages as the base source
115165
sourceList := s.packages
166+
if s.IsBrewfileMode() {
167+
sourceList = s.brewfilePackages
168+
}
169+
170+
// Apply filters on the base source list (either all packages or Brewfile packages)
116171
if s.showOnlyInstalled && !s.showOnlyOutdated {
117-
sourceList = &[]models.Package{}
118-
for _, info := range *s.packages {
172+
filteredSource := &[]models.Package{}
173+
for _, info := range *sourceList {
119174
if info.LocallyInstalled {
120-
*sourceList = append(*sourceList, info)
175+
*filteredSource = append(*filteredSource, info)
121176
}
122177
}
178+
sourceList = filteredSource
123179
}
124180

125181
if s.showOnlyOutdated {
126-
sourceList = &[]models.Package{}
127-
for _, info := range *s.packages {
182+
filteredSource := &[]models.Package{}
183+
for _, info := range *sourceList {
128184
if info.LocallyInstalled && info.Outdated {
129-
*sourceList = append(*sourceList, info)
185+
*filteredSource = append(*filteredSource, info)
130186
}
131187
}
188+
sourceList = filteredSource
132189
}
133190

134191
if s.showOnlyLeaves {
135-
sourceList = &[]models.Package{}
136-
for _, info := range *s.packages {
192+
filteredSource := &[]models.Package{}
193+
for _, info := range *sourceList {
137194
if info.LocallyInstalled && info.InstalledOnRequest {
138-
*sourceList = append(*sourceList, info)
195+
*filteredSource = append(*filteredSource, info)
139196
}
140197
}
198+
sourceList = filteredSource
141199
}
142200

143201
if s.showOnlyCasks {
144-
sourceList = &[]models.Package{}
145-
for _, info := range *s.packages {
202+
filteredSource := &[]models.Package{}
203+
for _, info := range *sourceList {
146204
if info.Type == models.PackageTypeCask {
147-
*sourceList = append(*sourceList, info)
205+
*filteredSource = append(*filteredSource, info)
148206
}
149207
}
208+
sourceList = filteredSource
150209
}
151210

152211
if searchText == "" {
@@ -185,7 +244,14 @@ func (s *AppService) search(searchText string, scrollToTop bool) {
185244
func (s *AppService) forceRefreshResults() {
186245
_ = s.brewService.SetupData(true)
187246
s.packages = s.brewService.GetPackages()
188-
*s.filteredPackages = *s.packages
247+
248+
// If in Brewfile mode, reload the filtered packages
249+
if s.IsBrewfileMode() {
250+
_ = s.loadBrewfilePackages()
251+
*s.filteredPackages = *s.brewfilePackages
252+
} else {
253+
*s.filteredPackages = *s.packages
254+
}
189255

190256
s.app.QueueUpdateDraw(func() {
191257
s.search(s.layout.GetSearch().Field().GetText(), false)
@@ -251,7 +317,15 @@ func (s *AppService) setResults(data *[]models.Package, scrollToTop bool) {
251317
func (s *AppService) BuildApp() {
252318
// Build the layout
253319
s.layout.Setup()
254-
s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion)
320+
321+
// Update header and enable Brewfile mode features if needed
322+
headerName := AppName
323+
if s.IsBrewfileMode() {
324+
headerName = fmt.Sprintf("%s [Brewfile Mode]", AppName)
325+
s.layout.GetSearch().Field().SetLabel("Search (Brewfile): ")
326+
s.ioService.EnableBrewfileMode() // Add Install All action
327+
}
328+
s.layout.GetHeader().Update(headerName, AppVersion, s.brewVersion)
255329

256330
// Evaluate if there is a new version available
257331
// This is done in a goroutine to avoid blocking the UI during startup
@@ -264,7 +338,11 @@ func (s *AppService) BuildApp() {
264338
if latestVersion, err := s.selfUpdateService.CheckForUpdates(ctx); err == nil && latestVersion != AppVersion {
265339
s.app.QueueUpdateDraw(func() {
266340
AppVersion = fmt.Sprintf("%s ([orange]New Version Available: %s[-])", AppVersion, latestVersion)
267-
s.layout.GetHeader().Update(AppName, AppVersion, s.brewVersion)
341+
headerName := AppName
342+
if s.IsBrewfileMode() {
343+
headerName = fmt.Sprintf("%s [Brewfile Mode]", AppName)
344+
}
345+
s.layout.GetHeader().Update(headerName, AppVersion, s.brewVersion)
268346
})
269347
}
270348
}()
@@ -295,6 +373,13 @@ func (s *AppService) BuildApp() {
295373
s.app.SetRoot(s.layout.Root(), true)
296374
s.app.SetFocus(s.layout.GetTable().View())
297375

298-
go s.updateHomeBrew() // Update Async the Homebrew formulae
299-
s.setResults(s.packages, true) // Set the results
376+
go s.updateHomeBrew() // Update Async the Homebrew formulae
377+
378+
// Set initial results based on mode
379+
if s.IsBrewfileMode() {
380+
*s.filteredPackages = *s.brewfilePackages // Sync filteredPackages
381+
s.setResults(s.brewfilePackages, true) // Show only Brewfile packages
382+
} else {
383+
s.setResults(s.packages, true) // Show all packages
384+
}
300385
}

0 commit comments

Comments
 (0)