Skip to content

Updates tab#65

Open
graeme wants to merge 15 commits into
mainfrom
updates-tab
Open

Updates tab#65
graeme wants to merge 15 commits into
mainfrom
updates-tab

Conversation

@graeme

@graeme graeme commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

PR: Add an Updates tab for reviewing and upgrading outdated packages

Summary

Adds a dedicated Updates tab to BrewUI for reviewing and upgrading outdated formulae and casks. Reuses the Installed tab's row + detail panel chrome, projects outdated rows off the same reconciled inventory, surfaces a sidebar badge for the outdated count, and ships a bulk "Upgrade All" that runs as a single brew upgrade subprocess.

Changes

New Updates surface

  • UpdatesViewModel mirrors InstalledViewModel's search/selection/error-mapping and filters the projection to outdated packages.
  • UpdatesPackagesView + UpdatesColumns reuse InstalledListRowRoot and InstalledPackageDetailRoot so row/detail chrome stays consistent across tabs.
  • UpdatesSidebarBadge shows a warning-tinted outdated-count capsule, recomputing as repository reconcile drops rows post-upgrade.
  • outdatedPackages / outdatedCount are derived in the InstalledInventoryObserving @MainActor extension, so the badge, subtitle, and list observe one reconciled source.

Bulk upgrade

  • BrewOperationID.bulkUpgrade + BrewOperationKind.upgradeAll and a new BulkUpgradeCommand run "Upgrade All" as a single brew upgrade subprocess instead of N per-package submissions.
  • CommandJob and CommandBlockView render the bulk op as brew upgrade in the console + Updates header.
  • bulkUpgradeDisplayCommand on UpgradesViewModel is the single source of truth shared between the header chip and the command center.

Cross-tab "Used by" deep-link

  • NavigationEnvironment gains navigateToInstalledPackage. MainWindowView provides the closure (sidebar switch + pending selection write); InstalledColumns consumes a deep-link Binding that clears on appear.
  • InstalledViewModel.init takes an optional initialSelection to seed selectedPackageID before inventory lands.
  • Updates' detail panel routes Dependency / Used-by row taps cross-tab; intra-tab Installed ‚Üí Installed taps are unchanged.

Convention cleanups while in the area

  • Installed + Updates migrated to AsyncContentView / LoadState<InstalledPackagesContent, String> (matches Doctor, Config, Discover); both now render a Retry button wired to viewModel.refresh().
  • Updates' empty state splits "no search matches" (with Show All) from genuinely "all caught up".
  • Updates subtitle is search-aware (Showing N of M updates / No matches in M outdated packages) so it stops claiming caught-up when search hides rows.
  • Updates nav subtitle is a static tab description ("Review and upgrade outdated packages") to match other tabs; dynamic copy stays in the list header.
  • Default Updates selection is the first formula (not first row) so it mirrors the list's formulae + casks layout instead of picking an alphabetically-earlier cask.
  • Update ‚Üí Upgrade terminology pass across user-facing copy.

Testing

  • xcrun swift test ‚Äî per-layer SPM tests (added UpgradesViewModelTests, BulkUpgradeCommandTests, extended InstalledViewModelTests, CommandJobTests, etc.)
  • xcodebuild -scheme Brew-Unit for the app-hosted path
  • SwiftFormat / SwiftLint / BrewUILint
  • Manual: sidebar badge updates when an upgrade completes; "Upgrade All" runs a single brew upgrade and the Updates list reconciles; deep-link from Updates ‚Üí Installed via "Used by" row selects the right package; empty / search-empty / all-caught-up states render correctly; Retry on error works on both tabs.

PR checklist

  • Have you followed this repository's contribution and workflow guidance?
  • Have you explained what changed and why this should land now?
  • Have you run relevant local checks for the changed scope?
  • Are changes scoped and free of unrelated modifications?

  • AI was used to generate or assist with generating this PR.
  • If yes, describe exactly how AI was used and what manual verification was performed.

