Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions BrewUITests/Screens/InstalledScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ struct InstalledScreen: Screen {
BrewUISearchField(app)
}

var hideDependenciesCheckbox: BrewUIElement {
BrewUIElement(app, .installedHideDependenciesCheckbox)
}

@discardableResult
func toggleHideDependencies(file: StaticString = #filePath, line: UInt = #line) -> Self {
hideDependenciesCheckbox.tap(file: file, line: line)
return self
}

@discardableResult
func assertHasPackage(
_ token: String,
Expand Down
2 changes: 2 additions & 0 deletions Sources/BrewAccessibilityID/AXID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public enum AXID: Hashable, Sendable {
case installedList
case installedRow(token: String)
case installedSearchField
case installedHideDependenciesCheckbox
case upgradesScreen
case upgradesList
case upgradesRow(token: String)
Expand Down Expand Up @@ -72,6 +73,7 @@ public enum AXID: Hashable, Sendable {
case .installedList: "installed.list"
case let .installedRow(token): "installed.row.\(token)"
case .installedSearchField: "installed.search"
case .installedHideDependenciesCheckbox: "installed.hideDependencies"
case .upgradesScreen: "upgrades.screen"
case .upgradesList: "upgrades.list"
case let .upgradesRow(token): "upgrades.row.\(token)"
Expand Down
2 changes: 2 additions & 0 deletions Sources/BrewCoreTestSupport/BrewPackageFixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public extension InstalledBrewPackage {
outdated: Bool = false,
tap: String? = nil,
rubySourcePath: String? = nil,
installedOnRequest: Bool = true,
linkedKeg: String? = nil,
) -> InstalledBrewPackage {
InstalledBrewPackage(
Expand All @@ -57,6 +58,7 @@ public extension InstalledBrewPackage {
outdated: outdated,
tap: tap,
rubySourcePath: rubySourcePath,
installedOnRequest: installedOnRequest,
linkedKeg: linkedKeg,
)
}
Expand Down
38 changes: 30 additions & 8 deletions Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ struct InstalledPackagesContent: Equatable {
InstalledPackagesContent(packages: caskPackages)
}
}

/// Filters packages to only direct packages (installed on request) when `hideDependencies` is true.
func filtered(hidingDependencies: Bool) -> InstalledPackagesContent {
guard hidingDependencies else { return self }
return InstalledPackagesContent(packages: packages.filter(\.installedOnRequest))
}
}

@Observable
Expand All @@ -60,14 +66,24 @@ final class InstalledViewModel {
guard oldValue != scope else {
return
}
updateSelectionForScopeChange()
updateSelectionForFilterChange()
}
}

/// Filter to hide packages installed solely as dependencies.
var hideDependencies: Bool = false {
didSet {
guard oldValue != hideDependencies else {
return
}
updateSelectionForFilterChange()
}
}

private var selectedPackageID: InstalledBrewPackage.ID?

/// Projects the shared repository's inventory through the active scope and search query. The
/// repository is the single source of truth; this view model owns only screen-local filter and
/// Projects the shared repository's inventory through the active scope, dependency filter, and search query.
/// The repository is the single source of truth; this view model owns only screen-local filter and
/// selection state.
var state: LoadState<InstalledPackagesContent, String> {
switch repository.state {
Expand All @@ -79,6 +95,7 @@ final class InstalledViewModel {
.loaded(Self.filteredContent(
InstalledPackagesContent(packages: repository.userManagedPackages),
scope: scope,
hideDependencies: hideDependencies,
query: searchQuery,
))
}
Expand Down Expand Up @@ -126,9 +143,11 @@ final class InstalledViewModel {
init(
repository: any InstalledInventoryObserving,
initialSelection: InstalledBrewPackage.ID? = nil,
hideDependencies: Bool = false,
) {
self.repository = repository
selectedPackageID = initialSelection
self.hideDependencies = hideDependencies
}

func load() async {
Expand Down Expand Up @@ -225,10 +244,10 @@ final class InstalledViewModel {
}
}

/// Re-homes the search preview when a scope change hides the previewed row. Committed selections
/// are left untouched: `activeSelectedPackageID` already falls back to the first visible row while a
/// selection is scoped out, and restores it if the user widens the scope again.
private func updateSelectionForScopeChange() {
/// Re-homes the search preview when a filter change (scope or hide dependencies) hides the previewed row.
/// Committed selections are left untouched: `activeSelectedPackageID` already falls back to the first visible
/// row while a selection is filtered out, and restores it if the user widens the filter again.
private func updateSelectionForFilterChange() {
guard isSearchActive, !didCommitSelectionDuringSearch else {
return
}
Expand All @@ -242,9 +261,12 @@ final class InstalledViewModel {
private static func filteredContent(
_ content: InstalledPackagesContent,
scope: InstalledPackageScope,
hideDependencies: Bool,
query: String,
) -> InstalledPackagesContent {
let scoped = content.filtered(by: scope)
let scoped = content
.filtered(by: scope)
.filtered(hidingDependencies: hideDependencies)
let normalizedQuery = normalizedSearchQuery(query)
guard !normalizedQuery.isEmpty else {
return scoped
Expand Down
14 changes: 13 additions & 1 deletion Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ struct InstalledPackagesView: View {
.accessibilityHeading(.h1)

scopePicker
hideDependenciesToggle
Divider()

AsyncContentView(
Expand Down Expand Up @@ -63,7 +64,18 @@ struct InstalledPackagesView: View {
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, BrewSpacing.lg)
.padding(.bottom, BrewSpacing.md)
.padding(.bottom, BrewSpacing.sm)
}

/// Direct packages vs. dependencies filter checkbox.
private var hideDependenciesToggle: some View {
Toggle("Hide dependencies", isOn: $viewModel.hideDependencies)
.toggleStyle(.checkbox)
.font(.brewSubheadline)
.foregroundStyle(Color.brewTextSecondary)
.padding(.horizontal, BrewSpacing.lg)
.padding(.bottom, BrewSpacing.md)
.axid(.installedHideDependenciesCheckbox)
}

private func installedList(_ content: InstalledPackagesContent) -> some View {
Expand Down
2 changes: 2 additions & 0 deletions Tests/BrewAccessibilityIDTests/AXIDTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct AXIDTests {
.installedScreen,
.installedList,
.installedSearchField,
.installedHideDependenciesCheckbox,
.upgradesScreen,
.upgradesList,
.upgradesRefreshButton,
Expand All @@ -42,6 +43,7 @@ struct AXIDTests {
"installed.screen",
"installed.list",
"installed.search",
"installed.hideDependencies",
"upgrades.screen",
"upgrades.list",
"upgrades.refresh",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// InstalledViewModelHideDependenciesTests.swift
// BrewTests
//

import BrewCore
import BrewCoreTestSupport
@testable import BrewFeatureInstalled
import BrewRepositories
import Foundation
import Testing

struct InstalledViewModelHideDependenciesTests {
@Test @MainActor func `hideDependencies defaults to false and includes dependencies`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "git", installedOnRequest: true),
.fixture(name: "pcre2", installedOnRequest: false),
],
casks: [
.fixture(name: "slack", kind: .cask, installedOnRequest: true),
],
)

#expect(vm.hideDependencies == false)
#expect(vm.loadedFormulaPackages.map(\.name) == ["git", "pcre2"])
#expect(vm.loadedCaskPackages.map(\.name) == ["slack"])
#expect(vm.totalPackageCount == 3)
#expect(vm.packageCountSubtitle == "3 packages")
}

@Test @MainActor func `hideDependencies true filters out packages installed as dependencies`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "git", installedOnRequest: true),
.fixture(name: "pcre2", installedOnRequest: false),
],
casks: [
.fixture(name: "slack", kind: .cask, installedOnRequest: true),
.fixture(name: "font-dep", kind: .cask, installedOnRequest: false),
],
)

vm.hideDependencies = true

#expect(vm.loadedFormulaPackages.map(\.name) == ["git"])
#expect(vm.loadedCaskPackages.map(\.name) == ["slack"])
#expect(vm.totalPackageCount == 2)
#expect(vm.packageCountSubtitle == "2 packages")
}

@Test @MainActor func `hideDependencies composes with scope`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "git", installedOnRequest: true),
.fixture(name: "pcre2", installedOnRequest: false),
],
casks: [
.fixture(name: "slack", kind: .cask, installedOnRequest: true),
],
)

vm.hideDependencies = true
vm.scope = .formulae

#expect(vm.loadedFormulaPackages.map(\.name) == ["git"])
#expect(vm.loadedCaskPackages.isEmpty)
#expect(vm.totalPackageCount == 1)
#expect(vm.packageCountSubtitle == "1 package")
}

@Test @MainActor func `hideDependencies composes with search query`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "openssl@3", installedOnRequest: true),
.fixture(name: "openssl-dep", installedOnRequest: false),
],
)

