From 6c8058543141c5854df899582d9e870cbab75eca Mon Sep 17 00:00:00 2001 From: Vito Castellano Date: Mon, 13 Oct 2025 21:26:18 +0200 Subject: [PATCH] chore: release version 2.0.0 - Cask support and XDG compliance (#30) * feat: add leaves filter to show explicitly installed packages (#25) Add new filter [L] to display only "leaf" packages - those installed explicitly by the user and not as dependencies of other packages. * refactor: Migrate to Podman with OCI Containerfile and enhanced Makefile (#26) * refactor: migrate from Docker to Podman with OCI Containerfile Replace Docker with Podman for better security and OCI compliance. Switch from Dockerfile to standard Containerfile format. * chore: upgrade Go from 1.24 to 1.25 Update Go version to 1.25 to support latest goreleaser v2 and benefit from improved performance and language features. * refactor: migrate to Podman and enhance Makefile Replace Docker with Podman and upgrade Makefile with help system and new developer-friendly targets. * chore: upgrade to Go 1.25 and golangci-lint v2.5.0 Update Go to 1.25 and golangci-lint to v2.5.0 for better tooling support. * feat: add security scanning with govulncheck and gosec (#27) Add comprehensive security scanning to the project with vulnerability checks and static analysis tools. * feat: Add complete Casks support with unified UI (#28) * feat(cask): add backend support for Homebrew casks Implement complete backend infrastructure for managing Homebrew casks alongside formulae, preparing for unified UI. * feat(cask): add complete Homebrew casks support with unified UI Implement full backend and UI support for managing Homebrew casks alongside formulae in a unified interface. * fix(cask): parse cask analytics correctly Fix cask analytics not being displayed (showing 0 for all casks). * feat(cask): add complete Homebrew casks support with unified UI Implement full backend and UI support for managing Homebrew casks alongside formulae in a unified interface. * fix: create copy to avoid implicit memory aliasing * feat: implement XDG Base Directory Specification with github.com/adrg/xdg (#29) Implement XDG Base Directory Specification using the github.com/adrg/xdg package for robust cross-platform support. --- .env | 8 +- .github/workflows/quality.yml | 6 +- .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 53 ++++++ Containerfile | 11 ++ Dockerfile | 7 - Makefile | 140 +++++++++++--- go.mod | 9 +- go.sum | 25 +-- internal/models/cask.go | 31 +++ internal/models/formula.go | 3 +- internal/models/package.go | 79 ++++++++ internal/services/app.go | 75 +++++--- internal/services/brew.go | 301 +++++++++++++++++++++++++++--- internal/services/io.go | 53 +++++- internal/ui/components/details.go | 141 +++++++------- 16 files changed, 760 insertions(+), 184 deletions(-) create mode 100644 .github/workflows/security.yml create mode 100644 Containerfile delete mode 100644 Dockerfile create mode 100644 internal/models/cask.go create mode 100644 internal/models/package.go diff --git a/.env b/.env index 41a28d3..c1956e0 100644 --- a/.env +++ b/.env @@ -1,10 +1,6 @@ APP_NAME=bbrew APP_VERSION=0.0.1-local - -### Docker -DOCKER_IMAGE_NAME=bbrew - -### Build -BUILD_GOVERSION=1.20 +CONTAINER_IMAGE_NAME=bbrew +BUILD_GOVERSION=1.25 BUILD_GOOS=darwin BUILD_GOARCH=arm64 \ No newline at end of file diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b9850e0..0eb4189 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,11 +16,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.25' - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: - version: v2.1 + version: v2.5.0 skip-cache: true build: @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.25' - name: Get dependencies run: go mod download - name: Build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a0995..93e9135 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: stable + go-version: '1.25' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..a7b94d0 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,53 @@ +name: Security + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + govulncheck: + name: Go Vulnerability Check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Run govulncheck + run: govulncheck ./... + + gosec: + name: Security Scanner + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out results.sarif ./...' + + - name: Upload SARIF file + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..a1ae9a6 --- /dev/null +++ b/Containerfile @@ -0,0 +1,11 @@ +FROM golang:1.25 + +# Install dependencies +RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.5.0 +RUN go install github.com/goreleaser/goreleaser/v2@latest + +# Install security tools +RUN go install golang.org/x/vuln/cmd/govulncheck@latest +RUN go install github.com/securego/gosec/v2/cmd/gosec@latest + +WORKDIR /app \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 09b3600..0000000 --- a/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM golang:1.24 - -# Install dependencies -RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6 -RUN go install github.com/goreleaser/goreleaser/v2@latest - -WORKDIR /app \ No newline at end of file diff --git a/Makefile b/Makefile index 89eda1a..81e3147 100644 --- a/Makefile +++ b/Makefile @@ -1,62 +1,148 @@ ############################## # VARIABLES ############################## -ifneq (,$(wildcard ./.env)) - include .env - export -endif -%:@ +# Load .env if exists (loaded first so defaults can override if not set) +-include .env + +# Default values (can be overridden by .env or command line) +APP_NAME ?= bbrew +APP_VERSION ?= 0.0.1-local +CONTAINER_IMAGE_NAME ?= bbrew +BUILD_GOVERSION ?= 1.25 +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) ############################## -# DOCKER +# HELP ############################## -.PHONY: docker-build-image -docker-build-image: - @docker build -t $(DOCKER_IMAGE_NAME) . +.PHONY: help +help: ## Show this help message + @echo "Usage: make [target]" + @echo "" + @echo "Available targets:" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-25s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | sort -docker-build-force-recreate: - @docker build --no-cache -t $(DOCKER_IMAGE_NAME) . +.DEFAULT_GOAL := help + +############################## +# CONTAINER +############################## +.PHONY: container-build-image +container-build-image: ## Build container image + @podman build -f Containerfile -t $(CONTAINER_IMAGE_NAME) . + +.PHONY: container-build-force +container-build-force: ## Force rebuild container image (no cache) + @podman build --no-cache -f Containerfile -t $(CONTAINER_IMAGE_NAME) . + +.PHONY: container-clean +container-clean: ## Remove container image + @podman rmi $(CONTAINER_IMAGE_NAME) 2>/dev/null || true ############################## # RELEASE ############################## .PHONY: release-snapshot -release-snapshot: docker-build-image # Builds the project in snapshot mode and releases it [This is used for testing releases] - @docker run --rm -v $(PWD):/app $(DOCKER_IMAGE_NAME) goreleaser release --snapshot --clean +release-snapshot: container-build-image ## Build and release snapshot (testing) + @$(CONTAINER_RUN) goreleaser release --snapshot --clean -.PHONY: build-snapshot # Builds the project in snapshot mode [This is used for testing releases] -build-snapshot: docker-build-image - @docker run --rm -v $(PWD):/app $(DOCKER_IMAGE_NAME) goreleaser build --snapshot --clean +.PHONY: build-snapshot +build-snapshot: container-build-image ## Build snapshot without release + @$(CONTAINER_RUN) goreleaser build --snapshot --clean ############################## # BUILD ############################## .PHONY: build -build: docker-build-image - @docker run --rm -v $(PWD):/app $(DOCKER_IMAGE_NAME) \ - env GOOS=$(BUILD_GOOS) GOARCH=$(BUILD_GOARCH) go build -o $(APP_NAME) ./cmd/$(APP_NAME) +build: container-build-image ## Build the application binary + @$(CONTAINER_RUN) env GOOS=$(BUILD_GOOS) GOARCH=$(BUILD_GOARCH) \ + go build -o $(APP_NAME) ./cmd/$(APP_NAME) + +.PHONY: build-local +build-local: ## Build locally without container (requires Go installed) + @go build -o $(APP_NAME) ./cmd/$(APP_NAME) .PHONY: run -run: build - ./$(APP_NAME) +run: build ## Build and run the application + @./$(APP_NAME) + +.PHONY: clean +clean: ## Clean build artifacts + @rm -f $(APP_NAME) + @rm -rf dist/ ############################## -# HELPER +# QUALITY ############################## .PHONY: quality -quality: docker-build-image - @docker run --rm -v $(PWD):/app $(DOCKER_IMAGE_NAME) golangci-lint run +quality: container-build-image ## Run linter checks + @$(CONTAINER_RUN) golangci-lint run + +.PHONY: quality-local +quality-local: ## Run linter locally (requires golangci-lint installed) + @golangci-lint run + +.PHONY: test +test: ## Run tests + @go test -v ./... + +.PHONY: test-coverage +test-coverage: ## Run tests with coverage + @go test -v -coverprofile=coverage.out ./... + @go tool cover -html=coverage.out -o coverage.html + +############################## +# SECURITY +############################## +.PHONY: security +security: security-vuln security-scan ## Run all security checks + +.PHONY: security-vuln +security-vuln: container-build-image ## Check for known vulnerabilities + @$(CONTAINER_RUN) govulncheck ./... + +.PHONY: security-vuln-local +security-vuln-local: ## Check vulnerabilities locally (requires govulncheck) + @govulncheck ./... + +.PHONY: security-scan +security-scan: container-build-image ## Run security scanner + @$(CONTAINER_RUN) gosec ./... + +.PHONY: security-scan-local +security-scan-local: ## Run security scanner locally (requires gosec) + @gosec ./... ############################## # WEBSITE ############################## .PHONY: build-site -build-site: +build-site: ## Build the static website @node build.js .PHONY: serve-site -serve-site: +serve-site: ## Serve the website locally @npx http-server docs -p 3000 .PHONY: dev-site -dev-site: build-site serve-site \ No newline at end of file +dev-site: build-site serve-site ## Build and serve the website + +############################## +# UTILITY +############################## +.PHONY: install +install: build-local ## Install binary to $GOPATH/bin + @go install ./cmd/$(APP_NAME) + +.PHONY: deps +deps: ## Download and tidy dependencies + @go mod download + @go mod tidy + +.PHONY: deps-update +deps-update: ## Update all dependencies + @go get -u ./... + @go mod tidy diff --git a/go.mod b/go.mod index 917f030..d597923 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,19 @@ module bbrew -go 1.24 +go 1.25 require ( github.com/gdamore/tcell/v2 v2.8.1 github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb - golang.org/x/text v0.26.0 + golang.org/x/text v0.27.0 ) require ( + github.com/adrg/xdg v0.5.3 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/term v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index ece9794..7a97ee6 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,14 @@ -github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.7.1 h1:TiCcmpWHiAU7F0rA2I3S2Y4mmLmO9KHxJ7E1QhYzQbc= -github.com/gdamore/tcell/v2 v2.7.1/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE= -github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8= github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -55,36 +49,33 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/models/cask.go b/internal/models/cask.go new file mode 100644 index 0000000..7998c7f --- /dev/null +++ b/internal/models/cask.go @@ -0,0 +1,31 @@ +package models + +// Cask represents a Homebrew cask (GUI application). +type Cask struct { + Token string `json:"token"` + FullToken string `json:"full_token"` + OldTokens []string `json:"old_tokens"` + Tap string `json:"tap"` + Name []string `json:"name"` + Description string `json:"desc"` + Homepage string `json:"homepage"` + URL string `json:"url"` + Version string `json:"version"` + Installed *string `json:"installed"` // Null if not installed, version string if installed + InstalledTime *int64 `json:"installed_time"` // Unix timestamp + Outdated bool `json:"outdated"` + SHA256 string `json:"sha256"` + Deprecated bool `json:"deprecated"` + DeprecationDate interface{} `json:"deprecation_date"` + DeprecationReason interface{} `json:"deprecation_reason"` + Disabled bool `json:"disabled"` + DisableDate interface{} `json:"disable_date"` + DisableReason interface{} `json:"disable_reason"` + TapGitHead string `json:"tap_git_head"` + RubySourcePath string `json:"ruby_source_path"` + RubySourceChecksum RubySourceChecksum `json:"ruby_source_checksum"` + Analytics90dRank int // Internal: Populated from analytics + Analytics90dDownloads int // Internal: Populated from analytics + LocallyInstalled bool `json:"-"` // Internal flag + IsCask bool `json:"-"` // Internal flag to distinguish from formulae +} diff --git a/internal/models/formula.go b/internal/models/formula.go index da068ce..9ff57a6 100644 --- a/internal/models/formula.go +++ b/internal/models/formula.go @@ -67,7 +67,8 @@ type Analytics struct { type AnalyticsItem struct { Number int `json:"number"` - Formula string `json:"formula"` + Formula string `json:"formula"` // For formula analytics + Cask string `json:"cask"` // For cask analytics Count string `json:"count"` Percent string `json:"percent"` } diff --git a/internal/models/package.go b/internal/models/package.go new file mode 100644 index 0000000..61df0d2 --- /dev/null +++ b/internal/models/package.go @@ -0,0 +1,79 @@ +package models + +// PackageType distinguishes between formulae and casks. +type PackageType string + +const ( + PackageTypeFormula PackageType = "formula" + PackageTypeCask PackageType = "cask" +) + +// Package represents a unified view of both Formula and Cask for UI display. +type Package struct { + // Common fields + Name string // Formula.Name or Cask.Token + DisplayName string // Formula.FullName or Cask.Name[0] + Description string // desc + Homepage string // homepage + Version string // versions.stable or version + LocallyInstalled bool // Is installed locally + Outdated bool // Needs update + Type PackageType // formula or cask + Analytics90dRank int + Analytics90dDownloads int + + // Original data (for operations) + Formula *Formula `json:"-"` // nil if Type == cask + Cask *Cask `json:"-"` // nil if Type == formula + + // For leaves filter (only meaningful for formulae) + InstalledOnRequest bool +} + +// NewPackageFromFormula creates a Package from a Formula. +func NewPackageFromFormula(f *Formula) Package { + installedOnRequest := false + if len(f.Installed) > 0 { + installedOnRequest = f.Installed[0].InstalledOnRequest + } + + return Package{ + Name: f.Name, + DisplayName: f.FullName, + Description: f.Description, + Homepage: f.Homepage, + Version: f.Versions.Stable, + LocallyInstalled: f.LocallyInstalled, + Outdated: f.Outdated, + Type: PackageTypeFormula, + Analytics90dRank: f.Analytics90dRank, + Analytics90dDownloads: f.Analytics90dDownloads, + Formula: f, + Cask: nil, + InstalledOnRequest: installedOnRequest, + } +} + +// NewPackageFromCask creates a Package from a Cask. +func NewPackageFromCask(c *Cask) Package { + displayName := c.Token + if len(c.Name) > 0 { + displayName = c.Name[0] + } + + return Package{ + Name: c.Token, + DisplayName: displayName, + Description: c.Description, + Homepage: c.Homepage, + Version: c.Version, + LocallyInstalled: c.LocallyInstalled, + Outdated: c.Outdated, + Type: PackageTypeCask, + Analytics90dRank: c.Analytics90dRank, + Analytics90dDownloads: c.Analytics90dDownloads, + Formula: nil, + Cask: c, + InstalledOnRequest: true, // Casks are always explicitly installed + } +} diff --git a/internal/services/app.go b/internal/services/app.go index ce91d26..cc2e883 100644 --- a/internal/services/app.go +++ b/internal/services/app.go @@ -32,10 +32,12 @@ type AppService struct { theme *theme.Theme layout ui.LayoutInterface - packages *[]models.Formula - filteredPackages *[]models.Formula + packages *[]models.Package + filteredPackages *[]models.Package showOnlyInstalled bool showOnlyOutdated bool + showOnlyLeaves bool + showOnlyCasks bool brewVersion string brewService BrewServiceInterface @@ -54,10 +56,12 @@ var NewAppService = func() AppServiceInterface { theme: themeService, layout: layout, - packages: new([]models.Formula), - filteredPackages: new([]models.Formula), + packages: new([]models.Package), + filteredPackages: new([]models.Package), showOnlyInstalled: false, showOnlyOutdated: false, + showOnlyLeaves: false, + showOnlyCasks: false, brewVersion: "-", } @@ -85,7 +89,7 @@ func (s *AppService) Boot() (err error) { } // Initialize packages and filteredPackages - s.packages = s.brewService.GetFormulae() + s.packages = s.brewService.GetPackages() *s.filteredPackages = *s.packages return nil } @@ -104,13 +108,13 @@ func (s *AppService) updateHomeBrew() { // search filters the packages based on the search text and the current filter state. func (s *AppService) search(searchText string, scrollToTop bool) { - var filteredList []models.Formula + var filteredList []models.Package uniquePackages := make(map[string]bool) // Determine the source list based on the current filter state sourceList := s.packages if s.showOnlyInstalled && !s.showOnlyOutdated { - sourceList = &[]models.Formula{} + sourceList = &[]models.Package{} for _, info := range *s.packages { if info.LocallyInstalled { *sourceList = append(*sourceList, info) @@ -119,7 +123,7 @@ func (s *AppService) search(searchText string, scrollToTop bool) { } if s.showOnlyOutdated { - sourceList = &[]models.Formula{} + sourceList = &[]models.Package{} for _, info := range *s.packages { if info.LocallyInstalled && info.Outdated { *sourceList = append(*sourceList, info) @@ -127,6 +131,24 @@ func (s *AppService) search(searchText string, scrollToTop bool) { } } + if s.showOnlyLeaves { + sourceList = &[]models.Package{} + for _, info := range *s.packages { + if info.LocallyInstalled && info.InstalledOnRequest { + *sourceList = append(*sourceList, info) + } + } + } + + if s.showOnlyCasks { + sourceList = &[]models.Package{} + for _, info := range *s.packages { + if info.Type == models.PackageTypeCask { + *sourceList = append(*sourceList, info) + } + } + } + if searchText == "" { // Reset to the appropriate list when the search string is empty filteredList = *sourceList @@ -159,10 +181,10 @@ func (s *AppService) search(searchText string, scrollToTop bool) { s.setResults(s.filteredPackages, scrollToTop) } -// forceRefreshResults forces a refresh of the Homebrew formulae data and updates the results in the UI. +// forceRefreshResults forces a refresh of the Homebrew formulae and cask data and updates the results in the UI. func (s *AppService) forceRefreshResults() { _ = s.brewService.SetupData(true) - s.packages = s.brewService.GetFormulae() + s.packages = s.brewService.GetPackages() *s.filteredPackages = *s.packages s.app.QueueUpdateDraw(func() { @@ -171,37 +193,42 @@ func (s *AppService) forceRefreshResults() { } // setResults updates the results table with the provided data and optionally scrolls to the top. -func (s *AppService) setResults(data *[]models.Formula, scrollToTop bool) { +func (s *AppService) setResults(data *[]models.Package, scrollToTop bool) { s.layout.GetTable().Clear() - s.layout.GetTable().SetTableHeaders("Name", "Version", "Description", "↓ (90d)") + s.layout.GetTable().SetTableHeaders("Type", "Name", "Version", "Description", "↓ (90d)") for i, info := range *data { - version := info.Versions.Stable - if len(info.Installed) > 0 { - // Check if the installed version is the same as the stable version (handle revisions) - if strings.HasPrefix(info.Installed[0].Version, info.Versions.Stable) { - version = info.Installed[0].Version - } else if info.Installed[0].Version != info.Versions.Stable { - version = fmt.Sprintf("%s → %s", info.Installed[0].Version, info.Versions.Stable) - } + // Type cell with escaped brackets + typeTag := tview.Escape("[F]") // Formula + if info.Type == models.PackageTypeCask { + typeTag = tview.Escape("[C]") // Cask } + typeCell := tview.NewTableCell(typeTag).SetSelectable(true).SetAlign(tview.AlignLeft) + // Version handling + version := info.Version + + // Name cell nameCell := tview.NewTableCell(info.Name).SetSelectable(true) if info.LocallyInstalled { nameCell.SetTextColor(tcell.ColorGreen) } + // Version cell versionCell := tview.NewTableCell(version).SetSelectable(true) if info.LocallyInstalled && info.Outdated { versionCell.SetTextColor(tcell.ColorOrange) } + // Downloads cell downloadsCell := tview.NewTableCell(fmt.Sprintf("%d", info.Analytics90dDownloads)).SetSelectable(true).SetAlign(tview.AlignRight) - s.layout.GetTable().View().SetCell(i+1, 0, nameCell.SetExpansion(0)) - s.layout.GetTable().View().SetCell(i+1, 1, versionCell.SetExpansion(0)) - s.layout.GetTable().View().SetCell(i+1, 2, tview.NewTableCell(info.Description).SetSelectable(true).SetExpansion(1)) - s.layout.GetTable().View().SetCell(i+1, 3, downloadsCell.SetExpansion(0)) + // Set cells with new column order: Type, Name, Version, Description, Downloads + s.layout.GetTable().View().SetCell(i+1, 0, typeCell.SetExpansion(0)) + s.layout.GetTable().View().SetCell(i+1, 1, nameCell.SetExpansion(0)) + s.layout.GetTable().View().SetCell(i+1, 2, versionCell.SetExpansion(0)) + s.layout.GetTable().View().SetCell(i+1, 3, tview.NewTableCell(info.Description).SetSelectable(true).SetExpansion(1)) + s.layout.GetTable().View().SetCell(i+1, 4, downloadsCell.SetExpansion(0)) } // Update the details view with the first item in the list diff --git a/internal/services/brew.go b/internal/services/brew.go index d42f070..edf8c40 100644 --- a/internal/services/brew.go +++ b/internal/services/brew.go @@ -4,7 +4,6 @@ import ( "bbrew/internal/models" "encoding/json" "fmt" - "github.com/rivo/tview" "io" "net/http" "os" @@ -14,33 +13,53 @@ import ( "strconv" "strings" "sync" + + "github.com/adrg/xdg" + "github.com/rivo/tview" ) const FormulaeAPIURL = "https://formulae.brew.sh/api/formula.json" +const CaskAPIURL = "https://formulae.brew.sh/api/cask.json" const AnalyticsAPIURL = "https://formulae.brew.sh/api/analytics/install-on-request/90d.json" +const CaskAnalyticsAPIURL = "https://formulae.brew.sh/api/analytics/cask-install/90d.json" + +// getCacheDir - returns the cache directory following XDG Base Directory Specification. +func getCacheDir() string { + return filepath.Join(xdg.CacheHome, "bbrew") +} type BrewServiceInterface interface { GetPrefixPath() (path string) GetFormulae() (formulae *[]models.Formula) + GetPackages() (packages *[]models.Package) SetupData(forceDownload bool) (err error) GetBrewVersion() (version string, err error) UpdateHomebrew() error UpdateAllPackages(app *tview.Application, outputView *tview.TextView) error - UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error - RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error - InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error + UpdatePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error + RemovePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error + InstallPackage(info models.Package, app *tview.Application, outputView *tview.TextView) error } // BrewService provides methods to interact with Homebrew, including -// retrieving formulae, managing packages, and handling analytics. +// retrieving formulae, casks, and handling analytics. type BrewService struct { - // Package lists + // Formula lists all *[]models.Formula installed *[]models.Formula remote *[]models.Formula analytics map[string]models.AnalyticsItem + // Cask lists + allCasks *[]models.Cask + installedCasks *[]models.Cask + remoteCasks *[]models.Cask + caskAnalytics map[string]models.AnalyticsItem + + // Unified package list + allPackages *[]models.Package + brewVersion string prefixPath string } @@ -48,9 +67,13 @@ type BrewService struct { // NewBrewService creates a new instance of BrewService with initialized package lists. var NewBrewService = func() BrewServiceInterface { return &BrewService{ - all: new([]models.Formula), - installed: new([]models.Formula), - remote: new([]models.Formula), + all: new([]models.Formula), + installed: new([]models.Formula), + remote: new([]models.Formula), + allCasks: new([]models.Cask), + installedCasks: new([]models.Cask), + remoteCasks: new([]models.Cask), + allPackages: new([]models.Package), } } @@ -107,18 +130,106 @@ func (s *BrewService) GetFormulae() (formulae *[]models.Formula) { return s.all } -// SetupData initializes the BrewService by loading installed packages, remote formulae, and analytics data. +// GetPackages retrieves all packages (formulae + casks), merging remote and installed. +func (s *BrewService) GetPackages() (packages *[]models.Package) { + packageMap := make(map[string]models.Package) + + // Add REMOTE formulae + for _, formula := range *s.remote { + if _, exists := packageMap[formula.Name]; !exists { + f := formula // Create a copy to avoid implicit memory aliasing + pkg := models.NewPackageFromFormula(&f) + // Merge analytics data + if a, exists := s.analytics[formula.Name]; exists && a.Number > 0 { + downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", "")) + pkg.Analytics90dRank = a.Number + pkg.Analytics90dDownloads = downloads + } + packageMap[formula.Name] = pkg + } + } + + // Add INSTALLED formulae (override remote data) + for _, formula := range *s.installed { + f := formula // Create a copy to avoid implicit memory aliasing + pkg := models.NewPackageFromFormula(&f) + // Merge analytics data + if a, exists := s.analytics[formula.Name]; exists && a.Number > 0 { + downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", "")) + pkg.Analytics90dRank = a.Number + pkg.Analytics90dDownloads = downloads + } + packageMap[formula.Name] = pkg + } + + // Add REMOTE casks + for _, cask := range *s.remoteCasks { + if _, exists := packageMap[cask.Token]; !exists { + c := cask // Create a copy to avoid implicit memory aliasing + pkg := models.NewPackageFromCask(&c) + // Merge analytics data + if a, exists := s.caskAnalytics[cask.Token]; exists && a.Number > 0 { + downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", "")) + pkg.Analytics90dRank = a.Number + pkg.Analytics90dDownloads = downloads + } + packageMap[cask.Token] = pkg + } + } + + // Add INSTALLED casks (override remote data) + for _, cask := range *s.installedCasks { + c := cask // Create a copy to avoid implicit memory aliasing + pkg := models.NewPackageFromCask(&c) + // Merge analytics data + if a, exists := s.caskAnalytics[cask.Token]; exists && a.Number > 0 { + downloads, _ := strconv.Atoi(strings.ReplaceAll(a.Count, ",", "")) + pkg.Analytics90dRank = a.Number + pkg.Analytics90dDownloads = downloads + } + packageMap[cask.Token] = pkg + } + + // Convert map to slice + *s.allPackages = make([]models.Package, 0, len(packageMap)) + for _, pkg := range packageMap { + *s.allPackages = append(*s.allPackages, pkg) + } + + // Sort by name + sort.Slice(*s.allPackages, func(i, j int) bool { + return (*s.allPackages)[i].Name < (*s.allPackages)[j].Name + }) + + return s.allPackages +} + +// SetupData initializes the BrewService by loading installed packages, remote formulae, casks, and analytics data. func (s *BrewService) SetupData(forceDownload bool) (err error) { + // Load formulae if err = s.loadInstalled(); err != nil { - return err + return fmt.Errorf("failed to load installed formulae: %w", err) } if err = s.loadRemote(forceDownload); err != nil { - return err + return fmt.Errorf("failed to load remote formulae: %w", err) } if err = s.loadAnalytics(); err != nil { - return err + return fmt.Errorf("failed to load formulae analytics: %w", err) + } + + // Load casks + if err = s.loadInstalledCasks(); err != nil { + return fmt.Errorf("failed to load installed casks: %w", err) + } + + if err = s.loadRemoteCasks(forceDownload); err != nil { + return fmt.Errorf("failed to load remote casks: %w", err) + } + + if err = s.loadCaskAnalytics(); err != nil { + return fmt.Errorf("failed to load cask analytics: %w", err) } return nil @@ -148,17 +259,60 @@ func (s *BrewService) loadInstalled() (err error) { return nil } -// loadRemote retrieves the list of remote Homebrew formulae from the API and caches them locally. -func (s *BrewService) loadRemote(forceDownload bool) (err error) { - homeDir, err := os.UserHomeDir() +// loadInstalledCasks retrieves the list of installed Homebrew casks. +func (s *BrewService) loadInstalledCasks() (err error) { + // Get list of installed cask names + listCmd := exec.Command("brew", "list", "--cask") + listOutput, err := listCmd.Output() + if err != nil { + // If no casks are installed, brew returns error - ignore it + *s.installedCasks = make([]models.Cask, 0) + return nil + } + + // Parse cask names (one per line) + caskNames := strings.Split(strings.TrimSpace(string(listOutput)), "\n") + if len(caskNames) == 0 || (len(caskNames) == 1 && caskNames[0] == "") { + *s.installedCasks = make([]models.Cask, 0) + return nil + } + + // Get info for each installed cask using --json=v2 (v2 required for casks) + args := append([]string{"info", "--json=v2", "--cask"}, caskNames...) + infoCmd := exec.Command("brew", args...) + infoOutput, err := infoCmd.Output() + if err != nil { + *s.installedCasks = make([]models.Cask, 0) + return nil + } + + // Parse JSON response (v2 returns object with "formulae" and "casks" keys) + // We only need the "casks" array since we specified --cask flag + var response struct { + Casks []models.Cask `json:"casks"` + } + err = json.Unmarshal(infoOutput, &response) if err != nil { return err } - bbrewDir := filepath.Join(homeDir, ".bbrew") // TODO: Move to config - formulaFile := filepath.Join(bbrewDir, "formula.json") - if _, err := os.Stat(bbrewDir); os.IsNotExist(err) { - if err := os.MkdirAll(bbrewDir, 0755); err != nil { + *s.installedCasks = response.Casks + + // Mark all installed casks as locally installed + for i := range *s.installedCasks { + (*s.installedCasks)[i].LocallyInstalled = true + (*s.installedCasks)[i].IsCask = true + } + + return nil +} + +// loadRemote retrieves the list of remote Homebrew formulae from the API and caches them locally. +func (s *BrewService) loadRemote(forceDownload bool) (err error) { + cacheDir := getCacheDir() + formulaFile := filepath.Join(cacheDir, "formula.json") + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + if err := os.MkdirAll(cacheDir, 0750); err != nil { return err } } @@ -166,6 +320,7 @@ func (s *BrewService) loadRemote(forceDownload bool) (err error) { // Check if we should use the cached file if !forceDownload { if _, err := os.Stat(formulaFile); err == nil { + // #nosec G304 -- formulaFile path is safely constructed from UserHomeDir and sanitized with filepath.Join data, err := os.ReadFile(formulaFile) if err == nil { *s.remote = make([]models.Formula, 0) @@ -198,6 +353,52 @@ func (s *BrewService) loadRemote(forceDownload bool) (err error) { return nil } +// loadRemoteCasks retrieves the list of remote Homebrew casks from the API and caches them locally. +func (s *BrewService) loadRemoteCasks(forceDownload bool) (err error) { + cacheDir := getCacheDir() + caskFile := filepath.Join(cacheDir, "cask.json") + if _, err := os.Stat(cacheDir); os.IsNotExist(err) { + if err := os.MkdirAll(cacheDir, 0750); err != nil { + return err + } + } + + // Check if we should use the cached file + if !forceDownload { + if _, err := os.Stat(caskFile); err == nil { + // #nosec G304 -- caskFile path is safely constructed from UserHomeDir and sanitized with filepath.Join + data, err := os.ReadFile(caskFile) + if err == nil { + *s.remoteCasks = make([]models.Cask, 0) + if err := json.Unmarshal(data, &s.remoteCasks); err == nil { + return nil + } + } + } + } + + resp, err := http.Get(CaskAPIURL) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + *s.remoteCasks = make([]models.Cask, 0) + err = json.Unmarshal(body, s.remoteCasks) + if err != nil { + return err + } + + // Cache the remote cask data + _ = os.WriteFile(caskFile, body, 0600) + return nil +} + // loadAnalytics retrieves the analytics data for Homebrew formulae from the API. func (s *BrewService) loadAnalytics() (err error) { resp, err := http.Get(AnalyticsAPIURL) @@ -221,6 +422,33 @@ func (s *BrewService) loadAnalytics() (err error) { return nil } +// loadCaskAnalytics retrieves the analytics data for Homebrew casks from the API. +func (s *BrewService) loadCaskAnalytics() (err error) { + resp, err := http.Get(CaskAnalyticsAPIURL) + if err != nil { + return err + } + defer resp.Body.Close() + + analytics := models.Analytics{} + err = json.NewDecoder(resp.Body).Decode(&analytics) + if err != nil { + return err + } + + analyticsByCask := map[string]models.AnalyticsItem{} + for _, c := range analytics.Items { + // Cask analytics use the "cask" field instead of "formula" + caskName := c.Cask + if caskName != "" { + analyticsByCask[caskName] = c + } + } + + s.caskAnalytics = analyticsByCask + return nil +} + // GetBrewVersion retrieves the version of Homebrew installed on the system, caching it for future calls. func (s *BrewService) GetBrewVersion() (version string, err error) { if s.brewVersion != "" { @@ -251,18 +479,33 @@ func (s *BrewService) UpdateAllPackages(app *tview.Application, outputView *tvie return s.executeCommand(app, cmd, outputView) } -func (s *BrewService) UpdatePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "upgrade", info.Name) // #nosec G204 +func (s *BrewService) UpdatePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error { + var cmd *exec.Cmd + if info.Type == models.PackageTypeCask { + cmd = exec.Command("brew", "upgrade", "--cask", info.Name) // #nosec G204 + } else { + cmd = exec.Command("brew", "upgrade", info.Name) // #nosec G204 + } return s.executeCommand(app, cmd, outputView) } -func (s *BrewService) RemovePackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "remove", info.Name) // #nosec G204 +func (s *BrewService) RemovePackage(info models.Package, app *tview.Application, outputView *tview.TextView) error { + var cmd *exec.Cmd + if info.Type == models.PackageTypeCask { + cmd = exec.Command("brew", "uninstall", "--cask", info.Name) // #nosec G204 + } else { + cmd = exec.Command("brew", "uninstall", info.Name) // #nosec G204 + } return s.executeCommand(app, cmd, outputView) } -func (s *BrewService) InstallPackage(info models.Formula, app *tview.Application, outputView *tview.TextView) error { - cmd := exec.Command("brew", "install", info.Name) // #nosec G204 +func (s *BrewService) InstallPackage(info models.Package, app *tview.Application, outputView *tview.TextView) error { + var cmd *exec.Cmd + if info.Type == models.PackageTypeCask { + cmd = exec.Command("brew", "install", "--cask", info.Name) // #nosec G204 + } else { + cmd = exec.Command("brew", "install", info.Name) // #nosec G204 + } return s.executeCommand(app, cmd, outputView) } @@ -290,7 +533,7 @@ func (s *BrewService) executeCommand( defer wg.Done() defer stdoutWriter.Close() defer stderrWriter.Close() - cmd.Wait() + _ = cmd.Wait() // #nosec G104 -- Error is handled by pipe readers below }() // Stdout handler @@ -304,7 +547,7 @@ func (s *BrewService) executeCommand( output := make([]byte, n) copy(output, buf[:n]) app.QueueUpdateDraw(func() { - outputView.Write(output) + _, _ = outputView.Write(output) // #nosec G104 outputView.ScrollToEnd() }) } @@ -330,7 +573,7 @@ func (s *BrewService) executeCommand( output := make([]byte, n) copy(output, buf[:n]) app.QueueUpdateDraw(func() { - outputView.Write(output) + _, _ = outputView.Write(output) // #nosec G104 outputView.ScrollToEnd() }) } diff --git a/internal/services/io.go b/internal/services/io.go index 6933555..ec5c6a4 100644 --- a/internal/services/io.go +++ b/internal/services/io.go @@ -3,6 +3,7 @@ package services import ( "bbrew/internal/ui" "fmt" + "github.com/gdamore/tcell/v2" ) @@ -11,6 +12,8 @@ type FilterType int const ( FilterInstalled FilterType = iota FilterOutdated + FilterLeaves + FilterCasks ) // IOAction represents an input/output action that can be triggered by a key event. @@ -43,6 +46,8 @@ type IOService struct { ActionSearch *IOAction ActionFilterInstalled *IOAction ActionFilterOutdated *IOAction + ActionFilterLeaves *IOAction + ActionFilterCasks *IOAction ActionInstall *IOAction ActionUpdate *IOAction ActionRemove *IOAction @@ -62,6 +67,8 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionSearch = &IOAction{Key: tcell.KeyRune, Rune: '/', KeySlug: "/", Name: "Search"} s.ActionFilterInstalled = &IOAction{Key: tcell.KeyRune, Rune: 'f', KeySlug: "f", Name: "Filter Installed"} s.ActionFilterOutdated = &IOAction{Key: tcell.KeyRune, Rune: 'o', KeySlug: "o", Name: "Filter Outdated"} + s.ActionFilterLeaves = &IOAction{Key: tcell.KeyRune, Rune: 'l', KeySlug: "l", Name: "Filter Leaves"} + s.ActionFilterCasks = &IOAction{Key: tcell.KeyRune, Rune: 'c', KeySlug: "c", Name: "Filter Casks"} s.ActionInstall = &IOAction{Key: tcell.KeyRune, Rune: 'i', KeySlug: "i", Name: "Install"} s.ActionUpdate = &IOAction{Key: tcell.KeyRune, Rune: 'u', KeySlug: "u", Name: "Update"} s.ActionRemove = &IOAction{Key: tcell.KeyRune, Rune: 'r', KeySlug: "r", Name: "Remove"} @@ -73,6 +80,8 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionSearch.SetAction(s.handleSearchFieldEvent) s.ActionFilterInstalled.SetAction(s.handleFilterPackagesEvent) s.ActionFilterOutdated.SetAction(s.handleFilterOutdatedPackagesEvent) + s.ActionFilterLeaves.SetAction(s.handleFilterLeavesEvent) + s.ActionFilterCasks.SetAction(s.handleFilterCasksEvent) s.ActionInstall.SetAction(s.handleInstallPackageEvent) s.ActionUpdate.SetAction(s.handleUpdatePackageEvent) s.ActionRemove.SetAction(s.handleRemovePackageEvent) @@ -85,6 +94,8 @@ var NewIOService = func(appService *AppService) IOServiceInterface { s.ActionSearch, s.ActionFilterInstalled, s.ActionFilterOutdated, + s.ActionFilterLeaves, + s.ActionFilterCasks, s.ActionInstall, s.ActionUpdate, s.ActionRemove, @@ -149,19 +160,41 @@ func (s *IOService) handleFilterEvent(filterType FilterType) { switch filterType { case FilterInstalled: - if s.appService.showOnlyOutdated { + if s.appService.showOnlyOutdated || s.appService.showOnlyLeaves || s.appService.showOnlyCasks { s.appService.showOnlyOutdated = false + s.appService.showOnlyLeaves = false + s.appService.showOnlyCasks = false s.appService.showOnlyInstalled = true } else { s.appService.showOnlyInstalled = !s.appService.showOnlyInstalled } case FilterOutdated: - if s.appService.showOnlyInstalled { + if s.appService.showOnlyInstalled || s.appService.showOnlyLeaves || s.appService.showOnlyCasks { s.appService.showOnlyInstalled = false + s.appService.showOnlyLeaves = false + s.appService.showOnlyCasks = false s.appService.showOnlyOutdated = true } else { s.appService.showOnlyOutdated = !s.appService.showOnlyOutdated } + case FilterLeaves: + if s.appService.showOnlyInstalled || s.appService.showOnlyOutdated || s.appService.showOnlyCasks { + s.appService.showOnlyInstalled = false + s.appService.showOnlyOutdated = false + s.appService.showOnlyCasks = false + s.appService.showOnlyLeaves = true + } else { + s.appService.showOnlyLeaves = !s.appService.showOnlyLeaves + } + case FilterCasks: + if s.appService.showOnlyInstalled || s.appService.showOnlyOutdated || s.appService.showOnlyLeaves { + s.appService.showOnlyInstalled = false + s.appService.showOnlyOutdated = false + s.appService.showOnlyLeaves = false + s.appService.showOnlyCasks = true + } else { + s.appService.showOnlyCasks = !s.appService.showOnlyCasks + } } // Update the search field label and legend based on the current filter state @@ -171,6 +204,12 @@ func (s *IOService) handleFilterEvent(filterType FilterType) { } else if s.appService.showOnlyInstalled { s.layout.GetSearch().Field().SetLabel("Search (Installed): ") s.layout.GetLegend().SetLegend(s.legendEntries, s.ActionFilterInstalled.KeySlug) + } else if s.appService.showOnlyLeaves { + s.layout.GetSearch().Field().SetLabel("Search (Leaves): ") + s.layout.GetLegend().SetLegend(s.legendEntries, s.ActionFilterLeaves.KeySlug) + } else if s.appService.showOnlyCasks { + s.layout.GetSearch().Field().SetLabel("Search (Casks): ") + s.layout.GetLegend().SetLegend(s.legendEntries, s.ActionFilterCasks.KeySlug) } else { s.layout.GetSearch().Field().SetLabel("Search (All): ") } @@ -188,6 +227,16 @@ func (s *IOService) handleFilterOutdatedPackagesEvent() { s.handleFilterEvent(FilterOutdated) } +// handleFilterLeavesEvent toggles the filter for leaf packages (installed on request) +func (s *IOService) handleFilterLeavesEvent() { + s.handleFilterEvent(FilterLeaves) +} + +// handleFilterCasksEvent toggles the filter for cask packages only +func (s *IOService) handleFilterCasksEvent() { + s.handleFilterEvent(FilterCasks) +} + // showModal displays a modal dialog with the specified text and confirmation/cancellation actions. // This is used for actions like installing, removing, or updating packages, invoking user confirmation. func (s *IOService) showModal(text string, confirmFunc func(), cancelFunc func()) { diff --git a/internal/ui/components/details.go b/internal/ui/components/details.go index eccf3c4..9ffbb96 100644 --- a/internal/ui/components/details.go +++ b/internal/ui/components/details.go @@ -32,8 +32,8 @@ func NewDetails(theme *theme.Theme) *Details { return details } -func (d *Details) SetContent(info *models.Formula) { - if info == nil { +func (d *Details) SetContent(pkg *models.Package) { + if pkg == nil { d.view.SetText("") return } @@ -41,97 +41,112 @@ func (d *Details) SetContent(info *models.Formula) { // Installation status with colors installedStatus := "[red]Not installed[-]" installedIcon := "✗" - if len(info.Installed) > 0 { + if pkg.LocallyInstalled { installedStatus = "[green]Installed[-]" installedIcon = "✓" - if info.Outdated { + if pkg.Outdated { installedStatus = "[orange]Update available[-]" installedIcon = "⟳" } } - // Basic information with icons + // Type tag with escaped brackets + typeTag := tview.Escape("[F]") // Formula + typeLabel := "Formula" + if pkg.Type == models.PackageTypeCask { + typeTag = tview.Escape("[C]") // Cask + typeLabel = "Cask" + } + + // Basic information with status basicInfo := fmt.Sprintf( "[yellow::b]%s %s[-]\n\n"+ + "[blue]• Type:[-] %s %s\n"+ "[blue]• Name:[-] %s\n"+ + "[blue]• Display Name:[-] %s\n"+ "[blue]• Version:[-] %s\n"+ - "[blue]• Status:[-] %s %s\n"+ - "[blue]• Tap:[-] %s\n"+ - "[blue]• License:[-] %s\n\n"+ + "[blue]• Status:[-] %s\n\n"+ "[yellow::b]Description[-]\n%s\n\n"+ "[blue]• Homepage:[-] %s", - info.Name, installedIcon, - info.FullName, - info.Versions.Stable, - installedStatus, d.getPackageVersionInfo(info), - info.Tap, - info.License, - info.Description, - info.Homepage, + pkg.Name, installedIcon, + typeTag, typeLabel, + pkg.Name, + pkg.DisplayName, + pkg.Version, + installedStatus, + pkg.Description, + pkg.Homepage, ) // Installation details - installDetails := d.getPackageInstallationDetails(info) + installDetails := d.getPackageInstallationDetails(pkg) - // Dependencies with improved formatting - dependenciesInfo := d.getDependenciesInfo(info) - - analyticsInfo := d.getAnalyticsInfo(info) - - d.view.SetText(strings.Join([]string{basicInfo, installDetails, dependenciesInfo, analyticsInfo}, "\n\n")) -} - -func (d *Details) getPackageVersionInfo(info *models.Formula) string { - if len(info.Installed) == 0 { - return "" + // Dependencies (only for formulae) + dependenciesInfo := "" + if pkg.Type == models.PackageTypeFormula && pkg.Formula != nil { + dependenciesInfo = d.getDependenciesInfo(pkg.Formula) } - installedVersion := info.Installed[0].Version - stableVersion := info.Versions.Stable + analyticsInfo := d.getAnalyticsInfo(pkg) - // Revision version - if strings.HasPrefix(installedVersion, stableVersion+"_") { - return fmt.Sprintf("([green]%s[-])", installedVersion) - } else if installedVersion == stableVersion { - return fmt.Sprintf("([green]%s[-])", installedVersion) - } else if installedVersion < stableVersion || info.Outdated { - return fmt.Sprintf("([orange]%s[-] → [green]%s[-])", - installedVersion, stableVersion) + parts := []string{basicInfo, installDetails} + if dependenciesInfo != "" { + parts = append(parts, dependenciesInfo) } + parts = append(parts, analyticsInfo) - // Other cases - return fmt.Sprintf("([green]%s[-])", installedVersion) + d.view.SetText(strings.Join(parts, "\n\n")) } -func (d *Details) getPackageInstallationDetails(info *models.Formula) string { - if len(info.Installed) == 0 { +func (d *Details) getPackageInstallationDetails(pkg *models.Package) string { + if !pkg.LocallyInstalled { return "[yellow::b]Installation[-]\nNot installed" } - packagePrefix := info.LocalPath + // For formulae, show detailed installation info + if pkg.Type == models.PackageTypeFormula && pkg.Formula != nil && len(pkg.Formula.Installed) > 0 { + packagePrefix := pkg.Formula.LocalPath - installedOnRequest := "No" - if info.Installed[0].InstalledOnRequest { - installedOnRequest = "Yes" + installedOnRequest := "No" + if pkg.Formula.Installed[0].InstalledOnRequest { + installedOnRequest = "Yes" + } + + installedAsDependency := "No" + if pkg.Formula.Installed[0].InstalledAsDependency { + installedAsDependency = "Yes" + } + + return fmt.Sprintf( + "[yellow::b]Installation Details[-]\n"+ + "[blue]• Path:[-] %s\n"+ + "[blue]• Installed on request:[-] %s\n"+ + "[blue]• Installed as dependency:[-] %s\n"+ + "[blue]• Installed version:[-] %s", + packagePrefix, + installedOnRequest, + installedAsDependency, + pkg.Formula.Installed[0].Version, + ) } - installedAsDependency := "No" - if info.Installed[0].InstalledAsDependency { - installedAsDependency = "Yes" + // For casks, show simpler installation info + if pkg.Type == models.PackageTypeCask && pkg.Cask != nil { + installedVersion := "Unknown" + if pkg.Cask.Installed != nil { + installedVersion = *pkg.Cask.Installed + } + + return fmt.Sprintf( + "[yellow::b]Installation Details[-]\n"+ + "[blue]• Type:[-] macOS Application\n"+ + "[blue]• Installed version:[-] %s", + installedVersion, + ) } - return fmt.Sprintf( - "[yellow::b]Installation Details[-]\n"+ - "[blue]• Path:[-] %s\n"+ - "[blue]• Installed on request:[-] %s\n"+ - "[blue]• Installed as dependency:[-] %s\n"+ - "[blue]• Installed version:[-] %s", - packagePrefix, - installedOnRequest, - installedAsDependency, - info.Installed[0].Version, - ) + return "[yellow::b]Installation[-]\nInstalled" } func (d *Details) getDependenciesInfo(info *models.Formula) string { @@ -157,13 +172,13 @@ func (d *Details) getDependenciesInfo(info *models.Formula) string { return title + deps } -func (d *Details) getAnalyticsInfo(info *models.Formula) string { +func (d *Details) getAnalyticsInfo(pkg *models.Package) string { title := "[yellow::b]Analytics[-]\n" p := message.NewPrinter(language.English) - title += fmt.Sprintf("[blue]• 90d Global Rank:[-] %s\n", p.Sprintf("%d", info.Analytics90dRank)) - title += fmt.Sprintf("[blue]• 90d Downloads:[-] %s\n", p.Sprintf("%d", info.Analytics90dDownloads)) + title += fmt.Sprintf("[blue]• 90d Global Rank:[-] %s\n", p.Sprintf("%d", pkg.Analytics90dRank)) + title += fmt.Sprintf("[blue]• 90d Downloads:[-] %s\n", p.Sprintf("%d", pkg.Analytics90dDownloads)) return title }