Claude wrote almost all of the code on this branch; I directed scope, reviewed each commit, and gave correction/feedback iterations (e.g. distinguishing "no matches" from "caught up", making the bulk upgrade one subprocess instead of N submissions, fixing the default-selection mismatch, keeping the static-vs-dynamic subtitle split consistent with other tabs). Manual verification: ran the app against real brew state, exercised Upgrade All end-to-end, walked the Updates ‚Üí Installed deep-link, and confirmed sidebar badge / empty states under search.

graeme and others added 15 commits June 8, 2026 19:35
Computed in the @mainactor protocol extension so the Updates tab badge,
subtitle, and row projection all observe the same reconciled inventory
without touching concrete-repo code or stubs.
Mirrors InstalledViewModel's search/selection/error-mapping verbatim
and filters the projection to outdated packages. upgradeAll() submits
one upgrade per outdated package; SerialBrewCommandCenter.submit
dedupes duplicate in-flight ids, so a re-tap during a batch is a no-op
and the existing repository reconcile-on-completion drops each finished
row from the list automatically. isUpgradingAny tracks the
allPhaseChanges stream for the Update All button's disabled state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UpdatesColumnsRoot reads the env and threads the repo / command center /
factory into UpdatesColumns, which owns the VM and applies the Updates
title + dynamic outdatedSubtitle to the window chrome.

UpdatesPackagesView mirrors InstalledPackagesView with three diffs:
header copy + Update All (n) keyboard-shortcut button when there are
outdated rows, an empty state ("You're all caught up" + Refresh) when
the loaded projection is empty, and a redacted skeleton tinted for the
outdated case while loading. Rows reuse InstalledListRowRoot so the
warning badge and version arrow line stay consistent with Installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an Updates sidebar entry between Installed and Discover with an
UpdatesSidebarBadge that shows a warning-tinted outdated-count capsule
when the inventory has outdated rows. The badge reads the installed-
packages repository from the env so it recomputes when the existing
reconcile-on-completion drops a row from the outdated set.

sidebarRow now takes an optional trailing ViewBuilder accessory so the
existing rows keep their call sites and the new row can place the badge
after the Spacer. MainWindowView routes .updates to UpdatesColumnsRoot,
which owns the dynamic navigation subtitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The empty state previously fired off the filtered projection, so a search
that hid every outdated row celebrated "You're all caught up" even when
updates were still available. Split it into two: when the inventory has
outdated packages but none match the search, show "No matching updates"
with the hidden count and a Show All button that clears the search; only
when totalOutdatedCount is genuinely zero does the original copy fire.

The two empty states share a centredEmptyState helper and the loading
skeleton's repeated placeholders collapse to Array(repeating:count:) so
the view type stays under the project's body-length lint cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
outdatedSubtitle previously switched on the filtered count, so the
list header and window chrome both said "All packages are up to date"
the moment a search hid every outdated row — exactly the same false
positive the empty state used to give. While the search is active the
subtitle now reads "Showing N of M updates" when at least one matches
or "No matches in M outdated packages" when none do; with no active
search it still reflects the unfiltered inventory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
content.packages is name-sorted across kinds by BrewInfoJSON.mapping, so
the Updates list could pick an alphabetically-earlier cask (e.g. alfred)
as the default selection while the view showed the Formulae section
first — a confusing mismatch between selection and visual order. allRows
now returns formulaPackages + caskPackages so firstVisibleRowID mirrors
the list layout: first formula if any, else first cask.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The other tabs (Installed, Discover, Doctor, Configuration) all describe
the tab's purpose in their navigation subtitle, not the current content
of the screen. Match that pattern: MainWindowView now owns
"Review and upgrade outdated packages" for the .updates case, and
UpdatesColumns stops applying its own dynamic subtitle. The dynamic
"Showing N of M" / "No matches" copy still drives the in-page list
header where state-aware information actually belongs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nding

Groundwork for cross-tab "Used by" navigation. A new
navigateToInstalledPackage env entry will let other tabs request that a
specific package open in the Installed list; this commit gets the
receiving side ready without changing any user-visible behaviour yet.