vm.searchQuery = "openssl"
#expect(vm.loadedFormulaPackages.map(\.name) == ["openssl@3", "openssl-dep"])

vm.hideDependencies = true
#expect(vm.loadedFormulaPackages.map(\.name) == ["openssl@3"])
}

@Test @MainActor func `selection falls back to first visible row when hideDependencies hides it`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "git", installedOnRequest: true),
.fixture(name: "pcre2", installedOnRequest: false),
],
)
vm.setSelection(.formula(name: "pcre2"))
#expect(vm.activeSelectedPackageID == .formula(name: "pcre2"))

// Hiding dependencies hides pcre2; selection falls back to the first visible package.
vm.hideDependencies = true
#expect(vm.activeSelectedPackageID == .formula(name: "git"))
}

@Test @MainActor func `unchecking hideDependencies restores the previously hidden selection`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "git", installedOnRequest: true),
.fixture(name: "pcre2", installedOnRequest: false),
],
)
vm.setSelection(.formula(name: "pcre2"))

vm.hideDependencies = true
#expect(vm.activeSelectedPackageID == .formula(name: "git"))

// The committed selection was not discarded, so clearing the filter restores it.
vm.hideDependencies = false
#expect(vm.activeSelectedPackageID == .formula(name: "pcre2"))
}

@Test @MainActor func `hideDependencies change re-homes the search preview to the first visible row`() async {
let vm = await InstalledFeatureTestSupport.loadedViewModel(
formulae: [
.fixture(name: "libuv", installedOnRequest: false),
.fixture(name: "libusb", installedOnRequest: true),
],
)

vm.searchQuery = "lib"
#expect(vm.activeSelectedPackageID == .formula(name: "libuv"))

vm.hideDependencies = true
#expect(vm.activeSelectedPackageID == .formula(name: "libusb"))
}
}
Loading