Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .ai/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,3 +584,11 @@
- **The renderer and the transcript must agree character-for-character.** Offsets address the rendered text, so `ConsoleTranscript.text(of:)` and `ANSIConsoleText.attributed(for:)` both emit spans-joined + `"\n"`. A unit test pins the invariant (`rendered length == transcript.length`) because nothing else would catch drift.
- **Follow-the-tail is owned by a scroll observer, not by the update.** `ConsoleOutputTextView` gives up following when the user scrolls away and takes it back when they return to the end — growing the document doesn't move the clip view, so the bounds-change notification only ever fires for a real scroll. The pin also runs from `layout()`: SwiftUI hands over the first batch of output *before* the scroll view has any size, and a scroll issued then goes nowhere (symptom: opening the console on a finished job showed the top of the log).
- **Verified by driving the real app** (DYLD-injected driver, see the 2026-08-25 note): drag across lines selects four lines; `selectAll:` validates on the first responder and selects the whole document; hit-testing the bottom edge of the output area lands on the text view (no arrow-cursor dead zone); a 200-character selection survives ~1100 characters of streamed output; scrolling away leaves the reader in place. Menu-routed ⌘A could **not** be exercised — a bare exec never becomes the key app — so that path rests on the responder validating `selectAll:`.

## 2026-08-31 — The outdated check answers from `brew info`, which brew never auto-updates