InstalledViewModel.init now takes an optional initialSelection that
seeds selectedPackageID directly. The existing activeSelectedPackageID
fallback already drops a candidate that isn't in allRows, so a deep link
applied before the inventory loads simply resolves on the first
observation-driven re-render once allRows lands — no extra observer or
pending-queue plumbing required.

InstalledColumnsRoot / InstalledColumns thread a
Binding<InstalledBrewPackage.ID?> through and clear it on appear so the
deep link is a single-shot signal. Default .constant(nil) keeps all
existing call sites compiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MainWindowView now provides the navigateToInstalledPackage env closure
that pairs sidebar switch with a deep-link selection write into
pendingInstalledSelection, and threads that pending value as a Binding
into InstalledColumnsRoot's deepLinkSelection slot. The Installed column
consumes the deep link via its VM's initialSelection and clears the
binding on appear so it's a one-shot signal.

UpdatesColumnsRoot reads the closure from the env (env reads stay in
Roots) and threads it into UpdatesColumns, which uses it as
InstalledPackageDetailRoot.onSelectInstalledPackage. The local Updates
VM's selectInstalledPackage path stays for the in-list onTapGesture
selections — only the detail panel's Dependency / Used-by row taps go
cross-tab.

Installed tab keeps its existing intra-tab onSelectInstalledPackage
wiring; nothing changes for Installed → Installed taps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the bespoke InstalledLoadState enum with the standard
LoadState<InstalledPackagesContent, String>, adds Placeholdable
conformance on InstalledPackagesContent, and routes both surfaces
through AsyncContentView so loading/error/retry match the convention
used by Doctor, Config, and Discover. The Updates tab keeps its two
distinct empty states (all-caught-up vs no-search-matches) inside the
loaded closure, and both error states now render a Retry button wired
to viewModel.refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the per-package upgrade loop in UpgradesViewModel.upgradeAll with a
single bulk submit. Adds BrewOperationID.bulkUpgrade (singleton) and
BrewOperationKind.upgradeAll, wires bulkUpgradeCommand() through the factory
protocol, ships BulkUpgradeCommand in BrewCLI, and extends the CommandJob
switches so the bulk op renders "brew upgrade" in the console.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures the Updates header from an HStack to a vertical stack: title /
subtitle, then a CommandBlockView showing the user-facing "brew upgrade"
command (matching the package detail view chrome), then the "Upgrade All (n)"
button styled as bordered-prominent. Adds bulkUpgradeDisplayCommand on
UpgradesViewModel so the view and command center share one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a dedicated Upgrades tab to BrewUI for reviewing and upgrading outdated formulae/casks, including a sidebar badge for the outdated count and a bulk “Upgrade All” flow implemented as a single brew upgrade operation. It also introduces cross-tab navigation into the Installed tab for “Used by”/dependency taps, and aligns Installed/Upgrades list loading/error chrome via AsyncContentView + LoadState.

Changes:

  • Introduces UpgradesViewModel + UpgradesColumns/UpgradesPackagesView and a sidebar badge powered by InstalledInventoryObserving.outdatedCount.
  • Adds a bulk upgrade operation (BrewOperationID.bulkUpgrade, BrewOperationKind.upgradeAll) with a new BulkUpgradeCommand, plus console rendering support.
  • Adds navigation plumbing (navigateToInstalledPackage env entry + Installed deep-link selection binding) and updates terminology from “Update” → “Upgrade”.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift New unit tests for Upgrades projection/search/selection and bulk upgrade submission behavior.
