diff --git a/.ai/memory.md b/.ai/memory.md index cc6f38d..2c721a5 100644 --- a/.ai/memory.md +++ b/.ai/memory.md @@ -66,7 +66,7 @@ - `BrewTests` = unit tests - `BrewUITests` = UI tests - Repository root folder remains `BrewUI` (part of a larger parent project layout). -- App bundle/package identifier remains unchanged for compatibility (`sh.brew.BrewUI`), while test bundle identifiers were updated to match renamed targets (`sh.brew.BrewTests` and `sh.brew.BrewUITests`). +- App bundle identifier and installer package identifier changed to `sh.brew.app` (was `sh.brew.BrewUI`). Test bundle identifiers track renamed targets (`sh.brew.BrewTests` and `sh.brew.BrewUITests`). ## 2026-03-15 β€” Actionlint Policy-Compliant Pattern diff --git a/.github/workflows/pr_build_test.yml b/.github/workflows/pr_build_test.yml index 13fdec3..55f8fa6 100644 --- a/.github/workflows/pr_build_test.yml +++ b/.github/workflows/pr_build_test.yml @@ -8,7 +8,7 @@ on: - "Brew/**" - "BrewTests/**" - "BrewUITests/**" - - "Brew.xcodeproj/**" + - "Homebrew.xcodeproj/**" - "Brew.entitlements" - "Sources/**" - "Tests/**" @@ -23,7 +23,7 @@ on: - "Brew/**" - "BrewTests/**" - "BrewUITests/**" - - "Brew.xcodeproj/**" + - "Homebrew.xcodeproj/**" - "Brew.entitlements" - "Sources/**" - "Tests/**" @@ -50,13 +50,13 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Resolve package dependencies - run: xcodebuild -resolvePackageDependencies -project Brew.xcodeproj + run: xcodebuild -resolvePackageDependencies -project Homebrew.xcodeproj - name: Build and test (Xcode app target) run: | set -o pipefail xcodebuild test \ - -project Brew.xcodeproj \ + -project Homebrew.xcodeproj \ -scheme Brew-Unit \ -destination "platform=macOS" \ -skipPackagePluginValidation \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9377a37..198d590 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,7 +87,7 @@ jobs: - name: Build and archive run: | xcodebuild archive \ - -project Brew.xcodeproj \ + -project Homebrew.xcodeproj \ -scheme Brew \ -configuration Release \ -archivePath "$RUNNER_TEMP/Brew.xcarchive" \ @@ -140,7 +140,7 @@ jobs: echo "VERSION=$VERSION" >> "$GITHUB_ENV" pkgbuild \ --root "$RUNNER_TEMP/export" \ - --identifier "sh.brew.BrewUI" \ + --identifier "sh.brew.app" \ --version "$VERSION" \ --install-location "/Applications" \ --scripts scripts \ diff --git a/.github/workflows/ui_smoke.yml b/.github/workflows/ui_smoke.yml index 87cfeb0..fc2a1be 100644 --- a/.github/workflows/ui_smoke.yml +++ b/.github/workflows/ui_smoke.yml @@ -28,7 +28,7 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Resolve package dependencies - run: xcodebuild -resolvePackageDependencies -project Brew.xcodeproj + run: xcodebuild -resolvePackageDependencies -project Homebrew.xcodeproj - name: Run UI smoke test run: | @@ -53,7 +53,7 @@ jobs: XCODEBUILD_ARGS=( test - -project Brew.xcodeproj + -project Homebrew.xcodeproj -scheme Brew-UI -destination "platform=macOS" -skipPackagePluginValidation diff --git a/Brew-UI.xctestplan b/Brew-UI.xctestplan index 0bc9b00..5147964 100644 --- a/Brew-UI.xctestplan +++ b/Brew-UI.xctestplan @@ -14,7 +14,7 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Brew.xcodeproj", + "containerPath" : "container:Homebrew.xcodeproj", "identifier" : "EEC39ED52F5AB4B900269514", "name" : "BrewUITests" } diff --git a/Brew-Unit.xctestplan b/Brew-Unit.xctestplan index 51d1816..48e0b8b 100644 --- a/Brew-Unit.xctestplan +++ b/Brew-Unit.xctestplan @@ -14,7 +14,7 @@ "testTargets" : [ { "target" : { - "containerPath" : "container:Brew.xcodeproj", + "containerPath" : "container:Homebrew.xcodeproj", "identifier" : "EEC39ECB2F5AB4B900269514", "name" : "BrewTests" } diff --git a/Brew/Assets.xcassets/AppIcon.appiconset/Contents.json b/Brew/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 3f00db4..0000000 --- a/Brew/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "images" : [ - { - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/BrewTests/.swiftlint.yml b/BrewTests/.swiftlint.yml index 66794ad..bb34732 100644 --- a/BrewTests/.swiftlint.yml +++ b/BrewTests/.swiftlint.yml @@ -1,6 +1,8 @@ # Merge with repo root `.swiftlint.yml`. Integration/UI test scaffolding often grows beyond -# the default struct type-body cap without adding real complexity. +# the default type-body and file caps without adding real complexity, so test files +# and test types are allowed to be any length. parent_config: ../.swiftlint.yml disabled_rules: - type_body_length + - file_length diff --git a/BrewUITests/.swiftlint.yml b/BrewUITests/.swiftlint.yml index 2c22be6..f8da0bd 100644 --- a/BrewUITests/.swiftlint.yml +++ b/BrewUITests/.swiftlint.yml @@ -3,3 +3,4 @@ parent_config: ../.swiftlint.yml disabled_rules: - type_body_length + - file_length diff --git a/Brew.xcodeproj/project.pbxproj b/Homebrew.xcodeproj/project.pbxproj similarity index 95% rename from Brew.xcodeproj/project.pbxproj rename to Homebrew.xcodeproj/project.pbxproj index 5138215..ba50d6d 100644 --- a/Brew.xcodeproj/project.pbxproj +++ b/Homebrew.xcodeproj/project.pbxproj @@ -45,15 +45,15 @@ EE00AA000000000000000002 /* Signing.local.xcconfig.example */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.local.xcconfig.example; sourceTree = ""; }; EE2FB0352F653F89004AE92A /* Brew-Unit.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Brew-Unit.xctestplan"; sourceTree = ""; }; EE2FB0372F654024004AE92A /* Brew-UI.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Brew-UI.xctestplan"; sourceTree = ""; }; - EEC39EBF2F5AB4B900269514 /* Brew.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Brew.app; sourceTree = BUILT_PRODUCTS_DIR; }; + EEC39EBF2F5AB4B900269514 /* Homebrew.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Homebrew.app; sourceTree = BUILT_PRODUCTS_DIR; }; EEC39ECC2F5AB4B900269514 /* BrewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BrewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EEC39ED62F5AB4B900269514 /* BrewUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BrewUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EEC39EC12F5AB4B900269514 /* Brew */ = { + EEC39EC12F5AB4B900269514 /* Homebrew */ = { isa = PBXFileSystemSynchronizedRootGroup; - path = Brew; + path = Homebrew; sourceTree = ""; }; EEC39ECF2F5AB4B900269514 /* BrewTests */ = { @@ -119,7 +119,7 @@ children = ( EE2FB0352F653F89004AE92A /* Brew-Unit.xctestplan */, EE2FB0372F654024004AE92A /* Brew-UI.xctestplan */, - EEC39EC12F5AB4B900269514 /* Brew */, + EEC39EC12F5AB4B900269514 /* Homebrew */, EEC39ECF2F5AB4B900269514 /* BrewTests */, EEC39ED92F5AB4B900269514 /* BrewUITests */, EEC39EC02F5AB4B900269514 /* Products */, @@ -130,7 +130,7 @@ EEC39EC02F5AB4B900269514 /* Products */ = { isa = PBXGroup; children = ( - EEC39EBF2F5AB4B900269514 /* Brew.app */, + EEC39EBF2F5AB4B900269514 /* Homebrew.app */, EEC39ECC2F5AB4B900269514 /* BrewTests.xctest */, EEC39ED62F5AB4B900269514 /* BrewUITests.xctest */, ); @@ -140,9 +140,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - EEC39EBE2F5AB4B900269514 /* Brew */ = { + EEC39EBE2F5AB4B900269514 /* Homebrew */ = { isa = PBXNativeTarget; - buildConfigurationList = EEC39EE02F5AB4B900269514 /* Build configuration list for PBXNativeTarget "Brew" */; + buildConfigurationList = EEC39EE02F5AB4B900269514 /* Build configuration list for PBXNativeTarget "Homebrew" */; buildPhases = ( EEC39EBB2F5AB4B900269514 /* Sources */, EEC39EBC2F5AB4B900269514 /* Frameworks */, @@ -154,9 +154,9 @@ EEAA00012F70000000000003 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( - EEC39EC12F5AB4B900269514 /* Brew */, + EEC39EC12F5AB4B900269514 /* Homebrew */, ); - name = Brew; + name = Homebrew; packageProductDependencies = ( EEAA100100000000000000C1 /* BrewCore */, EEAA100200000000000000C2 /* BrewUIComponents */, @@ -172,7 +172,7 @@ EEAA100C00000000000000CC /* BrewFeatureConfig */, ); productName = Brew; - productReference = EEC39EBF2F5AB4B900269514 /* Brew.app */; + productReference = EEC39EBF2F5AB4B900269514 /* Homebrew.app */; productType = "com.apple.product-type.application"; }; EEC39ECB2F5AB4B900269514 /* BrewTests */ = { @@ -244,7 +244,7 @@ }; }; }; - buildConfigurationList = EEC39EBA2F5AB4B900269514 /* Build configuration list for PBXProject "Brew" */; + buildConfigurationList = EEC39EBA2F5AB4B900269514 /* Build configuration list for PBXProject "Homebrew" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -262,7 +262,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - EEC39EBE2F5AB4B900269514 /* Brew */, + EEC39EBE2F5AB4B900269514 /* Homebrew */, EEC39ECB2F5AB4B900269514 /* BrewTests */, EEC39ED52F5AB4B900269514 /* BrewUITests */, ); @@ -326,12 +326,12 @@ }; EEC39ECE2F5AB4B900269514 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = EEC39EBE2F5AB4B900269514 /* Brew */; + target = EEC39EBE2F5AB4B900269514 /* Homebrew */; targetProxy = EEC39ECD2F5AB4B900269514 /* PBXContainerItemProxy */; }; EEC39ED82F5AB4B900269514 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = EEC39EBE2F5AB4B900269514 /* Brew */; + target = EEC39EBE2F5AB4B900269514 /* Homebrew */; targetProxy = EEC39ED72F5AB4B900269514 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -482,7 +482,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = sh.brew.BrewUI; + PRODUCT_BUNDLE_IDENTIFIER = sh.brew.app; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -513,7 +513,7 @@ "@executable_path/../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = sh.brew.BrewUI; + PRODUCT_BUNDLE_IDENTIFIER = sh.brew.app; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -541,7 +541,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Brew.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Brew"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Homebrew.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Homebrew"; }; name = Debug; }; @@ -561,7 +561,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Brew.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Brew"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Homebrew.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Homebrew"; }; name = Release; }; @@ -579,7 +579,7 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TEST_TARGET_NAME = Brew; + TEST_TARGET_NAME = Homebrew; }; name = Debug; }; @@ -597,14 +597,14 @@ SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; - TEST_TARGET_NAME = Brew; + TEST_TARGET_NAME = Homebrew; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - EEC39EBA2F5AB4B900269514 /* Build configuration list for PBXProject "Brew" */ = { + EEC39EBA2F5AB4B900269514 /* Build configuration list for PBXProject "Homebrew" */ = { isa = XCConfigurationList; buildConfigurations = ( EEC39EDE2F5AB4B900269514 /* Debug */, @@ -613,7 +613,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - EEC39EE02F5AB4B900269514 /* Build configuration list for PBXNativeTarget "Brew" */ = { + EEC39EE02F5AB4B900269514 /* Build configuration list for PBXNativeTarget "Homebrew" */ = { isa = XCConfigurationList; buildConfigurations = ( EEC39EE12F5AB4B900269514 /* Debug */, diff --git a/Brew.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Homebrew.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Brew.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to Homebrew.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Brew.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Homebrew.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from Brew.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to Homebrew.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Brew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme similarity index 85% rename from Brew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme rename to Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme index eaf3f04..464384d 100644 --- a/Brew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme +++ b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-UI.xcscheme @@ -16,9 +16,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -50,9 +50,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -67,9 +67,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> diff --git a/Brew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme similarity index 85% rename from Brew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme rename to Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme index f257935..1425766 100644 --- a/Brew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme +++ b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew-Unit.xcscheme @@ -16,9 +16,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -50,9 +50,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -67,9 +67,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> diff --git a/Brew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme similarity index 84% rename from Brew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme rename to Homebrew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme index f88e41c..fad3828 100644 --- a/Brew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme +++ b/Homebrew.xcodeproj/xcshareddata/xcschemes/Brew.xcscheme @@ -16,9 +16,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -38,7 +38,7 @@ BlueprintIdentifier = "EEC39ECB2F5AB4B900269514" BuildableName = "BrewTests.xctest" BlueprintName = "BrewTests" - ReferencedContainer = "container:Brew.xcodeproj"> + ReferencedContainer = "container:Homebrew.xcodeproj"> + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -69,9 +69,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> @@ -86,9 +86,9 @@ + BuildableName = "Homebrew.app" + BlueprintName = "Homebrew" + ReferencedContainer = "container:Homebrew.xcodeproj"> diff --git a/Brew/Assets.xcassets/AccentColor.colorset/Contents.json b/Homebrew/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Brew/Assets.xcassets/AccentColor.colorset/Contents.json rename to Homebrew/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/Contents.json b/Homebrew/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3ba5775 --- /dev/null +++ b/Homebrew/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images": [ + { + "idiom": "mac", + "size": "16x16", + "scale": "1x", + "filename": "icon_16.png" + }, + { + "idiom": "mac", + "size": "16x16", + "scale": "2x", + "filename": "icon_32.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "1x", + "filename": "icon_32.png" + }, + { + "idiom": "mac", + "size": "32x32", + "scale": "2x", + "filename": "icon_64.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "1x", + "filename": "icon_128.png" + }, + { + "idiom": "mac", + "size": "128x128", + "scale": "2x", + "filename": "icon_256.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "1x", + "filename": "icon_256.png" + }, + { + "idiom": "mac", + "size": "256x256", + "scale": "2x", + "filename": "icon_512.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "1x", + "filename": "icon_512.png" + }, + { + "idiom": "mac", + "size": "512x512", + "scale": "2x", + "filename": "icon_1024.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} \ No newline at end of file diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..dd68ed7 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_128.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_128.png new file mode 100644 index 0000000..8127ee9 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_128.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_16.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_16.png new file mode 100644 index 0000000..ae9ecce Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_16.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_256.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_256.png new file mode 100644 index 0000000..570caf0 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_256.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_32.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_32.png new file mode 100644 index 0000000..44c3431 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_32.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_512.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_512.png new file mode 100644 index 0000000..96644c4 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_512.png differ diff --git a/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_64.png b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_64.png new file mode 100644 index 0000000..2f28591 Binary files /dev/null and b/Homebrew/Assets.xcassets/AppIcon.appiconset/icon_64.png differ diff --git a/Brew/Assets.xcassets/Contents.json b/Homebrew/Assets.xcassets/Contents.json similarity index 100% rename from Brew/Assets.xcassets/Contents.json rename to Homebrew/Assets.xcassets/Contents.json diff --git a/Homebrew/Assets.xcassets/Mark.imageset/Contents.json b/Homebrew/Assets.xcassets/Mark.imageset/Contents.json new file mode 100644 index 0000000..7e651ad --- /dev/null +++ b/Homebrew/Assets.xcassets/Mark.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Homebrew/Assets.xcassets/Mark.imageset/Mark.svg b/Homebrew/Assets.xcassets/Mark.imageset/Mark.svg new file mode 100644 index 0000000..90fc9d0 --- /dev/null +++ b/Homebrew/Assets.xcassets/Mark.imageset/Mark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Brew/BrewApp.swift b/Homebrew/BrewApp.swift similarity index 95% rename from Brew/BrewApp.swift rename to Homebrew/BrewApp.swift index 084fd83..dd0c611 100644 --- a/Brew/BrewApp.swift +++ b/Homebrew/BrewApp.swift @@ -84,6 +84,10 @@ struct BrewApp: App { configRepository.invalidate() envFileRepository.invalidate() } + .frame( + minWidth: BrewLayout.minWindowWidth, + minHeight: BrewLayout.minWindowHeight, + ) } .defaultSize( width: BrewLayout.minWindowWidth, @@ -91,6 +95,8 @@ struct BrewApp: App { ) .commands { ConsoleCommands() + SidebarCommands() + SearchCommands() } #if DEBUG .commands { diff --git a/Brew/Debug/DebugMenuCommands.swift b/Homebrew/Debug/DebugMenuCommands.swift similarity index 100% rename from Brew/Debug/DebugMenuCommands.swift rename to Homebrew/Debug/DebugMenuCommands.swift diff --git a/Brew/Features/MainWindow/Views/MainWindowView.swift b/Homebrew/Features/MainWindow/Views/MainWindowView.swift similarity index 82% rename from Brew/Features/MainWindow/Views/MainWindowView.swift rename to Homebrew/Features/MainWindow/Views/MainWindowView.swift index 8c87aca..18c37c7 100644 --- a/Brew/Features/MainWindow/Views/MainWindowView.swift +++ b/Homebrew/Features/MainWindow/Views/MainWindowView.swift @@ -16,25 +16,26 @@ struct MainWindowView: View { @SceneStorage("consoleHeight") private var consoleHeight: Double = BrewLayout.consoleDefaultExpandedHeight var body: some View { - AnimatedSplit( - collapsed: !consoleExpanded, - collapsedHeight: BrewLayout.consoleCollapsedHeight, - expandedHeight: consoleHeight, - minExpandedHeight: BrewLayout.consoleMinExpandedHeight, - maxExpandedHeight: BrewLayout.consoleMaxExpandedHeight, - animation: .brewFast, - ) { - NavigationSplitView { - sidebarColumn - } detail: { + NavigationSplitView { + sidebarColumn + } detail: { + AnimatedSplit( + collapsed: !consoleExpanded, + collapsedHeight: BrewLayout.consoleCollapsedHeight, + expandedHeight: consoleHeight, + minExpandedHeight: BrewLayout.consoleMinExpandedHeight, + maxExpandedHeight: BrewLayout.consoleMaxExpandedHeight, + animation: .brewFast, + ) { featureColumn + } bottom: { + ConsolePanelRoot(expanded: $consoleExpanded) } - .background(.bar) - .navigationSplitViewStyle(.prominentDetail) - } bottom: { - ConsolePanelRoot(expanded: $consoleExpanded) + .focusedSceneValue(\.consoleExpanded, $consoleExpanded) } + .navigationSplitViewStyle(.automatic) .focusedSceneValue(\.consoleExpanded, $consoleExpanded) + .focusedSceneValue(\.sidebarSelection, $selectedSidebarItem) .environment(\.navigateToInstalledPackage) { id in pendingInstalledSelection = id selectedSidebarItem = .installed diff --git a/Brew/Utilities/UserDefaultsDebug.swift b/Homebrew/Utilities/UserDefaultsDebug.swift similarity index 100% rename from Brew/Utilities/UserDefaultsDebug.swift rename to Homebrew/Utilities/UserDefaultsDebug.swift diff --git a/Brew/Views/MainSidebarView.swift b/Homebrew/Views/MainSidebarView.swift similarity index 77% rename from Brew/Views/MainSidebarView.swift rename to Homebrew/Views/MainSidebarView.swift index eba100a..09098e5 100644 --- a/Brew/Views/MainSidebarView.swift +++ b/Homebrew/Views/MainSidebarView.swift @@ -7,27 +7,16 @@ import BrewFeatureInstalled import BrewUIComponents import SwiftUI -/// Primary navigation items for the main window sidebar. -enum SidebarItem: String, CaseIterable, Hashable, Identifiable { - case installed - case upgrades - case discover - case doctor - case configuration - - var id: String { - rawValue - } -} - struct MainSidebarView: View { @Binding var selection: SidebarItem var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: BrewSpacing.sm) { - Text("🍺") - .font(.brewTitle2) + HStack(alignment: .bottom, spacing: BrewSpacing.sm) { + Image("Mark") + .resizable() + .scaledToFit() + .frame(height: 24) Text("Homebrew") .font(.brewTitle2) .foregroundStyle(Color.brewTextPrimary) @@ -43,7 +32,7 @@ struct MainSidebarView: View { sidebarRow( title: "Installed", - systemImage: "cube.box.fill", + emoji: "πŸ“¦", item: .installed, ) .padding(.horizontal, BrewSpacing.sm) @@ -51,7 +40,7 @@ struct MainSidebarView: View { sidebarRow( title: "Upgrades", - systemImage: "arrow.triangle.2.circlepath", + emoji: "⬆️", item: .upgrades, trailingAccessory: { UpgradesSidebarBadge() }, ) @@ -60,7 +49,7 @@ struct MainSidebarView: View { sidebarRow( title: "Discover", - systemImage: "magnifyingglass", + emoji: "πŸ”", item: .discover, ) .padding(.horizontal, BrewSpacing.sm) @@ -68,7 +57,7 @@ struct MainSidebarView: View { sidebarRow( title: "Doctor", - systemImage: "stethoscope", + emoji: "🩺", item: .doctor, ) .padding(.horizontal, BrewSpacing.sm) @@ -76,7 +65,7 @@ struct MainSidebarView: View { sidebarRow( title: "Configuration", - systemImage: "gearshape", + emoji: "βš™οΈ", item: .configuration, ) .padding(.horizontal, BrewSpacing.sm) @@ -85,13 +74,13 @@ struct MainSidebarView: View { Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Color.brewSurfaceRecessed) + .background(Color.brewSurface) } @ViewBuilder private func sidebarRow( title: String, - systemImage: String, + emoji: String, item: SidebarItem, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, ) -> some View { @@ -100,10 +89,7 @@ struct MainSidebarView: View { selection = item } label: { HStack(spacing: BrewSpacing.sm) { - Image(systemName: systemImage) - .foregroundStyle(isSelected ? Color.brewBrandPrimary : Color.brewTextSecondary) - .imageScale(.medium) - Text(title) + Text("\(emoji) \(title)") .font(.brewBody) .foregroundStyle(isSelected ? Color.brewBrandPrimary : Color.brewTextPrimary) Spacer(minLength: 0) diff --git a/Homebrew/Views/SidebarItem.swift b/Homebrew/Views/SidebarItem.swift new file mode 100644 index 0000000..1f793e3 --- /dev/null +++ b/Homebrew/Views/SidebarItem.swift @@ -0,0 +1,54 @@ +// +// SidebarItem.swift +// Homebrew +// +// Created by Graeme Arthur on 17/6/2026. +// + +import SwiftUI + +/// Primary navigation items for the main window sidebar. +enum SidebarItem: String, CaseIterable, Hashable, Identifiable { + case installed + case upgrades + case discover + case doctor + case configuration + + var id: String { + rawValue + } + + var title: LocalizedStringKey { + switch self { + case .installed: "Installed" + case .upgrades: "Upgrades" + case .discover: "Discover" + case .doctor: "Doctor" + case .configuration: "Configuration" + } + } +} + +extension FocusedValues { + @Entry var sidebarSelection: Binding? +} + +/// View menu commands for navigating the main window sidebar (⌘1β€“βŒ˜5). +public struct SidebarCommands: Commands { + @FocusedValue(\.sidebarSelection) private var selection + + public init() {} + + public var body: some Commands { + CommandGroup(after: .sidebar) { + ForEach(Array(SidebarItem.allCases.enumerated()), id: \.element) { index, item in + Button(item.title) { + selection?.wrappedValue = item + } + .keyboardShortcut(KeyEquivalent(Character("\(index + 1)"))) + } + .disabled(selection == nil) + } + } +} diff --git a/README.md b/README.md index 65c82d6..b96315d 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ After cloning: ./scripts/bootstrap ``` -This installs Mint from `Brewfile`, runs `mint bootstrap` to build the SwiftFormat and SwiftLint versions pinned in `Mintfile`, enables repository git hooks, and resolves Swift package dependencies for `Brew.xcodeproj`. +This installs Mint from `Brewfile`, runs `mint bootstrap` to build the SwiftFormat and SwiftLint versions pinned in `Mintfile`, enables repository git hooks, and resolves Swift package dependencies for `Homebrew.xcodeproj`. ### Pre-commit formatting and linting diff --git a/Sources/BrewCore/Support/LoadState.swift b/Sources/BrewCore/Support/LoadState.swift index e530539..1549ed3 100644 --- a/Sources/BrewCore/Support/LoadState.swift +++ b/Sources/BrewCore/Support/LoadState.swift @@ -21,6 +21,13 @@ public enum LoadState { } return value } + + public var isLoaded: Bool { + guard case .loaded = self else { + return false + } + return true + } } extension LoadState: Equatable where Value: Equatable, Failure: Equatable {} diff --git a/Sources/BrewFeatureDiscover/ViewModels/DiscoverViewModel.swift b/Sources/BrewFeatureDiscover/ViewModels/DiscoverViewModel.swift index 85dc987..7435894 100644 --- a/Sources/BrewFeatureDiscover/ViewModels/DiscoverViewModel.swift +++ b/Sources/BrewFeatureDiscover/ViewModels/DiscoverViewModel.swift @@ -77,6 +77,12 @@ final class DiscoverViewModel { isSearching ? results : trending } + /// Drives the list view's `@FocusState`. The list only owns keyboard focus on the trending landing + /// once it has loaded β€” while a search is active focus belongs to the catalogue search field. + var shouldFocusList: Bool { + trending.isLoaded && !isSearching + } + /// Search results have no analytics, so install-count metadata is suppressed in that mode. var showsInstallMetrics: Bool { !isSearching @@ -196,8 +202,94 @@ final class DiscoverViewModel { return package } - // MARK: - Loading + // MARK: - Helpers + static func sortedSection( + _ packages: [DiscoveryBrewPackage], + kind: HomebrewPackageKind, + ) -> [DiscoveryBrewPackage] { + packages + .filter { $0.kind == kind } + .sorted(by: sortByPopularityThenName) + } + + private static func sortByPopularityThenName( + _ lhs: DiscoveryBrewPackage, + _ rhs: DiscoveryBrewPackage, + ) -> Bool { + if lhs.thirtyDayInstallCount == rhs.thirtyDayInstallCount { + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + return lhs.thirtyDayInstallCount > rhs.thirtyDayInstallCount + } + + private static func userMessage(for error: Error, searching: Bool) -> String { + if case let BrewAPIClientError.transport(underlying) = error { + return underlying + } + if searching { + return String( + localized: "Something went wrong searching the catalogue.", + comment: "Discover tab generic search failure", + ) + } + return String( + localized: "Something went wrong loading Discover packages.", + comment: "Discover tab generic load failure", + ) + } +} + +// MARK: - Selection + +extension DiscoverViewModel { + func setSelection(_ packageID: BrewPackage.ID?) { + if let packageID { + guard visiblePackages.contains(where: { $0.id == packageID }) else { + return + } + selectedPackageID = packageID + } else { + selectedPackageID = visiblePackages.first?.id + } + } + + func selectNext() { + let orderedIDs = visiblePackages.map(\.id) + guard let currentID = selectedPackageID else { + if let first = orderedIDs.first { setSelection(first) } + return + } + if let nextID = orderedIDs.item(after: currentID) { + setSelection(nextID) + } + } + + func selectPrevious() { + let orderedIDs = visiblePackages.map(\.id) + guard let currentID = selectedPackageID else { + if let last = orderedIDs.last { setSelection(last) } + return + } + if let previousID = orderedIDs.item(before: currentID) { + setSelection(previousID) + } + } + + private func synchronizeSelectionWithVisibleRows() { + let visibleIDs = Set(visiblePackages.map(\.id)) + if let selectedPackageID, !visibleIDs.contains(selectedPackageID) { + self.selectedPackageID = nil + } + if selectedPackageID == nil { + selectedPackageID = visiblePackages.first?.id + } + } +} + +// MARK: - Loading + +extension DiscoverViewModel { func load() async { trending = .loading do { @@ -242,64 +334,20 @@ final class DiscoverViewModel { await load() } } +} - // MARK: - Selection - - func setSelection(_ packageID: BrewPackage.ID?) { - if let packageID { - guard visiblePackages.contains(where: { $0.id == packageID }) else { - return - } - selectedPackageID = packageID - } else { - selectedPackageID = visiblePackages.first?.id - } - } - - private func synchronizeSelectionWithVisibleRows() { - let visibleIDs = Set(visiblePackages.map(\.id)) - if let selectedPackageID, !visibleIDs.contains(selectedPackageID) { - self.selectedPackageID = nil - } - if selectedPackageID == nil { - selectedPackageID = visiblePackages.first?.id - } - } - - // MARK: - Helpers - - static func sortedSection( - _ packages: [DiscoveryBrewPackage], - kind: HomebrewPackageKind, - ) -> [DiscoveryBrewPackage] { - packages - .filter { $0.kind == kind } - .sorted(by: sortByPopularityThenName) - } - - private static func sortByPopularityThenName( - _ lhs: DiscoveryBrewPackage, - _ rhs: DiscoveryBrewPackage, - ) -> Bool { - if lhs.thirtyDayInstallCount == rhs.thirtyDayInstallCount { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending +extension Array where Element: Equatable { + func item(after value: Element) -> Element? { + guard let index = firstIndex(of: value), index + 1 < count else { + return nil } - return lhs.thirtyDayInstallCount > rhs.thirtyDayInstallCount + return self[index + 1] } - private static func userMessage(for error: Error, searching: Bool) -> String { - if case let BrewAPIClientError.transport(underlying) = error { - return underlying - } - if searching { - return String( - localized: "Something went wrong searching the catalogue.", - comment: "Discover tab generic search failure", - ) + func item(before value: Element) -> Element? { + guard let index = firstIndex(of: value), index - 1 >= 0 else { + return nil } - return String( - localized: "Something went wrong loading Discover packages.", - comment: "Discover tab generic load failure", - ) + return self[index - 1] } } diff --git a/Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift b/Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift index faa9769..b103f2a 100644 --- a/Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift +++ b/Sources/BrewFeatureDiscover/Views/DiscoverPackagesView.swift @@ -6,6 +6,7 @@ import SwiftUI /// Middle column of the main window: Discover package list. struct DiscoverPackagesView: View { @Bindable var viewModel: DiscoverViewModel + @State private var searchPresented = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -24,9 +25,11 @@ struct DiscoverPackagesView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .searchable( text: $viewModel.query, + isPresented: $searchPresented, placement: .toolbar, prompt: "Search Homebrew's Catalogue", ) + .focusedSceneValue(\.searchPresented, $searchPresented) .task(id: viewModel.query) { // Debounce so intermediate keystrokes don't each fire a search; cancellation handles the rest. try? await Task.sleep(for: .milliseconds(250)) @@ -76,6 +79,8 @@ struct DiscoverPackagesView: View { /// Sectioned Discover list, split by package kind and filtered by the active scope. Renders an inline /// empty-state message when a visible section has no rows (e.g. a scope filter that excludes everything). private struct DiscoverPackageSections: View { + @FocusState private var isFocused: Bool + let viewModel: DiscoverViewModel /// The redacted-placeholder or loaded packages handed down by `AsyncContentView` for this render. let packages: [DiscoveryBrewPackage] @@ -102,17 +107,29 @@ private struct DiscoverPackageSections: View { } } } - .listStyle(.plain) + .listStyle(.inset) .accessibilityLabel("Discover packages") .onAppear { scrollToSelection(viewModel.selectedPackageID, with: proxy) } + .task(id: viewModel.shouldFocusList) { + isFocused = viewModel.shouldFocusList + } + .focused($isFocused) .onChange(of: viewModel.selectedPackageID) { _, selectedID in scrollToSelection(selectedID, with: proxy) } .onChange(of: packages.map(\.id)) { _, _ in scrollToSelection(viewModel.selectedPackageID, with: proxy) } + .onKeyPress(.upArrow) { + viewModel.selectPrevious() + return .handled + } + .onKeyPress(.downArrow) { + viewModel.selectNext() + return .handled + } .onExitCommand { viewModel.setSelection(nil) } @@ -128,12 +145,13 @@ private struct DiscoverPackageSections: View { listRow(package) .id(package.id) .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(package.id) - } .listRowBackground( viewModel.selectedPackageID == package.id ? Color.brewBrandTint : Color.clear, ) + .onTapGesture { + // Needed to suppress the default ugly blue macOS highlight state + viewModel.setSelection(package.id) + } } } } diff --git a/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift b/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift index de78822..dae41b9 100644 --- a/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift +++ b/Sources/BrewFeatureDoctor/ViewModels/DoctorViewModel.swift @@ -74,6 +74,12 @@ final class DoctorViewModel { doctorRepository.isRefreshing } + /// Drives the issues list's `@FocusState`. The list only owns keyboard focus once a report has + /// loaded β€” loading and failure states have no rows to focus. + var shouldFocusList: Bool { + state.isLoaded + } + var issueItems: [DoctorIssueItem] { guard case let .loaded(report) = state else { return [] @@ -129,6 +135,35 @@ final class DoctorViewModel { selectedIssueID = id } + /// Issue ids in the order their rows render β€” grouped by descending severity, matching + /// `DoctorIssueGroup.grouped(from:)` β€” so keyboard navigation steps through the list as shown. + var orderedIssueIDs: [Int] { + guard case let .loaded(report) = state else { + return [] + } + return DoctorIssueGroup.grouped(from: report).flatMap { $0.items.map(\.id) } + } + + func selectNext() { + guard let currentID = selectedIssueID else { + if let first = orderedIssueIDs.first { setSelection(first) } + return + } + if let nextID = orderedIssueIDs.item(after: currentID) { + setSelection(nextID) + } + } + + func selectPrevious() { + guard let currentID = selectedIssueID else { + if let last = orderedIssueIDs.last { setSelection(last) } + return + } + if let previousID = orderedIssueIDs.item(before: currentID) { + setSelection(previousID) + } + } + func isFixRunning(_ item: DoctorIssueItem) -> Bool { guard let token = item.fixToken else { return false @@ -220,3 +255,19 @@ final class DoctorViewModel { } } } + +extension Array where Element: Equatable { + func item(after value: Element) -> Element? { + guard let index = firstIndex(of: value), index + 1 < count else { + return nil + } + return self[index + 1] + } + + func item(before value: Element) -> Element? { + guard let index = firstIndex(of: value), index - 1 >= 0 else { + return nil + } + return self[index - 1] + } +} diff --git a/Sources/BrewFeatureDoctor/Views/DoctorView.swift b/Sources/BrewFeatureDoctor/Views/DoctorView.swift index 0c0dbde..3707f51 100644 --- a/Sources/BrewFeatureDoctor/Views/DoctorView.swift +++ b/Sources/BrewFeatureDoctor/Views/DoctorView.swift @@ -12,6 +12,7 @@ import SwiftUI /// a small "checking" spinner in the header. struct DoctorView: View { @Bindable var viewModel: DoctorViewModel + @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -89,20 +90,33 @@ struct DoctorView: View { DoctorIssueRowView(item: item) .id(item.id) .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(item.id) - } .listRowBackground( viewModel.selectedIssueID == item.id ? Color.brewBrandTint : Color.clear, ) + .onTapGesture { + // Needed to suppress the default ugly blue macOS highlight state + viewModel.setSelection(item.id) + } } } header: { DoctorSeveritySectionHeader(severity: group.severity, issueCount: group.items.count) } } } - .listStyle(.plain) + .task(id: viewModel.shouldFocusList) { + isFocused = viewModel.shouldFocusList + } + .focused($isFocused) + .listStyle(.inset) .accessibilityLabel("Doctor issues") + .onKeyPress(.upArrow) { + viewModel.selectPrevious() + return .handled + } + .onKeyPress(.downArrow) { + viewModel.selectNext() + return .handled + } } } diff --git a/Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift b/Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift index d62caf1..9e8192b 100644 --- a/Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift +++ b/Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift @@ -27,6 +27,10 @@ struct InstalledPackagesContent: Equatable { var caskPackages: [InstalledBrewPackage] { packages.filter { $0.kind == .cask } } + + var orderedPackageIDs: [InstalledBrewPackage.ID] { + formulaPackages.map(\.id) + caskPackages.map(\.id) + } } @Observable @@ -78,6 +82,12 @@ final class InstalledViewModel { return false } + /// Drives the list view's `@FocusState`. The list only owns keyboard focus once the inventory has + /// loaded β€” while loading or in an error state focus belongs elsewhere (or to nothing). + var shouldFocusList: Bool { + state.isLoaded + } + var packageCountSubtitle: String { if shouldShowInitialLoadingIndicator { return String(localized: "Loading packages…", comment: "Installed tab subtitle while fetching") @@ -126,6 +136,26 @@ final class InstalledViewModel { } } + func selectNext() { + guard let currentID = activeSelectedPackageID else { + if let first = state.value?.orderedPackageIDs.first { setSelection(first) } + return + } + if let nextID = state.value?.orderedPackageIDs.item(after: currentID) { + setSelection(nextID) + } + } + + func selectPrevious() { + guard let currentID = activeSelectedPackageID else { + if let last = state.value?.orderedPackageIDs.last { setSelection(last) } + return + } + if let previousID = state.value?.orderedPackageIDs.item(before: currentID) { + setSelection(previousID) + } + } + func clearSelection() { selectedPackageID = firstVisibleRowID() searchPreviewSelectedPackageID = nil @@ -229,3 +259,19 @@ final class InstalledViewModel { } } } + +extension Array where Element: Equatable { + func item(after value: Element) -> Element? { + guard let index = firstIndex(of: value), index + 1 < count else { + return nil + } + return self[index + 1] + } + + func item(before value: Element) -> Element? { + guard let index = firstIndex(of: value), index - 1 >= 0 else { + return nil + } + return self[index - 1] + } +} diff --git a/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift b/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift index 0a0326b..6b831e4 100644 --- a/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift +++ b/Sources/BrewFeatureInstalled/ViewModels/UpgradesViewModel.swift @@ -78,6 +78,12 @@ final class UpgradesViewModel { return false } + /// Drives the list view's `@FocusState`. The list only owns keyboard focus once the outdated + /// inventory has loaded β€” while loading or in an error state focus belongs elsewhere. + var shouldFocusList: Bool { + state.isLoaded + } + /// Subtitle for the in-page Upgrades header. Reflects the unfiltered /// inventory when no search is active, and "Showing N of M" / "No matches /// in M outdated packages" once a query narrows the list. The window-chrome @@ -169,30 +175,6 @@ final class UpgradesViewModel { await repository.load(forceRefresh: true) } - func setSelection(_ selection: InstalledBrewPackage.ID?) { - if isSearchActive { - didCommitSelectionDuringSearch = true - searchPreviewSelectedPackageID = nil - } - if let selection { - selectedPackageID = selection - } else { - selectedPackageID = firstVisibleRowID() - } - } - - func clearSelection() { - selectedPackageID = firstVisibleRowID() - searchPreviewSelectedPackageID = nil - } - - func selectInstalledPackage(id: InstalledBrewPackage.ID) { - guard allRows.contains(where: { $0.id == id }) else { - return - } - setSelection(id) - } - /// User-facing command rendered by the Updates header's `CommandBlockView`. Reads the canonical /// literal from ``BrewOperationID/bulkUpgradeDisplayCommand`` so the view, the console job, and /// the live `BulkUpgradeCommand` all share one source of truth. @@ -322,3 +304,51 @@ final class UpgradesViewModel { } } } + +// MARK: - Selection + +extension UpgradesViewModel { + func setSelection(_ selection: InstalledBrewPackage.ID?) { + if isSearchActive { + didCommitSelectionDuringSearch = true + searchPreviewSelectedPackageID = nil + } + if let selection { + selectedPackageID = selection + } else { + selectedPackageID = firstVisibleRowID() + } + } + + func selectNext() { + guard let currentID = activeSelectedPackageID else { + if let first = state.value?.orderedPackageIDs.first { setSelection(first) } + return + } + if let nextID = state.value?.orderedPackageIDs.item(after: currentID) { + setSelection(nextID) + } + } + + func selectPrevious() { + guard let currentID = activeSelectedPackageID else { + if let last = state.value?.orderedPackageIDs.last { setSelection(last) } + return + } + if let previousID = state.value?.orderedPackageIDs.item(before: currentID) { + setSelection(previousID) + } + } + + func clearSelection() { + selectedPackageID = firstVisibleRowID() + searchPreviewSelectedPackageID = nil + } + + func selectInstalledPackage(id: InstalledBrewPackage.ID) { + guard allRows.contains(where: { $0.id == id }) else { + return + } + setSelection(id) + } +} diff --git a/Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift b/Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift index a34f6f9..ecc8d17 100644 --- a/Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift +++ b/Sources/BrewFeatureInstalled/Views/InstalledPackagesView.swift @@ -11,6 +11,8 @@ import SwiftUI /// Middle column of the main window: β€œInstalled” chrome and the package list. struct InstalledPackagesView: View { @Bindable var viewModel: InstalledViewModel + @State private var searchPresented = false + @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -38,9 +40,11 @@ struct InstalledPackagesView: View { } .searchable( text: $viewModel.searchQuery, + isPresented: $searchPresented, placement: .toolbar, prompt: "Search Installed Packages", ) + .focusedSceneValue(\.searchPresented, $searchPresented) } private func installedList(_ content: InstalledPackagesContent) -> some View { @@ -48,53 +52,60 @@ struct InstalledPackagesView: View { List { if content.shouldShowFormulaeSection { Section("Formulae") { - ForEach(content.formulaPackages) { package in - listRow(for: package) - .id(package.id) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(package.id) - } - .listRowBackground( - viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, - ) - } + sectionContent(for: content.formulaPackages) } } if content.shouldShowCasksSection { Section("Casks") { - ForEach(content.caskPackages) { package in - listRow(for: package) - .id(package.id) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(package.id) - } - .listRowBackground( - viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, - ) - } + sectionContent(for: content.caskPackages) } } } - .listStyle(.plain) + .listStyle(.inset) .accessibilityLabel("Installed packages") .onAppear { scrollToSelection(viewModel.activeSelectedPackageID, in: content, with: proxy) } + .task(id: viewModel.shouldFocusList) { + isFocused = viewModel.shouldFocusList + } + .focused($isFocused) .onChange(of: viewModel.activeSelectedPackageID) { _, selectedID in scrollToSelection(selectedID, in: content, with: proxy) } .onChange(of: content.packages.map(\.id)) { _, _ in scrollToSelection(viewModel.activeSelectedPackageID, in: content, with: proxy) } + .onKeyPress(.upArrow) { + viewModel.selectPrevious() + return .handled + } + .onKeyPress(.downArrow) { + viewModel.selectNext() + return .handled + } .onExitCommand { viewModel.clearSelection() } } } + private func sectionContent(for packages: [InstalledBrewPackage]) -> some View { + ForEach(packages) { package in + listRow(for: package) + .id(package.id) + .contentShape(Rectangle()) + .listRowBackground( + viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, + ) + .onTapGesture { + // Needed to suppress the default ugly blue macOS highlight state + viewModel.setSelection(package.id) + } + } + } + private func listRow(for package: InstalledBrewPackage) -> some View { InstalledListRowRoot(package: package) } diff --git a/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift b/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift index 8bda933..f80dbaf 100644 --- a/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift +++ b/Sources/BrewFeatureInstalled/Views/UpgradesPackagesView.swift @@ -11,6 +11,8 @@ import SwiftUI /// and a friendly empty state when nothing is outdated. struct UpgradesPackagesView: View { @Bindable var viewModel: UpgradesViewModel + @State private var searchPresented = false + @FocusState private var isFocused: Bool var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -35,9 +37,11 @@ struct UpgradesPackagesView: View { } .searchable( text: $viewModel.searchQuery, + isPresented: $searchPresented, placement: .toolbar, prompt: "Search Upgrades", ) + .focusedSceneValue(\.searchPresented, $searchPresented) } private var header: some View { @@ -80,53 +84,60 @@ struct UpgradesPackagesView: View { List { if content.shouldShowFormulaeSection { Section("Formulae") { - ForEach(content.formulaPackages) { package in - listRow(for: package) - .id(package.id) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(package.id) - } - .listRowBackground( - viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, - ) - } + sectionContent(for: content.formulaPackages) } } if content.shouldShowCasksSection { Section("Casks") { - ForEach(content.caskPackages) { package in - listRow(for: package) - .id(package.id) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.setSelection(package.id) - } - .listRowBackground( - viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, - ) - } + sectionContent(for: content.caskPackages) } } } - .listStyle(.plain) + .listStyle(.inset) .accessibilityLabel("Outdated packages") .onAppear { scrollToSelection(viewModel.activeSelectedPackageID, in: content, with: proxy) } + .task(id: viewModel.shouldFocusList) { + isFocused = viewModel.shouldFocusList + } + .focused($isFocused) .onChange(of: viewModel.activeSelectedPackageID) { _, selectedID in scrollToSelection(selectedID, in: content, with: proxy) } .onChange(of: content.packages.map(\.id)) { _, _ in scrollToSelection(viewModel.activeSelectedPackageID, in: content, with: proxy) } + .onKeyPress(.upArrow) { + viewModel.selectPrevious() + return .handled + } + .onKeyPress(.downArrow) { + viewModel.selectNext() + return .handled + } .onExitCommand { viewModel.clearSelection() } } } + private func sectionContent(for packages: [InstalledBrewPackage]) -> some View { + ForEach(packages) { package in + listRow(for: package) + .id(package.id) + .contentShape(Rectangle()) + .listRowBackground( + viewModel.activeSelectedPackageID == package.id ? Color.brewBrandTint : Color.clear, + ) + .onTapGesture { + // Needed to suppress the default ugly blue macOS highlight state + viewModel.setSelection(package.id) + } + } + } + private func listRow(for package: InstalledBrewPackage) -> some View { InstalledListRowRoot(package: package) } diff --git a/Sources/BrewUIComponents/Commands/SearchCommands.swift b/Sources/BrewUIComponents/Commands/SearchCommands.swift new file mode 100644 index 0000000..57cbc02 --- /dev/null +++ b/Sources/BrewUIComponents/Commands/SearchCommands.swift @@ -0,0 +1,25 @@ +// +// SearchCommands.swift +// BrewKit +// +// + +import SwiftUI + +public struct SearchCommands: Commands { + @FocusedValue(\.searchPresented) private var searchPresented + + public init() {} + + public var body: some Commands { + CommandGroup(after: .textEditing) { + Button("Find") { searchPresented?.wrappedValue = true } + .keyboardShortcut("f") // ⌘F + .disabled(searchPresented == nil) + } + } +} + +public extension FocusedValues { + @Entry var searchPresented: Binding? +} diff --git a/Sources/BrewUIComponents/Theme/BrewSpacing.swift b/Sources/BrewUIComponents/Theme/BrewSpacing.swift index a624455..4b69bdc 100644 --- a/Sources/BrewUIComponents/Theme/BrewSpacing.swift +++ b/Sources/BrewUIComponents/Theme/BrewSpacing.swift @@ -48,10 +48,8 @@ public enum BrewLayout { public static let installedDetailColumnMaxWidth: CGFloat = 1200 public static let installedThreePaneMinWindowWidth: CGFloat = 960 - /// Minimum window width for the main window: sidebar + feature surface. - /// Installed detail is handled inside the feature view when selected. - public static let minWindowWidth: CGFloat = - Self.sidebarWidth + Self.installedListColumnMinWidth + /// Minimum supported window width. + public static let minWindowWidth: CGFloat = 820 /// Minimum supported window height. public static let minWindowHeight: CGFloat = 520 diff --git a/Tests/.swiftlint.yml b/Tests/.swiftlint.yml index 66794ad..bb34732 100644 --- a/Tests/.swiftlint.yml +++ b/Tests/.swiftlint.yml @@ -1,6 +1,8 @@ # Merge with repo root `.swiftlint.yml`. Integration/UI test scaffolding often grows beyond -# the default struct type-body cap without adding real complexity. +# the default type-body and file caps without adding real complexity, so test files +# and test types are allowed to be any length. parent_config: ../.swiftlint.yml disabled_rules: - type_body_length + - file_length diff --git a/Tests/BrewFeatureDiscoverTests/DiscoverViewModelTests.swift b/Tests/BrewFeatureDiscoverTests/DiscoverViewModelTests.swift index 8275d94..791ea03 100644 --- a/Tests/BrewFeatureDiscoverTests/DiscoverViewModelTests.swift +++ b/Tests/BrewFeatureDiscoverTests/DiscoverViewModelTests.swift @@ -21,6 +21,7 @@ struct DiscoverViewModelTests { thirtyDayInstallCount: 100, ), ], + topCasks: [ discoveryPackage( name: "iterm2", @@ -344,6 +345,85 @@ struct DiscoverViewModelTests { #expect(viewModel.selectedPackage?.id == .formula(name: "git")) } + // MARK: - Keyboard navigation + + @Test @MainActor func `selectNext steps through visible rows and stops at the last`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot( + topFormulae: [ + discoveryPackage(name: "git", thirtyDayInstallCount: 100), + discoveryPackage(name: "node", thirtyDayInstallCount: 80), + ], + topCasks: [discoveryPackage(name: "docker", kind: .cask, thirtyDayInstallCount: 70)], + ), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + #expect(viewModel.selectedPackage?.id == .formula(name: "git")) + + viewModel.selectNext() + #expect(viewModel.selectedPackage?.id == .formula(name: "node")) + // Crosses the formulae β†’ casks section boundary. + viewModel.selectNext() + #expect(viewModel.selectedPackage?.id == .cask(token: "docker")) + // Clamps at the last visible row. + viewModel.selectNext() + #expect(viewModel.selectedPackage?.id == .cask(token: "docker")) + } + + @Test @MainActor func `selectPrevious steps backward through visible rows and stops at the first`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot( + topFormulae: [ + discoveryPackage(name: "git", thirtyDayInstallCount: 100), + discoveryPackage(name: "node", thirtyDayInstallCount: 80), + ], + topCasks: [discoveryPackage(name: "docker", kind: .cask, thirtyDayInstallCount: 70)], + ), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + viewModel.setSelection(.cask(token: "docker")) + + viewModel.selectPrevious() + #expect(viewModel.selectedPackage?.id == .formula(name: "node")) + viewModel.selectPrevious() + #expect(viewModel.selectedPackage?.id == .formula(name: "git")) + viewModel.selectPrevious() + #expect(viewModel.selectedPackage?.id == .formula(name: "git")) + } + + @Test @MainActor func `selectNext navigates only within the active scope`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot( + topFormulae: [discoveryPackage(name: "git", thirtyDayInstallCount: 100)], + topCasks: [discoveryPackage(name: "docker", kind: .cask, thirtyDayInstallCount: 70)], + ), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + viewModel.scope = .casks + #expect(viewModel.selectedPackage?.id == .cask(token: "docker")) + + // Only the cask is visible, so there's nothing to advance to. + viewModel.selectNext() + #expect(viewModel.selectedPackage?.id == .cask(token: "docker")) + viewModel.selectPrevious() + #expect(viewModel.selectedPackage?.id == .cask(token: "docker")) + } + // MARK: - Search @Test @MainActor func `search populates results and switches into searching mode`() async { @@ -498,6 +578,68 @@ struct DiscoverViewModelTests { } #expect(message == "Something went wrong searching the catalogue.") } + + // MARK: - shouldFocusList + + @Test @MainActor func `shouldFocusList is true when trending has loaded and not searching`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot( + topFormulae: [discoveryPackage(name: "git", thirtyDayInstallCount: 100)], + topCasks: [], + ), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + + #expect(viewModel.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is false while trending is still loading`() { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot(topFormulae: [], topCasks: []), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + // trending starts as .loading and load() has not been awaited yet. + #expect(!viewModel.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is false when trending failed to load`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: ThrowingDiscoverPackagesRepository(error: DiscoverOddError()), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + + #expect(!viewModel.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is false when trending has loaded but a search is active`() async { + let viewModel = DiscoverViewModel( + discoverPackagesRepository: StubDiscoverPackagesRepository( + snapshot: DiscoverTopPackagesSnapshot( + topFormulae: [discoveryPackage(name: "git", thirtyDayInstallCount: 100)], + topCasks: [], + ), + ), + catalogueRepository: StubCatalogueRepository(), + installedRepository: installedRepo(), + ) + + await viewModel.load() + viewModel.query = "foo" + + #expect(!viewModel.shouldFocusList) + } } @MainActor @@ -532,6 +674,10 @@ private final class MutableDiscoverPackagesRepository: DiscoverPackagesRepositor private struct ThrowingDiscoverPackagesRepository: DiscoverPackagesRepository { let error: Error + init(error: Error = DiscoverOddError()) { + self.error = error + } + func loadTopPackages( limit _: Int, window _: BrewAnalyticsWindow, diff --git a/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift b/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift index 11d14eb..f944a1b 100644 --- a/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift +++ b/Tests/BrewFeatureDoctorTests/DoctorViewModelTests.swift @@ -285,6 +285,94 @@ struct DoctorViewModelTests { DoctorIssue(title: title, severity: severity, blocks: [], rawBody: "") } + // MARK: - Keyboard navigation + + @Test func `orderedIssueIDs follows grouped descending-severity order`() async { + let caution = Self.minimal(.caution, "c1") + let danger = Self.minimal(.danger, "d1") + let unsupported = Self.minimal(.unsupported, "u1") + let viewModel = Self.viewModel( + repository: StubDoctorRepository(report: DoctorReport(issues: [caution, danger, unsupported])), + ) + await viewModel.load() + + #expect(viewModel.orderedIssueIDs == [ + DoctorIssueItem.contentID(for: unsupported), + DoctorIssueItem.contentID(for: danger), + DoctorIssueItem.contentID(for: caution), + ]) + } + + @Test func `selectNext steps through issues in grouped order and stops at the last`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: Self.threeSeverityReport())) + await viewModel.load() + let ordered = viewModel.orderedIssueIDs + #expect(ordered.count == 3) + + viewModel.setSelection(ordered[0]) + viewModel.selectNext() + #expect(viewModel.selectedIssueID == ordered[1]) + viewModel.selectNext() + #expect(viewModel.selectedIssueID == ordered[2]) + // Clamps at the final issue. + viewModel.selectNext() + #expect(viewModel.selectedIssueID == ordered[2]) + } + + @Test func `selectPrevious steps backward through issues and stops at the first`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: Self.threeSeverityReport())) + await viewModel.load() + let ordered = viewModel.orderedIssueIDs + + viewModel.setSelection(ordered[2]) + viewModel.selectPrevious() + #expect(viewModel.selectedIssueID == ordered[1]) + viewModel.selectPrevious() + #expect(viewModel.selectedIssueID == ordered[0]) + // Clamps at the first issue. + viewModel.selectPrevious() + #expect(viewModel.selectedIssueID == ordered[0]) + } + + @Test func `selectNext from no selection selects the first issue`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: Self.threeSeverityReport())) + await viewModel.load() + viewModel.setSelection(nil) + + viewModel.selectNext() + + #expect(viewModel.selectedIssueID == viewModel.orderedIssueIDs.first) + } + + @Test func `selectPrevious from no selection selects the last issue`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: Self.threeSeverityReport())) + await viewModel.load() + viewModel.setSelection(nil) + + viewModel.selectPrevious() + + #expect(viewModel.selectedIssueID == viewModel.orderedIssueIDs.last) + } + + @Test func `selectNext is a no-op on a healthy report`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: DoctorReport(issues: []))) + await viewModel.load() + #expect(viewModel.selectedIssueID == nil) + + viewModel.selectNext() + #expect(viewModel.selectedIssueID == nil) + viewModel.selectPrevious() + #expect(viewModel.selectedIssueID == nil) + } + + private static func threeSeverityReport() -> DoctorReport { + DoctorReport(issues: [ + minimal(.caution, "c1"), + minimal(.danger, "d1"), + minimal(.unsupported, "u1"), + ]) + } + // MARK: - Header chrome @Test func `showsHeaderControls is hidden while loading and on failure`() { @@ -338,6 +426,37 @@ struct DoctorViewModelTests { ) #expect(viewModel.subtitle == "The check could not be completed") } + + // MARK: - shouldFocusList + + @Test func `shouldFocusList is false while the doctor check is running`() { + let viewModel = Self.viewModel(repository: LoadingDoctorRepository()) + #expect(!viewModel.shouldFocusList) + } + + @Test func `shouldFocusList is true once a report with issues has loaded`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: Self.issuesReport())) + + await viewModel.load() + + #expect(viewModel.shouldFocusList) + } + + @Test func `shouldFocusList is true on a healthy report`() async { + let viewModel = Self.viewModel(repository: StubDoctorRepository(report: DoctorReport(issues: []))) + + await viewModel.load() + + #expect(viewModel.shouldFocusList) + } + + @Test func `shouldFocusList is false when the doctor check fails`() { + let viewModel = Self.viewModel( + repository: StubDoctorRepository(error: BrewLookupError.executableNotFound), + ) + + #expect(!viewModel.shouldFocusList) + } } /// Test-scoped doctor repository pinned in the `.loading` state. Used to exercise the loading branches of diff --git a/Tests/BrewFeatureInstalledTests/InstalledViewModelTests.swift b/Tests/BrewFeatureInstalledTests/InstalledViewModelTests.swift index df94e3c..75e3422 100644 --- a/Tests/BrewFeatureInstalledTests/InstalledViewModelTests.swift +++ b/Tests/BrewFeatureInstalledTests/InstalledViewModelTests.swift @@ -60,6 +60,53 @@ struct InstalledViewModelTests { #expect(vm.selectedPackage?.id == selectedID) } + // MARK: - Keyboard navigation + + @Test @MainActor func `selectNext steps forward through the rows and stops at the last`() async { + let vm = await InstalledFeatureTestSupport.loadedViewModel( + formulae: [.fixture(name: "git", kind: .formula), .fixture(name: "wget", kind: .formula)], + casks: [.fixture(name: "slack", kind: .cask)], + ) + let ordered = vm.loadedFormulaPackages.map(\.id) + vm.loadedCaskPackages.map(\.id) + #expect(ordered.count == 3) + + vm.setSelection(ordered[0]) + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[1]) + // Crosses the formulae β†’ casks section boundary. + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[2]) + // Clamps at the final row. + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[2]) + } + + @Test @MainActor func `selectPrevious steps backward through the rows and stops at the first`() async { + let vm = await InstalledFeatureTestSupport.loadedViewModel( + formulae: [.fixture(name: "git", kind: .formula), .fixture(name: "wget", kind: .formula)], + casks: [.fixture(name: "slack", kind: .cask)], + ) + let ordered = vm.loadedFormulaPackages.map(\.id) + vm.loadedCaskPackages.map(\.id) + + vm.setSelection(ordered[2]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[1]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[0]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[0]) + } + + @Test @MainActor func `selectNext and selectPrevious are no-ops with an empty inventory`() async { + let vm = await InstalledFeatureTestSupport.loadedViewModel() + #expect(vm.selectedPackage == nil) + + vm.selectNext() + #expect(vm.selectedPackage == nil) + vm.selectPrevious() + #expect(vm.selectedPackage == nil) + } + @Test @MainActor func `refresh preserves selection when package still exists`() async { let firstJSON = """ { @@ -220,4 +267,34 @@ struct InstalledViewModelTests { } #expect(message == InstalledPackagesTestSupport.localizedGenericLoadFailureMessage()) } + + // MARK: - shouldFocusList + + @Test @MainActor func `shouldFocusList is false before the inventory has loaded`() { + let vm = makeInstalledViewModel(repository: unloadedInstalledRepository()) + + #expect(!vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is true once the inventory has loaded`() async { + let vm = await InstalledFeatureTestSupport.loadedViewModel( + formulae: [.fixture(name: "git", kind: .formula)], + ) + + #expect(vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is true when loaded with an empty inventory`() async { + let vm = await InstalledFeatureTestSupport.loadedViewModel() + + #expect(vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is false when the load fails`() async { + let vm = makeInstalledViewModel(repository: failingInstalledRepository(error: OddRepositoryError())) + + await vm.load() + + #expect(!vm.shouldFocusList) + } } diff --git a/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift b/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift index dde1a8d..3ae713d 100644 --- a/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift +++ b/Tests/BrewFeatureInstalledTests/UpgradesViewModelTests.swift @@ -266,6 +266,101 @@ struct UpgradesViewModelTests { await center.emit(id: unrelated, phase: .idle) } + // MARK: - Keyboard navigation + + @Test @MainActor func `selectNext steps forward through outdated rows and stops at the last`() { + let vm = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + .fixture(name: "wget", kind: .formula, outdated: true), + .fixture(name: "slack", kind: .cask, outdated: true), + ]) + guard case let .loaded(content) = vm.state else { + Issue.record("expected loaded state") + return + } + let ordered = content.formulaPackages.map(\.id) + content.caskPackages.map(\.id) + #expect(ordered.count == 3) + + vm.setSelection(ordered[0]) + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[1]) + // Crosses the formulae β†’ casks section boundary. + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[2]) + // Clamps at the final row. + vm.selectNext() + #expect(vm.selectedPackage?.id == ordered[2]) + } + + @Test @MainActor func `selectPrevious steps backward through outdated rows and stops at the first`() { + let vm = Self.makeViewModel(packages: [ + .fixture(name: "git", kind: .formula, outdated: true), + .fixture(name: "wget", kind: .formula, outdated: true), + .fixture(name: "slack", kind: .cask, outdated: true), + ]) + guard case let .loaded(content) = vm.state else { + Issue.record("expected loaded state") + return + } + let ordered = content.formulaPackages.map(\.id) + content.caskPackages.map(\.id) + + vm.setSelection(ordered[2]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[1]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[0]) + vm.selectPrevious() + #expect(vm.selectedPackage?.id == ordered[0]) + } + + @Test @MainActor func `selectNext and selectPrevious are no-ops when nothing is outdated`() { + let vm = Self.makeViewModel(packages: [ + .fixture(name: "wget", kind: .formula, outdated: false), + ]) + #expect(vm.selectedPackage == nil) + + vm.selectNext() + #expect(vm.selectedPackage == nil) + vm.selectPrevious() + #expect(vm.selectedPackage == nil) + } + + // MARK: - shouldFocusList + + @Test @MainActor func `shouldFocusList is false while the outdated inventory is still loading`() { + let vm = UpgradesViewModel( + repository: StubInstalledPackagesRepository(state: .loading), + brewCommandCenter: StubBrewCommandCenter(), + commandFactory: StubMutatingCommandFactory(), + ) + + #expect(!vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is true once the outdated inventory has loaded`() { + let vm = Self.makeViewModel(packages: Self.mixedPackages) + + #expect(vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is true when loaded with no outdated rows`() { + let vm = Self.makeViewModel(packages: [ + .fixture(name: "wget", kind: .formula, outdated: false), + ]) + + #expect(vm.shouldFocusList) + } + + @Test @MainActor func `shouldFocusList is false when the inventory load failed`() { + let vm = UpgradesViewModel( + repository: StubInstalledPackagesRepository(state: .failed(OddRepositoryError())), + brewCommandCenter: StubBrewCommandCenter(), + commandFactory: StubMutatingCommandFactory(), + ) + + #expect(!vm.shouldFocusList) + } + // MARK: - Helpers private static var mixedPackages: [InstalledBrewPackage] { diff --git a/Tools/BrewUILint/Tests/BrewUILintTests/.swiftlint.yml b/Tools/BrewUILint/Tests/BrewUILintTests/.swiftlint.yml index ef7ac61..0a90c10 100644 --- a/Tools/BrewUILint/Tests/BrewUILintTests/.swiftlint.yml +++ b/Tools/BrewUILint/Tests/BrewUILintTests/.swiftlint.yml @@ -1,3 +1,9 @@ +# Test scaffolding is allowed to grow without tripping length caps. parent_config: ../../../.swiftlint.yml + +disabled_rules: + - type_body_length + - file_length + identifier_name: min_length: 2 diff --git a/scripts/bootstrap b/scripts/bootstrap index 7eb5016..29f284a 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -45,7 +45,7 @@ echo "==> Installing repository git hooks" "$ROOT_DIR/scripts/install-git-hooks" echo "==> Resolving Swift package dependencies" -xcodebuild -resolvePackageDependencies -project "$ROOT_DIR/Brew.xcodeproj" +xcodebuild -resolvePackageDependencies -project "$ROOT_DIR/Homebrew.xcodeproj" # --------------------------------------------------------------------------- # Per-developer signing config @@ -59,11 +59,11 @@ if [ ! -f "$SIGNING_LOCAL" ]; then echo "==> Created Configurations/Signing.local.xcconfig" echo " Open that file and replace YOUR_TEAM_ID_HERE with your" echo " 10-character Apple Team ID (Xcode β†’ Settings β†’ Accounts)." - echo " Then open Brew.xcodeproj β€” signing will resolve automatically." + echo " Then open Homebrew.xcodeproj β€” signing will resolve automatically." else echo "==> Signing config already exists (Configurations/Signing.local.xcconfig)" fi echo "" echo "==> Bootstrap complete" -echo "Next step: open Brew.xcodeproj" +echo "Next step: open Homebrew.xcodeproj" diff --git a/scripts/pre-commit b/scripts/pre-commit index ba51c85..0fb1ce6 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -18,7 +18,7 @@ if ! command -v mint >/dev/null 2>&1; then exit 1 fi -# BrewUILint runs over the WHOLE tree (Brew + Sources), not just the staged files. Its +# BrewUILint runs over the WHOLE tree (Homebrew + Sources), not just the staged files. Its # nonisolated-extension rule needs to see every `nonisolated` type declaration to flag a bad # extension on it, and that declaration may live in an unstaged file in another package. Tests # (Tests/) are intentionally excluded. Builds the tool first (cached in Tools/BrewUILint/.build). @@ -29,7 +29,7 @@ if ! xcrun swift build --package-path Tools/BrewUILint -c release >/dev/null 2>& fi brewuilint_bin="$(xcrun swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint" set +e -brewuilint_output="$(find Brew Sources -name '*.swift' -print0 | xargs -0 "${brewuilint_bin}" 2>&1)" +brewuilint_output="$(find Homebrew Sources -name '*.swift' -print0 | xargs -0 "${brewuilint_bin}" 2>&1)" brewuilint_status=$? set -e if [[ ${brewuilint_status} -ne 0 ]]; then