- **`brew info` is not an auto-update command.** `Library/Homebrew/utils/auto-update.sh` lists `install`, `outdated`, `upgrade`, `bundle`, `release` (plus `tap` with args). `brew info --installed --json=v2` — the app's only source of outdated state — is not among them. With the JSON API in play that is harmless: brew re-fetches `api/formula.jws.json` on its own TTL whenever a command reads it. Under **`HOMEBREW_NO_INSTALL_FROM_API` there is no such refresh** — formula and cask data come from tap git clones, so the app reported whatever the taps held the last time the user ran `brew update` by hand, forever. `BrewInstalledPackagesRepository.updateTapsIfNeeded` now runs `brew update --auto-update --quiet` (the same command `brew upgrade` runs) ahead of the info fetch, on Homebrew's own 300s interval for this mode.
- **The app cannot read `HOMEBREW_*` from its own process.** It is launched by Finder, not from a shell, so a profile-exported variable is invisible to `ProcessInfo` while being fully in effect for every brew invocation the app makes through `LoginShellBrewCommandRunner` (see 2026-06-23). `BrewConfigEnvironmentReader` asks brew instead: `brew config` prints `HOMEBREW_NO_INSTALL_FROM_API: set` **only when it is set**, so presence of the row is the signal. Probed once per process — changing it means editing a shell profile, which does not take effect for a running app anyway — and it falls back to the API path whenever `brew config` cannot be run or exits non-zero.
- **A failed tap update is deliberately not fatal.** The taps keep their previous contents, and the `brew info` fetch is what decides whether the check produced an answer at all. The *attempt* is timestamped whether or not it succeeded, so a persistently failing `brew update` cannot stall every fetch behind it (each mutating operation reconciles with a forced fetch).
- **`state` alone cannot express "the check failed".** The repository deliberately keeps cached packages `.loaded` when a refresh fails, so a surface reading only `state` presents a stale zero as fact — the app claiming "everything is up to date" when it never found out. `InstalledInventoryObserving.refreshFailure` carries the last failure, **cleared only by a fetch that completes**: clearing it in `apply(_:)` would have let a cache-first repaint (which fetches nothing) silently turn "couldn't check" back into "nothing to upgrade".
- **A revision bump moves `revision`, not `versions.stable`.** Reading `versions.stable` alone made the Upgrades tab advertise a target identical to the installed keg (ffmpeg `9.0.1` revision 1 rendered "v9.0.1 → v9.0.1"). `HomebrewPkgVersion` renders Homebrew's own `PkgVersion` (`version_revision` when the revision is non-zero) and both the `brew info` mapping and the formula catalogue go through it.
1 change: 1 addition & 0 deletions Homebrew/BrewApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ struct BrewApp: App {
.commands {
SearchCommands()
SidebarCommands()
RefreshCommands()
ConsoleCommands()

// Replace the default "Homebrew Help" item (which points at a
Expand Down
19 changes: 17 additions & 2 deletions Homebrew/Features/MainWindow/Views/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import BrewFeatureConsole
import BrewFeatureDiscover
import BrewFeatureDoctor
import BrewFeatureInstalled
import BrewRepositoryInterfaces
import BrewUIComponents
import SwiftUI

struct MainWindowView: View {
@Environment(\.installedPackagesRepository) private var installedPackagesRepository
@Environment(\.discoverPackagesRepository) private var discoverPackagesRepository
@Environment(\.configRepository) private var configRepository

@State var selectedSidebarItem: SidebarItem = .installed
@State private var pendingInstalledSelection: InstalledBrewPackage.ID?
@SceneStorage("consoleExpanded") private var consoleExpanded: Bool = false
Expand All @@ -36,12 +41,24 @@ struct MainWindowView: View {
.navigationSplitViewStyle(.automatic)
.focusedSceneValue(\.consoleExpanded, $consoleExpanded)
.focusedSceneValue(\.sidebarSelection, $selectedSidebarItem)
.focusedSceneValue(\.refreshAll, RefreshAllAction { refreshAll() })
.environment(\.navigateToInstalledPackage) { id in
pendingInstalledSelection = id
selectedSidebarItem = .installed
}
}

/// ⌘R refetches every cached surface at once, whichever tab is showing, since the sidebar counts and
/// the other tabs go stale just as readily as the visible one. Doctor is deliberately left out: it
/// shells out to a slow `brew doctor` run and keeps its own explicit "Run Again".
private func refreshAll() {
Task {
await installedPackagesRepository.load(forceRefresh: true)
await discoverPackagesRepository.load(forceRefresh: true)
await configRepository.load(forceRefresh: true)
}
}

/// Approximate catalogue size for the Discover subtitle. Hardcoded for now; should eventually be
/// sourced from the catalogue once a package-count property is exposed.
private static let approximateCatalogueSize = "9,000+"
Expand Down Expand Up @@ -88,8 +105,6 @@ struct MainWindowView: View {
}

#if DEBUG
import BrewRepositoryInterfaces

#Preview {
MainWindowView()
.environment(\.brewCommandCenter, PreviewSupport.commandCenter)
Expand Down
2 changes: 2 additions & 0 deletions Sources/BrewAccessibilityID/AXID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public enum AXID: Hashable, Sendable {
case upgradesScreen
case upgradesList
case upgradesRow(token: String)
case upgradesRefreshButton

// Discover
case discoverScreen
Expand Down Expand Up @@ -68,6 +69,7 @@ public enum AXID: Hashable, Sendable {
case .upgradesScreen: "upgrades.screen"
case .upgradesList: "upgrades.list"
case let .upgradesRow(token): "upgrades.row.\(token)"
case .upgradesRefreshButton: "upgrades.refresh"
case .discoverScreen: "discover.screen"
case .discoverSearchField: "discover.search"
case .discoverList: "discover.list"
Expand Down
4 changes: 4 additions & 0 deletions Sources/BrewAppEnvironment/UnimplementedRepositories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ final class UnimplementedInstalledPackagesRepository: InstalledPackagesRepositor
unimplemented()
}

var refreshFailure: (any Error)? {
unimplemented()
}

func load(forceRefresh _: Bool) async {
unimplemented()
}
Expand Down
47 changes: 47 additions & 0 deletions Sources/BrewCLI/Config/BrewConfigEnvironmentReader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// BrewConfigEnvironmentReader.swift
// BrewCLI
//

import BrewCore
import Foundation

/// Asks `brew config`, which prints `HOMEBREW_NO_INSTALL_FROM_API: set` only when it is set.
/// Probed once: changing it means editing a shell profile, which needs a relaunch anyway.
public actor BrewConfigEnvironmentReader: HomebrewEnvironmentReading {
private static let noInstallFromAPIKey = "HOMEBREW_NO_INSTALL_FROM_API"

private let commandRunner: any BrewCommandRunning
private let locator: any BrewExecutableLocating
private var probed: Bool?

public init(commandRunner: any BrewCommandRunning, locator: any BrewExecutableLocating) {
self.commandRunner = commandRunner
self.locator = locator
}

public init(executionContext: BrewCommandExecutionContext) {
self.init(commandRunner: executionContext.commandRunner, locator: executionContext.locator)
}

public func isInstallFromAPIDisabled() async -> Bool {
if let probed {
return probed
}
let result = await probe()
probed = result
return result
}

/// Falls back to the API path; the caller's own brew invocation surfaces the real problem.
private func probe() async -> Bool {
guard let brew = try? locator.findBrewExecutable(),
let output = try? await commandRunner.run(executableURL: brew, arguments: ["config"]),
output.terminationStatus == 0
else {
return false
}
return BrewConfigParser.parse(output.standardOutput).entries
.contains { $0.key == Self.noInstallFromAPIKey }
}
}
2 changes: 1 addition & 1 deletion Sources/BrewCLI/JSON/BrewInfoJSON+Mapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ private extension BrewInfoFormula {
kind: .formula,
description: BrewInfoJSON.trimmedOrEmpty(desc),
homepage: BrewInfoJSON.trimmedOrEmpty(homepage),
latestVersion: BrewInfoJSON.trimmedOrEmpty(versions.stable),
latestVersion: HomebrewPkgVersion.string(version: versions.stable, revision: revision) ?? "",
dependencies: HomebrewPackageID.formulaDependencies(from: dependencies),
),
installedVersions: installedVersions,
Expand Down
3 changes: 3 additions & 0 deletions Sources/BrewCLI/JSON/BrewInfoJSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ struct BrewInfoFormula: Decodable {
var dependencies: [String]
var rubySourcePath: String?
var versions: BrewInfoFormulaVersions
var revision: Int
var installed: [BrewInfoFormulaInstalled]
var linkedKeg: String?
var pinned: Bool
Expand All @@ -52,6 +53,7 @@ struct BrewInfoFormula: Decodable {
rubySourcePath = try? container.decode(String.self, forKey: .rubySourcePath)
versions = (try? container.decode(BrewInfoFormulaVersions.self, forKey: .versions))
?? BrewInfoFormulaVersions(stable: nil)
revision = (try? container.decode(Int.self, forKey: .revision)) ?? 0
installed = (try? container.decode([BrewInfoFormulaInstalled].self, forKey: .installed))
?? []
linkedKeg = try? container.decode(String.self, forKey: .linkedKeg)
Expand All @@ -71,6 +73,7 @@ struct BrewInfoFormula: Decodable {
case dependencies
case rubySourcePath = "ruby_source_path"
case versions
case revision
case installed
case linkedKeg = "linked_keg"
case pinned
Expand Down
13 changes: 13 additions & 0 deletions Sources/BrewCore/Operations/HomebrewEnvironmentReading.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// HomebrewEnvironmentReading.swift
// BrewCore
//

import Foundation

/// The Homebrew environment as `brew` resolves it. Not `ProcessInfo`: the app is Finder-launched, so
/// a profile-exported `HOMEBREW_*` is invisible to it yet in effect for every brew invocation.
public protocol HomebrewEnvironmentReading: Sendable {
/// True when brew resolves packages from local tap clones rather than the JSON API.
func isInstallFromAPIDisabled() async -> Bool
}
20 changes: 20 additions & 0 deletions Sources/BrewCore/Support/HomebrewPkgVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// HomebrewPkgVersion.swift
// BrewCore
//

import Foundation

/// Homebrew's `PkgVersion`: `version_revision` when the revision is non-zero, as the keg is named.
/// A revision bump leaves `versions.stable` untouched, so the bare version reads as no upgrade at all.
public enum HomebrewPkgVersion {
public static func string(version: String?, revision: Int?) -> String? {
guard let trimmed = version?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
return nil
}
guard let revision, revision > 0 else {
return trimmed
}
return "\(trimmed)_\(revision)"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private struct DiscoverPackageDetailMetadataSection: View {
valueFontWeight: .heavy,
)
}
detailRow(label: "Latest stable", value: viewModel.stableVersionLabel)
detailRow(label: "Latest version", value: viewModel.stableVersionLabel)
if viewModel.showsInstallMetrics {
detailRow(label: "30-day installs", value: viewModel.installs30DayLabel)
}
Expand Down
36 changes: 36 additions & 0 deletions Sources/BrewFeatureInstalled/ViewModels/UpgradesUpToDateCopy.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// UpgradesUpToDateCopy.swift
// BrewFeatureInstalled
//

import Foundation

/// One phrase for "nothing to upgrade", shared by the four places the tab makes that claim at once.
enum UpgradesUpToDateCopy {
static var headline: String {
String(
localized: "Everything is up to date",
comment: "Upgrades tab: canonical phrase for having no upgrades available",
)
}

static func installedDetail(count: Int) -> String {
switch count {
case 0:
String(
localized: "No installed packages to check.",
comment: "Upgrades empty state when nothing is installed",
)
case 1:
String(
localized: "Your installed package is up to date.",
comment: "Upgrades empty state for a single installed package",
)
default:
String(
localized: "All \(count) installed packages are up to date.",
comment: "Upgrades empty state with total installed count",
)
}
}
}
60 changes: 51 additions & 9 deletions Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ final class UpgradesViewModel {

private var runningIDs: Set<BrewOperationID> = []

/// The repository stays `.loaded` through a revalidation, so Refresh needs its own signal.
private(set) var isRefreshing = false

var state: LoadState<InstalledPackagesContent, String> {
switch repository.state {
case .loading:
Expand Down Expand Up @@ -82,6 +85,15 @@ final class UpgradesViewModel {
repository.outdatedCount
}

var upgradeCheckFailureMessage: String? {
repository.refreshFailure.map(Self.userMessage(for:))
}

/// No upgrades to show, and a failed check means the app cannot vouch for that.
var showsUpgradeCheckFailure: Bool {
totalOutdatedCount == 0 && state.isLoaded && upgradeCheckFailureMessage != nil
}

/// Initial fetch with no rows yet — show blocking spinner.
private var shouldShowInitialLoadingIndicator: Bool {
if case .loading = state {
Expand All @@ -98,19 +110,29 @@ final class UpgradesViewModel {
if shouldShowInitialLoadingIndicator {
return String(localized: "Loading packages…", comment: "Upgrades tab subtitle while fetching")
}
if isFiltering {
return filteredSubtitle
if showsUpgradeCheckFailure {
return Self.upgradeCheckFailedTitle
}
return inventorySubtitle
let subtitle = isFiltering ? filteredSubtitle : inventorySubtitle
guard upgradeCheckFailureMessage != nil else {
return subtitle
}
// The count came from the last check that succeeded, so it must not read as current.
return String(
localized: "\(subtitle) — last check failed",
comment: "Upgrades tab subtitle when cached upgrades are shown after a failed re-check",
)
}

static let upgradeCheckFailedTitle = String(
localized: "Couldn't check for upgrades",
comment: "Upgrades tab: the outdated check failed, so the tab cannot report an answer",
)

private var inventorySubtitle: String {
switch totalOutdatedCount {
case 0:
String(
localized: "All packages are up to date",
comment: "Upgrades tab subtitle when nothing is outdated",
)
UpgradesUpToDateCopy.headline
case 1:
String(
localized: "1 package can be upgraded",
Expand Down Expand Up @@ -180,6 +202,8 @@ final class UpgradesViewModel {
}

func refresh() async {
isRefreshing = true
defer { isRefreshing = false }
await repository.load(forceRefresh: true)
}

Expand Down Expand Up @@ -359,15 +383,33 @@ extension UpgradesViewModel {
}

var emptyUpgradeActionTitle: String {
if showsUpgradeCheckFailure {
return Self.upgradeCheckFailedTitle
}
if isFilteringOutEveryUpgrade {
return String(
localized: "Nothing to upgrade here",
comment: "Upgrades header stand-in when filters hide every available upgrade",
)
}
return UpgradesUpToDateCopy.headline
}

var upToDateTitle: String {
UpgradesUpToDateCopy.headline
}

var upToDateDetail: String {
UpgradesUpToDateCopy.installedDetail(count: totalInstalledCount)
}

var upgradeCheckFailureDetail: String {
guard let message = upgradeCheckFailureMessage else {
return ""
}
return String(
localized: "Nothing to upgrade",
comment: "Upgrades header stand-in when no package is outdated",
localized: "\(message)\n\nUntil this succeeds the app can't tell whether anything needs upgrading.",
comment: "Upgrades empty state under a failed check: brew's error, then why the list is empty",
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ struct InstalledPackageDetailMetadataSection: View {
valueColor: metadata.isOutdated ? .brewStatusWarning : .brewTextPrimary,
valueFontWeight: .heavy,
)
detailRow(label: "Latest stable", value: metadata.latestVersionValue)
detailRow(label: "Latest version", value: metadata.latestVersionValue)
if let dateValue = metadata.installDateValue {
detailRow(label: "Installed on", value: dateValue)
}
Expand Down
Loading
Loading