Tests/BrewFeatureInstalledTests/UpgradePackageItemTests.swift Updates button copy expectations from “Update” to “Upgrade”.
Tests/BrewFeatureInstalledTests/InstalledViewModelTestsSupport.swift Migrates snapshot helper to LoadState<InstalledPackagesContent, String>.
Tests/BrewFeatureInstalledTests/InstalledViewModelTests.swift Adds deep-link initial selection tests; updates error-state assertions to .failed.
Tests/BrewFeatureInstalledTests/InstalledPackageRowPresentationTests.swift Updates accessibility summary copy to “Upgrade available…”.
Tests/BrewFeatureInstalledTests/InstalledListRowViewModelTests.swift Renames update-availability API to upgrade-availability in tests.
Tests/BrewFeatureInstalledTests/InstalledDetailsViewModelTestsSupport.swift Adds a submit-recording command center test double used by Upgrades tests.
Tests/BrewFeatureConsoleTests/CommandJobTests.swift Ensures bulk upgrade job renders the shared display command.
Tests/BrewCLITests/BulkUpgradeCommandTests.swift New tests for BulkUpgradeCommand invocation/operation kind.
Sources/BrewRepositoryInterfaces/Protocols/InstalledInventoryObserving+Outdated.swift Adds outdatedPackages/outdatedCount derived from installed inventory state.
Sources/BrewRepositoryInterfaces/Fakes/Stubs.swift Extends stub mutating command factory with bulkUpgradeCommand().
Sources/BrewRepositoryInterfaces/CommandJob.swift Adds command rendering support for .bulkUpgrade and .upgradeAll.
Sources/BrewFeatureInstalled/Views/UpgradesSidebarBadge.swift New sidebar badge showing outdated-count capsule.
Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift New Upgrades middle-column UI (header, Upgrade All, list, empty/search-empty states).
Sources/BrewFeatureInstalled/Views/UpgradesColumns.swift New Upgrades content/detail split view, reusing Installed detail chrome.
Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift Migrates Installed list chrome to AsyncContentView.
Sources/BrewFeatureInstalled/Views/InstalledListRowView.swift Renames update-availability usage to upgrade-availability.
Sources/BrewFeatureInstalled/Views/InstalledColumns.swift Adds deep-link selection binding and clears it on appear.
Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift New view model that projects outdated rows, supports search/selection, and submits bulk upgrade.
Sources/BrewFeatureInstalled/ViewModels/UpgradePackageItem.swift Updates user-facing copy from “Update to …” to “Upgrade to …”.
Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift Migrates to LoadState, adds optional initial selection for deep links.
Sources/BrewFeatureInstalled/ViewModels/InstalledPackagesContent+Placeholdable.swift Adds placeholder data for redacted skeleton loading under AsyncContentView.
Sources/BrewFeatureInstalled/ViewModels/InstalledListRowViewModel.swift Renames and updates copy to “Upgrade available…”.
Sources/BrewCore/Operations/BrewOperationModels.swift Adds upgradeAll, .bulkUpgrade, and shared bulkUpgradeDisplayCommand.
Sources/BrewCore/Operations/BrewMutatingCommandFactory.swift Adds bulkUpgradeCommand() factory API.
Sources/BrewCLI/LiveBrewMutatingCommandFactory.swift Wires live factory to return BulkUpgradeCommand().
Sources/BrewCLI/BulkUpgradeCommand.swift New mutating command that runs brew upgrade with no args.
Sources/BrewAppEnvironment/UnimplementedRepositories.swift Adds unimplemented bulkUpgradeCommand() stub to keep environment wiring complete.
Sources/BrewAppEnvironment/NavigationEnvironment.swift Adds navigateToInstalledPackage environment entry for cross-tab deep links.
Brew/Views/MainSidebarView.swift Adds Upgrades sidebar item with badge accessory.
Brew/Features/MainWindow/Views/MainWindowView.swift Adds Upgrades tab routing and deep-link plumbing into Installed.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +268 to +274
for await (id, phase) in stream {
switch phase {
case .running:
runningIDs.insert(id)
case .idle, .failed:
runningIDs.remove(id)
}
Comment on lines +6 to +10
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import Foundation
import Observation
Comment on lines +6 to +10
import BrewCore
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI

Comment on lines +6 to +8
import BrewCore
import BrewRepositoryInterfaces
import Foundation
Comment on lines +179 to +183
func phaseChanges(for _: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
continuation.yield(.idle)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants