mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 08:34:36 +08:00
Merge pull request #57 from Homebrew/modularisation
Package Modularisation
This commit is contained in:
@@ -78,4 +78,4 @@ jobs:
|
||||
- name: Run BrewUILint
|
||||
run: |
|
||||
BREWUILINT="$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint"
|
||||
find Brew -name '*.swift' -print0 | xargs -0 "$BREWUILINT"
|
||||
find Brew Sources -name '*.swift' -print0 | xargs -0 "$BREWUILINT"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.ai/scratchpad.md
|
||||
# progress is per-developer local state
|
||||
.ai/progress.md
|
||||
.ai/plans/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -19,11 +19,18 @@ disabled_rules:
|
||||
- unneeded_synthesized_initializer
|
||||
- vertical_whitespace
|
||||
- void_return
|
||||
# Fires on the common pattern of putting a // swiftlint:disable:next
|
||||
# comment between a doc comment and the declaration it documents.
|
||||
# The combined "explain why you're suppressing a rule" pattern is more
|
||||
# valuable than the rule catches.
|
||||
- orphaned_doc_comment
|
||||
|
||||
included:
|
||||
- Brew
|
||||
- BrewTests
|
||||
- BrewUITests
|
||||
- Sources
|
||||
- Tests
|
||||
|
||||
excluded:
|
||||
- .build
|
||||
@@ -49,3 +56,28 @@ identifier_name:
|
||||
- x
|
||||
- y
|
||||
- z
|
||||
|
||||
custom_rules:
|
||||
unchecked_sendable:
|
||||
name: "Unchecked Sendable"
|
||||
regex: '@unchecked\s+Sendable'
|
||||
message: "@unchecked Sendable bypasses concurrency checking. Justify with a comment explaining why this is safe, then add // swiftlint:disable:next unchecked_sendable"
|
||||
severity: error
|
||||
|
||||
nonisolated_unsafe:
|
||||
name: "Nonisolated Unsafe"
|
||||
regex: 'nonisolated\(unsafe\)'
|
||||
message: "nonisolated(unsafe) disables data-race checking. Justify with a comment explaining the synchronisation mechanism, then add // swiftlint:disable:next nonisolated_unsafe"
|
||||
severity: error
|
||||
|
||||
preconcurrency_import:
|
||||
name: "Preconcurrency Import"
|
||||
regex: '@preconcurrency\s+import'
|
||||
message: "@preconcurrency import suppresses isolation checking for the imported module. Document which APIs from this module are being bridged and why."
|
||||
severity: error
|
||||
|
||||
assume_isolated:
|
||||
name: "Assume Isolated"
|
||||
regex: '\.assumeIsolated\s*\{'
|
||||
message: "MainActor.assumeIsolated asserts isolation without compile-time verification. Justify with a comment explaining why the assumption holds at this call site."
|
||||
severity: error
|
||||
|
||||
@@ -83,10 +83,11 @@ When you change Swift sources or anything that affects Swift formatting or linti
|
||||
2. `mint run swiftlint lint --strict`
|
||||
3. BrewUILint (matches CI; first build is slow because it compiles `swift-syntax`):
|
||||
- `swift build --package-path Tools/BrewUILint -c release --enable-experimental-prebuilts`
|
||||
- `"$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint" $(find Brew -name '*.swift')`
|
||||
- `"$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint" $(find Brew Sources -name '*.swift')`
|
||||
- BrewUILint lints `Brew` + `Sources` (the whole production tree, all packages); `Tests` is excluded. It must run over all files in one pass — the `nonisolated`-extension rule needs to see every `nonisolated` type declaration to flag a bad extension on it.
|
||||
- Keep `Tools/BrewUILint/.build` between runs; deleting it forces a full rebuild (~2+ minutes).
|
||||
|
||||
The pre-commit hook formats and lints **staged** files only; these commands validate the **whole** tree like CI and catch drift in unstaged paths.
|
||||
The pre-commit hook formats and lints **staged** Swift files (SwiftFormat/SwiftLint) and additionally runs **BrewUILint over the whole tree** on every commit; these manual commands validate the whole tree like CI and catch drift in unstaged paths.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,16 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
EE2FB0362F653F89004AE92A /* Brew-Unit.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = EE2FB0352F653F89004AE92A /* Brew-Unit.xctestplan */; };
|
||||
EE2FB0382F654024004AE92A /* Brew-UI.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = EE2FB0372F654024004AE92A /* Brew-UI.xctestplan */; };
|
||||
EEAA110100000000000000B1 /* BrewCore in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100100000000000000C1 /* BrewCore */; };
|
||||
EEAA110200000000000000B2 /* BrewUIComponents in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100200000000000000C2 /* BrewUIComponents */; };
|
||||
EEAA110300000000000000B3 /* BrewCLI in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100300000000000000C3 /* BrewCLI */; };
|
||||
EEAA110400000000000000B4 /* BrewNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100400000000000000C4 /* BrewNetworking */; };
|
||||
EEAA110500000000000000B5 /* BrewRepositoryInterfaces in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100500000000000000C5 /* BrewRepositoryInterfaces */; };
|
||||
EEAA110600000000000000B6 /* BrewRepositories in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100600000000000000C6 /* BrewRepositories */; };
|
||||
EEAA110700000000000000B7 /* BrewFeatureConsole in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100700000000000000C7 /* BrewFeatureConsole */; };
|
||||
EEAA110800000000000000B8 /* BrewFeatureInstalled in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100800000000000000C8 /* BrewFeatureInstalled */; };
|
||||
EEAA110900000000000000B9 /* BrewFeatureDiscover in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100900000000000000C9 /* BrewFeatureDiscover */; };
|
||||
EEAA110A00000000000000BA /* BrewAppEnvironment in Frameworks */ = {isa = PBXBuildFile; productRef = EEAA100A00000000000000CA /* BrewAppEnvironment */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -61,6 +71,16 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EEAA110100000000000000B1 /* BrewCore in Frameworks */,
|
||||
EEAA110200000000000000B2 /* BrewUIComponents in Frameworks */,
|
||||
EEAA110300000000000000B3 /* BrewCLI in Frameworks */,
|
||||
EEAA110400000000000000B4 /* BrewNetworking in Frameworks */,
|
||||
EEAA110500000000000000B5 /* BrewRepositoryInterfaces in Frameworks */,
|
||||
EEAA110600000000000000B6 /* BrewRepositories in Frameworks */,
|
||||
EEAA110700000000000000B7 /* BrewFeatureConsole in Frameworks */,
|
||||
EEAA110800000000000000B8 /* BrewFeatureInstalled in Frameworks */,
|
||||
EEAA110900000000000000B9 /* BrewFeatureDiscover in Frameworks */,
|
||||
EEAA110A00000000000000BA /* BrewAppEnvironment in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -134,6 +154,16 @@
|
||||
);
|
||||
name = Brew;
|
||||
packageProductDependencies = (
|
||||
EEAA100100000000000000C1 /* BrewCore */,
|
||||
EEAA100200000000000000C2 /* BrewUIComponents */,
|
||||
EEAA100300000000000000C3 /* BrewCLI */,
|
||||
EEAA100400000000000000C4 /* BrewNetworking */,
|
||||
EEAA100500000000000000C5 /* BrewRepositoryInterfaces */,
|
||||
EEAA100600000000000000C6 /* BrewRepositories */,
|
||||
EEAA100700000000000000C7 /* BrewFeatureConsole */,
|
||||
EEAA100800000000000000C8 /* BrewFeatureInstalled */,
|
||||
EEAA100900000000000000C9 /* BrewFeatureDiscover */,
|
||||
EEAA100A00000000000000CA /* BrewAppEnvironment */,
|
||||
);
|
||||
productName = Brew;
|
||||
productReference = EEC39EBF2F5AB4B900269514 /* Brew.app */;
|
||||
@@ -219,6 +249,7 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
EEAA00012F70000000000001 /* XCLocalSwiftPackageReference "Tools/BrewUILint" */,
|
||||
EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = EEC39EC02F5AB4B900269514 /* Products */;
|
||||
@@ -610,6 +641,10 @@
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Tools/BrewUILint;
|
||||
};
|
||||
EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = .;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
@@ -618,6 +653,56 @@
|
||||
package = EEAA00012F70000000000001 /* XCLocalSwiftPackageReference "Tools/BrewUILint" */;
|
||||
productName = "plugin:BrewUILintPlugin";
|
||||
};
|
||||
EEAA100100000000000000C1 /* BrewCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewCore;
|
||||
};
|
||||
EEAA100200000000000000C2 /* BrewUIComponents */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewUIComponents;
|
||||
};
|
||||
EEAA100300000000000000C3 /* BrewCLI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewCLI;
|
||||
};
|
||||
EEAA100400000000000000C4 /* BrewNetworking */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewNetworking;
|
||||
};
|
||||
EEAA100500000000000000C5 /* BrewRepositoryInterfaces */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewRepositoryInterfaces;
|
||||
};
|
||||
EEAA100600000000000000C6 /* BrewRepositories */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewRepositories;
|
||||
};
|
||||
EEAA100700000000000000C7 /* BrewFeatureConsole */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewFeatureConsole;
|
||||
};
|
||||
EEAA100800000000000000C8 /* BrewFeatureInstalled */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewFeatureInstalled;
|
||||
};
|
||||
EEAA100900000000000000C9 /* BrewFeatureDiscover */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewFeatureDiscover;
|
||||
};
|
||||
EEAA100A00000000000000CA /* BrewAppEnvironment */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EEAA1F0000000000000000FF /* XCLocalSwiftPackageReference "." */;
|
||||
productName = BrewAppEnvironment;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = EEC39EB72F5AB4B900269514 /* Project object */;
|
||||
|
||||
+35
-8
@@ -5,35 +5,62 @@
|
||||
// Created by Graeme Arthur on 6/3/2026.
|
||||
//
|
||||
|
||||
import BrewAppEnvironment
|
||||
import BrewCLI
|
||||
import BrewCore
|
||||
import BrewFeatureConsole
|
||||
import BrewNetworking
|
||||
import BrewRepositories
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BrewApp: App {
|
||||
private let commandCenter: SerialBrewCommandCenter
|
||||
private let commandFactory: LiveBrewMutatingCommandFactory
|
||||
private let installedInventoryCache: InstalledInventoryCache
|
||||
private let catalogueCache: CatalogueCache
|
||||
private let installedPackagesRepository: BrewInstalledPackagesRepository
|
||||
private let commandJobsRepository: BrewCommandJobsRepository
|
||||
private let installedDependentsRepository: BrewInstalledDependentsRepository
|
||||
private let catalogueRepository: BrewCatalogueRepository
|
||||
private let discoverPackagesRepository: BrewDiscoverPackagesRepository
|
||||
|
||||
init() {
|
||||
installedInventoryCache = InstalledInventoryCache()
|
||||
catalogueCache = CatalogueCache()
|
||||
commandCenter = SerialBrewCommandCenter(executionContext: .live())
|
||||
let inventoryCache = InstalledInventoryCache()
|
||||
let catalogue = CatalogueCache()
|
||||
let center = SerialBrewCommandCenter(executionContext: .live())
|
||||
let apiClient = URLSessionBrewAPIClient.live()
|
||||
let catalogueRepo = BrewCatalogueRepository(apiClient: apiClient, cache: catalogue)
|
||||
|
||||
installedInventoryCache = inventoryCache
|
||||
catalogueCache = catalogue
|
||||
commandCenter = center
|
||||
commandFactory = LiveBrewMutatingCommandFactory()
|
||||
installedPackagesRepository = BrewInstalledPackagesRepository.live(
|
||||
cache: installedInventoryCache,
|
||||
commandCenter: commandCenter,
|
||||
cache: inventoryCache,
|
||||
commandCenter: center,
|
||||
)
|
||||
commandJobsRepository = BrewCommandJobsRepository(commandCenter: center)
|
||||
installedDependentsRepository = BrewInstalledDependentsRepository(cache: inventoryCache)
|
||||
catalogueRepository = catalogueRepo
|
||||
discoverPackagesRepository = BrewDiscoverPackagesRepository(
|
||||
apiClient: apiClient,
|
||||
catalogueRepository: catalogueRepo,
|
||||
)
|
||||
commandJobsRepository = BrewCommandJobsRepository(commandCenter: commandCenter)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MainWindowView()
|
||||
.environment(\.brewCommandCenter, commandCenter)
|
||||
.environment(\.installedInventoryCache, installedInventoryCache)
|
||||
.environment(\.catalogueCache, catalogueCache)
|
||||
.environment(\.mutatingCommandFactory, commandFactory)
|
||||
.environment(\.installedPackagesRepository, installedPackagesRepository)
|
||||
.environment(\.commandJobsRepository, commandJobsRepository)
|
||||
.environment(\.installedDependentsRepository, installedDependentsRepository)
|
||||
.environment(\.catalogueRepository, catalogueRepository)
|
||||
.environment(\.discoverPackagesRepository, discoverPackagesRepository)
|
||||
.task { await catalogueCache.prepare() }
|
||||
.task { await installedPackagesRepository.load() }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import AppKit
|
||||
import BrewAppEnvironment
|
||||
import BrewFeatureConsole
|
||||
import BrewFeatureDiscover
|
||||
import BrewFeatureInstalled
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
struct MainWindowView: View {
|
||||
@@ -55,9 +60,15 @@ struct MainWindowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainWindowView()
|
||||
.environment(\.brewCommandCenter, AppPreviewSupport.commandCenter)
|
||||
.environment(\.installedInventoryCache, AppPreviewSupport.installedInventoryCache)
|
||||
.environment(\.installedPackagesRepository, AppPreviewSupport.makeInstalledPackagesRepository())
|
||||
}
|
||||
#if DEBUG
|
||||
import BrewRepositoryInterfaces
|
||||
|
||||
#Preview {
|
||||
MainWindowView()
|
||||
.environment(\.brewCommandCenter, PreviewSupport.commandCenter)
|
||||
.environment(\.installedPackagesRepository, PreviewSupport.makeInstalledPackagesRepository())
|
||||
.environment(\.discoverPackagesRepository, PreviewSupport.makeDiscoverPackagesRepository())
|
||||
.environment(\.catalogueRepository, PreviewSupport.makeDiscoverCatalogueRepository())
|
||||
.environment(\.installedDependentsRepository, PreviewSupport.makeInstalledDependentsRepository())
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// BrewPackage.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated struct BrewPackage: Identifiable, Hashable {
|
||||
let name: String
|
||||
let displayName: String
|
||||
let kind: HomebrewPackageKind
|
||||
var description: String
|
||||
var homepage: String
|
||||
var latestVersion: String
|
||||
var dependencies: [HomebrewPackageID]
|
||||
|
||||
var id: HomebrewPackageID {
|
||||
reference
|
||||
}
|
||||
|
||||
var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: self)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//
|
||||
// DiscoveryBrewPackage.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Domain output for Discover top package sections.
|
||||
nonisolated struct DiscoverTopPackagesSnapshot: Equatable {
|
||||
let topFormulae: [DiscoveryBrewPackage]
|
||||
let topCasks: [DiscoveryBrewPackage]
|
||||
}
|
||||
|
||||
nonisolated struct DiscoveryBrewPackage: Identifiable, Equatable, Hashable {
|
||||
var package: BrewPackage
|
||||
var thirtyDayInstallCount: Int
|
||||
|
||||
var name: String {
|
||||
package.name
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
package.displayName
|
||||
}
|
||||
|
||||
var kind: HomebrewPackageKind {
|
||||
package.kind
|
||||
}
|
||||
|
||||
var description: String {
|
||||
get { package.description }
|
||||
set { package.description = newValue }
|
||||
}
|
||||
|
||||
var homepage: String {
|
||||
get { package.homepage }
|
||||
set { package.homepage = newValue }
|
||||
}
|
||||
|
||||
var latestVersion: String {
|
||||
get { package.latestVersion }
|
||||
set { package.latestVersion = newValue }
|
||||
}
|
||||
|
||||
var dependencies: [HomebrewPackageID] {
|
||||
get { package.dependencies }
|
||||
set { package.dependencies = newValue }
|
||||
}
|
||||
|
||||
var id: HomebrewPackageID {
|
||||
package.id
|
||||
}
|
||||
|
||||
var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: package)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// InstalledBrewPackage.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated struct InstalledBrewPackage: Identifiable, Hashable {
|
||||
var package: BrewPackage
|
||||
var installedVersions: [String]
|
||||
var outdated: Bool
|
||||
|
||||
var name: String {
|
||||
package.name
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
package.displayName
|
||||
}
|
||||
|
||||
var kind: HomebrewPackageKind {
|
||||
package.kind
|
||||
}
|
||||
|
||||
var description: String {
|
||||
get { package.description }
|
||||
set { package.description = newValue }
|
||||
}
|
||||
|
||||
var homepage: String {
|
||||
get { package.homepage }
|
||||
set { package.homepage = newValue }
|
||||
}
|
||||
|
||||
var latestVersion: String {
|
||||
get { package.latestVersion }
|
||||
set { package.latestVersion = newValue }
|
||||
}
|
||||
|
||||
var dependencies: [HomebrewPackageID] {
|
||||
get { package.dependencies }
|
||||
set { package.dependencies = newValue }
|
||||
}
|
||||
|
||||
var id: HomebrewPackageID {
|
||||
package.id
|
||||
}
|
||||
|
||||
var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: package)
|
||||
}
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
//
|
||||
// AppPreviewSupport.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated enum AppPreviewSupport {
|
||||
static let commandCenter = PreviewBrewCommandCenter()
|
||||
static let installedInventoryCache = InstalledInventoryCache()
|
||||
static let discoverTopPackagesSnapshot = DiscoverTopPackagesSnapshot(
|
||||
topFormulae: [
|
||||
DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "git",
|
||||
displayName: "git",
|
||||
kind: .formula,
|
||||
description: "",
|
||||
homepage: "",
|
||||
latestVersion: "",
|
||||
dependencies: [],
|
||||
),
|
||||
thirtyDayInstallCount: 420_000,
|
||||
),
|
||||
DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "node",
|
||||
displayName: "node",
|
||||
kind: .formula,
|
||||
description: "",
|
||||
homepage: "",
|
||||
latestVersion: "",
|
||||
dependencies: [],
|
||||
),
|
||||
thirtyDayInstallCount: 360_000,
|
||||
),
|
||||
],
|
||||
topCasks: [
|
||||
DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "iterm2",
|
||||
displayName: "iterm2",
|
||||
kind: .cask,
|
||||
description: "",
|
||||
homepage: "",
|
||||
latestVersion: "",
|
||||
dependencies: [],
|
||||
),
|
||||
thirtyDayInstallCount: 180_000,
|
||||
),
|
||||
DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "docker",
|
||||
displayName: "docker",
|
||||
kind: .cask,
|
||||
description: "",
|
||||
homepage: "",
|
||||
latestVersion: "",
|
||||
dependencies: [],
|
||||
),
|
||||
thirtyDayInstallCount: 160_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
/// A single representative discovery package for previews that only need one row's worth of data.
|
||||
static let discoverPreviewPackage = DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "git",
|
||||
displayName: "git",
|
||||
kind: .formula,
|
||||
description: "Distributed revision control system",
|
||||
homepage: "https://git-scm.com",
|
||||
latestVersion: "2.46.1",
|
||||
dependencies: [],
|
||||
),
|
||||
thirtyDayInstallCount: 420_000,
|
||||
)
|
||||
|
||||
static let discoverFormulaeCatalogue: [BrewPackage] = [
|
||||
BrewPackage(
|
||||
name: "git",
|
||||
displayName: "git",
|
||||
kind: .formula,
|
||||
description: "Distributed revision control system",
|
||||
homepage: "https://git-scm.com",
|
||||
latestVersion: "2.46.1",
|
||||
dependencies: [],
|
||||
),
|
||||
BrewPackage(
|
||||
name: "node",
|
||||
displayName: "node",
|
||||
kind: .formula,
|
||||
description: "JavaScript runtime built on V8",
|
||||
homepage: "https://nodejs.org",
|
||||
latestVersion: "22.14.0",
|
||||
dependencies: [],
|
||||
),
|
||||
]
|
||||
static let discoverCasksCatalogue: [BrewPackage] = [
|
||||
BrewPackage(
|
||||
name: "iterm2",
|
||||
displayName: "iTerm2",
|
||||
kind: .cask,
|
||||
description: "Terminal emulator and shell replacement",
|
||||
homepage: "https://iterm2.com",
|
||||
latestVersion: "3.5.0",
|
||||
dependencies: [],
|
||||
),
|
||||
BrewPackage(
|
||||
name: "docker",
|
||||
displayName: "Docker",
|
||||
kind: .cask,
|
||||
description: "Build and share containerized applications",
|
||||
homepage: "https://www.docker.com",
|
||||
latestVersion: "4.39.0",
|
||||
dependencies: [],
|
||||
),
|
||||
]
|
||||
|
||||
static let outdatedFormula = InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "git",
|
||||
displayName: "git",
|
||||
kind: .formula,
|
||||
description: "Distributed revision control system.",
|
||||
homepage: "https://git-scm.com",
|
||||
latestVersion: "2.46.1",
|
||||
dependencies: [.formula(name: "openssl@3"), .formula(name: "pcre2")],
|
||||
),
|
||||
installedVersions: ["2.45.0"],
|
||||
outdated: true,
|
||||
)
|
||||
|
||||
static let currentFormula = InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "node",
|
||||
displayName: "node",
|
||||
kind: .formula,
|
||||
description: "JavaScript runtime built on V8.",
|
||||
homepage: "https://nodejs.org",
|
||||
latestVersion: "22.14.0",
|
||||
dependencies: [.formula(name: "icu4c@76")],
|
||||
),
|
||||
installedVersions: ["22.14.0"],
|
||||
outdated: false,
|
||||
)
|
||||
|
||||
static let currentCask = InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "docker",
|
||||
displayName: "Docker",
|
||||
kind: .cask,
|
||||
description: "Build and share containerized applications.",
|
||||
homepage: "https://www.docker.com",
|
||||
latestVersion: "4.39.0",
|
||||
dependencies: [],
|
||||
),
|
||||
installedVersions: ["4.39.0"],
|
||||
outdated: false,
|
||||
)
|
||||
|
||||
static let installedPackages: [InstalledBrewPackage] = [
|
||||
outdatedFormula,
|
||||
currentFormula,
|
||||
InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "python",
|
||||
displayName: "python",
|
||||
kind: .formula,
|
||||
description: "Interpreted, interactive, object-oriented programming language.",
|
||||
homepage: "https://www.python.org",
|
||||
latestVersion: "3.13.2",
|
||||
dependencies: [.formula(name: "openssl@3"), .formula(name: "sqlite")],
|
||||
),
|
||||
installedVersions: ["3.13.2"],
|
||||
outdated: false,
|
||||
),
|
||||
InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "visual-studio-code",
|
||||
displayName: "Visual Studio Code",
|
||||
kind: .cask,
|
||||
description: "Open-source code editor.",
|
||||
homepage: "https://code.visualstudio.com",
|
||||
latestVersion: "1.99.0",
|
||||
dependencies: [],
|
||||
),
|
||||
installedVersions: ["1.99.0"],
|
||||
outdated: false,
|
||||
),
|
||||
currentCask,
|
||||
]
|
||||
|
||||
static let emptyPackages: [InstalledBrewPackage] = []
|
||||
|
||||
static let installedDependentsByPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage]] = [
|
||||
outdatedFormula.id: [
|
||||
InstalledBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "gh",
|
||||
displayName: "gh",
|
||||
kind: .formula,
|
||||
description: "GitHub's official command line tool.",
|
||||
homepage: "https://cli.github.com",
|
||||
latestVersion: "2.67.0",
|
||||
dependencies: [.formula(name: "git")],
|
||||
),
|
||||
installedVersions: ["2.67.0"],
|
||||
outdated: false,
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
static let installedInventoryIDs: Set<InstalledBrewPackage.ID> = Set(
|
||||
installedPackages.map(\.id) + [
|
||||
.formula(name: "openssl@3"),
|
||||
.formula(name: "pcre2"),
|
||||
.formula(name: "icu4c@76"),
|
||||
],
|
||||
)
|
||||
|
||||
@MainActor
|
||||
static func makeInstalledPackagesRepository(
|
||||
packages: [InstalledBrewPackage] = installedPackages,
|
||||
) -> BrewInstalledPackagesRepository {
|
||||
BrewInstalledPackagesRepository.previewLoaded(packages)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeInstalledViewModel(
|
||||
packages: [InstalledBrewPackage] = installedPackages,
|
||||
) -> InstalledViewModel {
|
||||
InstalledViewModel(repository: makeInstalledPackagesRepository(packages: packages))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeInstalledDependentsRepository() -> any InstalledDependentsRepository {
|
||||
PreviewInstalledDependentsRepository(
|
||||
dependentsByPackageID: installedDependentsByPackageID,
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeInstalledInventoryReading() -> any InstalledInventoryReading {
|
||||
makeInstalledPackagesRepository()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDiscoverPackagesRepository() -> any DiscoverPackagesRepository {
|
||||
PreviewDiscoverPackagesRepository(snapshot: discoverTopPackagesSnapshot)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDiscoverListRowViewModel(
|
||||
package: DiscoveryBrewPackage = discoverPreviewPackage,
|
||||
showsInstallMetrics: Bool = true,
|
||||
) -> DiscoverListRowViewModel {
|
||||
DiscoverListRowViewModel(
|
||||
discoveryPackage: package,
|
||||
installedRepository: makeInstalledPackagesRepository(),
|
||||
brewCommandCenter: NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews()),
|
||||
showsInstallMetrics: showsInstallMetrics,
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDiscoverCatalogueRepository() -> any CatalogueRepository {
|
||||
PreviewDiscoverCatalogueRepository(
|
||||
formulaCatalogue: discoverFormulaeCatalogue,
|
||||
caskCatalogue: discoverCasksCatalogue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
actor PreviewBrewCommandCenter: BrewCommandCenter {
|
||||
func phase(for _: BrewOperationID) async -> BrewOperationPhase {
|
||||
.idle
|
||||
}
|
||||
|
||||
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
[:]
|
||||
}
|
||||
|
||||
func isActive(id _: BrewOperationID) async -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func phaseChanges(for _: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.yield(.idle)
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func outputChanges(for _: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func submit(
|
||||
id _: BrewOperationID,
|
||||
command _: any BrewMutatingCommand,
|
||||
) async throws {}
|
||||
}
|
||||
|
||||
struct PreviewInstalledDependentsRepository: InstalledDependentsRepository {
|
||||
let dependentsByPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage]]
|
||||
|
||||
func installedDependents(for packageID: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
|
||||
dependentsByPackageID[packageID] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewDiscoverPackagesRepository: DiscoverPackagesRepository {
|
||||
let snapshot: DiscoverTopPackagesSnapshot
|
||||
|
||||
func loadTopPackages(
|
||||
limit _: Int,
|
||||
window _: BrewAnalyticsWindow,
|
||||
) async throws -> DiscoverTopPackagesSnapshot {
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewDiscoverCatalogueRepository: CatalogueRepository {
|
||||
let formulaCatalogue: [BrewPackage]
|
||||
let caskCatalogue: [BrewPackage]
|
||||
|
||||
func package(for reference: HomebrewPackageID) async throws -> BrewPackage? {
|
||||
let packages = reference.kind == .formula ? formulaCatalogue : caskCatalogue
|
||||
return packages.first { $0.id == reference }
|
||||
}
|
||||
|
||||
func searchPackages(matching query: String, limit: Int) async throws -> [BrewPackage] {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedQuery.isEmpty, limit > 0 else {
|
||||
return []
|
||||
}
|
||||
let matches = (formulaCatalogue + caskCatalogue).filter {
|
||||
$0.name.localizedCaseInsensitiveContains(trimmedQuery)
|
||||
|| $0.displayName.localizedCaseInsensitiveContains(trimmedQuery)
|
||||
}
|
||||
return Array(matches.prefix(limit))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// BrewInstalledDependentsRepository.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct BrewInstalledDependentsRepository: InstalledDependentsRepository {
|
||||
private let cache: InstalledInventoryCache
|
||||
|
||||
init(cache: InstalledInventoryCache) {
|
||||
self.cache = cache
|
||||
}
|
||||
|
||||
func installedDependents(for packageID: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
|
||||
guard let snapshot = await cache.currentSnapshot() else {
|
||||
return []
|
||||
}
|
||||
return snapshot.graph.installedDependents(for: packageID)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// CommandJobsRepositoryEnvironment.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// App-scoped projection of command-center operations. Injected at the app root with the live instance;
|
||||
/// read with `@Environment(\.commandJobsRepository)` from the console root view, which constructs
|
||||
/// the ``ConsoleViewModel`` that downstream console views consume.
|
||||
/// The default is an inert placeholder for previews and unscoped subtrees.
|
||||
@Entry var commandJobsRepository = BrewCommandJobsRepository.placeholder()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//
|
||||
// InstalledPackagesRepositoryEnvironment.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// App-scoped installed-inventory source of truth. Injected at the nearest common ancestor of the
|
||||
/// Installed and Discover tabs (``MainWindowView``); read with `@Environment(\.installedPackagesRepository)`.
|
||||
/// The default is an inert placeholder for previews and unscoped subtrees.
|
||||
@Entry var installedPackagesRepository = BrewInstalledPackagesRepository.placeholder()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//
|
||||
// CatalogueCacheEnvironment.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Inject from ``BrewApp``; otherwise a default cache is created for previews and unscoped subtrees.
|
||||
@Entry var catalogueCache = CatalogueCache()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// BrewCommandCenterEnvironment.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Mutating `brew` coordinator from the composition root (`BrewApp`).
|
||||
/// Inject with `.environment(\.brewCommandCenter, center)`; read with `@Environment(\.brewCommandCenter)`.
|
||||
@Entry var brewCommandCenter: (any BrewCommandCenter) = SerialBrewCommandCenter(executionContext: .live())
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// BrewCommandExecutionContext.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for mutating `brew` subprocess work passed into ``BrewMutatingCommand/run(in:)``.
|
||||
struct BrewCommandExecutionContext {
|
||||
var commandRunner: BrewCommandRunning
|
||||
var locator: BrewExecutableLocating
|
||||
|
||||
func brewExecutableURL() throws -> URL {
|
||||
try locator.findBrewExecutable()
|
||||
}
|
||||
}
|
||||
|
||||
extension BrewCommandExecutionContext {
|
||||
/// Production wiring: real subprocess runner + default `brew` lookup (same pairing as ``BrewInstalledPackagesRepository/live()``).
|
||||
static func live() -> BrewCommandExecutionContext {
|
||||
BrewCommandExecutionContext(
|
||||
commandRunner: BrewCommandService(),
|
||||
locator: BrewExecutableLocator(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//
|
||||
// BrewExecutableLocating.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Resolves the `brew` executable — real implementation is [`BrewExecutableLocator`](BrewExecutableLocator.swift); tests may inject fakes (`CONVENTIONS.md` — Testing).
|
||||
protocol BrewExecutableLocating: Sendable {
|
||||
func findBrewExecutable() throws -> URL
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// CommandOutput.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated struct CommandOutput: Equatable {
|
||||
var standardOutput: String
|
||||
var standardError: String
|
||||
var terminationStatus: Int32
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
//
|
||||
// InstalledInventoryEnvironment.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// Inject from ``BrewApp``; otherwise a default cache is created for previews and unscoped subtrees.
|
||||
@Entry var installedInventoryCache = InstalledInventoryCache()
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// InstalledInventorySnapshot.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated struct InstalledInventorySnapshot: Equatable {
|
||||
nonisolated static let defaultTTL: TimeInterval = 3600
|
||||
|
||||
var fetchedAt: Date
|
||||
let packages: [InstalledBrewPackage]
|
||||
let graph: PackageDependencyGraph
|
||||
|
||||
init(fetchedAt: Date, packages: [InstalledBrewPackage]) {
|
||||
self.fetchedAt = fetchedAt
|
||||
self.packages = packages
|
||||
graph = PackageDependencyGraph(packages: packages)
|
||||
}
|
||||
|
||||
nonisolated func isStale(relativeTo now: Date = .now, ttl: TimeInterval = Self.defaultTTL) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) >= ttl
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Primary navigation items for the main window sidebar.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
@testable import Brew
|
||||
import Foundation
|
||||
|
||||
struct EmptyInstalledDependentsRepository: InstalledDependentsRepository {
|
||||
func installedDependents(for _: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@testable import Brew
|
||||
import Foundation
|
||||
|
||||
struct EmptyInstalledInventoryReading: InstalledInventoryReading {
|
||||
func installedPackageIDs() async -> Set<InstalledBrewPackage.ID> {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// InstalledPackagesRepositoryTestSupport.swift
|
||||
// BrewTests
|
||||
//
|
||||
// Shared boundary fakes for `BrewInstalledPackagesRepository` / `InstalledViewModel` slice tests (`CONVENTIONS.md` — Testing).
|
||||
//
|
||||
|
||||
@testable import Brew
|
||||
import Foundation
|
||||
|
||||
// MARK: - Locator fakes
|
||||
|
||||
/// Injects `BrewLookupError.executableNotFound` through the real repository (no disk, no real `brew`).
|
||||
struct MissingBrewExecutableLocator: BrewExecutableLocating {
|
||||
func findBrewExecutable() throws -> URL {
|
||||
throw BrewLookupError.executableNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Command runner
|
||||
|
||||
/// Per-invocation result for [`MockBrewCommandRunner`](MockBrewCommandRunner).
|
||||
enum MockBrewCommandRunnerBehavior {
|
||||
case output(CommandOutput)
|
||||
case `throw`(Error)
|
||||
}
|
||||
|
||||
/// Maps `brew` argument lists to output or thrown errors — fails fast on unexpected invocations.
|
||||
struct MockBrewCommandRunner: BrewCommandRunning {
|
||||
private let behaviors: [[String]: MockBrewCommandRunnerBehavior]
|
||||
|
||||
init(behaviors: [[String]: MockBrewCommandRunnerBehavior]) {
|
||||
self.behaviors = behaviors
|
||||
}
|
||||
|
||||
/// Convenience: every invocation returns the given `CommandOutput`.
|
||||
init(responses: [[String]: CommandOutput]) {
|
||||
behaviors = responses.mapValues { .output($0) }
|
||||
}
|
||||
|
||||
func run(executableURL _: URL, arguments: [String]) async throws -> CommandOutput {
|
||||
guard let behavior = behaviors[arguments] else {
|
||||
throw BrewCommandError.failed(exitCode: 99, stderr: "unmocked: \(arguments.joined(separator: " "))")
|
||||
}
|
||||
switch behavior {
|
||||
case let .output(out):
|
||||
return out
|
||||
case let .throw(error):
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a different `brew info` JSON payload per invocation (last payload repeats), for two-load tests.
|
||||
actor QueuedBrewInfoRunner: BrewCommandRunning {
|
||||
private let outputs: [CommandOutput]
|
||||
private var index = 0
|
||||
|
||||
init(infoJSON: [String]) {
|
||||
outputs = infoJSON.map {
|
||||
CommandOutput(standardOutput: $0, standardError: "", terminationStatus: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func run(executableURL _: URL, arguments _: [String]) async throws -> CommandOutput {
|
||||
let output = outputs[min(index, outputs.count - 1)]
|
||||
if index < outputs.count - 1 {
|
||||
index += 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
enum InstalledPackagesTestSupport {
|
||||
/// Stable fake path passed to `commandRunner` when using `BrewExecutableLocator(overrideURL:)`.
|
||||
static let fakeBrewExecutableURL = URL(fileURLWithPath: "/fake/brew")
|
||||
|
||||
/// Wired like production slice tests: default locator is [`fakeBrewExecutableURL`](fakeBrewExecutableURL).
|
||||
@MainActor
|
||||
static func repository(
|
||||
commandRunner: BrewCommandRunning,
|
||||
locator: (any BrewExecutableLocating)? = nil,
|
||||
cache: InstalledInventoryCache? = nil,
|
||||
commandCenter: any BrewCommandCenter = NoopBrewCommandCenter.forTesting(),
|
||||
) -> BrewInstalledPackagesRepository {
|
||||
let resolvedCache = cache ?? InstalledInventoryCache()
|
||||
let resolvedLocator = locator ?? BrewExecutableLocator(overrideURL: fakeBrewExecutableURL)
|
||||
return BrewInstalledPackagesRepository(
|
||||
commandRunner: commandRunner,
|
||||
locator: resolvedLocator,
|
||||
cache: resolvedCache,
|
||||
commandCenter: commandCenter,
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads (force-refresh) and returns the resulting packages, failing the test if the repository did not reach `.loaded`.
|
||||
@MainActor
|
||||
static func loadedPackages(
|
||||
from repository: BrewInstalledPackagesRepository,
|
||||
) async -> [InstalledBrewPackage] {
|
||||
await repository.load(forceRefresh: true)
|
||||
guard case let .loaded(packages) = repository.state else {
|
||||
return []
|
||||
}
|
||||
return packages
|
||||
}
|
||||
|
||||
// MARK: Localized copy (must match `InstalledViewModel.userMessage`)
|
||||
|
||||
static func localizedBrewExecutableNotFoundMessage() -> String {
|
||||
String(
|
||||
localized: "Could not find Homebrew. Install it or ensure brew is in the default location.",
|
||||
comment: "Installed tab error when brew binary missing",
|
||||
)
|
||||
}
|
||||
|
||||
static func localizedGenericLoadFailureMessage() -> String {
|
||||
String(
|
||||
localized: "Something went wrong loading packages.",
|
||||
comment: "Installed tab generic error",
|
||||
)
|
||||
}
|
||||
|
||||
/// Response for `brew info --installed --json=v2`.
|
||||
static func responsesInstalledInfoFailure(
|
||||
standardOutput: String = "",
|
||||
standardError: String,
|
||||
terminationStatus: Int32,
|
||||
) -> [[String]: CommandOutput] {
|
||||
[
|
||||
["info", "--installed", "--json=v2"]: CommandOutput(
|
||||
standardOutput: standardOutput,
|
||||
standardError: standardError,
|
||||
terminationStatus: terminationStatus,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Success response for `brew info --installed --json=v2`.
|
||||
static func installedInfoJSONResponse(
|
||||
standardOutput: String,
|
||||
terminationStatus: Int32 = 0,
|
||||
standardError: String = "",
|
||||
) -> [[String]: CommandOutput] {
|
||||
[
|
||||
["info", "--installed", "--json=v2"]: CommandOutput(
|
||||
standardOutput: standardOutput,
|
||||
standardError: standardError,
|
||||
terminationStatus: terminationStatus,
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
// swift-tools-version: 6.2
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "BrewKit",
|
||||
platforms: [
|
||||
.macOS("26.0"),
|
||||
],
|
||||
products: [
|
||||
.library(name: "BrewCore", targets: ["BrewCore"]),
|
||||
.library(name: "BrewUIComponents", targets: ["BrewUIComponents"]),
|
||||
.library(name: "BrewCLI", targets: ["BrewCLI"]),
|
||||
.library(name: "BrewNetworking", targets: ["BrewNetworking"]),
|
||||
.library(name: "BrewRepositoryInterfaces", targets: ["BrewRepositoryInterfaces"]),
|
||||
.library(name: "BrewRepositories", targets: ["BrewRepositories"]),
|
||||
.library(name: "BrewAppEnvironment", targets: ["BrewAppEnvironment"]),
|
||||
.library(name: "BrewCoreTestSupport", targets: ["BrewCoreTestSupport"]),
|
||||
.library(name: "BrewServicesTestSupport", targets: ["BrewServicesTestSupport"]),
|
||||
.library(name: "BrewFeatureConsole", targets: ["BrewFeatureConsole"]),
|
||||
.library(name: "BrewFeatureInstalled", targets: ["BrewFeatureInstalled"]),
|
||||
.library(name: "BrewFeatureDiscover", targets: ["BrewFeatureDiscover"]),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "BrewCore",
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewUIComponents",
|
||||
dependencies: ["BrewCore"],
|
||||
resources: [
|
||||
.process("Resources/Media.xcassets"),
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewCLI",
|
||||
dependencies: ["BrewCore"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewNetworking",
|
||||
dependencies: ["BrewCore"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewRepositoryInterfaces",
|
||||
dependencies: ["BrewCore"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewRepositories",
|
||||
dependencies: ["BrewRepositoryInterfaces", "BrewCLI", "BrewNetworking"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewAppEnvironment",
|
||||
dependencies: ["BrewCore", "BrewRepositoryInterfaces"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewCoreTestSupport",
|
||||
dependencies: ["BrewCore"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewServicesTestSupport",
|
||||
dependencies: [
|
||||
"BrewCore",
|
||||
"BrewCLI",
|
||||
"BrewNetworking",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewRepositories",
|
||||
"BrewCoreTestSupport",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewFeatureConsole",
|
||||
dependencies: [
|
||||
"BrewCore",
|
||||
"BrewUIComponents",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewAppEnvironment",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewFeatureInstalled",
|
||||
dependencies: [
|
||||
"BrewCore",
|
||||
"BrewUIComponents",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewAppEnvironment",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.target(
|
||||
name: "BrewFeatureDiscover",
|
||||
dependencies: [
|
||||
"BrewCore",
|
||||
"BrewUIComponents",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewAppEnvironment",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
|
||||
// MARK: - Test targets
|
||||
|
||||
.testTarget(
|
||||
name: "BrewCoreTests",
|
||||
dependencies: ["BrewCore", "BrewCoreTestSupport"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewCLITests",
|
||||
dependencies: ["BrewCLI", "BrewCoreTestSupport", "BrewServicesTestSupport"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewNetworkingTests",
|
||||
dependencies: ["BrewNetworking", "BrewCoreTestSupport"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(nil),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewRepositoriesTests",
|
||||
dependencies: [
|
||||
"BrewRepositories",
|
||||
"BrewCore",
|
||||
"BrewCLI",
|
||||
"BrewNetworking",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewCoreTestSupport",
|
||||
"BrewServicesTestSupport",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewUIComponentsTests",
|
||||
dependencies: ["BrewUIComponents", "BrewCore"],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewFeatureConsoleTests",
|
||||
dependencies: [
|
||||
"BrewFeatureConsole",
|
||||
"BrewCore",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewRepositories",
|
||||
"BrewCoreTestSupport",
|
||||
"BrewServicesTestSupport",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewFeatureDiscoverTests",
|
||||
dependencies: [
|
||||
"BrewFeatureDiscover",
|
||||
"BrewCLI",
|
||||
"BrewCore",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewCoreTestSupport",
|
||||
"BrewServicesTestSupport",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
.testTarget(
|
||||
name: "BrewFeatureInstalledTests",
|
||||
dependencies: [
|
||||
"BrewFeatureInstalled",
|
||||
"BrewCLI",
|
||||
"BrewCore",
|
||||
"BrewUIComponents",
|
||||
"BrewRepositoryInterfaces",
|
||||
"BrewRepositories",
|
||||
"BrewCoreTestSupport",
|
||||
"BrewServicesTestSupport",
|
||||
],
|
||||
swiftSettings: [
|
||||
.defaultIsolation(MainActor.self),
|
||||
.swiftLanguageMode(.v6),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// RepositoryEnvironment.swift
|
||||
// BrewAppEnvironment
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import SwiftUI
|
||||
|
||||
public extension EnvironmentValues {
|
||||
/// App-scoped installed-inventory source of truth, injected at the nearest common ancestor of the
|
||||
/// installed-aware features. The default traps — the composition root must inject a live instance.
|
||||
@Entry var installedPackagesRepository: any InstalledPackagesRepository = UnimplementedInstalledPackagesRepository()
|
||||
|
||||
/// Discover top-packages source, injected by the composition root.
|
||||
@Entry var discoverPackagesRepository: any DiscoverPackagesRepository = UnimplementedDiscoverPackagesRepository()
|
||||
|
||||
/// Catalogue lookups/search, injected by the composition root.
|
||||
@Entry var catalogueRepository: any CatalogueRepository = UnimplementedCatalogueRepository()
|
||||
|
||||
/// Live projection of command-center operations for the console, injected by the composition root.
|
||||
@Entry var commandJobsRepository: any CommandJobsObserving = UnimplementedCommandJobsObserving()
|
||||
|
||||
/// Reverse-dependency lookups over the installed inventory, injected by the composition root.
|
||||
@Entry var installedDependentsRepository: any InstalledDependentsRepository = UnimplementedDependentsRepository()
|
||||
|
||||
/// Mutating `brew` coordinator, injected by the composition root.
|
||||
@Entry var brewCommandCenter: any BrewCommandCenter = UnimplementedBrewCommandCenter()
|
||||
|
||||
/// Builds mutating `brew` commands for view models, injected by the composition root.
|
||||
@Entry var mutatingCommandFactory: any BrewMutatingCommandFactory = UnimplementedMutatingCommandFactory()
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//
|
||||
// UnimplementedRepositories.swift
|
||||
// BrewAppEnvironment
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
private nonisolated func unimplemented(_ function: StaticString = #function) -> Never {
|
||||
fatalError("\(function): unimplemented — inject a repository at the composition root.")
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class UnimplementedInstalledPackagesRepository: InstalledPackagesRepository {
|
||||
var state: LoadState<[InstalledBrewPackage], any Error> {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func load(forceRefresh _: Bool) async {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func isInstalled(_: HomebrewPackageID) -> Bool {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func info(for _: HomebrewPackageID) -> InstalledBrewPackage? {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func installedPackageIDs() async -> Set<InstalledBrewPackage.ID> {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
struct UnimplementedDiscoverPackagesRepository: DiscoverPackagesRepository {
|
||||
func loadTopPackages(limit _: Int, window _: BrewAnalyticsWindow) async throws -> DiscoverTopPackagesSnapshot {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
struct UnimplementedCatalogueRepository: CatalogueRepository {
|
||||
func package(for _: HomebrewPackageID) async throws -> BrewPackage? {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func searchPackages(matching _: String, limit _: Int) async throws -> [BrewPackage] {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
struct UnimplementedDependentsRepository: InstalledDependentsRepository {
|
||||
func installedDependents(for _: InstalledBrewPackage.ID) async -> [InstalledBrewPackage] {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
struct UnimplementedMutatingCommandFactory: BrewMutatingCommandFactory {
|
||||
func installCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func upgradeCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func uninstallCommand(kind _: HomebrewPackageKind, name _: String) -> any BrewMutatingCommand {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class UnimplementedCommandJobsObserving: CommandJobsObserving {
|
||||
var jobs: [BrewOperationID: CommandJob] {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
var orderedIDs: [BrewOperationID] {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func remove(id _: BrewOperationID) {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func clearCompleted() {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
|
||||
actor UnimplementedBrewCommandCenter: BrewCommandCenter {
|
||||
func phase(for _: BrewOperationID) async -> BrewOperationPhase {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func isActive(id _: BrewOperationID) async -> Bool {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func phaseChanges(for _: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func outputChanges(for _: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
unimplemented()
|
||||
}
|
||||
|
||||
func submit(id _: BrewOperationID, command _: any BrewMutatingCommand) async throws {
|
||||
unimplemented()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// BrewCommandExecutionContext+Live.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
public extension BrewCommandExecutionContext {
|
||||
/// Production wiring: real subprocess runner + default `brew` lookup.
|
||||
static func live() -> BrewCommandExecutionContext {
|
||||
BrewCommandExecutionContext(
|
||||
commandRunner: BrewCommandService(),
|
||||
locator: BrewExecutableLocator(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -3,11 +3,12 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
extension BrewCommandExecutionContext {
|
||||
public extension BrewCommandExecutionContext {
|
||||
/// Runner returns empty success without touching disk; locator yields a dummy `brew` URL for callers that resolve it.
|
||||
nonisolated static func noopForTestingAndPreviews() -> BrewCommandExecutionContext {
|
||||
static func noopForTestingAndPreviews() -> BrewCommandExecutionContext {
|
||||
BrewCommandExecutionContext(
|
||||
commandRunner: ImmediateSuccessCommandRunner(),
|
||||
locator: BrewExecutableLocator(overrideURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew")),
|
||||
@@ -15,7 +16,7 @@ extension BrewCommandExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated struct ImmediateSuccessCommandRunner: BrewCommandRunning {
|
||||
private struct ImmediateSuccessCommandRunner: BrewCommandRunning {
|
||||
func run(executableURL _: URL, arguments _: [String]) async throws -> CommandOutput {
|
||||
CommandOutput(standardOutput: "", standardError: "", terminationStatus: 0)
|
||||
}
|
||||
+11
-7
@@ -3,13 +3,14 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Subprocess runner for Homebrew CLI (`ARCHITECTURE.md` — Command execution).
|
||||
struct BrewCommandService: BrewCommandRunning {
|
||||
nonisolated init() {}
|
||||
public struct BrewCommandService: BrewCommandRunning {
|
||||
public init() {}
|
||||
|
||||
nonisolated func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput {
|
||||
public func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput {
|
||||
try Task.checkCancellation()
|
||||
|
||||
let process = Process()
|
||||
@@ -68,7 +69,7 @@ struct BrewCommandService: BrewCommandRunning {
|
||||
/// When `sink` is non-nil, also emits each `\n`-terminated line through it as bytes arrive — the trailing
|
||||
/// partial line (no terminating newline) is flushed on EOF if non-empty. Lines whose bytes aren't valid UTF-8
|
||||
/// are dropped from the stream (they remain in the verbatim byte return).
|
||||
private nonisolated static func drainPipe(
|
||||
private static func drainPipe(
|
||||
handle: FileHandle,
|
||||
stream: BrewCommandOutputLine.Stream,
|
||||
sink: (@Sendable (BrewCommandOutputLine) -> Void)?,
|
||||
@@ -106,7 +107,7 @@ struct BrewCommandService: BrewCommandRunning {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func waitForExit(_ process: Process) async -> Int32 {
|
||||
private static func waitForExit(_ process: Process) async -> Int32 {
|
||||
await withCheckedContinuation { continuation in
|
||||
// `waitUntilExit` blocks; run on a GCD worker thread.
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
@@ -117,15 +118,18 @@ struct BrewCommandService: BrewCommandRunning {
|
||||
}
|
||||
}
|
||||
|
||||
// `process` is the only non-Sendable stored state, and every access to it goes through `lock`; the
|
||||
// sole mutating call (`terminate`) runs under that lock, so concurrent use is serialised.
|
||||
// swiftlint:disable:next unchecked_sendable
|
||||
private final class ProcessController: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let process: Process
|
||||
|
||||
nonisolated init(_ process: Process) {
|
||||
init(_ process: Process) {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
nonisolated func terminateIfRunning() {
|
||||
func terminateIfRunning() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if process.isRunning {
|
||||
+5
-4
@@ -3,15 +3,16 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Resolves the `brew` binary without relying on a single hard-coded prefix (`AGENTS.md`).
|
||||
struct BrewExecutableLocator: BrewExecutableLocating {
|
||||
public struct BrewExecutableLocator: BrewExecutableLocating {
|
||||
private let isExecutableAtPath: @Sendable (String) -> Bool
|
||||
/// When set (tests only), skip filesystem probing.
|
||||
private let overrideURL: URL?
|
||||
|
||||
nonisolated init(
|
||||
public init(
|
||||
isExecutableAtPath: @escaping @Sendable (String) -> Bool = { path in
|
||||
FileManager.default.isExecutableFile(atPath: path)
|
||||
},
|
||||
@@ -20,7 +21,7 @@ struct BrewExecutableLocator: BrewExecutableLocating {
|
||||
overrideURL = nil
|
||||
}
|
||||
|
||||
nonisolated init(overrideURL: URL) {
|
||||
public init(overrideURL: URL) {
|
||||
isExecutableAtPath = { path in
|
||||
FileManager.default.isExecutableFile(atPath: path)
|
||||
}
|
||||
@@ -28,7 +29,7 @@ struct BrewExecutableLocator: BrewExecutableLocating {
|
||||
}
|
||||
|
||||
/// Tries Apple Silicon default, then Intel default.
|
||||
nonisolated func findBrewExecutable() throws -> URL {
|
||||
public func findBrewExecutable() throws -> URL {
|
||||
if let overrideURL {
|
||||
return overrideURL
|
||||
}
|
||||
+8
-5
@@ -3,11 +3,12 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Shared installed-inventory storage with TTL semantics. Domain reads (packages, dependents) go through repositories, not this actor.
|
||||
actor InstalledInventoryCache {
|
||||
enum CacheResult {
|
||||
public actor InstalledInventoryCache {
|
||||
public enum CacheResult: Sendable {
|
||||
case fresh([InstalledBrewPackage])
|
||||
case stale([InstalledBrewPackage])
|
||||
case empty
|
||||
@@ -15,15 +16,17 @@ actor InstalledInventoryCache {
|
||||
|
||||
private var snapshot: InstalledInventorySnapshot?
|
||||
|
||||
func replace(_ snapshot: InstalledInventorySnapshot) {
|
||||
public init() {}
|
||||
|
||||
public func replace(_ snapshot: InstalledInventorySnapshot) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
func currentSnapshot() -> InstalledInventorySnapshot? {
|
||||
public func currentSnapshot() -> InstalledInventorySnapshot? {
|
||||
snapshot
|
||||
}
|
||||
|
||||
func cachedPackages() -> CacheResult {
|
||||
public func cachedPackages() -> CacheResult {
|
||||
guard let snapshot else { return .empty }
|
||||
return snapshot.isStale() ? .stale(snapshot.packages) : .fresh(snapshot.packages)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// InstalledInventorySnapshot.swift
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
public struct InstalledInventorySnapshot: Equatable, Sendable {
|
||||
public static let defaultTTL: TimeInterval = 3600
|
||||
|
||||
public var fetchedAt: Date
|
||||
public let packages: [InstalledBrewPackage]
|
||||
public let graph: PackageDependencyGraph
|
||||
|
||||
public init(fetchedAt: Date, packages: [InstalledBrewPackage]) {
|
||||
self.fetchedAt = fetchedAt
|
||||
self.packages = packages
|
||||
graph = PackageDependencyGraph(packages: packages)
|
||||
}
|
||||
|
||||
public func isStale(relativeTo now: Date = .now, ttl: TimeInterval = Self.defaultTTL) -> Bool {
|
||||
now.timeIntervalSince(fetchedAt) >= ttl
|
||||
}
|
||||
}
|
||||
+6
-5
@@ -3,16 +3,17 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
extension BrewInfoJSON {
|
||||
public extension BrewInfoJSON {
|
||||
func installedPackages() -> [InstalledBrewPackage] {
|
||||
let formulaPackages = formulae.map(\.asBrewPackage)
|
||||
let caskPackages = casks.map(\.asBrewPackage)
|
||||
return (formulaPackages + caskPackages).sorted(by: Self.sortByName)
|
||||
}
|
||||
|
||||
private nonisolated static func sortByName(_ lhs: InstalledBrewPackage, _ rhs: InstalledBrewPackage) -> Bool {
|
||||
private static func sortByName(_ lhs: InstalledBrewPackage, _ rhs: InstalledBrewPackage) -> Bool {
|
||||
lhs.package.name.localizedCaseInsensitiveCompare(rhs.package.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
@@ -59,7 +60,7 @@ private extension BrewInfoCask {
|
||||
}
|
||||
|
||||
private extension BrewInfoJSON {
|
||||
nonisolated static func uniqueNonEmpty(_ values: [String]) -> [String] {
|
||||
static func uniqueNonEmpty(_ values: [String]) -> [String] {
|
||||
var seen: Set<String> = []
|
||||
var result: [String] = []
|
||||
for value in values {
|
||||
@@ -72,14 +73,14 @@ private extension BrewInfoJSON {
|
||||
return result
|
||||
}
|
||||
|
||||
nonisolated static func trimmedOrNil(_ value: String?) -> String? {
|
||||
static func trimmedOrNil(_ value: String?) -> String? {
|
||||
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
nonisolated static func trimmedOrEmpty(_ value: String?) -> String {
|
||||
static func trimmedOrEmpty(_ value: String?) -> String {
|
||||
trimmedOrNil(value) ?? ""
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -3,14 +3,15 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Minimal resilient schema for `brew info --json=v2`.
|
||||
struct BrewInfoJSON: Decodable {
|
||||
public struct BrewInfoJSON: Decodable {
|
||||
var formulae: [BrewInfoFormula]
|
||||
var casks: [BrewInfoCask]
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
formulae = (try? container.decode([BrewInfoFormula].self, forKey: .formulae)) ?? []
|
||||
casks = (try? container.decode([BrewInfoCask].self, forKey: .casks)) ?? []
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// LiveBrewMutatingCommandFactory.swift
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Production factory vending the concrete `brew` command types from this module.
|
||||
public struct LiveBrewMutatingCommandFactory: BrewMutatingCommandFactory {
|
||||
public init() {}
|
||||
|
||||
public func installCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
|
||||
PackageInstallCommand(kind: kind, name: name)
|
||||
}
|
||||
|
||||
public func upgradeCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
|
||||
PackageUpgradeCommand(kind: kind, name: name)
|
||||
}
|
||||
|
||||
public func uninstallCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand {
|
||||
PackageUninstallCommand(kind: kind, name: name)
|
||||
}
|
||||
}
|
||||
+14
-13
@@ -1,43 +1,44 @@
|
||||
//
|
||||
// NoopBrewCommandCenter.swift
|
||||
// Brew
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Pass-through center for previews and tests: runs commands immediately with no phase bookkeeping or queuing.
|
||||
actor NoopBrewCommandCenter: BrewCommandCenter {
|
||||
public actor NoopBrewCommandCenter: BrewCommandCenter {
|
||||
private let executionContext: BrewCommandExecutionContext
|
||||
|
||||
init(executionContext: BrewCommandExecutionContext) {
|
||||
public init(executionContext: BrewCommandExecutionContext) {
|
||||
self.executionContext = executionContext
|
||||
}
|
||||
|
||||
/// Preconfigured noop center for SwiftUI previews (immediate execution, standard noop context).
|
||||
nonisolated static func preview() -> NoopBrewCommandCenter {
|
||||
public static func preview() -> NoopBrewCommandCenter {
|
||||
NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews())
|
||||
}
|
||||
|
||||
/// Preconfigured noop center for unit tests (same wiring as ``preview()``).
|
||||
nonisolated static func forTesting() -> NoopBrewCommandCenter {
|
||||
public static func forTesting() -> NoopBrewCommandCenter {
|
||||
NoopBrewCommandCenter(executionContext: .noopForTestingAndPreviews())
|
||||
}
|
||||
|
||||
func phase(for id: BrewOperationID) async -> BrewOperationPhase {
|
||||
public func phase(for id: BrewOperationID) async -> BrewOperationPhase {
|
||||
_ = id
|
||||
return .idle
|
||||
}
|
||||
|
||||
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
public func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
[:]
|
||||
}
|
||||
|
||||
func isActive(id: BrewOperationID) async -> Bool {
|
||||
public func isActive(id: BrewOperationID) async -> Bool {
|
||||
_ = id
|
||||
return false
|
||||
}
|
||||
|
||||
func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
public func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
_ = id
|
||||
return AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.yield(BrewOperationPhase.idle)
|
||||
@@ -45,26 +46,26 @@ actor NoopBrewCommandCenter: BrewCommandCenter {
|
||||
}
|
||||
}
|
||||
|
||||
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
public func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
public func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
_ = id
|
||||
return AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
public func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func submit(
|
||||
public func submit(
|
||||
id: BrewOperationID,
|
||||
command: any BrewMutatingCommand,
|
||||
) async throws {
|
||||
+3
-2
@@ -3,12 +3,13 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Schedules `brew install <name>` or `brew install --cask <name>` via ``BrewCommandCenter/submit``,
|
||||
/// using ``BrewCommandExecutionContext`` for subprocess execution and `brew` resolution.
|
||||
///
|
||||
nonisolated struct PackageInstallCommand: BrewMutatingCommand {
|
||||
struct PackageInstallCommand: BrewMutatingCommand {
|
||||
let packageName: String
|
||||
let kind: InstalledPackageKind
|
||||
|
||||
@@ -22,7 +23,7 @@ nonisolated struct PackageInstallCommand: BrewMutatingCommand {
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
nonisolated var operationKind: BrewOperationKind {
|
||||
var operationKind: BrewOperationKind {
|
||||
switch kind {
|
||||
case .formula:
|
||||
.installFormula
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
// PackageUninstallCommand.swift
|
||||
// Brew
|
||||
//
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Schedules `brew uninstall <name>` or `brew uninstall --cask <name>` via ``BrewCommandCenter/submit``,
|
||||
@@ -20,7 +21,7 @@ struct PackageUninstallCommand: BrewMutatingCommand {
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
nonisolated var operationKind: BrewOperationKind {
|
||||
var operationKind: BrewOperationKind {
|
||||
switch kind {
|
||||
case .formula:
|
||||
.uninstallFormula
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
/// Schedules `brew upgrade <name>` or `brew upgrade --cask <name>` via ``BrewCommandCenter/submit``,
|
||||
@@ -22,7 +23,7 @@ struct PackageUpgradeCommand: BrewMutatingCommand {
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
nonisolated var operationKind: BrewOperationKind {
|
||||
var operationKind: BrewOperationKind {
|
||||
switch kind {
|
||||
case .formula:
|
||||
.upgradeFormula
|
||||
+12
-11
@@ -1,8 +1,9 @@
|
||||
//
|
||||
// SerialBrewCommandCenter.swift
|
||||
// Brew
|
||||
// BrewCLI
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
private typealias AllPhaseStreamTermination =
|
||||
@@ -42,7 +43,7 @@ private struct AllOutputStreamListener {
|
||||
}
|
||||
|
||||
/// Default app implementation: serializes mutating `brew` subprocess work and exposes per-operation phase for UI.
|
||||
actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
public actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
private let executionContext: BrewCommandExecutionContext
|
||||
private let workQueue = SerialBrewWorkQueue()
|
||||
|
||||
@@ -53,26 +54,26 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
private var outputListenersByID: [BrewOperationID: [OutputStreamListener]] = [:]
|
||||
private var allOutputListeners: [AllOutputStreamListener] = []
|
||||
|
||||
init(executionContext: BrewCommandExecutionContext) {
|
||||
public init(executionContext: BrewCommandExecutionContext) {
|
||||
self.executionContext = executionContext
|
||||
}
|
||||
|
||||
func phase(for id: BrewOperationID) async -> BrewOperationPhase {
|
||||
public func phase(for id: BrewOperationID) async -> BrewOperationPhase {
|
||||
trackedPhasesByID[id] ?? .idle
|
||||
}
|
||||
|
||||
func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
public func phaseByID() async -> [BrewOperationID: BrewOperationPhase] {
|
||||
trackedPhasesByID
|
||||
}
|
||||
|
||||
func isActive(id: BrewOperationID) async -> Bool {
|
||||
public func isActive(id: BrewOperationID) async -> Bool {
|
||||
if case .running = trackedPhasesByID[id] ?? .idle {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
public func phaseChanges(for id: BrewOperationID) async -> AsyncStream<BrewOperationPhase> {
|
||||
AsyncStream<BrewOperationPhase>(bufferingPolicy: .unbounded) { continuation in
|
||||
let token = UUID()
|
||||
continuation.onTermination = { @Sendable (_: AsyncStream<BrewOperationPhase>.Continuation.Termination) in
|
||||
@@ -84,7 +85,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
}
|
||||
}
|
||||
|
||||
func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
public func allPhaseChanges() async -> AsyncStream<(BrewOperationID, BrewOperationPhase)> {
|
||||
AsyncStream<(BrewOperationID, BrewOperationPhase)>(bufferingPolicy: .unbounded) { continuation in
|
||||
let token = UUID()
|
||||
continuation.onTermination = { @Sendable (_: AllPhaseStreamTermination) in
|
||||
@@ -96,7 +97,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
}
|
||||
}
|
||||
|
||||
func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
public func outputChanges(for id: BrewOperationID) async -> AsyncStream<BrewCommandOutputLine> {
|
||||
AsyncStream<BrewCommandOutputLine>(bufferingPolicy: .unbounded) { continuation in
|
||||
let token = UUID()
|
||||
continuation.onTermination = { @Sendable (_: OutputStreamTermination) in
|
||||
@@ -108,7 +109,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
}
|
||||
}
|
||||
|
||||
func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
public func allOutputChanges() async -> AsyncStream<(BrewOperationID, BrewCommandOutputLine)> {
|
||||
AsyncStream<(BrewOperationID, BrewCommandOutputLine)>(bufferingPolicy: .unbounded) { continuation in
|
||||
let token = UUID()
|
||||
continuation.onTermination = { @Sendable (_: AllOutputStreamTermination) in
|
||||
@@ -120,7 +121,7 @@ actor SerialBrewCommandCenter: BrewCommandCenter {
|
||||
}
|
||||
}
|
||||
|
||||
func submit(
|
||||
public func submit(
|
||||
id: BrewOperationID,
|
||||
command: any BrewMutatingCommand,
|
||||
) async throws {
|
||||
+22
-18
@@ -1,31 +1,30 @@
|
||||
//
|
||||
// BrewAnalyticsJSON.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Supported Homebrew analytics windows used by Discover.
|
||||
nonisolated enum BrewAnalyticsWindow: String, CaseIterable {
|
||||
public enum BrewAnalyticsWindow: String, CaseIterable, Sendable {
|
||||
case days30 = "30d"
|
||||
case days90 = "90d"
|
||||
case days365 = "365d"
|
||||
}
|
||||
|
||||
/// Strict schema for Homebrew analytics API responses.
|
||||
nonisolated struct BrewAnalyticsJSON: Decodable {
|
||||
let category: String
|
||||
let totalItems: Int
|
||||
let totalCount: Int
|
||||
let startDate: String
|
||||
let endDate: String
|
||||
public struct BrewAnalyticsJSON: Decodable, Sendable {
|
||||
public let category: String
|
||||
public let totalItems: Int
|
||||
public let totalCount: Int
|
||||
public let startDate: String
|
||||
public let endDate: String
|
||||
private let parsedPackageCounts: [BrewAnalyticsPackageCount]
|
||||
|
||||
var packageCounts: [BrewAnalyticsPackageCount] {
|
||||
public var packageCounts: [BrewAnalyticsPackageCount] {
|
||||
parsedPackageCounts
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
category = try container.decodeNonEmptyString(forKey: .category)
|
||||
totalItems = try container.decodeRequiredIntLossy(forKey: .totalItems)
|
||||
@@ -74,16 +73,21 @@ nonisolated struct BrewAnalyticsJSON: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct BrewAnalyticsPackageCount: Hashable {
|
||||
let reference: HomebrewPackageID
|
||||
let count: Int
|
||||
public struct BrewAnalyticsPackageCount: Hashable, Sendable {
|
||||
public let reference: HomebrewPackageID
|
||||
public let count: Int
|
||||
|
||||
var name: String {
|
||||
public var name: String {
|
||||
reference.name
|
||||
}
|
||||
|
||||
public init(reference: HomebrewPackageID, count: Int) {
|
||||
self.reference = reference
|
||||
self.count = count
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated struct BrewAnalyticsEntry: Decodable {
|
||||
private struct BrewAnalyticsEntry: Decodable {
|
||||
let formula: String?
|
||||
let cask: String?
|
||||
let count: Int
|
||||
@@ -136,7 +140,7 @@ private nonisolated struct BrewAnalyticsEntry: Decodable {
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
nonisolated func decodeRequiredIntLossy(forKey key: Key) throws -> Int {
|
||||
func decodeRequiredIntLossy(forKey key: Key) throws -> Int {
|
||||
if let value = try? decode(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
@@ -153,7 +157,7 @@ private extension KeyedDecodingContainer {
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func decodeNonEmptyString(forKey key: Key) throws -> String {
|
||||
func decodeNonEmptyString(forKey key: Key) throws -> String {
|
||||
let value = try decode(String.self, forKey: key)
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// BrewPackage.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct BrewPackage: Identifiable, Hashable, Sendable {
|
||||
public let name: String
|
||||
public let displayName: String
|
||||
public let kind: HomebrewPackageKind
|
||||
public var description: String
|
||||
public var homepage: String
|
||||
public var latestVersion: String
|
||||
public var dependencies: [HomebrewPackageID]
|
||||
|
||||
public var id: HomebrewPackageID {
|
||||
reference
|
||||
}
|
||||
|
||||
public var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: self)
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
displayName: String,
|
||||
kind: HomebrewPackageKind,
|
||||
description: String,
|
||||
homepage: String,
|
||||
latestVersion: String,
|
||||
dependencies: [HomebrewPackageID],
|
||||
) {
|
||||
self.name = name
|
||||
self.displayName = displayName
|
||||
self.kind = kind
|
||||
self.description = description
|
||||
self.homepage = homepage
|
||||
self.latestVersion = latestVersion
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// DiscoveryBrewPackage+Placeholdable.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated extension DiscoveryBrewPackage: Placeholdable {
|
||||
static var placeholder: DiscoveryBrewPackage {
|
||||
extension DiscoveryBrewPackage: Placeholdable {
|
||||
public static var placeholder: DiscoveryBrewPackage {
|
||||
DiscoveryBrewPackage(
|
||||
package: BrewPackage(
|
||||
name: "placeholder-package",
|
||||
@@ -22,10 +22,10 @@ nonisolated extension DiscoveryBrewPackage: Placeholdable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension [DiscoveryBrewPackage]: Placeholdable {
|
||||
extension [DiscoveryBrewPackage]: Placeholdable {
|
||||
/// A spread of distinct, mixed-kind stub packages. Distinct ids keep `ForEach` happy, and the
|
||||
/// formula/cask mix lets both sections render redacted under the "All" scope while loading.
|
||||
static var placeholder: [DiscoveryBrewPackage] {
|
||||
public static var placeholder: [DiscoveryBrewPackage] {
|
||||
(0 ..< 8).map { index in
|
||||
let kind: HomebrewPackageKind = index.isMultiple(of: 2) ? .formula : .cask
|
||||
return DiscoveryBrewPackage(
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// DiscoveryBrewPackage.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Domain output for Discover top package sections.
|
||||
public struct DiscoverTopPackagesSnapshot: Equatable, Sendable {
|
||||
public let topFormulae: [DiscoveryBrewPackage]
|
||||
public let topCasks: [DiscoveryBrewPackage]
|
||||
|
||||
public init(topFormulae: [DiscoveryBrewPackage], topCasks: [DiscoveryBrewPackage]) {
|
||||
self.topFormulae = topFormulae
|
||||
self.topCasks = topCasks
|
||||
}
|
||||
}
|
||||
|
||||
public struct DiscoveryBrewPackage: Identifiable, Equatable, Hashable, Sendable {
|
||||
public var package: BrewPackage
|
||||
public var thirtyDayInstallCount: Int
|
||||
|
||||
public var name: String {
|
||||
package.name
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
package.displayName
|
||||
}
|
||||
|
||||
public var kind: HomebrewPackageKind {
|
||||
package.kind
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
get { package.description }
|
||||
set { package.description = newValue }
|
||||
}
|
||||
|
||||
public var homepage: String {
|
||||
get { package.homepage }
|
||||
set { package.homepage = newValue }
|
||||
}
|
||||
|
||||
public var latestVersion: String {
|
||||
get { package.latestVersion }
|
||||
set { package.latestVersion = newValue }
|
||||
}
|
||||
|
||||
public var dependencies: [HomebrewPackageID] {
|
||||
get { package.dependencies }
|
||||
set { package.dependencies = newValue }
|
||||
}
|
||||
|
||||
public var id: HomebrewPackageID {
|
||||
package.id
|
||||
}
|
||||
|
||||
public var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: package)
|
||||
}
|
||||
|
||||
public init(package: BrewPackage, thirtyDayInstallCount: Int) {
|
||||
self.package = package
|
||||
self.thirtyDayInstallCount = thirtyDayInstallCount
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// HomebrewPackageID.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -9,15 +9,15 @@ import Foundation
|
||||
///
|
||||
/// This is the canonical package identity across the app: every package type's
|
||||
/// `id` is a `HomebrewPackageID`, and the type is its own `Identifiable.ID`.
|
||||
nonisolated enum HomebrewPackageID: Hashable, Identifiable {
|
||||
public enum HomebrewPackageID: Hashable, Identifiable, Sendable {
|
||||
case formula(name: String)
|
||||
case cask(token: String)
|
||||
|
||||
var id: Self {
|
||||
public var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var kind: HomebrewPackageKind {
|
||||
public var kind: HomebrewPackageKind {
|
||||
switch self {
|
||||
case .formula:
|
||||
.formula
|
||||
@@ -26,7 +26,7 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
var name: String {
|
||||
public var name: String {
|
||||
switch self {
|
||||
case let .formula(name):
|
||||
name
|
||||
@@ -35,7 +35,7 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
init(package: BrewPackage) {
|
||||
public init(package: BrewPackage) {
|
||||
switch package.kind {
|
||||
case .formula:
|
||||
self = .formula(name: package.name)
|
||||
@@ -44,12 +44,12 @@ nonisolated enum HomebrewPackageID: Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
init(installedPackage: InstalledBrewPackage) {
|
||||
public init(installedPackage: InstalledBrewPackage) {
|
||||
self.init(package: installedPackage.package)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension HomebrewPackageID {
|
||||
public extension HomebrewPackageID {
|
||||
static func formulaDependencies(from names: [String]) -> [HomebrewPackageID] {
|
||||
uniqueReferences(names.map { .formula(name: $0) })
|
||||
}
|
||||
+3
-3
@@ -1,15 +1,15 @@
|
||||
//
|
||||
// HomebrewPackageKind.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Whether an installed Homebrew unit is a formula or a cask — domain-level discriminator (repositories, operations, presentation).
|
||||
nonisolated enum HomebrewPackageKind: String, Hashable {
|
||||
public enum HomebrewPackageKind: String, Hashable, Sendable {
|
||||
case formula
|
||||
case cask
|
||||
}
|
||||
|
||||
/// Stable synonym used across the Installed feature and tests — identical to ``HomebrewPackageKind``.
|
||||
typealias InstalledPackageKind = HomebrewPackageKind
|
||||
public typealias InstalledPackageKind = HomebrewPackageKind
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// InstalledBrewPackage.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct InstalledBrewPackage: Identifiable, Hashable, Sendable {
|
||||
public var package: BrewPackage
|
||||
public var installedVersions: [String]
|
||||
public var outdated: Bool
|
||||
|
||||
public var name: String {
|
||||
package.name
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
package.displayName
|
||||
}
|
||||
|
||||
public var kind: HomebrewPackageKind {
|
||||
package.kind
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
get { package.description }
|
||||
set { package.description = newValue }
|
||||
}
|
||||
|
||||
public var homepage: String {
|
||||
get { package.homepage }
|
||||
set { package.homepage = newValue }
|
||||
}
|
||||
|
||||
public var latestVersion: String {
|
||||
get { package.latestVersion }
|
||||
set { package.latestVersion = newValue }
|
||||
}
|
||||
|
||||
public var dependencies: [HomebrewPackageID] {
|
||||
get { package.dependencies }
|
||||
set { package.dependencies = newValue }
|
||||
}
|
||||
|
||||
public var id: HomebrewPackageID {
|
||||
package.id
|
||||
}
|
||||
|
||||
public var reference: HomebrewPackageID {
|
||||
HomebrewPackageID(package: package)
|
||||
}
|
||||
|
||||
public init(package: BrewPackage, installedVersions: [String], outdated: Bool) {
|
||||
self.package = package
|
||||
self.installedVersions = installedVersions
|
||||
self.outdated = outdated
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,16 +1,16 @@
|
||||
//
|
||||
// PackageDependencyGraph.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Reverse dependency lookups over one installed-inventory snapshot.
|
||||
nonisolated struct PackageDependencyGraph: Equatable {
|
||||
public struct PackageDependencyGraph: Equatable, Sendable {
|
||||
private let packagesByID: [InstalledBrewPackage.ID: InstalledBrewPackage]
|
||||
private let dependentsByDependencyPackageID: [InstalledBrewPackage.ID: [InstalledBrewPackage.ID]]
|
||||
|
||||
init(packages: [InstalledBrewPackage]) {
|
||||
public init(packages: [InstalledBrewPackage]) {
|
||||
var byID: [InstalledBrewPackage.ID: InstalledBrewPackage] = [:]
|
||||
var reverse: [InstalledBrewPackage.ID: [InstalledBrewPackage.ID]] = [:]
|
||||
|
||||
@@ -25,7 +25,7 @@ nonisolated struct PackageDependencyGraph: Equatable {
|
||||
dependentsByDependencyPackageID = reverse
|
||||
}
|
||||
|
||||
func installedDependents(for packageID: InstalledBrewPackage.ID) -> [InstalledBrewPackage] {
|
||||
public func installedDependents(for packageID: InstalledBrewPackage.ID) -> [InstalledBrewPackage] {
|
||||
(dependentsByDependencyPackageID[packageID] ?? [])
|
||||
.compactMap { packagesByID[$0] }
|
||||
.sorted {
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// BrewAPIClientError.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Failure surfaced by the Homebrew JSON API client. Lives in BrewCore so feature view models can map it
|
||||
/// to user-facing copy without importing the networking layer (mirrors ``BrewCommandError``).
|
||||
public enum BrewAPIClientError: Error, Equatable, Sendable {
|
||||
case invalidURL(path: String)
|
||||
case transport(underlying: String)
|
||||
case invalidResponse
|
||||
case httpStatus(code: Int, bodySnippet: String)
|
||||
case decoding(underlying: String)
|
||||
}
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// BrewCommandCenter.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Coordinates mutating `brew` operations and cross-surface visibility (`ARCHITECTURE.md` — command execution).
|
||||
protocol BrewCommandCenter: Actor {
|
||||
public protocol BrewCommandCenter: Actor {
|
||||
/// Snapshot for UI — ``BrewOperationPhase/idle`` when no state is tracked for `id`.
|
||||
func phase(for id: BrewOperationID) async -> BrewOperationPhase
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// BrewCommandError.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// `brew` exited non-zero or could not be launched.
|
||||
nonisolated enum BrewCommandError: Error, Equatable {
|
||||
public enum BrewCommandError: Error, Equatable, Sendable {
|
||||
case failed(exitCode: Int32, stderr: String)
|
||||
case launchFailed(underlying: String)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// BrewCommandExecutionContext.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for mutating `brew` subprocess work passed into ``BrewMutatingCommand/run(in:)``.
|
||||
public struct BrewCommandExecutionContext: Sendable {
|
||||
public var commandRunner: BrewCommandRunning
|
||||
public var locator: BrewExecutableLocating
|
||||
|
||||
public init(commandRunner: BrewCommandRunning, locator: BrewExecutableLocating) {
|
||||
self.commandRunner = commandRunner
|
||||
self.locator = locator
|
||||
}
|
||||
|
||||
public func brewExecutableURL() throws -> URL {
|
||||
try locator.findBrewExecutable()
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// BrewCommandOutputContext.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -8,10 +8,10 @@ import Foundation
|
||||
/// Task-local plumbing for streaming subprocess output without threading a sink through every command type.
|
||||
///
|
||||
/// ``BrewCommandCenter/submit(id:command:)`` implementations set ``sink`` for the duration of a submit;
|
||||
/// ``BrewCommandService`` reads the value and forwards each `\n`-delimited line through it as the subprocess emits bytes.
|
||||
/// `BrewCommandService` reads the value and forwards each `\n`-delimited line through it as the subprocess emits bytes.
|
||||
/// Commands themselves remain unchanged — they still call `context.commandRunner.run(...)`.
|
||||
nonisolated enum BrewCommandOutputContext {
|
||||
public enum BrewCommandOutputContext {
|
||||
/// Per-task sink for subprocess output. `nil` when no console is observing — the runner falls back to the
|
||||
/// existing buffered-read path with no overhead.
|
||||
@TaskLocal static var sink: (@Sendable (BrewCommandOutputLine) -> Void)?
|
||||
@TaskLocal public static var sink: (@Sendable (BrewCommandOutputLine) -> Void)?
|
||||
}
|
||||
+8
-8
@@ -1,23 +1,23 @@
|
||||
//
|
||||
// BrewCommandOutputLine.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// One line of subprocess output, attributed to a stream (`ARCHITECTURE.md` — command execution; transparency).
|
||||
nonisolated struct BrewCommandOutputLine: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let stream: Stream
|
||||
let text: String
|
||||
let timestamp: Date
|
||||
public struct BrewCommandOutputLine: Identifiable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let stream: Stream
|
||||
public let text: String
|
||||
public let timestamp: Date
|
||||
|
||||
enum Stream: Equatable {
|
||||
public enum Stream: Equatable, Sendable {
|
||||
case stdout
|
||||
case stderr
|
||||
}
|
||||
|
||||
init(stream: Stream, text: String, timestamp: Date = Date(), id: UUID = UUID()) {
|
||||
public init(stream: Stream, text: String, timestamp: Date = Date(), id: UUID = UUID()) {
|
||||
self.id = id
|
||||
self.stream = stream
|
||||
self.text = text
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
//
|
||||
// BrewCommandRunning.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Runs `brew` (or any executable) and captures stdout/stderr — mock in tests (`CONVENTIONS.md`).
|
||||
protocol BrewCommandRunning: Sendable {
|
||||
public protocol BrewCommandRunning: Sendable {
|
||||
func run(executableURL: URL, arguments: [String]) async throws -> CommandOutput
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// BrewExecutableLocating.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Resolves the `brew` executable — real implementation is `BrewExecutableLocator` in `BrewCLI`; tests may inject fakes (`CONVENTIONS.md` — Testing).
|
||||
public protocol BrewExecutableLocating: Sendable {
|
||||
func findBrewExecutable() throws -> URL
|
||||
}
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
//
|
||||
// BrewLookupError.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Could not locate a `brew` executable in supported locations.
|
||||
nonisolated enum BrewLookupError: Error, Equatable {
|
||||
public enum BrewLookupError: Error, Equatable, Sendable {
|
||||
case executableNotFound
|
||||
}
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
//
|
||||
// BrewMutatingCommand.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// One schedulable unit of mutating Homebrew work (command pattern); implemented by small `Sendable` types with `run(in:)`.
|
||||
protocol BrewMutatingCommand: Sendable {
|
||||
public protocol BrewMutatingCommand: Sendable {
|
||||
/// Kind of mutating work — drives ``BrewOperationPhase/running(_:)`` when this command is scheduled.
|
||||
nonisolated var operationKind: BrewOperationKind { get }
|
||||
var operationKind: BrewOperationKind { get }
|
||||
|
||||
func run(in context: BrewCommandExecutionContext) async throws
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// BrewMutatingCommandFactory.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Builds concrete ``BrewMutatingCommand`` values without exposing their types to callers.
|
||||
///
|
||||
/// View models depend on this port (an abstraction over `BrewCore` value types) rather than importing
|
||||
/// the concrete command types in `BrewCLI`, then hand the result to ``BrewCommandCenter/submit(id:command:)``.
|
||||
public protocol BrewMutatingCommandFactory: Sendable {
|
||||
func installCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
|
||||
func upgradeCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
|
||||
func uninstallCommand(kind: HomebrewPackageKind, name: String) -> any BrewMutatingCommand
|
||||
}
|
||||
+5
-5
@@ -1,22 +1,22 @@
|
||||
//
|
||||
// BrewOperationID+Homebrew.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
nonisolated extension BrewOperationID {
|
||||
public extension BrewOperationID {
|
||||
/// Operation identity from a domain package kind + name.
|
||||
init(kind: HomebrewPackageKind, name: String) {
|
||||
switch kind {
|
||||
case .formula:
|
||||
packageID = .formula(name: name)
|
||||
self.init(packageID: .formula(name: name))
|
||||
case .cask:
|
||||
packageID = .cask(token: name)
|
||||
self.init(packageID: .cask(token: name))
|
||||
}
|
||||
}
|
||||
|
||||
init(package: InstalledBrewPackage) {
|
||||
packageID = HomebrewPackageID(installedPackage: package)
|
||||
self.init(packageID: HomebrewPackageID(installedPackage: package))
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// BrewOperationModels.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Mutating Homebrew work the command center may schedule (extend as features grow).
|
||||
nonisolated enum BrewOperationKind: String, Hashable {
|
||||
public enum BrewOperationKind: String, Hashable, Sendable {
|
||||
case installFormula
|
||||
case installCask
|
||||
case upgradeFormula
|
||||
@@ -18,17 +18,21 @@ nonisolated enum BrewOperationKind: String, Hashable {
|
||||
/// Stable identity for in-flight mutating work (e.g. upgrades), backed by the canonical
|
||||
/// ``HomebrewPackageID``. There is one mutating operation per package at a time, so the package
|
||||
/// identity *is* the operation key — no stringly-typed `kind:name` encoding to parse.
|
||||
nonisolated struct BrewOperationID: Hashable, Identifiable {
|
||||
let packageID: HomebrewPackageID
|
||||
public struct BrewOperationID: Hashable, Identifiable, Sendable {
|
||||
public let packageID: HomebrewPackageID
|
||||
|
||||
var id: HomebrewPackageID {
|
||||
public var id: HomebrewPackageID {
|
||||
packageID
|
||||
}
|
||||
|
||||
public init(packageID: HomebrewPackageID) {
|
||||
self.packageID = packageID
|
||||
}
|
||||
}
|
||||
|
||||
/// Visibility for UI and tests — mutually exclusive with “absent” represented by ``BrewCommandCenter/phase(for:)``
|
||||
/// returning ``BrewOperationPhase/idle`` when the center has no record for that id.
|
||||
nonisolated enum BrewOperationPhase: Equatable {
|
||||
public enum BrewOperationPhase: Equatable, Sendable {
|
||||
case idle
|
||||
case running(BrewOperationKind)
|
||||
case failed(reason: OperationFailure)
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// CommandOutput.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct CommandOutput: Equatable, Sendable {
|
||||
public var standardOutput: String
|
||||
public var standardError: String
|
||||
public var terminationStatus: Int32
|
||||
|
||||
public init(standardOutput: String, standardError: String, terminationStatus: Int32) {
|
||||
self.standardOutput = standardOutput
|
||||
self.standardError = standardError
|
||||
self.terminationStatus = terminationStatus
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
//
|
||||
// OperationFailure.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// User-visible and diagnostic failure surfaced through ``BrewOperationPhase/failed(reason:)`` (`Sendable` for actor-isolated state).
|
||||
nonisolated enum OperationFailure: Equatable {
|
||||
public enum OperationFailure: Equatable, Sendable {
|
||||
/// `brew` ran but exited non-zero; stderr is the primary user-visible detail when present.
|
||||
case brewCommand(exitCode: Int32, stderr: String)
|
||||
|
||||
@@ -20,7 +20,7 @@ nonisolated enum OperationFailure: Equatable {
|
||||
case generic(userFacing: String, diagnostic: String?)
|
||||
|
||||
/// Primary line for UI and accessibility (derived per case).
|
||||
var userFacingMessage: String {
|
||||
public var userFacingMessage: String {
|
||||
switch self {
|
||||
case let .brewCommand(_, stderr):
|
||||
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -46,11 +46,11 @@ nonisolated enum OperationFailure: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
init(userFacingMessage: String, diagnosticDescription: String? = nil) {
|
||||
public init(userFacingMessage: String, diagnosticDescription: String? = nil) {
|
||||
self = .generic(userFacing: userFacingMessage, diagnostic: diagnosticDescription)
|
||||
}
|
||||
|
||||
init(catching error: Error) {
|
||||
public init(catching error: Error) {
|
||||
switch error {
|
||||
case let brewCommandError as BrewCommandError:
|
||||
switch brewCommandError {
|
||||
+5
-5
@@ -1,14 +1,14 @@
|
||||
//
|
||||
// InstalledBrewVersionFormatting.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Display-only version rules for installed list rows (used from `InstalledViewModel`, not repositories).
|
||||
nonisolated enum InstalledBrewVersionFormatting {
|
||||
/// Display-only version rules shared by installed and discover surfaces (used from view models, not repositories).
|
||||
public enum InstalledBrewVersionFormatting {
|
||||
/// Applies the same prefix rules as listed installed versions (`v` only when absent).
|
||||
static func displayVersionLabel(trimmedRaw: String) -> String {
|
||||
public static func displayVersionLabel(trimmedRaw: String) -> String {
|
||||
if trimmedRaw.hasPrefix("v") || trimmedRaw.hasPrefix("V") {
|
||||
return trimmedRaw
|
||||
}
|
||||
@@ -16,7 +16,7 @@ nonisolated enum InstalledBrewVersionFormatting {
|
||||
}
|
||||
|
||||
/// Optional raw tap/stable string → display label; nil when missing or whitespace-only after trim.
|
||||
static func upgradeDisplayLabel(from raw: String?) -> String? {
|
||||
public static func upgradeDisplayLabel(from raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else {
|
||||
return nil
|
||||
@@ -1,6 +1,6 @@
|
||||
//
|
||||
// LoadState.swift
|
||||
// Brew
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -10,12 +10,12 @@ import Foundation
|
||||
/// `failed` carries a typed `Failure` (e.g. an `Error` surfaced by a repository); presentation layers
|
||||
/// map it to user-facing copy. The producer keeps prior `loaded` data on screen when a refresh fails,
|
||||
/// only transitioning to `failed` when there is nothing to show.
|
||||
enum LoadState<Value, Failure> {
|
||||
public enum LoadState<Value, Failure> {
|
||||
case loading
|
||||
case loaded(Value)
|
||||
case failed(Failure)
|
||||
|
||||
var value: Value? {
|
||||
public var value: Value? {
|
||||
guard case let .loaded(value) = self else {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Placeholdable.swift
|
||||
// BrewCore
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type that can supply a representative placeholder value for redacted loading states.
|
||||
///
|
||||
/// Conforming types return a `placeholder` populated with realistic stub data (non-empty name,
|
||||
/// plausible version, etc.) so a `.redacted(reason: .placeholder)` rendering sizes the same as the
|
||||
/// eventual content.
|
||||
public protocol Placeholdable {
|
||||
static var placeholder: Self { get }
|
||||
}
|
||||
+9
-15
@@ -1,6 +1,12 @@
|
||||
@testable import Brew
|
||||
//
|
||||
// BrewPackageFixtures.swift
|
||||
// BrewCoreTestSupport
|
||||
//
|
||||
|
||||
extension BrewPackage {
|
||||
import BrewCore
|
||||
import Foundation
|
||||
|
||||
public extension BrewPackage {
|
||||
static func fixture(
|
||||
name: String = "git",
|
||||
displayName: String? = nil,
|
||||
@@ -22,13 +28,7 @@ extension BrewPackage {
|
||||
}
|
||||
}
|
||||
|
||||
extension [BrewPackage] {
|
||||
static var empty: [BrewPackage] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
extension InstalledBrewPackage {
|
||||
public extension InstalledBrewPackage {
|
||||
static func fixture(
|
||||
name: String = "git",
|
||||
displayName: String? = nil,
|
||||
@@ -55,9 +55,3 @@ extension InstalledBrewPackage {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension [InstalledBrewPackage] {
|
||||
static var empty: [InstalledBrewPackage] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//
|
||||
// StubURLProtocol.swift
|
||||
// BrewCoreTestSupport
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public func makeStubbedSession() -> URLSession {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [StubURLProtocol.self]
|
||||
return URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
public func makeStubBaseURL() -> URL {
|
||||
URL(string: "https://stub-\(UUID().uuidString).local") ?? URL(fileURLWithPath: "/")
|
||||
}
|
||||
|
||||
public final class StubURLProtocol: URLProtocol {
|
||||
public enum StubbedResult {
|
||||
case successWithStatus(data: Data, statusCode: Int, headers: [String: String] = [:])
|
||||
case successWithResponse(data: Data, response: URLResponse)
|
||||
case failure(any Error)
|
||||
}
|
||||
|
||||
/// Mutation is serialized by `lock`; opt out of the global-mutable-state check rather than
|
||||
/// re-expressing the lock through `Mutex` for test-only stubbing.
|
||||
private static let lock = NSLock()
|
||||
// swiftlint:disable:next nonisolated_unsafe
|
||||
private nonisolated(unsafe) static var queuedResultsByHost: [String: [StubbedResult]] = [:]
|
||||
// swiftlint:disable:next nonisolated_unsafe
|
||||
private nonisolated(unsafe) static var queuedResultsByHostAndPath: [String: [String: [StubbedResult]]] = [:]
|
||||
// swiftlint:disable:next nonisolated_unsafe
|
||||
private nonisolated(unsafe) static var repeatingResultsByHostAndPath: [String: [String: StubbedResult]] = [:]
|
||||
// swiftlint:disable:next nonisolated_unsafe
|
||||
private nonisolated(unsafe) static var requestsByHost: [String: [URLRequest]] = [:]
|
||||
|
||||
public static func register(_ results: [StubbedResult], forHost host: String) {
|
||||
lock.lock()
|
||||
queuedResultsByHost[host] = results
|
||||
queuedResultsByHostAndPath[host] = [:]
|
||||
repeatingResultsByHostAndPath[host] = [:]
|
||||
requestsByHost[host] = []
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Registers one stub per URL path so concurrent requests get the correct payload regardless of completion order.
|
||||
public static func registerByPath(_ resultsByPath: [String: StubbedResult], forHost host: String) {
|
||||
lock.lock()
|
||||
queuedResultsByHost[host] = []
|
||||
queuedResultsByHostAndPath[host] = resultsByPath.mapValues { [$0] }
|
||||
repeatingResultsByHostAndPath[host] = [:]
|
||||
requestsByHost[host] = []
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Same as `registerByPath`, but stubs are reused for every matching request (for stress tests).
|
||||
public static func registerRepeatingByPath(_ resultsByPath: [String: StubbedResult], forHost host: String) {
|
||||
lock.lock()
|
||||
queuedResultsByHost[host] = []
|
||||
queuedResultsByHostAndPath[host] = [:]
|
||||
repeatingResultsByHostAndPath[host] = resultsByPath
|
||||
requestsByHost[host] = []
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
public static func requests(forHost host: String) -> [URLRequest] {
|
||||
lock.lock()
|
||||
let requests = requestsByHost[host] ?? []
|
||||
lock.unlock()
|
||||
return requests
|
||||
}
|
||||
|
||||
// swiftlint:disable:next static_over_final_class
|
||||
override public class func canInit(with _: URLRequest) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
// swiftlint:disable:next static_over_final_class
|
||||
override public class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
request
|
||||
}
|
||||
|
||||
override public func startLoading() {
|
||||
guard let host = request.url?.host else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
|
||||
return
|
||||
}
|
||||
|
||||
let result: StubbedResult
|
||||
Self.lock.lock()
|
||||
Self.requestsByHost[host, default: []].append(request)
|
||||
if let path = request.url?.path,
|
||||
let repeatingResults = Self.repeatingResultsByHostAndPath[host],
|
||||
let repeatingResult = repeatingResults[path]
|
||||
{
|
||||
result = repeatingResult
|
||||
} else if let path = request.url?.path,
|
||||
var pathQueues = Self.queuedResultsByHostAndPath[host],
|
||||
var pathQueue = pathQueues[path],
|
||||
!pathQueue.isEmpty
|
||||
{
|
||||
result = pathQueue.removeFirst()
|
||||
pathQueues[path] = pathQueue.isEmpty ? nil : pathQueue
|
||||
Self.queuedResultsByHostAndPath[host] = pathQueues
|
||||
} else {
|
||||
var queuedResults = Self.queuedResultsByHost[host] ?? []
|
||||
if queuedResults.isEmpty {
|
||||
result = .failure(URLError(.badServerResponse))
|
||||
} else {
|
||||
result = queuedResults.removeFirst()
|
||||
Self.queuedResultsByHost[host] = queuedResults
|
||||
}
|
||||
}
|
||||
Self.lock.unlock()
|
||||
|
||||
switch result {
|
||||
case let .successWithStatus(data, statusCode, headers):
|
||||
if let url = request.url,
|
||||
let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: headers)
|
||||
{
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} else {
|
||||
client?.urlProtocol(self, didFailWithError: URLError(.badURL))
|
||||
}
|
||||
case let .successWithResponse(data, response):
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
case let .failure(error):
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
|
||||
override public func stopLoading() {}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension UserDefaults {
|
||||
public extension UserDefaults {
|
||||
/// Removes every key in `UserDefaults.standard` (or whichever instance) that starts with
|
||||
/// the given prefix. Used by tests that isolate state with a per-test prefix instead of a
|
||||
/// suite name, so cleanup doesn't depend on `removePersistentDomain`.
|
||||
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// CommandJob+Presentation.swift
|
||||
// BrewFeatureConsole
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
extension CommandJob {
|
||||
/// Status-dot state for this job: running until terminal, then success/failure by exit code.
|
||||
/// Single source of truth for the dot — both the status bar and the toolbar pill bind to it.
|
||||
var dotState: ConsoleStatusPresentation.DotState {
|
||||
if !isTerminal {
|
||||
return .running
|
||||
}
|
||||
return succeeded ? .succeeded : .failed
|
||||
}
|
||||
|
||||
/// Plain-text dump of the output buffer for clipboard / save use. `stderr` lines get a `[stderr] ` prefix
|
||||
/// so a reader can disambiguate without losing the timeline.
|
||||
func formattedOutputForExport() -> String {
|
||||
output.map { line in
|
||||
switch line.stream {
|
||||
case .stdout:
|
||||
line.text
|
||||
case .stderr:
|
||||
"[stderr] \(line.text)"
|
||||
}
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Suggested filename when saving this job's output to disk.
|
||||
/// Pattern: `brewui-<sanitized-command>-<yyyy-MM-dd-HHmmss>.log`.
|
||||
func suggestedExportFilename(now: Date = Date()) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
let timestamp = formatter.string(from: now)
|
||||
let sanitized = command
|
||||
.replacingOccurrences(of: " ", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
return "brewui-\(sanitized)-\(timestamp).log"
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -3,11 +3,14 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// View-facing snapshot of "what does the collapsed status bar render right now" — keeps the view passive.
|
||||
/// Derived by ``ConsoleViewModel/statusPresentation``.
|
||||
nonisolated struct ConsoleStatusPresentation: Equatable {
|
||||
struct ConsoleStatusPresentation: Equatable {
|
||||
let dotState: DotState
|
||||
let summary: Summary
|
||||
let isRunning: Bool
|
||||
@@ -26,7 +29,7 @@ nonisolated struct ConsoleStatusPresentation: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension BrewOperationPhase {
|
||||
extension BrewOperationPhase {
|
||||
/// Plain-English label for surfaces (status bar, inline card).
|
||||
/// The center's phase enum is coarser than brew's stdout (`fetching`/`pouring`/`linking`) so this
|
||||
/// stays at the operation-lifecycle level. Sub-phase granularity would require stdout parsing.
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Output area of the expanded console — virtualizing `List` over the selected job's output buffer
|
||||
+7
-2
@@ -3,13 +3,18 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// View menu commands for the command console. Currently a single `⌘\`` toggle matching Xcode / VS Code / Terminal.
|
||||
struct ConsoleCommands: Commands {
|
||||
public struct ConsoleCommands: Commands {
|
||||
@FocusedBinding(\.consoleExpanded) private var expanded: Bool?
|
||||
|
||||
var body: some Commands {
|
||||
public init() {}
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(after: .toolbar) {
|
||||
Button(expanded == true ? "Hide Console" : "Show Console") {
|
||||
expanded?.toggle()
|
||||
+3
@@ -4,6 +4,9 @@
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
|
||||
/// View-layer AppKit bridge for saving/copying a command job's output. Lives at the view layer so the
|
||||
/// console view model stays AppKit-free; views call these static methods with the data already shaped by
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Bottom-of-window console. Collapsed → status strip only. Expanded → toolbar + output body.
|
||||
+10
-2
@@ -3,17 +3,25 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewAppEnvironment
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// App-window-owned root for the console feature: reads the shared command-jobs repository from the
|
||||
/// environment and constructs the per-window ``ConsoleViewModel`` passed down to the panel's subviews.
|
||||
/// Resize is owned by the parent ``VSplitView`` in ``MainWindowView`` — the panel itself no longer
|
||||
/// tracks a height, it just renders for whatever size the split view gives it.
|
||||
struct ConsolePanelRoot: View {
|
||||
public struct ConsolePanelRoot: View {
|
||||
@Binding var expanded: Bool
|
||||
@Environment(\.commandJobsRepository) private var commandJobsRepository
|
||||
|
||||
var body: some View {
|
||||
public init(expanded: Binding<Bool>) {
|
||||
_expanded = expanded
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ConsolePanel(
|
||||
expanded: $expanded,
|
||||
commandJobsRepository: commandJobsRepository,
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Always-visible collapsed strip showing the active (or most recent) brew operation
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// 8pt status indicator for the console strip. Color follows design-system §4.3/§4.4:
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Expanded-console toolbar: selected-job pill on the left, Save/Copy/Clear + collapse chevron on the right.
|
||||
+4
-1
@@ -3,9 +3,12 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
extension FocusedValues {
|
||||
public extension FocusedValues {
|
||||
/// Per-window expand/collapse binding for the console. Published by ``MainWindowView`` via `focusedSceneValue`;
|
||||
/// consumed by ``ConsoleCommands`` so `⌘\`` toggles the active window's strip.
|
||||
@Entry var consoleExpanded: Binding<Bool>?
|
||||
+5
-2
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// Derived presentation for "install in progress" chrome on a Discover row.
|
||||
@@ -12,7 +15,7 @@ import Foundation
|
||||
/// Instead, bridge the gap between the operation finishing and the installed badge appearing by
|
||||
/// reading the observable installed-state: stay busy while the operation is running, and keep busy
|
||||
/// after it finishes until the package is observed as installed.
|
||||
nonisolated enum DiscoverInstallBusyPresentation {
|
||||
enum DiscoverInstallBusyPresentation {
|
||||
static func showsInstallBusy(
|
||||
phase: BrewOperationPhase,
|
||||
awaitingResolution: Bool,
|
||||
@@ -25,7 +28,7 @@ nonisolated enum DiscoverInstallBusyPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated extension BrewOperationPhase {
|
||||
extension BrewOperationPhase {
|
||||
var isRunningInstall: Bool {
|
||||
switch self {
|
||||
case .running(.installFormula), .running(.installCask):
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
+7
-1
@@ -1,3 +1,6 @@
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -7,6 +10,7 @@ final class DiscoverPackageDetailViewModel {
|
||||
private(set) var discoveryPackage: DiscoveryBrewPackage
|
||||
@ObservationIgnored private let installedRepository: any InstalledPackageStatusReading
|
||||
@ObservationIgnored private let brewCommandCenter: any BrewCommandCenter
|
||||
@ObservationIgnored private let commandFactory: any BrewMutatingCommandFactory
|
||||
@ObservationIgnored private var mutationTask: Task<Void, Never>?
|
||||
|
||||
/// Latest phase from the command center stream (see ``observeInstallUpdates()``); drives install chrome.
|
||||
@@ -30,10 +34,12 @@ final class DiscoverPackageDetailViewModel {
|
||||
package: DiscoveryBrewPackage,
|
||||
installedRepository: any InstalledPackageStatusReading,
|
||||
brewCommandCenter: any BrewCommandCenter,
|
||||
commandFactory: any BrewMutatingCommandFactory,
|
||||
) {
|
||||
discoveryPackage = package
|
||||
self.installedRepository = installedRepository
|
||||
self.brewCommandCenter = brewCommandCenter
|
||||
self.commandFactory = commandFactory
|
||||
}
|
||||
|
||||
private var installedPackage: InstalledBrewPackage? {
|
||||
@@ -123,7 +129,7 @@ final class DiscoverPackageDetailViewModel {
|
||||
}
|
||||
installErrorMessage = nil
|
||||
let operationID = BrewOperationID(kind: packageKind, name: discoveryPackage.name)
|
||||
let command = PackageInstallCommand(package: discoveryPackage)
|
||||
let command = commandFactory.installCommand(kind: packageKind, name: discoveryPackage.name)
|
||||
mutationTask?.cancel()
|
||||
mutationTask = Task { @MainActor [self] in
|
||||
do {
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
+22
-20
@@ -1,21 +1,20 @@
|
||||
import BrewAppEnvironment
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Entry-point wrapper for the Discover tab content.
|
||||
struct DiscoverColumnsRoot: View {
|
||||
@Environment(\.catalogueCache) private var catalogueCache
|
||||
/// Entry-point wrapper for the Discover tab content. Reads the discover/catalogue/installed
|
||||
/// repositories from the environment (composed by the app's composition root).
|
||||
public struct DiscoverColumnsRoot: View {
|
||||
@Environment(\.discoverPackagesRepository) private var discoverPackagesRepository
|
||||
@Environment(\.catalogueRepository) private var catalogueRepository
|
||||
@Environment(\.installedPackagesRepository) private var installedPackagesRepository
|
||||
|
||||
var body: some View {
|
||||
let apiClient = URLSessionBrewAPIClient.live()
|
||||
let catalogueRepository = BrewCatalogueRepository(
|
||||
apiClient: apiClient,
|
||||
cache: catalogueCache,
|
||||
)
|
||||
let discoverPackagesRepository = BrewDiscoverPackagesRepository(
|
||||
apiClient: apiClient,
|
||||
catalogueRepository: catalogueRepository,
|
||||
)
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
DiscoverColumns(
|
||||
discoverPackagesRepository: discoverPackagesRepository,
|
||||
catalogueRepository: catalogueRepository,
|
||||
@@ -74,10 +73,13 @@ struct DiscoverColumns: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DiscoverColumns(
|
||||
discoverPackagesRepository: AppPreviewSupport.makeDiscoverPackagesRepository(),
|
||||
catalogueRepository: AppPreviewSupport.makeDiscoverCatalogueRepository(),
|
||||
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
|
||||
#Preview {
|
||||
DiscoverColumns(
|
||||
discoverPackagesRepository: PreviewSupport.makeDiscoverPackagesRepository(),
|
||||
catalogueRepository: PreviewSupport.makeDiscoverCatalogueRepository(),
|
||||
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
|
||||
)
|
||||
}
|
||||
#endif
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Installed status pill shared by discover list rows and package detail.
|
||||
+16
-9
@@ -1,3 +1,7 @@
|
||||
import BrewAppEnvironment
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Owns one Discover row's view model, reading the shared installed-status source from the environment at
|
||||
@@ -151,12 +155,15 @@ struct DiscoverListRowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Installed") {
|
||||
DiscoverListRowView(
|
||||
discoveryPackage: AppPreviewSupport.discoverPreviewPackage,
|
||||
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
|
||||
brewCommandCenter: AppPreviewSupport.commandCenter,
|
||||
)
|
||||
.padding()
|
||||
.frame(width: 440)
|
||||
}
|
||||
#if DEBUG
|
||||
|
||||
#Preview("Installed") {
|
||||
DiscoverListRowView(
|
||||
discoveryPackage: PreviewSupport.discoverPreviewPackage,
|
||||
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
|
||||
brewCommandCenter: PreviewSupport.commandCenter,
|
||||
)
|
||||
.padding()
|
||||
.frame(width: 440)
|
||||
}
|
||||
#endif
|
||||
+22
-9
@@ -1,17 +1,24 @@
|
||||
import AppKit
|
||||
import BrewAppEnvironment
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Root view for the selected row; detail content reads ``EnvironmentValues/brewCommandCenter`` and builds relationship repositories from ``EnvironmentValues/installedInventoryCache``.
|
||||
/// Root view for the selected row; reads the installed repository, command center, and command
|
||||
/// factory from the environment and injects them into the detail content's view model.
|
||||
struct DiscoverPackageDetailRoot: View {
|
||||
let selectedPackage: DiscoveryBrewPackage
|
||||
@Environment(\.installedPackagesRepository) private var installedPackagesRepository
|
||||
@Environment(\.brewCommandCenter) private var brewCommandCenter
|
||||
@Environment(\.mutatingCommandFactory) private var mutatingCommandFactory
|
||||
|
||||
var body: some View {
|
||||
DiscoverPackageDetailView(
|
||||
package: selectedPackage,
|
||||
installedRepository: installedPackagesRepository,
|
||||
brewCommandCenter: brewCommandCenter,
|
||||
mutatingCommandFactory: mutatingCommandFactory,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +35,7 @@ struct DiscoverPackageDetailView: View {
|
||||
package: DiscoveryBrewPackage,
|
||||
installedRepository: any InstalledPackageStatusReading,
|
||||
brewCommandCenter: any BrewCommandCenter,
|
||||
mutatingCommandFactory: any BrewMutatingCommandFactory,
|
||||
) {
|
||||
self.package = package
|
||||
_viewModel = State(
|
||||
@@ -35,6 +43,7 @@ struct DiscoverPackageDetailView: View {
|
||||
package: package,
|
||||
installedRepository: installedRepository,
|
||||
brewCommandCenter: brewCommandCenter,
|
||||
commandFactory: mutatingCommandFactory,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -263,11 +272,15 @@ struct DiscoverPackageDetailPlaceholder: View {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DiscoverPackageDetailView(
|
||||
package: AppPreviewSupport.discoverPreviewPackage,
|
||||
installedRepository: AppPreviewSupport.makeInstalledPackagesRepository(),
|
||||
brewCommandCenter: AppPreviewSupport.commandCenter,
|
||||
)
|
||||
.frame(minWidth: 380, minHeight: 480)
|
||||
}
|
||||
#if DEBUG
|
||||
|
||||
#Preview {
|
||||
DiscoverPackageDetailView(
|
||||
package: PreviewSupport.discoverPreviewPackage,
|
||||
installedRepository: PreviewSupport.makeInstalledPackagesRepository(),
|
||||
brewCommandCenter: PreviewSupport.commandCenter,
|
||||
mutatingCommandFactory: PreviewSupport.mutatingCommandFactory,
|
||||
)
|
||||
.frame(minWidth: 380, minHeight: 480)
|
||||
}
|
||||
#endif
|
||||
+3
@@ -1,3 +1,6 @@
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import SwiftUI
|
||||
|
||||
/// Middle column of the main window: Discover package list.
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
+8
-2
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@@ -10,6 +13,7 @@ import Observation
|
||||
@MainActor
|
||||
final class InstalledPackageDetailViewModel {
|
||||
private let brewCommandCenter: any BrewCommandCenter
|
||||
private let commandFactory: any BrewMutatingCommandFactory
|
||||
private let installedDependentsRepository: any InstalledDependentsRepository
|
||||
private let installedInventoryReading: any InstalledInventoryReading
|
||||
private var mutationTask: Task<Void, Never>?
|
||||
@@ -88,11 +92,13 @@ final class InstalledPackageDetailViewModel {
|
||||
init(
|
||||
package: InstalledBrewPackage,
|
||||
brewCommandCenter: any BrewCommandCenter,
|
||||
commandFactory: any BrewMutatingCommandFactory,
|
||||
installedDependentsRepository: any InstalledDependentsRepository,
|
||||
installedInventoryReading: any InstalledInventoryReading,
|
||||
) {
|
||||
self.package = package
|
||||
self.brewCommandCenter = brewCommandCenter
|
||||
self.commandFactory = commandFactory
|
||||
self.installedDependentsRepository = installedDependentsRepository
|
||||
self.installedInventoryReading = installedInventoryReading
|
||||
}
|
||||
@@ -135,7 +141,7 @@ final class InstalledPackageDetailViewModel {
|
||||
|
||||
func upgradeSelectedPackage() {
|
||||
let operationID = BrewOperationID(kind: package.kind, name: package.name)
|
||||
let command = PackageUpgradeCommand(kind: package.kind, name: package.name)
|
||||
let command = commandFactory.upgradeCommand(kind: package.kind, name: package.name)
|
||||
submitMutation(
|
||||
action: .upgrade,
|
||||
operationID: operationID,
|
||||
@@ -145,7 +151,7 @@ final class InstalledPackageDetailViewModel {
|
||||
|
||||
func uninstallSelectedPackage() {
|
||||
let operationID = BrewOperationID(kind: package.kind, name: package.name)
|
||||
let command = PackageUninstallCommand(kind: package.kind, name: package.name)
|
||||
let command = commandFactory.uninstallCommand(kind: package.kind, name: package.name)
|
||||
submitMutation(
|
||||
action: .uninstall,
|
||||
operationID: operationID,
|
||||
+5
-2
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// Derived presentation for "uninstall in progress" chrome when observing ``BrewOperationPhase`` for an installed row.
|
||||
@@ -15,7 +18,7 @@ import Foundation
|
||||
/// - The phase transitions to ``BrewOperationPhase/failed(reason:)`` (error is surfaced instead), or
|
||||
/// - ``InstalledListRowViewModel/update(package:)`` / ``InstalledPackageDetailViewModel/update(package:)``
|
||||
/// resets `operationPhase` to ``BrewOperationPhase/idle`` after the catalog refresh propagates.
|
||||
nonisolated enum InstalledUninstallBusyPresentation {
|
||||
enum InstalledUninstallBusyPresentation {
|
||||
static func showsUninstallBusy(
|
||||
oldPhase: BrewOperationPhase,
|
||||
newPhase: BrewOperationPhase,
|
||||
@@ -30,7 +33,7 @@ nonisolated enum InstalledUninstallBusyPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated extension BrewOperationPhase {
|
||||
private extension BrewOperationPhase {
|
||||
var isRunningUninstall: Bool {
|
||||
switch self {
|
||||
case .running(.uninstallFormula), .running(.uninstallCask):
|
||||
+5
-2
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// Derived presentation for "upgrade in progress" chrome when observing ``BrewOperationPhase`` for an installed row.
|
||||
@@ -12,7 +15,7 @@ import Foundation
|
||||
/// keep showing busy state.
|
||||
///
|
||||
/// Only upgrade operation kinds are matched; uninstall busy state is handled by ``InstalledUninstallBusyPresentation``.
|
||||
nonisolated enum InstalledUpgradeBusyPresentation {
|
||||
enum InstalledUpgradeBusyPresentation {
|
||||
static func showsUpgradeBusy(
|
||||
oldPhase: BrewOperationPhase,
|
||||
newPhase: BrewOperationPhase,
|
||||
@@ -28,7 +31,7 @@ nonisolated enum InstalledUpgradeBusyPresentation {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated extension BrewOperationPhase {
|
||||
private extension BrewOperationPhase {
|
||||
var isRunningUpgrade: Bool {
|
||||
switch self {
|
||||
case .running(.upgradeFormula), .running(.upgradeCask):
|
||||
+5
-2
@@ -3,10 +3,13 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
nonisolated struct InstalledPackagesContent: Equatable {
|
||||
struct InstalledPackagesContent: Equatable {
|
||||
var packages: [InstalledBrewPackage]
|
||||
|
||||
var shouldShowFormulaeSection: Bool {
|
||||
@@ -26,7 +29,7 @@ nonisolated struct InstalledPackagesContent: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum InstalledLoadState: Equatable {
|
||||
enum InstalledLoadState: Equatable {
|
||||
case loading
|
||||
case loaded(InstalledPackagesContent)
|
||||
case error(String)
|
||||
+4
-1
@@ -3,10 +3,13 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// Presentation mapping for Installed detail metadata content.
|
||||
nonisolated struct PackageDetailMetadataItem {
|
||||
struct PackageDetailMetadataItem {
|
||||
private let package: InstalledBrewPackage
|
||||
|
||||
init(package: InstalledBrewPackage) {
|
||||
+3
@@ -3,6 +3,9 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// One dependency or dependent row in installed package detail.
|
||||
+6
-3
@@ -3,10 +3,13 @@
|
||||
// Brew
|
||||
//
|
||||
|
||||
import BrewCore
|
||||
import BrewRepositoryInterfaces
|
||||
import BrewUIComponents
|
||||
import Foundation
|
||||
|
||||
/// Presentation mapping for uninstall actions shown in Installed package detail subviews.
|
||||
nonisolated struct UninstallPackageItem {
|
||||
struct UninstallPackageItem {
|
||||
private let package: InstalledBrewPackage
|
||||
private let blockingDependentCount: Int
|
||||
|
||||
@@ -118,13 +121,13 @@ nonisolated struct UninstallPackageItem {
|
||||
}
|
||||
|
||||
/// Copy shown in the uninstall-blocked warning callout.
|
||||
nonisolated struct UninstallBlockedCalloutContent: Equatable {
|
||||
struct UninstallBlockedCalloutContent: Equatable {
|
||||
let lead: String
|
||||
let body: String
|
||||
}
|
||||
|
||||
/// Result of activating the primary uninstall control in Installed detail.
|
||||
nonisolated enum UninstallPrimaryButtonAction: Equatable {
|
||||
enum UninstallPrimaryButtonAction: Equatable {
|
||||
case presentConfirmation
|
||||
case revealBlockedExplanation
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user