Commit 9954d3a4fa
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj added +377
| @@ -0,0 +1,377 @@ | ||
| 1 | // !$*UTF8*$! | |
| 2 | { | |
| 3 | archiveVersion = 1; | |
| 4 | classes = { | |
| 5 | }; | |
| 6 | objectVersion = 77; | |
| 7 | objects = { | |
| 8 | ||
| 9 | /* Begin PBXBuildFile section */ | |
| 10 | 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89612F69DEA900FC0253 /* TODO.md */; }; | |
| 11 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; }; | |
| 12 | 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; }; | |
| 13 | 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; }; | |
| 14 | /* End PBXBuildFile section */ | |
| 15 | ||
| 16 | /* Begin PBXFileReference section */ | |
| 17 | 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; }; | |
| 18 | 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; }; | |
| 19 | 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; }; | |
| 20 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; }; | |
| 21 | 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; }; | |
| 22 | /* End PBXFileReference section */ | |
| 23 | ||
| 24 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ | |
| 25 | 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */ = { | |
| 26 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; | |
| 27 | membershipExceptions = ( | |
| 28 | Info.plist, | |
| 29 | ); | |
| 30 | target = 8B4B28D02F6704280045FA19 /* Hutch */; | |
| 31 | }; | |
| 32 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ | |
| 33 | ||
| 34 | /* Begin PBXFileSystemSynchronizedRootGroup section */ | |
| 35 | 8B4B28D32F6704280045FA19 /* Hutch */ = { | |
| 36 | isa = PBXFileSystemSynchronizedRootGroup; | |
| 37 | exceptions = ( | |
| 38 | 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */, | |
| 39 | ); | |
| 40 | path = Hutch; | |
| 41 | sourceTree = "<group>"; | |
| 42 | }; | |
| 43 | /* End PBXFileSystemSynchronizedRootGroup section */ | |
| 44 | ||
| 45 | /* Begin PBXFrameworksBuildPhase section */ | |
| 46 | 8B4B28CE2F6704280045FA19 /* Frameworks */ = { | |
| 47 | isa = PBXFrameworksBuildPhase; | |
| 48 | buildActionMask = 2147483647; | |
| 49 | files = ( | |
| 50 | ); | |
| 51 | runOnlyForDeploymentPostprocessing = 0; | |
| 52 | }; | |
| 53 | /* End PBXFrameworksBuildPhase section */ | |
| 54 | ||
| 55 | /* Begin PBXGroup section */ | |
| 56 | 8B4B28C82F6704280045FA19 = { | |
| 57 | isa = PBXGroup; | |
| 58 | children = ( | |
| 59 | 8B2F89632F69DEB900FC0253 /* LICENSE */, | |
| 60 | 8B2F89642F69DEB900FC0253 /* README.md */, | |
| 61 | 8B2F89652F69DEB900FC0253 /* SECURITY.md */, | |
| 62 | 8B2F89612F69DEA900FC0253 /* TODO.md */, | |
| 63 | 8B4B28D32F6704280045FA19 /* Hutch */, | |
| 64 | 8B4B28D22F6704280045FA19 /* Products */, | |
| 65 | ); | |
| 66 | sourceTree = "<group>"; | |
| 67 | }; | |
| 68 | 8B4B28D22F6704280045FA19 /* Products */ = { | |
| 69 | isa = PBXGroup; | |
| 70 | children = ( | |
| 71 | 8B4B28D12F6704280045FA19 /* Hutch.app */, | |
| 72 | ); | |
| 73 | name = Products; | |
| 74 | sourceTree = "<group>"; | |
| 75 | }; | |
| 76 | /* End PBXGroup section */ | |
| 77 | ||
| 78 | /* Begin PBXNativeTarget section */ | |
| 79 | 8B4B28D02F6704280045FA19 /* Hutch */ = { | |
| 80 | isa = PBXNativeTarget; | |
| 81 | buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */; | |
| 82 | buildPhases = ( | |
| 83 | 8B4B28CD2F6704280045FA19 /* Sources */, | |
| 84 | 8B4B28CE2F6704280045FA19 /* Frameworks */, | |
| 85 | 8B4B28CF2F6704280045FA19 /* Resources */, | |
| 86 | ); | |
| 87 | buildRules = ( | |
| 88 | ); | |
| 89 | dependencies = ( | |
| 90 | ); | |
| 91 | fileSystemSynchronizedGroups = ( | |
| 92 | 8B4B28D32F6704280045FA19 /* Hutch */, | |
| 93 | ); | |
| 94 | name = Hutch; | |
| 95 | packageProductDependencies = ( | |
| 96 | ); | |
| 97 | productName = Hutch; | |
| 98 | productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; | |
| 99 | productType = "com.apple.product-type.application"; | |
| 100 | }; | |
| 101 | /* End PBXNativeTarget section */ | |
| 102 | ||
| 103 | /* Begin PBXProject section */ | |
| 104 | 8B4B28C92F6704280045FA19 /* Project object */ = { | |
| 105 | isa = PBXProject; | |
| 106 | attributes = { | |
| 107 | BuildIndependentTargetsInParallel = 1; | |
| 108 | LastSwiftUpdateCheck = 2630; | |
| 109 | LastUpgradeCheck = 2630; | |
| 110 | TargetAttributes = { | |
| 111 | 8B4B28D02F6704280045FA19 = { | |
| 112 | CreatedOnToolsVersion = 26.3; | |
| 113 | }; | |
| 114 | }; | |
| 115 | }; | |
| 116 | buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */; | |
| 117 | developmentRegion = en; | |
| 118 | hasScannedForEncodings = 0; | |
| 119 | knownRegions = ( | |
| 120 | en, | |
| 121 | Base, | |
| 122 | ); | |
| 123 | mainGroup = 8B4B28C82F6704280045FA19; | |
| 124 | minimizedProjectReferenceProxies = 1; | |
| 125 | preferredProjectObjectVersion = 77; | |
| 126 | productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; | |
| 127 | projectDirPath = ""; | |
| 128 | projectRoot = ""; | |
| 129 | targets = ( | |
| 130 | 8B4B28D02F6704280045FA19 /* Hutch */, | |
| 131 | ); | |
| 132 | }; | |
| 133 | /* End PBXProject section */ | |
| 134 | ||
| 135 | /* Begin PBXResourcesBuildPhase section */ | |
| 136 | 8B4B28CF2F6704280045FA19 /* Resources */ = { | |
| 137 | isa = PBXResourcesBuildPhase; | |
| 138 | buildActionMask = 2147483647; | |
| 139 | files = ( | |
| 140 | 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */, | |
| 141 | 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */, | |
| 142 | 8B2F89672F69DEB900FC0253 /* README.md in Resources */, | |
| 143 | 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */, | |
| 144 | ); | |
| 145 | runOnlyForDeploymentPostprocessing = 0; | |
| 146 | }; | |
| 147 | /* End PBXResourcesBuildPhase section */ | |
| 148 | ||
| 149 | /* Begin PBXSourcesBuildPhase section */ | |
| 150 | 8B4B28CD2F6704280045FA19 /* Sources */ = { | |
| 151 | isa = PBXSourcesBuildPhase; | |
| 152 | buildActionMask = 2147483647; | |
| 153 | files = ( | |
| 154 | ); | |
| 155 | runOnlyForDeploymentPostprocessing = 0; | |
| 156 | }; | |
| 157 | /* End PBXSourcesBuildPhase section */ | |
| 158 | ||
| 159 | /* Begin XCBuildConfiguration section */ | |
| 160 | 8B4B28DA2F6704290045FA19 /* Debug */ = { | |
| 161 | isa = XCBuildConfiguration; | |
| 162 | buildSettings = { | |
| 163 | ALWAYS_SEARCH_USER_PATHS = NO; | |
| 164 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | |
| 165 | CLANG_ANALYZER_NONNULL = YES; | |
| 166 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | |
| 167 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | |
| 168 | CLANG_ENABLE_MODULES = YES; | |
| 169 | CLANG_ENABLE_OBJC_ARC = YES; | |
| 170 | CLANG_ENABLE_OBJC_WEAK = YES; | |
| 171 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; | |
| 172 | CLANG_WARN_BOOL_CONVERSION = YES; | |
| 173 | CLANG_WARN_COMMA = YES; | |
| 174 | CLANG_WARN_CONSTANT_CONVERSION = YES; | |
| 175 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; | |
| 176 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; | |
| 177 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | |
| 178 | CLANG_WARN_EMPTY_BODY = YES; | |
| 179 | CLANG_WARN_ENUM_CONVERSION = YES; | |
| 180 | CLANG_WARN_INFINITE_RECURSION = YES; | |
| 181 | CLANG_WARN_INT_CONVERSION = YES; | |
| 182 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; | |
| 183 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; | |
| 184 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; | |
| 185 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; | |
| 186 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | |
| 187 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; | |
| 188 | CLANG_WARN_STRICT_PROTOTYPES = YES; | |
| 189 | CLANG_WARN_SUSPICIOUS_MOVE = YES; | |
| 190 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | |
| 191 | CLANG_WARN_UNREACHABLE_CODE = YES; | |
| 192 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; | |
| 193 | COPY_PHASE_STRIP = NO; | |
| 194 | DEBUG_INFORMATION_FORMAT = dwarf; | |
| 195 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 196 | ENABLE_STRICT_OBJC_MSGSEND = YES; | |
| 197 | ENABLE_TESTABILITY = YES; | |
| 198 | ENABLE_USER_SCRIPT_SANDBOXING = YES; | |
| 199 | GCC_C_LANGUAGE_STANDARD = gnu17; | |
| 200 | GCC_DYNAMIC_NO_PIC = NO; | |
| 201 | GCC_NO_COMMON_BLOCKS = YES; | |
| 202 | GCC_OPTIMIZATION_LEVEL = 0; | |
| 203 | GCC_PREPROCESSOR_DEFINITIONS = ( | |
| 204 | "DEBUG=1", | |
| 205 | "$(inherited)", | |
| 206 | ); | |
| 207 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; | |
| 208 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; | |
| 209 | GCC_WARN_UNDECLARED_SELECTOR = YES; | |
| 210 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | |
| 211 | GCC_WARN_UNUSED_FUNCTION = YES; | |
| 212 | GCC_WARN_UNUSED_VARIABLE = YES; | |
| 213 | IPHONEOS_DEPLOYMENT_TARGET = 26.2; | |
| 214 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | |
| 215 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; | |
| 216 | MTL_FAST_MATH = YES; | |
| 217 | ONLY_ACTIVE_ARCH = YES; | |
| 218 | SDKROOT = iphoneos; | |
| 219 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; | |
| 220 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; | |
| 221 | }; | |
| 222 | name = Debug; | |
| 223 | }; | |
| 224 | 8B4B28DB2F6704290045FA19 /* Release */ = { | |
| 225 | isa = XCBuildConfiguration; | |
| 226 | buildSettings = { | |
| 227 | ALWAYS_SEARCH_USER_PATHS = NO; | |
| 228 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; | |
| 229 | CLANG_ANALYZER_NONNULL = YES; | |
| 230 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; | |
| 231 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; | |
| 232 | CLANG_ENABLE_MODULES = YES; | |
| 233 | CLANG_ENABLE_OBJC_ARC = YES; | |
| 234 | CLANG_ENABLE_OBJC_WEAK = YES; | |
| 235 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; | |
| 236 | CLANG_WARN_BOOL_CONVERSION = YES; | |
| 237 | CLANG_WARN_COMMA = YES; | |
| 238 | CLANG_WARN_CONSTANT_CONVERSION = YES; | |
| 239 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; | |
| 240 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; | |
| 241 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; | |
| 242 | CLANG_WARN_EMPTY_BODY = YES; | |
| 243 | CLANG_WARN_ENUM_CONVERSION = YES; | |
| 244 | CLANG_WARN_INFINITE_RECURSION = YES; | |
| 245 | CLANG_WARN_INT_CONVERSION = YES; | |
| 246 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; | |
| 247 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; | |
| 248 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; | |
| 249 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; | |
| 250 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; | |
| 251 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; | |
| 252 | CLANG_WARN_STRICT_PROTOTYPES = YES; | |
| 253 | CLANG_WARN_SUSPICIOUS_MOVE = YES; | |
| 254 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; | |
| 255 | CLANG_WARN_UNREACHABLE_CODE = YES; | |
| 256 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; | |
| 257 | COPY_PHASE_STRIP = NO; | |
| 258 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; | |
| 259 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 260 | ENABLE_NS_ASSERTIONS = NO; | |
| 261 | ENABLE_STRICT_OBJC_MSGSEND = YES; | |
| 262 | ENABLE_USER_SCRIPT_SANDBOXING = YES; | |
| 263 | GCC_C_LANGUAGE_STANDARD = gnu17; | |
| 264 | GCC_NO_COMMON_BLOCKS = YES; | |
| 265 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; | |
| 266 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; | |
| 267 | GCC_WARN_UNDECLARED_SELECTOR = YES; | |
| 268 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; | |
| 269 | GCC_WARN_UNUSED_FUNCTION = YES; | |
| 270 | GCC_WARN_UNUSED_VARIABLE = YES; | |
| 271 | IPHONEOS_DEPLOYMENT_TARGET = 26.2; | |
| 272 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; | |
| 273 | MTL_ENABLE_DEBUG_INFO = NO; | |
| 274 | MTL_FAST_MATH = YES; | |
| 275 | SDKROOT = iphoneos; | |
| 276 | SWIFT_COMPILATION_MODE = wholemodule; | |
| 277 | VALIDATE_PRODUCT = YES; | |
| 278 | }; | |
| 279 | name = Release; | |
| 280 | }; | |
| 281 | 8B4B28DD2F6704290045FA19 /* Debug */ = { | |
| 282 | isa = XCBuildConfiguration; | |
| 283 | buildSettings = { | |
| 284 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 285 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | |
| 286 | CODE_SIGN_STYLE = Automatic; | |
| 287 | CURRENT_PROJECT_VERSION = 1; | |
| 288 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 289 | ENABLE_PREVIEWS = YES; | |
| 290 | GENERATE_INFOPLIST_FILE = YES; | |
| 291 | INFOPLIST_FILE = Hutch/Info.plist; | |
| 292 | INFOPLIST_KEY_CFBundleDisplayName = Hutch; | |
| 293 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; | |
| 294 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | |
| 295 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | |
| 296 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 297 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 298 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 299 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 300 | LD_RUNPATH_SEARCH_PATHS = ( | |
| 301 | "$(inherited)", | |
| 302 | "@executable_path/Frameworks", | |
| 303 | ); | |
| 304 | MARKETING_VERSION = 1.0; | |
| 305 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | |
| 306 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 307 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | |
| 308 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 309 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 310 | SWIFT_EMIT_LOC_STRINGS = YES; | |
| 311 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 312 | SWIFT_VERSION = 5.0; | |
| 313 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 314 | }; | |
| 315 | name = Debug; | |
| 316 | }; | |
| 317 | 8B4B28DE2F6704290045FA19 /* Release */ = { | |
| 318 | isa = XCBuildConfiguration; | |
| 319 | buildSettings = { | |
| 320 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | |
| 321 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; | |
| 322 | CODE_SIGN_STYLE = Automatic; | |
| 323 | CURRENT_PROJECT_VERSION = 1; | |
| 324 | DEVELOPMENT_TEAM = ZCNAX3VL9D; | |
| 325 | ENABLE_PREVIEWS = YES; | |
| 326 | GENERATE_INFOPLIST_FILE = YES; | |
| 327 | INFOPLIST_FILE = Hutch/Info.plist; | |
| 328 | INFOPLIST_KEY_CFBundleDisplayName = Hutch; | |
| 329 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; | |
| 330 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; | |
| 331 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; | |
| 332 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; | |
| 333 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 334 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; | |
| 335 | IPHONEOS_DEPLOYMENT_TARGET = 17.6; | |
| 336 | LD_RUNPATH_SEARCH_PATHS = ( | |
| 337 | "$(inherited)", | |
| 338 | "@executable_path/Frameworks", | |
| 339 | ); | |
| 340 | MARKETING_VERSION = 1.0; | |
| 341 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; | |
| 342 | PRODUCT_NAME = "$(TARGET_NAME)"; | |
| 343 | STRING_CATALOG_GENERATE_SYMBOLS = YES; | |
| 344 | SWIFT_APPROACHABLE_CONCURRENCY = YES; | |
| 345 | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; | |
| 346 | SWIFT_EMIT_LOC_STRINGS = YES; | |
| 347 | SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; | |
| 348 | SWIFT_VERSION = 5.0; | |
| 349 | TARGETED_DEVICE_FAMILY = "1,2"; | |
| 350 | }; | |
| 351 | name = Release; | |
| 352 | }; | |
| 353 | /* End XCBuildConfiguration section */ | |
| 354 | ||
| 355 | /* Begin XCConfigurationList section */ | |
| 356 | 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = { | |
| 357 | isa = XCConfigurationList; | |
| 358 | buildConfigurations = ( | |
| 359 | 8B4B28DA2F6704290045FA19 /* Debug */, | |
| 360 | 8B4B28DB2F6704290045FA19 /* Release */, | |
| 361 | ); | |
| 362 | defaultConfigurationIsVisible = 0; | |
| 363 | defaultConfigurationName = Release; | |
| 364 | }; | |
| 365 | 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */ = { | |
| 366 | isa = XCConfigurationList; | |
| 367 | buildConfigurations = ( | |
| 368 | 8B4B28DD2F6704290045FA19 /* Debug */, | |
| 369 | 8B4B28DE2F6704290045FA19 /* Release */, | |
| 370 | ); | |
| 371 | defaultConfigurationIsVisible = 0; | |
| 372 | defaultConfigurationName = Release; | |
| 373 | }; | |
| 374 | /* End XCConfigurationList section */ | |
| 375 | }; | |
| 376 | rootObject = 8B4B28C92F6704280045FA19 /* Project object */; | |
| 377 | } | |
Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
| @@ -0,0 +1,7 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <Workspace | |
| 3 | version = "1.0"> | |
| 4 | <FileRef | |
| 5 | location = "self:"> | |
| 6 | </FileRef> | |
| 7 | </Workspace> | |
Hutch/API_REFERENCE.md added +147
| @@ -0,0 +1,147 @@ | ||
| 1 | # Sourcehut API Reference | |
| 2 | ||
| 3 | This document provides context and references for working with Sourcehut's APIs. | |
| 4 | ||
| 5 | ## Official Documentation | |
| 6 | ||
| 7 | The official Sourcehut API documentation can be found at: | |
| 8 | - **https://man.sr.ht** - Contains links to API reference pages for all *.sr.ht services | |
| 9 | ||
| 10 | ## Services and Their APIs | |
| 11 | ||
| 12 | ### git.sr.ht (Git Repositories) | |
| 13 | - **GraphQL API**: https://git.sr.ht/graphql | |
| 14 | - **REST API**: https://git.sr.ht/api | |
| 15 | - **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write) | |
| 16 | ||
| 17 | ### todo.sr.ht (Ticket Tracking) | |
| 18 | - **GraphQL API**: https://todo.sr.ht/graphql | |
| 19 | - **REST API**: https://todo.sr.ht/api | |
| 20 | - **Scope required**: `TICKETS:RO` (read-only), `TICKETS:RW` (read-write) | |
| 21 | ||
| 22 | ### hg.sr.ht (Mercurial Repositories) | |
| 23 | - **GraphQL API**: https://hg.sr.ht/graphql | |
| 24 | - **REST API**: https://hg.sr.ht/api | |
| 25 | - **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write) | |
| 26 | ||
| 27 | ### lists.sr.ht (Mailing Lists) | |
| 28 | - **GraphQL API**: https://lists.sr.ht/graphql | |
| 29 | - **REST API**: https://lists.sr.ht/api | |
| 30 | - **Scope required**: `LISTS:RO` (read-only), `LISTS:RW` (read-write) | |
| 31 | ||
| 32 | ### builds.sr.ht (CI/CD) | |
| 33 | - **GraphQL API**: https://builds.sr.ht/graphql | |
| 34 | - **REST API**: https://builds.sr.ht/api | |
| 35 | - **Scope required**: `BUILDS:RO` (read-only), `BUILDS:RW` (read-write) | |
| 36 | ||
| 37 | ### meta.sr.ht (User Accounts) | |
| 38 | - **GraphQL API**: https://meta.sr.ht/graphql | |
| 39 | - **REST API**: https://meta.sr.ht/api | |
| 40 | - **Scope required**: `ACCOUNT:RO` (read-only), `ACCOUNT:RW` (read-write) | |
| 41 | ||
| 42 | ## Authentication | |
| 43 | ||
| 44 | All API requests require a **Personal Access Token** with the appropriate scopes. | |
| 45 | ||
| 46 | - **Token creation**: https://meta.sr.ht/oauth2/personal-token | |
| 47 | - **Token format**: `Bearer <your-token>` in the Authorization header | |
| 48 | ||
| 49 | ## GraphQL Conventions | |
| 50 | ||
| 51 | ### Common Types | |
| 52 | ||
| 53 | ```graphql | |
| 54 | # Cursor-based pagination | |
| 55 | type Query { | |
| 56 | items(cursor: Cursor, filter: Filter): ItemsPage | |
| 57 | } | |
| 58 | ||
| 59 | type ItemsPage { | |
| 60 | results: [Item!]! | |
| 61 | cursor: Cursor | |
| 62 | } | |
| 63 | ||
| 64 | # Filter structure (varies by service) | |
| 65 | input Filter { | |
| 66 | search: String | |
| 67 | # ... other service-specific filters | |
| 68 | } | |
| 69 | ``` | |
| 70 | ||
| 71 | ### Error Handling | |
| 72 | ||
| 73 | Sourcehut APIs return GraphQL errors in the following format: | |
| 74 | ||
| 75 | ```json | |
| 76 | { | |
| 77 | "data": null, | |
| 78 | "errors": [ | |
| 79 | { | |
| 80 | "message": "Error message", | |
| 81 | "path": ["query", "field"], | |
| 82 | "extensions": { | |
| 83 | "code": "ERROR_CODE" | |
| 84 | } | |
| 85 | } | |
| 86 | ] | |
| 87 | } | |
| 88 | ``` | |
| 89 | ||
| 90 | ## Rate Limiting | |
| 91 | ||
| 92 | - **Rate limits**: Vary by service and authentication status | |
| 93 | - **Headers**: Check `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | |
| 94 | - **Unauthenticated**: Lower limits (typically 60 requests/hour) | |
| 95 | - **Authenticated**: Higher limits (typically 5000 requests/hour) | |
| 96 | ||
| 97 | ## Development Notes | |
| 98 | ||
| 99 | ### API Exploration | |
| 100 | ||
| 101 | Use GraphiQL interfaces available at each service's `/graphql` endpoint to: | |
| 102 | - Explore the schema | |
| 103 | - Test queries | |
| 104 | - Understand available fields and types | |
| 105 | ||
| 106 | ### Common Issues | |
| 107 | ||
| 108 | 1. **Filtering**: Some services may not support all filter operations. Check the schema. | |
| 109 | 2. **Pagination**: Always handle `cursor` properly for pagination. | |
| 110 | 3. **Caching**: Sourcehut APIs may have aggressive caching. Use cache headers appropriately. | |
| 111 | ||
| 112 | ### Example Query Structure | |
| 113 | ||
| 114 | ```graphql | |
| 115 | query { | |
| 116 | repositories(cursor: $cursor, filter: $filter) { | |
| 117 | results { | |
| 118 | id | |
| 119 | name | |
| 120 | description | |
| 121 | # ... other fields | |
| 122 | } | |
| 123 | cursor | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | # Variables | |
| 128 | { | |
| 129 | "filter": { | |
| 130 | "search": "query string" | |
| 131 | } | |
| 132 | } | |
| 133 | ``` | |
| 134 | ||
| 135 | ## Troubleshooting | |
| 136 | ||
| 137 | 1. **401 Unauthorized**: Check token scopes and expiration | |
| 138 | 2. **403 Forbidden**: Verify you have access to the requested resource | |
| 139 | 3. **429 Too Many Requests**: Implement proper rate limiting in your client | |
| 140 | 4. **500 Internal Server Error**: Check if the API is temporarily down | |
| 141 | ||
| 142 | ## Additional Resources | |
| 143 | ||
| 144 | - Sourcehut API Status: https://status.sr.ht | |
| 145 | - Sourcehut Meta (announcements): https://meta.sr.ht | |
| 146 | - Sourcehut GitHub Mirror: https://github.com/sourcehut | |
| 147 | - IRC: #sourcehut on Libera.Chat | |
| \ No newline at end of file | ||
Hutch/App/AppState.swift added +222
| @@ -0,0 +1,222 @@ | ||
| 1 | import Foundation | |
| 2 | import SwiftUI | |
| 3 | import WebKit | |
| 4 | ||
| 5 | /// Central application state shared across the view hierarchy. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class AppState { | |
| 9 | ||
| 10 | enum AuthPhase { | |
| 11 | /// App just launched, checking for an existing token. | |
| 12 | case launching | |
| 13 | /// No valid token — show the token entry screen. | |
| 14 | case unauthenticated | |
| 15 | /// Token validated, user is signed in. | |
| 16 | case authenticated | |
| 17 | } | |
| 18 | ||
| 19 | // MARK: - Authentication | |
| 20 | ||
| 21 | private(set) var authPhase: AuthPhase = .launching | |
| 22 | ||
| 23 | /// Convenience for views that need a simple bool. | |
| 24 | var isAuthenticated: Bool { | |
| 25 | authPhase == .authenticated && currentUser != nil | |
| 26 | } | |
| 27 | ||
| 28 | // MARK: - Current user (populated after successful validation) | |
| 29 | ||
| 30 | private(set) var currentUser: User? | |
| 31 | ||
| 32 | // MARK: - Networking | |
| 33 | ||
| 34 | let client: SRHTClient | |
| 35 | ||
| 36 | // MARK: - Deep link pending navigation | |
| 37 | ||
| 38 | /// Set by the deep link handler; consumed by RootView to drive navigation. | |
| 39 | var pendingDeepLink: DeepLink? | |
| 40 | ||
| 41 | // MARK: - Init | |
| 42 | ||
| 43 | init() { | |
| 44 | let token = KeychainHelper.loadToken() | |
| 45 | self.client = SRHTClient(token: token) | |
| 46 | } | |
| 47 | ||
| 48 | // MARK: - Launch validation | |
| 49 | ||
| 50 | /// Called once at app launch. If a token exists in Keychain, validates it | |
| 51 | /// silently. On failure, clears the token and falls through to unauthenticated. | |
| 52 | func validateOnLaunch() async { | |
| 53 | guard client.hasToken else { | |
| 54 | authPhase = .unauthenticated | |
| 55 | return | |
| 56 | } | |
| 57 | ||
| 58 | do { | |
| 59 | let user = try await fetchMe() | |
| 60 | currentUser = user | |
| 61 | authPhase = .authenticated | |
| 62 | } catch { | |
| 63 | try? KeychainHelper.deleteToken() | |
| 64 | client.setToken(nil) | |
| 65 | currentUser = nil | |
| 66 | authPhase = .unauthenticated | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | // MARK: - Token management | |
| 71 | ||
| 72 | /// Validate a new token by querying meta.sr.ht, then persist it. | |
| 73 | /// Throws on network/GraphQL errors so the caller can display the message. | |
| 74 | func connect(with token: String) async throws { | |
| 75 | // Temporarily set the token so the client can use it for the request. | |
| 76 | client.setToken(token) | |
| 77 | ||
| 78 | do { | |
| 79 | let user = try await fetchMe() | |
| 80 | try KeychainHelper.saveToken(token) | |
| 81 | currentUser = user | |
| 82 | authPhase = .authenticated | |
| 83 | } catch { | |
| 84 | // Roll back — don't leave an invalid token in the client. | |
| 85 | client.setToken(nil) | |
| 86 | throw error | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | func signOut() async { | |
| 91 | clearSessionState() | |
| 92 | URLCache.shared.removeAllCachedResponses() | |
| 93 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | |
| 94 | await clearWebData() | |
| 95 | clearWebContentRenderCaches() | |
| 96 | authPhase = .unauthenticated | |
| 97 | } | |
| 98 | ||
| 99 | func resetAppData() async { | |
| 100 | clearSessionState() | |
| 101 | ||
| 102 | if let bundleIdentifier = Bundle.main.bundleIdentifier { | |
| 103 | UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier) | |
| 104 | } | |
| 105 | URLCache.shared.removeAllCachedResponses() | |
| 106 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } | |
| 107 | await clearWebData() | |
| 108 | clearWebContentRenderCaches() | |
| 109 | ||
| 110 | authPhase = .unauthenticated | |
| 111 | } | |
| 112 | ||
| 113 | // MARK: - Deep link resolution | |
| 114 | ||
| 115 | /// Resolve a repository by owner and name for deep linking. | |
| 116 | func resolveRepository(owner: String, name: String) async throws -> RepositorySummary { | |
| 117 | let result = try await client.execute( | |
| 118 | service: .git, | |
| 119 | query: Self.repoLookupQuery, | |
| 120 | variables: ["owner": owner, "name": name], | |
| 121 | responseType: RepoLookupResponse.self | |
| 122 | ) | |
| 123 | return result.user.repository | |
| 124 | } | |
| 125 | ||
| 126 | /// Resolve a tracker by owner and name for deep linking. | |
| 127 | func resolveTracker(owner: String, name: String) async throws -> TrackerSummary { | |
| 128 | let result = try await client.execute( | |
| 129 | service: .todo, | |
| 130 | query: Self.trackerLookupQuery, | |
| 131 | variables: ["owner": owner, "name": name], | |
| 132 | responseType: TrackerLookupResponse.self | |
| 133 | ) | |
| 134 | return result.user.tracker | |
| 135 | } | |
| 136 | ||
| 137 | // MARK: - Private | |
| 138 | ||
| 139 | private static let meQuery = """ | |
| 140 | { | |
| 141 | me { | |
| 142 | id | |
| 143 | username | |
| 144 | canonicalName | |
| 145 | ||
| 146 | avatar | |
| 147 | } | |
| 148 | } | |
| 149 | """ | |
| 150 | ||
| 151 | private struct MeResponse: Decodable { | |
| 152 | let me: User | |
| 153 | } | |
| 154 | ||
| 155 | private func fetchMe() async throws -> User { | |
| 156 | let result = try await client.execute( | |
| 157 | service: .meta, | |
| 158 | query: Self.meQuery, | |
| 159 | responseType: MeResponse.self | |
| 160 | ) | |
| 161 | return result.me | |
| 162 | } | |
| 163 | ||
| 164 | // MARK: - Deep link queries | |
| 165 | ||
| 166 | private static let repoLookupQuery = """ | |
| 167 | query repoLookup($owner: String!, $name: String!) { | |
| 168 | user(username: $owner) { | |
| 169 | repository(name: $name) { | |
| 170 | id rid name description visibility updated | |
| 171 | owner { canonicalName } | |
| 172 | HEAD { name target } | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | """ | |
| 177 | ||
| 178 | private struct RepoLookupResponse: Decodable, Sendable { | |
| 179 | let user: RepoLookupUser | |
| 180 | } | |
| 181 | ||
| 182 | private struct RepoLookupUser: Decodable, Sendable { | |
| 183 | let repository: RepositorySummary | |
| 184 | } | |
| 185 | ||
| 186 | private static let trackerLookupQuery = """ | |
| 187 | query trackerLookup($owner: String!, $name: String!) { | |
| 188 | user(username: $owner) { | |
| 189 | tracker(name: $name) { | |
| 190 | id rid name description visibility updated | |
| 191 | owner { canonicalName } | |
| 192 | } | |
| 193 | } | |
| 194 | } | |
| 195 | """ | |
| 196 | ||
| 197 | private struct TrackerLookupResponse: Decodable, Sendable { | |
| 198 | let user: TrackerLookupUser | |
| 199 | } | |
| 200 | ||
| 201 | private struct TrackerLookupUser: Decodable, Sendable { | |
| 202 | let tracker: TrackerSummary | |
| 203 | } | |
| 204 | ||
| 205 | private func clearSessionState() { | |
| 206 | try? KeychainHelper.deleteAll() | |
| 207 | client.setToken(nil) | |
| 208 | client.responseCache.clear() | |
| 209 | currentUser = nil | |
| 210 | pendingDeepLink = nil | |
| 211 | } | |
| 212 | ||
| 213 | private func clearWebData() async { | |
| 214 | await withCheckedContinuation { continuation in | |
| 215 | let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() | |
| 216 | let since = Date(timeIntervalSince1970: 0) | |
| 217 | WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) { | |
| 218 | continuation.resume() | |
| 219 | } | |
| 220 | } | |
| 221 | } | |
| 222 | } | |
Hutch/App/DeepLink.swift added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Represents a parsed `hutch://` deep link. | |
| 4 | enum DeepLink: Equatable { | |
| 5 | /// hutch://git/<owner>/<repo> | |
| 6 | case repository(owner: String, repo: String) | |
| 7 | /// hutch://todo/<owner>/<tracker>/<ticketId> | |
| 8 | case ticket(owner: String, tracker: String, ticketId: Int) | |
| 9 | /// hutch://builds/<jobId> | |
| 10 | case build(jobId: Int) | |
| 11 | ||
| 12 | /// Attempt to parse a URL into a DeepLink. | |
| 13 | /// Expected format: hutch://<path> | |
| 14 | init?(url: URL) { | |
| 15 | guard url.scheme == "hutch" else { return nil } | |
| 16 | ||
| 17 | // url.host gives the first path component for opaque URLs; | |
| 18 | // use standardized path components from the full string. | |
| 19 | let components = url.pathComponents(fromScheme: "hutch") | |
| 20 | ||
| 21 | switch components.first { | |
| 22 | case "git" where components.count >= 3: | |
| 23 | let owner = components[1] | |
| 24 | let repo = components[2] | |
| 25 | self = .repository(owner: owner, repo: repo) | |
| 26 | ||
| 27 | case "todo" where components.count >= 4: | |
| 28 | let owner = components[1] | |
| 29 | let tracker = components[2] | |
| 30 | guard let ticketId = Int(components[3]) else { return nil } | |
| 31 | self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId) | |
| 32 | ||
| 33 | case "builds" where components.count >= 2: | |
| 34 | guard let jobId = Int(components[1]) else { return nil } | |
| 35 | self = .build(jobId: jobId) | |
| 36 | ||
| 37 | default: | |
| 38 | return nil | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | private extension URL { | |
| 44 | /// Parse path components from a custom-scheme URL. | |
| 45 | /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`. | |
| 46 | func pathComponents(fromScheme scheme: String) -> [String] { | |
| 47 | // Remove scheme prefix and split by "/" | |
| 48 | var str = absoluteString | |
| 49 | if str.hasPrefix("\(scheme)://") { | |
| 50 | str = String(str.dropFirst("\(scheme)://".count)) | |
| 51 | } | |
| 52 | return str.split(separator: "/").map(String.init) | |
| 53 | } | |
| 54 | } | |
Hutch/App/HutchApp.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | @main | |
| 4 | struct HutchApp: App { | |
| 5 | @State private var appState = AppState() | |
| 6 | @State private var networkMonitor = NetworkMonitor() | |
| 7 | ||
| 8 | var body: some Scene { | |
| 9 | WindowGroup { | |
| 10 | RootView() | |
| 11 | .environment(appState) | |
| 12 | .environment(networkMonitor) | |
| 13 | .onOpenURL { url in | |
| 14 | if let link = DeepLink(url: url) { | |
| 15 | appState.pendingDeepLink = link | |
| 16 | } | |
| 17 | } | |
| 18 | } | |
| 19 | } | |
| 20 | } | |
Hutch/App/RootView.swift added +197
| @@ -0,0 +1,197 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// The root view of the app. Shows a TabView when authenticated, or a | |
| 4 | /// full-screen sheet for token entry on first launch. | |
| 5 | struct RootView: View { | |
| 6 | @Environment(AppState.self) private var appState | |
| 7 | ||
| 8 | enum Tab: Hashable { | |
| 9 | case repositories | |
| 10 | case builds | |
| 11 | case tickets | |
| 12 | case settings | |
| 13 | } | |
| 14 | ||
| 15 | @State private var selectedTab: Tab = .repositories | |
| 16 | @State private var repoPath = NavigationPath() | |
| 17 | @State private var buildsPath = NavigationPath() | |
| 18 | @State private var ticketsPath = NavigationPath() | |
| 19 | @State private var isResolvingDeepLink = false | |
| 20 | ||
| 21 | var body: some View { | |
| 22 | Group { | |
| 23 | switch appState.authPhase { | |
| 24 | case .launching: | |
| 25 | ProgressView("Connecting…") | |
| 26 | .task { | |
| 27 | await appState.validateOnLaunch() | |
| 28 | } | |
| 29 | ||
| 30 | case .unauthenticated: | |
| 31 | // Full-screen token entry that cannot be dismissed. | |
| 32 | TokenEntryView() | |
| 33 | ||
| 34 | case .authenticated: | |
| 35 | tabContent | |
| 36 | } | |
| 37 | } | |
| 38 | .onChange(of: appState.pendingDeepLink) { _, newValue in | |
| 39 | consumePendingDeepLinkIfPossible(newValue) | |
| 40 | } | |
| 41 | .onChange(of: appState.authPhase) { _, newPhase in | |
| 42 | handleAuthPhaseChange(newPhase) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | // MARK: - Tab View | |
| 47 | ||
| 48 | private var tabContent: some View { | |
| 49 | TabView(selection: $selectedTab) { | |
| 50 | NavigationStack(path: $repoPath) { | |
| 51 | RepositoryListView() | |
| 52 | } | |
| 53 | .tag(Tab.repositories) | |
| 54 | .tabItem { | |
| 55 | Label("Repositories", systemImage: "book.closed") | |
| 56 | } | |
| 57 | ||
| 58 | NavigationStack(path: $buildsPath) { | |
| 59 | BuildListView() | |
| 60 | // Int destination used by deep links (hutch://builds/<id>). | |
| 61 | // JobSummary destination is registered inside BuildListView. | |
| 62 | .navigationDestination(for: Int.self) { jobId in | |
| 63 | BuildDetailView(jobId: jobId) | |
| 64 | } | |
| 65 | } | |
| 66 | .tag(Tab.builds) | |
| 67 | .tabItem { | |
| 68 | Label("Builds", systemImage: "hammer") | |
| 69 | } | |
| 70 | ||
| 71 | NavigationStack(path: $ticketsPath) { | |
| 72 | TrackerListView() | |
| 73 | // Deep link destination for jumping straight to a ticket. | |
| 74 | .navigationDestination(for: TicketDeepLinkTarget.self) { target in | |
| 75 | TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId) | |
| 76 | } | |
| 77 | } | |
| 78 | .tag(Tab.tickets) | |
| 79 | .tabItem { | |
| 80 | Label("Tickets", systemImage: "ticket") | |
| 81 | } | |
| 82 | ||
| 83 | SettingsView() | |
| 84 | .tag(Tab.settings) | |
| 85 | .tabItem { | |
| 86 | Label("Settings", systemImage: "gear") | |
| 87 | } | |
| 88 | } | |
| 89 | .overlay { | |
| 90 | if isResolvingDeepLink { | |
| 91 | ZStack { | |
| 92 | Color.black.opacity(0.3) | |
| 93 | .ignoresSafeArea() | |
| 94 | ProgressView("Opening link…") | |
| 95 | .padding() | |
| 96 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | // MARK: - Deep Link Handling | |
| 103 | ||
| 104 | private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) { | |
| 105 | switch newPhase { | |
| 106 | case .launching: | |
| 107 | break | |
| 108 | case .unauthenticated: | |
| 109 | repoPath = NavigationPath() | |
| 110 | buildsPath = NavigationPath() | |
| 111 | ticketsPath = NavigationPath() | |
| 112 | selectedTab = .repositories | |
| 113 | isResolvingDeepLink = false | |
| 114 | case .authenticated: | |
| 115 | consumePendingDeepLinkIfPossible(appState.pendingDeepLink) | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) { | |
| 120 | guard appState.isAuthenticated, let link else { return } | |
| 121 | handleDeepLink(link) | |
| 122 | appState.pendingDeepLink = nil | |
| 123 | } | |
| 124 | ||
| 125 | private func handleDeepLink(_ link: DeepLink) { | |
| 126 | guard appState.isAuthenticated else { return } | |
| 127 | ||
| 128 | switch link { | |
| 129 | case .repository(let owner, let repo): | |
| 130 | resolveRepositoryLink(owner: owner, repo: repo) | |
| 131 | ||
| 132 | case .build(let jobId): | |
| 133 | // Reset the builds navigation and push the detail | |
| 134 | buildsPath = NavigationPath() | |
| 135 | selectedTab = .builds | |
| 136 | // Defer the push slightly so the tab switch takes effect | |
| 137 | Task { @MainActor in | |
| 138 | try? await Task.sleep(for: .milliseconds(100)) | |
| 139 | buildsPath.append(jobId) | |
| 140 | } | |
| 141 | ||
| 142 | case .ticket(let owner, let tracker, let ticketId): | |
| 143 | resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId) | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | private func resolveRepositoryLink(owner: String, repo: String) { | |
| 148 | isResolvingDeepLink = true | |
| 149 | Task { | |
| 150 | defer { isResolvingDeepLink = false } | |
| 151 | do { | |
| 152 | let summary = try await appState.resolveRepository(owner: owner, name: repo) | |
| 153 | repoPath = NavigationPath() | |
| 154 | selectedTab = .repositories | |
| 155 | try? await Task.sleep(for: .milliseconds(100)) | |
| 156 | repoPath.append(summary) | |
| 157 | } catch { | |
| 158 | // Silently fail — the repo may not exist or be inaccessible | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | ||
| 163 | private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) { | |
| 164 | isResolvingDeepLink = true | |
| 165 | Task { | |
| 166 | defer { isResolvingDeepLink = false } | |
| 167 | do { | |
| 168 | let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker) | |
| 169 | ticketsPath = NavigationPath() | |
| 170 | selectedTab = .tickets | |
| 171 | try? await Task.sleep(for: .milliseconds(100)) | |
| 172 | ticketsPath.append(trackerSummary) | |
| 173 | try? await Task.sleep(for: .milliseconds(100)) | |
| 174 | ticketsPath.append(TicketDeepLinkTarget( | |
| 175 | ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()), | |
| 176 | trackerName: trackerSummary.name, | |
| 177 | trackerId: trackerSummary.id, | |
| 178 | trackerRid: trackerSummary.rid, | |
| 179 | ticketId: ticketId | |
| 180 | )) | |
| 181 | } catch { | |
| 182 | // Silently fail | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | // MARK: - Ticket Deep Link Navigation Target | |
| 189 | ||
| 190 | /// Hashable wrapper to push a ticket detail view from a deep link. | |
| 191 | struct TicketDeepLinkTarget: Hashable { | |
| 192 | let ownerUsername: String | |
| 193 | let trackerName: String | |
| 194 | let trackerId: Int | |
| 195 | let trackerRid: String | |
| 196 | let ticketId: Int | |
| 197 | } | |
Hutch/Assets.xcassets/AccentColor.colorset/Contents.json added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | { | |
| 2 | "colors" : [ | |
| 3 | { | |
| 4 | "idiom" : "universal" | |
| 5 | } | |
| 6 | ], | |
| 7 | "info" : { | |
| 8 | "author" : "xcode", | |
| 9 | "version" : 1 | |
| 10 | } | |
| 11 | } | |
Hutch/Assets.xcassets/AppIcon.appiconset/1.png added
Binary file not shown.
Hutch/Assets.xcassets/AppIcon.appiconset/2.png added
Binary file not shown.
Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | { | |
| 2 | "images" : [ | |
| 3 | { | |
| 4 | "filename" : "1.png", | |
| 5 | "idiom" : "universal", | |
| 6 | "platform" : "ios", | |
| 7 | "size" : "1024x1024" | |
| 8 | }, | |
| 9 | { | |
| 10 | "appearances" : [ | |
| 11 | { | |
| 12 | "appearance" : "luminosity", | |
| 13 | "value" : "dark" | |
| 14 | } | |
| 15 | ], | |
| 16 | "filename" : "2.png", | |
| 17 | "idiom" : "universal", | |
| 18 | "platform" : "ios", | |
| 19 | "size" : "1024x1024" | |
| 20 | }, | |
| 21 | { | |
| 22 | "appearances" : [ | |
| 23 | { | |
| 24 | "appearance" : "luminosity", | |
| 25 | "value" : "tinted" | |
| 26 | } | |
| 27 | ], | |
| 28 | "idiom" : "universal", | |
| 29 | "platform" : "ios", | |
| 30 | "size" : "1024x1024" | |
| 31 | } | |
| 32 | ], | |
| 33 | "info" : { | |
| 34 | "author" : "xcode", | |
| 35 | "version" : 1 | |
| 36 | } | |
| 37 | } | |
Hutch/Assets.xcassets/Contents.json added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | { | |
| 2 | "info" : { | |
| 3 | "author" : "xcode", | |
| 4 | "version" : 1 | |
| 5 | } | |
| 6 | } | |
Hutch/Extensions/Date+Relative.swift added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension Date { | |
| 4 | /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025". | |
| 5 | var relativeDescription: String { | |
| 6 | let formatter = RelativeDateTimeFormatter() | |
| 7 | formatter.unitsStyle = .abbreviated | |
| 8 | return formatter.localizedString(for: self, relativeTo: .now) | |
| 9 | } | |
| 10 | } | |
Hutch/Extensions/DateFormatter+SRHT.swift added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension DateFormatter { | |
| 4 | /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC). | |
| 5 | static let srht: DateFormatter = { | |
| 6 | let formatter = DateFormatter() | |
| 7 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" | |
| 8 | formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 9 | formatter.timeZone = TimeZone(identifier: "UTC") | |
| 10 | return formatter | |
| 11 | }() | |
| 12 | ||
| 13 | /// Fallback formatter that also accepts fractional seconds. | |
| 14 | static let srhtFractional: DateFormatter = { | |
| 15 | let formatter = DateFormatter() | |
| 16 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" | |
| 17 | formatter.locale = Locale(identifier: "en_US_POSIX") | |
| 18 | formatter.timeZone = TimeZone(identifier: "UTC") | |
| 19 | return formatter | |
| 20 | }() | |
| 21 | } | |
| 22 | ||
| 23 | extension JSONDecoder.DateDecodingStrategy { | |
| 24 | /// Tries the primary sr.ht format first, then falls back to fractional seconds. | |
| 25 | static let srhtFlexible: JSONDecoder.DateDecodingStrategy = .custom { decoder in | |
| 26 | let container = try decoder.singleValueContainer() | |
| 27 | let string = try container.decode(String.self) | |
| 28 | if let date = DateFormatter.srht.date(from: string) { | |
| 29 | return date | |
| 30 | } | |
| 31 | if let date = DateFormatter.srhtFractional.date(from: string) { | |
| 32 | return date | |
| 33 | } | |
| 34 | throw DecodingError.dataCorruptedError( | |
| 35 | in: container, | |
| 36 | debugDescription: "Cannot decode date string: \(string)" | |
| 37 | ) | |
| 38 | } | |
| 39 | } | |
Hutch/Extensions/ErrorViews.swift added +159
| @@ -0,0 +1,159 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | // MARK: - SRHTErrorBanner ViewModifier | |
| 4 | ||
| 5 | /// Displays a dismissible error banner at the top of the screen. | |
| 6 | /// Attach to any view with `.srhtErrorBanner(error:)`. | |
| 7 | struct SRHTErrorBanner: ViewModifier { | |
| 8 | @Binding var error: String? | |
| 9 | ||
| 10 | func body(content: Content) -> some View { | |
| 11 | content | |
| 12 | .overlay(alignment: .top) { | |
| 13 | if let message = error { | |
| 14 | banner(message) | |
| 15 | .transition(.move(edge: .top).combined(with: .opacity)) | |
| 16 | } | |
| 17 | } | |
| 18 | .animation(.easeInOut(duration: 0.3), value: error) | |
| 19 | } | |
| 20 | ||
| 21 | @ViewBuilder | |
| 22 | private func banner(_ message: String) -> some View { | |
| 23 | HStack(spacing: 8) { | |
| 24 | Image(systemName: "exclamationmark.triangle.fill") | |
| 25 | .foregroundStyle(.white) | |
| 26 | ||
| 27 | Text(message) | |
| 28 | .font(.subheadline) | |
| 29 | .foregroundStyle(.white) | |
| 30 | .lineLimit(3) | |
| 31 | ||
| 32 | Spacer() | |
| 33 | ||
| 34 | Button { | |
| 35 | error = nil | |
| 36 | } label: { | |
| 37 | Image(systemName: "xmark.circle.fill") | |
| 38 | .foregroundStyle(.white.opacity(0.8)) | |
| 39 | } | |
| 40 | } | |
| 41 | .padding(12) | |
| 42 | .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12)) | |
| 43 | .padding(.horizontal, 12) | |
| 44 | .padding(.top, 4) | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | extension View { | |
| 49 | /// Attach an error banner that shows at the top when `error` is non-nil. | |
| 50 | func srhtErrorBanner(error: Binding<String?>) -> some View { | |
| 51 | modifier(SRHTErrorBanner(error: error)) | |
| 52 | } | |
| 53 | } | |
| 54 | ||
| 55 | // MARK: - Shared Screen States | |
| 56 | ||
| 57 | struct SRHTLoadingStateView: View { | |
| 58 | let message: String | |
| 59 | ||
| 60 | var body: some View { | |
| 61 | VStack(spacing: 12) { | |
| 62 | ProgressView() | |
| 63 | Text(message) | |
| 64 | .font(.subheadline) | |
| 65 | .foregroundStyle(.secondary) | |
| 66 | } | |
| 67 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | struct SRHTErrorStateView: View { | |
| 72 | let title: String | |
| 73 | let message: String | |
| 74 | let retryAction: (() async -> Void)? | |
| 75 | ||
| 76 | var body: some View { | |
| 77 | ContentUnavailableView { | |
| 78 | SwiftUI.Label(title, systemImage: "exclamationmark.triangle") | |
| 79 | } description: { | |
| 80 | Text(message) | |
| 81 | } actions: { | |
| 82 | if let retryAction { | |
| 83 | Button("Retry") { | |
| 84 | Task { await retryAction() } | |
| 85 | } | |
| 86 | .buttonStyle(.borderedProminent) | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | ||
| 92 | // MARK: - NoConnectionView | |
| 93 | ||
| 94 | /// Empty state view shown when the device is offline. Includes a retry button. | |
| 95 | struct NoConnectionView: View { | |
| 96 | var retryAction: () async -> Void | |
| 97 | ||
| 98 | var body: some View { | |
| 99 | ContentUnavailableView { | |
| 100 | SwiftUI.Label("No Connection", systemImage: "wifi.slash") | |
| 101 | } description: { | |
| 102 | Text("Check your internet connection and try again.") | |
| 103 | } actions: { | |
| 104 | Button { | |
| 105 | Task { await retryAction() } | |
| 106 | } label: { | |
| 107 | Text("Retry") | |
| 108 | } | |
| 109 | .buttonStyle(.borderedProminent) | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | // MARK: - Connectivity Overlay | |
| 115 | ||
| 116 | /// ViewModifier that shows NoConnectionView when the device is offline and | |
| 117 | /// there is no content to display. When content exists, shows a subtle | |
| 118 | /// offline indicator instead. | |
| 119 | struct ConnectivityOverlay: ViewModifier { | |
| 120 | @Environment(NetworkMonitor.self) private var networkMonitor | |
| 121 | let hasContent: Bool | |
| 122 | var retryAction: () async -> Void | |
| 123 | ||
| 124 | func body(content: Content) -> some View { | |
| 125 | content | |
| 126 | .overlay { | |
| 127 | if !networkMonitor.isConnected, !hasContent { | |
| 128 | NoConnectionView(retryAction: retryAction) | |
| 129 | } | |
| 130 | } | |
| 131 | .safeAreaInset(edge: .bottom) { | |
| 132 | if !networkMonitor.isConnected, hasContent { | |
| 133 | offlineBadge | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | private var offlineBadge: some View { | |
| 139 | HStack(spacing: 6) { | |
| 140 | Image(systemName: "wifi.slash") | |
| 141 | .font(.caption2) | |
| 142 | Text("Offline — showing cached data") | |
| 143 | .font(.caption2) | |
| 144 | } | |
| 145 | .foregroundStyle(.white) | |
| 146 | .padding(.horizontal, 12) | |
| 147 | .padding(.vertical, 6) | |
| 148 | .background(.orange.gradient, in: Capsule()) | |
| 149 | .padding(.bottom, 4) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | extension View { | |
| 154 | /// Overlay a no-connection view when offline with no content, or a | |
| 155 | /// subtle offline badge when showing cached data. | |
| 156 | func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View { | |
| 157 | modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction)) | |
| 158 | } | |
| 159 | } | |
Hutch/Extensions/Int+ByteFormatting.swift added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension Int { | |
| 4 | /// Human-readable byte size string (e.g. "1.2 MB", "340 KB"). | |
| 5 | var formattedByteCount: String { | |
| 6 | ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file) | |
| 7 | } | |
| 8 | } | |
Hutch/Extensions/KeychainHelper.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | @preconcurrency import Security | |
| 3 | ||
| 4 | enum KeychainHelper: Sendable { | |
| 5 | ||
| 6 | private static let service = "net.cleberg.Hutch" | |
| 7 | private static let tokenAccount = "srht-access-token" | |
| 8 | ||
| 9 | // MARK: - Save | |
| 10 | ||
| 11 | static func saveToken(_ token: String) throws { | |
| 12 | guard let data = token.data(using: .utf8) else { | |
| 13 | throw KeychainError.encodingFailed | |
| 14 | } | |
| 15 | ||
| 16 | // Delete any existing item first | |
| 17 | let deleteQuery: [String: Any] = [ | |
| 18 | kSecClass as String: kSecClassGenericPassword, | |
| 19 | kSecAttrService as String: service, | |
| 20 | kSecAttrAccount as String: tokenAccount | |
| 21 | ] | |
| 22 | SecItemDelete(deleteQuery as CFDictionary) | |
| 23 | ||
| 24 | let addQuery: [String: Any] = [ | |
| 25 | kSecClass as String: kSecClassGenericPassword, | |
| 26 | kSecAttrService as String: service, | |
| 27 | kSecAttrAccount as String: tokenAccount, | |
| 28 | kSecValueData as String: data, | |
| 29 | kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly | |
| 30 | ] | |
| 31 | ||
| 32 | let status = SecItemAdd(addQuery as CFDictionary, nil) | |
| 33 | guard status == errSecSuccess else { | |
| 34 | throw KeychainError.saveFailed(status) | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | // MARK: - Load | |
| 39 | ||
| 40 | static func loadToken() -> String? { | |
| 41 | let query: [String: Any] = [ | |
| 42 | kSecClass as String: kSecClassGenericPassword, | |
| 43 | kSecAttrService as String: service, | |
| 44 | kSecAttrAccount as String: tokenAccount, | |
| 45 | kSecReturnData as String: true, | |
| 46 | kSecMatchLimit as String: kSecMatchLimitOne | |
| 47 | ] | |
| 48 | ||
| 49 | var result: AnyObject? | |
| 50 | let status = SecItemCopyMatching(query as CFDictionary, &result) | |
| 51 | ||
| 52 | guard status == errSecSuccess, | |
| 53 | let data = result as? Data, | |
| 54 | let token = String(data: data, encoding: .utf8) else { | |
| 55 | return nil | |
| 56 | } | |
| 57 | return token | |
| 58 | } | |
| 59 | ||
| 60 | // MARK: - Delete | |
| 61 | ||
| 62 | static func deleteToken() throws { | |
| 63 | let query: [String: Any] = [ | |
| 64 | kSecClass as String: kSecClassGenericPassword, | |
| 65 | kSecAttrService as String: service, | |
| 66 | kSecAttrAccount as String: tokenAccount | |
| 67 | ] | |
| 68 | ||
| 69 | let status = SecItemDelete(query as CFDictionary) | |
| 70 | guard status == errSecSuccess || status == errSecItemNotFound else { | |
| 71 | throw KeychainError.deleteFailed(status) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | static func deleteAll() throws { | |
| 76 | let query: [String: Any] = [ | |
| 77 | kSecClass as String: kSecClassGenericPassword, | |
| 78 | kSecAttrService as String: service | |
| 79 | ] | |
| 80 | ||
| 81 | let status = SecItemDelete(query as CFDictionary) | |
| 82 | guard status == errSecSuccess || status == errSecItemNotFound else { | |
| 83 | throw KeychainError.deleteFailed(status) | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | enum KeychainError: LocalizedError { | |
| 89 | case encodingFailed | |
| 90 | case saveFailed(OSStatus) | |
| 91 | case deleteFailed(OSStatus) | |
| 92 | ||
| 93 | var errorDescription: String? { | |
| 94 | switch self { | |
| 95 | case .encodingFailed: | |
| 96 | "Failed to encode token data." | |
| 97 | case .saveFailed(let status): | |
| 98 | "Keychain save failed with status \(status)." | |
| 99 | case .deleteFailed(let status): | |
| 100 | "Keychain delete failed with status \(status)." | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
Hutch/Extensions/SRHTWebURL.swift +49 −1
| @@ -9,6 +9,41 @@ enum SRHTWebURL { | ||
| 9 | 9 | ) |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | static func commit(repository: RepositorySummary, commitId: String) -> URL? { | |
| 13 | userScopedURL( | |
| 14 | host: "\(repository.service.rawValue).sr.ht", | |
| 15 | ownerCanonicalName: repository.owner.canonicalName, | |
| 16 | pathComponents: [repository.name, commitPathComponent(for: repository.service), commitId] | |
| 17 | ) | |
| 18 | } | |
| 19 | ||
| 20 | static func file(repository: RepositorySummary, revspec: String, path: String) -> URL? { | |
| 21 | switch repository.service { | |
| 22 | case .git: | |
| 23 | return userScopedURL( | |
| 24 | host: "git.sr.ht", | |
| 25 | ownerCanonicalName: repository.owner.canonicalName, | |
| 26 | pathComponents: [repository.name, "tree", revspec, "item"] + pathComponents(from: path) | |
| 27 | ) | |
| 28 | case .hg: | |
| 29 | var components = URLComponents() | |
| 30 | components.scheme = "https" | |
| 31 | components.host = "hg.sr.ht" | |
| 32 | ||
| 33 | let ownerUsername = username(from: repository.owner.canonicalName) | |
| 34 | let encodedRepository = repository.name.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? repository.name | |
| 35 | let encodedPath = path.split(separator: "/").map { | |
| 36 | String($0).addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? String($0) | |
| 37 | }.joined(separator: "/") | |
| 38 | ||
| 39 | components.percentEncodedPath = "/~\(ownerUsername)/\(encodedRepository)/browse/\(encodedPath)" | |
| 40 | components.queryItems = [URLQueryItem(name: "rev", value: revspec)] | |
| 41 | return components.url | |
| 42 | default: | |
| 43 | return nil | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 12 | 47 | static func build(jobId: Int, ownerCanonicalName: String) -> URL? { |
| 13 | 48 | userScopedURL( |
| 14 | 49 | host: "builds.sr.ht", |
| @@ -35,7 +70,7 @@ enum SRHTWebURL { | ||
| 35 | 70 | |
| 36 | 71 | static func profile(canonicalName: String) -> URL? { |
| 37 | 72 | userScopedURL( |
| 38 | host: "meta.sr.ht", | |
| 73 | host: "sr.ht", | |
| 39 | 74 | ownerCanonicalName: canonicalName, |
| 40 | 75 | pathComponents: [] |
| 41 | 76 | ) |
| @@ -76,9 +111,22 @@ enum SRHTWebURL { | ||
| 76 | 111 | return canonicalName |
| 77 | 112 | } |
| 78 | 113 | |
| 114 | private static func commitPathComponent(for service: SRHTService) -> String { | |
| 115 | switch service { | |
| 116 | case .hg: | |
| 117 | return "rev" | |
| 118 | default: | |
| 119 | return "commit" | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 79 | 123 | private static let pathComponentCharacterSet: CharacterSet = { |
| 80 | 124 | var characterSet = CharacterSet.urlPathAllowed |
| 81 | 125 | characterSet.remove(charactersIn: "/") |
| 82 | 126 | return characterSet |
| 83 | 127 | }() |
| 128 | ||
| 129 | private static func pathComponents(from path: String) -> [String] { | |
| 130 | path.split(separator: "/").map(String.init) | |
| 131 | } | |
| 84 | 132 | } |
Hutch/Info.plist added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | <plist version="1.0"> | |
| 4 | <dict> | |
| 5 | <key>CFBundleDisplayName</key> | |
| 6 | <string>Hutch</string> | |
| 7 | <key>CFBundleName</key> | |
| 8 | <string>Hutch</string> | |
| 9 | <key>CFBundleURLTypes</key> | |
| 10 | <array> | |
| 11 | <dict> | |
| 12 | <key>CFBundleTypeRole</key> | |
| 13 | <string>Editor</string> | |
| 14 | <key>CFBundleURLName</key> | |
| 15 | <string>.cleberg.Hutch</string> | |
| 16 | <key>CFBundleURLSchemes</key> | |
| 17 | <array> | |
| 18 | <string>hutch</string> | |
| 19 | </array> | |
| 20 | </dict> | |
| 21 | </array> | |
| 22 | </dict> | |
| 23 | </plist> | |
Hutch/Models/ArtifactInfo.swift added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// An artifact with its parent reference name, used in the artifacts tab. | |
| 4 | struct ArtifactInfo: Codable, Sendable, Identifiable { | |
| 5 | let id: Int | |
| 6 | let filename: String | |
| 7 | let checksum: String | |
| 8 | let size: Int | |
| 9 | let url: URL | |
| 10 | } | |
| 11 | ||
| 12 | struct ArtifactPage: Codable, Sendable { | |
| 13 | let results: [ArtifactInfo] | |
| 14 | let cursor: String? | |
| 15 | } | |
| 16 | ||
| 17 | /// A reference (tag) that has associated artifacts. | |
| 18 | struct ReferenceWithArtifacts: Codable, Sendable, Identifiable { | |
| 19 | var id: String { name } | |
| 20 | let name: String | |
| 21 | let artifacts: [ArtifactInfo] | |
| 22 | } | |
Hutch/Models/Builds.swift added +111
| @@ -0,0 +1,111 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Enums | |
| 4 | ||
| 5 | /// Status of a build job. | |
| 6 | enum JobStatus: String, Codable, Sendable { | |
| 7 | case pending = "PENDING" | |
| 8 | case queued = "QUEUED" | |
| 9 | case running = "RUNNING" | |
| 10 | case success = "SUCCESS" | |
| 11 | case failed = "FAILED" | |
| 12 | case cancelled = "CANCELLED" | |
| 13 | case timeout = "TIMEOUT" | |
| 14 | ||
| 15 | /// Whether the job can be cancelled. | |
| 16 | var isCancellable: Bool { | |
| 17 | switch self { | |
| 18 | case .pending, .queued, .running: true | |
| 19 | default: false | |
| 20 | } | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | /// Status of a single build task within a job. | |
| 25 | enum TaskStatus: String, Codable, Sendable { | |
| 26 | case pending = "PENDING" | |
| 27 | case running = "RUNNING" | |
| 28 | case success = "SUCCESS" | |
| 29 | case failed = "FAILED" | |
| 30 | case skipped = "SKIPPED" | |
| 31 | } | |
| 32 | ||
| 33 | // MARK: - Build Task | |
| 34 | ||
| 35 | /// A single task within a build job. | |
| 36 | struct BuildTask: Codable, Sendable, Identifiable { | |
| 37 | var id: String { name } | |
| 38 | let name: String | |
| 39 | let status: TaskStatus | |
| 40 | let log: BuildLog? | |
| 41 | } | |
| 42 | ||
| 43 | // MARK: - Job Summary (for list view) | |
| 44 | ||
| 45 | /// Lightweight job model matching the fields returned by the jobs list query. | |
| 46 | struct JobSummary: Codable, Sendable, Identifiable, Hashable { | |
| 47 | let id: Int | |
| 48 | let created: Date | |
| 49 | let updated: Date | |
| 50 | let status: JobStatus | |
| 51 | let note: String? | |
| 52 | let tags: [String] | |
| 53 | let visibility: Visibility? | |
| 54 | let image: String? | |
| 55 | let tasks: [JobTaskSummary] | |
| 56 | ||
| 57 | /// Number of completed (success) tasks. | |
| 58 | var completedTaskCount: Int { | |
| 59 | tasks.filter { $0.status == .success }.count | |
| 60 | } | |
| 61 | ||
| 62 | /// Display label: note if available, otherwise tags joined. | |
| 63 | var displayLabel: String { | |
| 64 | if let note, !note.isEmpty { | |
| 65 | return note | |
| 66 | } | |
| 67 | if !tags.isEmpty { | |
| 68 | return tags.joined(separator: ", ") | |
| 69 | } | |
| 70 | return "Job #\(id)" | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | /// Minimal task info for the list query. | |
| 75 | struct JobTaskSummary: Codable, Sendable, Hashable { | |
| 76 | let name: String | |
| 77 | let status: TaskStatus | |
| 78 | } | |
| 79 | ||
| 80 | // MARK: - Job Detail (for detail view) | |
| 81 | ||
| 82 | /// Full job model with all fields for the detail view. | |
| 83 | struct JobDetail: Codable, Sendable { | |
| 84 | let id: Int | |
| 85 | let created: Date | |
| 86 | let updated: Date | |
| 87 | let status: JobStatus | |
| 88 | let note: String? | |
| 89 | let tags: [String] | |
| 90 | let visibility: Visibility? | |
| 91 | let image: String? | |
| 92 | let manifest: String? | |
| 93 | let tasks: [BuildTask] | |
| 94 | let log: BuildLog? | |
| 95 | let owner: Entity | |
| 96 | } | |
| 97 | ||
| 98 | /// The log associated with a build job. | |
| 99 | struct BuildLog: Codable, Sendable { | |
| 100 | let fullURL: String | |
| 101 | } | |
| 102 | ||
| 103 | // MARK: - Job Group | |
| 104 | ||
| 105 | /// A group of related build jobs. | |
| 106 | struct JobGroup: Codable, Sendable, Identifiable { | |
| 107 | let id: Int | |
| 108 | let created: Date | |
| 109 | let note: String? | |
| 110 | let jobs: [JobSummary] | |
| 111 | } | |
Hutch/Models/CommitDetail.swift added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Full commit detail returned by the revparse_single query. | |
| 4 | struct CommitDetail: Codable, Sendable, Identifiable { | |
| 5 | let id: String | |
| 6 | let shortId: String | |
| 7 | let author: CommitAuthor | |
| 8 | let committer: CommitAuthor | |
| 9 | let message: String | |
| 10 | let diff: String? | |
| 11 | let trailers: [CommitTrailer] | |
| 12 | let parents: [ParentCommit] | |
| 13 | let tree: CommitTree? | |
| 14 | ||
| 15 | /// First line of the commit message. | |
| 16 | var title: String { | |
| 17 | message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces) | |
| 18 | } | |
| 19 | ||
| 20 | /// The commit message body (everything after the first line), if any. | |
| 21 | var body: String? { | |
| 22 | guard let newlineIndex = message.firstIndex(of: "\n") else { return nil } | |
| 23 | let body = message[message.index(after: newlineIndex)...] | |
| 24 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 25 | return body.isEmpty ? nil : body | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | struct CommitTrailer: Codable, Sendable, Identifiable { | |
| 30 | var id: String { "\(name):\(value)" } | |
| 31 | let name: String | |
| 32 | let value: String | |
| 33 | } | |
| 34 | ||
| 35 | struct ParentCommit: Codable, Sendable, Identifiable, Hashable { | |
| 36 | let id: String | |
| 37 | let shortId: String | |
| 38 | let author: ParentAuthor | |
| 39 | } | |
| 40 | ||
| 41 | struct ParentAuthor: Codable, Sendable, Hashable { | |
| 42 | let name: String | |
| 43 | } | |
| 44 | ||
| 45 | struct CommitTree: Codable, Sendable { | |
| 46 | let entries: CommitTreeEntries | |
| 47 | } | |
| 48 | ||
| 49 | struct CommitTreeEntries: Codable, Sendable { | |
| 50 | let results: [CommitTreeEntry] | |
| 51 | let cursor: String? | |
| 52 | } | |
| 53 | ||
| 54 | struct CommitTreeEntry: Codable, Sendable, Identifiable { | |
| 55 | let id: String | |
| 56 | let name: String | |
| 57 | let mode: Int | |
| 58 | let object: CommitTreeObject? | |
| 59 | } | |
| 60 | ||
| 61 | struct CommitTreeObject: Codable, Sendable { | |
| 62 | let type: String? | |
| 63 | let id: String? | |
| 64 | let shortId: String? | |
| 65 | } | |
Hutch/Models/CommitSummary.swift added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Lightweight commit model for list views. Matches the subset of fields | |
| 4 | /// returned by the repository log query. | |
| 5 | struct CommitSummary: Codable, Sendable, Identifiable, Hashable { | |
| 6 | let id: String | |
| 7 | let shortId: String | |
| 8 | let author: CommitAuthor | |
| 9 | let message: String | |
| 10 | ||
| 11 | /// First line of the commit message. | |
| 12 | var title: String { | |
| 13 | message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces) | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | /// A compact author representation used in commit list responses. | |
| 18 | struct CommitAuthor: Codable, Sendable, Hashable { | |
| 19 | let name: String | |
| 20 | let email: String? | |
| 21 | let time: Date | |
| 22 | } | |
Hutch/Models/Git.swift added +241
| @@ -0,0 +1,241 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Enums | |
| 4 | ||
| 5 | /// Repository visibility level. | |
| 6 | enum Visibility: String, Codable, Sendable { | |
| 7 | case `public` = "PUBLIC" | |
| 8 | case unlisted = "UNLISTED" | |
| 9 | case `private` = "PRIVATE" | |
| 10 | } | |
| 11 | ||
| 12 | /// Repository access mode. | |
| 13 | enum AccessMode: String, Codable, Sendable { | |
| 14 | case ro = "RO" | |
| 15 | case rw = "RW" | |
| 16 | } | |
| 17 | ||
| 18 | // MARK: - Entity | |
| 19 | ||
| 20 | /// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a | |
| 21 | /// resource (typically a user). | |
| 22 | struct Entity: Codable, Sendable, Hashable { | |
| 23 | let canonicalName: String | |
| 24 | } | |
| 25 | ||
| 26 | // MARK: - Repository | |
| 27 | ||
| 28 | /// A git repository from git.sr.ht. | |
| 29 | struct Repository: Codable, Sendable, Identifiable { | |
| 30 | let id: Int | |
| 31 | let created: Date | |
| 32 | let updated: Date | |
| 33 | let name: String | |
| 34 | let description: String? | |
| 35 | let visibility: Visibility | |
| 36 | let readme: String? | |
| 37 | let accessMode: AccessMode | |
| 38 | let owner: Entity | |
| 39 | ||
| 40 | enum CodingKeys: String, CodingKey { | |
| 41 | case id, created, updated, name, description, visibility, readme | |
| 42 | case accessMode = "access" | |
| 43 | case owner | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | // MARK: - Signature | |
| 48 | ||
| 49 | /// A Git commit/tag signature (author or committer). | |
| 50 | struct Signature: Codable, Sendable { | |
| 51 | let name: String | |
| 52 | let email: String | |
| 53 | let time: Date | |
| 54 | } | |
| 55 | ||
| 56 | // MARK: - Trailer | |
| 57 | ||
| 58 | /// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by"). | |
| 59 | struct Trailer: Codable, Sendable { | |
| 60 | let name: String | |
| 61 | let value: String | |
| 62 | } | |
| 63 | ||
| 64 | // MARK: - Commit | |
| 65 | ||
| 66 | /// A Git commit from git.sr.ht. | |
| 67 | struct Commit: Codable, Sendable, Identifiable { | |
| 68 | let id: String | |
| 69 | let shortId: String | |
| 70 | let author: Signature | |
| 71 | let committer: Signature | |
| 72 | let message: String | |
| 73 | let diff: String? | |
| 74 | let trailers: [Trailer] | |
| 75 | } | |
| 76 | ||
| 77 | // MARK: - Reference | |
| 78 | ||
| 79 | /// A Git reference (branch or tag name). | |
| 80 | struct Reference: Codable, Sendable, Hashable { | |
| 81 | let name: String | |
| 82 | let target: String? | |
| 83 | } | |
| 84 | ||
| 85 | // MARK: - TreeEntry | |
| 86 | ||
| 87 | /// An entry in a Git tree (file or directory). | |
| 88 | struct TreeEntry: Codable, Sendable, Identifiable { | |
| 89 | let id: String | |
| 90 | let name: String | |
| 91 | let mode: Int? | |
| 92 | let object: GitObject? | |
| 93 | } | |
| 94 | ||
| 95 | /// A Git object returned by the git.sr.ht GraphQL API. | |
| 96 | /// The API returns `type` as "TREE", "BLOB", "COMMIT", or "TAG". | |
| 97 | /// Both TextBlob and BinaryBlob have type == "BLOB"; they are | |
| 98 | /// distinguished by the presence of the "text" key (TextBlob) vs | |
| 99 | /// the "content" key (BinaryBlob). | |
| 100 | enum GitObject: Sendable { | |
| 101 | case tree(GitTree) | |
| 102 | case textBlob(GitTextBlob) | |
| 103 | case binaryBlob(GitBinaryBlob) | |
| 104 | case unknown | |
| 105 | } | |
| 106 | ||
| 107 | struct GitTree: Codable, Sendable { | |
| 108 | let id: String? | |
| 109 | let shortId: String? | |
| 110 | let entries: GitTreeEntryPage? | |
| 111 | } | |
| 112 | ||
| 113 | struct GitTextBlob: Codable, Sendable { | |
| 114 | let id: String? | |
| 115 | let shortId: String? | |
| 116 | let text: String | |
| 117 | let size: Int? | |
| 118 | } | |
| 119 | ||
| 120 | struct GitBinaryBlob: Codable, Sendable { | |
| 121 | let id: String? | |
| 122 | let shortId: String? | |
| 123 | let size: Int? | |
| 124 | let content: String? | |
| 125 | } | |
| 126 | ||
| 127 | struct GitTreeEntryPage: Codable, Sendable { | |
| 128 | let results: [TreeEntry] | |
| 129 | let cursor: String? | |
| 130 | } | |
| 131 | ||
| 132 | // MARK: - GitObject Codable | |
| 133 | ||
| 134 | extension GitObject: Codable { | |
| 135 | private enum CodingKeys: String, CodingKey { | |
| 136 | case type, id, shortId, entries, text, size, content | |
| 137 | } | |
| 138 | ||
| 139 | init(from decoder: any Decoder) throws { | |
| 140 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 141 | let type = try container.decodeIfPresent(String.self, forKey: .type) | |
| 142 | ||
| 143 | switch type { | |
| 144 | case "TREE": | |
| 145 | let tree = GitTree( | |
| 146 | id: try container.decodeIfPresent(String.self, forKey: .id), | |
| 147 | shortId: try container.decodeIfPresent(String.self, forKey: .shortId), | |
| 148 | entries: try container.decodeIfPresent(GitTreeEntryPage.self, forKey: .entries) | |
| 149 | ) | |
| 150 | self = .tree(tree) | |
| 151 | ||
| 152 | case "BLOB": | |
| 153 | // TextBlob has a "text" key; BinaryBlob does not | |
| 154 | if container.contains(.text) { | |
| 155 | let blob = GitTextBlob( | |
| 156 | id: try container.decodeIfPresent(String.self, forKey: .id), | |
| 157 | shortId: try container.decodeIfPresent(String.self, forKey: .shortId), | |
| 158 | text: try container.decode(String.self, forKey: .text), | |
| 159 | size: try container.decodeIfPresent(Int.self, forKey: .size) | |
| 160 | ) | |
| 161 | self = .textBlob(blob) | |
| 162 | } else { | |
| 163 | let blob = GitBinaryBlob( | |
| 164 | id: try container.decodeIfPresent(String.self, forKey: .id), | |
| 165 | shortId: try container.decodeIfPresent(String.self, forKey: .shortId), | |
| 166 | size: try container.decodeIfPresent(Int.self, forKey: .size), | |
| 167 | content: try container.decodeIfPresent(String.self, forKey: .content) | |
| 168 | ) | |
| 169 | self = .binaryBlob(blob) | |
| 170 | } | |
| 171 | ||
| 172 | default: | |
| 173 | self = .unknown | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | func encode(to encoder: any Encoder) throws { | |
| 178 | var container = encoder.container(keyedBy: CodingKeys.self) | |
| 179 | switch self { | |
| 180 | case .tree(let tree): | |
| 181 | try container.encode("TREE", forKey: .type) | |
| 182 | try container.encodeIfPresent(tree.id, forKey: .id) | |
| 183 | try container.encodeIfPresent(tree.shortId, forKey: .shortId) | |
| 184 | try container.encodeIfPresent(tree.entries, forKey: .entries) | |
| 185 | case .textBlob(let blob): | |
| 186 | try container.encode("BLOB", forKey: .type) | |
| 187 | try container.encodeIfPresent(blob.id, forKey: .id) | |
| 188 | try container.encodeIfPresent(blob.shortId, forKey: .shortId) | |
| 189 | try container.encode(blob.text, forKey: .text) | |
| 190 | try container.encodeIfPresent(blob.size, forKey: .size) | |
| 191 | case .binaryBlob(let blob): | |
| 192 | try container.encode("BLOB", forKey: .type) | |
| 193 | try container.encodeIfPresent(blob.id, forKey: .id) | |
| 194 | try container.encodeIfPresent(blob.shortId, forKey: .shortId) | |
| 195 | try container.encodeIfPresent(blob.size, forKey: .size) | |
| 196 | try container.encodeIfPresent(blob.content, forKey: .content) | |
| 197 | case .unknown: | |
| 198 | break | |
| 199 | } | |
| 200 | } | |
| 201 | } | |
| 202 | ||
| 203 | /// Convenience helpers for checking object type. | |
| 204 | extension GitObject { | |
| 205 | var isTree: Bool { | |
| 206 | if case .tree = self { return true } | |
| 207 | return false | |
| 208 | } | |
| 209 | ||
| 210 | var treeId: String? { | |
| 211 | switch self { | |
| 212 | case .tree(let t): t.id | |
| 213 | case .textBlob(let b): b.id | |
| 214 | case .binaryBlob(let b): b.id | |
| 215 | case .unknown: nil | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | ||
| 220 | // MARK: - Tag | |
| 221 | ||
| 222 | /// An annotated Git tag from git.sr.ht. | |
| 223 | struct Tag: Codable, Sendable, Identifiable { | |
| 224 | let id: String | |
| 225 | let shortId: String | |
| 226 | let name: String | |
| 227 | let message: String? | |
| 228 | let tagger: Signature? | |
| 229 | } | |
| 230 | ||
| 231 | // MARK: - Artifact | |
| 232 | ||
| 233 | /// A release artifact attached to a Git tag. | |
| 234 | struct Artifact: Codable, Sendable, Identifiable { | |
| 235 | let id: Int | |
| 236 | let created: Date | |
| 237 | let filename: String | |
| 238 | let checksum: String | |
| 239 | let size: Int | |
| 240 | let url: URL | |
| 241 | } | |
Hutch/Models/Meta.swift added +65
| @@ -0,0 +1,65 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - User Profile (full) | |
| 4 | ||
| 5 | /// Extended user profile from meta.sr.ht with all fields from the `me` query. | |
| 6 | struct UserProfile: Codable, Sendable { | |
| 7 | let username: String | |
| 8 | let canonicalName: String | |
| 9 | let email: String | |
| 10 | let url: String? | |
| 11 | let location: String? | |
| 12 | let bio: String? | |
| 13 | let avatar: String? | |
| 14 | let userType: String? | |
| 15 | let sshKeys: SSHKeyPage | |
| 16 | let pgpKeys: PGPKeyPage | |
| 17 | let paymentStatus: String? | |
| 18 | let subscription: Subscription? | |
| 19 | } | |
| 20 | ||
| 21 | // MARK: - SSH Key | |
| 22 | ||
| 23 | struct SSHKey: Codable, Sendable, Identifiable { | |
| 24 | let id: Int | |
| 25 | let fingerprint: String | |
| 26 | let comment: String? | |
| 27 | let created: Date | |
| 28 | let lastUsed: Date? | |
| 29 | } | |
| 30 | ||
| 31 | struct SSHKeyPage: Codable, Sendable { | |
| 32 | let results: [SSHKey] | |
| 33 | let cursor: String? | |
| 34 | } | |
| 35 | ||
| 36 | // MARK: - PGP Key | |
| 37 | ||
| 38 | struct PGPKey: Codable, Sendable, Identifiable { | |
| 39 | let id: Int | |
| 40 | let fingerprint: String | |
| 41 | let created: Date | |
| 42 | } | |
| 43 | ||
| 44 | struct PGPKeyPage: Codable, Sendable { | |
| 45 | let results: [PGPKey] | |
| 46 | let cursor: String? | |
| 47 | } | |
| 48 | ||
| 49 | // MARK: - Subscription | |
| 50 | ||
| 51 | struct Subscription: Codable, Sendable { | |
| 52 | let status: String? | |
| 53 | let autorenew: Bool? | |
| 54 | let interval: String? | |
| 55 | } | |
| 56 | ||
| 57 | // MARK: - Personal Access Token | |
| 58 | ||
| 59 | struct PersonalAccessToken: Codable, Sendable, Identifiable { | |
| 60 | let id: Int | |
| 61 | let issued: Date | |
| 62 | let expires: Date? | |
| 63 | let comment: String? | |
| 64 | let grants: String? | |
| 65 | } | |
Hutch/Models/RepositorySummary.swift added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Lightweight repository model matching the fields returned by the | |
| 4 | /// repositories list query. Avoids optionalizing all fields on the full | |
| 5 | /// `Repository` model. | |
| 6 | struct RepositorySummary: Codable, Sendable, Identifiable, Hashable { | |
| 7 | let id: Int | |
| 8 | /// GraphQL resource identifier used by `repository(rid:)` queries. | |
| 9 | let rid: String | |
| 10 | let service: SRHTService | |
| 11 | let name: String | |
| 12 | let description: String? | |
| 13 | let visibility: Visibility | |
| 14 | let updated: Date | |
| 15 | let owner: Entity | |
| 16 | let head: Reference? | |
| 17 | ||
| 18 | enum CodingKeys: String, CodingKey { | |
| 19 | case id, rid, service, name, description, visibility, updated, owner | |
| 20 | case head = "HEAD" | |
| 21 | } | |
| 22 | ||
| 23 | init( | |
| 24 | id: Int, | |
| 25 | rid: String, | |
| 26 | service: SRHTService, | |
| 27 | name: String, | |
| 28 | description: String?, | |
| 29 | visibility: Visibility, | |
| 30 | updated: Date, | |
| 31 | owner: Entity, | |
| 32 | head: Reference? | |
| 33 | ) { | |
| 34 | self.id = id | |
| 35 | self.rid = rid | |
| 36 | self.service = service | |
| 37 | self.name = name | |
| 38 | self.description = description | |
| 39 | self.visibility = visibility | |
| 40 | self.updated = updated | |
| 41 | self.owner = owner | |
| 42 | self.head = head | |
| 43 | } | |
| 44 | ||
| 45 | init(from decoder: any Decoder) throws { | |
| 46 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 47 | self.id = try container.decode(Int.self, forKey: .id) | |
| 48 | self.rid = try container.decode(String.self, forKey: .rid) | |
| 49 | self.service = try container.decodeIfPresent(SRHTService.self, forKey: .service) ?? .git | |
| 50 | self.name = try container.decode(String.self, forKey: .name) | |
| 51 | self.description = try container.decodeIfPresent(String.self, forKey: .description) | |
| 52 | self.visibility = try container.decode(Visibility.self, forKey: .visibility) | |
| 53 | self.updated = try container.decode(Date.self, forKey: .updated) | |
| 54 | self.owner = try container.decode(Entity.self, forKey: .owner) | |
| 55 | self.head = try container.decodeIfPresent(Reference.self, forKey: .head) | |
| 56 | } | |
| 57 | } | |
Hutch/Models/Todo.swift added +234
| @@ -0,0 +1,234 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Enums | |
| 4 | ||
| 5 | /// Status of a ticket. | |
| 6 | enum TicketStatus: String, Codable, Sendable, CaseIterable { | |
| 7 | case reported = "REPORTED" | |
| 8 | case confirmed = "CONFIRMED" | |
| 9 | case inProgress = "IN_PROGRESS" | |
| 10 | case pending = "PENDING" | |
| 11 | case resolved = "RESOLVED" | |
| 12 | ||
| 13 | /// Whether this status represents an open (unresolved) ticket. | |
| 14 | var isOpen: Bool { | |
| 15 | switch self { | |
| 16 | case .reported, .confirmed, .inProgress, .pending: true | |
| 17 | case .resolved: false | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | var displayName: String { | |
| 22 | switch self { | |
| 23 | case .reported: "Reported" | |
| 24 | case .confirmed: "Confirmed" | |
| 25 | case .inProgress: "In Progress" | |
| 26 | case .pending: "Pending" | |
| 27 | case .resolved: "Resolved" | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | /// Resolution of a ticket. | |
| 33 | enum TicketResolution: String, Codable, Sendable { | |
| 34 | case unresolved = "UNRESOLVED" | |
| 35 | case fixed = "FIXED" | |
| 36 | case implemented = "IMPLEMENTED" | |
| 37 | case wontFix = "WONT_FIX" | |
| 38 | case byDesign = "BY_DESIGN" | |
| 39 | case invalid = "INVALID" | |
| 40 | case duplicate = "DUPLICATE" | |
| 41 | case notOurBug = "NOT_OUR_BUG" | |
| 42 | case closed = "CLOSED" | |
| 43 | case notApplicable = "NOT_APPLICABLE" | |
| 44 | ||
| 45 | init(from decoder: Decoder) throws { | |
| 46 | let value = try decoder.singleValueContainer().decode(String.self) | |
| 47 | self = TicketResolution(rawValue: value) ?? .unresolved | |
| 48 | } | |
| 49 | ||
| 50 | var displayName: String { | |
| 51 | switch self { | |
| 52 | case .unresolved: "Unresolved" | |
| 53 | case .fixed: "Fixed" | |
| 54 | case .implemented: "Implemented" | |
| 55 | case .wontFix: "Won't Fix" | |
| 56 | case .byDesign: "By Design" | |
| 57 | case .invalid: "Invalid" | |
| 58 | case .duplicate: "Duplicate" | |
| 59 | case .notOurBug: "Not Our Bug" | |
| 60 | case .closed: "Closed" | |
| 61 | case .notApplicable: "Not Applicable" | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// Authenticity of a ticket or comment. | |
| 67 | enum Authenticity: String, Codable, Sendable { | |
| 68 | case authentic = "AUTHENTIC" | |
| 69 | case tampered = "TAMPERED" | |
| 70 | case unauthenticated = "UNAUTHENTICATED" | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - Label | |
| 74 | ||
| 75 | /// A label that can be applied to tickets. | |
| 76 | /// Named `TicketLabel` to avoid collision with `SwiftUI.Label`. | |
| 77 | struct TicketLabel: Codable, Sendable, Identifiable, Hashable { | |
| 78 | let id: Int | |
| 79 | let name: String | |
| 80 | let backgroundColor: String | |
| 81 | let foregroundColor: String | |
| 82 | } | |
| 83 | ||
| 84 | // MARK: - Tracker | |
| 85 | ||
| 86 | /// A bug tracker from todo.sr.ht. | |
| 87 | struct Tracker: Codable, Sendable, Identifiable, Hashable { | |
| 88 | let id: Int | |
| 89 | let created: Date | |
| 90 | let updated: Date | |
| 91 | let name: String | |
| 92 | let description: String? | |
| 93 | let visibility: Visibility | |
| 94 | let owner: Entity | |
| 95 | ||
| 96 | static func == (lhs: Tracker, rhs: Tracker) -> Bool { | |
| 97 | lhs.id == rhs.id | |
| 98 | } | |
| 99 | ||
| 100 | func hash(into hasher: inout Hasher) { | |
| 101 | hasher.combine(id) | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // MARK: - Tracker Summary (for list view) | |
| 106 | ||
| 107 | /// Lightweight tracker model matching the fields returned by the trackers list query. | |
| 108 | struct TrackerSummary: Codable, Sendable, Identifiable, Hashable { | |
| 109 | let id: Int | |
| 110 | /// GraphQL resource identifier used by `tracker(id:)` queries. | |
| 111 | let rid: String | |
| 112 | let name: String | |
| 113 | let description: String? | |
| 114 | let visibility: Visibility | |
| 115 | let updated: Date | |
| 116 | let owner: Entity | |
| 117 | } | |
| 118 | ||
| 119 | // MARK: - Ticket Summary (for list view) | |
| 120 | ||
| 121 | /// Lightweight ticket model for the list query. | |
| 122 | struct TicketSummary: Codable, Sendable, Identifiable, Hashable { | |
| 123 | let id: Int | |
| 124 | let title: String | |
| 125 | let status: TicketStatus | |
| 126 | let resolution: TicketResolution? | |
| 127 | let created: Date | |
| 128 | let submitter: Entity | |
| 129 | let labels: [TicketLabel] | |
| 130 | let assignees: [Entity] | |
| 131 | ||
| 132 | static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool { | |
| 133 | lhs.id == rhs.id | |
| 134 | } | |
| 135 | ||
| 136 | func hash(into hasher: inout Hasher) { | |
| 137 | hasher.combine(id) | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | // MARK: - Ticket Detail | |
| 142 | ||
| 143 | /// Full ticket model for the detail view. | |
| 144 | struct TicketDetail: Codable, Sendable { | |
| 145 | let id: Int | |
| 146 | let created: Date | |
| 147 | let updated: Date | |
| 148 | let title: String | |
| 149 | let description: String? | |
| 150 | let status: TicketStatus | |
| 151 | let resolution: TicketResolution? | |
| 152 | let authenticity: Authenticity | |
| 153 | let submitter: Entity | |
| 154 | let assignees: [Entity] | |
| 155 | let labels: [TicketLabel] | |
| 156 | } | |
| 157 | ||
| 158 | // MARK: - Event | |
| 159 | ||
| 160 | /// A timeline event on a ticket. Each event contains one or more changes. | |
| 161 | struct TicketEvent: Codable, Sendable, Identifiable { | |
| 162 | let id: Int | |
| 163 | let created: Date | |
| 164 | var changes: [EventChange] | |
| 165 | } | |
| 166 | ||
| 167 | /// A single change within an event, decoded from the polymorphic EventDetail | |
| 168 | /// interface using inline fragments. | |
| 169 | struct EventChange: Codable, Sendable, Identifiable { | |
| 170 | let id: UUID | |
| 171 | ||
| 172 | let eventType: String | |
| 173 | ||
| 174 | // Comment fields | |
| 175 | let author: Entity? | |
| 176 | var text: String? | |
| 177 | let authenticity: Authenticity? | |
| 178 | ||
| 179 | // StatusChange fields | |
| 180 | let oldStatus: TicketStatus? | |
| 181 | let newStatus: TicketStatus? | |
| 182 | ||
| 183 | // LabelUpdate fields | |
| 184 | let labeler: Entity? | |
| 185 | let label: EventLabel? | |
| 186 | ||
| 187 | // Assignment fields | |
| 188 | let assigner: Entity? | |
| 189 | let assignee: Entity? | |
| 190 | ||
| 191 | // Mention fields | |
| 192 | // TicketMention: mentioned is a Ticket with { id } | |
| 193 | // UserMention: mentioned is an Entity with { canonicalName } | |
| 194 | let mentioned: MentionTarget? | |
| 195 | ||
| 196 | // Created fields (author is shared with Comment) | |
| 197 | ||
| 198 | private enum CodingKeys: String, CodingKey { | |
| 199 | case eventType | |
| 200 | case author, text, authenticity | |
| 201 | case oldStatus, newStatus | |
| 202 | case labeler, label | |
| 203 | case assigner, assignee | |
| 204 | case mentioned | |
| 205 | } | |
| 206 | ||
| 207 | init(from decoder: any Decoder) throws { | |
| 208 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 209 | self.id = UUID() | |
| 210 | self.eventType = try container.decode(String.self, forKey: .eventType) | |
| 211 | self.author = try container.decodeIfPresent(Entity.self, forKey: .author) | |
| 212 | self.text = try container.decodeIfPresent(String.self, forKey: .text) | |
| 213 | self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity) | |
| 214 | self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus) | |
| 215 | self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus) | |
| 216 | self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler) | |
| 217 | self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label) | |
| 218 | self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner) | |
| 219 | self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee) | |
| 220 | self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned) | |
| 221 | } | |
| 222 | } | |
| 223 | ||
| 224 | /// Decoded from the `mentioned` field which may be a Ticket (with `id`) or | |
| 225 | /// an Entity (with `canonicalName`) depending on the event type. | |
| 226 | struct MentionTarget: Codable, Sendable { | |
| 227 | let id: Int? | |
| 228 | let canonicalName: String? | |
| 229 | } | |
| 230 | ||
| 231 | /// Label info as returned within a LabelUpdate event change. | |
| 232 | struct EventLabel: Codable, Sendable { | |
| 233 | let name: String | |
| 234 | } | |
Hutch/Models/User.swift added +10
| @@ -0,0 +1,10 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A Sourcehut user from meta.sr.ht. | |
| 4 | struct User: Decodable, Sendable { | |
| 5 | let id: Int | |
| 6 | let username: String | |
| 7 | let canonicalName: String | |
| 8 | let email: String | |
| 9 | let avatar: String? | |
| 10 | } | |
Hutch/Networking/GraphQLRequest.swift added +45
| @@ -0,0 +1,45 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The JSON body sent with every GraphQL request. | |
| 4 | struct GraphQLRequestBody: Encodable, Sendable { | |
| 5 | let query: String | |
| 6 | let variables: [String: AnyCodable]? | |
| 7 | } | |
| 8 | ||
| 9 | /// The top-level shape of every GraphQL response. | |
| 10 | struct GraphQLResponse<T: Decodable>: Decodable { | |
| 11 | let data: T? | |
| 12 | let errors: [GraphQLError]? | |
| 13 | } | |
| 14 | ||
| 15 | // MARK: - AnyCodable | |
| 16 | ||
| 17 | /// A type-erased `Codable` wrapper so callers can pass `[String: Any]` variables | |
| 18 | /// without losing type information at the encoding boundary. | |
| 19 | struct AnyCodable: Sendable, Encodable { | |
| 20 | let value: any Sendable | |
| 21 | ||
| 22 | init(_ value: any Sendable) { | |
| 23 | self.value = value | |
| 24 | } | |
| 25 | ||
| 26 | func encode(to encoder: any Encoder) throws { | |
| 27 | var container = encoder.singleValueContainer() | |
| 28 | switch value { | |
| 29 | case let v as String: | |
| 30 | try container.encode(v) | |
| 31 | case let v as Int: | |
| 32 | try container.encode(v) | |
| 33 | case let v as Double: | |
| 34 | try container.encode(v) | |
| 35 | case let v as Bool: | |
| 36 | try container.encode(v) | |
| 37 | case let v as [any Sendable]: | |
| 38 | try container.encode(v.map { AnyCodable($0) }) | |
| 39 | case let v as [String: any Sendable]: | |
| 40 | try container.encode(v.mapValues { AnyCodable($0) }) | |
| 41 | default: | |
| 42 | try container.encodeNil() | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
Hutch/Networking/NetworkMonitor.swift added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | import Foundation | |
| 2 | import Network | |
| 3 | ||
| 4 | /// Observes network connectivity using `NWPathMonitor`. | |
| 5 | /// Shared singleton injected into the environment. | |
| 6 | @Observable | |
| 7 | @MainActor | |
| 8 | final class NetworkMonitor { | |
| 9 | ||
| 10 | private(set) var isConnected = true | |
| 11 | private(set) var connectionType: ConnectionType = .unknown | |
| 12 | ||
| 13 | enum ConnectionType: Sendable { | |
| 14 | case wifi | |
| 15 | case cellular | |
| 16 | case wiredEthernet | |
| 17 | case unknown | |
| 18 | } | |
| 19 | ||
| 20 | private let monitor = NWPathMonitor() | |
| 21 | private let queue = DispatchQueue(label: "net.cleberg.Hutch.NetworkMonitor") | |
| 22 | ||
| 23 | init() { | |
| 24 | startMonitoring() | |
| 25 | } | |
| 26 | ||
| 27 | private func startMonitoring() { | |
| 28 | monitor.pathUpdateHandler = { [weak self] path in | |
| 29 | Task { @MainActor [weak self] in | |
| 30 | guard let self else { return } | |
| 31 | self.isConnected = path.status == .satisfied | |
| 32 | self.connectionType = self.resolveConnectionType(path) | |
| 33 | } | |
| 34 | } | |
| 35 | monitor.start(queue: queue) | |
| 36 | } | |
| 37 | ||
| 38 | private nonisolated func resolveConnectionType(_ path: NWPath) -> ConnectionType { | |
| 39 | if path.usesInterfaceType(.wifi) { return .wifi } | |
| 40 | if path.usesInterfaceType(.cellular) { return .cellular } | |
| 41 | if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet } | |
| 42 | return .unknown | |
| 43 | } | |
| 44 | ||
| 45 | deinit { | |
| 46 | monitor.cancel() | |
| 47 | } | |
| 48 | } | |
Hutch/Networking/Pagination.swift added +176
| @@ -0,0 +1,176 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// The standard paginated response shape used by all sr.ht GraphQL APIs. | |
| 4 | /// { results: [T], cursor: String? } | |
| 5 | /// A null cursor means the list is exhausted. | |
| 6 | struct CursorPage<Element: Decodable & Sendable>: Decodable, Sendable { | |
| 7 | let results: [Element] | |
| 8 | let cursor: String? | |
| 9 | } | |
| 10 | ||
| 11 | /// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL | |
| 12 | /// query. Each element yielded is a single `Element` from the `results` array. | |
| 13 | /// | |
| 14 | /// The sequence re-issues the query with an updated `$cursor` variable on each | |
| 15 | /// page until the server returns a null cursor. | |
| 16 | /// | |
| 17 | /// Usage: | |
| 18 | /// ```swift | |
| 19 | /// let sequence = SRHTPaginatedSequence<Repository>( | |
| 20 | /// client: client, | |
| 21 | /// service: .git, | |
| 22 | /// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }", | |
| 23 | /// variables: nil, | |
| 24 | /// resultKeyPath: "me.repositories" | |
| 25 | /// ) | |
| 26 | /// for try await repo in sequence { | |
| 27 | /// print(repo.name) | |
| 28 | /// } | |
| 29 | /// ``` | |
| 30 | struct SRHTPaginatedSequence<Element: Decodable & Sendable>: AsyncSequence, Sendable { | |
| 31 | let client: SRHTClient | |
| 32 | let service: SRHTService | |
| 33 | let query: String | |
| 34 | let variables: [String: any Sendable]? | |
| 35 | let resultKeyPath: String | |
| 36 | ||
| 37 | func makeAsyncIterator() -> Iterator { | |
| 38 | Iterator( | |
| 39 | client: client, | |
| 40 | service: service, | |
| 41 | query: query, | |
| 42 | variables: variables, | |
| 43 | resultKeyPath: resultKeyPath | |
| 44 | ) | |
| 45 | } | |
| 46 | ||
| 47 | struct Iterator: AsyncIteratorProtocol { | |
| 48 | private let client: SRHTClient | |
| 49 | private let service: SRHTService | |
| 50 | private let query: String | |
| 51 | private let baseVariables: [String: any Sendable]? | |
| 52 | private let resultKeyPath: String | |
| 53 | ||
| 54 | /// Buffer of elements from the current page. | |
| 55 | private var buffer: [Element] = [] | |
| 56 | /// Index into the current buffer. | |
| 57 | private var bufferIndex = 0 | |
| 58 | /// The cursor for the next page. Nil means we haven't started or are done. | |
| 59 | private var nextCursor: String? = nil | |
| 60 | /// Whether we've exhausted all pages. | |
| 61 | private var isFinished = false | |
| 62 | ||
| 63 | init( | |
| 64 | client: SRHTClient, | |
| 65 | service: SRHTService, | |
| 66 | query: String, | |
| 67 | variables: [String: any Sendable]?, | |
| 68 | resultKeyPath: String | |
| 69 | ) { | |
| 70 | self.client = client | |
| 71 | self.service = service | |
| 72 | self.query = query | |
| 73 | self.baseVariables = variables | |
| 74 | self.resultKeyPath = resultKeyPath | |
| 75 | } | |
| 76 | ||
| 77 | mutating func next() async throws -> Element? { | |
| 78 | // Yield buffered elements first. | |
| 79 | if bufferIndex < buffer.count { | |
| 80 | let element = buffer[bufferIndex] | |
| 81 | bufferIndex += 1 | |
| 82 | return element | |
| 83 | } | |
| 84 | ||
| 85 | // If we already know there are no more pages, stop. | |
| 86 | if isFinished { | |
| 87 | return nil | |
| 88 | } | |
| 89 | ||
| 90 | // Fetch the next page. | |
| 91 | var vars = baseVariables ?? [:] | |
| 92 | if let cursor = nextCursor { | |
| 93 | vars["cursor"] = cursor | |
| 94 | } | |
| 95 | ||
| 96 | let page = try await fetchPage(variables: vars) | |
| 97 | ||
| 98 | if let cursor = page.cursor { | |
| 99 | nextCursor = cursor | |
| 100 | } else { | |
| 101 | isFinished = true | |
| 102 | } | |
| 103 | ||
| 104 | buffer = page.results | |
| 105 | bufferIndex = 0 | |
| 106 | ||
| 107 | guard bufferIndex < buffer.count else { | |
| 108 | return nil | |
| 109 | } | |
| 110 | ||
| 111 | let element = buffer[bufferIndex] | |
| 112 | bufferIndex += 1 | |
| 113 | return element | |
| 114 | } | |
| 115 | ||
| 116 | private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage<Element> { | |
| 117 | // We decode the raw JSON and navigate the key path manually, | |
| 118 | // since the paginated object can be nested arbitrarily | |
| 119 | // (e.g. "me.repositories" or just "repositories"). | |
| 120 | let raw = try await client.execute( | |
| 121 | service: service, | |
| 122 | query: query, | |
| 123 | variables: variables.isEmpty ? nil : variables, | |
| 124 | responseType: RawJSON.self | |
| 125 | ) | |
| 126 | ||
| 127 | // Walk the key path to find the paginated object. | |
| 128 | let pathComponents = resultKeyPath.split(separator: ".").map(String.init) | |
| 129 | var current = raw.value | |
| 130 | for component in pathComponents { | |
| 131 | guard let dict = current as? [String: Any], | |
| 132 | let next = dict[component] else { | |
| 133 | throw SRHTError.decodingError( | |
| 134 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)")) | |
| 135 | ) | |
| 136 | } | |
| 137 | current = next | |
| 138 | } | |
| 139 | ||
| 140 | // Re-serialize the nested object and decode as CursorPage<Element>. | |
| 141 | let pageData = try JSONSerialization.data(withJSONObject: current) | |
| 142 | let decoder = JSONDecoder() | |
| 143 | decoder.dateDecodingStrategy = .formatted(.srht) | |
| 144 | return try decoder.decode(CursorPage<Element>.self, from: pageData) | |
| 145 | } | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | // MARK: - RawJSON | |
| 150 | ||
| 151 | /// A Decodable wrapper that preserves the raw JSON structure as Foundation objects | |
| 152 | /// so we can navigate dynamic key paths at runtime. | |
| 153 | struct RawJSON: Decodable, Sendable { | |
| 154 | let value: Any | |
| 155 | ||
| 156 | init(from decoder: any Decoder) throws { | |
| 157 | let container = try decoder.singleValueContainer() | |
| 158 | if let dict = try? container.decode([String: RawJSON].self) { | |
| 159 | value = dict.mapValues(\.value) | |
| 160 | } else if let array = try? container.decode([RawJSON].self) { | |
| 161 | value = array.map(\.value) | |
| 162 | } else if let string = try? container.decode(String.self) { | |
| 163 | value = string | |
| 164 | } else if let int = try? container.decode(Int.self) { | |
| 165 | value = int | |
| 166 | } else if let double = try? container.decode(Double.self) { | |
| 167 | value = double | |
| 168 | } else if let bool = try? container.decode(Bool.self) { | |
| 169 | value = bool | |
| 170 | } else if container.decodeNil() { | |
| 171 | value = NSNull() | |
| 172 | } else { | |
| 173 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
Hutch/Networking/ResponseCache.swift added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | /// Thread-safe in-memory cache for raw GraphQL response data. | |
| 5 | /// Keyed by a caller-provided string (typically service name + query hash). | |
| 6 | final class ResponseCache: Sendable { | |
| 7 | ||
| 8 | private let storage: OSAllocatedUnfairLock<[String: Data]> | |
| 9 | ||
| 10 | init() { | |
| 11 | self.storage = OSAllocatedUnfairLock(initialState: [:]) | |
| 12 | } | |
| 13 | ||
| 14 | /// Store raw response data under a cache key. | |
| 15 | func set(_ data: Data, forKey key: String) { | |
| 16 | storage.withLock { $0[key] = data } | |
| 17 | } | |
| 18 | ||
| 19 | /// Retrieve cached response data. Returns nil on cache miss. | |
| 20 | func get(forKey key: String) -> Data? { | |
| 21 | storage.withLock { $0[key] } | |
| 22 | } | |
| 23 | ||
| 24 | /// Remove a specific entry. | |
| 25 | func remove(forKey key: String) { | |
| 26 | storage.withLock { _ = $0.removeValue(forKey: key) } | |
| 27 | } | |
| 28 | ||
| 29 | /// Clear all cached data (e.g. on sign-out). | |
| 30 | func clear() { | |
| 31 | storage.withLock { $0.removeAll() } | |
| 32 | } | |
| 33 | } | |
Hutch/Networking/SRHTClient.swift added +521
| @@ -0,0 +1,521 @@ | ||
| 1 | import Foundation | |
| 2 | import os | |
| 3 | ||
| 4 | private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient") | |
| 5 | ||
| 6 | /// Placeholder type for decoding GraphQL error responses when the data shape is unknown. | |
| 7 | private struct EmptyData: Decodable {} | |
| 8 | ||
| 9 | /// A lightweight GraphQL client for Sourcehut services. | |
| 10 | /// All requests require a personal access token set via ``token``. | |
| 11 | final class SRHTClient: Sendable { | |
| 12 | ||
| 13 | private let session: URLSession | |
| 14 | private let decoder: JSONDecoder | |
| 15 | private let encoder: JSONEncoder | |
| 16 | ||
| 17 | /// The personal access token used for `Authorization: Bearer` headers. | |
| 18 | /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. | |
| 19 | private let _token: OSAllocatedUnfairLock<String?> | |
| 20 | ||
| 21 | /// In-memory response cache for stale-while-revalidate pattern. | |
| 22 | let responseCache = ResponseCache() | |
| 23 | ||
| 24 | var hasToken: Bool { | |
| 25 | _token.withLock { $0 != nil } | |
| 26 | } | |
| 27 | ||
| 28 | init(session: URLSession = .shared, token: String? = nil) { | |
| 29 | self.session = session | |
| 30 | self.decoder = JSONDecoder() | |
| 31 | self.decoder.dateDecodingStrategy = .srhtFlexible | |
| 32 | self.encoder = JSONEncoder() | |
| 33 | self._token = OSAllocatedUnfairLock(initialState: token) | |
| 34 | } | |
| 35 | ||
| 36 | /// Update the stored token (e.g. after the user saves a new one in Keychain). | |
| 37 | func setToken(_ token: String?) { | |
| 38 | _token.withLock { $0 = token } | |
| 39 | } | |
| 40 | ||
| 41 | /// Execute a GraphQL query or mutation against a Sourcehut service. | |
| 42 | /// | |
| 43 | /// - Parameters: | |
| 44 | /// - service: The target Sourcehut service (determines the endpoint URL). | |
| 45 | /// - query: The GraphQL query or mutation string. | |
| 46 | /// - variables: Optional dictionary of GraphQL variables. | |
| 47 | /// - responseType: The expected `Decodable` type nested under `data`. | |
| 48 | /// - Returns: The decoded `data` payload. | |
| 49 | func execute<T: Decodable>( | |
| 50 | service: SRHTService, | |
| 51 | query: String, | |
| 52 | variables: [String: any Sendable]? = nil, | |
| 53 | responseType: T.Type | |
| 54 | ) async throws -> T { | |
| 55 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | |
| 56 | throw SRHTError.unauthorized | |
| 57 | } | |
| 58 | ||
| 59 | // Build request | |
| 60 | var request = URLRequest(url: service.url) | |
| 61 | request.httpMethod = "POST" | |
| 62 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 63 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 64 | ||
| 65 | let body = GraphQLRequestBody( | |
| 66 | query: query, | |
| 67 | variables: variables?.mapValues { AnyCodable($0) } | |
| 68 | ) | |
| 69 | request.httpBody = try encoder.encode(body) | |
| 70 | ||
| 71 | // Execute | |
| 72 | let (data, response): (Data, URLResponse) | |
| 73 | do { | |
| 74 | (data, response) = try await session.data(for: request) | |
| 75 | } catch { | |
| 76 | throw SRHTError.networkError(error) | |
| 77 | } | |
| 78 | ||
| 79 | // Check HTTP status | |
| 80 | if let http = response as? HTTPURLResponse { | |
| 81 | if http.statusCode == 401 { | |
| 82 | throw SRHTError.unauthorized | |
| 83 | } | |
| 84 | if !(200...299).contains(http.statusCode) { | |
| 85 | // Try to extract GraphQL errors from the response body even on non-2xx | |
| 86 | if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | |
| 87 | let errors = gqlResponse.errors, !errors.isEmpty { | |
| 88 | throw SRHTError.graphQLErrors(errors) | |
| 89 | } | |
| 90 | throw SRHTError.httpError(http.statusCode) | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | // Decode GraphQL response envelope | |
| 95 | let graphQLResponse: GraphQLResponse<T> | |
| 96 | do { | |
| 97 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) | |
| 98 | } catch { | |
| 99 | #if DEBUG | |
| 100 | let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" | |
| 101 | let variablesDescription = String(describing: variables) | |
| 102 | if let decodingError = error as? DecodingError { | |
| 103 | logger.error( | |
| 104 | """ | |
| 105 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 106 | service: \(service.rawValue, privacy: .public) | |
| 107 | query: | |
| 108 | \(query, privacy: .public) | |
| 109 | variables: | |
| 110 | \(variablesDescription, privacy: .public) | |
| 111 | decodingError: | |
| 112 | \(String(describing: decodingError), privacy: .public) | |
| 113 | response: | |
| 114 | \(responseBody, privacy: .public) | |
| 115 | """ | |
| 116 | ) | |
| 117 | } else { | |
| 118 | logger.error( | |
| 119 | """ | |
| 120 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 121 | service: \(service.rawValue, privacy: .public) | |
| 122 | query: | |
| 123 | \(query, privacy: .public) | |
| 124 | variables: | |
| 125 | \(variablesDescription, privacy: .public) | |
| 126 | error: | |
| 127 | \(String(describing: error), privacy: .public) | |
| 128 | response: | |
| 129 | \(responseBody, privacy: .public) | |
| 130 | """ | |
| 131 | ) | |
| 132 | } | |
| 133 | #else | |
| 134 | logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") | |
| 135 | #endif | |
| 136 | throw SRHTError.decodingError(error) | |
| 137 | } | |
| 138 | ||
| 139 | // Surface GraphQL-level errors | |
| 140 | if let errors = graphQLResponse.errors, !errors.isEmpty { | |
| 141 | throw SRHTError.graphQLErrors(errors) | |
| 142 | } | |
| 143 | ||
| 144 | guard let result = graphQLResponse.data else { | |
| 145 | throw SRHTError.decodingError( | |
| 146 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) | |
| 147 | ) | |
| 148 | } | |
| 149 | ||
| 150 | return result | |
| 151 | } | |
| 152 | ||
| 153 | // MARK: - Multipart Upload | |
| 154 | ||
| 155 | /// Execute a GraphQL mutation with a file upload using the | |
| 156 | /// graphql-multipart-request-spec (multipart/form-data). | |
| 157 | /// | |
| 158 | /// - Parameters: | |
| 159 | /// - service: The target Sourcehut service. | |
| 160 | /// - query: The GraphQL mutation string. | |
| 161 | /// - variables: Variables dict; the file variable should be set to `nil`. | |
| 162 | /// - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar"). | |
| 163 | /// - fileData: The raw file data (e.g. JPEG). | |
| 164 | /// - fileName: The file name to send (e.g. "avatar.jpg"). | |
| 165 | /// - mimeType: The MIME type (e.g. "image/jpeg"). | |
| 166 | /// - responseType: The expected `Decodable` type nested under `data`. | |
| 167 | func executeMultipart<T: Decodable>( | |
| 168 | service: SRHTService, | |
| 169 | query: String, | |
| 170 | variables: [String: any Sendable], | |
| 171 | fileVariablePath: String, | |
| 172 | fileData: Data, | |
| 173 | fileName: String, | |
| 174 | mimeType: String, | |
| 175 | responseType: T.Type | |
| 176 | ) async throws -> T { | |
| 177 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | |
| 178 | throw SRHTError.unauthorized | |
| 179 | } | |
| 180 | ||
| 181 | let boundary = "Boundary-\(UUID().uuidString)" | |
| 182 | ||
| 183 | var request = URLRequest(url: service.url) | |
| 184 | request.httpMethod = "POST" | |
| 185 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 186 | request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") | |
| 187 | ||
| 188 | // Build the operations JSON (file variable mapped to null) | |
| 189 | let operationsBody = GraphQLRequestBody( | |
| 190 | query: query, | |
| 191 | variables: variables.mapValues { AnyCodable($0) } | |
| 192 | ) | |
| 193 | let operationsData = try encoder.encode(operationsBody) | |
| 194 | ||
| 195 | // Build the map JSON: { "0": ["variables.<fileVariablePath>"] } | |
| 196 | let mapDict = ["0": ["variables.\(fileVariablePath)"]] | |
| 197 | let mapData = try encoder.encode(mapDict) | |
| 198 | ||
| 199 | // Assemble multipart body | |
| 200 | var body = Data() | |
| 201 | ||
| 202 | // Part: operations | |
| 203 | body.append("--\(boundary)\r\n") | |
| 204 | body.append("Content-Disposition: form-data; name=\"operations\"\r\n") | |
| 205 | body.append("Content-Type: application/json\r\n\r\n") | |
| 206 | body.append(operationsData) | |
| 207 | body.append("\r\n") | |
| 208 | ||
| 209 | // Part: map | |
| 210 | body.append("--\(boundary)\r\n") | |
| 211 | body.append("Content-Disposition: form-data; name=\"map\"\r\n") | |
| 212 | body.append("Content-Type: application/json\r\n\r\n") | |
| 213 | body.append(mapData) | |
| 214 | body.append("\r\n") | |
| 215 | ||
| 216 | // Part: file | |
| 217 | body.append("--\(boundary)\r\n") | |
| 218 | body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n") | |
| 219 | body.append("Content-Type: \(mimeType)\r\n\r\n") | |
| 220 | body.append(fileData) | |
| 221 | body.append("\r\n") | |
| 222 | ||
| 223 | // Closing boundary | |
| 224 | body.append("--\(boundary)--\r\n") | |
| 225 | ||
| 226 | request.httpBody = body | |
| 227 | ||
| 228 | let (data, response): (Data, URLResponse) | |
| 229 | do { | |
| 230 | (data, response) = try await session.data(for: request) | |
| 231 | } catch { | |
| 232 | throw SRHTError.networkError(error) | |
| 233 | } | |
| 234 | ||
| 235 | if let http = response as? HTTPURLResponse { | |
| 236 | if http.statusCode == 401 { | |
| 237 | throw SRHTError.unauthorized | |
| 238 | } | |
| 239 | if !(200...299).contains(http.statusCode) { | |
| 240 | throw SRHTError.httpError(http.statusCode) | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | let graphQLResponse: GraphQLResponse<T> | |
| 245 | do { | |
| 246 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) | |
| 247 | } catch { | |
| 248 | #if DEBUG | |
| 249 | let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" | |
| 250 | let variablesDescription = String(describing: variables) | |
| 251 | if let decodingError = error as? DecodingError { | |
| 252 | logger.error( | |
| 253 | """ | |
| 254 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 255 | service: \(service.rawValue, privacy: .public) | |
| 256 | query: | |
| 257 | \(query, privacy: .public) | |
| 258 | variables: | |
| 259 | \(variablesDescription, privacy: .public) | |
| 260 | decodingError: | |
| 261 | \(String(describing: decodingError), privacy: .public) | |
| 262 | response: | |
| 263 | \(responseBody, privacy: .public) | |
| 264 | """ | |
| 265 | ) | |
| 266 | } else { | |
| 267 | logger.error( | |
| 268 | """ | |
| 269 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 270 | service: \(service.rawValue, privacy: .public) | |
| 271 | query: | |
| 272 | \(query, privacy: .public) | |
| 273 | variables: | |
| 274 | \(variablesDescription, privacy: .public) | |
| 275 | error: | |
| 276 | \(String(describing: error), privacy: .public) | |
| 277 | response: | |
| 278 | \(responseBody, privacy: .public) | |
| 279 | """ | |
| 280 | ) | |
| 281 | } | |
| 282 | #else | |
| 283 | logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") | |
| 284 | #endif | |
| 285 | throw SRHTError.decodingError(error) | |
| 286 | } | |
| 287 | ||
| 288 | if let errors = graphQLResponse.errors, !errors.isEmpty { | |
| 289 | throw SRHTError.graphQLErrors(errors) | |
| 290 | } | |
| 291 | ||
| 292 | guard let result = graphQLResponse.data else { | |
| 293 | throw SRHTError.decodingError( | |
| 294 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) | |
| 295 | ) | |
| 296 | } | |
| 297 | ||
| 298 | return result | |
| 299 | } | |
| 300 | ||
| 301 | // MARK: - Cached Execute | |
| 302 | ||
| 303 | /// Execute a query and cache the raw response data. Returns cached data | |
| 304 | /// immediately on cache hit, then refreshes in the background via the | |
| 305 | /// `onRefresh` callback. | |
| 306 | func executeCached<T: Decodable>( | |
| 307 | service: SRHTService, | |
| 308 | query: String, | |
| 309 | variables: [String: any Sendable]? = nil, | |
| 310 | responseType: T.Type, | |
| 311 | cacheKey: String | |
| 312 | ) async throws -> T { | |
| 313 | // Try cache first | |
| 314 | if let cachedData = responseCache.get(forKey: cacheKey) { | |
| 315 | if let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData), | |
| 316 | let data = cached.data { | |
| 317 | return data | |
| 318 | } | |
| 319 | } | |
| 320 | ||
| 321 | // No cache hit — fetch normally | |
| 322 | return try await executeAndCache( | |
| 323 | service: service, | |
| 324 | query: query, | |
| 325 | variables: variables, | |
| 326 | responseType: responseType, | |
| 327 | cacheKey: cacheKey | |
| 328 | ) | |
| 329 | } | |
| 330 | ||
| 331 | /// Execute a query, cache the raw data, and return the decoded result. | |
| 332 | func executeAndCache<T: Decodable>( | |
| 333 | service: SRHTService, | |
| 334 | query: String, | |
| 335 | variables: [String: any Sendable]? = nil, | |
| 336 | responseType: T.Type, | |
| 337 | cacheKey: String | |
| 338 | ) async throws -> T { | |
| 339 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | |
| 340 | throw SRHTError.unauthorized | |
| 341 | } | |
| 342 | ||
| 343 | var request = URLRequest(url: service.url) | |
| 344 | request.httpMethod = "POST" | |
| 345 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 346 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") | |
| 347 | ||
| 348 | let body = GraphQLRequestBody( | |
| 349 | query: query, | |
| 350 | variables: variables?.mapValues { AnyCodable($0) } | |
| 351 | ) | |
| 352 | request.httpBody = try encoder.encode(body) | |
| 353 | ||
| 354 | let (data, response): (Data, URLResponse) | |
| 355 | do { | |
| 356 | (data, response) = try await session.data(for: request) | |
| 357 | } catch { | |
| 358 | throw SRHTError.networkError(error) | |
| 359 | } | |
| 360 | ||
| 361 | if let http = response as? HTTPURLResponse { | |
| 362 | if http.statusCode == 401 { | |
| 363 | throw SRHTError.unauthorized | |
| 364 | } | |
| 365 | if !(200...299).contains(http.statusCode) { | |
| 366 | throw SRHTError.httpError(http.statusCode) | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | // Cache the raw response data before decoding | |
| 371 | responseCache.set(data, forKey: cacheKey) | |
| 372 | ||
| 373 | let graphQLResponse: GraphQLResponse<T> | |
| 374 | do { | |
| 375 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) | |
| 376 | } catch { | |
| 377 | #if DEBUG | |
| 378 | let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" | |
| 379 | let variablesDescription = String(describing: variables) | |
| 380 | if let decodingError = error as? DecodingError { | |
| 381 | logger.error( | |
| 382 | """ | |
| 383 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 384 | service: \(service.rawValue, privacy: .public) | |
| 385 | query: | |
| 386 | \(query, privacy: .public) | |
| 387 | variables: | |
| 388 | \(variablesDescription, privacy: .public) | |
| 389 | decodingError: | |
| 390 | \(String(describing: decodingError), privacy: .public) | |
| 391 | response: | |
| 392 | \(responseBody, privacy: .public) | |
| 393 | """ | |
| 394 | ) | |
| 395 | } else { | |
| 396 | logger.error( | |
| 397 | """ | |
| 398 | Decoding failed for \(String(describing: T.self), privacy: .public) | |
| 399 | service: \(service.rawValue, privacy: .public) | |
| 400 | query: | |
| 401 | \(query, privacy: .public) | |
| 402 | variables: | |
| 403 | \(variablesDescription, privacy: .public) | |
| 404 | error: | |
| 405 | \(String(describing: error), privacy: .public) | |
| 406 | response: | |
| 407 | \(responseBody, privacy: .public) | |
| 408 | """ | |
| 409 | ) | |
| 410 | } | |
| 411 | #else | |
| 412 | logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)") | |
| 413 | #endif | |
| 414 | throw SRHTError.decodingError(error) | |
| 415 | } | |
| 416 | ||
| 417 | if let errors = graphQLResponse.errors, !errors.isEmpty { | |
| 418 | throw SRHTError.graphQLErrors(errors) | |
| 419 | } | |
| 420 | ||
| 421 | guard let result = graphQLResponse.data else { | |
| 422 | throw SRHTError.decodingError( | |
| 423 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) | |
| 424 | ) | |
| 425 | } | |
| 426 | ||
| 427 | return result | |
| 428 | } | |
| 429 | ||
| 430 | // MARK: - Plain-text fetch | |
| 431 | ||
| 432 | /// Fetch the contents of a URL as plain text, using the same authorization header. | |
| 433 | /// Used for build logs and other non-GraphQL resources. | |
| 434 | func fetchText(url: URL) async throws -> String { | |
| 435 | guard let token = _token.withLock({ $0 }), !token.isEmpty else { | |
| 436 | throw SRHTError.unauthorized | |
| 437 | } | |
| 438 | ||
| 439 | var request = URLRequest(url: url) | |
| 440 | request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") | |
| 441 | ||
| 442 | let (data, response): (Data, URLResponse) | |
| 443 | do { | |
| 444 | (data, response) = try await session.data(for: request) | |
| 445 | } catch { | |
| 446 | throw SRHTError.networkError(error) | |
| 447 | } | |
| 448 | ||
| 449 | if let http = response as? HTTPURLResponse { | |
| 450 | if http.statusCode == 401 { | |
| 451 | throw SRHTError.unauthorized | |
| 452 | } | |
| 453 | if !(200...299).contains(http.statusCode) { | |
| 454 | throw SRHTError.httpError(http.statusCode) | |
| 455 | } | |
| 456 | } | |
| 457 | ||
| 458 | guard let text = String(data: data, encoding: .utf8) else { | |
| 459 | throw SRHTError.decodingError( | |
| 460 | DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) | |
| 461 | ) | |
| 462 | } | |
| 463 | ||
| 464 | return text | |
| 465 | } | |
| 466 | ||
| 467 | // MARK: - Pagination | |
| 468 | ||
| 469 | /// Returns an `AsyncSequence` that lazily iterates through all pages of a | |
| 470 | /// paginated sr.ht GraphQL query. | |
| 471 | /// | |
| 472 | /// The query must accept a `$cursor: String` variable and return the standard | |
| 473 | /// `{ results: [T], cursor: String? }` shape at the given key path. | |
| 474 | func paginated<T: Decodable & Sendable>( | |
| 475 | service: SRHTService, | |
| 476 | query: String, | |
| 477 | variables: [String: any Sendable]? = nil, | |
| 478 | resultKeyPath: String, | |
| 479 | type: T.Type | |
| 480 | ) -> SRHTPaginatedSequence<T> { | |
| 481 | SRHTPaginatedSequence( | |
| 482 | client: self, | |
| 483 | service: service, | |
| 484 | query: query, | |
| 485 | variables: variables, | |
| 486 | resultKeyPath: resultKeyPath | |
| 487 | ) | |
| 488 | } | |
| 489 | ||
| 490 | /// Fetches all pages of a paginated sr.ht GraphQL query and returns the | |
| 491 | /// collected results. | |
| 492 | func fetchAll<T: Decodable & Sendable>( | |
| 493 | service: SRHTService, | |
| 494 | query: String, | |
| 495 | variables: [String: any Sendable]? = nil, | |
| 496 | resultKeyPath: String, | |
| 497 | type: T.Type | |
| 498 | ) async throws -> [T] { | |
| 499 | var all: [T] = [] | |
| 500 | for try await element in paginated( | |
| 501 | service: service, | |
| 502 | query: query, | |
| 503 | variables: variables, | |
| 504 | resultKeyPath: resultKeyPath, | |
| 505 | type: type | |
| 506 | ) { | |
| 507 | all.append(element) | |
| 508 | } | |
| 509 | return all | |
| 510 | } | |
| 511 | } | |
| 512 | ||
| 513 | // MARK: - Data Helper | |
| 514 | ||
| 515 | private extension Data { | |
| 516 | mutating func append(_ string: String) { | |
| 517 | if let data = string.data(using: .utf8) { | |
| 518 | append(data) | |
| 519 | } | |
| 520 | } | |
| 521 | } | |
Hutch/Networking/SRHTError.swift added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Errors produced by the Sourcehut GraphQL client. | |
| 4 | enum SRHTError: LocalizedError, Sendable { | |
| 5 | /// The server returned one or more GraphQL-level errors. | |
| 6 | case graphQLErrors([GraphQLError]) | |
| 7 | /// The HTTP response had a non-2xx status code. | |
| 8 | case httpError(Int) | |
| 9 | /// The response data could not be decoded. | |
| 10 | case decodingError(any Error) | |
| 11 | /// A networking error from URLSession (timeout, DNS, connectivity, etc.). | |
| 12 | case networkError(any Error) | |
| 13 | /// 401 or no authentication token configured. | |
| 14 | case unauthorized | |
| 15 | ||
| 16 | var errorDescription: String? { | |
| 17 | switch self { | |
| 18 | case .graphQLErrors(let errors): | |
| 19 | let messages = errors.map(\.message).joined(separator: "\n") | |
| 20 | return "GraphQL error: \(messages)" | |
| 21 | case .httpError(let code): | |
| 22 | return "Server returned HTTP \(code)." | |
| 23 | case .decodingError(let error): | |
| 24 | return "Failed to decode response: \(error.localizedDescription)" | |
| 25 | case .networkError(let error): | |
| 26 | return "Network error: \(error.localizedDescription)" | |
| 27 | case .unauthorized: | |
| 28 | return "Authentication required. Please sign in again." | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | /// Whether this error represents a connectivity issue (no internet, timeout, DNS). | |
| 33 | var isConnectivityError: Bool { | |
| 34 | switch self { | |
| 35 | case .networkError(let error): | |
| 36 | let nsError = error as NSError | |
| 37 | let connectivityCodes: Set<Int> = [ | |
| 38 | NSURLErrorNotConnectedToInternet, | |
| 39 | NSURLErrorNetworkConnectionLost, | |
| 40 | NSURLErrorTimedOut, | |
| 41 | NSURLErrorCannotFindHost, | |
| 42 | NSURLErrorCannotConnectToHost, | |
| 43 | NSURLErrorDNSLookupFailed, | |
| 44 | NSURLErrorInternationalRoamingOff, | |
| 45 | NSURLErrorDataNotAllowed | |
| 46 | ] | |
| 47 | return connectivityCodes.contains(nsError.code) | |
| 48 | default: | |
| 49 | return false | |
| 50 | } | |
| 51 | } | |
| 52 | } | |
| 53 | ||
| 54 | /// A single error entry from the GraphQL `errors` array. | |
| 55 | struct GraphQLError: Decodable, Sendable { | |
| 56 | let message: String | |
| 57 | let locations: [GraphQLErrorLocation]? | |
| 58 | } | |
| 59 | ||
| 60 | struct GraphQLErrorLocation: Decodable, Sendable { | |
| 61 | let line: Int | |
| 62 | let column: Int | |
| 63 | } | |
Hutch/Networking/SRHTService.swift added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// Each Sourcehut service exposes its own GraphQL endpoint. | |
| 4 | enum SRHTService: String, Codable, Sendable, CaseIterable { | |
| 5 | case meta | |
| 6 | case git | |
| 7 | case hg | |
| 8 | case builds | |
| 9 | case lists | |
| 10 | case todo | |
| 11 | case paste | |
| 12 | case pages | |
| 13 | case man | |
| 14 | ||
| 15 | /// The GraphQL endpoint URL for this service. | |
| 16 | var url: URL { | |
| 17 | // Force-unwrap is safe here — these are compile-time constant strings. | |
| 18 | URL(string: "https://\(rawValue).sr.ht/query")! | |
| 19 | } | |
| 20 | ||
| 21 | var displayName: String { | |
| 22 | switch self { | |
| 23 | case .meta: "Meta" | |
| 24 | case .git: "Git" | |
| 25 | case .hg: "Mercurial" | |
| 26 | case .builds: "Builds" | |
| 27 | case .lists: "Lists" | |
| 28 | case .todo: "Todo" | |
| 29 | case .paste: "Paste" | |
| 30 | case .pages: "Pages" | |
| 31 | case .man: "Man" | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
Hutch/Views/Auth/AuthView.swift added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Token entry screen shown when the user is not authenticated. | |
| 4 | struct TokenEntryView: View { | |
| 5 | private let createAccountURL = URL(string: "https://meta.sr.ht/register")! | |
| 6 | private let personalAccessTokensURL = URL(string: "https://meta.sr.ht/oauth/personal-access-tokens")! | |
| 7 | ||
| 8 | @Environment(AppState.self) private var appState | |
| 9 | @State private var token = "" | |
| 10 | @State private var isConnecting = false | |
| 11 | @State private var errorMessage: String? | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | NavigationStack { | |
| 15 | Form { | |
| 16 | Section { | |
| 17 | Text("Enter your SourceHut personal access token to connect.") | |
| 18 | } header: { | |
| 19 | Text("Welcome to Hutch") | |
| 20 | } footer: { | |
| 21 | Text("Hutch stores your SourceHut personal access token securely in the iOS keychain.") | |
| 22 | } | |
| 23 | ||
| 24 | Section { | |
| 25 | SecureField("Personal Access Token", text: $token) | |
| 26 | .textContentType(.password) | |
| 27 | .autocorrectionDisabled() | |
| 28 | .textInputAutocapitalization(.never) | |
| 29 | .disabled(isConnecting) | |
| 30 | } header: { | |
| 31 | Text("Token") | |
| 32 | } | |
| 33 | ||
| 34 | if let errorMessage { | |
| 35 | Section { | |
| 36 | Label { | |
| 37 | Text(errorMessage) | |
| 38 | } icon: { | |
| 39 | Image(systemName: "exclamationmark.triangle.fill") | |
| 40 | .foregroundStyle(.red) | |
| 41 | } | |
| 42 | .foregroundStyle(.red) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | Section { | |
| 47 | Button { | |
| 48 | connect() | |
| 49 | } label: { | |
| 50 | HStack { | |
| 51 | Text("Connect") | |
| 52 | if isConnecting { | |
| 53 | Spacer() | |
| 54 | ProgressView() | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | .disabled(tokenTrimmed.isEmpty || isConnecting) | |
| 59 | } | |
| 60 | ||
| 61 | Section { | |
| 62 | Link(destination: createAccountURL) { | |
| 63 | Label("Create SourceHut account", systemImage: "person.badge.plus") | |
| 64 | } | |
| 65 | ||
| 66 | Link(destination: personalAccessTokensURL) { | |
| 67 | Label("Create Personal Access Token", systemImage: "key") | |
| 68 | } | |
| 69 | } header: { | |
| 70 | Text("Need an account?") | |
| 71 | } footer: { | |
| 72 | Text("These links open SourceHut in your browser. After signing up, create a Personal Access Token there and paste it here.") | |
| 73 | } | |
| 74 | } | |
| 75 | .navigationTitle("Hutch") | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | private var tokenTrimmed: String { | |
| 80 | token.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 81 | } | |
| 82 | ||
| 83 | private func connect() { | |
| 84 | errorMessage = nil | |
| 85 | isConnecting = true | |
| 86 | Task { | |
| 87 | do { | |
| 88 | try await appState.connect(with: tokenTrimmed) | |
| 89 | } catch { | |
| 90 | errorMessage = error.localizedDescription | |
| 91 | } | |
| 92 | isConnecting = false | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
Hutch/Views/Builds/BuildDetailView.swift added +383
| @@ -0,0 +1,383 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct BuildDetailView: View { | |
| 4 | let jobId: Int | |
| 5 | ||
| 6 | @Environment(AppState.self) private var appState | |
| 7 | @State private var viewModel: BuildDetailViewModel? | |
| 8 | @State private var rebuiltJobId: Int? | |
| 9 | @State private var showEditResubmitSheet = false | |
| 10 | @State private var showCancelConfirmation = false | |
| 11 | ||
| 12 | var body: some View { | |
| 13 | Group { | |
| 14 | if let viewModel { | |
| 15 | detailContent(viewModel) | |
| 16 | } else { | |
| 17 | SRHTLoadingStateView(message: "Loading build…") | |
| 18 | } | |
| 19 | } | |
| 20 | .navigationTitle("Job #\(jobId)") | |
| 21 | .navigationBarTitleDisplayMode(.inline) | |
| 22 | .toolbar { | |
| 23 | ToolbarItem(placement: .topBarTrailing) { | |
| 24 | SRHTShareButton( | |
| 25 | url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) }, | |
| 26 | target: .build | |
| 27 | ) { | |
| 28 | Image(systemName: "square.and.arrow.up") | |
| 29 | } | |
| 30 | } | |
| 31 | } | |
| 32 | .navigationDestination(isPresented: Binding( | |
| 33 | get: { rebuiltJobId != nil }, | |
| 34 | set: { isPresented in | |
| 35 | if !isPresented { | |
| 36 | rebuiltJobId = nil | |
| 37 | } | |
| 38 | } | |
| 39 | )) { | |
| 40 | if let rebuiltJobId { | |
| 41 | BuildDetailView(jobId: rebuiltJobId) | |
| 42 | } | |
| 43 | } | |
| 44 | .sheet(isPresented: $showEditResubmitSheet) { | |
| 45 | if let viewModel, let job = viewModel.job { | |
| 46 | EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in | |
| 47 | showEditResubmitSheet = false | |
| 48 | rebuiltJobId = jobId | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | .alert("Cancel Build?", isPresented: $showCancelConfirmation) { | |
| 53 | Button("Keep Running", role: .cancel) {} | |
| 54 | Button("Cancel Build", role: .destructive) { | |
| 55 | Task { await viewModel?.cancelJob() } | |
| 56 | } | |
| 57 | } message: { | |
| 58 | Text("The build will stop as soon as possible.") | |
| 59 | } | |
| 60 | .task { | |
| 61 | if viewModel == nil { | |
| 62 | let vm = BuildDetailViewModel(jobId: jobId, client: appState.client) | |
| 63 | viewModel = vm | |
| 64 | await vm.loadJob() | |
| 65 | } | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | @ViewBuilder | |
| 70 | private func detailContent(_ viewModel: BuildDetailViewModel) -> some View { | |
| 71 | if viewModel.isLoading, viewModel.job == nil { | |
| 72 | SRHTLoadingStateView(message: "Loading build…") | |
| 73 | } else if let error = viewModel.error, viewModel.job == nil { | |
| 74 | SRHTErrorStateView( | |
| 75 | title: "Couldn't Load Build", | |
| 76 | message: error, | |
| 77 | retryAction: { await viewModel.loadJob() } | |
| 78 | ) | |
| 79 | } else if let job = viewModel.job { | |
| 80 | List { | |
| 81 | // Status & metadata | |
| 82 | Section("Details") { | |
| 83 | HStack { | |
| 84 | Text("Status") | |
| 85 | Spacer() | |
| 86 | HStack(spacing: 6) { | |
| 87 | JobStatusIcon(status: job.status) | |
| 88 | Text(job.status.rawValue) | |
| 89 | .font(.subheadline.weight(.medium)) | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | if let note = job.note, !note.isEmpty { | |
| 94 | LabeledContent("Note", value: note) | |
| 95 | } | |
| 96 | ||
| 97 | if let image = job.image { | |
| 98 | LabeledContent("Image", value: image) | |
| 99 | } | |
| 100 | ||
| 101 | if !job.tags.isEmpty { | |
| 102 | LabeledContent("Tags", value: job.tags.joined(separator: ", ")) | |
| 103 | } | |
| 104 | ||
| 105 | if let visibility = job.visibility { | |
| 106 | LabeledContent("Visibility", value: visibility.rawValue.capitalized) | |
| 107 | } | |
| 108 | ||
| 109 | LabeledContent("Owner", value: job.owner.canonicalName) | |
| 110 | LabeledContent("Created", value: job.created.relativeDescription) | |
| 111 | LabeledContent("Updated", value: job.updated.relativeDescription) | |
| 112 | } | |
| 113 | ||
| 114 | // Per-task logs | |
| 115 | if !job.tasks.isEmpty { | |
| 116 | ForEach(job.tasks) { task in | |
| 117 | Section { | |
| 118 | TaskLogSection(task: task, viewModel: viewModel) | |
| 119 | } header: { | |
| 120 | HStack(spacing: 6) { | |
| 121 | TaskStatusIcon(status: task.status) | |
| 122 | Text(task.name) | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | // Cancel button | |
| 129 | if job.status.isCancellable { | |
| 130 | Section { | |
| 131 | Button(role: .destructive) { | |
| 132 | showCancelConfirmation = true | |
| 133 | } label: { | |
| 134 | HStack { | |
| 135 | Text("Cancel Build") | |
| 136 | if viewModel.isCancelling { | |
| 137 | Spacer() | |
| 138 | ProgressView() | |
| 139 | } | |
| 140 | } | |
| 141 | } | |
| 142 | .disabled(viewModel.isCancelling) | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | if let manifest = job.manifest, | |
| 147 | !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 148 | Section { | |
| 149 | Button { | |
| 150 | Task { | |
| 151 | rebuiltJobId = await viewModel.rebuildJob() | |
| 152 | } | |
| 153 | } label: { | |
| 154 | HStack { | |
| 155 | Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild") | |
| 156 | if viewModel.isRebuilding { | |
| 157 | Spacer() | |
| 158 | ProgressView() | |
| 159 | } | |
| 160 | } | |
| 161 | } | |
| 162 | .disabled(viewModel.isRebuilding) | |
| 163 | ||
| 164 | Button { | |
| 165 | showEditResubmitSheet = true | |
| 166 | } label: { | |
| 167 | Text("Edit & Resubmit") | |
| 168 | } | |
| 169 | .disabled(viewModel.isSubmittingEditedBuild) | |
| 170 | } footer: { | |
| 171 | Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.") | |
| 172 | } | |
| 173 | } | |
| 174 | } | |
| 175 | .refreshable { | |
| 176 | await viewModel.loadJob() | |
| 177 | } | |
| 178 | .srhtErrorBanner(error: Binding( | |
| 179 | get: { viewModel.error }, | |
| 180 | set: { viewModel.error = $0 } | |
| 181 | )) | |
| 182 | } | |
| 183 | } | |
| 184 | } | |
| 185 | ||
| 186 | private struct EditResubmitBuildSheet: View { | |
| 187 | let viewModel: BuildDetailViewModel | |
| 188 | let job: JobDetail | |
| 189 | let onSubmitted: (Int) -> Void | |
| 190 | ||
| 191 | @Environment(\.dismiss) private var dismiss | |
| 192 | @Bindable var viewModelBindable: BuildDetailViewModel | |
| 193 | @State private var manifest: String | |
| 194 | @State private var tagsText: String | |
| 195 | @State private var note: String | |
| 196 | @State private var secrets = false | |
| 197 | @State private var execute = true | |
| 198 | @State private var visibility: Visibility | |
| 199 | ||
| 200 | init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) { | |
| 201 | self.viewModel = viewModel | |
| 202 | self._viewModelBindable = Bindable(viewModel) | |
| 203 | self.job = job | |
| 204 | self.onSubmitted = onSubmitted | |
| 205 | _manifest = State(initialValue: job.manifest ?? "") | |
| 206 | _tagsText = State(initialValue: job.tags.joined(separator: ", ")) | |
| 207 | _note = State(initialValue: job.note ?? "") | |
| 208 | _visibility = State(initialValue: job.visibility ?? .public) | |
| 209 | } | |
| 210 | ||
| 211 | var body: some View { | |
| 212 | NavigationStack { | |
| 213 | Form { | |
| 214 | Section("Build Manifest") { | |
| 215 | TextField("Build manifest", text: $manifest, axis: .vertical) | |
| 216 | .font(.system(.body, design: .monospaced)) | |
| 217 | .lineLimit(12...24) | |
| 218 | .textInputAutocapitalization(.never) | |
| 219 | .autocorrectionDisabled() | |
| 220 | } | |
| 221 | ||
| 222 | Section("Build Options") { | |
| 223 | TextField("Note (optional)", text: $note) | |
| 224 | TextField("Tags (comma-separated, optional)", text: $tagsText) | |
| 225 | .textInputAutocapitalization(.never) | |
| 226 | .autocorrectionDisabled() | |
| 227 | Picker("Visibility", selection: $visibility) { | |
| 228 | Text("Public").tag(Visibility.public) | |
| 229 | Text("Unlisted").tag(Visibility.unlisted) | |
| 230 | Text("Private").tag(Visibility.private) | |
| 231 | } | |
| 232 | Toggle("Start build now", isOn: $execute) | |
| 233 | Toggle("Allow build secrets", isOn: $secrets) | |
| 234 | } | |
| 235 | ||
| 236 | Section { | |
| 237 | Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.") | |
| 238 | .font(.footnote) | |
| 239 | .foregroundStyle(.secondary) | |
| 240 | } | |
| 241 | ||
| 242 | if let error = viewModel.error { | |
| 243 | Section { | |
| 244 | Label { | |
| 245 | Text(error) | |
| 246 | } icon: { | |
| 247 | Image(systemName: "exclamationmark.triangle.fill") | |
| 248 | .foregroundStyle(.red) | |
| 249 | } | |
| 250 | .foregroundStyle(.red) | |
| 251 | } | |
| 252 | } | |
| 253 | } | |
| 254 | .navigationTitle("Edit & Resubmit") | |
| 255 | .navigationBarTitleDisplayMode(.inline) | |
| 256 | .onDisappear { | |
| 257 | viewModelBindable.error = nil | |
| 258 | } | |
| 259 | .toolbar { | |
| 260 | ToolbarItem(placement: .cancellationAction) { | |
| 261 | Button("Cancel") { | |
| 262 | viewModelBindable.error = nil | |
| 263 | dismiss() | |
| 264 | } | |
| 265 | } | |
| 266 | ToolbarItem(placement: .confirmationAction) { | |
| 267 | Button { | |
| 268 | Task { | |
| 269 | let tags = tagsText | |
| 270 | .split(separator: ",") | |
| 271 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | |
| 272 | .filter { !$0.isEmpty } | |
| 273 | if let jobId = await viewModel.submitBuild( | |
| 274 | manifest: manifest, | |
| 275 | tags: tags, | |
| 276 | note: note, | |
| 277 | secrets: secrets, | |
| 278 | execute: execute, | |
| 279 | visibility: visibility | |
| 280 | ) { | |
| 281 | onSubmitted(jobId) | |
| 282 | } | |
| 283 | } | |
| 284 | } label: { | |
| 285 | if viewModel.isSubmittingEditedBuild { | |
| 286 | ProgressView() | |
| 287 | .controlSize(.small) | |
| 288 | } else { | |
| 289 | Text("Submit Build") | |
| 290 | } | |
| 291 | } | |
| 292 | .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild) | |
| 293 | } | |
| 294 | } | |
| 295 | } | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | // MARK: - Task Log Section | |
| 300 | ||
| 301 | private struct TaskLogSection: View { | |
| 302 | let task: BuildTask | |
| 303 | let viewModel: BuildDetailViewModel | |
| 304 | ||
| 305 | @State private var isExpanded: Bool | |
| 306 | ||
| 307 | init(task: BuildTask, viewModel: BuildDetailViewModel) { | |
| 308 | self.task = task | |
| 309 | self.viewModel = viewModel | |
| 310 | self._isExpanded = State(initialValue: task.status == .failed) | |
| 311 | } | |
| 312 | ||
| 313 | var body: some View { | |
| 314 | DisclosureGroup(isExpanded: $isExpanded) { | |
| 315 | if viewModel.loadingTaskLogs.contains(task.name) { | |
| 316 | HStack { | |
| 317 | Spacer() | |
| 318 | ProgressView("Loading log…") | |
| 319 | Spacer() | |
| 320 | } | |
| 321 | } else if let logText = viewModel.taskLogs[task.name] { | |
| 322 | ScrollView(.horizontal, showsIndicators: false) { | |
| 323 | Text(logText) | |
| 324 | .font(.caption2.monospaced()) | |
| 325 | .foregroundStyle(.primary) | |
| 326 | .textSelection(.enabled) | |
| 327 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 328 | } | |
| 329 | } else if task.log == nil { | |
| 330 | Text("No log available.") | |
| 331 | .foregroundStyle(.secondary) | |
| 332 | } | |
| 333 | } label: { | |
| 334 | HStack { | |
| 335 | Text(task.status.rawValue) | |
| 336 | .font(.caption) | |
| 337 | .foregroundStyle(.secondary) | |
| 338 | } | |
| 339 | } | |
| 340 | .task { | |
| 341 | if isExpanded { | |
| 342 | await viewModel.loadTaskLog(task: task) | |
| 343 | } | |
| 344 | } | |
| 345 | .onChange(of: isExpanded) { _, expanded in | |
| 346 | if expanded { | |
| 347 | Task { await viewModel.loadTaskLog(task: task) } | |
| 348 | } | |
| 349 | } | |
| 350 | } | |
| 351 | } | |
| 352 | ||
| 353 | // MARK: - Task Status Icon | |
| 354 | ||
| 355 | private struct TaskStatusIcon: View { | |
| 356 | let status: TaskStatus | |
| 357 | ||
| 358 | var body: some View { | |
| 359 | Image(systemName: iconName) | |
| 360 | .foregroundStyle(color) | |
| 361 | .frame(width: 20) | |
| 362 | } | |
| 363 | ||
| 364 | private var iconName: String { | |
| 365 | switch status { | |
| 366 | case .success: "checkmark.circle.fill" | |
| 367 | case .failed: "xmark.circle.fill" | |
| 368 | case .running: "arrow.trianglehead.2.clockwise.rotate.90" | |
| 369 | case .pending: "circle.dashed" | |
| 370 | case .skipped: "forward.circle.fill" | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | private var color: Color { | |
| 375 | switch status { | |
| 376 | case .success: .green | |
| 377 | case .failed: .red | |
| 378 | case .running: .yellow | |
| 379 | case .pending: .gray | |
| 380 | case .skipped: .secondary | |
| 381 | } | |
| 382 | } | |
| 383 | } | |
Hutch/Views/Builds/BuildDetailViewModel.swift added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types | |
| 4 | ||
| 5 | private struct JobDetailResponse: Decodable, Sendable { | |
| 6 | let job: JobDetail | |
| 7 | } | |
| 8 | ||
| 9 | private struct CancelResponse: Decodable, Sendable { | |
| 10 | let cancel: CancelResult | |
| 11 | } | |
| 12 | ||
| 13 | private struct CancelResult: Decodable, Sendable { | |
| 14 | let id: Int | |
| 15 | } | |
| 16 | ||
| 17 | private struct SubmitJobResponse: Decodable, Sendable { | |
| 18 | let submit: SubmittedJob | |
| 19 | } | |
| 20 | ||
| 21 | private struct SubmittedJob: Decodable, Sendable { | |
| 22 | let id: Int | |
| 23 | } | |
| 24 | ||
| 25 | // MARK: - View Model | |
| 26 | ||
| 27 | @Observable | |
| 28 | @MainActor | |
| 29 | final class BuildDetailViewModel { | |
| 30 | ||
| 31 | let jobId: Int | |
| 32 | private let client: SRHTClient | |
| 33 | ||
| 34 | private(set) var job: JobDetail? | |
| 35 | private(set) var isLoading = false | |
| 36 | private(set) var taskLogs: [String: String] = [:] | |
| 37 | private(set) var loadingTaskLogs: Set<String> = [] | |
| 38 | private(set) var isCancelling = false | |
| 39 | private(set) var isRebuilding = false | |
| 40 | private(set) var isSubmittingEditedBuild = false | |
| 41 | var error: String? | |
| 42 | ||
| 43 | init(jobId: Int, client: SRHTClient) { | |
| 44 | self.jobId = jobId | |
| 45 | self.client = client | |
| 46 | } | |
| 47 | ||
| 48 | // MARK: - Queries | |
| 49 | ||
| 50 | private static let detailQuery = """ | |
| 51 | query job($id: Int!) { | |
| 52 | job(id: $id) { | |
| 53 | id | |
| 54 | created | |
| 55 | updated | |
| 56 | status | |
| 57 | note | |
| 58 | tags | |
| 59 | visibility | |
| 60 | image | |
| 61 | manifest | |
| 62 | tasks { name status log { fullURL } } | |
| 63 | log { fullURL } | |
| 64 | owner { canonicalName } | |
| 65 | } | |
| 66 | } | |
| 67 | """ | |
| 68 | ||
| 69 | private static let cancelMutation = """ | |
| 70 | mutation cancel($id: Int!) { | |
| 71 | cancel(jobId: $id) { | |
| 72 | id | |
| 73 | } | |
| 74 | } | |
| 75 | """ | |
| 76 | ||
| 77 | private static let submitMutation = """ | |
| 78 | mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) { | |
| 79 | submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) { | |
| 80 | id | |
| 81 | } | |
| 82 | } | |
| 83 | """ | |
| 84 | ||
| 85 | private static let editableSubmitMutation = """ | |
| 86 | mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { | |
| 87 | submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { | |
| 88 | id | |
| 89 | } | |
| 90 | } | |
| 91 | """ | |
| 92 | ||
| 93 | // MARK: - Public API | |
| 94 | ||
| 95 | func loadJob() async { | |
| 96 | guard !isLoading else { return } | |
| 97 | isLoading = true | |
| 98 | error = nil | |
| 99 | ||
| 100 | do { | |
| 101 | let result = try await client.execute( | |
| 102 | service: .builds, | |
| 103 | query: Self.detailQuery, | |
| 104 | variables: ["id": jobId], | |
| 105 | responseType: JobDetailResponse.self | |
| 106 | ) | |
| 107 | job = result.job | |
| 108 | } catch { | |
| 109 | self.error = error.localizedDescription | |
| 110 | } | |
| 111 | ||
| 112 | isLoading = false | |
| 113 | } | |
| 114 | ||
| 115 | func loadTaskLog(task: BuildTask) async { | |
| 116 | guard let log = task.log, | |
| 117 | let logURL = URL(string: log.fullURL), | |
| 118 | !loadingTaskLogs.contains(task.name), | |
| 119 | taskLogs[task.name] == nil else { return } | |
| 120 | loadingTaskLogs.insert(task.name) | |
| 121 | ||
| 122 | do { | |
| 123 | taskLogs[task.name] = try await client.fetchText(url: logURL) | |
| 124 | } catch { | |
| 125 | self.error = error.localizedDescription | |
| 126 | } | |
| 127 | ||
| 128 | loadingTaskLogs.remove(task.name) | |
| 129 | } | |
| 130 | ||
| 131 | func cancelJob() async { | |
| 132 | guard let job, job.status.isCancellable, !isCancelling else { return } | |
| 133 | isCancelling = true | |
| 134 | error = nil | |
| 135 | ||
| 136 | do { | |
| 137 | _ = try await client.execute( | |
| 138 | service: .builds, | |
| 139 | query: Self.cancelMutation, | |
| 140 | variables: ["id": jobId], | |
| 141 | responseType: CancelResponse.self | |
| 142 | ) | |
| 143 | // Reload job to get updated status. | |
| 144 | await loadJob() | |
| 145 | } catch { | |
| 146 | self.error = error.localizedDescription | |
| 147 | } | |
| 148 | ||
| 149 | isCancelling = false | |
| 150 | } | |
| 151 | ||
| 152 | func rebuildJob() async -> Int? { | |
| 153 | guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else { | |
| 154 | return nil | |
| 155 | } | |
| 156 | ||
| 157 | isRebuilding = true | |
| 158 | error = nil | |
| 159 | defer { isRebuilding = false } | |
| 160 | ||
| 161 | var variables: [String: any Sendable] = [ | |
| 162 | "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 163 | ] | |
| 164 | if !job.tags.isEmpty { | |
| 165 | variables["tags"] = job.tags | |
| 166 | } | |
| 167 | if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 168 | variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 169 | } | |
| 170 | if let visibility = job.visibility { | |
| 171 | variables["visibility"] = visibility.rawValue | |
| 172 | } | |
| 173 | ||
| 174 | do { | |
| 175 | let result = try await client.execute( | |
| 176 | service: .builds, | |
| 177 | query: Self.submitMutation, | |
| 178 | variables: variables, | |
| 179 | responseType: SubmitJobResponse.self | |
| 180 | ) | |
| 181 | return result.submit.id | |
| 182 | } catch { | |
| 183 | self.error = error.localizedDescription | |
| 184 | return nil | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | func submitBuild( | |
| 189 | manifest: String, | |
| 190 | tags: [String], | |
| 191 | note: String, | |
| 192 | secrets: Bool, | |
| 193 | execute: Bool, | |
| 194 | visibility: Visibility | |
| 195 | ) async -> Int? { | |
| 196 | guard !isSubmittingEditedBuild else { return nil } | |
| 197 | ||
| 198 | let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 199 | guard !trimmedManifest.isEmpty else { | |
| 200 | error = "Paste a build manifest." | |
| 201 | return nil | |
| 202 | } | |
| 203 | ||
| 204 | isSubmittingEditedBuild = true | |
| 205 | error = nil | |
| 206 | defer { isSubmittingEditedBuild = false } | |
| 207 | ||
| 208 | var variables: [String: any Sendable] = [ | |
| 209 | "manifest": trimmedManifest, | |
| 210 | "secrets": secrets, | |
| 211 | "execute": execute, | |
| 212 | "visibility": visibility.rawValue | |
| 213 | ] | |
| 214 | if !tags.isEmpty { | |
| 215 | variables["tags"] = tags | |
| 216 | } | |
| 217 | let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 218 | if !trimmedNote.isEmpty { | |
| 219 | variables["note"] = trimmedNote | |
| 220 | } | |
| 221 | ||
| 222 | do { | |
| 223 | let result = try await client.execute( | |
| 224 | service: .builds, | |
| 225 | query: Self.editableSubmitMutation, | |
| 226 | variables: variables, | |
| 227 | responseType: SubmitJobResponse.self | |
| 228 | ) | |
| 229 | return result.submit.id | |
| 230 | } catch { | |
| 231 | self.error = "Couldn’t submit the build. \(error.localizedDescription)" | |
| 232 | return nil | |
| 233 | } | |
| 234 | } | |
| 235 | } | |
Hutch/Views/Builds/BuildListView.swift added +217
| @@ -0,0 +1,217 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct BuildListView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @State private var viewModel: BuildListViewModel? | |
| 6 | @State private var showSubmitSheet = false | |
| 7 | @State private var submittedJobId: Int? | |
| 8 | ||
| 9 | var body: some View { | |
| 10 | Group { | |
| 11 | if let viewModel { | |
| 12 | listContent(viewModel) | |
| 13 | } else { | |
| 14 | SRHTLoadingStateView(message: "Loading builds…") | |
| 15 | } | |
| 16 | } | |
| 17 | .navigationTitle("Builds") | |
| 18 | .toolbar { | |
| 19 | if viewModel != nil { | |
| 20 | ToolbarItem(placement: .topBarTrailing) { | |
| 21 | Button { | |
| 22 | showSubmitSheet = true | |
| 23 | } label: { | |
| 24 | Image(systemName: "plus") | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | .sheet(isPresented: $showSubmitSheet) { | |
| 30 | if let viewModel { | |
| 31 | SubmitBuildSheet(viewModel: viewModel) { jobId in | |
| 32 | showSubmitSheet = false | |
| 33 | submittedJobId = jobId | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
| 37 | .navigationDestination(for: JobSummary.self) { job in | |
| 38 | BuildDetailView(jobId: job.id) | |
| 39 | } | |
| 40 | .navigationDestination(isPresented: Binding( | |
| 41 | get: { submittedJobId != nil }, | |
| 42 | set: { isPresented in | |
| 43 | if !isPresented { | |
| 44 | submittedJobId = nil | |
| 45 | } | |
| 46 | } | |
| 47 | )) { | |
| 48 | if let submittedJobId { | |
| 49 | BuildDetailView(jobId: submittedJobId) | |
| 50 | } | |
| 51 | } | |
| 52 | .task { | |
| 53 | if viewModel == nil { | |
| 54 | let vm = BuildListViewModel(client: appState.client) | |
| 55 | viewModel = vm | |
| 56 | await vm.loadJobs() | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | @ViewBuilder | |
| 62 | private func listContent(_ viewModel: BuildListViewModel) -> some View { | |
| 63 | @Bindable var vm = viewModel | |
| 64 | ||
| 65 | List { | |
| 66 | ForEach(viewModel.jobs) { job in | |
| 67 | NavigationLink(value: job) { | |
| 68 | BuildRowView(job: job) | |
| 69 | } | |
| 70 | .task { | |
| 71 | await viewModel.loadMoreIfNeeded(currentItem: job) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | if viewModel.isLoadingMore { | |
| 76 | HStack { | |
| 77 | Spacer() | |
| 78 | ProgressView() | |
| 79 | Spacer() | |
| 80 | } | |
| 81 | .listRowSeparator(.hidden) | |
| 82 | } | |
| 83 | } | |
| 84 | .listStyle(.plain) | |
| 85 | .overlay { | |
| 86 | if viewModel.isLoading, viewModel.jobs.isEmpty { | |
| 87 | SRHTLoadingStateView(message: "Loading builds…") | |
| 88 | } else if let error = viewModel.error, viewModel.jobs.isEmpty { | |
| 89 | SRHTErrorStateView( | |
| 90 | title: "Couldn't Load Builds", | |
| 91 | message: error, | |
| 92 | retryAction: { await viewModel.loadJobs() } | |
| 93 | ) | |
| 94 | } else if viewModel.jobs.isEmpty, viewModel.error == nil { | |
| 95 | ContentUnavailableView( | |
| 96 | "No Builds", | |
| 97 | systemImage: "hammer", | |
| 98 | description: Text("Your build jobs will appear here.") | |
| 99 | ) | |
| 100 | } | |
| 101 | } | |
| 102 | .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) { | |
| 103 | await viewModel.loadJobs() | |
| 104 | } | |
| 105 | .srhtErrorBanner(error: $vm.error) | |
| 106 | .refreshable { | |
| 107 | await viewModel.loadJobs() | |
| 108 | } | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | private struct SubmitBuildSheet: View { | |
| 113 | let viewModel: BuildListViewModel | |
| 114 | let onSubmitted: (Int) -> Void | |
| 115 | ||
| 116 | @Environment(\.dismiss) private var dismiss | |
| 117 | @Bindable var viewModelBindable: BuildListViewModel | |
| 118 | @State private var manifest = "" | |
| 119 | @State private var tagsText = "" | |
| 120 | @State private var note = "" | |
| 121 | @State private var secrets = false | |
| 122 | @State private var execute = true | |
| 123 | @State private var visibility: Visibility = .public | |
| 124 | ||
| 125 | init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) { | |
| 126 | self.viewModel = viewModel | |
| 127 | self._viewModelBindable = Bindable(viewModel) | |
| 128 | self.onSubmitted = onSubmitted | |
| 129 | } | |
| 130 | ||
| 131 | var body: some View { | |
| 132 | NavigationStack { | |
| 133 | Form { | |
| 134 | Section("Build Manifest") { | |
| 135 | TextField("Paste a build manifest", text: $manifest, axis: .vertical) | |
| 136 | .font(.system(.body, design: .monospaced)) | |
| 137 | .lineLimit(12...24) | |
| 138 | .textInputAutocapitalization(.never) | |
| 139 | .autocorrectionDisabled() | |
| 140 | } | |
| 141 | ||
| 142 | Section("Build Options") { | |
| 143 | TextField("Note (optional)", text: $note) | |
| 144 | TextField("Tags (comma-separated, optional)", text: $tagsText) | |
| 145 | .textInputAutocapitalization(.never) | |
| 146 | .autocorrectionDisabled() | |
| 147 | Picker("Visibility", selection: $visibility) { | |
| 148 | Text("Public").tag(Visibility.public) | |
| 149 | Text("Unlisted").tag(Visibility.unlisted) | |
| 150 | Text("Private").tag(Visibility.private) | |
| 151 | } | |
| 152 | Toggle("Start build now", isOn: $execute) | |
| 153 | Toggle("Allow build secrets", isOn: $secrets) | |
| 154 | } | |
| 155 | ||
| 156 | Section { | |
| 157 | Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.") | |
| 158 | .font(.footnote) | |
| 159 | .foregroundStyle(.secondary) | |
| 160 | } | |
| 161 | ||
| 162 | if let error = viewModel.error { | |
| 163 | Section { | |
| 164 | Label { | |
| 165 | Text(error) | |
| 166 | } icon: { | |
| 167 | Image(systemName: "exclamationmark.triangle.fill") | |
| 168 | .foregroundStyle(.red) | |
| 169 | } | |
| 170 | .foregroundStyle(.red) | |
| 171 | } | |
| 172 | } | |
| 173 | } | |
| 174 | .navigationTitle("Submit Build") | |
| 175 | .navigationBarTitleDisplayMode(.inline) | |
| 176 | .onDisappear { | |
| 177 | viewModelBindable.error = nil | |
| 178 | } | |
| 179 | .toolbar { | |
| 180 | ToolbarItem(placement: .cancellationAction) { | |
| 181 | Button("Cancel") { | |
| 182 | viewModelBindable.error = nil | |
| 183 | dismiss() | |
| 184 | } | |
| 185 | } | |
| 186 | ToolbarItem(placement: .confirmationAction) { | |
| 187 | Button { | |
| 188 | Task { | |
| 189 | let tags = tagsText | |
| 190 | .split(separator: ",") | |
| 191 | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } | |
| 192 | .filter { !$0.isEmpty } | |
| 193 | if let jobId = await viewModel.submitBuild( | |
| 194 | manifest: manifest, | |
| 195 | tags: tags, | |
| 196 | note: note, | |
| 197 | secrets: secrets, | |
| 198 | execute: execute, | |
| 199 | visibility: visibility | |
| 200 | ) { | |
| 201 | onSubmitted(jobId) | |
| 202 | } | |
| 203 | } | |
| 204 | } label: { | |
| 205 | if viewModel.isSubmitting { | |
| 206 | ProgressView() | |
| 207 | .controlSize(.small) | |
| 208 | } else { | |
| 209 | Text("Submit Build") | |
| 210 | } | |
| 211 | } | |
| 212 | .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting) | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
Hutch/Views/Builds/BuildListViewModel.swift added +220
| @@ -0,0 +1,220 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct JobsResponse: Decodable, Sendable { | |
| 6 | let jobs: JobsPage | |
| 7 | } | |
| 8 | ||
| 9 | private struct JobsPage: Decodable, Sendable { | |
| 10 | let results: [JobSummary] | |
| 11 | let cursor: String? | |
| 12 | } | |
| 13 | ||
| 14 | private struct SubmitJobResponse: Decodable, Sendable { | |
| 15 | let submit: SubmittedJob | |
| 16 | } | |
| 17 | ||
| 18 | private struct SubmittedJob: Decodable, Sendable { | |
| 19 | let id: Int | |
| 20 | } | |
| 21 | ||
| 22 | // MARK: - View Model | |
| 23 | ||
| 24 | @Observable | |
| 25 | @MainActor | |
| 26 | final class BuildListViewModel { | |
| 27 | ||
| 28 | private(set) var jobs: [JobSummary] = [] | |
| 29 | private(set) var isLoading = false | |
| 30 | private(set) var isLoadingMore = false | |
| 31 | private(set) var isRefreshing = false | |
| 32 | private(set) var isSubmitting = false | |
| 33 | var error: String? | |
| 34 | ||
| 35 | private var cursor: String? | |
| 36 | private var hasMore = true | |
| 37 | private let client: SRHTClient | |
| 38 | ||
| 39 | private static let cacheKey = "builds.jobs" | |
| 40 | ||
| 41 | init(client: SRHTClient) { | |
| 42 | self.client = client | |
| 43 | } | |
| 44 | ||
| 45 | // MARK: - Query | |
| 46 | ||
| 47 | private static let query = """ | |
| 48 | query jobs($cursor: Cursor) { | |
| 49 | jobs(cursor: $cursor) { | |
| 50 | results { | |
| 51 | id | |
| 52 | created | |
| 53 | updated | |
| 54 | status | |
| 55 | note | |
| 56 | tags | |
| 57 | visibility | |
| 58 | image | |
| 59 | tasks { name status } | |
| 60 | } | |
| 61 | cursor | |
| 62 | } | |
| 63 | } | |
| 64 | """ | |
| 65 | ||
| 66 | private static let submitMutation = """ | |
| 67 | mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) { | |
| 68 | submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) { | |
| 69 | id | |
| 70 | } | |
| 71 | } | |
| 72 | """ | |
| 73 | ||
| 74 | // MARK: - Public API | |
| 75 | ||
| 76 | /// Fetch the first page of jobs. Shows cached data instantly if available, | |
| 77 | /// then refreshes from the network in the background. | |
| 78 | func loadJobs() async { | |
| 79 | // Show cached data immediately on first load | |
| 80 | if jobs.isEmpty { | |
| 81 | loadFromCache() | |
| 82 | } | |
| 83 | ||
| 84 | if jobs.isEmpty { | |
| 85 | isLoading = true | |
| 86 | } else { | |
| 87 | isRefreshing = true | |
| 88 | } | |
| 89 | error = nil | |
| 90 | cursor = nil | |
| 91 | hasMore = true | |
| 92 | ||
| 93 | do { | |
| 94 | let page = try await fetchPage(cursor: nil, useCache: true) | |
| 95 | jobs = page.results | |
| 96 | cursor = page.cursor | |
| 97 | hasMore = page.cursor != nil | |
| 98 | } catch { | |
| 99 | if jobs.isEmpty { | |
| 100 | self.error = error.localizedDescription | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | isLoading = false | |
| 105 | isRefreshing = false | |
| 106 | } | |
| 107 | ||
| 108 | func loadMoreIfNeeded(currentItem: JobSummary) async { | |
| 109 | guard let last = jobs.last, | |
| 110 | last.id == currentItem.id, | |
| 111 | hasMore, | |
| 112 | !isLoadingMore else { | |
| 113 | return | |
| 114 | } | |
| 115 | ||
| 116 | isLoadingMore = true | |
| 117 | ||
| 118 | do { | |
| 119 | let page = try await fetchPage(cursor: cursor, useCache: false) | |
| 120 | jobs.append(contentsOf: page.results) | |
| 121 | cursor = page.cursor | |
| 122 | hasMore = page.cursor != nil | |
| 123 | } catch { | |
| 124 | self.error = error.localizedDescription | |
| 125 | } | |
| 126 | ||
| 127 | isLoadingMore = false | |
| 128 | } | |
| 129 | ||
| 130 | func submitBuild( | |
| 131 | manifest: String, | |
| 132 | tags: [String], | |
| 133 | note: String, | |
| 134 | secrets: Bool, | |
| 135 | execute: Bool, | |
| 136 | visibility: Visibility | |
| 137 | ) async -> Int? { | |
| 138 | guard !isSubmitting else { return nil } | |
| 139 | ||
| 140 | let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 141 | guard !trimmedManifest.isEmpty else { | |
| 142 | error = "Paste a build manifest." | |
| 143 | return nil | |
| 144 | } | |
| 145 | ||
| 146 | isSubmitting = true | |
| 147 | error = nil | |
| 148 | defer { isSubmitting = false } | |
| 149 | ||
| 150 | var variables: [String: any Sendable] = [ | |
| 151 | "manifest": trimmedManifest, | |
| 152 | "secrets": secrets, | |
| 153 | "execute": execute, | |
| 154 | "visibility": visibility.rawValue | |
| 155 | ] | |
| 156 | if !tags.isEmpty { | |
| 157 | variables["tags"] = tags | |
| 158 | } | |
| 159 | let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 160 | if !trimmedNote.isEmpty { | |
| 161 | variables["note"] = trimmedNote | |
| 162 | } | |
| 163 | ||
| 164 | do { | |
| 165 | let result = try await client.execute( | |
| 166 | service: .builds, | |
| 167 | query: Self.submitMutation, | |
| 168 | variables: variables, | |
| 169 | responseType: SubmitJobResponse.self | |
| 170 | ) | |
| 171 | await loadJobs() | |
| 172 | return result.submit.id | |
| 173 | } catch { | |
| 174 | self.error = "Couldn’t submit the build. \(error.localizedDescription)" | |
| 175 | return nil | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | // MARK: - Private | |
| 180 | ||
| 181 | private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage { | |
| 182 | var variables: [String: any Sendable] = [:] | |
| 183 | if let cursor { | |
| 184 | variables["cursor"] = cursor | |
| 185 | } | |
| 186 | ||
| 187 | if useCache && cursor == nil { | |
| 188 | let result = try await client.executeAndCache( | |
| 189 | service: .builds, | |
| 190 | query: Self.query, | |
| 191 | variables: variables.isEmpty ? nil : variables, | |
| 192 | responseType: JobsResponse.self, | |
| 193 | cacheKey: Self.cacheKey | |
| 194 | ) | |
| 195 | return result.jobs | |
| 196 | } else { | |
| 197 | let result = try await client.execute( | |
| 198 | service: .builds, | |
| 199 | query: Self.query, | |
| 200 | variables: variables.isEmpty ? nil : variables, | |
| 201 | responseType: JobsResponse.self | |
| 202 | ) | |
| 203 | return result.jobs | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | private func loadFromCache() { | |
| 208 | guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return } | |
| 209 | let decoder = JSONDecoder() | |
| 210 | decoder.dateDecodingStrategy = .srhtFlexible | |
| 211 | if let response = try? decoder.decode( | |
| 212 | GraphQLResponse<JobsResponse>.self, | |
| 213 | from: data | |
| 214 | ), let page = response.data?.jobs { | |
| 215 | jobs = page.results | |
| 216 | cursor = page.cursor | |
| 217 | hasMore = page.cursor != nil | |
| 218 | } | |
| 219 | } | |
| 220 | } | |
Hutch/Views/Builds/BuildRowView.swift added +106
| @@ -0,0 +1,106 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct BuildRowView: View { | |
| 4 | let job: JobSummary | |
| 5 | ||
| 6 | var body: some View { | |
| 7 | HStack(spacing: 12) { | |
| 8 | JobStatusIcon(status: job.status) | |
| 9 | .frame(width: 28) | |
| 10 | ||
| 11 | VStack(alignment: .leading, spacing: 4) { | |
| 12 | Text(job.displayLabel) | |
| 13 | .font(.subheadline) | |
| 14 | .lineLimit(1) | |
| 15 | ||
| 16 | HStack(spacing: 8) { | |
| 17 | if let image = job.image { | |
| 18 | Text(image) | |
| 19 | .font(.caption) | |
| 20 | .foregroundStyle(.secondary) | |
| 21 | } | |
| 22 | ||
| 23 | Spacer() | |
| 24 | ||
| 25 | Text(job.created.relativeDescription) | |
| 26 | .font(.caption) | |
| 27 | .foregroundStyle(.tertiary) | |
| 28 | } | |
| 29 | ||
| 30 | if !job.tasks.isEmpty { | |
| 31 | TaskProgressView(tasks: job.tasks) | |
| 32 | } | |
| 33 | } | |
| 34 | } | |
| 35 | .padding(.vertical, 2) | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | // MARK: - Job Status Icon | |
| 40 | ||
| 41 | struct JobStatusIcon: View { | |
| 42 | let status: JobStatus | |
| 43 | ||
| 44 | var body: some View { | |
| 45 | Image(systemName: iconName) | |
| 46 | .foregroundStyle(color) | |
| 47 | .symbolEffect(.pulse, isActive: status == .running) | |
| 48 | } | |
| 49 | ||
| 50 | private var iconName: String { | |
| 51 | switch status { | |
| 52 | case .success: "checkmark.circle.fill" | |
| 53 | case .failed, .timeout: "xmark.circle.fill" | |
| 54 | case .running: "arrow.trianglehead.2.clockwise.rotate.90" | |
| 55 | case .queued: "clock.fill" | |
| 56 | case .pending: "circle.dashed" | |
| 57 | case .cancelled: "minus.circle.fill" | |
| 58 | } | |
| 59 | } | |
| 60 | ||
| 61 | private var color: Color { | |
| 62 | switch status { | |
| 63 | case .success: .green | |
| 64 | case .failed, .timeout: .red | |
| 65 | case .running: .yellow | |
| 66 | case .queued: .orange | |
| 67 | case .pending, .cancelled: .gray | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | // MARK: - Task Progress | |
| 73 | ||
| 74 | struct TaskProgressView: View { | |
| 75 | let tasks: [JobTaskSummary] | |
| 76 | ||
| 77 | var body: some View { | |
| 78 | HStack(spacing: 6) { | |
| 79 | ProgressView(value: progress, total: 1.0) | |
| 80 | .tint(progressColor) | |
| 81 | .frame(maxWidth: 80) | |
| 82 | ||
| 83 | Text("\(completedCount)/\(tasks.count) tasks") | |
| 84 | .font(.caption2) | |
| 85 | .foregroundStyle(.secondary) | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | private var completedCount: Int { | |
| 90 | tasks.filter { $0.status == .success }.count | |
| 91 | } | |
| 92 | ||
| 93 | private var progress: Double { | |
| 94 | tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count) | |
| 95 | } | |
| 96 | ||
| 97 | private var progressColor: Color { | |
| 98 | if tasks.contains(where: { $0.status == .failed }) { | |
| 99 | return .red | |
| 100 | } | |
| 101 | if completedCount == tasks.count { | |
| 102 | return .green | |
| 103 | } | |
| 104 | return .blue | |
| 105 | } | |
| 106 | } | |
Hutch/Views/Repositories/ArtifactsView.swift added +73
| @@ -0,0 +1,73 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct ArtifactsView: View { | |
| 4 | let viewModel: RepositoryDetailViewModel | |
| 5 | @Environment(\.openURL) private var openURL | |
| 6 | ||
| 7 | var body: some View { | |
| 8 | List { | |
| 9 | ForEach(viewModel.referenceArtifacts) { refArtifacts in | |
| 10 | Section(refArtifacts.name) { | |
| 11 | ForEach(refArtifacts.artifacts) { artifact in | |
| 12 | ArtifactRow(artifact: artifact) { | |
| 13 | openURL(artifact.url) | |
| 14 | } | |
| 15 | } | |
| 16 | } | |
| 17 | } | |
| 18 | } | |
| 19 | .listStyle(.insetGrouped) | |
| 20 | .overlay { | |
| 21 | if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty { | |
| 22 | SRHTLoadingStateView(message: "Loading artifacts…") | |
| 23 | } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty { | |
| 24 | SRHTErrorStateView( | |
| 25 | title: "Couldn't Load Artifacts", | |
| 26 | message: error, | |
| 27 | retryAction: { await viewModel.loadArtifacts() } | |
| 28 | ) | |
| 29 | } else if viewModel.referenceArtifacts.isEmpty { | |
| 30 | ContentUnavailableView( | |
| 31 | "No Artifacts", | |
| 32 | systemImage: "archivebox", | |
| 33 | description: Text("This repository has no release artifacts.") | |
| 34 | ) | |
| 35 | } | |
| 36 | } | |
| 37 | .task { | |
| 38 | if viewModel.referenceArtifacts.isEmpty { | |
| 39 | await viewModel.loadArtifacts() | |
| 40 | } | |
| 41 | } | |
| 42 | .refreshable { | |
| 43 | await viewModel.loadArtifacts() | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | private struct ArtifactRow: View { | |
| 49 | let artifact: ArtifactInfo | |
| 50 | let onDownload: () -> Void | |
| 51 | ||
| 52 | var body: some View { | |
| 53 | HStack { | |
| 54 | VStack(alignment: .leading, spacing: 4) { | |
| 55 | Text(artifact.filename) | |
| 56 | .font(.subheadline) | |
| 57 | ||
| 58 | Text(artifact.size.formattedByteCount) | |
| 59 | .font(.caption) | |
| 60 | .foregroundStyle(.secondary) | |
| 61 | } | |
| 62 | ||
| 63 | Spacer() | |
| 64 | ||
| 65 | Button { | |
| 66 | onDownload() | |
| 67 | } label: { | |
| 68 | Image(systemName: "arrow.down.circle") | |
| 69 | .imageScale(.large) | |
| 70 | } | |
| 71 | } | |
| 72 | } | |
| 73 | } | |
Hutch/Views/Repositories/CommitDetailView.swift added +332
| @@ -0,0 +1,332 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct CommitDetailView: View { | |
| 4 | let commitSummary: CommitSummary | |
| 5 | let repository: RepositorySummary | |
| 6 | ||
| 7 | @Environment(AppState.self) private var appState | |
| 8 | @State private var viewModel: CommitDetailViewModel? | |
| 9 | ||
| 10 | var body: some View { | |
| 11 | Group { | |
| 12 | if let viewModel { | |
| 13 | commitContent(viewModel) | |
| 14 | } else { | |
| 15 | ProgressView() | |
| 16 | } | |
| 17 | } | |
| 18 | .navigationTitle(commitSummary.shortId) | |
| 19 | .navigationBarTitleDisplayMode(.inline) | |
| 20 | .toolbar { | |
| 21 | ToolbarItem(placement: .topBarTrailing) { | |
| 22 | SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) { | |
| 23 | Image(systemName: "square.and.arrow.up") | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | .task { | |
| 28 | if viewModel == nil { | |
| 29 | let vm = CommitDetailViewModel( | |
| 30 | repositoryRid: repository.rid, | |
| 31 | service: repository.service, | |
| 32 | commitId: commitSummary.id, | |
| 33 | client: appState.client | |
| 34 | ) | |
| 35 | viewModel = vm | |
| 36 | await vm.loadCommit() | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | @ViewBuilder | |
| 42 | private func commitContent(_ viewModel: CommitDetailViewModel) -> some View { | |
| 43 | if viewModel.isLoading { | |
| 44 | ProgressView() | |
| 45 | } else if let error = viewModel.error { | |
| 46 | ContentUnavailableView { | |
| 47 | Label("Error", systemImage: "exclamationmark.triangle") | |
| 48 | } description: { | |
| 49 | Text(error) | |
| 50 | } actions: { | |
| 51 | Button("Retry") { | |
| 52 | Task { await viewModel.loadCommit() } | |
| 53 | } | |
| 54 | } | |
| 55 | } else if let commit = viewModel.commit { | |
| 56 | ScrollView { | |
| 57 | LazyVStack(alignment: .leading, spacing: 0) { | |
| 58 | // Header | |
| 59 | commitHeader(commit) | |
| 60 | ||
| 61 | sectionDivider | |
| 62 | ||
| 63 | // Message | |
| 64 | commitMessage(commit) | |
| 65 | ||
| 66 | // Trailers | |
| 67 | if !commit.trailers.isEmpty { | |
| 68 | sectionDivider | |
| 69 | trailersSection(commit.trailers) | |
| 70 | } | |
| 71 | ||
| 72 | // Parents | |
| 73 | if !commit.parents.isEmpty { | |
| 74 | sectionDivider | |
| 75 | parentsSection(commit.parents) | |
| 76 | } | |
| 77 | ||
| 78 | // Diff | |
| 79 | if let diff = commit.diff, !diff.isEmpty { | |
| 80 | sectionDivider | |
| 81 | diffSection(diff) | |
| 82 | } | |
| 83 | ||
| 84 | // Tree | |
| 85 | if let tree = commit.tree, !tree.entries.results.isEmpty { | |
| 86 | sectionDivider | |
| 87 | treeSection(tree.entries.results) | |
| 88 | } | |
| 89 | } | |
| 90 | } | |
| 91 | .navigationDestination(for: ParentCommit.self) { parent in | |
| 92 | CommitDetailView( | |
| 93 | commitSummary: CommitSummary( | |
| 94 | id: parent.id, | |
| 95 | shortId: parent.shortId, | |
| 96 | author: CommitAuthor(name: parent.author.name, email: nil, time: .now), | |
| 97 | message: "" | |
| 98 | ), | |
| 99 | repository: repository | |
| 100 | ) | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | // MARK: - Header | |
| 106 | ||
| 107 | @ViewBuilder | |
| 108 | private func commitHeader(_ commit: CommitDetail) -> some View { | |
| 109 | VStack(alignment: .leading, spacing: 8) { | |
| 110 | // Full hash — tappable to copy | |
| 111 | Button { | |
| 112 | UIPasteboard.general.string = commit.id | |
| 113 | } label: { | |
| 114 | HStack(spacing: 4) { | |
| 115 | Text(commit.id) | |
| 116 | .font(.caption.monospaced()) | |
| 117 | .lineLimit(1) | |
| 118 | .truncationMode(.middle) | |
| 119 | Image(systemName: "doc.on.doc") | |
| 120 | .font(.caption2) | |
| 121 | } | |
| 122 | .foregroundStyle(.secondary) | |
| 123 | } | |
| 124 | ||
| 125 | // Author | |
| 126 | HStack { | |
| 127 | Label(commit.author.name, systemImage: "person") | |
| 128 | Spacer() | |
| 129 | Text(commit.author.time.relativeDescription) | |
| 130 | .foregroundStyle(.secondary) | |
| 131 | } | |
| 132 | .font(.subheadline) | |
| 133 | ||
| 134 | // Committer (if different from author) | |
| 135 | if commit.committer.name != commit.author.name | |
| 136 | || commit.committer.email != commit.author.email { | |
| 137 | HStack { | |
| 138 | Label(commit.committer.name, systemImage: "person.badge.shield.checkmark") | |
| 139 | Spacer() | |
| 140 | Text(commit.committer.time.relativeDescription) | |
| 141 | .foregroundStyle(.secondary) | |
| 142 | } | |
| 143 | .font(.subheadline) | |
| 144 | .foregroundStyle(.secondary) | |
| 145 | } | |
| 146 | } | |
| 147 | .padding() | |
| 148 | } | |
| 149 | ||
| 150 | // MARK: - Message | |
| 151 | ||
| 152 | @ViewBuilder | |
| 153 | private func commitMessage(_ commit: CommitDetail) -> some View { | |
| 154 | VStack(alignment: .leading, spacing: 8) { | |
| 155 | Text("Message") | |
| 156 | .font(.caption.weight(.semibold)) | |
| 157 | .foregroundStyle(.secondary) | |
| 158 | .textCase(.uppercase) | |
| 159 | ||
| 160 | Text(commit.title) | |
| 161 | .font(.headline) | |
| 162 | ||
| 163 | if let body = commit.body { | |
| 164 | Text(body) | |
| 165 | .font(.subheadline.monospaced()) | |
| 166 | .foregroundStyle(.secondary) | |
| 167 | } | |
| 168 | } | |
| 169 | .padding() | |
| 170 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 171 | } | |
| 172 | ||
| 173 | // MARK: - Trailers | |
| 174 | ||
| 175 | @ViewBuilder | |
| 176 | private func trailersSection(_ trailers: [CommitTrailer]) -> some View { | |
| 177 | VStack(alignment: .leading, spacing: 8) { | |
| 178 | Text("Trailers") | |
| 179 | .font(.caption.weight(.semibold)) | |
| 180 | .foregroundStyle(.secondary) | |
| 181 | .textCase(.uppercase) | |
| 182 | ||
| 183 | ForEach(trailers) { trailer in | |
| 184 | HStack(alignment: .top, spacing: 4) { | |
| 185 | Text("\(trailer.name):") | |
| 186 | .font(.subheadline.monospaced().weight(.medium)) | |
| 187 | Text(trailer.value) | |
| 188 | .font(.subheadline.monospaced()) | |
| 189 | .foregroundStyle(.secondary) | |
| 190 | } | |
| 191 | } | |
| 192 | } | |
| 193 | .padding() | |
| 194 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 195 | } | |
| 196 | ||
| 197 | // MARK: - Parents | |
| 198 | ||
| 199 | @ViewBuilder | |
| 200 | private func parentsSection(_ parents: [ParentCommit]) -> some View { | |
| 201 | VStack(alignment: .leading, spacing: 8) { | |
| 202 | Text("Parents") | |
| 203 | .font(.caption.weight(.semibold)) | |
| 204 | .foregroundStyle(.secondary) | |
| 205 | .textCase(.uppercase) | |
| 206 | ||
| 207 | ForEach(parents) { parent in | |
| 208 | NavigationLink(value: parent) { | |
| 209 | HStack { | |
| 210 | Text(parent.shortId) | |
| 211 | .font(.subheadline.monospaced()) | |
| 212 | Text(parent.author.name) | |
| 213 | .font(.subheadline) | |
| 214 | .foregroundStyle(.secondary) | |
| 215 | Spacer() | |
| 216 | Image(systemName: "chevron.right") | |
| 217 | .font(.caption) | |
| 218 | .foregroundStyle(.tertiary) | |
| 219 | } | |
| 220 | } | |
| 221 | .buttonStyle(.plain) | |
| 222 | } | |
| 223 | } | |
| 224 | .padding() | |
| 225 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 226 | } | |
| 227 | ||
| 228 | // MARK: - Diff | |
| 229 | ||
| 230 | @ViewBuilder | |
| 231 | private func diffSection(_ diff: String) -> some View { | |
| 232 | VStack(alignment: .leading, spacing: 8) { | |
| 233 | Text("Diff") | |
| 234 | .font(.caption.weight(.semibold)) | |
| 235 | .foregroundStyle(.secondary) | |
| 236 | .textCase(.uppercase) | |
| 237 | .padding(.horizontal) | |
| 238 | .padding(.top) | |
| 239 | ||
| 240 | DiffView(diff: invertDiff(diff)) | |
| 241 | .padding(.bottom) | |
| 242 | } | |
| 243 | } | |
| 244 | ||
| 245 | /// The sr.ht API returns diffs comparing current→parent (inverted). | |
| 246 | /// This swaps +/- prefixes so the diff reads as parent→current. | |
| 247 | private func invertDiff(_ diff: String) -> String { | |
| 248 | diff.split(separator: "\n", omittingEmptySubsequences: false) | |
| 249 | .map { line in | |
| 250 | let s = String(line) | |
| 251 | if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") { | |
| 252 | return s | |
| 253 | } | |
| 254 | if s.hasPrefix("---") { | |
| 255 | return "+++" + s.dropFirst(3) | |
| 256 | } | |
| 257 | if s.hasPrefix("+++") { | |
| 258 | return "---" + s.dropFirst(3) | |
| 259 | } | |
| 260 | if s.hasPrefix("+") { | |
| 261 | return "-" + s.dropFirst(1) | |
| 262 | } | |
| 263 | if s.hasPrefix("-") { | |
| 264 | return "+" + s.dropFirst(1) | |
| 265 | } | |
| 266 | return s | |
| 267 | } | |
| 268 | .joined(separator: "\n") | |
| 269 | } | |
| 270 | ||
| 271 | // MARK: - Tree | |
| 272 | ||
| 273 | @ViewBuilder | |
| 274 | private func treeSection(_ entries: [CommitTreeEntry]) -> some View { | |
| 275 | VStack(alignment: .leading, spacing: 8) { | |
| 276 | Text("Tree") | |
| 277 | .font(.caption.weight(.semibold)) | |
| 278 | .foregroundStyle(.secondary) | |
| 279 | .textCase(.uppercase) | |
| 280 | ||
| 281 | ForEach(entries) { entry in | |
| 282 | HStack(spacing: 8) { | |
| 283 | Image(systemName: treeEntryIcon(for: entry)) | |
| 284 | .foregroundStyle(treeEntryColor(for: entry)) | |
| 285 | .frame(width: 20) | |
| 286 | Text(entry.name) | |
| 287 | .font(.subheadline.monospaced()) | |
| 288 | Spacer() | |
| 289 | if let obj = entry.object, let shortId = obj.shortId { | |
| 290 | Text(shortId) | |
| 291 | .font(.caption.monospaced()) | |
| 292 | .foregroundStyle(.tertiary) | |
| 293 | } | |
| 294 | } | |
| 295 | } | |
| 296 | } | |
| 297 | .padding() | |
| 298 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 299 | } | |
| 300 | ||
| 301 | // MARK: - Helpers | |
| 302 | ||
| 303 | private var sectionDivider: some View { | |
| 304 | Divider().padding(.horizontal) | |
| 305 | } | |
| 306 | ||
| 307 | private func treeEntryIcon(for entry: CommitTreeEntry) -> String { | |
| 308 | guard let type = entry.object?.type else { | |
| 309 | return "doc" | |
| 310 | } | |
| 311 | switch type { | |
| 312 | case "tree": return "folder" | |
| 313 | case "blob": return "doc.text" | |
| 314 | case "tag": return "tag" | |
| 315 | case "commit": return "arrow.triangle.branch" | |
| 316 | default: return "doc" | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | private func treeEntryColor(for entry: CommitTreeEntry) -> Color { | |
| 321 | guard let type = entry.object?.type else { | |
| 322 | return .secondary | |
| 323 | } | |
| 324 | switch type { | |
| 325 | case "tree": return .blue | |
| 326 | case "blob": return .secondary | |
| 327 | case "tag": return .orange | |
| 328 | case "commit": return .purple | |
| 329 | default: return .secondary | |
| 330 | } | |
| 331 | } | |
| 332 | } | |
Hutch/Views/Repositories/CommitDetailViewModel.swift added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct CommitResponse: Decodable, Sendable { | |
| 6 | let repository: CommitRepository? | |
| 7 | } | |
| 8 | ||
| 9 | private struct CommitRepository: Decodable, Sendable { | |
| 10 | // swiftlint:disable:next identifier_name | |
| 11 | let revparse_single: CommitDetail | |
| 12 | } | |
| 13 | ||
| 14 | // MARK: - View Model | |
| 15 | ||
| 16 | @Observable | |
| 17 | @MainActor | |
| 18 | final class CommitDetailViewModel { | |
| 19 | ||
| 20 | let repositoryRid: String | |
| 21 | let service: SRHTService | |
| 22 | private let client: SRHTClient | |
| 23 | ||
| 24 | private(set) var commit: CommitDetail? | |
| 25 | private(set) var isLoading = false | |
| 26 | var error: String? | |
| 27 | ||
| 28 | init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) { | |
| 29 | self.repositoryRid = repositoryRid | |
| 30 | self.service = service | |
| 31 | self.commitId = commitId | |
| 32 | self.client = client | |
| 33 | } | |
| 34 | ||
| 35 | private let commitId: String | |
| 36 | ||
| 37 | // MARK: - Query | |
| 38 | ||
| 39 | private static let query = """ | |
| 40 | query commit($rid: ID!, $id: String!) { | |
| 41 | repository(rid: $rid) { | |
| 42 | revparse_single(revspec: $id) { | |
| 43 | id | |
| 44 | shortId | |
| 45 | author { name email time } | |
| 46 | committer { name email time } | |
| 47 | message | |
| 48 | diff | |
| 49 | trailers { name value } | |
| 50 | parents { id shortId author { name } } | |
| 51 | tree { | |
| 52 | entries { | |
| 53 | results { id name mode object { type id shortId } } | |
| 54 | cursor | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | """ | |
| 61 | ||
| 62 | func loadCommit() async { | |
| 63 | guard !isLoading else { return } | |
| 64 | isLoading = true | |
| 65 | error = nil | |
| 66 | ||
| 67 | do { | |
| 68 | let result = try await executeWithRetry() | |
| 69 | commit = result.repository?.revparse_single | |
| 70 | } catch { | |
| 71 | self.error = error.localizedDescription | |
| 72 | } | |
| 73 | ||
| 74 | isLoading = false | |
| 75 | } | |
| 76 | ||
| 77 | /// Execute the commit query, retrying once after a 1-second delay on 502/503. | |
| 78 | private func executeWithRetry() async throws -> CommitResponse { | |
| 79 | do { | |
| 80 | return try await client.execute( | |
| 81 | service: service, | |
| 82 | query: Self.query, | |
| 83 | variables: [ | |
| 84 | "rid": repositoryRid, | |
| 85 | "id": commitId | |
| 86 | ], | |
| 87 | responseType: CommitResponse.self | |
| 88 | ) | |
| 89 | } catch let SRHTError.httpError(code) where code == 502 || code == 503 { | |
| 90 | try await Task.sleep(for: .seconds(1)) | |
| 91 | return try await client.execute( | |
| 92 | service: service, | |
| 93 | query: Self.query, | |
| 94 | variables: [ | |
| 95 | "rid": repositoryRid, | |
| 96 | "id": commitId | |
| 97 | ], | |
| 98 | responseType: CommitResponse.self | |
| 99 | ) | |
| 100 | } | |
| 101 | } | |
| 102 | } | |
Hutch/Views/Repositories/CommitLogView.swift added +59
| @@ -0,0 +1,59 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct CommitLogView: View { | |
| 4 | let viewModel: RepositoryDetailViewModel | |
| 5 | ||
| 6 | var body: some View { | |
| 7 | List { | |
| 8 | ForEach(viewModel.commits) { commit in | |
| 9 | NavigationLink(value: commit) { | |
| 10 | CommitRowView(commit: commit) | |
| 11 | } | |
| 12 | .task { | |
| 13 | await viewModel.loadMoreCommitsIfNeeded(currentItem: commit) | |
| 14 | } | |
| 15 | } | |
| 16 | ||
| 17 | if viewModel.isLoadingMoreCommits { | |
| 18 | HStack { | |
| 19 | Spacer() | |
| 20 | ProgressView() | |
| 21 | Spacer() | |
| 22 | } | |
| 23 | .listRowSeparator(.hidden) | |
| 24 | } | |
| 25 | } | |
| 26 | .listStyle(.plain) | |
| 27 | .overlay { | |
| 28 | if viewModel.isLoadingCommits, viewModel.commits.isEmpty { | |
| 29 | SRHTLoadingStateView(message: "Loading commits…") | |
| 30 | } else if let error = viewModel.error, viewModel.commits.isEmpty { | |
| 31 | SRHTErrorStateView( | |
| 32 | title: "Couldn't Load Commits", | |
| 33 | message: error, | |
| 34 | retryAction: { await viewModel.loadCommits() } | |
| 35 | ) | |
| 36 | } else if viewModel.commits.isEmpty { | |
| 37 | ContentUnavailableView( | |
| 38 | "No Commits", | |
| 39 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", | |
| 40 | description: Text("This repository has no commit history.") | |
| 41 | ) | |
| 42 | } | |
| 43 | } | |
| 44 | .task { | |
| 45 | if viewModel.commits.isEmpty { | |
| 46 | await viewModel.loadCommits() | |
| 47 | } | |
| 48 | } | |
| 49 | .refreshable { | |
| 50 | await viewModel.loadCommits() | |
| 51 | } | |
| 52 | .navigationDestination(for: CommitSummary.self) { commit in | |
| 53 | CommitDetailView( | |
| 54 | commitSummary: commit, | |
| 55 | repository: viewModel.repository | |
| 56 | ) | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
Hutch/Views/Repositories/CommitRowView.swift added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct CommitRowView: View { | |
| 4 | let commit: CommitSummary | |
| 5 | ||
| 6 | var body: some View { | |
| 7 | VStack(alignment: .leading, spacing: 4) { | |
| 8 | Text(commit.title) | |
| 9 | .font(.subheadline) | |
| 10 | .lineLimit(1) | |
| 11 | ||
| 12 | HStack(spacing: 8) { | |
| 13 | Text(commit.shortId) | |
| 14 | .font(.caption.monospaced()) | |
| 15 | .foregroundStyle(.secondary) | |
| 16 | ||
| 17 | Text(commit.author.name) | |
| 18 | .font(.caption) | |
| 19 | .foregroundStyle(.secondary) | |
| 20 | ||
| 21 | Spacer() | |
| 22 | ||
| 23 | Text(commit.author.time.relativeDescription) | |
| 24 | .font(.caption) | |
| 25 | .foregroundStyle(.tertiary) | |
| 26 | } | |
| 27 | } | |
| 28 | .padding(.vertical, 2) | |
| 29 | } | |
| 30 | } | |
Hutch/Views/Repositories/DiffView.swift added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Renders a unified diff string with syntax highlighting: | |
| 4 | /// - Green background for added lines (+) | |
| 5 | /// - Red background for removed lines (-) | |
| 6 | /// - Gray for hunk headers (@@) | |
| 7 | /// - File headers (--- / +++ / diff) in bold | |
| 8 | struct DiffView: View { | |
| 9 | let diff: String | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | let lines = diff.components(separatedBy: "\n") | |
| 13 | ||
| 14 | LazyVStack(alignment: .leading, spacing: 0) { | |
| 15 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in | |
| 16 | DiffLineView(line: line) | |
| 17 | } | |
| 18 | } | |
| 19 | .font(.caption.monospaced()) | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | private struct DiffLineView: View { | |
| 24 | let line: String | |
| 25 | ||
| 26 | var body: some View { | |
| 27 | Text(line.isEmpty ? " " : line) | |
| 28 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 29 | .padding(.horizontal, 8) | |
| 30 | .padding(.vertical, 1) | |
| 31 | .background(backgroundColor) | |
| 32 | .foregroundStyle(foregroundColor) | |
| 33 | .fontWeight(isHeader ? .semibold : .regular) | |
| 34 | } | |
| 35 | ||
| 36 | private var kind: DiffLineKind { | |
| 37 | if line.hasPrefix("@@") { return .hunk } | |
| 38 | if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader } | |
| 39 | if line.hasPrefix("diff ") { return .fileHeader } | |
| 40 | if line.hasPrefix("index ") { return .meta } | |
| 41 | if line.hasPrefix("+") { return .added } | |
| 42 | if line.hasPrefix("-") { return .removed } | |
| 43 | return .context | |
| 44 | } | |
| 45 | ||
| 46 | private var backgroundColor: Color { | |
| 47 | switch kind { | |
| 48 | case .added: .green.opacity(0.15) | |
| 49 | case .removed: .red.opacity(0.15) | |
| 50 | case .hunk: .gray.opacity(0.12) | |
| 51 | case .fileHeader: .gray.opacity(0.08) | |
| 52 | case .meta: .gray.opacity(0.05) | |
| 53 | case .context: .clear | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | private var foregroundColor: Color { | |
| 58 | switch kind { | |
| 59 | case .added: .green | |
| 60 | case .removed: .red | |
| 61 | case .hunk: .secondary | |
| 62 | case .meta: .secondary | |
| 63 | default: .primary | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | private var isHeader: Bool { | |
| 68 | kind == .fileHeader | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | private enum DiffLineKind { | |
| 73 | case added | |
| 74 | case removed | |
| 75 | case hunk | |
| 76 | case fileHeader | |
| 77 | case meta | |
| 78 | case context | |
| 79 | } | |
Hutch/Views/Repositories/FileTreeView.swift added +446
| @@ -0,0 +1,446 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct FileTreeView: View { | |
| 4 | let repository: RepositorySummary | |
| 5 | let client: SRHTClient | |
| 6 | ||
| 7 | @State private var viewModel: FileTreeViewModel? | |
| 8 | ||
| 9 | var body: some View { | |
| 10 | Group { | |
| 11 | if let viewModel { | |
| 12 | FileTreeContentView(repository: repository, viewModel: viewModel) | |
| 13 | } else { | |
| 14 | SRHTLoadingStateView(message: "Loading files…") | |
| 15 | } | |
| 16 | } | |
| 17 | .task { | |
| 18 | if viewModel == nil { | |
| 19 | let vm = FileTreeViewModel( | |
| 20 | repositoryRid: repository.rid, | |
| 21 | service: repository.service, | |
| 22 | client: client | |
| 23 | ) | |
| 24 | viewModel = vm | |
| 25 | async let loadTree: () = vm.loadRootTree() | |
| 26 | async let loadRefs: () = vm.loadReferences() | |
| 27 | _ = await (loadTree, loadRefs) | |
| 28 | } | |
| 29 | } | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | // MARK: - Content View | |
| 34 | ||
| 35 | private struct FileTreeContentView: View { | |
| 36 | let repository: RepositorySummary | |
| 37 | let viewModel: FileTreeViewModel | |
| 38 | ||
| 39 | @State private var showRefPicker = false | |
| 40 | ||
| 41 | var body: some View { | |
| 42 | VStack(spacing: 0) { | |
| 43 | breadcrumbBar | |
| 44 | Divider() | |
| 45 | contentArea | |
| 46 | } | |
| 47 | .toolbar { | |
| 48 | ToolbarItem(placement: .topBarTrailing) { | |
| 49 | Button { | |
| 50 | showRefPicker = true | |
| 51 | } label: { | |
| 52 | Label( | |
| 53 | revspecLabel, | |
| 54 | systemImage: "arrow.triangle.branch" | |
| 55 | ) | |
| 56 | .font(.subheadline) | |
| 57 | } | |
| 58 | } | |
| 59 | } | |
| 60 | .sheet(isPresented: $showRefPicker) { | |
| 61 | RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker) | |
| 62 | } | |
| 63 | .srhtErrorBanner(error: Binding( | |
| 64 | get: { viewModel.error }, | |
| 65 | set: { viewModel.error = $0 } | |
| 66 | )) | |
| 67 | .refreshable { | |
| 68 | await viewModel.loadRootTree() | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | private var shareURL: URL? { | |
| 73 | guard let viewingEntry = viewModel.viewingEntry else { return nil } | |
| 74 | return SRHTWebURL.file( | |
| 75 | repository: repository, | |
| 76 | revspec: viewModel.revspec, | |
| 77 | path: currentFilePath(for: viewingEntry) | |
| 78 | ) | |
| 79 | } | |
| 80 | ||
| 81 | private var revspecLabel: String { | |
| 82 | let revspec = viewModel.revspec | |
| 83 | if revspec == "HEAD" { | |
| 84 | return "HEAD" | |
| 85 | } | |
| 86 | if revspec.hasPrefix("refs/heads/") { | |
| 87 | return String(revspec.dropFirst("refs/heads/".count)) | |
| 88 | } else if revspec.hasPrefix("refs/tags/") { | |
| 89 | return String(revspec.dropFirst("refs/tags/".count)) | |
| 90 | } | |
| 91 | return revspec | |
| 92 | } | |
| 93 | ||
| 94 | // MARK: - Breadcrumb Bar | |
| 95 | ||
| 96 | private var breadcrumbBar: some View { | |
| 97 | ScrollView(.horizontal, showsIndicators: false) { | |
| 98 | HStack(spacing: 12) { | |
| 99 | HStack(spacing: 4) { | |
| 100 | ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in | |
| 101 | if index > 0 { | |
| 102 | Image(systemName: "chevron.right") | |
| 103 | .font(.caption2) | |
| 104 | .foregroundStyle(.tertiary) | |
| 105 | } | |
| 106 | ||
| 107 | Button { | |
| 108 | Task { | |
| 109 | await viewModel.navigateToBreadcrumb(at: index) | |
| 110 | } | |
| 111 | } label: { | |
| 112 | Text(navEntry.name) | |
| 113 | .font(.subheadline.monospaced()) | |
| 114 | .foregroundStyle( | |
| 115 | index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil | |
| 116 | ? .primary : .secondary | |
| 117 | ) | |
| 118 | } | |
| 119 | .buttonStyle(.plain) | |
| 120 | } | |
| 121 | ||
| 122 | if let viewing = viewModel.viewingEntry { | |
| 123 | Image(systemName: "chevron.right") | |
| 124 | .font(.caption2) | |
| 125 | .foregroundStyle(.tertiary) | |
| 126 | ||
| 127 | Text(viewing.name) | |
| 128 | .font(.subheadline.monospaced()) | |
| 129 | .foregroundStyle(.primary) | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | Spacer(minLength: 0) | |
| 134 | } | |
| 135 | .padding(.horizontal) | |
| 136 | .padding(.vertical, 8) | |
| 137 | } | |
| 138 | .background(.bar) | |
| 139 | } | |
| 140 | ||
| 141 | private func currentFilePath(for entry: TreeEntry) -> String { | |
| 142 | let directoryComponents = viewModel.navStack | |
| 143 | .dropFirst() | |
| 144 | .map(\.name) | |
| 145 | return (directoryComponents + [entry.name]).joined(separator: "/") | |
| 146 | } | |
| 147 | ||
| 148 | // MARK: - Content Area | |
| 149 | ||
| 150 | @ViewBuilder | |
| 151 | private var contentArea: some View { | |
| 152 | if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil { | |
| 153 | SRHTLoadingStateView(message: "Loading files…") | |
| 154 | } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject { | |
| 155 | // Viewing a file | |
| 156 | fileContentView(entry: entry, object: object) | |
| 157 | } else if let error = viewModel.error, viewModel.entries.isEmpty { | |
| 158 | SRHTErrorStateView( | |
| 159 | title: "Couldn't Load Files", | |
| 160 | message: error, | |
| 161 | retryAction: { await viewModel.loadRootTree() } | |
| 162 | ) | |
| 163 | } else if !viewModel.entries.isEmpty { | |
| 164 | // Viewing a directory listing | |
| 165 | treeListView | |
| 166 | } else if viewModel.navStack.isEmpty { | |
| 167 | ContentUnavailableView( | |
| 168 | "No Files", | |
| 169 | systemImage: "folder", | |
| 170 | description: Text("This repository could not be loaded.") | |
| 171 | ) | |
| 172 | } else { | |
| 173 | ContentUnavailableView( | |
| 174 | "Empty Directory", | |
| 175 | systemImage: "folder", | |
| 176 | description: Text("This directory has no files.") | |
| 177 | ) | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | // MARK: - File Content View | |
| 182 | ||
| 183 | @ViewBuilder | |
| 184 | private func fileContentView(entry: TreeEntry, object: GitObject) -> some View { | |
| 185 | switch object { | |
| 186 | case .textBlob(let blob): | |
| 187 | textBlobView(entry: entry, blob: blob) | |
| 188 | case .binaryBlob(let blob): | |
| 189 | binaryBlobView(entry: entry, blob: blob) | |
| 190 | default: | |
| 191 | ContentUnavailableView( | |
| 192 | "Unknown Object", | |
| 193 | systemImage: "questionmark.folder", | |
| 194 | description: Text("Cannot display this object type.") | |
| 195 | ) | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | // MARK: - Tree List | |
| 200 | ||
| 201 | private var treeListView: some View { | |
| 202 | let sorted = viewModel.entries.sorted { a, b in | |
| 203 | let aIsTree = a.object?.isTree == true | |
| 204 | let bIsTree = b.object?.isTree == true | |
| 205 | if aIsTree != bIsTree { return aIsTree } | |
| 206 | return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending | |
| 207 | } | |
| 208 | ||
| 209 | return List(sorted) { entry in | |
| 210 | TreeEntryRow(entry: entry) | |
| 211 | .contentShape(Rectangle()) | |
| 212 | .onTapGesture { | |
| 213 | Task { | |
| 214 | await viewModel.navigateInto(entry: entry) | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | .listStyle(.plain) | |
| 219 | } | |
| 220 | ||
| 221 | // MARK: - Text Blob | |
| 222 | ||
| 223 | @ViewBuilder | |
| 224 | private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View { | |
| 225 | VStack(spacing: 0) { | |
| 226 | HStack { | |
| 227 | Spacer() | |
| 228 | SRHTShareButton(url: shareURL, target: .file) { | |
| 229 | Label("Share File", systemImage: "square.and.arrow.up") | |
| 230 | } | |
| 231 | .buttonStyle(.bordered) | |
| 232 | } | |
| 233 | .padding(.horizontal) | |
| 234 | .padding(.top, 12) | |
| 235 | ||
| 236 | GeometryReader { geometry in | |
| 237 | ScrollView([.vertical, .horizontal]) { | |
| 238 | Text(blob.text) | |
| 239 | .font(.system(.body, design: .monospaced)) | |
| 240 | .multilineTextAlignment(.leading) | |
| 241 | .fixedSize(horizontal: true, vertical: false) | |
| 242 | .frame(minWidth: geometry.size.width, | |
| 243 | minHeight: geometry.size.height, | |
| 244 | alignment: .topLeading) | |
| 245 | .padding() | |
| 246 | } | |
| 247 | .frame(width: geometry.size.width, height: geometry.size.height) | |
| 248 | } | |
| 249 | } | |
| 250 | } | |
| 251 | ||
| 252 | // MARK: - Binary Blob | |
| 253 | ||
| 254 | @ViewBuilder | |
| 255 | private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View { | |
| 256 | VStack(spacing: 16) { | |
| 257 | Spacer() | |
| 258 | ||
| 259 | Image(systemName: "doc.zipper") | |
| 260 | .font(.system(size: 48)) | |
| 261 | .foregroundStyle(.secondary) | |
| 262 | ||
| 263 | Text(entry.name) | |
| 264 | .font(.headline) | |
| 265 | ||
| 266 | if let size = blob.size { | |
| 267 | Text(formatBytes(size)) | |
| 268 | .font(.subheadline) | |
| 269 | .foregroundStyle(.secondary) | |
| 270 | } | |
| 271 | ||
| 272 | Text("Binary file — cannot be displayed inline.") | |
| 273 | .font(.subheadline) | |
| 274 | .foregroundStyle(.tertiary) | |
| 275 | ||
| 276 | SRHTShareButton(url: shareURL, target: .file) { | |
| 277 | Label("Share File", systemImage: "square.and.arrow.up") | |
| 278 | } | |
| 279 | .buttonStyle(.bordered) | |
| 280 | ||
| 281 | if let content = blob.content, let url = URL(string: content) { | |
| 282 | Link(destination: url) { | |
| 283 | Label("Open in Safari", systemImage: "safari") | |
| 284 | } | |
| 285 | .buttonStyle(.borderedProminent) | |
| 286 | } | |
| 287 | ||
| 288 | Button { | |
| 289 | viewModel.dismissFileView() | |
| 290 | } label: { | |
| 291 | Text("Back to directory") | |
| 292 | } | |
| 293 | ||
| 294 | Spacer() | |
| 295 | } | |
| 296 | .frame(maxWidth: .infinity) | |
| 297 | } | |
| 298 | ||
| 299 | // MARK: - Helpers | |
| 300 | ||
| 301 | private func formatBytes(_ bytes: Int) -> String { | |
| 302 | let formatter = ByteCountFormatter() | |
| 303 | formatter.countStyle = .file | |
| 304 | return formatter.string(fromByteCount: Int64(bytes)) | |
| 305 | } | |
| 306 | } | |
| 307 | ||
| 308 | // MARK: - Tree Entry Row | |
| 309 | ||
| 310 | private struct TreeEntryRow: View { | |
| 311 | let entry: TreeEntry | |
| 312 | ||
| 313 | var body: some View { | |
| 314 | Label { | |
| 315 | Text(entry.name) | |
| 316 | .font(.body.monospaced()) | |
| 317 | .lineLimit(1) | |
| 318 | } icon: { | |
| 319 | Image(systemName: iconName) | |
| 320 | .foregroundStyle(iconColor) | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | private var iconName: String { | |
| 325 | switch entry.object { | |
| 326 | case .tree: "folder.fill" | |
| 327 | case .unknown: "questionmark.circle" | |
| 328 | default: "doc" | |
| 329 | } | |
| 330 | } | |
| 331 | ||
| 332 | private var iconColor: Color { | |
| 333 | switch entry.object { | |
| 334 | case .tree: .blue | |
| 335 | case .unknown: .orange | |
| 336 | default: .secondary | |
| 337 | } | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | // MARK: - Ref Picker Sheet | |
| 342 | ||
| 343 | private struct RefPickerSheet: View { | |
| 344 | let viewModel: FileTreeViewModel | |
| 345 | @Binding var isPresented: Bool | |
| 346 | ||
| 347 | var body: some View { | |
| 348 | NavigationStack { | |
| 349 | List { | |
| 350 | Section { | |
| 351 | Button { | |
| 352 | Task { | |
| 353 | await viewModel.changeRevspec("HEAD") | |
| 354 | isPresented = false | |
| 355 | } | |
| 356 | } label: { | |
| 357 | refRow( | |
| 358 | title: "HEAD", | |
| 359 | systemImage: "arrow.triangle.branch", | |
| 360 | color: .blue, | |
| 361 | isSelected: viewModel.revspec == "HEAD" | |
| 362 | ) | |
| 363 | } | |
| 364 | .buttonStyle(.plain) | |
| 365 | } | |
| 366 | ||
| 367 | if !viewModel.branches.isEmpty { | |
| 368 | Section("Branches") { | |
| 369 | ForEach(viewModel.branches, id: \.name) { ref in | |
| 370 | Button { | |
| 371 | Task { | |
| 372 | await viewModel.changeRevspec(ref.name) | |
| 373 | isPresented = false | |
| 374 | } | |
| 375 | } label: { | |
| 376 | refRow( | |
| 377 | title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""), | |
| 378 | systemImage: "arrow.triangle.branch", | |
| 379 | color: .blue, | |
| 380 | isSelected: viewModel.revspec == ref.name | |
| 381 | ) | |
| 382 | } | |
| 383 | .buttonStyle(.plain) | |
| 384 | } | |
| 385 | } | |
| 386 | } | |
| 387 | ||
| 388 | if !viewModel.tags.isEmpty { | |
| 389 | Section("Tags") { | |
| 390 | ForEach(viewModel.tags, id: \.name) { ref in | |
| 391 | Button { | |
| 392 | Task { | |
| 393 | await viewModel.changeRevspec(ref.name) | |
| 394 | isPresented = false | |
| 395 | } | |
| 396 | } label: { | |
| 397 | refRow( | |
| 398 | title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""), | |
| 399 | systemImage: "tag", | |
| 400 | color: .orange, | |
| 401 | isSelected: viewModel.revspec == ref.name | |
| 402 | ) | |
| 403 | } | |
| 404 | .buttonStyle(.plain) | |
| 405 | } | |
| 406 | } | |
| 407 | } | |
| 408 | } | |
| 409 | .listStyle(.insetGrouped) | |
| 410 | .navigationTitle("Select Ref") | |
| 411 | .navigationBarTitleDisplayMode(.inline) | |
| 412 | .toolbar { | |
| 413 | ToolbarItem(placement: .cancellationAction) { | |
| 414 | Button("Cancel") { | |
| 415 | isPresented = false | |
| 416 | } | |
| 417 | } | |
| 418 | } | |
| 419 | .overlay { | |
| 420 | if viewModel.isLoadingRefs { | |
| 421 | SRHTLoadingStateView(message: "Loading references…") | |
| 422 | } | |
| 423 | } | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { | |
| 428 | HStack(spacing: 12) { | |
| 429 | Image(systemName: systemImage) | |
| 430 | .foregroundStyle(color) | |
| 431 | ||
| 432 | Text(title) | |
| 433 | .font(.body.monospaced()) | |
| 434 | .foregroundStyle(.primary) | |
| 435 | ||
| 436 | Spacer() | |
| 437 | ||
| 438 | if isSelected { | |
| 439 | Image(systemName: "checkmark") | |
| 440 | .font(.caption.weight(.semibold)) | |
| 441 | .foregroundStyle(.tint) | |
| 442 | } | |
| 443 | } | |
| 444 | .contentShape(Rectangle()) | |
| 445 | } | |
| 446 | } | |
Hutch/Views/Repositories/FileTreeViewModel.swift added +524
| @@ -0,0 +1,524 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct RevparseResponse: Decodable, Sendable { | |
| 6 | let repository: RevparseRepository? | |
| 7 | } | |
| 8 | ||
| 9 | private struct RevparseRepository: Decodable, Sendable { | |
| 10 | let revparse_single: RevparseCommit? | |
| 11 | } | |
| 12 | ||
| 13 | private struct RevparseCommit: Decodable, Sendable { | |
| 14 | let tree: GitTree? | |
| 15 | } | |
| 16 | ||
| 17 | private struct SubtreeResponse: Decodable, Sendable { | |
| 18 | let repository: SubtreeRepository? | |
| 19 | } | |
| 20 | ||
| 21 | private struct SubtreeRepository: Decodable, Sendable { | |
| 22 | let object: SubtreeObject? | |
| 23 | } | |
| 24 | ||
| 25 | /// Dedicated decoding struct for the subtree query response. | |
| 26 | /// Does not use GitObject enum — decodes entries directly from the Tree inline fragment. | |
| 27 | private struct SubtreeObject: Decodable, Sendable { | |
| 28 | let entries: GitTreeEntryPage? | |
| 29 | } | |
| 30 | ||
| 31 | private struct BlobResponse: Decodable, Sendable { | |
| 32 | let repository: BlobRepository? | |
| 33 | } | |
| 34 | ||
| 35 | private struct BlobRepository: Decodable, Sendable { | |
| 36 | let object: GitObject? | |
| 37 | } | |
| 38 | ||
| 39 | // MARK: - Navigation Stack Entry | |
| 40 | ||
| 41 | struct FileNavEntry: Hashable { | |
| 42 | let name: String | |
| 43 | let treeId: String | |
| 44 | } | |
| 45 | ||
| 46 | // MARK: - View Model | |
| 47 | ||
| 48 | @Observable | |
| 49 | @MainActor | |
| 50 | final class FileTreeViewModel { | |
| 51 | ||
| 52 | let repositoryRid: String | |
| 53 | let service: SRHTService | |
| 54 | private let client: SRHTClient | |
| 55 | ||
| 56 | /// The current revspec. "HEAD" by default, or a full ref name. | |
| 57 | var revspec: String = "HEAD" | |
| 58 | ||
| 59 | /// Navigation stack: each entry is a (name, treeId) pair. | |
| 60 | /// The first entry is always the root. | |
| 61 | private(set) var navStack: [FileNavEntry] = [] | |
| 62 | ||
| 63 | /// The tree entries at the current directory level. | |
| 64 | private(set) var entries: [TreeEntry] = [] | |
| 65 | ||
| 66 | /// When viewing a file (text blob or binary blob), this holds the object. | |
| 67 | private(set) var viewingEntry: TreeEntry? | |
| 68 | private(set) var viewingObject: GitObject? | |
| 69 | ||
| 70 | private(set) var isLoading = false | |
| 71 | var error: String? | |
| 72 | ||
| 73 | // Available references for the branch/tag picker | |
| 74 | private(set) var branches: [Reference] = [] | |
| 75 | private(set) var tags: [Reference] = [] | |
| 76 | private(set) var isLoadingRefs = false | |
| 77 | ||
| 78 | init(repositoryRid: String, service: SRHTService, client: SRHTClient) { | |
| 79 | self.repositoryRid = repositoryRid | |
| 80 | self.service = service | |
| 81 | self.client = client | |
| 82 | } | |
| 83 | ||
| 84 | // MARK: - Queries | |
| 85 | ||
| 86 | private static let rootTreeQuery = """ | |
| 87 | query files($rid: ID!, $revspec: String!) { | |
| 88 | repository(rid: $rid) { | |
| 89 | revparse_single(revspec: $revspec) { | |
| 90 | tree { | |
| 91 | id | |
| 92 | entries { | |
| 93 | results { | |
| 94 | id | |
| 95 | name | |
| 96 | mode | |
| 97 | object { | |
| 98 | type | |
| 99 | id | |
| 100 | shortId | |
| 101 | ... on Tree { | |
| 102 | entries { | |
| 103 | results { | |
| 104 | id | |
| 105 | name | |
| 106 | mode | |
| 107 | object { type id shortId } | |
| 108 | } | |
| 109 | cursor | |
| 110 | } | |
| 111 | } | |
| 112 | ... on TextBlob { | |
| 113 | text | |
| 114 | size | |
| 115 | } | |
| 116 | ... on BinaryBlob { | |
| 117 | size | |
| 118 | content | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
| 122 | cursor | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | } | |
| 127 | } | |
| 128 | """ | |
| 129 | ||
| 130 | /// Query to fetch additional pages of tree entries by tree ID + cursor. | |
| 131 | private static let treeEntriesPageQuery = """ | |
| 132 | query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) { | |
| 133 | repository(rid: $rid) { | |
| 134 | object(id: $treeId) { | |
| 135 | type | |
| 136 | id | |
| 137 | ... on Tree { | |
| 138 | entries(cursor: $cursor) { | |
| 139 | results { | |
| 140 | id | |
| 141 | name | |
| 142 | mode | |
| 143 | object { | |
| 144 | type | |
| 145 | id | |
| 146 | shortId | |
| 147 | ... on Tree { | |
| 148 | entries { | |
| 149 | results { | |
| 150 | id | |
| 151 | name | |
| 152 | mode | |
| 153 | object { type id shortId } | |
| 154 | } | |
| 155 | cursor | |
| 156 | } | |
| 157 | } | |
| 158 | ... on TextBlob { | |
| 159 | text | |
| 160 | size | |
| 161 | } | |
| 162 | ... on BinaryBlob { | |
| 163 | size | |
| 164 | content | |
| 165 | } | |
| 166 | } | |
| 167 | } | |
| 168 | cursor | |
| 169 | } | |
| 170 | } | |
| 171 | } | |
| 172 | } | |
| 173 | } | |
| 174 | """ | |
| 175 | ||
| 176 | private static let subtreeQuery = """ | |
| 177 | query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) { | |
| 178 | repository(rid: $rid) { | |
| 179 | object(id: $treeId) { | |
| 180 | type | |
| 181 | id | |
| 182 | ... on Tree { | |
| 183 | entries(cursor: $cursor) { | |
| 184 | results { | |
| 185 | id | |
| 186 | name | |
| 187 | mode | |
| 188 | object { | |
| 189 | type | |
| 190 | id | |
| 191 | shortId | |
| 192 | ... on Tree { | |
| 193 | entries { | |
| 194 | results { | |
| 195 | id | |
| 196 | name | |
| 197 | mode | |
| 198 | object { type id shortId } | |
| 199 | } | |
| 200 | cursor | |
| 201 | } | |
| 202 | } | |
| 203 | ... on TextBlob { | |
| 204 | text | |
| 205 | size | |
| 206 | } | |
| 207 | ... on BinaryBlob { | |
| 208 | size | |
| 209 | content | |
| 210 | } | |
| 211 | } | |
| 212 | } | |
| 213 | cursor | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | """ | |
| 220 | ||
| 221 | private static let blobQuery = """ | |
| 222 | query blob($rid: ID!, $blobId: String!) { | |
| 223 | repository(rid: $rid) { | |
| 224 | object(id: $blobId) { | |
| 225 | type | |
| 226 | id | |
| 227 | ... on TextBlob { | |
| 228 | text | |
| 229 | size | |
| 230 | } | |
| 231 | ... on BinaryBlob { | |
| 232 | size | |
| 233 | content | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | } | |
| 238 | """ | |
| 239 | ||
| 240 | private static let refsQuery = """ | |
| 241 | query refs($rid: ID!) { | |
| 242 | repository(rid: $rid) { | |
| 243 | references { | |
| 244 | results { name target } | |
| 245 | cursor | |
| 246 | } | |
| 247 | } | |
| 248 | } | |
| 249 | """ | |
| 250 | ||
| 251 | // MARK: - Load Root Tree | |
| 252 | ||
| 253 | func loadRootTree() async { | |
| 254 | guard !isLoading else { return } | |
| 255 | isLoading = true | |
| 256 | error = nil | |
| 257 | viewingEntry = nil | |
| 258 | viewingObject = nil | |
| 259 | defer { isLoading = false } | |
| 260 | ||
| 261 | let variables: [String: any Sendable] = [ | |
| 262 | "rid": repositoryRid, | |
| 263 | "revspec": revspec | |
| 264 | ] | |
| 265 | ||
| 266 | do { | |
| 267 | let result: RevparseResponse | |
| 268 | do { | |
| 269 | result = try await client.execute( | |
| 270 | service: service, | |
| 271 | query: Self.rootTreeQuery, | |
| 272 | variables: variables, | |
| 273 | responseType: RevparseResponse.self | |
| 274 | ) | |
| 275 | } catch { | |
| 276 | if isMissingGitReferenceError(error) { | |
| 277 | navStack = [FileNavEntry(name: "root", treeId: "")] | |
| 278 | entries = [] | |
| 279 | return | |
| 280 | } | |
| 281 | throw error | |
| 282 | } | |
| 283 | if let tree = result.repository?.revparse_single?.tree, | |
| 284 | let rootId = tree.id { | |
| 285 | navStack = [FileNavEntry(name: "root", treeId: rootId)] | |
| 286 | var allEntries = tree.entries?.results ?? [] | |
| 287 | var cursor = tree.entries?.cursor | |
| 288 | // Follow cursor pagination for remaining pages | |
| 289 | while let nextCursor = cursor { | |
| 290 | let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor) | |
| 291 | allEntries.append(contentsOf: pageEntries.results) | |
| 292 | cursor = pageEntries.cursor | |
| 293 | } | |
| 294 | entries = allEntries | |
| 295 | } else { | |
| 296 | navStack = [] | |
| 297 | entries = [] | |
| 298 | } | |
| 299 | } catch { | |
| 300 | self.error = error.localizedDescription | |
| 301 | } | |
| 302 | } | |
| 303 | ||
| 304 | // MARK: - Navigate Into Folder | |
| 305 | ||
| 306 | func navigateInto(entry: TreeEntry) async { | |
| 307 | guard let object = entry.object else { return } | |
| 308 | ||
| 309 | switch object { | |
| 310 | case .tree(let tree): | |
| 311 | // Use the git object SHA from entry.object, NOT entry.id | |
| 312 | guard let objectSHA = tree.id else { return } | |
| 313 | // If we already have the entries inline and no further pages, use them directly | |
| 314 | if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil { | |
| 315 | navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA)) | |
| 316 | entries = inlineEntries | |
| 317 | viewingEntry = nil | |
| 318 | viewingObject = nil | |
| 319 | return | |
| 320 | } | |
| 321 | // Otherwise fetch the subtree (handles pagination) | |
| 322 | await loadSubtree(name: entry.name, treeId: objectSHA) | |
| 323 | ||
| 324 | case .textBlob: | |
| 325 | viewingEntry = entry | |
| 326 | viewingObject = object | |
| 327 | ||
| 328 | case .binaryBlob(let blob): | |
| 329 | if blob.content != nil || blob.size != nil { | |
| 330 | viewingEntry = entry | |
| 331 | viewingObject = object | |
| 332 | } else if let blobId = blob.id { | |
| 333 | await loadBlob(entry: entry, blobId: blobId) | |
| 334 | } | |
| 335 | ||
| 336 | case .unknown: | |
| 337 | break | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | private func loadSubtree(name: String, treeId: String) async { | |
| 342 | guard !isLoading else { return } | |
| 343 | isLoading = true | |
| 344 | error = nil | |
| 345 | viewingEntry = nil | |
| 346 | viewingObject = nil | |
| 347 | defer { isLoading = false } | |
| 348 | ||
| 349 | let variables: [String: any Sendable] = [ | |
| 350 | "rid": repositoryRid, | |
| 351 | "treeId": treeId | |
| 352 | ] | |
| 353 | ||
| 354 | do { | |
| 355 | let result = try await client.execute( | |
| 356 | service: service, | |
| 357 | query: Self.subtreeQuery, | |
| 358 | variables: variables, | |
| 359 | responseType: SubtreeResponse.self | |
| 360 | ) | |
| 361 | navStack.append(FileNavEntry(name: name, treeId: treeId)) | |
| 362 | var allEntries = result.repository?.object?.entries?.results ?? [] | |
| 363 | var cursor = result.repository?.object?.entries?.cursor | |
| 364 | while let nextCursor = cursor { | |
| 365 | let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor) | |
| 366 | allEntries.append(contentsOf: pageEntries.results) | |
| 367 | cursor = pageEntries.cursor | |
| 368 | } | |
| 369 | entries = allEntries | |
| 370 | } catch { | |
| 371 | self.error = error.localizedDescription | |
| 372 | } | |
| 373 | } | |
| 374 | ||
| 375 | private func loadBlob(entry: TreeEntry, blobId: String) async { | |
| 376 | guard !isLoading else { return } | |
| 377 | isLoading = true | |
| 378 | error = nil | |
| 379 | defer { isLoading = false } | |
| 380 | ||
| 381 | let variables: [String: any Sendable] = [ | |
| 382 | "rid": repositoryRid, | |
| 383 | "blobId": blobId | |
| 384 | ] | |
| 385 | ||
| 386 | do { | |
| 387 | let result = try await client.execute( | |
| 388 | service: service, | |
| 389 | query: Self.blobQuery, | |
| 390 | variables: variables, | |
| 391 | responseType: BlobResponse.self | |
| 392 | ) | |
| 393 | viewingEntry = entry | |
| 394 | viewingObject = result.repository?.object ?? .unknown | |
| 395 | } catch { | |
| 396 | self.error = error.localizedDescription | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | // MARK: - Navigate to Breadcrumb | |
| 401 | ||
| 402 | func navigateToBreadcrumb(at index: Int) async { | |
| 403 | guard index >= 0, index < navStack.count else { return } | |
| 404 | ||
| 405 | // If tapping current level, do nothing | |
| 406 | if index == navStack.count - 1, viewingEntry == nil { | |
| 407 | return | |
| 408 | } | |
| 409 | ||
| 410 | // Clear file view | |
| 411 | viewingEntry = nil | |
| 412 | viewingObject = nil | |
| 413 | ||
| 414 | // Trim the stack | |
| 415 | let targetEntry = navStack[index] | |
| 416 | navStack = Array(navStack.prefix(index + 1)) | |
| 417 | ||
| 418 | if index == 0 { | |
| 419 | // Go back to root — reload from revparse_single | |
| 420 | await loadRootTree() | |
| 421 | } else { | |
| 422 | // Load the subtree at this level | |
| 423 | isLoading = true | |
| 424 | error = nil | |
| 425 | defer { isLoading = false } | |
| 426 | ||
| 427 | let variables: [String: any Sendable] = [ | |
| 428 | "rid": repositoryRid, | |
| 429 | "treeId": targetEntry.treeId | |
| 430 | ] | |
| 431 | ||
| 432 | do { | |
| 433 | let result = try await client.execute( | |
| 434 | service: service, | |
| 435 | query: Self.subtreeQuery, | |
| 436 | variables: variables, | |
| 437 | responseType: SubtreeResponse.self | |
| 438 | ) | |
| 439 | var allEntries = result.repository?.object?.entries?.results ?? [] | |
| 440 | var cursor = result.repository?.object?.entries?.cursor | |
| 441 | while let nextCursor = cursor { | |
| 442 | let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor) | |
| 443 | allEntries.append(contentsOf: pageEntries.results) | |
| 444 | cursor = pageEntries.cursor | |
| 445 | } | |
| 446 | entries = allEntries | |
| 447 | } catch { | |
| 448 | self.error = error.localizedDescription | |
| 449 | } | |
| 450 | } | |
| 451 | } | |
| 452 | ||
| 453 | /// Fetch a single page of tree entries by tree ID and cursor. | |
| 454 | private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage { | |
| 455 | let variables: [String: any Sendable] = [ | |
| 456 | "rid": repositoryRid, | |
| 457 | "treeId": treeId, | |
| 458 | "cursor": cursor | |
| 459 | ] | |
| 460 | let result = try await client.execute( | |
| 461 | service: service, | |
| 462 | query: Self.treeEntriesPageQuery, | |
| 463 | variables: variables, | |
| 464 | responseType: SubtreeResponse.self | |
| 465 | ) | |
| 466 | return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) | |
| 467 | } | |
| 468 | ||
| 469 | /// Dismiss the file view and go back to the directory listing. | |
| 470 | func dismissFileView() { | |
| 471 | viewingEntry = nil | |
| 472 | viewingObject = nil | |
| 473 | } | |
| 474 | ||
| 475 | // MARK: - Change Revspec | |
| 476 | ||
| 477 | func changeRevspec(_ newRevspec: String) async { | |
| 478 | revspec = newRevspec | |
| 479 | await loadRootTree() | |
| 480 | } | |
| 481 | ||
| 482 | // MARK: - Load References | |
| 483 | ||
| 484 | func loadReferences() async { | |
| 485 | guard !isLoadingRefs else { return } | |
| 486 | isLoadingRefs = true | |
| 487 | ||
| 488 | do { | |
| 489 | let result = try await client.execute( | |
| 490 | service: service, | |
| 491 | query: Self.refsQuery, | |
| 492 | variables: ["rid": repositoryRid], | |
| 493 | responseType: RefsResponseLocal.self | |
| 494 | ) | |
| 495 | let allRefs = result.repository?.references.results ?? [] | |
| 496 | branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } | |
| 497 | tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } | |
| 498 | } catch { | |
| 499 | // Silently fail for refs — non-critical | |
| 500 | } | |
| 501 | ||
| 502 | isLoadingRefs = false | |
| 503 | } | |
| 504 | ||
| 505 | private func isMissingGitReferenceError(_ error: Error) -> Bool { | |
| 506 | guard let srhtError = error as? SRHTError else { return false } | |
| 507 | guard case .graphQLErrors(let errors) = srhtError else { return false } | |
| 508 | return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } | |
| 509 | } | |
| 510 | } | |
| 511 | ||
| 512 | // File-private refs response to avoid collision with RepositoryDetailViewModel's private type | |
| 513 | private struct RefsResponseLocal: Decodable, Sendable { | |
| 514 | let repository: RefsRepoLocal? | |
| 515 | } | |
| 516 | ||
| 517 | private struct RefsRepoLocal: Decodable, Sendable { | |
| 518 | let references: RefsPageLocal | |
| 519 | } | |
| 520 | ||
| 521 | private struct RefsPageLocal: Decodable, Sendable { | |
| 522 | let results: [Reference] | |
| 523 | let cursor: String? | |
| 524 | } | |
Hutch/Views/Repositories/HgRepositoryDetailView.swift +363 −143
| @@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View { | ||
| 11 | 11 | @State private var viewModel: HgRepositoryDetailViewModel? |
| 12 | 12 | @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary |
| 13 | 13 | @State private var showSettings = false |
| 14 | @State private var isShowingRepositoryDetails = false | |
| 15 | @State private var showBrowseRefPicker = false | |
| 16 | ||
| 17 | private var shareURL: URL? { | |
| 18 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } | |
| 19 | return SRHTWebURL.file( | |
| 20 | repository: repository, | |
| 21 | revspec: viewModel.browseRevspec, | |
| 22 | path: selectedFilePath | |
| 23 | ) | |
| 24 | } | |
| 14 | 25 | |
| 15 | 26 | var body: some View { |
| 16 | 27 | Group { |
| 17 | 28 | if let viewModel { |
| 18 | 29 | content(viewModel) |
| 19 | 30 | } else { |
| 20 | ProgressView() | |
| 31 | SRHTLoadingStateView(message: "Loading repository…") | |
| 21 | 32 | } |
| 22 | 33 | } |
| 23 | 34 | .navigationTitle(repository.name) |
| 24 | 35 | .navigationBarTitleDisplayMode(.inline) |
| 25 | 36 | .toolbar { |
| 26 | ToolbarItem(placement: .topBarTrailing) { | |
| 37 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 38 | if selectedTab == .browse, let viewModel { | |
| 39 | Button { | |
| 40 | showBrowseRefPicker = true | |
| 41 | } label: { | |
| 42 | Label( | |
| 43 | browseRevspecLabel(viewModel.browseRevspec), | |
| 44 | systemImage: "arrow.triangle.branch" | |
| 45 | ) | |
| 46 | .font(.subheadline) | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { | |
| 51 | Image(systemName: "square.and.arrow.up") | |
| 52 | } | |
| 53 | ||
| 27 | 54 | Button { |
| 28 | 55 | showSettings = true |
| 29 | 56 | } label: { |
| @@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View { | ||
| 41 | 68 | } |
| 42 | 69 | ) |
| 43 | 70 | } |
| 71 | .sheet(isPresented: $showBrowseRefPicker) { | |
| 72 | if let viewModel { | |
| 73 | HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker) | |
| 74 | } | |
| 75 | } | |
| 44 | 76 | .task { |
| 45 | 77 | if viewModel == nil { |
| 46 | 78 | let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client) |
| @@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View { | ||
| 82 | 114 | revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.") |
| 83 | 115 | } |
| 84 | 116 | } |
| 85 | .alert("Error", isPresented: .constant(viewModel.error != nil)) { | |
| 86 | Button("OK") { viewModel.error = nil } | |
| 87 | } message: { | |
| 88 | if let error = viewModel.error { | |
| 89 | Text(error) | |
| 90 | } | |
| 91 | } | |
| 117 | .srhtErrorBanner(error: Binding( | |
| 118 | get: { viewModel.error }, | |
| 119 | set: { viewModel.error = $0 } | |
| 120 | )) | |
| 92 | 121 | } |
| 93 | 122 | |
| 94 | 123 | @ViewBuilder |
| 95 | 124 | private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 96 | if viewModel.isLoadingSummary && !viewModel.summaryLoaded { | |
| 97 | ProgressView() | |
| 98 | .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| 99 | } else { | |
| 100 | ScrollView { | |
| 101 | VStack(alignment: .leading, spacing: 16) { | |
| 102 | summaryCards(viewModel) | |
| 103 | ||
| 104 | if let readmeView = readmeContentView(viewModel) { | |
| 105 | readmeView | |
| 106 | } else { | |
| 107 | ContentUnavailableView( | |
| 108 | "No README", | |
| 109 | systemImage: "doc.text", | |
| 110 | description: Text("This repository does not have a README file.") | |
| 111 | ) | |
| 112 | } | |
| 113 | } | |
| 114 | .padding() | |
| 125 | ScrollView { | |
| 126 | VStack(alignment: .leading, spacing: 16) { | |
| 127 | headerSection | |
| 128 | metadataSection(viewModel) | |
| 129 | repositoryDetailsSection(viewModel) | |
| 130 | latestChangeSection(viewModel) | |
| 131 | readmeSection(viewModel) | |
| 115 | 132 | } |
| 116 | .refreshable { | |
| 117 | await viewModel.loadSummary() | |
| 133 | .padding() | |
| 134 | } | |
| 135 | .overlay { | |
| 136 | if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { | |
| 137 | SRHTLoadingStateView(message: "Loading repository…") | |
| 138 | } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil { | |
| 139 | SRHTErrorStateView( | |
| 140 | title: "Couldn't Load Repository", | |
| 141 | message: error, | |
| 142 | retryAction: { await viewModel.loadSummary() } | |
| 143 | ) | |
| 118 | 144 | } |
| 119 | 145 | } |
| 146 | .refreshable { | |
| 147 | await viewModel.loadSummary() | |
| 148 | } | |
| 120 | 149 | } |
| 121 | 150 | |
| 122 | private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View { | |
| 123 | VStack(alignment: .leading, spacing: 12) { | |
| 124 | LabeledContent("Visibility", value: visibilityLabel(repository.visibility)) | |
| 125 | LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") | |
| 126 | ||
| 151 | private var headerSection: some View { | |
| 152 | VStack(alignment: .leading, spacing: 6) { | |
| 153 | Text(repository.owner.canonicalName) | |
| 154 | .font(.subheadline) | |
| 155 | .foregroundStyle(.secondary) | |
| 156 | Text(repository.name) | |
| 157 | .font(.largeTitle.weight(.semibold)) | |
| 127 | 158 | if let description = repository.description, !description.isEmpty { |
| 128 | VStack(alignment: .leading, spacing: 4) { | |
| 129 | Text("Description") | |
| 130 | .font(.caption.weight(.semibold)) | |
| 131 | .foregroundStyle(.secondary) | |
| 132 | .textCase(.uppercase) | |
| 133 | Text(description) | |
| 134 | } | |
| 159 | Text(description) | |
| 160 | .font(.body) | |
| 135 | 161 | } |
| 162 | } | |
| 163 | } | |
| 136 | 164 | |
| 137 | if let tip = viewModel.tip { | |
| 138 | VStack(alignment: .leading, spacing: 6) { | |
| 139 | Text("Tip") | |
| 140 | .font(.caption.weight(.semibold)) | |
| 141 | .foregroundStyle(.secondary) | |
| 142 | .textCase(.uppercase) | |
| 143 | Text(tip.title) | |
| 144 | .font(.headline) | |
| 145 | HStack { | |
| 146 | Text(tip.displayShortId) | |
| 147 | .font(.caption.monospaced()) | |
| 148 | Spacer() | |
| 149 | Text(tip.author.time.relativeDescription) | |
| 150 | .font(.caption) | |
| 151 | .foregroundStyle(.secondary) | |
| 152 | } | |
| 153 | Text(tip.author.name) | |
| 154 | .font(.subheadline) | |
| 155 | .foregroundStyle(.secondary) | |
| 156 | } | |
| 165 | @ViewBuilder | |
| 166 | private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { | |
| 167 | VStack(alignment: .leading, spacing: 10) { | |
| 168 | SummaryMetadataRow( | |
| 169 | icon: "arrow.triangle.branch", | |
| 170 | title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) | |
| 171 | ) | |
| 172 | ||
| 173 | if let readmePath = viewModel.readmePath { | |
| 174 | SummaryMetadataRow( | |
| 175 | icon: "doc.text", | |
| 176 | title: readmePath | |
| 177 | ) | |
| 157 | 178 | } |
| 158 | 179 | } |
| 159 | .padding() | |
| 160 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
| 180 | } | |
| 181 | ||
| 182 | private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { | |
| 183 | DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { | |
| 184 | VStack(alignment: .leading, spacing: 12) { | |
| 185 | SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility)) | |
| 186 | SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") | |
| 187 | SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true) | |
| 188 | SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true) | |
| 189 | SummaryDetailRow(label: "RID", value: repository.rid, monospace: true) | |
| 190 | } | |
| 191 | .padding(.top, 8) | |
| 192 | } label: { | |
| 193 | Text("Repository Details") | |
| 194 | .font(.subheadline.weight(.medium)) | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | @ViewBuilder | |
| 199 | private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { | |
| 200 | VStack(alignment: .leading, spacing: 8) { | |
| 201 | if viewModel.isLoadingSummary && viewModel.tip == nil { | |
| 202 | SRHTLoadingStateView(message: "Loading latest change…") | |
| 203 | .frame(maxWidth: .infinity) | |
| 204 | } else if let tip = viewModel.tip { | |
| 205 | SummaryMetadataRow( | |
| 206 | icon: "arrow.trianglehead.clockwise", | |
| 207 | title: tip.title, | |
| 208 | subtitle: "\(tip.displayShortId) — \(tip.author)" | |
| 209 | ) | |
| 210 | } else if let error = viewModel.error, !viewModel.summaryLoaded { | |
| 211 | SRHTErrorStateView( | |
| 212 | title: "Couldn't Load Latest Change", | |
| 213 | message: error, | |
| 214 | retryAction: { await viewModel.loadSummary() } | |
| 215 | ) | |
| 216 | } else { | |
| 217 | ContentUnavailableView( | |
| 218 | "No Recent Revisions", | |
| 219 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", | |
| 220 | description: Text("This repository does not have any revision history yet.") | |
| 221 | ) | |
| 222 | } | |
| 223 | } | |
| 224 | } | |
| 225 | ||
| 226 | @ViewBuilder | |
| 227 | private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { | |
| 228 | if viewModel.isLoadingSummary && !viewModel.summaryLoaded { | |
| 229 | SRHTLoadingStateView(message: "Loading README…") | |
| 230 | } else if let readmeView = readmeContentView(viewModel) { | |
| 231 | readmeView | |
| 232 | } else if let error = viewModel.error, !viewModel.summaryLoaded { | |
| 233 | SRHTErrorStateView( | |
| 234 | title: "Couldn't Load README", | |
| 235 | message: error, | |
| 236 | retryAction: { await viewModel.loadSummary() } | |
| 237 | ) | |
| 238 | } else { | |
| 239 | ContentUnavailableView( | |
| 240 | "No README", | |
| 241 | systemImage: "doc.text", | |
| 242 | description: Text("This repository does not have a README file.") | |
| 243 | ) | |
| 244 | } | |
| 161 | 245 | } |
| 162 | 246 | |
| 163 | 247 | @ViewBuilder |
| @@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View { | ||
| 166 | 250 | browseBreadcrumbs(viewModel) |
| 167 | 251 | Divider() |
| 168 | 252 | |
| 169 | if viewModel.isLoadingBrowse { | |
| 170 | Spacer() | |
| 171 | ProgressView() | |
| 172 | Spacer() | |
| 253 | if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil { | |
| 254 | SRHTLoadingStateView(message: "Loading files…") | |
| 173 | 255 | } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent { |
| 174 | GeometryReader { geometry in | |
| 175 | ScrollView([.vertical, .horizontal]) { | |
| 176 | Text(fileContent) | |
| 177 | .font(.system(.body, design: .monospaced)) | |
| 178 | .frame( | |
| 179 | minWidth: geometry.size.width, | |
| 180 | minHeight: geometry.size.height, | |
| 181 | alignment: .topLeading | |
| 182 | ) | |
| 183 | .padding() | |
| 256 | VStack(spacing: 0) { | |
| 257 | HStack { | |
| 258 | Spacer() | |
| 259 | SRHTShareButton(url: shareURL, target: .file) { | |
| 260 | Label("Share File", systemImage: "square.and.arrow.up") | |
| 261 | } | |
| 262 | .buttonStyle(.bordered) | |
| 263 | } | |
| 264 | .padding(.horizontal) | |
| 265 | .padding(.top, 12) | |
| 266 | ||
| 267 | GeometryReader { geometry in | |
| 268 | ScrollView([.vertical, .horizontal]) { | |
| 269 | Text(fileContent) | |
| 270 | .font(.system(.body, design: .monospaced)) | |
| 271 | .frame( | |
| 272 | minWidth: geometry.size.width, | |
| 273 | minHeight: geometry.size.height, | |
| 274 | alignment: .topLeading | |
| 275 | ) | |
| 276 | .padding() | |
| 277 | } | |
| 184 | 278 | } |
| 185 | 279 | } |
| 186 | 280 | .safeAreaInset(edge: .bottom) { |
| @@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View { | ||
| 193 | 287 | .background(.bar) |
| 194 | 288 | } |
| 195 | 289 | .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name) |
| 290 | } else if let error = viewModel.error, viewModel.files.isEmpty { | |
| 291 | SRHTErrorStateView( | |
| 292 | title: "Couldn't Load Files", | |
| 293 | message: error, | |
| 294 | retryAction: { await viewModel.loadBrowseRoot() } | |
| 295 | ) | |
| 196 | 296 | } else if viewModel.files.isEmpty { |
| 197 | 297 | ContentUnavailableView( |
| 198 | 298 | "No Files", |
| @@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View { | ||
| 201 | 301 | ) |
| 202 | 302 | } else { |
| 203 | 303 | List(viewModel.files) { file in |
| 204 | Label { | |
| 205 | Text(displayFileName(file.name)) | |
| 206 | .font(.body.monospaced()) | |
| 207 | } icon: { | |
| 208 | Image(systemName: file.isDirectory ? "folder.fill" : "doc") | |
| 209 | .foregroundStyle(file.isDirectory ? .blue : .secondary) | |
| 210 | } | |
| 304 | HgFileRow(file: file) | |
| 211 | 305 | .contentShape(Rectangle()) |
| 212 | 306 | .onTapGesture { |
| 213 | 307 | Task { await viewModel.openFile(file) } |
| @@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View { | ||
| 282 | 376 | } |
| 283 | 377 | .listStyle(.plain) |
| 284 | 378 | .overlay { |
| 285 | if viewModel.isLoadingLog { | |
| 286 | ProgressView() | |
| 379 | if viewModel.isLoadingLog, viewModel.log.isEmpty { | |
| 380 | SRHTLoadingStateView(message: "Loading revisions…") | |
| 381 | } else if let error = viewModel.error, viewModel.log.isEmpty { | |
| 382 | SRHTErrorStateView( | |
| 383 | title: "Couldn't Load Revisions", | |
| 384 | message: error, | |
| 385 | retryAction: { await viewModel.loadLog() } | |
| 386 | ) | |
| 287 | 387 | } else if viewModel.log.isEmpty { |
| 288 | 388 | ContentUnavailableView( |
| 289 | 389 | "No Revisions", |
| @@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View { | ||
| 298 | 398 | } |
| 299 | 399 | |
| 300 | 400 | @ViewBuilder |
| 301 | private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View { | |
| 401 | private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View { | |
| 302 | 402 | if revisions.isEmpty { |
| 303 | 403 | ContentUnavailableView( |
| 304 | 404 | emptyTitle, |
| @@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View { | ||
| 307 | 407 | ) |
| 308 | 408 | } else { |
| 309 | 409 | List(revisions) { revision in |
| 310 | revisionRow(revision) | |
| 410 | namedRevisionRow(revision) | |
| 311 | 411 | } |
| 312 | 412 | .listStyle(.plain) |
| 313 | 413 | } |
| @@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View { | ||
| 335 | 435 | } |
| 336 | 436 | |
| 337 | 437 | HStack { |
| 338 | Text(revision.author.name) | |
| 339 | Spacer() | |
| 340 | Text(revision.author.time.relativeDescription) | |
| 438 | Text(revision.author) | |
| 341 | 439 | } |
| 342 | 440 | .font(.caption) |
| 343 | 441 | .foregroundStyle(.secondary) |
| @@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View { | ||
| 345 | 443 | .padding(.vertical, 4) |
| 346 | 444 | } |
| 347 | 445 | |
| 348 | @ViewBuilder | |
| 349 | private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { | |
| 350 | let imageURLResolver = makeImageURLResolver(viewModel) | |
| 446 | private func namedRevisionRow(_ revision: HgNamedRevision) -> some View { | |
| 447 | HStack(alignment: .firstTextBaseline) { | |
| 448 | Text(revision.name) | |
| 449 | .font(.headline) | |
| 450 | Spacer() | |
| 451 | Text(revision.displayShortId) | |
| 452 | .font(.caption.monospaced()) | |
| 453 | .foregroundStyle(.secondary) | |
| 454 | } | |
| 455 | .padding(.vertical, 6) | |
| 456 | } | |
| 351 | 457 | |
| 458 | private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { | |
| 352 | 459 | guard let content = viewModel.readmeContent else { |
| 353 | 460 | return nil |
| 354 | 461 | } |
| 355 | 462 | |
| 463 | return AnyView( | |
| 464 | RenderedMarkupContentView( | |
| 465 | content: sharedReadmeContent(from: content), | |
| 466 | readmePath: viewModel.readmePath, | |
| 467 | colorScheme: colorScheme, | |
| 468 | ownerCanonicalName: repository.owner.canonicalName, | |
| 469 | repositoryName: repository.name, | |
| 470 | repositoryHost: "hg.sr.ht" | |
| 471 | ) | |
| 472 | ) | |
| 473 | } | |
| 474 | ||
| 475 | private func browseRevspecLabel(_ revspec: String) -> String { | |
| 476 | if revspec == "tip" { | |
| 477 | return "tip" | |
| 478 | } | |
| 479 | return revspec | |
| 480 | } | |
| 481 | ||
| 482 | private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { | |
| 356 | 483 | switch content { |
| 357 | 484 | case .html(let html): |
| 358 | return AnyView( | |
| 359 | HTMLWebView(html: html, colorScheme: colorScheme) | |
| 360 | .frame(minHeight: 400) | |
| 361 | ) | |
| 485 | .html(html) | |
| 362 | 486 | case .markdown(let text): |
| 363 | return AnyView( | |
| 364 | HTMLWebView( | |
| 365 | html: markdownToHTML(text, imageURLResolver: imageURLResolver), | |
| 366 | colorScheme: colorScheme | |
| 367 | ) | |
| 368 | .frame(minHeight: 400) | |
| 369 | ) | |
| 487 | .markdown(text) | |
| 370 | 488 | case .org(let text): |
| 371 | return AnyView( | |
| 372 | HTMLWebView( | |
| 373 | html: orgToHTML(text, imageURLResolver: imageURLResolver), | |
| 374 | colorScheme: colorScheme | |
| 375 | ) | |
| 376 | .frame(minHeight: 400) | |
| 377 | ) | |
| 489 | .org(text) | |
| 378 | 490 | case .plainText(let text): |
| 379 | return AnyView( | |
| 380 | Text(text) | |
| 381 | .font(.system(.body, design: .monospaced)) | |
| 382 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 383 | .padding() | |
| 384 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
| 385 | ) | |
| 491 | .plainText(text) | |
| 386 | 492 | } |
| 387 | 493 | } |
| 388 | 494 | |
| 389 | private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? { | |
| 390 | let owner = repository.owner.canonicalName | |
| 391 | let repositoryName = repository.name | |
| 392 | let readmePath = viewModel.readmePath | |
| 393 | ||
| 394 | return { source in | |
| 395 | resolveRepositoryAssetURL( | |
| 396 | source, | |
| 397 | owner: owner, | |
| 398 | repositoryName: repositoryName, | |
| 399 | readmePath: readmePath | |
| 400 | )? | |
| 401 | .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht") | |
| 495 | private func displayFileName(_ name: String) -> String { | |
| 496 | name.hasSuffix("/") ? String(name.dropLast()) : name | |
| 497 | } | |
| 498 | ||
| 499 | } | |
| 500 | ||
| 501 | private struct HgBrowseRefPickerSheet: View { | |
| 502 | let viewModel: HgRepositoryDetailViewModel | |
| 503 | @Binding var isPresented: Bool | |
| 504 | ||
| 505 | var body: some View { | |
| 506 | NavigationStack { | |
| 507 | List { | |
| 508 | Section { | |
| 509 | Button { | |
| 510 | Task { | |
| 511 | await viewModel.changeBrowseRevspec("tip") | |
| 512 | isPresented = false | |
| 513 | } | |
| 514 | } label: { | |
| 515 | refRow( | |
| 516 | title: "tip", | |
| 517 | systemImage: "arrow.triangle.branch", | |
| 518 | color: .blue, | |
| 519 | isSelected: viewModel.browseRevspec == "tip" | |
| 520 | ) | |
| 521 | } | |
| 522 | .buttonStyle(.plain) | |
| 523 | } | |
| 524 | ||
| 525 | if !viewModel.branches.isEmpty { | |
| 526 | Section("Branches") { | |
| 527 | ForEach(viewModel.branches) { revision in | |
| 528 | Button { | |
| 529 | Task { | |
| 530 | await viewModel.changeBrowseRevspec(revision.name) | |
| 531 | isPresented = false | |
| 532 | } | |
| 533 | } label: { | |
| 534 | refRow( | |
| 535 | title: revision.name, | |
| 536 | systemImage: "arrow.triangle.branch", | |
| 537 | color: .blue, | |
| 538 | isSelected: viewModel.browseRevspec == revision.name | |
| 539 | ) | |
| 540 | } | |
| 541 | .buttonStyle(.plain) | |
| 542 | } | |
| 543 | } | |
| 544 | } | |
| 545 | ||
| 546 | if !viewModel.tags.isEmpty { | |
| 547 | Section("Tags") { | |
| 548 | ForEach(viewModel.tags) { revision in | |
| 549 | Button { | |
| 550 | Task { | |
| 551 | await viewModel.changeBrowseRevspec(revision.name) | |
| 552 | isPresented = false | |
| 553 | } | |
| 554 | } label: { | |
| 555 | refRow( | |
| 556 | title: revision.name, | |
| 557 | systemImage: "tag", | |
| 558 | color: .orange, | |
| 559 | isSelected: viewModel.browseRevspec == revision.name | |
| 560 | ) | |
| 561 | } | |
| 562 | .buttonStyle(.plain) | |
| 563 | } | |
| 564 | } | |
| 565 | } | |
| 566 | ||
| 567 | if !viewModel.bookmarks.isEmpty { | |
| 568 | Section("Bookmarks") { | |
| 569 | ForEach(viewModel.bookmarks) { revision in | |
| 570 | Button { | |
| 571 | Task { | |
| 572 | await viewModel.changeBrowseRevspec(revision.name) | |
| 573 | isPresented = false | |
| 574 | } | |
| 575 | } label: { | |
| 576 | refRow( | |
| 577 | title: revision.name, | |
| 578 | systemImage: "bookmark", | |
| 579 | color: .purple, | |
| 580 | isSelected: viewModel.browseRevspec == revision.name | |
| 581 | ) | |
| 582 | } | |
| 583 | .buttonStyle(.plain) | |
| 584 | } | |
| 585 | } | |
| 586 | } | |
| 587 | } | |
| 588 | .listStyle(.insetGrouped) | |
| 589 | .navigationTitle("Select Ref") | |
| 590 | .navigationBarTitleDisplayMode(.inline) | |
| 591 | .toolbar { | |
| 592 | ToolbarItem(placement: .cancellationAction) { | |
| 593 | Button("Cancel") { | |
| 594 | isPresented = false | |
| 595 | } | |
| 596 | } | |
| 597 | } | |
| 402 | 598 | } |
| 403 | 599 | } |
| 404 | 600 | |
| 405 | private func displayFileName(_ name: String) -> String { | |
| 406 | name.hasSuffix("/") ? String(name.dropLast()) : name | |
| 601 | private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { | |
| 602 | HStack(spacing: 12) { | |
| 603 | Image(systemName: systemImage) | |
| 604 | .foregroundStyle(color) | |
| 605 | ||
| 606 | Text(title) | |
| 607 | .font(.body.monospaced()) | |
| 608 | .foregroundStyle(.primary) | |
| 609 | ||
| 610 | Spacer() | |
| 611 | ||
| 612 | if isSelected { | |
| 613 | Image(systemName: "checkmark") | |
| 614 | .font(.caption.weight(.semibold)) | |
| 615 | .foregroundStyle(.tint) | |
| 616 | } | |
| 617 | } | |
| 618 | .contentShape(Rectangle()) | |
| 407 | 619 | } |
| 620 | } | |
| 408 | 621 | |
| 409 | private func visibilityLabel(_ visibility: Visibility) -> String { | |
| 410 | switch visibility { | |
| 411 | case .public: | |
| 412 | return "Public" | |
| 413 | case .unlisted: | |
| 414 | return "Unlisted" | |
| 415 | case .private: | |
| 416 | return "Private" | |
| 622 | private struct HgFileRow: View { | |
| 623 | let file: HgFile | |
| 624 | ||
| 625 | var body: some View { | |
| 626 | Label { | |
| 627 | Text(displayName) | |
| 628 | .font(.body.monospaced()) | |
| 629 | .lineLimit(1) | |
| 630 | } icon: { | |
| 631 | Image(systemName: file.isDirectory ? "folder.fill" : "doc") | |
| 632 | .foregroundStyle(file.isDirectory ? .blue : .secondary) | |
| 417 | 633 | } |
| 418 | 634 | } |
| 635 | ||
| 636 | private var displayName: String { | |
| 637 | file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name | |
| 638 | } | |
| 419 | 639 | } |
Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift added +545
| @@ -0,0 +1,545 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | private struct HgRepositorySummaryResponse: Decodable, Sendable { | |
| 4 | let repository: HgRepositorySummaryPayload? | |
| 5 | } | |
| 6 | ||
| 7 | private struct HgRepositorySummaryPayload: Decodable, Sendable { | |
| 8 | let id: Int | |
| 9 | let rid: String | |
| 10 | let name: String | |
| 11 | let description: String? | |
| 12 | let visibility: Visibility | |
| 13 | let readme: String? | |
| 14 | let nonPublishing: Bool? | |
| 15 | let tip: HgSummaryTip? | |
| 16 | let branches: HgNamedRevisionPage? | |
| 17 | let tags: HgNamedRevisionPage? | |
| 18 | let bookmarks: HgNamedRevisionPage? | |
| 19 | } | |
| 20 | ||
| 21 | private struct HgSummaryTip: Decodable, Sendable { | |
| 22 | let id: String? | |
| 23 | let author: String? | |
| 24 | let description: String? | |
| 25 | let branch: String? | |
| 26 | let tags: [String]? | |
| 27 | ||
| 28 | var resolvedRevision: HgRevision? { | |
| 29 | guard | |
| 30 | let id, | |
| 31 | let author, | |
| 32 | let description | |
| 33 | else { | |
| 34 | return nil | |
| 35 | } | |
| 36 | ||
| 37 | return HgRevision( | |
| 38 | id: id, | |
| 39 | author: author, | |
| 40 | description: description, | |
| 41 | branch: branch, | |
| 42 | tags: tags | |
| 43 | ) | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | private struct HgRevisionLogResponse: Decodable, Sendable { | |
| 48 | let repository: HgRevisionLogRepository? | |
| 49 | } | |
| 50 | ||
| 51 | private struct HgRevisionLogRepository: Decodable, Sendable { | |
| 52 | let log: HgRevisionPage? | |
| 53 | } | |
| 54 | ||
| 55 | private struct HgReadmeFileResponse: Decodable, Sendable { | |
| 56 | let repository: HgReadmeFileRepository? | |
| 57 | } | |
| 58 | ||
| 59 | private struct HgReadmeFileRepository: Decodable, Sendable { | |
| 60 | let readme: String? | |
| 61 | } | |
| 62 | ||
| 63 | private struct HgFilesResponse: Decodable, Sendable { | |
| 64 | let repository: HgFilesRepository? | |
| 65 | } | |
| 66 | ||
| 67 | private struct HgFilesRepository: Decodable, Sendable { | |
| 68 | let files: HgFilePage? | |
| 69 | } | |
| 70 | ||
| 71 | private struct HgFilePage: Decodable, Sendable { | |
| 72 | let results: [HgFile] | |
| 73 | let cursor: String? | |
| 74 | } | |
| 75 | ||
| 76 | private struct HgNamedRevisionPage: Decodable, Sendable { | |
| 77 | let results: [HgNamedRevision] | |
| 78 | let cursor: String? | |
| 79 | ||
| 80 | private enum CodingKeys: String, CodingKey { | |
| 81 | case results | |
| 82 | case cursor | |
| 83 | } | |
| 84 | ||
| 85 | init(results: [HgNamedRevision], cursor: String?) { | |
| 86 | self.results = results | |
| 87 | self.cursor = cursor | |
| 88 | } | |
| 89 | ||
| 90 | init(from decoder: any Decoder) throws { | |
| 91 | let container = try decoder.container(keyedBy: CodingKeys.self) | |
| 92 | self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? [] | |
| 93 | self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | private struct HgCatResponse: Decodable, Sendable { | |
| 98 | let repository: HgCatRepository? | |
| 99 | } | |
| 100 | ||
| 101 | private struct HgCatRepository: Decodable, Sendable { | |
| 102 | let cat: String? | |
| 103 | } | |
| 104 | ||
| 105 | struct HgRevisionPage: Decodable, Sendable { | |
| 106 | let results: [HgRevision] | |
| 107 | let cursor: String? | |
| 108 | } | |
| 109 | ||
| 110 | struct HgRevision: Decodable, Sendable, Identifiable, Hashable { | |
| 111 | let id: String | |
| 112 | let author: String | |
| 113 | let description: String | |
| 114 | let branch: String? | |
| 115 | let tags: [String]? | |
| 116 | ||
| 117 | var displayShortId: String { | |
| 118 | String(id.prefix(12)) | |
| 119 | } | |
| 120 | ||
| 121 | var title: String { | |
| 122 | description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 123 | } | |
| 124 | ||
| 125 | var body: String? { | |
| 126 | let body = description | |
| 127 | .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false) | |
| 128 | .dropFirst() | |
| 129 | .first | |
| 130 | .map(String.init)? | |
| 131 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 132 | return body?.isEmpty == false ? body : nil | |
| 133 | } | |
| 134 | ||
| 135 | var primaryName: String { | |
| 136 | if let tag = tags?.first, !tag.isEmpty { | |
| 137 | return tag | |
| 138 | } | |
| 139 | if let branch, !branch.isEmpty { | |
| 140 | return branch | |
| 141 | } | |
| 142 | return displayShortId | |
| 143 | } | |
| 144 | } | |
| 145 | ||
| 146 | struct HgFile: Decodable, Sendable, Hashable, Identifiable { | |
| 147 | let name: String | |
| 148 | ||
| 149 | var id: String { name } | |
| 150 | ||
| 151 | var isDirectory: Bool { | |
| 152 | name.hasSuffix("/") | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable { | |
| 157 | let name: String | |
| 158 | let id: String | |
| 159 | ||
| 160 | var displayShortId: String { | |
| 161 | String(id.prefix(12)) | |
| 162 | } | |
| 163 | } | |
| 164 | ||
| 165 | @Observable | |
| 166 | @MainActor | |
| 167 | final class HgRepositoryDetailViewModel { | |
| 168 | enum Tab: String, CaseIterable { | |
| 169 | case summary = "Summary" | |
| 170 | case browse = "Browse" | |
| 171 | case log = "Log" | |
| 172 | case tags = "Tags" | |
| 173 | case branches = "Branches" | |
| 174 | case bookmarks = "Bookmarks" | |
| 175 | } | |
| 176 | ||
| 177 | enum ReadmeContent { | |
| 178 | case html(String) | |
| 179 | case markdown(String) | |
| 180 | case org(String) | |
| 181 | case plainText(String) | |
| 182 | } | |
| 183 | ||
| 184 | let repository: RepositorySummary | |
| 185 | private let client: SRHTClient | |
| 186 | ||
| 187 | private(set) var summaryLoaded = false | |
| 188 | private(set) var isLoadingSummary = false | |
| 189 | private(set) var readmeContent: ReadmeContent? | |
| 190 | private(set) var readmePath: String? | |
| 191 | private(set) var nonPublishing = false | |
| 192 | private(set) var tip: HgRevision? | |
| 193 | private(set) var branches: [HgNamedRevision] = [] | |
| 194 | private(set) var tags: [HgNamedRevision] = [] | |
| 195 | private(set) var bookmarks: [HgNamedRevision] = [] | |
| 196 | ||
| 197 | private(set) var log: [HgRevision] = [] | |
| 198 | private(set) var isLoadingLog = false | |
| 199 | private(set) var isLoadingMoreLog = false | |
| 200 | private var logCursor: String? | |
| 201 | private var hasMoreLog = true | |
| 202 | ||
| 203 | private(set) var currentBrowsePath = "" | |
| 204 | private(set) var pathStack: [String] = [] | |
| 205 | private(set) var files: [HgFile] = [] | |
| 206 | private(set) var fileContent: String? | |
| 207 | private(set) var selectedFilePath: String? | |
| 208 | private(set) var isLoadingBrowse = false | |
| 209 | private(set) var browseRevspec = "tip" | |
| 210 | ||
| 211 | var error: String? | |
| 212 | ||
| 213 | init(repository: RepositorySummary, client: SRHTClient) { | |
| 214 | self.repository = repository | |
| 215 | self.client = client | |
| 216 | } | |
| 217 | ||
| 218 | private static let summaryQuery = """ | |
| 219 | query hgRepositorySummary($rid: ID!) { | |
| 220 | repository(rid: $rid) { | |
| 221 | id | |
| 222 | rid | |
| 223 | name | |
| 224 | description | |
| 225 | visibility | |
| 226 | readme | |
| 227 | nonPublishing | |
| 228 | tip { | |
| 229 | id | |
| 230 | author | |
| 231 | description | |
| 232 | branch | |
| 233 | tags | |
| 234 | } | |
| 235 | branches { | |
| 236 | results { | |
| 237 | name | |
| 238 | id | |
| 239 | } | |
| 240 | cursor | |
| 241 | } | |
| 242 | tags { | |
| 243 | results { | |
| 244 | name | |
| 245 | id | |
| 246 | } | |
| 247 | cursor | |
| 248 | } | |
| 249 | bookmarks { | |
| 250 | results { | |
| 251 | name | |
| 252 | id | |
| 253 | } | |
| 254 | cursor | |
| 255 | } | |
| 256 | } | |
| 257 | } | |
| 258 | """ | |
| 259 | ||
| 260 | private static let logQuery = """ | |
| 261 | query hgRepositoryLog($rid: ID!, $cursor: Cursor) { | |
| 262 | repository(rid: $rid) { | |
| 263 | log(cursor: $cursor) { | |
| 264 | results { | |
| 265 | id | |
| 266 | author | |
| 267 | description | |
| 268 | branch | |
| 269 | tags | |
| 270 | } | |
| 271 | cursor | |
| 272 | } | |
| 273 | } | |
| 274 | } | |
| 275 | """ | |
| 276 | ||
| 277 | private static func readmeFileQuery(filename: String) -> String { | |
| 278 | """ | |
| 279 | query hgReadmeFile($rid: ID!) { | |
| 280 | repository(rid: $rid) { | |
| 281 | readme: cat(path: "\(filename)", revspec: "tip") | |
| 282 | } | |
| 283 | } | |
| 284 | """ | |
| 285 | } | |
| 286 | ||
| 287 | private static let readmeFilenames = [ | |
| 288 | "README.md", "README.org", "README.txt", "README", | |
| 289 | "readme.md", "readme.org" | |
| 290 | ] | |
| 291 | ||
| 292 | private static let filesQuery = """ | |
| 293 | query hgFiles($rid: ID!, $path: String!, $revspec: String!) { | |
| 294 | repository(rid: $rid) { | |
| 295 | files(path: $path, revspec: $revspec) { | |
| 296 | results { | |
| 297 | name | |
| 298 | } | |
| 299 | cursor | |
| 300 | } | |
| 301 | } | |
| 302 | } | |
| 303 | """ | |
| 304 | ||
| 305 | private static let catQuery = """ | |
| 306 | query hgCat($rid: ID!, $path: String!, $revspec: String!) { | |
| 307 | repository(rid: $rid) { | |
| 308 | cat(path: $path, revspec: $revspec) | |
| 309 | } | |
| 310 | } | |
| 311 | """ | |
| 312 | ||
| 313 | func loadSummary() async { | |
| 314 | guard !isLoadingSummary, !summaryLoaded else { return } | |
| 315 | isLoadingSummary = true | |
| 316 | defer { isLoadingSummary = false } | |
| 317 | error = nil | |
| 318 | ||
| 319 | do { | |
| 320 | let result = try await client.execute( | |
| 321 | service: .hg, | |
| 322 | query: Self.summaryQuery, | |
| 323 | variables: ["rid": repository.rid], | |
| 324 | responseType: HgRepositorySummaryResponse.self | |
| 325 | ) | |
| 326 | ||
| 327 | guard let repository = result.repository else { | |
| 328 | summaryLoaded = true | |
| 329 | return | |
| 330 | } | |
| 331 | ||
| 332 | tip = repository.tip?.resolvedRevision | |
| 333 | branches = repository.branches?.results ?? [] | |
| 334 | tags = repository.tags?.results ?? [] | |
| 335 | bookmarks = repository.bookmarks?.results ?? [] | |
| 336 | nonPublishing = repository.nonPublishing ?? false | |
| 337 | ||
| 338 | if let html = repository.readme, !html.isEmpty { | |
| 339 | readmePath = nil | |
| 340 | readmeContent = .html(html) | |
| 341 | } else { | |
| 342 | await loadReadmeFile() | |
| 343 | } | |
| 344 | ||
| 345 | summaryLoaded = true | |
| 346 | } catch { | |
| 347 | self.error = error.localizedDescription | |
| 348 | } | |
| 349 | } | |
| 350 | ||
| 351 | func loadLog() async { | |
| 352 | guard !isLoadingLog else { return } | |
| 353 | isLoadingLog = true | |
| 354 | defer { isLoadingLog = false } | |
| 355 | error = nil | |
| 356 | logCursor = nil | |
| 357 | hasMoreLog = true | |
| 358 | ||
| 359 | do { | |
| 360 | let page = try await fetchLogPage(cursor: nil) | |
| 361 | log = page.results | |
| 362 | logCursor = page.cursor | |
| 363 | hasMoreLog = page.cursor != nil | |
| 364 | } catch { | |
| 365 | if isEmptyRepositoryError(error) { | |
| 366 | log = [] | |
| 367 | logCursor = nil | |
| 368 | hasMoreLog = false | |
| 369 | } else { | |
| 370 | self.error = error.localizedDescription | |
| 371 | } | |
| 372 | } | |
| 373 | } | |
| 374 | ||
| 375 | func loadMoreLogIfNeeded(currentItem: HgRevision) async { | |
| 376 | guard let last = log.last, | |
| 377 | last.id == currentItem.id, | |
| 378 | hasMoreLog, | |
| 379 | !isLoadingMoreLog else { | |
| 380 | return | |
| 381 | } | |
| 382 | ||
| 383 | isLoadingMoreLog = true | |
| 384 | defer { isLoadingMoreLog = false } | |
| 385 | ||
| 386 | do { | |
| 387 | let page = try await fetchLogPage(cursor: logCursor) | |
| 388 | log.append(contentsOf: page.results) | |
| 389 | logCursor = page.cursor | |
| 390 | hasMoreLog = page.cursor != nil | |
| 391 | } catch { | |
| 392 | self.error = error.localizedDescription | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage { | |
| 397 | var variables: [String: any Sendable] = ["rid": repository.rid] | |
| 398 | if let cursor { | |
| 399 | variables["cursor"] = cursor | |
| 400 | } | |
| 401 | ||
| 402 | let result = try await client.execute( | |
| 403 | service: .hg, | |
| 404 | query: Self.logQuery, | |
| 405 | variables: variables, | |
| 406 | responseType: HgRevisionLogResponse.self | |
| 407 | ) | |
| 408 | return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil) | |
| 409 | } | |
| 410 | ||
| 411 | private func loadReadmeFile() async { | |
| 412 | for filename in Self.readmeFilenames { | |
| 413 | do { | |
| 414 | let result = try await client.execute( | |
| 415 | service: .hg, | |
| 416 | query: Self.readmeFileQuery(filename: filename), | |
| 417 | variables: ["rid": repository.rid], | |
| 418 | responseType: HgReadmeFileResponse.self | |
| 419 | ) | |
| 420 | ||
| 421 | if let text = result.repository?.readme, !text.isEmpty { | |
| 422 | readmePath = filename | |
| 423 | if filename.hasSuffix(".md") { | |
| 424 | readmeContent = .markdown(text) | |
| 425 | } else if filename.hasSuffix(".org") { | |
| 426 | readmeContent = .org(text) | |
| 427 | } else { | |
| 428 | readmeContent = .plainText(text) | |
| 429 | } | |
| 430 | return | |
| 431 | } | |
| 432 | } catch { | |
| 433 | if isEmptyRepositoryError(error) { | |
| 434 | readmeContent = nil | |
| 435 | readmePath = nil | |
| 436 | return | |
| 437 | } | |
| 438 | continue | |
| 439 | } | |
| 440 | } | |
| 441 | } | |
| 442 | ||
| 443 | func loadBrowseRoot() async { | |
| 444 | await loadFiles(at: "") | |
| 445 | } | |
| 446 | ||
| 447 | func openFile(_ file: HgFile) async { | |
| 448 | let path = joinedPath(for: file.name) | |
| 449 | if file.isDirectory { | |
| 450 | await loadFiles(at: path) | |
| 451 | return | |
| 452 | } | |
| 453 | ||
| 454 | isLoadingBrowse = true | |
| 455 | defer { isLoadingBrowse = false } | |
| 456 | error = nil | |
| 457 | ||
| 458 | do { | |
| 459 | let result = try await client.execute( | |
| 460 | service: .hg, | |
| 461 | query: Self.catQuery, | |
| 462 | variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], | |
| 463 | responseType: HgCatResponse.self | |
| 464 | ) | |
| 465 | ||
| 466 | if let text = result.repository?.cat { | |
| 467 | selectedFilePath = path | |
| 468 | fileContent = text | |
| 469 | } else { | |
| 470 | await loadFiles(at: path) | |
| 471 | } | |
| 472 | } catch { | |
| 473 | self.error = error.localizedDescription | |
| 474 | } | |
| 475 | } | |
| 476 | ||
| 477 | func navigateToPath(index: Int) async { | |
| 478 | guard index >= 0, index <= pathStack.count else { return } | |
| 479 | let targetPath = Array(pathStack.prefix(index)).joined(separator: "/") | |
| 480 | await loadFiles(at: targetPath) | |
| 481 | } | |
| 482 | ||
| 483 | func dismissFileView() { | |
| 484 | selectedFilePath = nil | |
| 485 | fileContent = nil | |
| 486 | } | |
| 487 | ||
| 488 | func changeBrowseRevspec(_ newRevspec: String) async { | |
| 489 | guard browseRevspec != newRevspec else { return } | |
| 490 | browseRevspec = newRevspec | |
| 491 | await loadBrowseRoot() | |
| 492 | } | |
| 493 | ||
| 494 | private func loadFiles(at path: String) async { | |
| 495 | isLoadingBrowse = true | |
| 496 | defer { isLoadingBrowse = false } | |
| 497 | error = nil | |
| 498 | selectedFilePath = nil | |
| 499 | fileContent = nil | |
| 500 | ||
| 501 | do { | |
| 502 | let result = try await client.execute( | |
| 503 | service: .hg, | |
| 504 | query: Self.filesQuery, | |
| 505 | variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec], | |
| 506 | responseType: HgFilesResponse.self | |
| 507 | ) | |
| 508 | ||
| 509 | currentBrowsePath = path | |
| 510 | pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) | |
| 511 | files = result.repository?.files?.results ?? [] | |
| 512 | } catch { | |
| 513 | if isEmptyRepositoryError(error) { | |
| 514 | currentBrowsePath = path | |
| 515 | pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init) | |
| 516 | files = [] | |
| 517 | } else { | |
| 518 | self.error = error.localizedDescription | |
| 519 | } | |
| 520 | } | |
| 521 | } | |
| 522 | ||
| 523 | private func joinedPath(for name: String) -> String { | |
| 524 | let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name | |
| 525 | return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)" | |
| 526 | } | |
| 527 | ||
| 528 | private func isEmptyRepositoryError(_ error: Error) -> Bool { | |
| 529 | if let srhtError = error as? SRHTError, | |
| 530 | case .graphQLErrors(let errors) = srhtError { | |
| 531 | return errors.contains { | |
| 532 | let message = $0.message.localizedLowercase | |
| 533 | return message.contains("missing") | |
| 534 | || message.contains("not found") | |
| 535 | || message.contains("unknown revision") | |
| 536 | || message.contains("unknown revision or path not in the working tree") | |
| 537 | } | |
| 538 | } | |
| 539 | ||
| 540 | let message = error.localizedDescription.localizedLowercase | |
| 541 | return message.contains("missing") | |
| 542 | || message.contains("not found") | |
| 543 | || message.contains("unknown revision") | |
| 544 | } | |
| 545 | } | |
Hutch/Views/Repositories/HgRepositorySettingsView.swift added +239
| @@ -0,0 +1,239 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct HgRepositorySettingsView: View { | |
| 4 | let repository: RepositorySummary | |
| 5 | let client: SRHTClient | |
| 6 | let onDeleted: () -> Void | |
| 7 | ||
| 8 | @Environment(\.dismiss) private var dismiss | |
| 9 | @State private var viewModel: HgRepositorySettingsViewModel? | |
| 10 | @State private var showDeleteConfirmation = false | |
| 11 | @State private var pendingACLDeletion: HgACLEntry? | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | NavigationStack { | |
| 15 | Group { | |
| 16 | if let viewModel { | |
| 17 | settingsForm(viewModel) | |
| 18 | } else { | |
| 19 | SRHTLoadingStateView(message: "Loading settings…") | |
| 20 | } | |
| 21 | } | |
| 22 | .navigationTitle("Settings") | |
| 23 | .navigationBarTitleDisplayMode(.inline) | |
| 24 | .toolbar { | |
| 25 | ToolbarItem(placement: .cancellationAction) { | |
| 26 | Button("Done") { dismiss() } | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | .task { | |
| 31 | if viewModel == nil { | |
| 32 | let vm = HgRepositorySettingsViewModel(repository: repository, client: client) | |
| 33 | viewModel = vm | |
| 34 | async let info: () = vm.loadRepositoryInfo() | |
| 35 | async let acls: () = vm.loadACLs() | |
| 36 | _ = await (info, acls) | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | @ViewBuilder | |
| 42 | private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 43 | @Bindable var vm = viewModel | |
| 44 | ||
| 45 | Form { | |
| 46 | infoSection(viewModel) | |
| 47 | accessSection(viewModel) | |
| 48 | featuresSection(viewModel) | |
| 49 | histeditSection(viewModel) | |
| 50 | deleteSection(viewModel) | |
| 51 | } | |
| 52 | .srhtErrorBanner(error: $vm.error) | |
| 53 | .alert( | |
| 54 | "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", | |
| 55 | isPresented: $showDeleteConfirmation | |
| 56 | ) { | |
| 57 | Button("Cancel", role: .cancel) {} | |
| 58 | Button("Delete", role: .destructive) { | |
| 59 | Task { | |
| 60 | await viewModel.deleteRepository() | |
| 61 | if viewModel.didDelete { | |
| 62 | dismiss() | |
| 63 | onDeleted() | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | } message: { | |
| 68 | Text("This cannot be undone.") | |
| 69 | } | |
| 70 | .alert("Remove Access?", isPresented: Binding( | |
| 71 | get: { pendingACLDeletion != nil }, | |
| 72 | set: { isPresented in | |
| 73 | if !isPresented { | |
| 74 | pendingACLDeletion = nil | |
| 75 | } | |
| 76 | } | |
| 77 | )) { | |
| 78 | Button("Cancel", role: .cancel) {} | |
| 79 | Button("Remove Access", role: .destructive) { | |
| 80 | guard let entry = pendingACLDeletion else { return } | |
| 81 | Task { | |
| 82 | await viewModel.deleteACL(entry) | |
| 83 | pendingACLDeletion = nil | |
| 84 | } | |
| 85 | } | |
| 86 | } message: { | |
| 87 | if let entry = pendingACLDeletion { | |
| 88 | Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") | |
| 89 | } | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | @ViewBuilder | |
| 94 | private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 95 | Section("Info") { | |
| 96 | LabeledContent("Name") { | |
| 97 | Text(repository.name) | |
| 98 | .font(.body.monospaced()) | |
| 99 | } | |
| 100 | ||
| 101 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) | |
| 102 | .lineLimit(3...6) | |
| 103 | ||
| 104 | Picker("Visibility", selection: Bindable(viewModel).editedVisibility) { | |
| 105 | Text("Public").tag(Visibility.public) | |
| 106 | Text("Unlisted").tag(Visibility.unlisted) | |
| 107 | Text("Private").tag(Visibility.private) | |
| 108 | } | |
| 109 | ||
| 110 | Button { | |
| 111 | Task { await viewModel.saveInfo() } | |
| 112 | } label: { | |
| 113 | if viewModel.isSavingInfo { | |
| 114 | ProgressView() | |
| 115 | .frame(maxWidth: .infinity) | |
| 116 | } else { | |
| 117 | Text("Save Changes") | |
| 118 | .frame(maxWidth: .infinity) | |
| 119 | } | |
| 120 | } | |
| 121 | .disabled(viewModel.isSavingInfo) | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | @ViewBuilder | |
| 126 | private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 127 | Section("Access") { | |
| 128 | if viewModel.isLoadingACLs { | |
| 129 | HStack { | |
| 130 | Spacer() | |
| 131 | ProgressView() | |
| 132 | Spacer() | |
| 133 | } | |
| 134 | } else if viewModel.acls.isEmpty { | |
| 135 | Text("No access entries yet.") | |
| 136 | .foregroundStyle(.secondary) | |
| 137 | } else { | |
| 138 | ForEach(viewModel.acls) { entry in | |
| 139 | HStack { | |
| 140 | Text(entry.entity.canonicalName) | |
| 141 | Spacer() | |
| 142 | Text(entry.mode) | |
| 143 | .font(.caption.monospaced()) | |
| 144 | .foregroundStyle(.secondary) | |
| 145 | } | |
| 146 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 147 | Button(role: .destructive) { | |
| 148 | pendingACLDeletion = entry | |
| 149 | } label: { | |
| 150 | Label("Remove Access", systemImage: "trash") | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | HStack { | |
| 157 | TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) | |
| 158 | .autocorrectionDisabled() | |
| 159 | .textInputAutocapitalization(.never) | |
| 160 | ||
| 161 | Picker("", selection: Bindable(viewModel).newACLMode) { | |
| 162 | Text("RO").tag("RO") | |
| 163 | Text("RW").tag("RW") | |
| 164 | } | |
| 165 | .pickerStyle(.segmented) | |
| 166 | .frame(width: 100) | |
| 167 | ||
| 168 | Button { | |
| 169 | Task { await viewModel.addACL() } | |
| 170 | } label: { | |
| 171 | if viewModel.isAddingACL { | |
| 172 | ProgressView() | |
| 173 | } else { | |
| 174 | Text("Add") | |
| 175 | } | |
| 176 | } | |
| 177 | .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 178 | } | |
| 179 | Text("Add a SourceHut user and choose read-only or read/write access.") | |
| 180 | .font(.caption) | |
| 181 | .foregroundStyle(.secondary) | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | @ViewBuilder | |
| 186 | private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 187 | Section("Features") { | |
| 188 | Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing) | |
| 189 | ||
| 190 | Button { | |
| 191 | Task { await viewModel.saveInfo() } | |
| 192 | } label: { | |
| 193 | if viewModel.isSavingInfo { | |
| 194 | ProgressView() | |
| 195 | .frame(maxWidth: .infinity) | |
| 196 | } else { | |
| 197 | Text("Save Changes") | |
| 198 | .frame(maxWidth: .infinity) | |
| 199 | } | |
| 200 | } | |
| 201 | .disabled(viewModel.isSavingInfo) | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | @ViewBuilder | |
| 206 | private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 207 | Section("Histedit") { | |
| 208 | TextField("Revision hash", text: Bindable(viewModel).histeditRevision) | |
| 209 | .autocorrectionDisabled() | |
| 210 | .textInputAutocapitalization(.never) | |
| 211 | .disabled(true) | |
| 212 | ||
| 213 | Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.") | |
| 214 | .font(.caption) | |
| 215 | .foregroundStyle(.secondary) | |
| 216 | ||
| 217 | Button("Remove Revision", role: .destructive) {} | |
| 218 | .disabled(true) | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | @ViewBuilder | |
| 223 | private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View { | |
| 224 | Section { | |
| 225 | Button(role: .destructive) { | |
| 226 | showDeleteConfirmation = true | |
| 227 | } label: { | |
| 228 | if viewModel.isDeleting { | |
| 229 | ProgressView() | |
| 230 | .frame(maxWidth: .infinity) | |
| 231 | } else { | |
| 232 | Text("Delete Repository") | |
| 233 | .frame(maxWidth: .infinity) | |
| 234 | } | |
| 235 | } | |
| 236 | .disabled(viewModel.isDeleting) | |
| 237 | } | |
| 238 | } | |
| 239 | } | |
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift added +287
| @@ -0,0 +1,287 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | private struct HgUpdateRepositoryResponse: Decodable, Sendable { | |
| 4 | let updateRepository: HgUpdatedRepository | |
| 5 | } | |
| 6 | ||
| 7 | private struct HgUpdatedRepository: Decodable, Sendable { | |
| 8 | let id: Int | |
| 9 | } | |
| 10 | ||
| 11 | private struct HgRepositoryInfoResponse: Decodable, Sendable { | |
| 12 | let repository: HgRepositoryInfo? | |
| 13 | } | |
| 14 | ||
| 15 | private struct HgRepositoryInfo: Decodable, Sendable { | |
| 16 | let description: String? | |
| 17 | let visibility: Visibility | |
| 18 | let nonPublishing: Bool? | |
| 19 | } | |
| 20 | ||
| 21 | private struct HgACLResponse: Decodable, Sendable { | |
| 22 | let repository: HgACLRepository? | |
| 23 | } | |
| 24 | ||
| 25 | private struct HgACLRepository: Decodable, Sendable { | |
| 26 | let accessControlList: HgACLPage | |
| 27 | } | |
| 28 | ||
| 29 | private struct HgACLPage: Decodable, Sendable { | |
| 30 | let results: [HgACLEntry] | |
| 31 | let cursor: String? | |
| 32 | } | |
| 33 | ||
| 34 | private struct HgUpdateACLResponse: Decodable, Sendable { | |
| 35 | let updateACL: HgACLEntry | |
| 36 | } | |
| 37 | ||
| 38 | private struct HgDeleteACLResponse: Decodable, Sendable { | |
| 39 | let deleteACL: HgDeletedACL | |
| 40 | } | |
| 41 | ||
| 42 | private struct HgDeletedACL: Decodable, Sendable { | |
| 43 | let id: Int | |
| 44 | } | |
| 45 | ||
| 46 | private struct HgDeleteRepositoryResponse: Decodable, Sendable { | |
| 47 | let deleteRepository: HgDeletedRepository | |
| 48 | } | |
| 49 | ||
| 50 | private struct HgDeletedRepository: Decodable, Sendable { | |
| 51 | let id: Int | |
| 52 | } | |
| 53 | ||
| 54 | struct HgACLEntry: Decodable, Sendable, Identifiable { | |
| 55 | let id: Int | |
| 56 | let mode: String | |
| 57 | let entity: Entity | |
| 58 | } | |
| 59 | ||
| 60 | @Observable | |
| 61 | @MainActor | |
| 62 | final class HgRepositorySettingsViewModel { | |
| 63 | let repositoryId: Int | |
| 64 | let repositoryRid: String | |
| 65 | let repositoryName: String | |
| 66 | private let client: SRHTClient | |
| 67 | ||
| 68 | var editedDescription: String | |
| 69 | var editedVisibility: Visibility | |
| 70 | var editedNonPublishing: Bool | |
| 71 | var isSavingInfo = false | |
| 72 | ||
| 73 | private(set) var acls: [HgACLEntry] = [] | |
| 74 | private(set) var isLoadingACLs = false | |
| 75 | var newACLEntity = "" | |
| 76 | var newACLMode = "RO" | |
| 77 | var isAddingACL = false | |
| 78 | var isDeletingACL = false | |
| 79 | ||
| 80 | var histeditRevision = "" | |
| 81 | var isDeleting = false | |
| 82 | var didDelete = false | |
| 83 | var error: String? | |
| 84 | ||
| 85 | init(repository: RepositorySummary, client: SRHTClient) { | |
| 86 | self.repositoryId = repository.id | |
| 87 | self.repositoryRid = repository.rid | |
| 88 | self.repositoryName = repository.name | |
| 89 | self.client = client | |
| 90 | self.editedDescription = repository.description ?? "" | |
| 91 | self.editedVisibility = repository.visibility | |
| 92 | self.editedNonPublishing = false | |
| 93 | } | |
| 94 | ||
| 95 | private static let updateRepositoryMutation = """ | |
| 96 | mutation updateRepository($id: Int!, $input: RepoInput!) { | |
| 97 | updateRepository(id: $id, input: $input) { | |
| 98 | id | |
| 99 | } | |
| 100 | } | |
| 101 | """ | |
| 102 | ||
| 103 | private static let accessControlListQuery = """ | |
| 104 | query hgAccessControlList($rid: ID!) { | |
| 105 | repository(rid: $rid) { | |
| 106 | accessControlList { | |
| 107 | results { | |
| 108 | id | |
| 109 | mode | |
| 110 | entity { canonicalName } | |
| 111 | } | |
| 112 | cursor | |
| 113 | } | |
| 114 | } | |
| 115 | } | |
| 116 | """ | |
| 117 | ||
| 118 | private static let updateACLMutation = """ | |
| 119 | mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) { | |
| 120 | updateACL(repoId: $repoId, mode: $mode, entity: $entity) { | |
| 121 | id | |
| 122 | mode | |
| 123 | entity { canonicalName } | |
| 124 | } | |
| 125 | } | |
| 126 | """ | |
| 127 | ||
| 128 | private static let deleteACLMutation = """ | |
| 129 | mutation deleteACL($id: Int!) { | |
| 130 | deleteACL(id: $id) { id } | |
| 131 | } | |
| 132 | """ | |
| 133 | ||
| 134 | private static let deleteRepositoryMutation = """ | |
| 135 | mutation deleteRepository($id: Int!) { | |
| 136 | deleteRepository(id: $id) { id } | |
| 137 | } | |
| 138 | """ | |
| 139 | ||
| 140 | private static let repositoryInfoQuery = """ | |
| 141 | query hgRepositoryInfo($rid: ID!) { | |
| 142 | repository(rid: $rid) { | |
| 143 | description | |
| 144 | visibility | |
| 145 | nonPublishing | |
| 146 | } | |
| 147 | } | |
| 148 | """ | |
| 149 | ||
| 150 | func loadRepositoryInfo() async { | |
| 151 | error = nil | |
| 152 | ||
| 153 | do { | |
| 154 | let result = try await client.execute( | |
| 155 | service: .hg, | |
| 156 | query: Self.repositoryInfoQuery, | |
| 157 | variables: ["rid": repositoryRid], | |
| 158 | responseType: HgRepositoryInfoResponse.self | |
| 159 | ) | |
| 160 | ||
| 161 | if let repository = result.repository { | |
| 162 | editedDescription = repository.description ?? "" | |
| 163 | editedVisibility = repository.visibility | |
| 164 | editedNonPublishing = repository.nonPublishing ?? false | |
| 165 | } | |
| 166 | } catch { | |
| 167 | self.error = error.localizedDescription | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | func saveInfo() async { | |
| 172 | isSavingInfo = true | |
| 173 | defer { isSavingInfo = false } | |
| 174 | error = nil | |
| 175 | ||
| 176 | do { | |
| 177 | let input: [String: any Sendable] = [ | |
| 178 | "description": editedDescription, | |
| 179 | "visibility": editedVisibility.rawValue, | |
| 180 | "nonPublishing": editedNonPublishing | |
| 181 | ] | |
| 182 | _ = try await client.execute( | |
| 183 | service: .hg, | |
| 184 | query: Self.updateRepositoryMutation, | |
| 185 | variables: ["id": repositoryId, "input": input], | |
| 186 | responseType: HgUpdateRepositoryResponse.self | |
| 187 | ) | |
| 188 | } catch { | |
| 189 | self.error = error.localizedDescription | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | func loadACLs() async { | |
| 194 | guard !isLoadingACLs else { return } | |
| 195 | isLoadingACLs = true | |
| 196 | defer { isLoadingACLs = false } | |
| 197 | error = nil | |
| 198 | ||
| 199 | do { | |
| 200 | let result = try await client.execute( | |
| 201 | service: .hg, | |
| 202 | query: Self.accessControlListQuery, | |
| 203 | variables: ["rid": repositoryRid], | |
| 204 | responseType: HgACLResponse.self | |
| 205 | ) | |
| 206 | acls = result.repository?.accessControlList.results ?? [] | |
| 207 | } catch { | |
| 208 | self.error = error.localizedDescription | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | func addACL() async { | |
| 213 | let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 214 | guard !rawEntity.isEmpty else { return } | |
| 215 | let entity = hgCanonicalEntity(from: rawEntity) | |
| 216 | isAddingACL = true | |
| 217 | defer { isAddingACL = false } | |
| 218 | error = nil | |
| 219 | ||
| 220 | do { | |
| 221 | let result = try await client.execute( | |
| 222 | service: .hg, | |
| 223 | query: Self.updateACLMutation, | |
| 224 | variables: [ | |
| 225 | "repoId": repositoryId, | |
| 226 | "mode": newACLMode, | |
| 227 | "entity": entity | |
| 228 | ], | |
| 229 | responseType: HgUpdateACLResponse.self | |
| 230 | ) | |
| 231 | if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) { | |
| 232 | acls[index] = result.updateACL | |
| 233 | } else { | |
| 234 | acls.append(result.updateACL) | |
| 235 | } | |
| 236 | newACLEntity = "" | |
| 237 | } catch { | |
| 238 | let message = error.localizedDescription | |
| 239 | if message.localizedCaseInsensitiveContains("No such repository or user found") { | |
| 240 | self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first." | |
| 241 | } else { | |
| 242 | self.error = message | |
| 243 | } | |
| 244 | } | |
| 245 | } | |
| 246 | ||
| 247 | private func hgCanonicalEntity(from input: String) -> String { | |
| 248 | let username = input.hasPrefix("~") ? String(input.dropFirst()) : input | |
| 249 | return "~\(username)" | |
| 250 | } | |
| 251 | ||
| 252 | func deleteACL(_ entry: HgACLEntry) async { | |
| 253 | isDeletingACL = true | |
| 254 | defer { isDeletingACL = false } | |
| 255 | error = nil | |
| 256 | ||
| 257 | do { | |
| 258 | _ = try await client.execute( | |
| 259 | service: .hg, | |
| 260 | query: Self.deleteACLMutation, | |
| 261 | variables: ["id": entry.id], | |
| 262 | responseType: HgDeleteACLResponse.self | |
| 263 | ) | |
| 264 | acls.removeAll { $0.id == entry.id } | |
| 265 | } catch { | |
| 266 | self.error = error.localizedDescription | |
| 267 | } | |
| 268 | } | |
| 269 | ||
| 270 | func deleteRepository() async { | |
| 271 | isDeleting = true | |
| 272 | defer { isDeleting = false } | |
| 273 | error = nil | |
| 274 | ||
| 275 | do { | |
| 276 | _ = try await client.execute( | |
| 277 | service: .hg, | |
| 278 | query: Self.deleteRepositoryMutation, | |
| 279 | variables: ["id": repositoryId], | |
| 280 | responseType: HgDeleteRepositoryResponse.self | |
| 281 | ) | |
| 282 | didDelete = true | |
| 283 | } catch { | |
| 284 | self.error = error.localizedDescription | |
| 285 | } | |
| 286 | } | |
| 287 | } | |
Hutch/Views/Repositories/ReadmeView.swift added +1125
| @@ -0,0 +1,1125 @@ | ||
| 1 | import SwiftUI | |
| 2 | import WebKit | |
| 3 | ||
| 4 | struct ReadmeView: View { | |
| 5 | let viewModel: RepositoryDetailViewModel | |
| 6 | ||
| 7 | @Environment(\.colorScheme) private var colorScheme | |
| 8 | @State private var isShowingRepositoryDetails = false | |
| 9 | ||
| 10 | var body: some View { | |
| 11 | ScrollView { | |
| 12 | VStack(alignment: .leading, spacing: 16) { | |
| 13 | headerSection | |
| 14 | metadataSection | |
| 15 | repositoryDetailsSection | |
| 16 | latestChangeSection | |
| 17 | readmeSection | |
| 18 | } | |
| 19 | .padding() | |
| 20 | } | |
| 21 | .task { | |
| 22 | async let readme: () = viewModel.loadReadme() | |
| 23 | async let commits: () = viewModel.loadCommits() | |
| 24 | async let refs: () = viewModel.loadReferences() | |
| 25 | _ = await (readme, commits, refs) | |
| 26 | } | |
| 27 | .navigationDestination(for: CommitSummary.self) { commit in | |
| 28 | CommitDetailView( | |
| 29 | commitSummary: commit, | |
| 30 | repository: viewModel.repository | |
| 31 | ) | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | @ViewBuilder | |
| 36 | private var headerSection: some View { | |
| 37 | VStack(alignment: .leading, spacing: 6) { | |
| 38 | Text(viewModel.repository.owner.canonicalName) | |
| 39 | .font(.subheadline) | |
| 40 | .foregroundStyle(.secondary) | |
| 41 | Text(viewModel.repository.name) | |
| 42 | .font(.largeTitle.weight(.semibold)) | |
| 43 | if let description = viewModel.repository.description, !description.isEmpty { | |
| 44 | Text(description) | |
| 45 | .font(.body) | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | @ViewBuilder | |
| 51 | private var metadataSection: some View { | |
| 52 | VStack(alignment: .leading, spacing: 10) { | |
| 53 | SummaryMetadataRow( | |
| 54 | icon: "arrow.triangle.branch", | |
| 55 | title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility) | |
| 56 | ) | |
| 57 | ||
| 58 | if let readmePath = viewModel.readmePath { | |
| 59 | SummaryMetadataRow( | |
| 60 | icon: "doc.text", | |
| 61 | title: readmePath | |
| 62 | ) | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | private var repositoryDetailsSection: some View { | |
| 68 | DisclosureGroup(isExpanded: $isShowingRepositoryDetails) { | |
| 69 | VStack(alignment: .leading, spacing: 12) { | |
| 70 | SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility)) | |
| 71 | SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true) | |
| 72 | SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true) | |
| 73 | SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true) | |
| 74 | } | |
| 75 | .padding(.top, 8) | |
| 76 | } label: { | |
| 77 | Text("Repository Details") | |
| 78 | .font(.subheadline.weight(.medium)) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | @ViewBuilder | |
| 83 | private var latestChangeSection: some View { | |
| 84 | VStack(alignment: .leading, spacing: 8) { | |
| 85 | if viewModel.isLoadingCommits && viewModel.commits.isEmpty { | |
| 86 | SRHTLoadingStateView(message: "Loading latest change…") | |
| 87 | .frame(maxWidth: .infinity) | |
| 88 | } else if let commit = viewModel.commits.first { | |
| 89 | NavigationLink(value: commit) { | |
| 90 | SummaryMetadataRow( | |
| 91 | icon: "arrow.trianglehead.clockwise", | |
| 92 | title: commit.title, | |
| 93 | subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)" | |
| 94 | ) | |
| 95 | .contentShape(Rectangle()) | |
| 96 | } | |
| 97 | .buttonStyle(.plain) | |
| 98 | } else if let error = viewModel.error, viewModel.commits.isEmpty { | |
| 99 | SRHTErrorStateView( | |
| 100 | title: "Couldn't Load Latest Change", | |
| 101 | message: error, | |
| 102 | retryAction: { await viewModel.loadCommits() } | |
| 103 | ) | |
| 104 | } else { | |
| 105 | ContentUnavailableView( | |
| 106 | "No Recent Commits", | |
| 107 | systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90", | |
| 108 | description: Text("This repository does not have any commit history yet.") | |
| 109 | ) | |
| 110 | } | |
| 111 | } | |
| 112 | } | |
| 113 | ||
| 114 | @ViewBuilder | |
| 115 | private var readmeSection: some View { | |
| 116 | if viewModel.isLoadingReadme { | |
| 117 | SRHTLoadingStateView(message: "Loading README…") | |
| 118 | } else if let content = viewModel.readmeContent { | |
| 119 | RenderedMarkupContentView( | |
| 120 | content: sharedReadmeContent(from: content), | |
| 121 | readmePath: viewModel.readmePath, | |
| 122 | colorScheme: colorScheme, | |
| 123 | ownerCanonicalName: viewModel.repository.owner.canonicalName, | |
| 124 | repositoryName: viewModel.repository.name | |
| 125 | ) | |
| 126 | } else if let error = viewModel.error, !viewModel.readmeLoaded { | |
| 127 | SRHTErrorStateView( | |
| 128 | title: "Couldn't Load README", | |
| 129 | message: error, | |
| 130 | retryAction: { await viewModel.loadReadme() } | |
| 131 | ) | |
| 132 | } else { | |
| 133 | ContentUnavailableView( | |
| 134 | "No README", | |
| 135 | systemImage: "doc.text", | |
| 136 | description: Text("This repository does not have a README file.") | |
| 137 | ) | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent { | |
| 142 | switch content { | |
| 143 | case .html(let html): | |
| 144 | .html(html) | |
| 145 | case .markdown(let text): | |
| 146 | .markdown(text) | |
| 147 | case .org(let text): | |
| 148 | .org(text) | |
| 149 | case .plainText(let text): | |
| 150 | .plainText(text) | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | enum RenderedMarkupContent: Sendable { | |
| 156 | case html(String) | |
| 157 | case markdown(String) | |
| 158 | case org(String) | |
| 159 | case plainText(String) | |
| 160 | } | |
| 161 | ||
| 162 | struct RenderedMarkupContentView: View { | |
| 163 | let content: RenderedMarkupContent | |
| 164 | let readmePath: String? | |
| 165 | let colorScheme: ColorScheme | |
| 166 | let ownerCanonicalName: String | |
| 167 | let repositoryName: String | |
| 168 | var repositoryHost = "git.sr.ht" | |
| 169 | ||
| 170 | @State private var renderedHTML: String? | |
| 171 | ||
| 172 | private var cacheKey: String { | |
| 173 | switch content { | |
| 174 | case .html(let html): | |
| 175 | "html:\(readmePath ?? "custom"):\(html)" | |
| 176 | case .markdown(let text): | |
| 177 | "markdown:\(readmePath ?? ""):\(text)" | |
| 178 | case .org(let text): | |
| 179 | "org:\(readmePath ?? ""):\(text)" | |
| 180 | case .plainText(let text): | |
| 181 | "plain:\(readmePath ?? ""):\(text)" | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | var body: some View { | |
| 186 | Group { | |
| 187 | switch content { | |
| 188 | case .html(let html): | |
| 189 | HTMLWebView(html: html, colorScheme: colorScheme) | |
| 190 | case .markdown, .org: | |
| 191 | if let renderedHTML { | |
| 192 | HTMLWebView(html: renderedHTML, colorScheme: colorScheme) | |
| 193 | } else { | |
| 194 | SRHTLoadingStateView(message: "Preparing README…") | |
| 195 | } | |
| 196 | case .plainText(let text): | |
| 197 | Text(text) | |
| 198 | .font(.system(.body, design: .monospaced)) | |
| 199 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 200 | } | |
| 201 | } | |
| 202 | .task(id: cacheKey) { | |
| 203 | await prepareHTMLIfNeeded() | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | private func prepareHTMLIfNeeded() async { | |
| 208 | switch content { | |
| 209 | case .html, .plainText: | |
| 210 | renderedHTML = nil | |
| 211 | case .markdown(let text): | |
| 212 | if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { | |
| 213 | renderedHTML = cached | |
| 214 | return | |
| 215 | } | |
| 216 | let html = await Task.detached(priority: .userInitiated) { | |
| 217 | markdownToHTML(text) { source in | |
| 218 | resolveRepositoryAssetURL( | |
| 219 | source, | |
| 220 | owner: ownerCanonicalName, | |
| 221 | repositoryName: repositoryName, | |
| 222 | readmePath: readmePath | |
| 223 | )? | |
| 224 | .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) | |
| 225 | } | |
| 226 | }.value | |
| 227 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) | |
| 228 | guard !Task.isCancelled else { return } | |
| 229 | renderedHTML = html | |
| 230 | case .org(let text): | |
| 231 | if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) { | |
| 232 | renderedHTML = cached | |
| 233 | return | |
| 234 | } | |
| 235 | let html = await Task.detached(priority: .userInitiated) { | |
| 236 | orgToHTML(text) { source in | |
| 237 | resolveRepositoryAssetURL( | |
| 238 | source, | |
| 239 | owner: ownerCanonicalName, | |
| 240 | repositoryName: repositoryName, | |
| 241 | readmePath: readmePath | |
| 242 | )? | |
| 243 | .replacingOccurrences(of: "git.sr.ht", with: repositoryHost) | |
| 244 | } | |
| 245 | }.value | |
| 246 | RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) | |
| 247 | guard !Task.isCancelled else { return } | |
| 248 | renderedHTML = html | |
| 249 | } | |
| 250 | } | |
| 251 | } | |
| 252 | ||
| 253 | private final class RenderedReadmeHTMLCache: @unchecked Sendable { | |
| 254 | static let shared = RenderedReadmeHTMLCache() | |
| 255 | ||
| 256 | private let storage = NSCache<NSString, NSString>() | |
| 257 | ||
| 258 | func html(forKey key: String) -> String? { | |
| 259 | storage.object(forKey: key as NSString) as String? | |
| 260 | } | |
| 261 | ||
| 262 | func setHTML(_ html: String, forKey key: String) { | |
| 263 | storage.setObject(html as NSString, forKey: key as NSString) | |
| 264 | } | |
| 265 | ||
| 266 | func removeAll() { | |
| 267 | storage.removeAllObjects() | |
| 268 | } | |
| 269 | } | |
| 270 | ||
| 271 | @MainActor | |
| 272 | func clearWebContentRenderCaches() { | |
| 273 | RenderedReadmeHTMLCache.shared.removeAll() | |
| 274 | HTMLWebViewCoordinator.heightCache.removeAllObjects() | |
| 275 | } | |
| 276 | ||
| 277 | // MARK: - Markdown to HTML | |
| 278 | ||
| 279 | nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { | |
| 280 | let normalizedText = text | |
| 281 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 282 | .replacingOccurrences(of: "\r", with: "\n") | |
| 283 | let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| 284 | var html = "" | |
| 285 | var inCodeBlock = false | |
| 286 | var inList = false | |
| 287 | var paragraph: [String] = [] | |
| 288 | ||
| 289 | func flushParagraph() { | |
| 290 | if !paragraph.isEmpty { | |
| 291 | let normalizedParagraph = paragraph | |
| 292 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 293 | .joined(separator: " ") | |
| 294 | html += "<p>" + normalizedParagraph + "</p>\n" | |
| 295 | paragraph = [] | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | func closeList() { | |
| 300 | if inList { | |
| 301 | html += "</ul>\n" | |
| 302 | inList = false | |
| 303 | } | |
| 304 | } | |
| 305 | ||
| 306 | for line in lines { | |
| 307 | // Fenced code blocks | |
| 308 | if line.hasPrefix("```") { | |
| 309 | if inCodeBlock { | |
| 310 | html += "</code></pre>\n" | |
| 311 | inCodeBlock = false | |
| 312 | } else { | |
| 313 | flushParagraph() | |
| 314 | closeList() | |
| 315 | html += "<pre><code>" | |
| 316 | inCodeBlock = true | |
| 317 | } | |
| 318 | continue | |
| 319 | } | |
| 320 | ||
| 321 | if inCodeBlock { | |
| 322 | html += escapeHTML(line) + "\n" | |
| 323 | continue | |
| 324 | } | |
| 325 | ||
| 326 | // Headings | |
| 327 | if line.hasPrefix("### ") { | |
| 328 | flushParagraph() | |
| 329 | closeList() | |
| 330 | html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n" | |
| 331 | continue | |
| 332 | } | |
| 333 | if line.hasPrefix("## ") { | |
| 334 | flushParagraph() | |
| 335 | closeList() | |
| 336 | html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n" | |
| 337 | continue | |
| 338 | } | |
| 339 | if line.hasPrefix("# ") { | |
| 340 | flushParagraph() | |
| 341 | closeList() | |
| 342 | html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n" | |
| 343 | continue | |
| 344 | } | |
| 345 | ||
| 346 | // List items | |
| 347 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 348 | if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { | |
| 349 | flushParagraph() | |
| 350 | if !inList { | |
| 351 | html += "<ul>\n" | |
| 352 | inList = true | |
| 353 | } | |
| 354 | html += "<li>" + renderTaskListItem( | |
| 355 | String(trimmed.dropFirst(2)), | |
| 356 | inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } | |
| 357 | ) + "</li>\n" | |
| 358 | continue | |
| 359 | } | |
| 360 | ||
| 361 | // Blank line | |
| 362 | if trimmed.isEmpty { | |
| 363 | flushParagraph() | |
| 364 | closeList() | |
| 365 | continue | |
| 366 | } | |
| 367 | ||
| 368 | // Regular text — accumulate into paragraph | |
| 369 | paragraph.append(processInline(line, imageURLResolver: imageURLResolver)) | |
| 370 | } | |
| 371 | ||
| 372 | // Flush remaining state | |
| 373 | if inCodeBlock { | |
| 374 | html += "</code></pre>\n" | |
| 375 | } | |
| 376 | flushParagraph() | |
| 377 | closeList() | |
| 378 | ||
| 379 | return html | |
| 380 | } | |
| 381 | ||
| 382 | nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { | |
| 383 | var result = escapeHTML(text) | |
| 384 | ||
| 385 | // Images:  | |
| 386 | result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in | |
| 387 | let alt = nsText.substring(with: match.range(at: 1)) | |
| 388 | let source = nsText.substring(with: match.range(at: 2)) | |
| 389 | let resolvedSource = imageURLResolver?(source) ?? source | |
| 390 | return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# | |
| 391 | } | |
| 392 | // Links: [text](url) | |
| 393 | result = result.replacingOccurrences( | |
| 394 | of: #"\[([^\]]+)\]\(([^)]+)\)"#, | |
| 395 | with: #"<a href="$2">$1</a>"#, | |
| 396 | options: .regularExpression | |
| 397 | ) | |
| 398 | // Bold: **text** | |
| 399 | result = result.replacingOccurrences( | |
| 400 | of: #"\*\*(.+?)\*\*"#, | |
| 401 | with: "<strong>$1</strong>", | |
| 402 | options: .regularExpression | |
| 403 | ) | |
| 404 | // Italic: *text* | |
| 405 | result = result.replacingOccurrences( | |
| 406 | of: #"\*(.+?)\*"#, | |
| 407 | with: "<em>$1</em>", | |
| 408 | options: .regularExpression | |
| 409 | ) | |
| 410 | // Inline code: `text` | |
| 411 | result = result.replacingOccurrences( | |
| 412 | of: #"`([^`]+)`"#, | |
| 413 | with: "<code>$1</code>", | |
| 414 | options: .regularExpression | |
| 415 | ) | |
| 416 | ||
| 417 | return result | |
| 418 | } | |
| 419 | ||
| 420 | // MARK: - Org-mode to HTML | |
| 421 | ||
| 422 | nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { | |
| 423 | let normalizedText = text | |
| 424 | .replacingOccurrences(of: "\r\n", with: "\n") | |
| 425 | .replacingOccurrences(of: "\r", with: "\n") | |
| 426 | let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) | |
| 427 | var html = "" | |
| 428 | var listType: OrgListType? | |
| 429 | var inQuoteBlock = false | |
| 430 | var inPropertyDrawer = false | |
| 431 | var srcLanguage: String? | |
| 432 | var paragraph: [String] = [] | |
| 433 | var tableRows: [[String]] = [] | |
| 434 | var propertyRows: [(String, String)] = [] | |
| 435 | ||
| 436 | func flushParagraph() { | |
| 437 | if !paragraph.isEmpty { | |
| 438 | let normalizedParagraph = paragraph | |
| 439 | .map { $0.trimmingCharacters(in: .whitespaces) } | |
| 440 | .joined(separator: " ") | |
| 441 | html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n" | |
| 442 | paragraph = [] | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | func closeList() { | |
| 447 | switch listType { | |
| 448 | case .unordered: | |
| 449 | html += "</ul>\n" | |
| 450 | case .ordered: | |
| 451 | html += "</ol>\n" | |
| 452 | case nil: | |
| 453 | break | |
| 454 | } | |
| 455 | listType = nil | |
| 456 | } | |
| 457 | ||
| 458 | func flushTable() { | |
| 459 | guard !tableRows.isEmpty else { return } | |
| 460 | let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell) | |
| 461 | let headerRow = tableRows.first ?? [] | |
| 462 | let bodyRows: [[String]] | |
| 463 | ||
| 464 | html += "<table>\n" | |
| 465 | if hasHeaderSeparator { | |
| 466 | html += "<thead><tr>" | |
| 467 | for cell in headerRow { | |
| 468 | html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>" | |
| 469 | } | |
| 470 | html += "</tr></thead>\n<tbody>\n" | |
| 471 | bodyRows = Array(tableRows.dropFirst(2)) | |
| 472 | } else { | |
| 473 | bodyRows = tableRows | |
| 474 | } | |
| 475 | ||
| 476 | for row in bodyRows { | |
| 477 | html += "<tr>" | |
| 478 | for cell in row { | |
| 479 | html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>" | |
| 480 | } | |
| 481 | html += "</tr>\n" | |
| 482 | } | |
| 483 | ||
| 484 | if hasHeaderSeparator { | |
| 485 | html += "</tbody>\n" | |
| 486 | } | |
| 487 | html += "</table>\n" | |
| 488 | tableRows = [] | |
| 489 | } | |
| 490 | ||
| 491 | func flushPropertyDrawer() { | |
| 492 | guard !propertyRows.isEmpty else { return } | |
| 493 | html += "<dl class=\"org-properties\">\n" | |
| 494 | for (key, value) in propertyRows { | |
| 495 | html += "<dt>" + escapeHTML(key) + "</dt>" | |
| 496 | html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n" | |
| 497 | } | |
| 498 | html += "</dl>\n" | |
| 499 | propertyRows = [] | |
| 500 | } | |
| 501 | ||
| 502 | func closeQuoteBlock() { | |
| 503 | if inQuoteBlock { | |
| 504 | flushParagraph() | |
| 505 | html += "</blockquote>\n" | |
| 506 | inQuoteBlock = false | |
| 507 | } | |
| 508 | } | |
| 509 | ||
| 510 | func closeSourceBlock() { | |
| 511 | if srcLanguage != nil { | |
| 512 | html += "</code></pre>\n" | |
| 513 | srcLanguage = nil | |
| 514 | } | |
| 515 | } | |
| 516 | ||
| 517 | func flushBlockState() { | |
| 518 | flushParagraph() | |
| 519 | closeList() | |
| 520 | flushTable() | |
| 521 | flushPropertyDrawer() | |
| 522 | } | |
| 523 | ||
| 524 | for line in lines { | |
| 525 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 526 | ||
| 527 | if srcLanguage != nil { | |
| 528 | if trimmed.lowercased() == "#+end_src" { | |
| 529 | closeSourceBlock() | |
| 530 | } else { | |
| 531 | html += escapeHTML(line) + "\n" | |
| 532 | } | |
| 533 | continue | |
| 534 | } | |
| 535 | ||
| 536 | if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { | |
| 537 | closeQuoteBlock() | |
| 538 | continue | |
| 539 | } | |
| 540 | ||
| 541 | if trimmed.lowercased().hasPrefix("#+begin_src") { | |
| 542 | closeQuoteBlock() | |
| 543 | flushBlockState() | |
| 544 | let language = trimmed | |
| 545 | .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) | |
| 546 | .dropFirst() | |
| 547 | .first | |
| 548 | .map(String.init)? | |
| 549 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 550 | let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? "" | |
| 551 | html += "<pre><code\(classAttribute)>" | |
| 552 | srcLanguage = language ?? "" | |
| 553 | continue | |
| 554 | } | |
| 555 | ||
| 556 | if trimmed.lowercased() == "#+begin_quote" { | |
| 557 | flushBlockState() | |
| 558 | html += "<blockquote>\n" | |
| 559 | inQuoteBlock = true | |
| 560 | continue | |
| 561 | } | |
| 562 | ||
| 563 | if trimmed == ":PROPERTIES:" { | |
| 564 | closeQuoteBlock() | |
| 565 | flushBlockState() | |
| 566 | inPropertyDrawer = true | |
| 567 | continue | |
| 568 | } | |
| 569 | ||
| 570 | if trimmed == ":END:", inPropertyDrawer { | |
| 571 | flushPropertyDrawer() | |
| 572 | inPropertyDrawer = false | |
| 573 | continue | |
| 574 | } | |
| 575 | ||
| 576 | if inPropertyDrawer, | |
| 577 | trimmed.hasPrefix(":"), | |
| 578 | let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") { | |
| 579 | let keyStart = trimmed.index(after: trimmed.startIndex) | |
| 580 | let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces) | |
| 581 | let valueStart = trimmed.index(after: secondColonIndex) | |
| 582 | let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces) | |
| 583 | if !key.isEmpty { | |
| 584 | propertyRows.append((key, value)) | |
| 585 | continue | |
| 586 | } | |
| 587 | } | |
| 588 | ||
| 589 | if isOrgTableLine(trimmed) { | |
| 590 | closeQuoteBlock() | |
| 591 | flushParagraph() | |
| 592 | closeList() | |
| 593 | tableRows.append(parseOrgTableRow(trimmed)) | |
| 594 | continue | |
| 595 | } else { | |
| 596 | flushTable() | |
| 597 | } | |
| 598 | ||
| 599 | // Org headings: * heading, ** heading, *** heading | |
| 600 | if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) { | |
| 601 | closeQuoteBlock() | |
| 602 | flushBlockState() | |
| 603 | let level = match.1.count | |
| 604 | let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver) | |
| 605 | html += "<h\(level)>" + content + "</h\(level)>\n" | |
| 606 | continue | |
| 607 | } | |
| 608 | ||
| 609 | // List items: - item | |
| 610 | if trimmed.hasPrefix("- ") { | |
| 611 | flushParagraph() | |
| 612 | flushPropertyDrawer() | |
| 613 | if listType != .unordered { | |
| 614 | closeList() | |
| 615 | html += "<ul>\n" | |
| 616 | listType = .unordered | |
| 617 | } | |
| 618 | html += "<li>" + renderTaskListItem( | |
| 619 | String(trimmed.dropFirst(2)), | |
| 620 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } | |
| 621 | ) + "</li>\n" | |
| 622 | continue | |
| 623 | } | |
| 624 | ||
| 625 | if let orderedItem = orderedListItem(in: trimmed) { | |
| 626 | flushParagraph() | |
| 627 | flushPropertyDrawer() | |
| 628 | if listType != .ordered { | |
| 629 | closeList() | |
| 630 | html += "<ol>\n" | |
| 631 | listType = .ordered | |
| 632 | } | |
| 633 | html += "<li>" + renderTaskListItem( | |
| 634 | orderedItem, | |
| 635 | inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } | |
| 636 | ) + "</li>\n" | |
| 637 | continue | |
| 638 | } | |
| 639 | ||
| 640 | // Blank line | |
| 641 | if trimmed.isEmpty { | |
| 642 | if inQuoteBlock { | |
| 643 | flushParagraph() | |
| 644 | } else { | |
| 645 | flushBlockState() | |
| 646 | } | |
| 647 | continue | |
| 648 | } | |
| 649 | ||
| 650 | // Regular text | |
| 651 | paragraph.append(line) | |
| 652 | } | |
| 653 | ||
| 654 | closeSourceBlock() | |
| 655 | closeQuoteBlock() | |
| 656 | flushBlockState() | |
| 657 | ||
| 658 | return html | |
| 659 | } | |
| 660 | ||
| 661 | nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { | |
| 662 | var result = escapeHTML(text) | |
| 663 | var protectedFragments: [String: String] = [:] | |
| 664 | ||
| 665 | result = protectMatches( | |
| 666 | in: result, | |
| 667 | pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, | |
| 668 | protectedFragments: &protectedFragments | |
| 669 | ) { match, nsText in | |
| 670 | let url = nsText.substring(with: match.range(at: 1)) | |
| 671 | let label = nsText.substring(with: match.range(at: 2)) | |
| 672 | if let imageHTML = makeOrgImageHTML( | |
| 673 | source: url, | |
| 674 | alt: label, | |
| 675 | imageURLResolver: imageURLResolver | |
| 676 | ) { | |
| 677 | return imageHTML | |
| 678 | } | |
| 679 | return #"<a href="\#(url)">\#(label)</a>"# | |
| 680 | } | |
| 681 | result = protectMatches( | |
| 682 | in: result, | |
| 683 | pattern: #"\[\[([^\]]+)\]\]"#, | |
| 684 | protectedFragments: &protectedFragments | |
| 685 | ) { match, nsText in | |
| 686 | let url = nsText.substring(with: match.range(at: 1)) | |
| 687 | if let imageHTML = makeOrgImageHTML( | |
| 688 | source: url, | |
| 689 | alt: nil, | |
| 690 | imageURLResolver: imageURLResolver | |
| 691 | ) { | |
| 692 | return imageHTML | |
| 693 | } | |
| 694 | return #"<a href="\#(url)">\#(url)</a>"# | |
| 695 | } | |
| 696 | result = protectMatches( | |
| 697 | in: result, | |
| 698 | pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, | |
| 699 | protectedFragments: &protectedFragments | |
| 700 | ) { match, nsText in | |
| 701 | let tildeRange = match.range(at: 1) | |
| 702 | let equalsRange = match.range(at: 2) | |
| 703 | let codeText: String | |
| 704 | if tildeRange.location != NSNotFound { | |
| 705 | codeText = nsText.substring(with: tildeRange) | |
| 706 | } else { | |
| 707 | codeText = nsText.substring(with: equalsRange) | |
| 708 | } | |
| 709 | return "<code>\(codeText)</code>" | |
| 710 | } | |
| 711 | ||
| 712 | // Bold: *text* | |
| 713 | result = result.replacingOccurrences( | |
| 714 | of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#, | |
| 715 | with: "<strong>$1</strong>", | |
| 716 | options: .regularExpression | |
| 717 | ) | |
| 718 | // Italic: /text/ | |
| 719 | result = result.replacingOccurrences( | |
| 720 | of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#, | |
| 721 | with: "<em>$1</em>", | |
| 722 | options: .regularExpression | |
| 723 | ) | |
| 724 | ||
| 725 | for (token, fragment) in protectedFragments { | |
| 726 | result = result.replacingOccurrences(of: token, with: fragment) | |
| 727 | } | |
| 728 | ||
| 729 | return result | |
| 730 | } | |
| 731 | ||
| 732 | // MARK: - HTML Escaping | |
| 733 | ||
| 734 | nonisolated func escapeHTML(_ text: String) -> String { | |
| 735 | text.replacingOccurrences(of: "&", with: "&") | |
| 736 | .replacingOccurrences(of: "<", with: "<") | |
| 737 | .replacingOccurrences(of: ">", with: ">") | |
| 738 | .replacingOccurrences(of: "\"", with: """) | |
| 739 | } | |
| 740 | ||
| 741 | nonisolated private func escapeHTMLAttribute(_ text: String) -> String { | |
| 742 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") | |
| 743 | } | |
| 744 | ||
| 745 | nonisolated private func isOrgTableLine(_ line: String) -> Bool { | |
| 746 | line.hasPrefix("|") && line.hasSuffix("|") | |
| 747 | } | |
| 748 | ||
| 749 | nonisolated private func parseOrgTableRow(_ line: String) -> [String] { | |
| 750 | line | |
| 751 | .split(separator: "|", omittingEmptySubsequences: false) | |
| 752 | .dropFirst() | |
| 753 | .dropLast() | |
| 754 | .map { String($0).trimmingCharacters(in: .whitespaces) } | |
| 755 | } | |
| 756 | ||
| 757 | nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool { | |
| 758 | let trimmed = cell.trimmingCharacters(in: .whitespaces) | |
| 759 | return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } | |
| 760 | } | |
| 761 | ||
| 762 | private enum OrgListType { | |
| 763 | case unordered | |
| 764 | case ordered | |
| 765 | } | |
| 766 | ||
| 767 | nonisolated private func orderedListItem(in line: String) -> String? { | |
| 768 | guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } | |
| 769 | return String(match.2) | |
| 770 | } | |
| 771 | ||
| 772 | nonisolated private func protectMatches( | |
| 773 | in text: String, | |
| 774 | pattern: String, | |
| 775 | protectedFragments: inout [String: String], | |
| 776 | transform: (NSTextCheckingResult, NSString) -> String | |
| 777 | ) -> String { | |
| 778 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 779 | var result = text | |
| 780 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 781 | ||
| 782 | for match in matches.reversed() { | |
| 783 | let token = "__ORG_PROTECTED_\(protectedFragments.count)__" | |
| 784 | let nsText = result as NSString | |
| 785 | protectedFragments[token] = transform(match, nsText) | |
| 786 | result = nsText.replacingCharacters(in: match.range, with: token) | |
| 787 | } | |
| 788 | ||
| 789 | return result | |
| 790 | } | |
| 791 | ||
| 792 | nonisolated private func replaceMatches( | |
| 793 | in text: String, | |
| 794 | pattern: String, | |
| 795 | transform: (NSTextCheckingResult, NSString) -> String | |
| 796 | ) -> String { | |
| 797 | guard let regex = try? NSRegularExpression(pattern: pattern) else { return text } | |
| 798 | var result = text | |
| 799 | let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) | |
| 800 | ||
| 801 | for match in matches.reversed() { | |
| 802 | let nsText = result as NSString | |
| 803 | let replacement = transform(match, nsText) | |
| 804 | result = nsText.replacingCharacters(in: match.range, with: replacement) | |
| 805 | } | |
| 806 | ||
| 807 | return result | |
| 808 | } | |
| 809 | ||
| 810 | nonisolated private func makeOrgImageHTML( | |
| 811 | source: String, | |
| 812 | alt: String?, | |
| 813 | imageURLResolver: ((String) -> String?)? | |
| 814 | ) -> String? { | |
| 815 | guard isRenderableImageSource(source) else { return nil } | |
| 816 | let resolvedSource = imageURLResolver?(source) ?? source | |
| 817 | let altText = escapeHTMLAttribute(alt ?? "") | |
| 818 | return #"<img src="\#(resolvedSource)" alt="\#(altText)">"# | |
| 819 | } | |
| 820 | ||
| 821 | nonisolated private func isRenderableImageSource(_ source: String) -> Bool { | |
| 822 | let lowercased = source.lowercased() | |
| 823 | return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"] | |
| 824 | .contains(where: { lowercased.hasSuffix($0) }) | |
| 825 | } | |
| 826 | ||
| 827 | nonisolated func resolveRepositoryAssetURL( | |
| 828 | _ source: String, | |
| 829 | owner: String, | |
| 830 | repositoryName: String, | |
| 831 | readmePath: String? | |
| 832 | ) -> String? { | |
| 833 | let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 834 | guard !trimmedSource.isEmpty else { return nil } | |
| 835 | ||
| 836 | if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") { | |
| 837 | return trimmedSource | |
| 838 | } | |
| 839 | ||
| 840 | let relativePath: String | |
| 841 | if trimmedSource.hasPrefix("/") { | |
| 842 | relativePath = String(trimmedSource.dropFirst()) | |
| 843 | } else { | |
| 844 | let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? "" | |
| 845 | relativePath = normalizeRepositoryPath( | |
| 846 | (readmeDirectory as NSString).appendingPathComponent(trimmedSource) | |
| 847 | ) | |
| 848 | } | |
| 849 | ||
| 850 | guard !relativePath.isEmpty else { return nil } | |
| 851 | return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)" | |
| 852 | } | |
| 853 | ||
| 854 | nonisolated private func normalizeRepositoryPath(_ path: String) -> String { | |
| 855 | var components: [String] = [] | |
| 856 | ||
| 857 | for part in path.split(separator: "/") { | |
| 858 | switch part { | |
| 859 | case ".": | |
| 860 | continue | |
| 861 | case "..": | |
| 862 | if !components.isEmpty { | |
| 863 | components.removeLast() | |
| 864 | } | |
| 865 | default: | |
| 866 | components.append(String(part)) | |
| 867 | } | |
| 868 | } | |
| 869 | ||
| 870 | return components.joined(separator: "/") | |
| 871 | } | |
| 872 | ||
| 873 | nonisolated private func renderTaskListItem( | |
| 874 | _ text: String, | |
| 875 | inlineRenderer: (String) -> String | |
| 876 | ) -> String { | |
| 877 | let trimmed = text.trimmingCharacters(in: .whitespaces) | |
| 878 | guard trimmed.count >= 4 else { | |
| 879 | return inlineRenderer(text) | |
| 880 | } | |
| 881 | ||
| 882 | let prefix = String(trimmed.prefix(4)) | |
| 883 | let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces) | |
| 884 | ||
| 885 | switch prefix { | |
| 886 | case "[ ] ": | |
| 887 | return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"# | |
| 888 | case "[x] ", "[X] ": | |
| 889 | return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"# | |
| 890 | default: | |
| 891 | return inlineRenderer(text) | |
| 892 | } | |
| 893 | } | |
| 894 | ||
| 895 | // MARK: - WKWebView Wrapper | |
| 896 | ||
| 897 | /// A WKWebView wrapper that renders HTML inline and grows to fit its content. | |
| 898 | struct HTMLWebView: View { | |
| 899 | let html: String | |
| 900 | let colorScheme: ColorScheme | |
| 901 | var style: HTMLWebViewStyle = .readme | |
| 902 | @State private var contentHeight: CGFloat = 1 | |
| 903 | @State private var loadError: String? | |
| 904 | @State private var reloadToken = 0 | |
| 905 | ||
| 906 | var body: some View { | |
| 907 | Group { | |
| 908 | if let loadError { | |
| 909 | SRHTErrorStateView( | |
| 910 | title: "Couldn't Render Content", | |
| 911 | message: loadError, | |
| 912 | retryAction: { | |
| 913 | await MainActor.run { | |
| 914 | self.loadError = nil | |
| 915 | reloadToken += 1 | |
| 916 | } | |
| 917 | } | |
| 918 | ) | |
| 919 | } else { | |
| 920 | HTMLWebViewRepresentable( | |
| 921 | html: html, | |
| 922 | colorScheme: colorScheme, | |
| 923 | style: style, | |
| 924 | dynamicHeight: $contentHeight, | |
| 925 | loadError: $loadError, | |
| 926 | reloadToken: reloadToken | |
| 927 | ) | |
| 928 | .frame(height: max(contentHeight, 1)) | |
| 929 | } | |
| 930 | } | |
| 931 | } | |
| 932 | } | |
| 933 | ||
| 934 | struct HTMLWebViewStyle: Sendable { | |
| 935 | let bodyFontSize: Int | |
| 936 | let lineHeight: Double | |
| 937 | let codeFontSize: Int | |
| 938 | let viewport: String | |
| 939 | ||
| 940 | static let readme = HTMLWebViewStyle( | |
| 941 | bodyFontSize: 16, | |
| 942 | lineHeight: 1.6, | |
| 943 | codeFontSize: 13, | |
| 944 | viewport: "width=device-width, initial-scale=1, maximum-scale=1" | |
| 945 | ) | |
| 946 | ||
| 947 | static let commentPreview = HTMLWebViewStyle( | |
| 948 | bodyFontSize: 15, | |
| 949 | lineHeight: 1.5, | |
| 950 | codeFontSize: 12, | |
| 951 | viewport: "width=device-width, initial-scale=1, user-scalable=no" | |
| 952 | ) | |
| 953 | } | |
| 954 | ||
| 955 | private struct HTMLWebViewRepresentable: UIViewRepresentable { | |
| 956 | let html: String | |
| 957 | let colorScheme: ColorScheme | |
| 958 | let style: HTMLWebViewStyle | |
| 959 | @Binding var dynamicHeight: CGFloat | |
| 960 | @Binding var loadError: String? | |
| 961 | let reloadToken: Int | |
| 962 | ||
| 963 | func makeCoordinator() -> HTMLWebViewCoordinator { | |
| 964 | HTMLWebViewCoordinator(parent: self) | |
| 965 | } | |
| 966 | ||
| 967 | func makeUIView(context: Context) -> WKWebView { | |
| 968 | let config = WKWebViewConfiguration() | |
| 969 | config.defaultWebpagePreferences.allowsContentJavaScript = true | |
| 970 | config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore | |
| 971 | let webView = WKWebView(frame: .zero, configuration: config) | |
| 972 | webView.isOpaque = false | |
| 973 | webView.backgroundColor = .clear | |
| 974 | webView.clipsToBounds = false | |
| 975 | webView.scrollView.isScrollEnabled = false | |
| 976 | webView.scrollView.contentInsetAdjustmentBehavior = .never | |
| 977 | webView.scrollView.clipsToBounds = false | |
| 978 | webView.navigationDelegate = context.coordinator | |
| 979 | return webView | |
| 980 | } | |
| 981 | ||
| 982 | func updateUIView(_ webView: WKWebView, context: Context) { | |
| 983 | let textColor = colorScheme == .dark ? "#fff" : "#000" | |
| 984 | let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc" | |
| 985 | ||
| 986 | let wrapped = """ | |
| 987 | <!DOCTYPE html> | |
| 988 | <html> | |
| 989 | <head> | |
| 990 | <meta name="viewport" content="\(style.viewport)"> | |
| 991 | <style> | |
| 992 | body { | |
| 993 | font-family: -apple-system, system-ui, sans-serif; | |
| 994 | font-size: \(style.bodyFontSize)px; | |
| 995 | line-height: \(style.lineHeight); | |
| 996 | padding: 0; | |
| 997 | margin: 0; | |
| 998 | color: \(textColor); | |
| 999 | background: transparent; | |
| 1000 | word-wrap: break-word; | |
| 1001 | overflow-wrap: break-word; | |
| 1002 | max-width: 100%; | |
| 1003 | } | |
| 1004 | * { box-sizing: border-box; } | |
| 1005 | h1, h2, h3, h4, h5, h6 { line-height: 1.25; } | |
| 1006 | p:first-child { margin-top: 0; } | |
| 1007 | p:last-child { margin-bottom: 0; } | |
| 1008 | pre, code { | |
| 1009 | font-family: ui-monospace, Menlo, monospace; | |
| 1010 | font-size: \(style.codeFontSize)px; | |
| 1011 | background: rgba(128, 128, 128, 0.15); | |
| 1012 | padding: 2px 4px; | |
| 1013 | border-radius: 3px; | |
| 1014 | } | |
| 1015 | pre code { padding: 0; background: none; } | |
| 1016 | pre { | |
| 1017 | padding: 8px; | |
| 1018 | overflow-x: auto; | |
| 1019 | white-space: pre-wrap; | |
| 1020 | word-wrap: break-word; | |
| 1021 | } | |
| 1022 | img { max-width: 100%; height: auto; } | |
| 1023 | input[type="checkbox"] { | |
| 1024 | margin-right: 0.45rem; | |
| 1025 | vertical-align: middle; | |
| 1026 | } | |
| 1027 | .task-list-item { | |
| 1028 | display: inline-flex; | |
| 1029 | align-items: center; | |
| 1030 | gap: 0.1rem; | |
| 1031 | } | |
| 1032 | a { color: \(linkColor); } | |
| 1033 | table { border-collapse: collapse; width: 100%; } | |
| 1034 | td, th { border: 1px solid #ccc; padding: 4px 8px; } | |
| 1035 | </style> | |
| 1036 | </head> | |
| 1037 | <body>\(html)</body> | |
| 1038 | </html> | |
| 1039 | """ | |
| 1040 | ||
| 1041 | if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue { | |
| 1042 | let height = CGFloat(cachedHeight) | |
| 1043 | if abs(dynamicHeight - height) > 0.5 { | |
| 1044 | dynamicHeight = height | |
| 1045 | } | |
| 1046 | } | |
| 1047 | ||
| 1048 | guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return } | |
| 1049 | context.coordinator.lastHTML = wrapped | |
| 1050 | context.coordinator.lastReloadToken = reloadToken | |
| 1051 | if loadError != nil { | |
| 1052 | DispatchQueue.main.async { | |
| 1053 | self.loadError = nil | |
| 1054 | } | |
| 1055 | } | |
| 1056 | webView.loadHTMLString(wrapped, baseURL: nil) | |
| 1057 | } | |
| 1058 | } | |
| 1059 | ||
| 1060 | private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable { | |
| 1061 | static let websiteDataStore = WKWebsiteDataStore.nonPersistent() | |
| 1062 | static let heightCache = NSCache<NSString, NSNumber>() | |
| 1063 | ||
| 1064 | let parent: HTMLWebViewRepresentable | |
| 1065 | var lastHTML: String? | |
| 1066 | var lastReloadToken = 0 | |
| 1067 | ||
| 1068 | init(parent: HTMLWebViewRepresentable) { | |
| 1069 | self.parent = parent | |
| 1070 | } | |
| 1071 | ||
| 1072 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { | |
| 1073 | DispatchQueue.main.async { | |
| 1074 | self.parent.loadError = nil | |
| 1075 | } | |
| 1076 | updateHeight(for: webView) | |
| 1077 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in | |
| 1078 | guard let self, let webView else { return } | |
| 1079 | self.updateHeight(for: webView) | |
| 1080 | } | |
| 1081 | } | |
| 1082 | ||
| 1083 | func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { | |
| 1084 | handleLoadFailure(error) | |
| 1085 | } | |
| 1086 | ||
| 1087 | func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { | |
| 1088 | handleLoadFailure(error) | |
| 1089 | } | |
| 1090 | ||
| 1091 | private func handleLoadFailure(_ error: Error) { | |
| 1092 | let nsError = error as NSError | |
| 1093 | guard nsError.code != NSURLErrorCancelled else { return } | |
| 1094 | DispatchQueue.main.async { | |
| 1095 | self.parent.loadError = "The content could not be displayed right now." | |
| 1096 | } | |
| 1097 | } | |
| 1098 | ||
| 1099 | private func updateHeight(for webView: WKWebView) { | |
| 1100 | let script = """ | |
| 1101 | Math.max( | |
| 1102 | document.body.scrollHeight, | |
| 1103 | document.body.offsetHeight, | |
| 1104 | document.documentElement.scrollHeight, | |
| 1105 | document.documentElement.offsetHeight, | |
| 1106 | Math.ceil(document.body.getBoundingClientRect().height), | |
| 1107 | Math.ceil(document.documentElement.getBoundingClientRect().height) | |
| 1108 | ) | |
| 1109 | """ | |
| 1110 | ||
| 1111 | webView.evaluateJavaScript(script) { [weak self] result, _ in | |
| 1112 | guard let value = result as? Double, value > 0 else { return } | |
| 1113 | let height = ceil(value) + 4 | |
| 1114 | DispatchQueue.main.async { | |
| 1115 | guard let self else { return } | |
| 1116 | if let html = self.lastHTML { | |
| 1117 | Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) | |
| 1118 | } | |
| 1119 | if abs(self.parent.dynamicHeight - height) > 0.5 { | |
| 1120 | self.parent.dynamicHeight = height | |
| 1121 | } | |
| 1122 | } | |
| 1123 | } | |
| 1124 | } | |
| 1125 | } | |
Hutch/Views/Repositories/ReferencesListView.swift added +90
| @@ -0,0 +1,90 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct ReferencesListView: View { | |
| 4 | let viewModel: RepositoryDetailViewModel | |
| 5 | ||
| 6 | var body: some View { | |
| 7 | List { | |
| 8 | if !viewModel.branches.isEmpty { | |
| 9 | Section("Branches") { | |
| 10 | ForEach(viewModel.branches, id: \.name) { ref in | |
| 11 | ReferenceRow(reference: ref, prefix: "refs/heads/") | |
| 12 | } | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | if !viewModel.tags.isEmpty { | |
| 17 | Section("Tags") { | |
| 18 | ForEach(viewModel.tags, id: \.name) { ref in | |
| 19 | ReferenceRow(reference: ref, prefix: "refs/tags/") | |
| 20 | } | |
| 21 | } | |
| 22 | } | |
| 23 | } | |
| 24 | .listStyle(.insetGrouped) | |
| 25 | .overlay { | |
| 26 | if viewModel.isLoadingRefs, viewModel.branches.isEmpty, viewModel.tags.isEmpty { | |
| 27 | SRHTLoadingStateView(message: "Loading references…") | |
| 28 | } else if let error = viewModel.error, viewModel.branches.isEmpty, viewModel.tags.isEmpty { | |
| 29 | SRHTErrorStateView( | |
| 30 | title: "Couldn't Load References", | |
| 31 | message: error, | |
| 32 | retryAction: { await viewModel.loadReferences() } | |
| 33 | ) | |
| 34 | } else if viewModel.branches.isEmpty, viewModel.tags.isEmpty { | |
| 35 | ContentUnavailableView( | |
| 36 | "No References", | |
| 37 | systemImage: "arrow.triangle.branch", | |
| 38 | description: Text("This repository has no branches or tags.") | |
| 39 | ) | |
| 40 | } | |
| 41 | } | |
| 42 | .task { | |
| 43 | if viewModel.branches.isEmpty, viewModel.tags.isEmpty { | |
| 44 | await viewModel.loadReferences() | |
| 45 | } | |
| 46 | } | |
| 47 | .refreshable { | |
| 48 | await viewModel.loadReferences() | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | ||
| 53 | private struct ReferenceRow: View { | |
| 54 | let reference: Reference | |
| 55 | let prefix: String | |
| 56 | ||
| 57 | var body: some View { | |
| 58 | HStack { | |
| 59 | Label { | |
| 60 | Text(shortName) | |
| 61 | .font(.body.monospaced()) | |
| 62 | } icon: { | |
| 63 | Image(systemName: icon) | |
| 64 | .foregroundStyle(iconColor) | |
| 65 | } | |
| 66 | ||
| 67 | Spacer() | |
| 68 | ||
| 69 | Text(String((reference.target ?? "").prefix(8))) | |
| 70 | .font(.caption.monospaced()) | |
| 71 | .foregroundStyle(.secondary) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | private var shortName: String { | |
| 76 | if reference.name.hasPrefix(prefix) { | |
| 77 | String(reference.name.dropFirst(prefix.count)) | |
| 78 | } else { | |
| 79 | reference.name | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | private var icon: String { | |
| 84 | prefix.contains("tags") ? "tag" : "arrow.triangle.branch" | |
| 85 | } | |
| 86 | ||
| 87 | private var iconColor: Color { | |
| 88 | prefix.contains("tags") ? .orange : .blue | |
| 89 | } | |
| 90 | } | |
Hutch/Views/Repositories/RepositoryDetailView.swift added +107
| @@ -0,0 +1,107 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct RepositoryDetailView: View { | |
| 4 | var repository: RepositorySummary | |
| 5 | var onDeleted: (() -> Void)? | |
| 6 | ||
| 7 | @Environment(AppState.self) private var appState | |
| 8 | @Environment(\.dismiss) private var dismiss | |
| 9 | @State private var viewModel: RepositoryDetailViewModel? | |
| 10 | @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary | |
| 11 | @State private var showSettings = false | |
| 12 | @State private var displayName: String | |
| 13 | ||
| 14 | init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { | |
| 15 | self.repository = repository | |
| 16 | self.onDeleted = onDeleted | |
| 17 | self._displayName = State(initialValue: repository.name) | |
| 18 | } | |
| 19 | ||
| 20 | var body: some View { | |
| 21 | if repository.service == .hg { | |
| 22 | HgRepositoryDetailView(repository: repository, onDeleted: onDeleted) | |
| 23 | } else { | |
| 24 | Group { | |
| 25 | if let viewModel { | |
| 26 | detailContent(viewModel) | |
| 27 | } else { | |
| 28 | SRHTLoadingStateView(message: "Loading repository…") | |
| 29 | } | |
| 30 | } | |
| 31 | .navigationTitle(displayName) | |
| 32 | .navigationBarTitleDisplayMode(.inline) | |
| 33 | .toolbar { | |
| 34 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 35 | SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) { | |
| 36 | Image(systemName: "square.and.arrow.up") | |
| 37 | } | |
| 38 | ||
| 39 | Button { | |
| 40 | showSettings = true | |
| 41 | } label: { | |
| 42 | Image(systemName: "gear") | |
| 43 | } | |
| 44 | } | |
| 45 | } | |
| 46 | .sheet(isPresented: $showSettings) { | |
| 47 | RepositorySettingsView( | |
| 48 | repository: repository, | |
| 49 | branches: viewModel?.branches ?? [], | |
| 50 | client: appState.client, | |
| 51 | onRenamed: { newName in | |
| 52 | displayName = newName | |
| 53 | }, | |
| 54 | onDeleted: { | |
| 55 | dismiss() | |
| 56 | onDeleted?() | |
| 57 | } | |
| 58 | ) | |
| 59 | } | |
| 60 | .task { | |
| 61 | if viewModel == nil { | |
| 62 | viewModel = RepositoryDetailViewModel( | |
| 63 | repository: repository, | |
| 64 | client: appState.client | |
| 65 | ) | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | @ViewBuilder | |
| 72 | private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View { | |
| 73 | VStack(spacing: 0) { | |
| 74 | Picker("Tab", selection: $selectedTab) { | |
| 75 | ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in | |
| 76 | Text(tab.rawValue).tag(tab) | |
| 77 | } | |
| 78 | } | |
| 79 | .pickerStyle(.segmented) | |
| 80 | .padding(.horizontal) | |
| 81 | .padding(.vertical, 8) | |
| 82 | ||
| 83 | Divider() | |
| 84 | ||
| 85 | switch selectedTab { | |
| 86 | case .summary: | |
| 87 | ReadmeView(viewModel: viewModel) | |
| 88 | case .tree: | |
| 89 | FileTreeView( | |
| 90 | repository: repository, | |
| 91 | client: appState.client | |
| 92 | ) | |
| 93 | case .log: | |
| 94 | CommitLogView(viewModel: viewModel) | |
| 95 | case .refs: | |
| 96 | ReferencesListView(viewModel: viewModel) | |
| 97 | case .artifacts: | |
| 98 | ArtifactsView(viewModel: viewModel) | |
| 99 | } | |
| 100 | } | |
| 101 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) | |
| 102 | .srhtErrorBanner(error: Binding( | |
| 103 | get: { viewModel.error }, | |
| 104 | set: { viewModel.error = $0 } | |
| 105 | )) | |
| 106 | } | |
| 107 | } | |
Hutch/Views/Repositories/RepositoryDetailViewModel.swift added +397
| @@ -0,0 +1,397 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct LogResponse: Decodable, Sendable { | |
| 6 | let repository: LogRepository? | |
| 7 | } | |
| 8 | ||
| 9 | private struct LogRepository: Decodable, Sendable { | |
| 10 | let log: LogPage | |
| 11 | } | |
| 12 | ||
| 13 | private struct LogPage: Decodable, Sendable { | |
| 14 | let results: [CommitSummary] | |
| 15 | let cursor: String? | |
| 16 | } | |
| 17 | ||
| 18 | private struct RefsResponse: Decodable, Sendable { | |
| 19 | let repository: RefsRepository? | |
| 20 | } | |
| 21 | ||
| 22 | private struct RefsRepository: Decodable, Sendable { | |
| 23 | let references: RefsPage | |
| 24 | } | |
| 25 | ||
| 26 | private struct RefsPage: Decodable, Sendable { | |
| 27 | let results: [Reference] | |
| 28 | let cursor: String? | |
| 29 | } | |
| 30 | ||
| 31 | private struct ReadmeResponse: Decodable, Sendable { | |
| 32 | let repository: ReadmeRepository? | |
| 33 | } | |
| 34 | ||
| 35 | private struct ReadmeRepository: Decodable, Sendable { | |
| 36 | let readme: String? | |
| 37 | } | |
| 38 | ||
| 39 | private struct PathResponse: Decodable, Sendable { | |
| 40 | let repository: PathRepository? | |
| 41 | } | |
| 42 | ||
| 43 | private struct PathRepository: Decodable, Sendable { | |
| 44 | let readme: PathEntry? | |
| 45 | } | |
| 46 | ||
| 47 | private struct PathEntry: Decodable, Sendable { | |
| 48 | let object: PathObject? | |
| 49 | } | |
| 50 | ||
| 51 | private struct PathObject: Decodable, Sendable { | |
| 52 | let text: String? | |
| 53 | } | |
| 54 | ||
| 55 | private struct ArtifactsResponse: Decodable, Sendable { | |
| 56 | let repository: ArtifactsRepository? | |
| 57 | } | |
| 58 | ||
| 59 | private struct ArtifactsRepository: Decodable, Sendable { | |
| 60 | let references: ArtifactRefsPage | |
| 61 | } | |
| 62 | ||
| 63 | private struct ArtifactRefsPage: Decodable, Sendable { | |
| 64 | let results: [ArtifactRef] | |
| 65 | let cursor: String? | |
| 66 | } | |
| 67 | ||
| 68 | private struct ArtifactRef: Decodable, Sendable { | |
| 69 | let name: String | |
| 70 | let artifacts: ArtifactPage | |
| 71 | } | |
| 72 | ||
| 73 | // MARK: - View Model | |
| 74 | ||
| 75 | @Observable | |
| 76 | @MainActor | |
| 77 | final class RepositoryDetailViewModel { | |
| 78 | ||
| 79 | enum Tab: String, CaseIterable { | |
| 80 | case summary = "Summary" | |
| 81 | case tree = "Tree" | |
| 82 | case log = "Log" | |
| 83 | case refs = "Refs" | |
| 84 | case artifacts = "Artifacts" | |
| 85 | } | |
| 86 | ||
| 87 | let repository: RepositorySummary | |
| 88 | private var service: SRHTService { repository.service } | |
| 89 | private let client: SRHTClient | |
| 90 | ||
| 91 | // MARK: - Commit log state | |
| 92 | ||
| 93 | private(set) var commits: [CommitSummary] = [] | |
| 94 | private(set) var isLoadingCommits = false | |
| 95 | private(set) var isLoadingMoreCommits = false | |
| 96 | private var commitCursor: String? | |
| 97 | private var hasMoreCommits = true | |
| 98 | ||
| 99 | // MARK: - References state | |
| 100 | ||
| 101 | private(set) var branches: [Reference] = [] | |
| 102 | private(set) var tags: [Reference] = [] | |
| 103 | private(set) var isLoadingRefs = false | |
| 104 | ||
| 105 | // MARK: - README state | |
| 106 | ||
| 107 | enum ReadmeContent { | |
| 108 | case html(String) | |
| 109 | case markdown(String) | |
| 110 | case org(String) | |
| 111 | case plainText(String) | |
| 112 | } | |
| 113 | ||
| 114 | private(set) var readmeContent: ReadmeContent? | |
| 115 | private(set) var readmePath: String? | |
| 116 | private(set) var isLoadingReadme = false | |
| 117 | private(set) var readmeLoaded = false | |
| 118 | ||
| 119 | // MARK: - Artifacts state | |
| 120 | ||
| 121 | private(set) var referenceArtifacts: [ReferenceWithArtifacts] = [] | |
| 122 | private(set) var isLoadingArtifacts = false | |
| 123 | ||
| 124 | // MARK: - Error | |
| 125 | ||
| 126 | var error: String? | |
| 127 | ||
| 128 | init(repository: RepositorySummary, client: SRHTClient) { | |
| 129 | self.repository = repository | |
| 130 | self.client = client | |
| 131 | } | |
| 132 | ||
| 133 | // MARK: - Commit log | |
| 134 | ||
| 135 | private static let logQuery = """ | |
| 136 | query repoLog($rid: ID!, $cursor: Cursor) { | |
| 137 | repository(rid: $rid) { | |
| 138 | log(cursor: $cursor) { | |
| 139 | results { | |
| 140 | id | |
| 141 | shortId | |
| 142 | author { name email time } | |
| 143 | message | |
| 144 | } | |
| 145 | cursor | |
| 146 | } | |
| 147 | } | |
| 148 | } | |
| 149 | """ | |
| 150 | ||
| 151 | func loadCommits() async { | |
| 152 | guard !isLoadingCommits else { return } | |
| 153 | isLoadingCommits = true | |
| 154 | error = nil | |
| 155 | commitCursor = nil | |
| 156 | hasMoreCommits = true | |
| 157 | ||
| 158 | do { | |
| 159 | let page = try await fetchCommitPage(cursor: nil) | |
| 160 | commits = page.results | |
| 161 | commitCursor = page.cursor | |
| 162 | hasMoreCommits = page.cursor != nil | |
| 163 | } catch { | |
| 164 | self.error = error.localizedDescription | |
| 165 | } | |
| 166 | ||
| 167 | isLoadingCommits = false | |
| 168 | } | |
| 169 | ||
| 170 | func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async { | |
| 171 | guard let last = commits.last, | |
| 172 | last.id == currentItem.id, | |
| 173 | hasMoreCommits, | |
| 174 | !isLoadingMoreCommits else { | |
| 175 | return | |
| 176 | } | |
| 177 | ||
| 178 | isLoadingMoreCommits = true | |
| 179 | ||
| 180 | do { | |
| 181 | let page = try await fetchCommitPage(cursor: commitCursor) | |
| 182 | commits.append(contentsOf: page.results) | |
| 183 | commitCursor = page.cursor | |
| 184 | hasMoreCommits = page.cursor != nil | |
| 185 | } catch { | |
| 186 | self.error = error.localizedDescription | |
| 187 | } | |
| 188 | ||
| 189 | isLoadingMoreCommits = false | |
| 190 | } | |
| 191 | ||
| 192 | private func fetchCommitPage(cursor: String?) async throws -> LogPage { | |
| 193 | var variables: [String: any Sendable] = ["rid": repository.rid] | |
| 194 | if let cursor { | |
| 195 | variables["cursor"] = cursor | |
| 196 | } | |
| 197 | let result: LogResponse | |
| 198 | do { | |
| 199 | result = try await client.execute( | |
| 200 | service: service, | |
| 201 | query: Self.logQuery, | |
| 202 | variables: variables, | |
| 203 | responseType: LogResponse.self | |
| 204 | ) | |
| 205 | } catch { | |
| 206 | if isMissingGitReferenceError(error) { | |
| 207 | return LogPage(results: [], cursor: nil) | |
| 208 | } | |
| 209 | throw error | |
| 210 | } | |
| 211 | guard let repo = result.repository else { | |
| 212 | return LogPage(results: [], cursor: nil) | |
| 213 | } | |
| 214 | return repo.log | |
| 215 | } | |
| 216 | ||
| 217 | // MARK: - References | |
| 218 | ||
| 219 | private static let refsQuery = """ | |
| 220 | query refs($rid: ID!) { | |
| 221 | repository(rid: $rid) { | |
| 222 | references { | |
| 223 | results { name target } | |
| 224 | cursor | |
| 225 | } | |
| 226 | } | |
| 227 | } | |
| 228 | """ | |
| 229 | ||
| 230 | func loadReferences() async { | |
| 231 | guard !isLoadingRefs else { return } | |
| 232 | isLoadingRefs = true | |
| 233 | error = nil | |
| 234 | ||
| 235 | do { | |
| 236 | let result = try await client.execute( | |
| 237 | service: service, | |
| 238 | query: Self.refsQuery, | |
| 239 | variables: ["rid": repository.rid], | |
| 240 | responseType: RefsResponse.self | |
| 241 | ) | |
| 242 | let allRefs = result.repository?.references.results ?? [] | |
| 243 | branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") } | |
| 244 | tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") } | |
| 245 | } catch { | |
| 246 | self.error = error.localizedDescription | |
| 247 | } | |
| 248 | ||
| 249 | isLoadingRefs = false | |
| 250 | } | |
| 251 | ||
| 252 | // MARK: - README | |
| 253 | ||
| 254 | private static let readmeQuery = """ | |
| 255 | query readme($rid: ID!) { | |
| 256 | repository(rid: $rid) { | |
| 257 | readme | |
| 258 | } | |
| 259 | } | |
| 260 | """ | |
| 261 | ||
| 262 | private static func readmeFileQuery(filename: String) -> String { | |
| 263 | """ | |
| 264 | query readmeFile($rid: ID!) { | |
| 265 | repository(rid: $rid) { | |
| 266 | readme: path(revspec: "HEAD", path: "\(filename)") { | |
| 267 | object { | |
| 268 | ... on TextBlob { text } | |
| 269 | } | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 273 | """ | |
| 274 | } | |
| 275 | ||
| 276 | private static let readmeFilenames = [ | |
| 277 | "README.md", "README.org", "README.txt", "README", | |
| 278 | "readme.md", "readme.org" | |
| 279 | ] | |
| 280 | ||
| 281 | func loadReadme() async { | |
| 282 | guard !isLoadingReadme, !readmeLoaded else { return } | |
| 283 | isLoadingReadme = true | |
| 284 | defer { isLoadingReadme = false } | |
| 285 | error = nil | |
| 286 | ||
| 287 | do { | |
| 288 | // Step 1: Check the custom HTML readme set via the web UI | |
| 289 | let result = try await client.execute( | |
| 290 | service: service, | |
| 291 | query: Self.readmeQuery, | |
| 292 | variables: ["rid": repository.rid], | |
| 293 | responseType: ReadmeResponse.self | |
| 294 | ) | |
| 295 | if let html = result.repository?.readme, !html.isEmpty { | |
| 296 | readmePath = nil | |
| 297 | readmeContent = .html(html) | |
| 298 | readmeLoaded = true | |
| 299 | return | |
| 300 | } | |
| 301 | ||
| 302 | // Step 2: Try each README filename sequentially | |
| 303 | for filename in Self.readmeFilenames { | |
| 304 | let pathResult: PathResponse | |
| 305 | do { | |
| 306 | pathResult = try await client.execute( | |
| 307 | service: service, | |
| 308 | query: Self.readmeFileQuery(filename: filename), | |
| 309 | variables: ["rid": repository.rid], | |
| 310 | responseType: PathResponse.self | |
| 311 | ) | |
| 312 | } catch { | |
| 313 | if isMissingGitReferenceError(error) { | |
| 314 | readmeContent = nil | |
| 315 | readmePath = nil | |
| 316 | readmeLoaded = true | |
| 317 | return | |
| 318 | } | |
| 319 | throw error | |
| 320 | } | |
| 321 | if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty { | |
| 322 | readmePath = filename | |
| 323 | if filename.hasSuffix(".md") { | |
| 324 | readmeContent = .markdown(text) | |
| 325 | } else if filename.hasSuffix(".org") { | |
| 326 | readmeContent = .org(text) | |
| 327 | } else { | |
| 328 | readmeContent = .plainText(text) | |
| 329 | } | |
| 330 | readmeLoaded = true | |
| 331 | return | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | // Step 3: No readme found | |
| 336 | readmeContent = nil | |
| 337 | readmePath = nil | |
| 338 | readmeLoaded = true | |
| 339 | } catch { | |
| 340 | self.error = error.localizedDescription | |
| 341 | } | |
| 342 | } | |
| 343 | ||
| 344 | private func isMissingGitReferenceError(_ error: Error) -> Bool { | |
| 345 | guard let srhtError = error as? SRHTError else { return false } | |
| 346 | guard case .graphQLErrors(let errors) = srhtError else { return false } | |
| 347 | return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") } | |
| 348 | } | |
| 349 | ||
| 350 | // MARK: - Artifacts | |
| 351 | ||
| 352 | private static let artifactsQuery = """ | |
| 353 | query artifacts($rid: ID!) { | |
| 354 | repository(rid: $rid) { | |
| 355 | references { | |
| 356 | results { | |
| 357 | name | |
| 358 | artifacts { | |
| 359 | results { | |
| 360 | id | |
| 361 | filename | |
| 362 | checksum | |
| 363 | size | |
| 364 | url | |
| 365 | } | |
| 366 | cursor | |
| 367 | } | |
| 368 | } | |
| 369 | cursor | |
| 370 | } | |
| 371 | } | |
| 372 | } | |
| 373 | """ | |
| 374 | ||
| 375 | func loadArtifacts() async { | |
| 376 | guard !isLoadingArtifacts else { return } | |
| 377 | isLoadingArtifacts = true | |
| 378 | error = nil | |
| 379 | ||
| 380 | do { | |
| 381 | let result = try await client.execute( | |
| 382 | service: service, | |
| 383 | query: Self.artifactsQuery, | |
| 384 | variables: ["rid": repository.rid], | |
| 385 | responseType: ArtifactsResponse.self | |
| 386 | ) | |
| 387 | // Only include references that have at least one artifact. | |
| 388 | referenceArtifacts = (result.repository?.references.results ?? []) | |
| 389 | .filter { !$0.artifacts.results.isEmpty } | |
| 390 | .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) } | |
| 391 | } catch { | |
| 392 | self.error = error.localizedDescription | |
| 393 | } | |
| 394 | ||
| 395 | isLoadingArtifacts = false | |
| 396 | } | |
| 397 | } | |
Hutch/Views/Repositories/RepositoryListView.swift added +224
| @@ -0,0 +1,224 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct RepositoryListView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @State private var viewModel: RepositoryListViewModel? | |
| 6 | @State private var searchTask: Task<Void, Never>? | |
| 7 | @State private var showCreateRepositorySheet = false | |
| 8 | @State private var createdRepository: RepositorySummary? | |
| 9 | ||
| 10 | var body: some View { | |
| 11 | Group { | |
| 12 | if let viewModel { | |
| 13 | listContent(viewModel) | |
| 14 | } else { | |
| 15 | SRHTLoadingStateView(message: "Loading repositories…") | |
| 16 | } | |
| 17 | } | |
| 18 | .navigationTitle("Repositories") | |
| 19 | .toolbar { | |
| 20 | if viewModel != nil { | |
| 21 | ToolbarItem(placement: .topBarTrailing) { | |
| 22 | Button { | |
| 23 | showCreateRepositorySheet = true | |
| 24 | } label: { | |
| 25 | Image(systemName: "plus") | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | } | |
| 30 | .sheet(isPresented: $showCreateRepositorySheet) { | |
| 31 | if let viewModel { | |
| 32 | CreateRepositorySheet(viewModel: viewModel) { repository in | |
| 33 | showCreateRepositorySheet = false | |
| 34 | createdRepository = repository | |
| 35 | } | |
| 36 | } | |
| 37 | } | |
| 38 | .navigationDestination(isPresented: Binding( | |
| 39 | get: { createdRepository != nil }, | |
| 40 | set: { isPresented in | |
| 41 | if !isPresented { | |
| 42 | createdRepository = nil | |
| 43 | } | |
| 44 | } | |
| 45 | )) { | |
| 46 | if let createdRepository { | |
| 47 | RepositoryDetailView(repository: createdRepository) { | |
| 48 | viewModel?.removeRepository(id: createdRepository.id) | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
| 52 | .task { | |
| 53 | if viewModel == nil { | |
| 54 | viewModel = RepositoryListViewModel(client: appState.client) | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | @ViewBuilder | |
| 60 | private func listContent(_ viewModel: RepositoryListViewModel) -> some View { | |
| 61 | @Bindable var vm = viewModel | |
| 62 | ||
| 63 | List { | |
| 64 | ForEach(viewModel.repositories) { repo in | |
| 65 | NavigationLink(value: repo) { | |
| 66 | RepositoryRowView(repository: repo) | |
| 67 | } | |
| 68 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } | |
| 69 | .task { | |
| 70 | await viewModel.loadMoreIfNeeded(currentItem: repo) | |
| 71 | } | |
| 72 | } | |
| 73 | ||
| 74 | if viewModel.isLoadingMore { | |
| 75 | HStack { | |
| 76 | Spacer() | |
| 77 | ProgressView() | |
| 78 | Spacer() | |
| 79 | } | |
| 80 | .listRowSeparator(.hidden) | |
| 81 | } | |
| 82 | } | |
| 83 | .listStyle(.plain) | |
| 84 | .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories") | |
| 85 | .overlay { | |
| 86 | if viewModel.isLoading, viewModel.repositories.isEmpty { | |
| 87 | SRHTLoadingStateView(message: "Loading repositories…") | |
| 88 | } else if let error = viewModel.error, viewModel.repositories.isEmpty { | |
| 89 | SRHTErrorStateView( | |
| 90 | title: "Couldn't Load Repositories", | |
| 91 | message: error, | |
| 92 | retryAction: { await viewModel.loadRepositories() } | |
| 93 | ) | |
| 94 | } else if viewModel.repositories.isEmpty, viewModel.error == nil { | |
| 95 | if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 96 | ContentUnavailableView( | |
| 97 | "No Repositories", | |
| 98 | systemImage: "book.closed", | |
| 99 | description: Text("You don't have any repositories yet.") | |
| 100 | ) | |
| 101 | } else { | |
| 102 | ContentUnavailableView.search | |
| 103 | } | |
| 104 | } | |
| 105 | } | |
| 106 | .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) { | |
| 107 | await viewModel.loadRepositories() | |
| 108 | } | |
| 109 | .srhtErrorBanner(error: $vm.error) | |
| 110 | .refreshable { | |
| 111 | await viewModel.loadRepositories() | |
| 112 | } | |
| 113 | .task { | |
| 114 | await viewModel.loadRepositories() | |
| 115 | } | |
| 116 | .onChange(of: viewModel.searchText) { oldValue, newValue in | |
| 117 | // Cancel previous search task | |
| 118 | searchTask?.cancel() | |
| 119 | ||
| 120 | // Clear results immediately when search text is cleared | |
| 121 | if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 122 | viewModel.resetSearch() | |
| 123 | Task { | |
| 124 | await viewModel.loadRepositories() | |
| 125 | } | |
| 126 | return | |
| 127 | } | |
| 128 | ||
| 129 | // Debounce search to avoid excessive API calls | |
| 130 | searchTask = Task { | |
| 131 | try? await Task.sleep(for: .milliseconds(350)) | |
| 132 | guard !Task.isCancelled else { return } | |
| 133 | await viewModel.loadRepositories(search: newValue) | |
| 134 | } | |
| 135 | } | |
| 136 | .navigationDestination(for: RepositorySummary.self) { repo in | |
| 137 | RepositoryDetailView(repository: repo) { | |
| 138 | viewModel.removeRepository(id: repo.id) | |
| 139 | } | |
| 140 | } | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | private struct CreateRepositorySheet: View { | |
| 145 | let viewModel: RepositoryListViewModel | |
| 146 | let onCreated: (RepositorySummary) -> Void | |
| 147 | ||
| 148 | @Environment(\.dismiss) private var dismiss | |
| 149 | @State private var name = "" | |
| 150 | @State private var description = "" | |
| 151 | @State private var cloneURL = "" | |
| 152 | @State private var visibility: Visibility = .public | |
| 153 | @State private var service: RepositoryCreationService = .git | |
| 154 | ||
| 155 | var body: some View { | |
| 156 | NavigationStack { | |
| 157 | Form { | |
| 158 | Section("Repository Details") { | |
| 159 | Picker("Version Control", selection: $service) { | |
| 160 | ForEach(RepositoryCreationService.allCases) { service in | |
| 161 | Text(service.displayName).tag(service) | |
| 162 | } | |
| 163 | } | |
| 164 | TextField("Repository name", text: $name) | |
| 165 | .textInputAutocapitalization(.never) | |
| 166 | .autocorrectionDisabled() | |
| 167 | TextField("Short description (optional)", text: $description, axis: .vertical) | |
| 168 | .lineLimit(2...4) | |
| 169 | Picker("Visibility", selection: $visibility) { | |
| 170 | Text("Public").tag(Visibility.public) | |
| 171 | Text("Unlisted").tag(Visibility.unlisted) | |
| 172 | Text("Private").tag(Visibility.private) | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | Section("Import Existing Repository") { | |
| 177 | if service == .git { | |
| 178 | TextField("Remote URL (optional)", text: $cloneURL) | |
| 179 | .textInputAutocapitalization(.never) | |
| 180 | .autocorrectionDisabled() | |
| 181 | .keyboardType(.URL) | |
| 182 | Text("Import an existing Git repository from a remote URL.") | |
| 183 | .font(.footnote) | |
| 184 | .foregroundStyle(.secondary) | |
| 185 | } else { | |
| 186 | Text("Importing a Mercurial repository from a remote URL is not available through the public API.") | |
| 187 | .font(.footnote) | |
| 188 | .foregroundStyle(.secondary) | |
| 189 | } | |
| 190 | } | |
| 191 | } | |
| 192 | .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository") | |
| 193 | .navigationBarTitleDisplayMode(.inline) | |
| 194 | .toolbar { | |
| 195 | ToolbarItem(placement: .cancellationAction) { | |
| 196 | Button("Cancel") { dismiss() } | |
| 197 | } | |
| 198 | ToolbarItem(placement: .confirmationAction) { | |
| 199 | Button { | |
| 200 | Task { | |
| 201 | if let repository = await viewModel.createRepository( | |
| 202 | service: service, | |
| 203 | name: name, | |
| 204 | description: description, | |
| 205 | visibility: visibility, | |
| 206 | cloneURL: cloneURL | |
| 207 | ) { | |
| 208 | onCreated(repository) | |
| 209 | } | |
| 210 | } | |
| 211 | } label: { | |
| 212 | if viewModel.isCreatingRepository { | |
| 213 | ProgressView() | |
| 214 | .controlSize(.small) | |
| 215 | } else { | |
| 216 | Text("Create Repository") | |
| 217 | } | |
| 218 | } | |
| 219 | .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository) | |
| 220 | } | |
| 221 | } | |
| 222 | } | |
| 223 | } | |
| 224 | } | |
Hutch/Views/Repositories/RepositoryListViewModel.swift added +548
| @@ -0,0 +1,548 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable { | |
| 4 | case git | |
| 5 | case hg | |
| 6 | ||
| 7 | var id: String { rawValue } | |
| 8 | ||
| 9 | var service: SRHTService { | |
| 10 | switch self { | |
| 11 | case .git: .git | |
| 12 | case .hg: .hg | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | var displayName: String { | |
| 17 | switch self { | |
| 18 | case .git: "Git" | |
| 19 | case .hg: "Mercurial" | |
| 20 | } | |
| 21 | } | |
| 22 | } | |
| 23 | ||
| 24 | /// View model for the repository list screen. | |
| 25 | @Observable | |
| 26 | @MainActor | |
| 27 | final class RepositoryListViewModel { | |
| 28 | ||
| 29 | private(set) var repositories: [RepositorySummary] = [] | |
| 30 | private(set) var isLoading = false | |
| 31 | private(set) var isLoadingMore = false | |
| 32 | private(set) var isRefreshing = false | |
| 33 | var error: String? | |
| 34 | ||
| 35 | var searchText = "" | |
| 36 | ||
| 37 | private(set) var cursor: String? | |
| 38 | private(set) var hasMore = false | |
| 39 | private(set) var isSearching = false | |
| 40 | private(set) var isCreatingRepository = false | |
| 41 | private let client: SRHTClient | |
| 42 | ||
| 43 | private static let gitCacheKey = "git.repositories" | |
| 44 | private static let hgCacheKey = "hg.repositories" | |
| 45 | ||
| 46 | init(client: SRHTClient) { | |
| 47 | self.client = client | |
| 48 | } | |
| 49 | ||
| 50 | // MARK: - Queries | |
| 51 | ||
| 52 | private static let gitQuery = """ | |
| 53 | query repositories($cursor: Cursor, $filter: Filter) { | |
| 54 | repositories(cursor: $cursor, filter: $filter) { | |
| 55 | results { | |
| 56 | id | |
| 57 | rid | |
| 58 | name | |
| 59 | description | |
| 60 | visibility | |
| 61 | updated | |
| 62 | owner { canonicalName } | |
| 63 | HEAD { name } | |
| 64 | } | |
| 65 | cursor | |
| 66 | } | |
| 67 | } | |
| 68 | """ | |
| 69 | ||
| 70 | private static let hgQuery = """ | |
| 71 | query repositories($cursor: Cursor) { | |
| 72 | repositories(cursor: $cursor) { | |
| 73 | results { | |
| 74 | id | |
| 75 | rid | |
| 76 | name | |
| 77 | description | |
| 78 | visibility | |
| 79 | updated | |
| 80 | owner { canonicalName } | |
| 81 | tip { branch } | |
| 82 | } | |
| 83 | cursor | |
| 84 | } | |
| 85 | } | |
| 86 | """ | |
| 87 | ||
| 88 | private static let createRepositoryMutation = """ | |
| 89 | mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) { | |
| 90 | createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) { | |
| 91 | id | |
| 92 | rid | |
| 93 | name | |
| 94 | description | |
| 95 | visibility | |
| 96 | updated | |
| 97 | owner { canonicalName } | |
| 98 | } | |
| 99 | } | |
| 100 | """ | |
| 101 | ||
| 102 | private static let createHgRepositoryMutation = """ | |
| 103 | mutation createRepository($name: String!, $visibility: Visibility!, $description: String) { | |
| 104 | createRepository(name: $name, visibility: $visibility, description: $description) { | |
| 105 | id | |
| 106 | rid | |
| 107 | name | |
| 108 | description | |
| 109 | visibility | |
| 110 | updated | |
| 111 | owner { canonicalName } | |
| 112 | tip { branch } | |
| 113 | } | |
| 114 | } | |
| 115 | """ | |
| 116 | ||
| 117 | // MARK: - Public API | |
| 118 | ||
| 119 | /// Fetch the first page of repositories. Shows cached data instantly if available, | |
| 120 | /// then refreshes from the network in the background. | |
| 121 | /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`. | |
| 122 | func loadRepositories(search: String? = nil) async { | |
| 123 | let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 124 | let isSearch = !query.isEmpty | |
| 125 | ||
| 126 | // Only use cache for non-search, initial loads | |
| 127 | if !isSearch, repositories.isEmpty { | |
| 128 | loadFromCache() | |
| 129 | } | |
| 130 | ||
| 131 | // During search, never show the full-screen loading overlay (which | |
| 132 | // would remove the List and dismiss the keyboard). Use "refreshing" | |
| 133 | // instead so the list stays in the hierarchy. | |
| 134 | if isSearch { | |
| 135 | isRefreshing = true | |
| 136 | isSearching = true | |
| 137 | } else if repositories.isEmpty { | |
| 138 | isLoading = true | |
| 139 | isSearching = false | |
| 140 | } else { | |
| 141 | isRefreshing = true | |
| 142 | isSearching = false | |
| 143 | } | |
| 144 | error = nil | |
| 145 | cursor = nil | |
| 146 | hasMore = false | |
| 147 | ||
| 148 | do { | |
| 149 | var filteredResults: [RepositorySummary] | |
| 150 | ||
| 151 | if isSearch { | |
| 152 | // For search queries, fetch all repositories from both services. | |
| 153 | filteredResults = try await fetchAllRepositories() | |
| 154 | ||
| 155 | // Perform client-side filtering | |
| 156 | let lowercasedQuery = query.lowercased() | |
| 157 | filteredResults = filteredResults.filter { repo in | |
| 158 | repo.name.lowercased().contains(lowercasedQuery) || | |
| 159 | repo.description?.lowercased().contains(lowercasedQuery) ?? false | |
| 160 | } | |
| 161 | } else { | |
| 162 | let repositories = try await fetchAllRepositories(useCache: true) | |
| 163 | filteredResults = repositories | |
| 164 | } | |
| 165 | ||
| 166 | repositories = filteredResults.sorted(by: repositorySortOrder) | |
| 167 | } catch { | |
| 168 | // Only show error if we have no cached data to fall back on | |
| 169 | if repositories.isEmpty { | |
| 170 | self.error = error.localizedDescription | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | isLoading = false | |
| 175 | isRefreshing = false | |
| 176 | } | |
| 177 | ||
| 178 | /// Load the next page if available. Called when the user scrolls near the end. | |
| 179 | /// Note: Pagination is disabled during search (client-side filtering). | |
| 180 | func loadMoreIfNeeded(currentItem: RepositorySummary) async { | |
| 181 | _ = currentItem | |
| 182 | } | |
| 183 | ||
| 184 | /// Remove a repository from the local list (e.g. after deletion). | |
| 185 | func removeRepository(id: Int) { | |
| 186 | repositories.removeAll { $0.id == id } | |
| 187 | } | |
| 188 | ||
| 189 | func createRepository( | |
| 190 | service: RepositoryCreationService, | |
| 191 | name: String, | |
| 192 | description: String, | |
| 193 | visibility: Visibility, | |
| 194 | cloneURL: String | |
| 195 | ) async -> RepositorySummary? { | |
| 196 | guard !isCreatingRepository else { return nil } | |
| 197 | ||
| 198 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 199 | guard !trimmedName.isEmpty else { | |
| 200 | error = "Enter a repository name." | |
| 201 | return nil | |
| 202 | } | |
| 203 | ||
| 204 | isCreatingRepository = true | |
| 205 | error = nil | |
| 206 | defer { isCreatingRepository = false } | |
| 207 | ||
| 208 | var variables: [String: any Sendable] = [ | |
| 209 | "name": trimmedName, | |
| 210 | "visibility": visibility.rawValue | |
| 211 | ] | |
| 212 | let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 213 | if !trimmedDescription.isEmpty { | |
| 214 | variables["description"] = trimmedDescription | |
| 215 | } | |
| 216 | let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 217 | if !trimmedCloneURL.isEmpty { | |
| 218 | variables["cloneUrl"] = trimmedCloneURL | |
| 219 | } | |
| 220 | ||
| 221 | do { | |
| 222 | let repository: RepositorySummary | |
| 223 | switch service { | |
| 224 | case .git: | |
| 225 | let result = try await client.execute( | |
| 226 | service: .git, | |
| 227 | query: Self.createRepositoryMutation, | |
| 228 | variables: variables, | |
| 229 | responseType: CreateRepositoryResponse.self | |
| 230 | ) | |
| 231 | repository = result.createRepository | |
| 232 | case .hg: | |
| 233 | variables.removeValue(forKey: "cloneUrl") | |
| 234 | let result = try await client.execute( | |
| 235 | service: .hg, | |
| 236 | query: Self.createHgRepositoryMutation, | |
| 237 | variables: variables, | |
| 238 | responseType: CreateHGRepositoryResponse.self | |
| 239 | ) | |
| 240 | repository = result.createRepository.repositorySummary(service: .hg) | |
| 241 | } | |
| 242 | repositories.insert(repository, at: 0) | |
| 243 | return repository | |
| 244 | } catch { | |
| 245 | self.error = repositoryCreationErrorMessage(for: error) | |
| 246 | return nil | |
| 247 | } | |
| 248 | } | |
| 249 | ||
| 250 | private func repositoryCreationErrorMessage(for error: Error) -> String { | |
| 251 | let message: String | |
| 252 | ||
| 253 | if let srhtError = error as? SRHTError { | |
| 254 | switch srhtError { | |
| 255 | case .graphQLErrors(let errors): | |
| 256 | message = errors.map(\.message).joined(separator: "\n") | |
| 257 | default: | |
| 258 | message = srhtError.localizedDescription | |
| 259 | } | |
| 260 | } else { | |
| 261 | message = error.localizedDescription | |
| 262 | } | |
| 263 | ||
| 264 | return "Couldn’t create the repository. \(message)" | |
| 265 | } | |
| 266 | ||
| 267 | /// Fetch ALL repositories by paginating through all available pages. | |
| 268 | /// Used for search functionality to ensure we search through the complete dataset. | |
| 269 | private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] { | |
| 270 | async let gitRepositories = fetchRepositories(for: .git, useCache: useCache) | |
| 271 | async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache) | |
| 272 | return try await gitRepositories + hgRepositories | |
| 273 | } | |
| 274 | ||
| 275 | /// Reset search state and reload all repositories | |
| 276 | func resetSearch() { | |
| 277 | repositories = [] | |
| 278 | cursor = nil | |
| 279 | hasMore = false | |
| 280 | isSearching = false | |
| 281 | } | |
| 282 | ||
| 283 | // MARK: - Private | |
| 284 | ||
| 285 | /// Page shape matching the GraphQL response without generic constraints that | |
| 286 | /// conflict with strict concurrency when used from a @MainActor context. | |
| 287 | private struct Page: Decodable, Sendable { | |
| 288 | let results: [RepositoryPayload] | |
| 289 | let cursor: String? | |
| 290 | } | |
| 291 | ||
| 292 | private struct RepositoriesResponse: Decodable, Sendable { | |
| 293 | let repositories: Page? | |
| 294 | } | |
| 295 | ||
| 296 | private struct CreateRepositoryResponse: Decodable, Sendable { | |
| 297 | let createRepository: RepositorySummary | |
| 298 | } | |
| 299 | ||
| 300 | private struct CreateHGRepositoryResponse: Decodable, Sendable { | |
| 301 | let createRepository: HGRepositoryPayload | |
| 302 | } | |
| 303 | ||
| 304 | private struct HGPage: Decodable, Sendable { | |
| 305 | let results: [HGRepositoryPayload] | |
| 306 | let cursor: String? | |
| 307 | } | |
| 308 | ||
| 309 | private struct HGRepositoriesResponse: Decodable, Sendable { | |
| 310 | let repositories: HGPage? | |
| 311 | } | |
| 312 | ||
| 313 | private static let emptyPage = Page(results: [], cursor: nil) | |
| 314 | ||
| 315 | private struct RepositoryPayload: Decodable, Sendable { | |
| 316 | let id: Int | |
| 317 | let rid: String | |
| 318 | let name: String | |
| 319 | let description: String? | |
| 320 | let visibility: Visibility | |
| 321 | let updated: Date | |
| 322 | let owner: Entity | |
| 323 | let head: Reference? | |
| 324 | ||
| 325 | enum CodingKeys: String, CodingKey { | |
| 326 | case id, rid, name, description, visibility, updated, owner | |
| 327 | case head = "HEAD" | |
| 328 | } | |
| 329 | ||
| 330 | func repositorySummary(service: SRHTService) -> RepositorySummary { | |
| 331 | RepositorySummary( | |
| 332 | id: id, | |
| 333 | rid: rid, | |
| 334 | service: service, | |
| 335 | name: name, | |
| 336 | description: description, | |
| 337 | visibility: visibility, | |
| 338 | updated: updated, | |
| 339 | owner: owner, | |
| 340 | head: head | |
| 341 | ) | |
| 342 | } | |
| 343 | } | |
| 344 | ||
| 345 | private struct HGRepositoryPayload: Decodable, Sendable { | |
| 346 | let id: Int | |
| 347 | let rid: String | |
| 348 | let name: String | |
| 349 | let description: String? | |
| 350 | let visibility: Visibility | |
| 351 | let updated: Date | |
| 352 | let owner: Entity | |
| 353 | let tip: HGTipReference? | |
| 354 | ||
| 355 | func repositorySummary(service: SRHTService) -> RepositorySummary { | |
| 356 | RepositorySummary( | |
| 357 | id: id, | |
| 358 | rid: rid, | |
| 359 | service: service, | |
| 360 | name: name, | |
| 361 | description: description, | |
| 362 | visibility: visibility, | |
| 363 | updated: updated, | |
| 364 | owner: owner, | |
| 365 | head: tip.map { Reference(name: $0.branch, target: nil) } | |
| 366 | ) | |
| 367 | } | |
| 368 | } | |
| 369 | ||
| 370 | private struct HGTipReference: Decodable, Sendable { | |
| 371 | let branch: String | |
| 372 | } | |
| 373 | ||
| 374 | private func fetchPage( | |
| 375 | service: SRHTService, | |
| 376 | cursor: String?, | |
| 377 | search: String? = nil, | |
| 378 | useCache: Bool | |
| 379 | ) async throws -> Page { | |
| 380 | var variables: [String: any Sendable] = [:] | |
| 381 | if let cursor { | |
| 382 | variables["cursor"] = cursor | |
| 383 | } | |
| 384 | let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines) | |
| 385 | if !trimmed.isEmpty { | |
| 386 | variables["filter"] = ["search": trimmed] as [String: any Sendable] | |
| 387 | } | |
| 388 | ||
| 389 | if useCache && cursor == nil { | |
| 390 | switch service { | |
| 391 | case .git: | |
| 392 | let result = try await client.executeAndCache( | |
| 393 | service: service, | |
| 394 | query: Self.gitQuery, | |
| 395 | variables: variables.isEmpty ? nil : variables, | |
| 396 | responseType: RepositoriesResponse.self, | |
| 397 | cacheKey: cacheKey(for: service) | |
| 398 | ) | |
| 399 | return result.repositories ?? Self.emptyPage | |
| 400 | case .hg: | |
| 401 | let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } | |
| 402 | let result = try await client.executeAndCache( | |
| 403 | service: service, | |
| 404 | query: Self.hgQuery, | |
| 405 | variables: hgVariables, | |
| 406 | responseType: HGRepositoriesResponse.self, | |
| 407 | cacheKey: cacheKey(for: service) | |
| 408 | ) | |
| 409 | return Page( | |
| 410 | results: result.repositories?.results.map { | |
| 411 | RepositoryPayload( | |
| 412 | id: $0.id, | |
| 413 | rid: $0.rid, | |
| 414 | name: $0.name, | |
| 415 | description: $0.description, | |
| 416 | visibility: $0.visibility, | |
| 417 | updated: $0.updated, | |
| 418 | owner: $0.owner, | |
| 419 | head: $0.tip.map { Reference(name: $0.branch, target: nil) } | |
| 420 | ) | |
| 421 | } ?? [], | |
| 422 | cursor: result.repositories?.cursor | |
| 423 | ) | |
| 424 | default: | |
| 425 | let result = try await client.executeAndCache( | |
| 426 | service: service, | |
| 427 | query: Self.gitQuery, | |
| 428 | variables: variables.isEmpty ? nil : variables, | |
| 429 | responseType: RepositoriesResponse.self, | |
| 430 | cacheKey: cacheKey(for: service) | |
| 431 | ) | |
| 432 | return result.repositories ?? Self.emptyPage | |
| 433 | } | |
| 434 | } else { | |
| 435 | switch service { | |
| 436 | case .git: | |
| 437 | let result = try await client.execute( | |
| 438 | service: service, | |
| 439 | query: Self.gitQuery, | |
| 440 | variables: variables.isEmpty ? nil : variables, | |
| 441 | responseType: RepositoriesResponse.self | |
| 442 | ) | |
| 443 | return result.repositories ?? Self.emptyPage | |
| 444 | case .hg: | |
| 445 | let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } | |
| 446 | let result = try await client.execute( | |
| 447 | service: service, | |
| 448 | query: Self.hgQuery, | |
| 449 | variables: hgVariables, | |
| 450 | responseType: HGRepositoriesResponse.self | |
| 451 | ) | |
| 452 | return Page( | |
| 453 | results: result.repositories?.results.map { | |
| 454 | RepositoryPayload( | |
| 455 | id: $0.id, | |
| 456 | rid: $0.rid, | |
| 457 | name: $0.name, | |
| 458 | description: $0.description, | |
| 459 | visibility: $0.visibility, | |
| 460 | updated: $0.updated, | |
| 461 | owner: $0.owner, | |
| 462 | head: $0.tip.map { Reference(name: $0.branch, target: nil) } | |
| 463 | ) | |
| 464 | } ?? [], | |
| 465 | cursor: result.repositories?.cursor | |
| 466 | ) | |
| 467 | default: | |
| 468 | let result = try await client.execute( | |
| 469 | service: service, | |
| 470 | query: Self.gitQuery, | |
| 471 | variables: variables.isEmpty ? nil : variables, | |
| 472 | responseType: RepositoriesResponse.self | |
| 473 | ) | |
| 474 | return result.repositories ?? Self.emptyPage | |
| 475 | } | |
| 476 | } | |
| 477 | } | |
| 478 | ||
| 479 | private func loadFromCache() { | |
| 480 | let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in | |
| 481 | guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } | |
| 482 | let decoder = JSONDecoder() | |
| 483 | decoder.dateDecodingStrategy = .srhtFlexible | |
| 484 | switch service { | |
| 485 | case .git: | |
| 486 | if let response = try? decoder.decode( | |
| 487 | GraphQLResponse<RepositoriesResponse>.self, | |
| 488 | from: data | |
| 489 | ), let repos = response.data?.repositories { | |
| 490 | return repos.results.map { $0.repositorySummary(service: service) } | |
| 491 | } | |
| 492 | case .hg: | |
| 493 | if let response = try? decoder.decode( | |
| 494 | GraphQLResponse<HGRepositoriesResponse>.self, | |
| 495 | from: data | |
| 496 | ), let repos = response.data?.repositories { | |
| 497 | return repos.results.map { $0.repositorySummary(service: service) } | |
| 498 | } | |
| 499 | default: | |
| 500 | break | |
| 501 | } | |
| 502 | return [] | |
| 503 | } | |
| 504 | if !cachedRepositories.isEmpty { | |
| 505 | repositories = cachedRepositories.sorted(by: repositorySortOrder) | |
| 506 | } | |
| 507 | } | |
| 508 | ||
| 509 | private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] { | |
| 510 | var allRepositories: [RepositorySummary] = [] | |
| 511 | var currentCursor: String? = nil | |
| 512 | ||
| 513 | while true { | |
| 514 | let page = try await fetchPage( | |
| 515 | service: service, | |
| 516 | cursor: currentCursor, | |
| 517 | search: nil, | |
| 518 | useCache: useCache && currentCursor == nil | |
| 519 | ) | |
| 520 | allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) }) | |
| 521 | guard let nextCursor = page.cursor else { break } | |
| 522 | currentCursor = nextCursor | |
| 523 | } | |
| 524 | ||
| 525 | return allRepositories | |
| 526 | } | |
| 527 | ||
| 528 | private func cacheKey(for service: SRHTService) -> String { | |
| 529 | switch service { | |
| 530 | case .git: | |
| 531 | Self.gitCacheKey | |
| 532 | case .hg: | |
| 533 | Self.hgCacheKey | |
| 534 | default: | |
| 535 | "\(service.rawValue).repositories" | |
| 536 | } | |
| 537 | } | |
| 538 | ||
| 539 | private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool { | |
| 540 | if lhs.updated == rhs.updated { | |
| 541 | if lhs.service == rhs.service { | |
| 542 | return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending | |
| 543 | } | |
| 544 | return lhs.service.rawValue < rhs.service.rawValue | |
| 545 | } | |
| 546 | return lhs.updated > rhs.updated | |
| 547 | } | |
| 548 | } | |
Hutch/Views/Repositories/RepositoryRowView.swift added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct RepositoryRowView: View { | |
| 4 | let repository: RepositorySummary | |
| 5 | ||
| 6 | var body: some View { | |
| 7 | VStack(alignment: .leading, spacing: 4) { | |
| 8 | HStack(alignment: .firstTextBaseline) { | |
| 9 | Text(repository.name) | |
| 10 | .font(.headline) | |
| 11 | ||
| 12 | Spacer() | |
| 13 | ||
| 14 | if repository.service == .hg { | |
| 15 | Text("HG") | |
| 16 | .font(.caption2.weight(.medium)) | |
| 17 | .padding(.horizontal, 6) | |
| 18 | .padding(.vertical, 2) | |
| 19 | .background(Color.cyan.opacity(0.15), in: Capsule()) | |
| 20 | .foregroundStyle(.cyan) | |
| 21 | } | |
| 22 | ||
| 23 | VisibilityBadge(visibility: repository.visibility) | |
| 24 | } | |
| 25 | ||
| 26 | Text(repository.owner.canonicalName) | |
| 27 | .font(.subheadline) | |
| 28 | .foregroundStyle(.secondary) | |
| 29 | ||
| 30 | if let description = repository.description, | |
| 31 | !description.isEmpty { | |
| 32 | Text(description) | |
| 33 | .font(.subheadline) | |
| 34 | .foregroundStyle(.secondary) | |
| 35 | .lineLimit(2) | |
| 36 | } | |
| 37 | ||
| 38 | HStack(spacing: 12) { | |
| 39 | if let head = repository.head { | |
| 40 | Label(head.name, systemImage: "arrow.triangle.branch") | |
| 41 | .font(.caption) | |
| 42 | .foregroundStyle(.secondary) | |
| 43 | } | |
| 44 | ||
| 45 | Spacer() | |
| 46 | ||
| 47 | Text(repository.updated.relativeDescription) | |
| 48 | .font(.caption) | |
| 49 | .foregroundStyle(.tertiary) | |
| 50 | } | |
| 51 | } | |
| 52 | .padding(.vertical, 2) | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | // MARK: - VisibilityBadge | |
| 57 | ||
| 58 | struct VisibilityBadge: View { | |
| 59 | let visibility: Visibility | |
| 60 | ||
| 61 | var body: some View { | |
| 62 | Text(label) | |
| 63 | .font(.caption2.weight(.medium)) | |
| 64 | .padding(.horizontal, 6) | |
| 65 | .padding(.vertical, 2) | |
| 66 | .background(color.opacity(0.15), in: Capsule()) | |
| 67 | .foregroundStyle(color) | |
| 68 | } | |
| 69 | ||
| 70 | private var label: String { | |
| 71 | switch visibility { | |
| 72 | case .public: "PUBLIC" | |
| 73 | case .unlisted: "UNLISTED" | |
| 74 | case .private: "PRIVATE" | |
| 75 | } | |
| 76 | } | |
| 77 | ||
| 78 | private var color: Color { | |
| 79 | switch visibility { | |
| 80 | case .public: .green | |
| 81 | case .unlisted: .orange | |
| 82 | case .private: .red | |
| 83 | } | |
| 84 | } | |
| 85 | } | |
Hutch/Views/Repositories/RepositorySettingsView.swift +41 −17
| @@ -10,6 +10,7 @@ struct RepositorySettingsView: View { | ||
| 10 | 10 | @Environment(\.dismiss) private var dismiss |
| 11 | 11 | @State private var viewModel: RepositorySettingsViewModel? |
| 12 | 12 | @State private var showDeleteConfirmation = false |
| 13 | @State private var pendingACLDeletion: ACLEntry? | |
| 13 | 14 | |
| 14 | 15 | var body: some View { |
| 15 | 16 | NavigationStack { |
| @@ -17,7 +18,7 @@ struct RepositorySettingsView: View { | ||
| 17 | 18 | if let viewModel { |
| 18 | 19 | settingsForm(viewModel) |
| 19 | 20 | } else { |
| 20 | ProgressView() | |
| 21 | SRHTLoadingStateView(message: "Loading settings…") | |
| 21 | 22 | } |
| 22 | 23 | } |
| 23 | 24 | .navigationTitle("Settings") |
| @@ -51,13 +52,7 @@ struct RepositorySettingsView: View { | ||
| 51 | 52 | accessSection(viewModel) |
| 52 | 53 | deleteSection(viewModel) |
| 53 | 54 | } |
| 54 | .alert("Error", isPresented: .constant(viewModel.error != nil)) { | |
| 55 | Button("OK") { viewModel.error = nil } | |
| 56 | } message: { | |
| 57 | if let error = viewModel.error { | |
| 58 | Text(error) | |
| 59 | } | |
| 60 | } | |
| 55 | .srhtErrorBanner(error: $vm.error) | |
| 61 | 56 | .alert( |
| 62 | 57 | "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", |
| 63 | 58 | isPresented: $showDeleteConfirmation |
| @@ -75,6 +70,27 @@ struct RepositorySettingsView: View { | ||
| 75 | 70 | } message: { |
| 76 | 71 | Text("This cannot be undone.") |
| 77 | 72 | } |
| 73 | .alert("Remove Access?", isPresented: Binding( | |
| 74 | get: { pendingACLDeletion != nil }, | |
| 75 | set: { isPresented in | |
| 76 | if !isPresented { | |
| 77 | pendingACLDeletion = nil | |
| 78 | } | |
| 79 | } | |
| 80 | )) { | |
| 81 | Button("Cancel", role: .cancel) {} | |
| 82 | Button("Remove Access", role: .destructive) { | |
| 83 | guard let entry = pendingACLDeletion else { return } | |
| 84 | Task { | |
| 85 | await viewModel.deleteACL(entry) | |
| 86 | pendingACLDeletion = nil | |
| 87 | } | |
| 88 | } | |
| 89 | } message: { | |
| 90 | if let entry = pendingACLDeletion { | |
| 91 | Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.") | |
| 92 | } | |
| 93 | } | |
| 78 | 94 | } |
| 79 | 95 | |
| 80 | 96 | // MARK: - Info Section |
| @@ -82,6 +98,11 @@ struct RepositorySettingsView: View { | ||
| 82 | 98 | @ViewBuilder |
| 83 | 99 | private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { |
| 84 | 100 | Section("Info") { |
| 101 | LabeledContent("Name") { | |
| 102 | Text(repository.name) | |
| 103 | .font(.body.monospaced()) | |
| 104 | } | |
| 105 | ||
| 85 | 106 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) |
| 86 | 107 | .lineLimit(3...6) |
| 87 | 108 | |
| @@ -107,7 +128,7 @@ struct RepositorySettingsView: View { | ||
| 107 | 128 | ProgressView() |
| 108 | 129 | .frame(maxWidth: .infinity) |
| 109 | 130 | } else { |
| 110 | Text("Save") | |
| 131 | Text("Save Changes") | |
| 111 | 132 | .frame(maxWidth: .infinity) |
| 112 | 133 | } |
| 113 | 134 | } |
| @@ -120,7 +141,7 @@ struct RepositorySettingsView: View { | ||
| 120 | 141 | @ViewBuilder |
| 121 | 142 | private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { |
| 122 | 143 | Section { |
| 123 | TextField("Repository Name", text: Bindable(viewModel).editedName) | |
| 144 | TextField("New repository name", text: Bindable(viewModel).editedName) | |
| 124 | 145 | .autocorrectionDisabled() |
| 125 | 146 | .textInputAutocapitalization(.never) |
| 126 | 147 | |
| @@ -141,7 +162,7 @@ struct RepositorySettingsView: View { | ||
| 141 | 162 | ProgressView() |
| 142 | 163 | .frame(maxWidth: .infinity) |
| 143 | 164 | } else { |
| 144 | Text("Rename") | |
| 165 | Text("Rename Repository") | |
| 145 | 166 | .frame(maxWidth: .infinity) |
| 146 | 167 | } |
| 147 | 168 | } |
| @@ -163,7 +184,7 @@ struct RepositorySettingsView: View { | ||
| 163 | 184 | Spacer() |
| 164 | 185 | } |
| 165 | 186 | } else if viewModel.acls.isEmpty { |
| 166 | Text("No access control entries.") | |
| 187 | Text("No access entries yet.") | |
| 167 | 188 | .foregroundStyle(.secondary) |
| 168 | 189 | } else { |
| 169 | 190 | ForEach(viewModel.acls) { entry in |
| @@ -174,11 +195,11 @@ struct RepositorySettingsView: View { | ||
| 174 | 195 | .font(.caption.monospaced()) |
| 175 | 196 | .foregroundStyle(.secondary) |
| 176 | 197 | } |
| 177 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | |
| 198 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 178 | 199 | Button(role: .destructive) { |
| 179 | Task { await viewModel.deleteACL(entry) } | |
| 200 | pendingACLDeletion = entry | |
| 180 | 201 | } label: { |
| 181 | Label("Delete", systemImage: "trash") | |
| 202 | Label("Remove Access", systemImage: "trash") | |
| 182 | 203 | } |
| 183 | 204 | } |
| 184 | 205 | } |
| @@ -186,7 +207,7 @@ struct RepositorySettingsView: View { | ||
| 186 | 207 | |
| 187 | 208 | // Add ACL form |
| 188 | 209 | HStack { |
| 189 | TextField("Username", text: Bindable(viewModel).newACLEntity) | |
| 210 | TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) | |
| 190 | 211 | .autocorrectionDisabled() |
| 191 | 212 | .textInputAutocapitalization(.never) |
| 192 | 213 | |
| @@ -203,11 +224,14 @@ struct RepositorySettingsView: View { | ||
| 203 | 224 | if viewModel.isAddingACL { |
| 204 | 225 | ProgressView() |
| 205 | 226 | } else { |
| 206 | Image(systemName: "plus.circle.fill") | |
| 227 | Text("Add") | |
| 207 | 228 | } |
| 208 | 229 | } |
| 209 | 230 | .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| 210 | 231 | } |
| 232 | Text("Add a SourceHut user and choose read-only or read/write access.") | |
| 233 | .font(.caption) | |
| 234 | .foregroundStyle(.secondary) | |
| 211 | 235 | } header: { |
| 212 | 236 | Text("Access") |
| 213 | 237 | } |
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +18 −6
| @@ -63,6 +63,7 @@ final class RepositorySettingsViewModel { | ||
| 63 | 63 | |
| 64 | 64 | let repositoryId: Int |
| 65 | 65 | let repositoryRid: String |
| 66 | let service: SRHTService | |
| 66 | 67 | private let client: SRHTClient |
| 67 | 68 | |
| 68 | 69 | // MARK: - Info fields |
| @@ -107,6 +108,7 @@ final class RepositorySettingsViewModel { | ||
| 107 | 108 | ) { |
| 108 | 109 | self.repositoryId = repository.id |
| 109 | 110 | self.repositoryRid = repository.rid |
| 111 | self.service = repository.service | |
| 110 | 112 | self.client = client |
| 111 | 113 | self.editedDescription = repository.description ?? "" |
| 112 | 114 | self.editedVisibility = repository.visibility |
| @@ -143,7 +145,7 @@ final class RepositorySettingsViewModel { | ||
| 143 | 145 | "HEAD": editedHead |
| 144 | 146 | ] |
| 145 | 147 | _ = try await client.execute( |
| 146 | service: .git, | |
| 148 | service: service, | |
| 147 | 149 | query: Self.updateRepoMutation, |
| 148 | 150 | variables: ["id": repositoryId, "input": input], |
| 149 | 151 | responseType: UpdateRepoResponse.self |
| @@ -165,7 +167,7 @@ final class RepositorySettingsViewModel { | ||
| 165 | 167 | "name": editedName |
| 166 | 168 | ] |
| 167 | 169 | let result = try await client.execute( |
| 168 | service: .git, | |
| 170 | service: service, | |
| 169 | 171 | query: Self.updateRepoMutation, |
| 170 | 172 | variables: ["id": repositoryId, "input": input], |
| 171 | 173 | responseType: UpdateRepoResponse.self |
| @@ -207,6 +209,16 @@ final class RepositorySettingsViewModel { | ||
| 207 | 209 | } |
| 208 | 210 | """ |
| 209 | 211 | |
| 212 | private static let userLookupQuery = """ | |
| 213 | query userLookup($username: String!) { | |
| 214 | user(username: $username) { | |
| 215 | id | |
| 216 | username | |
| 217 | canonicalName | |
| 218 | } | |
| 219 | } | |
| 220 | """ | |
| 221 | ||
| 210 | 222 | func loadACLs() async { |
| 211 | 223 | guard !isLoadingACLs else { return } |
| 212 | 224 | isLoadingACLs = true |
| @@ -214,7 +226,7 @@ final class RepositorySettingsViewModel { | ||
| 214 | 226 | |
| 215 | 227 | do { |
| 216 | 228 | let result = try await client.execute( |
| 217 | service: .git, | |
| 229 | service: service, | |
| 218 | 230 | query: Self.aclsQuery, |
| 219 | 231 | variables: ["rid": repositoryRid], |
| 220 | 232 | responseType: ACLResponse.self |
| @@ -234,7 +246,7 @@ final class RepositorySettingsViewModel { | ||
| 234 | 246 | |
| 235 | 247 | do { |
| 236 | 248 | let result = try await client.execute( |
| 237 | service: .git, | |
| 249 | service: service, | |
| 238 | 250 | query: Self.updateACLMutation, |
| 239 | 251 | variables: [ |
| 240 | 252 | "repoId": repositoryId, |
| @@ -262,7 +274,7 @@ final class RepositorySettingsViewModel { | ||
| 262 | 274 | |
| 263 | 275 | do { |
| 264 | 276 | _ = try await client.execute( |
| 265 | service: .git, | |
| 277 | service: service, | |
| 266 | 278 | query: Self.deleteACLMutation, |
| 267 | 279 | variables: ["id": entry.id], |
| 268 | 280 | responseType: DeleteACLResponse.self |
| @@ -288,7 +300,7 @@ final class RepositorySettingsViewModel { | ||
| 288 | 300 | |
| 289 | 301 | do { |
| 290 | 302 | _ = try await client.execute( |
| 291 | service: .git, | |
| 303 | service: service, | |
| 292 | 304 | query: Self.deleteRepoMutation, |
| 293 | 305 | variables: ["id": repositoryId], |
| 294 | 306 | responseType: DeleteRepoResponse.self |
Hutch/Views/Repositories/RepositorySummarySupport.swift +15 −36
| @@ -39,31 +39,30 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String { | ||
| 39 | 39 | } |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | struct RepositorySummaryCard<Content: View>: View { | |
| 42 | struct SummaryMetadataRow: View { | |
| 43 | let icon: String | |
| 43 | 44 | let title: String |
| 44 | @ViewBuilder let content: Content | |
| 45 | ||
| 46 | init(_ title: String, @ViewBuilder content: () -> Content) { | |
| 47 | self.title = title | |
| 48 | self.content = content() | |
| 49 | } | |
| 45 | var subtitle: String? = nil | |
| 50 | 46 | |
| 51 | 47 | var body: some View { |
| 52 | VStack(alignment: .leading, spacing: 12) { | |
| 53 | Text(title) | |
| 54 | .font(.caption.weight(.semibold)) | |
| 48 | HStack(alignment: .top, spacing: 10) { | |
| 49 | Image(systemName: icon) | |
| 55 | 50 | .foregroundStyle(.secondary) |
| 56 | .textCase(.uppercase) | |
| 51 | .frame(width: 18) | |
| 57 | 52 | |
| 58 | content | |
| 53 | VStack(alignment: .leading, spacing: 2) { | |
| 54 | Text(title) | |
| 55 | if let subtitle, !subtitle.isEmpty { | |
| 56 | Text(subtitle) | |
| 57 | .font(.subheadline) | |
| 58 | .foregroundStyle(.secondary) | |
| 59 | } | |
| 60 | } | |
| 59 | 61 | } |
| 60 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 61 | .padding() | |
| 62 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) | |
| 63 | 62 | } |
| 64 | 63 | } |
| 65 | 64 | |
| 66 | struct RepositorySummaryField: View { | |
| 65 | struct SummaryDetailRow: View { | |
| 67 | 66 | let label: String |
| 68 | 67 | let value: String |
| 69 | 68 | var monospace: Bool = false |
| @@ -79,23 +78,3 @@ struct RepositorySummaryField: View { | ||
| 79 | 78 | } |
| 80 | 79 | } |
| 81 | 80 | } |
| 82 | ||
| 83 | struct RepositorySummaryListRow: View { | |
| 84 | let label: String | |
| 85 | let values: [String] | |
| 86 | ||
| 87 | var body: some View { | |
| 88 | VStack(alignment: .leading, spacing: 4) { | |
| 89 | Text(label) | |
| 90 | .font(.caption) | |
| 91 | .foregroundStyle(.secondary) | |
| 92 | ||
| 93 | if values.isEmpty { | |
| 94 | Text("None") | |
| 95 | .foregroundStyle(.tertiary) | |
| 96 | } else { | |
| 97 | Text(values.joined(separator: ", ")) | |
| 98 | } | |
| 99 | } | |
| 100 | } | |
| 101 | } | |
Hutch/Views/Settings/SettingsView.swift added +724
| @@ -0,0 +1,724 @@ | ||
| 1 | import PhotosUI | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | struct SettingsView: View { | |
| 5 | @Environment(AppState.self) private var appState | |
| 6 | @Environment(\.colorScheme) private var colorScheme | |
| 7 | @State private var viewModel: SettingsViewModel? | |
| 8 | @State private var pendingDestructiveAction: SettingsDestructiveAction? | |
| 9 | ||
| 10 | var body: some View { | |
| 11 | NavigationStack { | |
| 12 | Group { | |
| 13 | if let viewModel { | |
| 14 | settingsContent(viewModel) | |
| 15 | } else { | |
| 16 | SRHTLoadingStateView(message: "Loading profile…") | |
| 17 | } | |
| 18 | } | |
| 19 | .navigationTitle("Settings") | |
| 20 | .task { | |
| 21 | if viewModel == nil { | |
| 22 | let vm = SettingsViewModel(client: appState.client) | |
| 23 | viewModel = vm | |
| 24 | await vm.loadProfile() | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | @ViewBuilder | |
| 31 | private func settingsContent(_ viewModel: SettingsViewModel) -> some View { | |
| 32 | @Bindable var vm = viewModel | |
| 33 | ||
| 34 | Form { | |
| 35 | if let profile = viewModel.profile { | |
| 36 | // Profile section | |
| 37 | profileSection(profile, viewModel: viewModel) | |
| 38 | ||
| 39 | // SSH Keys | |
| 40 | sshKeysSection(viewModel) | |
| 41 | ||
| 42 | // PGP Keys | |
| 43 | pgpKeysSection(viewModel) | |
| 44 | ||
| 45 | // Personal Access Tokens | |
| 46 | patSection(viewModel) | |
| 47 | } | |
| 48 | ||
| 49 | // Token / Sign Out | |
| 50 | tokenSection() | |
| 51 | ||
| 52 | aboutSection() | |
| 53 | } | |
| 54 | .overlay { | |
| 55 | if viewModel.isLoading, viewModel.profile == nil { | |
| 56 | SRHTLoadingStateView(message: "Loading profile…") | |
| 57 | } else if let error = viewModel.error, viewModel.profile == nil { | |
| 58 | SRHTErrorStateView( | |
| 59 | title: "Couldn't Load Profile", | |
| 60 | message: error, | |
| 61 | retryAction: { await viewModel.loadProfile() } | |
| 62 | ) | |
| 63 | } | |
| 64 | } | |
| 65 | .sheet(isPresented: $vm.isEditingProfile) { | |
| 66 | if let profile = viewModel.profile { | |
| 67 | EditProfileSheet( | |
| 68 | profile: profile, | |
| 69 | viewModel: viewModel | |
| 70 | ) | |
| 71 | } | |
| 72 | } | |
| 73 | .alert("Error", isPresented: Binding( | |
| 74 | get: { viewModel.error != nil && viewModel.profile != nil }, | |
| 75 | set: { isPresented in | |
| 76 | if !isPresented { | |
| 77 | viewModel.error = nil | |
| 78 | } | |
| 79 | } | |
| 80 | )) { | |
| 81 | Button("OK") { viewModel.error = nil } | |
| 82 | } message: { | |
| 83 | if let error = viewModel.error { | |
| 84 | Text(error) | |
| 85 | } | |
| 86 | } | |
| 87 | .alert( | |
| 88 | pendingDestructiveAction?.title ?? "", | |
| 89 | isPresented: Binding( | |
| 90 | get: { pendingDestructiveAction != nil }, | |
| 91 | set: { isPresented in | |
| 92 | if !isPresented { | |
| 93 | pendingDestructiveAction = nil | |
| 94 | } | |
| 95 | } | |
| 96 | ) | |
| 97 | ) { | |
| 98 | Button("Cancel", role: .cancel) {} | |
| 99 | Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) { | |
| 100 | guard let action = pendingDestructiveAction else { return } | |
| 101 | pendingDestructiveAction = nil | |
| 102 | Task { | |
| 103 | switch action { | |
| 104 | case .resetAppData: | |
| 105 | await appState.resetAppData() | |
| 106 | case .signOut: | |
| 107 | await appState.signOut() | |
| 108 | case .deleteSSHKey(let key): | |
| 109 | await viewModel.deleteSSHKey(key) | |
| 110 | case .deletePGPKey(let key): | |
| 111 | await viewModel.deletePGPKey(key) | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | } message: { | |
| 116 | if let pendingDestructiveAction { | |
| 117 | Text(pendingDestructiveAction.message) | |
| 118 | } | |
| 119 | } | |
| 120 | .refreshable { | |
| 121 | await viewModel.loadProfile() | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | // MARK: - Profile Section | |
| 126 | ||
| 127 | @ViewBuilder | |
| 128 | private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View { | |
| 129 | Section("Profile") { | |
| 130 | HStack(spacing: 12) { | |
| 131 | AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in | |
| 132 | switch phase { | |
| 133 | case .success(let image): | |
| 134 | image | |
| 135 | .resizable() | |
| 136 | .scaledToFill() | |
| 137 | default: | |
| 138 | Image(systemName: "person.crop.circle.fill") | |
| 139 | .resizable() | |
| 140 | .foregroundStyle(.secondary) | |
| 141 | } | |
| 142 | } | |
| 143 | .frame(width: 56, height: 56) | |
| 144 | .clipShape(Circle()) | |
| 145 | ||
| 146 | VStack(alignment: .leading, spacing: 2) { | |
| 147 | Text(profile.canonicalName) | |
| 148 | .font(.headline) | |
| 149 | Text(profile.email) | |
| 150 | .font(.subheadline) | |
| 151 | .foregroundStyle(.secondary) | |
| 152 | if let userType = profile.userType { | |
| 153 | Text(userType.capitalized) | |
| 154 | .font(.caption) | |
| 155 | .foregroundStyle(.tertiary) | |
| 156 | } | |
| 157 | } | |
| 158 | } | |
| 159 | .padding(.vertical, 4) | |
| 160 | ||
| 161 | if let bio = profile.bio, !bio.isEmpty { | |
| 162 | VStack(alignment: .leading, spacing: 2) { | |
| 163 | Text("Bio") | |
| 164 | .font(.caption) | |
| 165 | .foregroundStyle(.secondary) | |
| 166 | RenderedMarkupContentView( | |
| 167 | content: .markdown(bio), | |
| 168 | readmePath: nil, | |
| 169 | colorScheme: colorScheme, | |
| 170 | ownerCanonicalName: "", | |
| 171 | repositoryName: "" | |
| 172 | ) | |
| 173 | } | |
| 174 | } | |
| 175 | ||
| 176 | if let location = profile.location, !location.isEmpty { | |
| 177 | LabeledContent("Location", value: location) | |
| 178 | } | |
| 179 | ||
| 180 | if let url = profile.url, !url.isEmpty { | |
| 181 | LabeledContent("URL", value: url) | |
| 182 | } | |
| 183 | ||
| 184 | if let status = profile.paymentStatus { | |
| 185 | LabeledContent("Payment", value: status.capitalized) | |
| 186 | } | |
| 187 | ||
| 188 | if let sub = profile.subscription { | |
| 189 | if let status = sub.status { | |
| 190 | LabeledContent("Subscription", value: status.capitalized) | |
| 191 | } | |
| 192 | if let interval = sub.interval { | |
| 193 | LabeledContent("Interval", value: interval.capitalized) | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| 197 | Button("Edit Profile") { | |
| 198 | viewModel.isEditingProfile = true | |
| 199 | } | |
| 200 | ||
| 201 | SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) { | |
| 202 | SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up") | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | ||
| 207 | // MARK: - SSH Keys Section | |
| 208 | ||
| 209 | @ViewBuilder | |
| 210 | private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View { | |
| 211 | @Bindable var vm = viewModel | |
| 212 | ||
| 213 | Section { | |
| 214 | ForEach(viewModel.sshKeys) { key in | |
| 215 | VStack(alignment: .leading, spacing: 2) { | |
| 216 | Text(key.fingerprint) | |
| 217 | .font(.caption.monospaced()) | |
| 218 | .lineLimit(1) | |
| 219 | .truncationMode(.middle) | |
| 220 | ||
| 221 | HStack { | |
| 222 | if let comment = key.comment, !comment.isEmpty { | |
| 223 | Text(comment) | |
| 224 | .font(.caption2) | |
| 225 | .foregroundStyle(.secondary) | |
| 226 | } | |
| 227 | Spacer() | |
| 228 | Text(key.created.relativeDescription) | |
| 229 | .font(.caption2) | |
| 230 | .foregroundStyle(.tertiary) | |
| 231 | } | |
| 232 | ||
| 233 | if let lastUsed = key.lastUsed { | |
| 234 | Text("Last used \(lastUsed.relativeDescription)") | |
| 235 | .font(.caption2) | |
| 236 | .foregroundStyle(.tertiary) | |
| 237 | } | |
| 238 | } | |
| 239 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 240 | Button("Delete", role: .destructive) { | |
| 241 | pendingDestructiveAction = .deleteSSHKey(key) | |
| 242 | } | |
| 243 | } | |
| 244 | } | |
| 245 | ||
| 246 | if viewModel.isAddingSSHKey { | |
| 247 | TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical) | |
| 248 | .font(.caption.monospaced()) | |
| 249 | .lineLimit(3...6) | |
| 250 | ||
| 251 | HStack { | |
| 252 | Button("Cancel") { | |
| 253 | viewModel.isAddingSSHKey = false | |
| 254 | viewModel.newSSHKey = "" | |
| 255 | } | |
| 256 | Spacer() | |
| 257 | Button("Add") { | |
| 258 | Task { await viewModel.addSSHKey() } | |
| 259 | } | |
| 260 | .buttonStyle(.borderedProminent) | |
| 261 | .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 262 | } | |
| 263 | } else { | |
| 264 | Button { | |
| 265 | viewModel.isAddingSSHKey = true | |
| 266 | } label: { | |
| 267 | SwiftUI.Label("Add SSH Key", systemImage: "key") | |
| 268 | } | |
| 269 | } | |
| 270 | } header: { | |
| 271 | Text("SSH Keys") | |
| 272 | } footer: { | |
| 273 | Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")") | |
| 274 | } | |
| 275 | } | |
| 276 | ||
| 277 | // MARK: - PGP Keys Section | |
| 278 | ||
| 279 | @ViewBuilder | |
| 280 | private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View { | |
| 281 | @Bindable var vm = viewModel | |
| 282 | ||
| 283 | Section { | |
| 284 | ForEach(viewModel.pgpKeys) { key in | |
| 285 | VStack(alignment: .leading, spacing: 2) { | |
| 286 | Text(key.fingerprint) | |
| 287 | .font(.caption.monospaced()) | |
| 288 | .lineLimit(1) | |
| 289 | .truncationMode(.middle) | |
| 290 | ||
| 291 | Text(key.created.relativeDescription) | |
| 292 | .font(.caption2) | |
| 293 | .foregroundStyle(.tertiary) | |
| 294 | } | |
| 295 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { | |
| 296 | Button("Delete", role: .destructive) { | |
| 297 | pendingDestructiveAction = .deletePGPKey(key) | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | if viewModel.isAddingPGPKey { | |
| 303 | TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical) | |
| 304 | .font(.caption.monospaced()) | |
| 305 | .lineLimit(3...6) | |
| 306 | ||
| 307 | HStack { | |
| 308 | Button("Cancel") { | |
| 309 | viewModel.isAddingPGPKey = false | |
| 310 | viewModel.newPGPKey = "" | |
| 311 | } | |
| 312 | Spacer() | |
| 313 | Button("Add") { | |
| 314 | Task { await viewModel.addPGPKey() } | |
| 315 | } | |
| 316 | .buttonStyle(.borderedProminent) | |
| 317 | .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | |
| 318 | } | |
| 319 | } else { | |
| 320 | Button { | |
| 321 | viewModel.isAddingPGPKey = true | |
| 322 | } label: { | |
| 323 | SwiftUI.Label("Add PGP Key", systemImage: "key.fill") | |
| 324 | } | |
| 325 | } | |
| 326 | } header: { | |
| 327 | Text("PGP Keys") | |
| 328 | } footer: { | |
| 329 | Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")") | |
| 330 | } | |
| 331 | } | |
| 332 | ||
| 333 | // MARK: - Personal Access Tokens Section | |
| 334 | ||
| 335 | @ViewBuilder | |
| 336 | private func patSection(_ viewModel: SettingsViewModel) -> some View { | |
| 337 | Section { | |
| 338 | if viewModel.isLoadingPATs { | |
| 339 | HStack { | |
| 340 | Spacer() | |
| 341 | ProgressView() | |
| 342 | Spacer() | |
| 343 | } | |
| 344 | } else if viewModel.personalAccessTokens.isEmpty { | |
| 345 | Button("Load Tokens") { | |
| 346 | Task { await viewModel.loadPersonalAccessTokens() } | |
| 347 | } | |
| 348 | } else { | |
| 349 | ForEach(viewModel.personalAccessTokens) { token in | |
| 350 | VStack(alignment: .leading, spacing: 4) { | |
| 351 | HStack { | |
| 352 | Text(token.comment ?? "Token #\(token.id)") | |
| 353 | .font(.subheadline) | |
| 354 | Spacer() | |
| 355 | } | |
| 356 | ||
| 357 | HStack(spacing: 12) { | |
| 358 | Text("Issued \(token.issued.relativeDescription)") | |
| 359 | .font(.caption2) | |
| 360 | .foregroundStyle(.secondary) | |
| 361 | ||
| 362 | if let expires = token.expires { | |
| 363 | Text("Expires \(expires.relativeDescription)") | |
| 364 | .font(.caption2) | |
| 365 | .foregroundStyle(expires < Date.now ? .red : .secondary) | |
| 366 | } | |
| 367 | } | |
| 368 | ||
| 369 | if let grants = token.grants, !grants.isEmpty { | |
| 370 | Text(grants) | |
| 371 | .font(.caption2.monospaced()) | |
| 372 | .foregroundStyle(.tertiary) | |
| 373 | .lineLimit(2) | |
| 374 | } | |
| 375 | } | |
| 376 | } | |
| 377 | } | |
| 378 | } header: { | |
| 379 | Text("Personal Access Tokens") | |
| 380 | } footer: { | |
| 381 | if !viewModel.personalAccessTokens.isEmpty { | |
| 382 | Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")") | |
| 383 | } | |
| 384 | } | |
| 385 | } | |
| 386 | ||
| 387 | // MARK: - Token / Sign Out Section | |
| 388 | ||
| 389 | @ViewBuilder | |
| 390 | private func tokenSection() -> some View { | |
| 391 | Section { | |
| 392 | HStack { | |
| 393 | Image(systemName: "key.fill") | |
| 394 | .foregroundStyle(.secondary) | |
| 395 | Text("Personal access token in use") | |
| 396 | .font(.subheadline) | |
| 397 | .foregroundStyle(.secondary) | |
| 398 | } | |
| 399 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } | |
| 400 | ||
| 401 | Button("Reset App Data", role: .destructive) { | |
| 402 | pendingDestructiveAction = .resetAppData | |
| 403 | } | |
| 404 | ||
| 405 | Button("Sign Out", role: .destructive) { | |
| 406 | pendingDestructiveAction = .signOut | |
| 407 | } | |
| 408 | } header: { | |
| 409 | Text("Authentication") | |
| 410 | } footer: { | |
| 411 | Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.") | |
| 412 | } | |
| 413 | } | |
| 414 | ||
| 415 | @ViewBuilder | |
| 416 | private func aboutSection() -> some View { | |
| 417 | Section("App") { | |
| 418 | NavigationLink { | |
| 419 | AboutView() | |
| 420 | } label: { | |
| 421 | SwiftUI.Label("About Hutch", systemImage: "info.circle") | |
| 422 | } | |
| 423 | } | |
| 424 | } | |
| 425 | } | |
| 426 | ||
| 427 | // MARK: - Edit Profile Sheet | |
| 428 | ||
| 429 | private struct EditProfileSheet: View { | |
| 430 | let profile: UserProfile | |
| 431 | let viewModel: SettingsViewModel | |
| 432 | ||
| 433 | @State private var email: String | |
| 434 | @State private var url: String | |
| 435 | @State private var location: String | |
| 436 | @State private var bio: String | |
| 437 | @State private var selectedPhoto: PhotosPickerItem? | |
| 438 | @State private var avatarPreview: UIImage? | |
| 439 | @State private var isShowingRemoveAvatarConfirmation = false | |
| 440 | ||
| 441 | @Environment(\.dismiss) private var dismiss | |
| 442 | ||
| 443 | init(profile: UserProfile, viewModel: SettingsViewModel) { | |
| 444 | self.profile = profile | |
| 445 | self.viewModel = viewModel | |
| 446 | _email = State(initialValue: profile.email) | |
| 447 | _url = State(initialValue: profile.url ?? "") | |
| 448 | _location = State(initialValue: profile.location ?? "") | |
| 449 | _bio = State(initialValue: profile.bio ?? "") | |
| 450 | } | |
| 451 | ||
| 452 | var body: some View { | |
| 453 | NavigationStack { | |
| 454 | Form { | |
| 455 | Section { | |
| 456 | HStack { | |
| 457 | Spacer() | |
| 458 | VStack(spacing: 8) { | |
| 459 | PhotosPicker(selection: $selectedPhoto, matching: .images) { | |
| 460 | Group { | |
| 461 | if let avatarPreview { | |
| 462 | Image(uiImage: avatarPreview) | |
| 463 | .resizable() | |
| 464 | .scaledToFill() | |
| 465 | } else { | |
| 466 | AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in | |
| 467 | switch phase { | |
| 468 | case .success(let image): | |
| 469 | image | |
| 470 | .resizable() | |
| 471 | .scaledToFill() | |
| 472 | default: | |
| 473 | Image(systemName: "person.crop.circle.fill") | |
| 474 | .resizable() | |
| 475 | .foregroundStyle(.secondary) | |
| 476 | } | |
| 477 | } | |
| 478 | } | |
| 479 | } | |
| 480 | .frame(width: 80, height: 80) | |
| 481 | .clipShape(Circle()) | |
| 482 | .overlay( | |
| 483 | Circle() | |
| 484 | .stroke(.secondary.opacity(0.3), lineWidth: 1) | |
| 485 | ) | |
| 486 | } | |
| 487 | ||
| 488 | Text("Tap to change avatar") | |
| 489 | .font(.caption) | |
| 490 | .foregroundStyle(.secondary) | |
| 491 | ||
| 492 | if viewModel.isUploadingAvatar { | |
| 493 | ProgressView() | |
| 494 | .controlSize(.small) | |
| 495 | } | |
| 496 | } | |
| 497 | Spacer() | |
| 498 | } | |
| 499 | .listRowBackground(Color.clear) | |
| 500 | ||
| 501 | if profile.avatar != nil || avatarPreview != nil { | |
| 502 | HStack { | |
| 503 | Spacer() | |
| 504 | Button(role: .destructive) { | |
| 505 | isShowingRemoveAvatarConfirmation = true | |
| 506 | } label: { | |
| 507 | Text("Remove Avatar") | |
| 508 | } | |
| 509 | .buttonStyle(.borderedProminent) | |
| 510 | .disabled(viewModel.isUploadingAvatar) | |
| 511 | Spacer() | |
| 512 | } | |
| 513 | .listRowBackground(Color.clear) | |
| 514 | .listRowSeparator(.hidden) | |
| 515 | } | |
| 516 | } | |
| 517 | ||
| 518 | Section("Edit Profile") { | |
| 519 | VStack(alignment: .leading, spacing: 4) { | |
| 520 | Text("Email") | |
| 521 | .font(.caption) | |
| 522 | .foregroundStyle(.secondary) | |
| 523 | TextField("Enter email", text: $email) | |
| 524 | .textContentType(.emailAddress) | |
| 525 | .keyboardType(.emailAddress) | |
| 526 | .autocorrectionDisabled() | |
| 527 | .textInputAutocapitalization(.never) | |
| 528 | } | |
| 529 | ||
| 530 | VStack(alignment: .leading, spacing: 4) { | |
| 531 | Text("URL") | |
| 532 | .font(.caption) | |
| 533 | .foregroundStyle(.secondary) | |
| 534 | TextField("Enter URL", text: $url) | |
| 535 | .textContentType(.URL) | |
| 536 | .keyboardType(.URL) | |
| 537 | .autocorrectionDisabled() | |
| 538 | .textInputAutocapitalization(.never) | |
| 539 | } | |
| 540 | ||
| 541 | VStack(alignment: .leading, spacing: 4) { | |
| 542 | Text("Location") | |
| 543 | .font(.caption) | |
| 544 | .foregroundStyle(.secondary) | |
| 545 | TextField("Enter location", text: $location) | |
| 546 | } | |
| 547 | ||
| 548 | VStack(alignment: .leading, spacing: 4) { | |
| 549 | Text("Bio") | |
| 550 | .font(.caption) | |
| 551 | .foregroundStyle(.secondary) | |
| 552 | TextField("Enter bio", text: $bio, axis: .vertical) | |
| 553 | .lineLimit(3...6) | |
| 554 | } | |
| 555 | } | |
| 556 | } | |
| 557 | .navigationTitle("Edit Profile") | |
| 558 | .navigationBarTitleDisplayMode(.inline) | |
| 559 | .onChange(of: selectedPhoto) { _, newItem in | |
| 560 | guard let newItem else { return } | |
| 561 | Task { | |
| 562 | if let data = try? await newItem.loadTransferable(type: Data.self), | |
| 563 | let image = UIImage(data: data) { | |
| 564 | avatarPreview = image | |
| 565 | // Encode as JPEG and upload | |
| 566 | if let jpegData = image.jpegData(compressionQuality: 0.85) { | |
| 567 | await viewModel.uploadAvatar(jpegData: jpegData) | |
| 568 | } | |
| 569 | } | |
| 570 | } | |
| 571 | } | |
| 572 | .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) { | |
| 573 | Button("Cancel", role: .cancel) {} | |
| 574 | Button("Remove Avatar", role: .destructive) { | |
| 575 | Task { | |
| 576 | await viewModel.removeAvatar() | |
| 577 | if viewModel.error == nil { | |
| 578 | avatarPreview = nil | |
| 579 | selectedPhoto = nil | |
| 580 | } | |
| 581 | } | |
| 582 | } | |
| 583 | } message: { | |
| 584 | Text("Your profile avatar will be removed from SourceHut.") | |
| 585 | } | |
| 586 | .toolbar { | |
| 587 | ToolbarItem(placement: .cancellationAction) { | |
| 588 | Button("Cancel") { | |
| 589 | dismiss() | |
| 590 | } | |
| 591 | } | |
| 592 | ToolbarItem(placement: .confirmationAction) { | |
| 593 | Button { | |
| 594 | Task { | |
| 595 | await viewModel.saveProfile( | |
| 596 | email: email, | |
| 597 | url: url, | |
| 598 | location: location, | |
| 599 | bio: bio | |
| 600 | ) | |
| 601 | if viewModel.error == nil { | |
| 602 | dismiss() | |
| 603 | } | |
| 604 | } | |
| 605 | } label: { | |
| 606 | if viewModel.isSavingProfile { | |
| 607 | ProgressView() | |
| 608 | .controlSize(.small) | |
| 609 | } else { | |
| 610 | Text("Save") | |
| 611 | } | |
| 612 | } | |
| 613 | .disabled(viewModel.isSavingProfile) | |
| 614 | } | |
| 615 | } | |
| 616 | } | |
| 617 | } | |
| 618 | } | |
| 619 | ||
| 620 | private enum SettingsDestructiveAction { | |
| 621 | case resetAppData | |
| 622 | case signOut | |
| 623 | case deleteSSHKey(SSHKey) | |
| 624 | case deletePGPKey(PGPKey) | |
| 625 | ||
| 626 | var title: String { | |
| 627 | switch self { | |
| 628 | case .resetAppData: | |
| 629 | "Reset App Data?" | |
| 630 | case .signOut: | |
| 631 | "Sign Out?" | |
| 632 | case .deleteSSHKey: | |
| 633 | "Remove SSH Key?" | |
| 634 | case .deletePGPKey: | |
| 635 | "Remove PGP Key?" | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | var confirmationLabel: String { | |
| 640 | switch self { | |
| 641 | case .resetAppData: | |
| 642 | "Reset App Data" | |
| 643 | case .signOut: | |
| 644 | "Sign Out" | |
| 645 | case .deleteSSHKey: | |
| 646 | "Remove SSH Key" | |
| 647 | case .deletePGPKey: | |
| 648 | "Remove PGP Key" | |
| 649 | } | |
| 650 | } | |
| 651 | ||
| 652 | var message: String { | |
| 653 | switch self { | |
| 654 | case .resetAppData: | |
| 655 | "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device." | |
| 656 | case .signOut: | |
| 657 | "This signs you out of Hutch and clears saved authentication state on this device." | |
| 658 | case .deleteSSHKey(let key): | |
| 659 | "Remove SSH key \(key.fingerprint) from your account?" | |
| 660 | case .deletePGPKey(let key): | |
| 661 | "Remove PGP key \(key.fingerprint) from your account?" | |
| 662 | } | |
| 663 | } | |
| 664 | } | |
| 665 | ||
| 666 | private struct AboutView: View { | |
| 667 | private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String | |
| 668 | ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String | |
| 669 | ?? "Hutch" | |
| 670 | private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String | |
| 671 | ?? "Unknown" | |
| 672 | private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String | |
| 673 | ?? "Unknown" | |
| 674 | ||
| 675 | var body: some View { | |
| 676 | Form { | |
| 677 | Section { | |
| 678 | VStack(alignment: .leading, spacing: 6) { | |
| 679 | Text(appName) | |
| 680 | .font(.title2.weight(.semibold)) | |
| 681 | Text("A native SourceHut client for iPhone.") | |
| 682 | .font(.subheadline) | |
| 683 | .foregroundStyle(.secondary) | |
| 684 | } | |
| 685 | .padding(.vertical, 4) | |
| 686 | ||
| 687 | LabeledContent("Version", value: version) | |
| 688 | LabeledContent("Build", value: build) | |
| 689 | } | |
| 690 | ||
| 691 | Section("Links") { | |
| 692 | Link(destination: URL(string: "https://sr.ht")!) { | |
| 693 | SwiftUI.Label("SourceHut", systemImage: "link") | |
| 694 | } | |
| 695 | Link(destination: URL(string: "https://man.sr.ht")!) { | |
| 696 | SwiftUI.Label("SourceHut Manuals", systemImage: "book") | |
| 697 | } | |
| 698 | Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) { | |
| 699 | SwiftUI.Label("Project Repository", systemImage: "folder") | |
| 700 | } | |
| 701 | } | |
| 702 | ||
| 703 | Section("Support") { | |
| 704 | Link(destination: URL(string: "mailto:hello@cleberg.net")!) { | |
| 705 | SwiftUI.Label("Email Support", systemImage: "envelope") | |
| 706 | } | |
| 707 | } | |
| 708 | ||
| 709 | Section("Privacy") { | |
| 710 | Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.") | |
| 711 | .font(.subheadline) | |
| 712 | .foregroundStyle(.secondary) | |
| 713 | } | |
| 714 | ||
| 715 | Section("Acknowledgements") { | |
| 716 | Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.") | |
| 717 | .font(.subheadline) | |
| 718 | .foregroundStyle(.secondary) | |
| 719 | } | |
| 720 | } | |
| 721 | .navigationTitle("About") | |
| 722 | .navigationBarTitleDisplayMode(.inline) | |
| 723 | } | |
| 724 | } | |
Hutch/Views/Settings/SettingsViewModel.swift added +394
| @@ -0,0 +1,394 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct MeProfileResponse: Decodable, Sendable { | |
| 6 | let me: UserProfile | |
| 7 | } | |
| 8 | ||
| 9 | private struct UpdateUserResponse: Decodable, Sendable { | |
| 10 | let updateUser: UpdatedUser | |
| 11 | } | |
| 12 | ||
| 13 | private struct UpdatedUser: Decodable, Sendable { | |
| 14 | let username: String | |
| 15 | let email: String | |
| 16 | let url: String? | |
| 17 | let location: String? | |
| 18 | let bio: String? | |
| 19 | let avatar: String? | |
| 20 | } | |
| 21 | ||
| 22 | private struct CreateSSHKeyResponse: Decodable, Sendable { | |
| 23 | let createSSHKey: SSHKey | |
| 24 | } | |
| 25 | ||
| 26 | private struct DeleteSSHKeyResponse: Decodable, Sendable { | |
| 27 | let deleteSSHKey: DeleteResult | |
| 28 | } | |
| 29 | ||
| 30 | private struct CreatePGPKeyResponse: Decodable, Sendable { | |
| 31 | let createPGPKey: PGPKey | |
| 32 | } | |
| 33 | ||
| 34 | private struct DeletePGPKeyResponse: Decodable, Sendable { | |
| 35 | let deletePGPKey: DeleteResult | |
| 36 | } | |
| 37 | ||
| 38 | private struct DeleteResult: Decodable, Sendable { | |
| 39 | let id: Int? | |
| 40 | } | |
| 41 | ||
| 42 | private struct PATListResponse: Decodable, Sendable { | |
| 43 | let personalAccessTokens: [PersonalAccessToken] | |
| 44 | } | |
| 45 | ||
| 46 | // MARK: - View Model | |
| 47 | ||
| 48 | @Observable | |
| 49 | @MainActor | |
| 50 | final class SettingsViewModel { | |
| 51 | ||
| 52 | private(set) var profile: UserProfile? | |
| 53 | private(set) var sshKeys: [SSHKey] = [] | |
| 54 | private(set) var pgpKeys: [PGPKey] = [] | |
| 55 | private(set) var personalAccessTokens: [PersonalAccessToken] = [] | |
| 56 | ||
| 57 | private(set) var isLoading = false | |
| 58 | private(set) var isLoadingPATs = false | |
| 59 | private(set) var isSavingProfile = false | |
| 60 | private(set) var isUploadingAvatar = false | |
| 61 | var error: String? | |
| 62 | ||
| 63 | var isEditingProfile = false | |
| 64 | ||
| 65 | // Add SSH key fields | |
| 66 | var newSSHKey = "" | |
| 67 | var isAddingSSHKey = false | |
| 68 | ||
| 69 | // Add PGP key fields | |
| 70 | var newPGPKey = "" | |
| 71 | var isAddingPGPKey = false | |
| 72 | ||
| 73 | private let client: SRHTClient | |
| 74 | ||
| 75 | init(client: SRHTClient) { | |
| 76 | self.client = client | |
| 77 | } | |
| 78 | ||
| 79 | // MARK: - Queries | |
| 80 | ||
| 81 | private static let profileQuery = """ | |
| 82 | query me { | |
| 83 | me { | |
| 84 | username | |
| 85 | canonicalName | |
| 86 | ||
| 87 | url | |
| 88 | location | |
| 89 | bio | |
| 90 | avatar | |
| 91 | userType | |
| 92 | sshKeys { | |
| 93 | results { id fingerprint comment created lastUsed } | |
| 94 | cursor | |
| 95 | } | |
| 96 | pgpKeys { | |
| 97 | results { id fingerprint created } | |
| 98 | cursor | |
| 99 | } | |
| 100 | paymentStatus | |
| 101 | subscription { status autorenew interval } | |
| 102 | } | |
| 103 | } | |
| 104 | """ | |
| 105 | ||
| 106 | private static let updateUserMutation = """ | |
| 107 | mutation updateUser($input: UserInput!) { | |
| 108 | updateUser(input: $input) { | |
| 109 | username email url location bio avatar | |
| 110 | } | |
| 111 | } | |
| 112 | """ | |
| 113 | ||
| 114 | private static let createSSHKeyMutation = """ | |
| 115 | mutation createSSHKey($key: String!) { | |
| 116 | createSSHKey(key: $key) { | |
| 117 | id fingerprint comment created lastUsed | |
| 118 | } | |
| 119 | } | |
| 120 | """ | |
| 121 | ||
| 122 | private static let deleteSSHKeyMutation = """ | |
| 123 | mutation deleteSSHKey($id: Int!) { | |
| 124 | deleteSSHKey(id: $id) { id } | |
| 125 | } | |
| 126 | """ | |
| 127 | ||
| 128 | private static let createPGPKeyMutation = """ | |
| 129 | mutation createPGPKey($key: String!) { | |
| 130 | createPGPKey(key: $key) { | |
| 131 | id fingerprint created | |
| 132 | } | |
| 133 | } | |
| 134 | """ | |
| 135 | ||
| 136 | private static let deletePGPKeyMutation = """ | |
| 137 | mutation deletePGPKey($id: Int!) { | |
| 138 | deletePGPKey(id: $id) { id } | |
| 139 | } | |
| 140 | """ | |
| 141 | ||
| 142 | private static let personalAccessTokensQuery = """ | |
| 143 | query personalAccessTokens { | |
| 144 | personalAccessTokens { id issued expires comment grants } | |
| 145 | } | |
| 146 | """ | |
| 147 | ||
| 148 | // MARK: - Load Profile | |
| 149 | ||
| 150 | func loadProfile() async { | |
| 151 | guard !isLoading else { return } | |
| 152 | isLoading = true | |
| 153 | error = nil | |
| 154 | ||
| 155 | do { | |
| 156 | let result = try await client.execute( | |
| 157 | service: .meta, | |
| 158 | query: Self.profileQuery, | |
| 159 | responseType: MeProfileResponse.self | |
| 160 | ) | |
| 161 | profile = result.me | |
| 162 | sshKeys = result.me.sshKeys.results | |
| 163 | pgpKeys = result.me.pgpKeys.results | |
| 164 | } catch { | |
| 165 | self.error = error.localizedDescription | |
| 166 | } | |
| 167 | ||
| 168 | isLoading = false | |
| 169 | } | |
| 170 | ||
| 171 | // MARK: - Update Profile | |
| 172 | ||
| 173 | func saveProfile(email: String, url: String, location: String, bio: String) async { | |
| 174 | guard !isSavingProfile else { return } | |
| 175 | isSavingProfile = true | |
| 176 | error = nil | |
| 177 | ||
| 178 | do { | |
| 179 | let input: [String: any Sendable] = [ | |
| 180 | "email": email, | |
| 181 | "url": url.isEmpty ? nil as String? as Any : url, | |
| 182 | "location": location.isEmpty ? nil as String? as Any : location, | |
| 183 | "bio": bio.isEmpty ? nil as String? as Any : bio | |
| 184 | ] | |
| 185 | let result = try await client.execute( | |
| 186 | service: .meta, | |
| 187 | query: Self.updateUserMutation, | |
| 188 | variables: ["input": input], | |
| 189 | responseType: UpdateUserResponse.self | |
| 190 | ) | |
| 191 | let updated = result.updateUser | |
| 192 | if let p = profile { | |
| 193 | profile = UserProfile( | |
| 194 | username: p.username, | |
| 195 | canonicalName: p.canonicalName, | |
| 196 | email: updated.email, | |
| 197 | url: updated.url, | |
| 198 | location: updated.location, | |
| 199 | bio: updated.bio, | |
| 200 | avatar: updated.avatar ?? p.avatar, | |
| 201 | userType: p.userType, | |
| 202 | sshKeys: p.sshKeys, | |
| 203 | pgpKeys: p.pgpKeys, | |
| 204 | paymentStatus: p.paymentStatus, | |
| 205 | subscription: p.subscription | |
| 206 | ) | |
| 207 | } | |
| 208 | isEditingProfile = false | |
| 209 | } catch { | |
| 210 | self.error = error.localizedDescription | |
| 211 | } | |
| 212 | ||
| 213 | isSavingProfile = false | |
| 214 | } | |
| 215 | ||
| 216 | // MARK: - Avatar | |
| 217 | ||
| 218 | func uploadAvatar(jpegData: Data) async { | |
| 219 | guard !isUploadingAvatar else { return } | |
| 220 | isUploadingAvatar = true | |
| 221 | error = nil | |
| 222 | ||
| 223 | do { | |
| 224 | // The input variable has avatar set to null; the actual file | |
| 225 | // is sent as a separate multipart part per graphql-multipart-request-spec. | |
| 226 | let input: [String: any Sendable] = ["avatar": nil as String? as Any] | |
| 227 | let result = try await client.executeMultipart( | |
| 228 | service: .meta, | |
| 229 | query: Self.updateUserMutation, | |
| 230 | variables: ["input": input], | |
| 231 | fileVariablePath: "input.avatar", | |
| 232 | fileData: jpegData, | |
| 233 | fileName: "avatar.jpg", | |
| 234 | mimeType: "image/jpeg", | |
| 235 | responseType: UpdateUserResponse.self | |
| 236 | ) | |
| 237 | let updated = result.updateUser | |
| 238 | if let p = profile { | |
| 239 | profile = UserProfile( | |
| 240 | username: p.username, | |
| 241 | canonicalName: p.canonicalName, | |
| 242 | email: updated.email, | |
| 243 | url: updated.url, | |
| 244 | location: updated.location, | |
| 245 | bio: updated.bio, | |
| 246 | avatar: updated.avatar ?? p.avatar, | |
| 247 | userType: p.userType, | |
| 248 | sshKeys: p.sshKeys, | |
| 249 | pgpKeys: p.pgpKeys, | |
| 250 | paymentStatus: p.paymentStatus, | |
| 251 | subscription: p.subscription | |
| 252 | ) | |
| 253 | } | |
| 254 | } catch { | |
| 255 | self.error = error.localizedDescription | |
| 256 | } | |
| 257 | ||
| 258 | isUploadingAvatar = false | |
| 259 | } | |
| 260 | ||
| 261 | func removeAvatar() async { | |
| 262 | guard !isUploadingAvatar else { return } | |
| 263 | isUploadingAvatar = true | |
| 264 | error = nil | |
| 265 | ||
| 266 | do { | |
| 267 | let input: [String: any Sendable] = ["avatar": nil as String? as Any] | |
| 268 | let result = try await client.execute( | |
| 269 | service: .meta, | |
| 270 | query: Self.updateUserMutation, | |
| 271 | variables: ["input": input], | |
| 272 | responseType: UpdateUserResponse.self | |
| 273 | ) | |
| 274 | let updated = result.updateUser | |
| 275 | if let p = profile { | |
| 276 | profile = UserProfile( | |
| 277 | username: p.username, | |
| 278 | canonicalName: p.canonicalName, | |
| 279 | email: updated.email, | |
| 280 | url: updated.url, | |
| 281 | location: updated.location, | |
| 282 | bio: updated.bio, | |
| 283 | avatar: nil, | |
| 284 | userType: p.userType, | |
| 285 | sshKeys: p.sshKeys, | |
| 286 | pgpKeys: p.pgpKeys, | |
| 287 | paymentStatus: p.paymentStatus, | |
| 288 | subscription: p.subscription | |
| 289 | ) | |
| 290 | } | |
| 291 | } catch { | |
| 292 | self.error = error.localizedDescription | |
| 293 | } | |
| 294 | ||
| 295 | isUploadingAvatar = false | |
| 296 | } | |
| 297 | ||
| 298 | // MARK: - SSH Keys | |
| 299 | ||
| 300 | func addSSHKey() async { | |
| 301 | let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 302 | guard !key.isEmpty else { return } | |
| 303 | error = nil | |
| 304 | ||
| 305 | do { | |
| 306 | let result = try await client.execute( | |
| 307 | service: .meta, | |
| 308 | query: Self.createSSHKeyMutation, | |
| 309 | variables: ["key": key], | |
| 310 | responseType: CreateSSHKeyResponse.self | |
| 311 | ) | |
| 312 | sshKeys.append(result.createSSHKey) | |
| 313 | newSSHKey = "" | |
| 314 | isAddingSSHKey = false | |
| 315 | } catch { | |
| 316 | self.error = error.localizedDescription | |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | func deleteSSHKey(_ key: SSHKey) async { | |
| 321 | error = nil | |
| 322 | ||
| 323 | do { | |
| 324 | _ = try await client.execute( | |
| 325 | service: .meta, | |
| 326 | query: Self.deleteSSHKeyMutation, | |
| 327 | variables: ["id": key.id], | |
| 328 | responseType: DeleteSSHKeyResponse.self | |
| 329 | ) | |
| 330 | sshKeys.removeAll { $0.id == key.id } | |
| 331 | } catch { | |
| 332 | self.error = error.localizedDescription | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | // MARK: - PGP Keys | |
| 337 | ||
| 338 | func addPGPKey() async { | |
| 339 | let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 340 | guard !key.isEmpty else { return } | |
| 341 | error = nil | |
| 342 | ||
| 343 | do { | |
| 344 | let result = try await client.execute( | |
| 345 | service: .meta, | |
| 346 | query: Self.createPGPKeyMutation, | |
| 347 | variables: ["key": key], | |
| 348 | responseType: CreatePGPKeyResponse.self | |
| 349 | ) | |
| 350 | pgpKeys.append(result.createPGPKey) | |
| 351 | newPGPKey = "" | |
| 352 | isAddingPGPKey = false | |
| 353 | } catch { | |
| 354 | self.error = error.localizedDescription | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | func deletePGPKey(_ key: PGPKey) async { | |
| 359 | error = nil | |
| 360 | ||
| 361 | do { | |
| 362 | _ = try await client.execute( | |
| 363 | service: .meta, | |
| 364 | query: Self.deletePGPKeyMutation, | |
| 365 | variables: ["id": key.id], | |
| 366 | responseType: DeletePGPKeyResponse.self | |
| 367 | ) | |
| 368 | pgpKeys.removeAll { $0.id == key.id } | |
| 369 | } catch { | |
| 370 | self.error = error.localizedDescription | |
| 371 | } | |
| 372 | } | |
| 373 | ||
| 374 | // MARK: - Personal Access Tokens | |
| 375 | ||
| 376 | func loadPersonalAccessTokens() async { | |
| 377 | guard !isLoadingPATs else { return } | |
| 378 | isLoadingPATs = true | |
| 379 | ||
| 380 | do { | |
| 381 | let result = try await client.execute( | |
| 382 | service: .meta, | |
| 383 | query: Self.personalAccessTokensQuery, | |
| 384 | responseType: PATListResponse.self | |
| 385 | ) | |
| 386 | personalAccessTokens = result.personalAccessTokens | |
| 387 | } catch { | |
| 388 | self.error = error.localizedDescription | |
| 389 | } | |
| 390 | ||
| 391 | isLoadingPATs = false | |
| 392 | } | |
| 393 | ||
| 394 | } | |
Hutch/Views/Tickets/TicketDetailView.swift added +828
| @@ -0,0 +1,828 @@ | ||
| 1 | import SwiftUI | |
| 2 | import WebKit | |
| 3 | ||
| 4 | struct TicketDetailView: View { | |
| 5 | let ownerUsername: String | |
| 6 | let trackerName: String | |
| 7 | let trackerId: Int | |
| 8 | let trackerRid: String | |
| 9 | let ticketId: Int | |
| 10 | ||
| 11 | @Environment(AppState.self) private var appState | |
| 12 | @Environment(\.colorScheme) private var colorScheme | |
| 13 | @State private var viewModel: TicketDetailViewModel? | |
| 14 | ||
| 15 | // Sheet state | |
| 16 | @State private var showResolveSheet = false | |
| 17 | @State private var showAssignSheet = false | |
| 18 | @State private var showLabelsSheet = false | |
| 19 | ||
| 20 | // Comment composer mode | |
| 21 | @State private var commentMode: CommentMode = .write | |
| 22 | ||
| 23 | private enum CommentMode: String, CaseIterable { | |
| 24 | case write = "Write" | |
| 25 | case preview = "Preview" | |
| 26 | } | |
| 27 | ||
| 28 | var body: some View { | |
| 29 | Group { | |
| 30 | if let viewModel { | |
| 31 | detailContent(viewModel) | |
| 32 | } else { | |
| 33 | SRHTLoadingStateView(message: "Loading ticket…") | |
| 34 | } | |
| 35 | } | |
| 36 | .navigationTitle("#\(ticketId)") | |
| 37 | .navigationBarTitleDisplayMode(.inline) | |
| 38 | .toolbar { | |
| 39 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 40 | SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) { | |
| 41 | Image(systemName: "square.and.arrow.up") | |
| 42 | } | |
| 43 | ||
| 44 | if let viewModel, viewModel.ticket != nil { | |
| 45 | actionsMenu(viewModel) | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | .task { | |
| 50 | if viewModel == nil { | |
| 51 | let vm = TicketDetailViewModel( | |
| 52 | ownerUsername: ownerUsername, | |
| 53 | trackerName: trackerName, | |
| 54 | trackerId: trackerId, | |
| 55 | trackerRid: trackerRid, | |
| 56 | ticketId: ticketId, | |
| 57 | client: appState.client | |
| 58 | ) | |
| 59 | viewModel = vm | |
| 60 | await vm.loadTicket() | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | // MARK: - Actions Menu | |
| 66 | ||
| 67 | @ViewBuilder | |
| 68 | private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View { | |
| 69 | Menu { | |
| 70 | if let ticket = viewModel.ticket { | |
| 71 | if ticket.status == .resolved { | |
| 72 | Button { | |
| 73 | Task { | |
| 74 | await viewModel.updateStatus( | |
| 75 | status: .reported, | |
| 76 | resolution: .unresolved | |
| 77 | ) | |
| 78 | } | |
| 79 | } label: { | |
| 80 | SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward") | |
| 81 | } | |
| 82 | } else { | |
| 83 | Button { | |
| 84 | showResolveSheet = true | |
| 85 | } label: { | |
| 86 | SwiftUI.Label("Resolve", systemImage: "checkmark.circle") | |
| 87 | } | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | Button { | |
| 92 | showAssignSheet = true | |
| 93 | } label: { | |
| 94 | SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus") | |
| 95 | } | |
| 96 | ||
| 97 | Button { | |
| 98 | showLabelsSheet = true | |
| 99 | Task { await viewModel.loadTrackerLabels() } | |
| 100 | } label: { | |
| 101 | SwiftUI.Label("Manage Labels", systemImage: "tag") | |
| 102 | } | |
| 103 | } label: { | |
| 104 | Image(systemName: "ellipsis.circle") | |
| 105 | } | |
| 106 | .sheet(isPresented: $showResolveSheet) { | |
| 107 | ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet) | |
| 108 | .presentationDetents([.medium]) | |
| 109 | } | |
| 110 | .sheet(isPresented: $showAssignSheet) { | |
| 111 | AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet) | |
| 112 | .presentationDetents([.medium]) | |
| 113 | } | |
| 114 | .sheet(isPresented: $showLabelsSheet) { | |
| 115 | LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet) | |
| 116 | .presentationDetents([.medium]) | |
| 117 | } | |
| 118 | } | |
| 119 | ||
| 120 | // MARK: - Detail Content | |
| 121 | ||
| 122 | @ViewBuilder | |
| 123 | private func detailContent(_ viewModel: TicketDetailViewModel) -> some View { | |
| 124 | @Bindable var vm = viewModel | |
| 125 | ||
| 126 | if viewModel.isLoading, viewModel.ticket == nil { | |
| 127 | SRHTLoadingStateView(message: "Loading ticket…") | |
| 128 | } else if let error = viewModel.error, viewModel.ticket == nil { | |
| 129 | SRHTErrorStateView( | |
| 130 | title: "Couldn't Load Ticket", | |
| 131 | message: error, | |
| 132 | retryAction: { await viewModel.loadTicket() } | |
| 133 | ) | |
| 134 | } else if let ticket = viewModel.ticket { | |
| 135 | ScrollView { | |
| 136 | VStack(alignment: .leading, spacing: 0) { | |
| 137 | // Header | |
| 138 | ticketHeader(ticket) | |
| 139 | ||
| 140 | Divider() | |
| 141 | .padding(.vertical, 12) | |
| 142 | ||
| 143 | // Description | |
| 144 | if let description = ticket.description, !description.isEmpty { | |
| 145 | MarkdownContentView(markdown: description) | |
| 146 | .padding(.horizontal) | |
| 147 | .padding(.bottom, 16) | |
| 148 | ||
| 149 | Divider() | |
| 150 | .padding(.bottom, 12) | |
| 151 | } | |
| 152 | ||
| 153 | // Event timeline | |
| 154 | if !viewModel.events.isEmpty { | |
| 155 | Text("Activity") | |
| 156 | .font(.headline) | |
| 157 | .padding(.horizontal) | |
| 158 | .padding(.bottom, 8) | |
| 159 | ||
| 160 | LazyVStack(alignment: .leading, spacing: 0) { | |
| 161 | ForEach(viewModel.events) { event in | |
| 162 | EventRow( | |
| 163 | event: event, | |
| 164 | ticketSubmitter: viewModel.ticket?.submitter.canonicalName, | |
| 165 | ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName } | |
| 166 | ) | |
| 167 | if event.id != viewModel.events.last?.id { | |
| 168 | Divider() | |
| 169 | .padding(.leading, 40) | |
| 170 | } | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | Divider() | |
| 175 | .padding(.vertical, 12) | |
| 176 | } | |
| 177 | ||
| 178 | // Comment input | |
| 179 | commentInput(viewModel) | |
| 180 | } | |
| 181 | } | |
| 182 | .srhtErrorBanner(error: $vm.error) | |
| 183 | .refreshable { | |
| 184 | await viewModel.loadTicket() | |
| 185 | } | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | // MARK: - Header | |
| 190 | ||
| 191 | @ViewBuilder | |
| 192 | private func ticketHeader(_ ticket: TicketDetail) -> some View { | |
| 193 | VStack(alignment: .leading, spacing: 8) { | |
| 194 | Text(ticket.title) | |
| 195 | .font(.title3.weight(.semibold)) | |
| 196 | ||
| 197 | HStack(spacing: 8) { | |
| 198 | TicketStatusIcon(status: ticket.status) | |
| 199 | Text(ticket.status.displayName) | |
| 200 | .font(.subheadline.weight(.medium)) | |
| 201 | ||
| 202 | if ticket.status == .resolved, let resolution = ticket.resolution { | |
| 203 | Text("(\(resolution.displayName))") | |
| 204 | .font(.subheadline) | |
| 205 | .foregroundStyle(.secondary) | |
| 206 | } | |
| 207 | } | |
| 208 | ||
| 209 | HStack(spacing: 4) { | |
| 210 | Text("Opened by") | |
| 211 | .foregroundStyle(.secondary) | |
| 212 | Text(ticket.submitter.canonicalName) | |
| 213 | .fontWeight(.medium) | |
| 214 | Text(ticket.created.relativeDescription) | |
| 215 | .foregroundStyle(.tertiary) | |
| 216 | } | |
| 217 | .font(.caption) | |
| 218 | ||
| 219 | if !ticket.assignees.isEmpty { | |
| 220 | HStack(spacing: 4) { | |
| 221 | Image(systemName: "person.fill") | |
| 222 | .font(.caption2) | |
| 223 | .foregroundStyle(.secondary) | |
| 224 | Text(ticket.assignees.map(\.canonicalName).joined(separator: ", ")) | |
| 225 | .font(.caption) | |
| 226 | .foregroundStyle(.secondary) | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | if !ticket.labels.isEmpty { | |
| 231 | FlowLayout(spacing: 4) { | |
| 232 | ForEach(ticket.labels) { label in | |
| 233 | LabelPill(label: label) | |
| 234 | } | |
| 235 | } | |
| 236 | } | |
| 237 | } | |
| 238 | .padding() | |
| 239 | } | |
| 240 | ||
| 241 | // MARK: - Comment Input | |
| 242 | ||
| 243 | @ViewBuilder | |
| 244 | private func commentInput(_ viewModel: TicketDetailViewModel) -> some View { | |
| 245 | @Bindable var vm = viewModel | |
| 246 | ||
| 247 | VStack(alignment: .leading, spacing: 8) { | |
| 248 | Text("New Comment") | |
| 249 | .font(.headline) | |
| 250 | ||
| 251 | Picker("Mode", selection: $commentMode) { | |
| 252 | ForEach(CommentMode.allCases, id: \.self) { mode in | |
| 253 | Text(mode.rawValue).tag(mode) | |
| 254 | } | |
| 255 | } | |
| 256 | .pickerStyle(.segmented) | |
| 257 | ||
| 258 | if commentMode == .write { | |
| 259 | TextField("Write your comment…", text: $vm.commentText, axis: .vertical) | |
| 260 | .textFieldStyle(.roundedBorder) | |
| 261 | .lineLimit(3...8) | |
| 262 | } else { | |
| 263 | // Markdown preview | |
| 264 | if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 265 | Text("Nothing to preview") | |
| 266 | .font(.subheadline) | |
| 267 | .foregroundStyle(.secondary) | |
| 268 | .frame(maxWidth: .infinity, minHeight: 80, alignment: .center) | |
| 269 | .background(Color(.secondarySystemBackground)) | |
| 270 | .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| 271 | } else { | |
| 272 | MarkdownContentView(markdown: viewModel.commentText) | |
| 273 | .frame(minHeight: 80, maxHeight: 200) | |
| 274 | .clipShape(RoundedRectangle(cornerRadius: 8)) | |
| 275 | } | |
| 276 | } | |
| 277 | ||
| 278 | HStack { | |
| 279 | Spacer() | |
| 280 | Button { | |
| 281 | Task { await viewModel.submitComment() } | |
| 282 | } label: { | |
| 283 | if viewModel.isSubmitting { | |
| 284 | ProgressView() | |
| 285 | .controlSize(.small) | |
| 286 | } else { | |
| 287 | Text("Post Comment") | |
| 288 | } | |
| 289 | } | |
| 290 | .buttonStyle(.borderedProminent) | |
| 291 | .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting) | |
| 292 | } | |
| 293 | } | |
| 294 | .padding() | |
| 295 | } | |
| 296 | } | |
| 297 | ||
| 298 | // MARK: - Self-Sizing Markdown Web View | |
| 299 | ||
| 300 | /// Renders markdown as HTML in a WKWebView that auto-sizes its height to | |
| 301 | /// fit the rendered content. Reuses the same `markdownToHTML` converter and | |
| 302 | /// styling as the README renderer. | |
| 303 | private struct MarkdownContentView: View { | |
| 304 | let markdown: String | |
| 305 | @Environment(\.colorScheme) private var colorScheme | |
| 306 | @State private var renderedHTML: String? | |
| 307 | ||
| 308 | var body: some View { | |
| 309 | Group { | |
| 310 | if let renderedHTML { | |
| 311 | HTMLWebView( | |
| 312 | html: renderedHTML, | |
| 313 | colorScheme: colorScheme, | |
| 314 | style: .commentPreview | |
| 315 | ) | |
| 316 | } else { | |
| 317 | SRHTLoadingStateView(message: "Preparing content…") | |
| 318 | .frame(minHeight: 80) | |
| 319 | } | |
| 320 | } | |
| 321 | .task(id: markdown) { | |
| 322 | if renderedHTML != nil { | |
| 323 | try? await Task.sleep(for: .milliseconds(150)) | |
| 324 | guard !Task.isCancelled else { return } | |
| 325 | } | |
| 326 | ||
| 327 | let html = await Task.detached(priority: .userInitiated) { | |
| 328 | markdownToHTML(markdown) | |
| 329 | }.value | |
| 330 | guard !Task.isCancelled else { return } | |
| 331 | renderedHTML = html | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | // MARK: - Event Row | |
| 337 | ||
| 338 | private struct EventRow: View { | |
| 339 | let event: TicketEvent | |
| 340 | let ticketSubmitter: String? | |
| 341 | let ticketAssignees: [String]? | |
| 342 | @State private var isShowingSystemStatusInfo = false | |
| 343 | ||
| 344 | var body: some View { | |
| 345 | ForEach(event.changes) { change in | |
| 346 | HStack(alignment: .top, spacing: 12) { | |
| 347 | Image(systemName: icon(for: change)) | |
| 348 | .foregroundStyle(color(for: change)) | |
| 349 | .frame(width: 24) | |
| 350 | .padding(.top, 2) | |
| 351 | ||
| 352 | VStack(alignment: .leading, spacing: 4) { | |
| 353 | HStack { | |
| 354 | if kind(for: change) == .comment { | |
| 355 | Text(change.author?.canonicalName ?? "") | |
| 356 | .font(.subheadline.weight(.medium)) | |
| 357 | } else { | |
| 358 | let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees) | |
| 359 | if descriptionText.hasPrefix("System") { | |
| 360 | HStack(spacing: 4) { | |
| 361 | Text(descriptionText) | |
| 362 | .font(.subheadline) | |
| 363 | .foregroundStyle(.secondary) | |
| 364 | Button { | |
| 365 | isShowingSystemStatusInfo = true | |
| 366 | } label: { | |
| 367 | Image(systemName: "info.circle") | |
| 368 | .foregroundStyle(.gray) | |
| 369 | } | |
| 370 | .buttonStyle(.plain) | |
| 371 | } | |
| 372 | } else { | |
| 373 | Text(descriptionText) | |
| 374 | .font(.subheadline) | |
| 375 | .foregroundStyle(.secondary) | |
| 376 | } | |
| 377 | } | |
| 378 | Spacer() | |
| 379 | Text(event.created.relativeDescription) | |
| 380 | .font(.caption) | |
| 381 | .foregroundStyle(.tertiary) | |
| 382 | } | |
| 383 | if kind(for: change) == .comment, let text = change.text { | |
| 384 | MarkdownContentView(markdown: text) | |
| 385 | } | |
| 386 | } | |
| 387 | } | |
| 388 | .padding(.horizontal) | |
| 389 | .padding(.vertical, 8) | |
| 390 | .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) { | |
| 391 | Button("OK", role: .cancel) {} | |
| 392 | } message: { | |
| 393 | Text("This status change was recorded automatically or without a named user attached to the event.") | |
| 394 | } | |
| 395 | } | |
| 396 | } | |
| 397 | ||
| 398 | private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String { | |
| 399 | let eventKind = kind(for: change) | |
| 400 | let authorName: String | |
| 401 | ||
| 402 | if let commentAuthor = event.changes.first(where: { | |
| 403 | kind(for: $0) == .comment && $0.author != nil | |
| 404 | })?.author?.canonicalName { | |
| 405 | authorName = commentAuthor | |
| 406 | } else { | |
| 407 | switch eventKind { | |
| 408 | case .created: | |
| 409 | authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone" | |
| 410 | case .statusChange: | |
| 411 | authorName = "System" | |
| 412 | case .labelAdded, .labelRemoved, .labelUpdated: | |
| 413 | authorName = change.labeler?.canonicalName ?? "Someone" | |
| 414 | case .assigned, .unassigned: | |
| 415 | authorName = change.assigner?.canonicalName ?? "Someone" | |
| 416 | case .comment: | |
| 417 | authorName = change.author?.canonicalName ?? "Someone" | |
| 418 | case .ticketMention, .userMention: | |
| 419 | authorName = change.author?.canonicalName | |
| 420 | ?? change.assigner?.canonicalName | |
| 421 | ?? change.labeler?.canonicalName | |
| 422 | ?? "Someone" | |
| 423 | case .unknown: | |
| 424 | authorName = change.author?.canonicalName | |
| 425 | ?? change.assigner?.canonicalName | |
| 426 | ?? change.labeler?.canonicalName | |
| 427 | ?? change.assignee?.canonicalName | |
| 428 | ?? change.mentioned?.canonicalName | |
| 429 | ?? ticketAssignees?.first | |
| 430 | ?? "Someone" | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | switch eventKind { | |
| 435 | case .statusChange: | |
| 436 | let oldStatus = change.oldStatus?.displayName ?? "unknown" | |
| 437 | let newStatus = change.newStatus?.displayName ?? "unknown" | |
| 438 | return "\(authorName) changed status from \(oldStatus) to \(newStatus)" | |
| 439 | case .labelUpdated, .labelAdded: | |
| 440 | let labelName = change.label?.name ?? "a label" | |
| 441 | let verb = eventKind == .labelAdded ? "added" : "updated" | |
| 442 | return "\(authorName) \(verb) label \"\(labelName)\"" | |
| 443 | case .labelRemoved: | |
| 444 | let labelName = change.label?.name ?? "a label" | |
| 445 | return "\(authorName) removed label \"\(labelName)\"" | |
| 446 | case .assigned: | |
| 447 | let assigneeName = change.assignee?.canonicalName ?? "someone" | |
| 448 | return "\(authorName) assigned \(assigneeName)" | |
| 449 | case .unassigned: | |
| 450 | let assigneeName = change.assignee?.canonicalName ?? "someone" | |
| 451 | return "\(authorName) unassigned \(assigneeName)" | |
| 452 | case .ticketMention: | |
| 453 | if let ticketId = change.mentioned?.id { | |
| 454 | return "\(authorName) mentioned ticket #\(ticketId)" | |
| 455 | } | |
| 456 | return "\(authorName) mentioned another ticket" | |
| 457 | case .userMention: | |
| 458 | let user = change.mentioned?.canonicalName ?? "someone" | |
| 459 | return "\(authorName) mentioned \(user)" | |
| 460 | case .created: | |
| 461 | return "\(authorName) opened this ticket" | |
| 462 | case .comment: | |
| 463 | return "\(authorName) commented" | |
| 464 | case .unknown: | |
| 465 | return "\(authorName) updated this ticket" | |
| 466 | } | |
| 467 | } | |
| 468 | ||
| 469 | private func icon(for change: EventChange) -> String { | |
| 470 | switch kind(for: change) { | |
| 471 | case .comment: | |
| 472 | "text.bubble" | |
| 473 | case .statusChange: | |
| 474 | "arrow.triangle.2.circlepath" | |
| 475 | case .labelAdded, .labelRemoved, .labelUpdated: | |
| 476 | "tag" | |
| 477 | case .assigned: | |
| 478 | "person.badge.plus" | |
| 479 | case .unassigned: | |
| 480 | "person.badge.minus" | |
| 481 | case .ticketMention, .userMention: | |
| 482 | "at" | |
| 483 | case .created: | |
| 484 | "plus.circle" | |
| 485 | case .unknown: | |
| 486 | "circle.fill" | |
| 487 | } | |
| 488 | } | |
| 489 | ||
| 490 | private func color(for change: EventChange) -> Color { | |
| 491 | switch kind(for: change) { | |
| 492 | case .comment: | |
| 493 | .blue | |
| 494 | case .statusChange: | |
| 495 | change.newStatus == .resolved ? .green : .orange | |
| 496 | case .labelAdded, .labelRemoved, .labelUpdated: | |
| 497 | .purple | |
| 498 | case .assigned, .unassigned: | |
| 499 | .cyan | |
| 500 | case .ticketMention, .userMention: | |
| 501 | .indigo | |
| 502 | case .created: | |
| 503 | .green | |
| 504 | case .unknown: | |
| 505 | .gray | |
| 506 | } | |
| 507 | } | |
| 508 | ||
| 509 | private func kind(for change: EventChange) -> EventKind { | |
| 510 | switch change.eventType { | |
| 511 | case "COMMENT", "Comment": | |
| 512 | .comment | |
| 513 | case "STATUS_CHANGE", "StatusChange": | |
| 514 | .statusChange | |
| 515 | case "LABEL_UPDATE", "LabelUpdate": | |
| 516 | .labelUpdated | |
| 517 | case "LABEL_ADDED", "LabelAdded": | |
| 518 | .labelAdded | |
| 519 | case "LABEL_REMOVED", "LabelRemoved": | |
| 520 | .labelRemoved | |
| 521 | case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser": | |
| 522 | .assigned | |
| 523 | case "UNASSIGNED_USER", "UnassignedUser": | |
| 524 | .unassigned | |
| 525 | case "TICKET_MENTION", "TicketMention": | |
| 526 | .ticketMention | |
| 527 | case "USER_MENTION", "UserMention": | |
| 528 | .userMention | |
| 529 | case "CREATED", "Created": | |
| 530 | .created | |
| 531 | default: | |
| 532 | .unknown | |
| 533 | } | |
| 534 | } | |
| 535 | ||
| 536 | private enum EventKind: Equatable { | |
| 537 | case comment | |
| 538 | case statusChange | |
| 539 | case labelUpdated | |
| 540 | case labelAdded | |
| 541 | case labelRemoved | |
| 542 | case assigned | |
| 543 | case unassigned | |
| 544 | case ticketMention | |
| 545 | case userMention | |
| 546 | case created | |
| 547 | case unknown | |
| 548 | } | |
| 549 | } | |
| 550 | ||
| 551 | // MARK: - Resolve Sheet | |
| 552 | ||
| 553 | private struct ResolveSheet: View { | |
| 554 | let viewModel: TicketDetailViewModel | |
| 555 | @Binding var isPresented: Bool | |
| 556 | @State private var selectedResolution: TicketResolution = .fixed | |
| 557 | ||
| 558 | private static let resolutionOptions: [TicketResolution] = [ | |
| 559 | .closed, .fixed, .implemented, .wontFix, | |
| 560 | .byDesign, .invalid, .duplicate, .notOurBug | |
| 561 | ] | |
| 562 | ||
| 563 | var body: some View { | |
| 564 | NavigationStack { | |
| 565 | Form { | |
| 566 | Section("Resolution") { | |
| 567 | Picker("Resolution", selection: $selectedResolution) { | |
| 568 | ForEach(Self.resolutionOptions, id: \.self) { resolution in | |
| 569 | Text(resolution.displayName).tag(resolution) | |
| 570 | } | |
| 571 | } | |
| 572 | .pickerStyle(.inline) | |
| 573 | .labelsHidden() | |
| 574 | } | |
| 575 | } | |
| 576 | .navigationTitle("Resolve Ticket") | |
| 577 | .navigationBarTitleDisplayMode(.inline) | |
| 578 | .toolbar { | |
| 579 | ToolbarItem(placement: .cancellationAction) { | |
| 580 | Button("Cancel") { isPresented = false } | |
| 581 | } | |
| 582 | ToolbarItem(placement: .confirmationAction) { | |
| 583 | Button("Mark Resolved") { | |
| 584 | Task { | |
| 585 | await viewModel.updateStatus( | |
| 586 | status: .resolved, | |
| 587 | resolution: selectedResolution | |
| 588 | ) | |
| 589 | if viewModel.error == nil { | |
| 590 | isPresented = false | |
| 591 | } | |
| 592 | } | |
| 593 | } | |
| 594 | .disabled(viewModel.isPerformingAction) | |
| 595 | } | |
| 596 | } | |
| 597 | .overlay { | |
| 598 | if viewModel.isPerformingAction { | |
| 599 | ProgressView() | |
| 600 | } | |
| 601 | } | |
| 602 | } | |
| 603 | } | |
| 604 | } | |
| 605 | ||
| 606 | // MARK: - Assign Sheet | |
| 607 | ||
| 608 | private struct AssignSheet: View { | |
| 609 | let viewModel: TicketDetailViewModel | |
| 610 | @Binding var isPresented: Bool | |
| 611 | @State private var username = "" | |
| 612 | ||
| 613 | var body: some View { | |
| 614 | NavigationStack { | |
| 615 | Form { | |
| 616 | // Current assignees with remove buttons | |
| 617 | if let ticket = viewModel.ticket, !ticket.assignees.isEmpty { | |
| 618 | Section("Current Assignees") { | |
| 619 | ForEach(ticket.assignees, id: \.canonicalName) { assignee in | |
| 620 | HStack { | |
| 621 | Text(assignee.canonicalName) | |
| 622 | Spacer() | |
| 623 | Button(role: .destructive) { | |
| 624 | Task { | |
| 625 | await viewModel.unassignUser( | |
| 626 | username: assignee.canonicalName | |
| 627 | ) | |
| 628 | } | |
| 629 | } label: { | |
| 630 | Image(systemName: "minus.circle.fill") | |
| 631 | .foregroundStyle(.red) | |
| 632 | } | |
| 633 | .buttonStyle(.plain) | |
| 634 | } | |
| 635 | } | |
| 636 | } | |
| 637 | } | |
| 638 | ||
| 639 | Section("Add Assignee") { | |
| 640 | TextField("Username or ~username", text: $username) | |
| 641 | .textContentType(.username) | |
| 642 | .autocorrectionDisabled() | |
| 643 | .textInputAutocapitalization(.never) | |
| 644 | ||
| 645 | Button("Add Assignee") { | |
| 646 | let name = username.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 647 | guard !name.isEmpty else { return } | |
| 648 | Task { | |
| 649 | await viewModel.assignUser(username: name) | |
| 650 | username = "" | |
| 651 | } | |
| 652 | } | |
| 653 | .disabled( | |
| 654 | username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 655 | || viewModel.isPerformingAction | |
| 656 | ) | |
| 657 | } | |
| 658 | } | |
| 659 | .navigationTitle("Assignees") | |
| 660 | .navigationBarTitleDisplayMode(.inline) | |
| 661 | .toolbar { | |
| 662 | ToolbarItem(placement: .confirmationAction) { | |
| 663 | Button("Done") { isPresented = false } | |
| 664 | } | |
| 665 | } | |
| 666 | .overlay { | |
| 667 | if viewModel.isPerformingAction { | |
| 668 | ProgressView() | |
| 669 | } | |
| 670 | } | |
| 671 | } | |
| 672 | } | |
| 673 | } | |
| 674 | ||
| 675 | // MARK: - Labels Sheet | |
| 676 | ||
| 677 | private struct LabelsSheet: View { | |
| 678 | let viewModel: TicketDetailViewModel | |
| 679 | @Binding var isPresented: Bool | |
| 680 | @State private var showCreateLabel = false | |
| 681 | ||
| 682 | var body: some View { | |
| 683 | NavigationStack { | |
| 684 | Group { | |
| 685 | if viewModel.trackerLabels.isEmpty { | |
| 686 | if viewModel.isPerformingAction { | |
| 687 | ProgressView() | |
| 688 | } else { | |
| 689 | ContentUnavailableView( | |
| 690 | "No Labels", | |
| 691 | systemImage: "tag", | |
| 692 | description: Text("This tracker has no labels defined.") | |
| 693 | ) | |
| 694 | } | |
| 695 | } else { | |
| 696 | List { | |
| 697 | ForEach(viewModel.trackerLabels) { label in | |
| 698 | LabelToggleRow( | |
| 699 | label: label, | |
| 700 | isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false, | |
| 701 | isLoading: viewModel.isPerformingAction | |
| 702 | ) { shouldApply in | |
| 703 | Task { | |
| 704 | if shouldApply { | |
| 705 | await viewModel.labelTicket(labelId: label.id) | |
| 706 | } else { | |
| 707 | await viewModel.unlabelTicket(labelId: label.id) | |
| 708 | } | |
| 709 | } | |
| 710 | } | |
| 711 | } | |
| 712 | } | |
| 713 | } | |
| 714 | } | |
| 715 | .navigationTitle("Labels") | |
| 716 | .navigationBarTitleDisplayMode(.inline) | |
| 717 | .toolbar { | |
| 718 | ToolbarItem(placement: .cancellationAction) { | |
| 719 | Button("Done") { isPresented = false } | |
| 720 | } | |
| 721 | ToolbarItem(placement: .primaryAction) { | |
| 722 | Button { | |
| 723 | showCreateLabel = true | |
| 724 | } label: { | |
| 725 | SwiftUI.Label("New Label", systemImage: "plus") | |
| 726 | } | |
| 727 | } | |
| 728 | } | |
| 729 | .sheet(isPresented: $showCreateLabel) { | |
| 730 | CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel) | |
| 731 | .presentationDetents([.medium]) | |
| 732 | } | |
| 733 | } | |
| 734 | } | |
| 735 | } | |
| 736 | ||
| 737 | // MARK: - Create Label Sheet | |
| 738 | ||
| 739 | private struct CreateLabelSheet: View { | |
| 740 | let viewModel: TicketDetailViewModel | |
| 741 | @Binding var isPresented: Bool | |
| 742 | @State private var labelName = "" | |
| 743 | @State private var backgroundColor = Color.blue | |
| 744 | @State private var foregroundColor = Color.white | |
| 745 | ||
| 746 | var body: some View { | |
| 747 | NavigationStack { | |
| 748 | Form { | |
| 749 | Section("Label Details") { | |
| 750 | TextField("Label name", text: $labelName) | |
| 751 | .autocorrectionDisabled() | |
| 752 | } | |
| 753 | ||
| 754 | Section("Colors") { | |
| 755 | ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false) | |
| 756 | ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false) | |
| 757 | } | |
| 758 | ||
| 759 | Section("Preview") { | |
| 760 | HStack { | |
| 761 | Spacer() | |
| 762 | Text(labelName.isEmpty ? "Label" : labelName) | |
| 763 | .font(.caption2.weight(.medium)) | |
| 764 | .padding(.horizontal, 6) | |
| 765 | .padding(.vertical, 2) | |
| 766 | .background(backgroundColor) | |
| 767 | .foregroundStyle(foregroundColor) | |
| 768 | .clipShape(Capsule()) | |
| 769 | Spacer() | |
| 770 | } | |
| 771 | } | |
| 772 | } | |
| 773 | .navigationTitle("New Label") | |
| 774 | .navigationBarTitleDisplayMode(.inline) | |
| 775 | .toolbar { | |
| 776 | ToolbarItem(placement: .cancellationAction) { | |
| 777 | Button("Cancel") { isPresented = false } | |
| 778 | } | |
| 779 | ToolbarItem(placement: .confirmationAction) { | |
| 780 | Button("Create Label") { | |
| 781 | Task { | |
| 782 | await viewModel.createLabel( | |
| 783 | name: labelName.trimmingCharacters(in: .whitespacesAndNewlines), | |
| 784 | backgroundColor: backgroundColor.hexString, | |
| 785 | foregroundColor: foregroundColor.hexString | |
| 786 | ) | |
| 787 | if viewModel.error == nil { | |
| 788 | isPresented = false | |
| 789 | } | |
| 790 | } | |
| 791 | } | |
| 792 | .disabled( | |
| 793 | labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty | |
| 794 | || viewModel.isPerformingAction | |
| 795 | ) | |
| 796 | } | |
| 797 | } | |
| 798 | .overlay { | |
| 799 | if viewModel.isPerformingAction { | |
| 800 | ProgressView() | |
| 801 | } | |
| 802 | } | |
| 803 | } | |
| 804 | } | |
| 805 | } | |
| 806 | ||
| 807 | private struct LabelToggleRow: View { | |
| 808 | let label: TicketLabel | |
| 809 | let isApplied: Bool | |
| 810 | let isLoading: Bool | |
| 811 | let onToggle: (Bool) -> Void | |
| 812 | ||
| 813 | var body: some View { | |
| 814 | Button { | |
| 815 | onToggle(!isApplied) | |
| 816 | } label: { | |
| 817 | HStack { | |
| 818 | LabelPill(label: label) | |
| 819 | Spacer() | |
| 820 | if isApplied { | |
| 821 | Image(systemName: "checkmark") | |
| 822 | .foregroundStyle(.blue) | |
| 823 | } | |
| 824 | } | |
| 825 | } | |
| 826 | .disabled(isLoading) | |
| 827 | } | |
| 828 | } | |
Hutch/Views/Tickets/TicketDetailViewModel.swift added +551
| @@ -0,0 +1,551 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct TicketDetailResponse: Decodable, Sendable { | |
| 6 | let user: UserTrackerTicketWrapper | |
| 7 | } | |
| 8 | ||
| 9 | private struct UserTrackerTicketWrapper: Decodable, Sendable { | |
| 10 | let tracker: TrackerTicketWrapper | |
| 11 | } | |
| 12 | ||
| 13 | private struct TrackerTicketWrapper: Decodable, Sendable { | |
| 14 | let ticket: TicketDetailPayload | |
| 15 | } | |
| 16 | ||
| 17 | private struct TicketDetailPayload: Decodable, Sendable { | |
| 18 | let id: Int | |
| 19 | let created: Date | |
| 20 | let updated: Date | |
| 21 | let title: String | |
| 22 | let description: String? | |
| 23 | let status: TicketStatus | |
| 24 | let resolution: TicketResolution? | |
| 25 | let authenticity: Authenticity | |
| 26 | let submitter: Entity | |
| 27 | let assignees: [Entity] | |
| 28 | let labels: [TicketLabel] | |
| 29 | let events: EventsPage | |
| 30 | } | |
| 31 | ||
| 32 | private struct EventsPage: Decodable, Sendable { | |
| 33 | let results: [TicketEvent] | |
| 34 | let cursor: String? | |
| 35 | } | |
| 36 | ||
| 37 | private struct SubmitCommentResponse: Decodable, Sendable { | |
| 38 | let submitComment: SubmittedEvent | |
| 39 | } | |
| 40 | ||
| 41 | private struct SubmittedEvent: Decodable, Sendable { | |
| 42 | let id: Int | |
| 43 | let created: Date | |
| 44 | let changes: [EventChange] | |
| 45 | } | |
| 46 | ||
| 47 | private struct MutationEventResponse: Decodable, Sendable { | |
| 48 | let id: Int | |
| 49 | } | |
| 50 | ||
| 51 | private struct UpdateStatusResponse: Decodable, Sendable { | |
| 52 | let updateTicketStatus: UpdatedStatusEvent | |
| 53 | } | |
| 54 | ||
| 55 | private struct UpdatedStatusEvent: Decodable, Sendable { | |
| 56 | let eventType: String | |
| 57 | } | |
| 58 | ||
| 59 | private struct AssignUserResponse: Decodable, Sendable { | |
| 60 | let assignUser: MutationEventResponse | |
| 61 | } | |
| 62 | ||
| 63 | private struct UnassignUserResponse: Decodable, Sendable { | |
| 64 | let unassignUser: MutationEventResponse | |
| 65 | } | |
| 66 | ||
| 67 | private struct LabelTicketResponse: Decodable, Sendable { | |
| 68 | let labelTicket: MutationEventResponse | |
| 69 | } | |
| 70 | ||
| 71 | private struct UnlabelTicketResponse: Decodable, Sendable { | |
| 72 | let unlabelTicket: MutationEventResponse | |
| 73 | } | |
| 74 | ||
| 75 | private struct UserLookupResponse: Decodable, Sendable { | |
| 76 | let user: UserIdPayload | |
| 77 | } | |
| 78 | ||
| 79 | private struct UserIdPayload: Decodable, Sendable { | |
| 80 | let id: Int | |
| 81 | } | |
| 82 | ||
| 83 | private struct CreateLabelResponse: Decodable, Sendable { | |
| 84 | let createLabel: TicketLabel | |
| 85 | } | |
| 86 | ||
| 87 | private struct TrackerLabelsResponse: Decodable, Sendable { | |
| 88 | let user: UserTrackerLabelsWrapper | |
| 89 | } | |
| 90 | ||
| 91 | private struct UserTrackerLabelsWrapper: Decodable, Sendable { | |
| 92 | let tracker: TrackerLabelsWrapper | |
| 93 | } | |
| 94 | ||
| 95 | private struct TrackerLabelsWrapper: Decodable, Sendable { | |
| 96 | let labels: LabelsPage | |
| 97 | } | |
| 98 | ||
| 99 | private struct LabelsPage: Decodable, Sendable { | |
| 100 | let results: [TicketLabel] | |
| 101 | } | |
| 102 | ||
| 103 | // MARK: - View Model | |
| 104 | ||
| 105 | @Observable | |
| 106 | @MainActor | |
| 107 | final class TicketDetailViewModel { | |
| 108 | ||
| 109 | let ownerUsername: String | |
| 110 | let trackerName: String | |
| 111 | let trackerId: Int | |
| 112 | let trackerRid: String | |
| 113 | let ticketId: Int | |
| 114 | ||
| 115 | private(set) var ticket: TicketDetail? | |
| 116 | private(set) var events: [TicketEvent] = [] | |
| 117 | private(set) var isLoading = false | |
| 118 | private(set) var isSubmitting = false | |
| 119 | private(set) var isPerformingAction = false | |
| 120 | private(set) var trackerLabels: [TicketLabel] = [] | |
| 121 | var commentText = "" | |
| 122 | var error: String? | |
| 123 | ||
| 124 | private let client: SRHTClient | |
| 125 | ||
| 126 | private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool { | |
| 127 | if lhs.created == rhs.created { | |
| 128 | return lhs.id < rhs.id | |
| 129 | } | |
| 130 | return lhs.created < rhs.created | |
| 131 | } | |
| 132 | ||
| 133 | init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) { | |
| 134 | self.ownerUsername = ownerUsername | |
| 135 | self.trackerName = trackerName | |
| 136 | self.trackerId = trackerId | |
| 137 | self.trackerRid = trackerRid | |
| 138 | self.ticketId = ticketId | |
| 139 | self.client = client | |
| 140 | } | |
| 141 | ||
| 142 | // MARK: - Queries | |
| 143 | ||
| 144 | private static let detailQuery = """ | |
| 145 | query ticket($owner: String!, $tracker: String!, $ticketId: Int!) { | |
| 146 | user(username: $owner) { | |
| 147 | tracker(name: $tracker) { | |
| 148 | ticket(id: $ticketId) { | |
| 149 | id | |
| 150 | created | |
| 151 | updated | |
| 152 | title: subject | |
| 153 | description: body | |
| 154 | status | |
| 155 | resolution | |
| 156 | authenticity | |
| 157 | submitter { canonicalName } | |
| 158 | assignees { canonicalName } | |
| 159 | labels { id name backgroundColor foregroundColor } | |
| 160 | events { | |
| 161 | results { | |
| 162 | id | |
| 163 | created | |
| 164 | changes { | |
| 165 | eventType | |
| 166 | ... on Comment { | |
| 167 | author { canonicalName } | |
| 168 | text | |
| 169 | authenticity | |
| 170 | } | |
| 171 | ... on StatusChange { | |
| 172 | oldStatus | |
| 173 | newStatus | |
| 174 | } | |
| 175 | ... on LabelUpdate { | |
| 176 | labeler { canonicalName } | |
| 177 | label { name } | |
| 178 | } | |
| 179 | ... on Assignment { | |
| 180 | assigner { canonicalName } | |
| 181 | assignee { canonicalName } | |
| 182 | } | |
| 183 | ... on TicketMention { | |
| 184 | mentioned { id } | |
| 185 | } | |
| 186 | ... on UserMention { | |
| 187 | mentioned { canonicalName } | |
| 188 | } | |
| 189 | ... on Created { | |
| 190 | author { canonicalName } | |
| 191 | } | |
| 192 | } | |
| 193 | } | |
| 194 | cursor | |
| 195 | } | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | } | |
| 200 | """ | |
| 201 | ||
| 202 | private static let submitCommentMutation = """ | |
| 203 | mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) { | |
| 204 | submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) { | |
| 205 | id | |
| 206 | created | |
| 207 | changes { | |
| 208 | eventType | |
| 209 | ... on Comment { | |
| 210 | author { canonicalName } | |
| 211 | text | |
| 212 | authenticity | |
| 213 | } | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
| 217 | """ | |
| 218 | ||
| 219 | private static let updateStatusMutation = """ | |
| 220 | mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) { | |
| 221 | updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) { | |
| 222 | eventType: __typename | |
| 223 | } | |
| 224 | } | |
| 225 | """ | |
| 226 | ||
| 227 | private static let assignUserMutation = """ | |
| 228 | mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { | |
| 229 | assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } | |
| 230 | } | |
| 231 | """ | |
| 232 | ||
| 233 | private static let unassignUserMutation = """ | |
| 234 | mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) { | |
| 235 | unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id } | |
| 236 | } | |
| 237 | """ | |
| 238 | ||
| 239 | private static let labelTicketMutation = """ | |
| 240 | mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { | |
| 241 | labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } | |
| 242 | } | |
| 243 | """ | |
| 244 | ||
| 245 | private static let unlabelTicketMutation = """ | |
| 246 | mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) { | |
| 247 | unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id } | |
| 248 | } | |
| 249 | """ | |
| 250 | ||
| 251 | private static let userLookupQuery = """ | |
| 252 | query userLookup($username: String!) { | |
| 253 | user(username: $username) { id } | |
| 254 | } | |
| 255 | """ | |
| 256 | ||
| 257 | private static let trackerLabelsQuery = """ | |
| 258 | query trackerLabels($owner: String!, $tracker: String!) { | |
| 259 | user(username: $owner) { | |
| 260 | tracker(name: $tracker) { | |
| 261 | labels { | |
| 262 | results { id name backgroundColor foregroundColor } | |
| 263 | } | |
| 264 | } | |
| 265 | } | |
| 266 | } | |
| 267 | """ | |
| 268 | ||
| 269 | private static let createLabelMutation = """ | |
| 270 | mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) { | |
| 271 | createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) { | |
| 272 | id | |
| 273 | name | |
| 274 | backgroundColor | |
| 275 | foregroundColor | |
| 276 | } | |
| 277 | } | |
| 278 | """ | |
| 279 | ||
| 280 | // MARK: - Public API | |
| 281 | ||
| 282 | func loadTicket() async { | |
| 283 | guard !isLoading else { return } | |
| 284 | isLoading = true | |
| 285 | error = nil | |
| 286 | ||
| 287 | do { | |
| 288 | let result = try await client.execute( | |
| 289 | service: .todo, | |
| 290 | query: Self.detailQuery, | |
| 291 | variables: [ | |
| 292 | "owner": ownerUsername, | |
| 293 | "tracker": trackerName, | |
| 294 | "ticketId": ticketId | |
| 295 | ], | |
| 296 | responseType: TicketDetailResponse.self | |
| 297 | ) | |
| 298 | let payload = result.user.tracker.ticket | |
| 299 | ticket = TicketDetail( | |
| 300 | id: payload.id, | |
| 301 | created: payload.created, | |
| 302 | updated: payload.updated, | |
| 303 | title: payload.title, | |
| 304 | description: payload.description, | |
| 305 | status: payload.status, | |
| 306 | resolution: payload.resolution, | |
| 307 | authenticity: payload.authenticity, | |
| 308 | submitter: payload.submitter, | |
| 309 | assignees: payload.assignees, | |
| 310 | labels: payload.labels | |
| 311 | ) | |
| 312 | events = payload.events.results.sorted(by: Self.timelineOrder) | |
| 313 | } catch { | |
| 314 | self.error = error.localizedDescription | |
| 315 | } | |
| 316 | ||
| 317 | isLoading = false | |
| 318 | } | |
| 319 | ||
| 320 | func submitComment() async { | |
| 321 | let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 322 | guard !text.isEmpty, !isSubmitting else { return } | |
| 323 | isSubmitting = true | |
| 324 | error = nil | |
| 325 | ||
| 326 | do { | |
| 327 | let input: [String: any Sendable] = ["text": text] | |
| 328 | let result = try await client.execute( | |
| 329 | service: .todo, | |
| 330 | query: Self.submitCommentMutation, | |
| 331 | variables: [ | |
| 332 | "trackerId": trackerId, | |
| 333 | "ticketId": ticketId, | |
| 334 | "input": input | |
| 335 | ], | |
| 336 | responseType: SubmitCommentResponse.self | |
| 337 | ) | |
| 338 | // Append the returned event so the comment shows immediately. | |
| 339 | let submitted = result.submitComment | |
| 340 | let event = TicketEvent( | |
| 341 | id: submitted.id, | |
| 342 | created: submitted.created, | |
| 343 | changes: submitted.changes | |
| 344 | ) | |
| 345 | events.append(event) | |
| 346 | events.sort(by: Self.timelineOrder) | |
| 347 | commentText = "" | |
| 348 | } catch { | |
| 349 | self.error = error.localizedDescription | |
| 350 | } | |
| 351 | ||
| 352 | isSubmitting = false | |
| 353 | } | |
| 354 | ||
| 355 | func updateComment(commentId: Int, text: String) async { | |
| 356 | _ = commentId | |
| 357 | _ = text | |
| 358 | error = "Comment editing is not available in todo.sr.ht's public GraphQL API." | |
| 359 | } | |
| 360 | ||
| 361 | // MARK: - Ticket Actions | |
| 362 | ||
| 363 | func updateStatus(status: TicketStatus, resolution: TicketResolution) async { | |
| 364 | guard !isPerformingAction else { return } | |
| 365 | isPerformingAction = true | |
| 366 | error = nil | |
| 367 | ||
| 368 | do { | |
| 369 | let input: [String: any Sendable] = [ | |
| 370 | "status": status.rawValue, | |
| 371 | "resolution": resolution.rawValue | |
| 372 | ] | |
| 373 | _ = try await client.execute( | |
| 374 | service: .todo, | |
| 375 | query: Self.updateStatusMutation, | |
| 376 | variables: [ | |
| 377 | "trackerId": trackerId, | |
| 378 | "ticketId": ticketId, | |
| 379 | "input": input | |
| 380 | ], | |
| 381 | responseType: UpdateStatusResponse.self | |
| 382 | ) | |
| 383 | // Re-fetch the ticket to get updated status/resolution | |
| 384 | await loadTicket() | |
| 385 | } catch { | |
| 386 | self.error = error.localizedDescription | |
| 387 | } | |
| 388 | ||
| 389 | isPerformingAction = false | |
| 390 | } | |
| 391 | ||
| 392 | func assignUser(username: String) async { | |
| 393 | guard !isPerformingAction else { return } | |
| 394 | isPerformingAction = true | |
| 395 | error = nil | |
| 396 | ||
| 397 | do { | |
| 398 | // Resolve username to user ID | |
| 399 | let userResult = try await client.execute( | |
| 400 | service: .todo, | |
| 401 | query: Self.userLookupQuery, | |
| 402 | variables: ["username": username], | |
| 403 | responseType: UserLookupResponse.self | |
| 404 | ) | |
| 405 | let userId = userResult.user.id | |
| 406 | ||
| 407 | _ = try await client.execute( | |
| 408 | service: .todo, | |
| 409 | query: Self.assignUserMutation, | |
| 410 | variables: [ | |
| 411 | "trackerId": trackerId, | |
| 412 | "ticketId": ticketId, | |
| 413 | "userId": userId | |
| 414 | ], | |
| 415 | responseType: AssignUserResponse.self | |
| 416 | ) | |
| 417 | // Reload to reflect the change | |
| 418 | await loadTicket() | |
| 419 | } catch { | |
| 420 | self.error = error.localizedDescription | |
| 421 | } | |
| 422 | ||
| 423 | isPerformingAction = false | |
| 424 | } | |
| 425 | ||
| 426 | func unassignUser(username: String) async { | |
| 427 | guard !isPerformingAction else { return } | |
| 428 | isPerformingAction = true | |
| 429 | error = nil | |
| 430 | ||
| 431 | do { | |
| 432 | // Resolve username to user ID | |
| 433 | let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username | |
| 434 | let userResult = try await client.execute( | |
| 435 | service: .todo, | |
| 436 | query: Self.userLookupQuery, | |
| 437 | variables: ["username": stripped], | |
| 438 | responseType: UserLookupResponse.self | |
| 439 | ) | |
| 440 | let userId = userResult.user.id | |
| 441 | ||
| 442 | _ = try await client.execute( | |
| 443 | service: .todo, | |
| 444 | query: Self.unassignUserMutation, | |
| 445 | variables: [ | |
| 446 | "trackerId": trackerId, | |
| 447 | "ticketId": ticketId, | |
| 448 | "userId": userId | |
| 449 | ], | |
| 450 | responseType: UnassignUserResponse.self | |
| 451 | ) | |
| 452 | // Reload to reflect the change | |
| 453 | await loadTicket() | |
| 454 | } catch { | |
| 455 | self.error = error.localizedDescription | |
| 456 | } | |
| 457 | ||
| 458 | isPerformingAction = false | |
| 459 | } | |
| 460 | ||
| 461 | func labelTicket(labelId: Int) async { | |
| 462 | guard !isPerformingAction else { return } | |
| 463 | isPerformingAction = true | |
| 464 | error = nil | |
| 465 | ||
| 466 | do { | |
| 467 | _ = try await client.execute( | |
| 468 | service: .todo, | |
| 469 | query: Self.labelTicketMutation, | |
| 470 | variables: [ | |
| 471 | "trackerId": trackerId, | |
| 472 | "ticketId": ticketId, | |
| 473 | "labelId": labelId | |
| 474 | ], | |
| 475 | responseType: LabelTicketResponse.self | |
| 476 | ) | |
| 477 | await loadTicket() | |
| 478 | } catch { | |
| 479 | self.error = error.localizedDescription | |
| 480 | } | |
| 481 | ||
| 482 | isPerformingAction = false | |
| 483 | } | |
| 484 | ||
| 485 | func unlabelTicket(labelId: Int) async { | |
| 486 | guard !isPerformingAction else { return } | |
| 487 | isPerformingAction = true | |
| 488 | error = nil | |
| 489 | ||
| 490 | do { | |
| 491 | _ = try await client.execute( | |
| 492 | service: .todo, | |
| 493 | query: Self.unlabelTicketMutation, | |
| 494 | variables: [ | |
| 495 | "trackerId": trackerId, | |
| 496 | "ticketId": ticketId, | |
| 497 | "labelId": labelId | |
| 498 | ], | |
| 499 | responseType: UnlabelTicketResponse.self | |
| 500 | ) | |
| 501 | await loadTicket() | |
| 502 | } catch { | |
| 503 | self.error = error.localizedDescription | |
| 504 | } | |
| 505 | ||
| 506 | isPerformingAction = false | |
| 507 | } | |
| 508 | ||
| 509 | func loadTrackerLabels() async { | |
| 510 | do { | |
| 511 | let result = try await client.execute( | |
| 512 | service: .todo, | |
| 513 | query: Self.trackerLabelsQuery, | |
| 514 | variables: [ | |
| 515 | "owner": ownerUsername, | |
| 516 | "tracker": trackerName | |
| 517 | ], | |
| 518 | responseType: TrackerLabelsResponse.self | |
| 519 | ) | |
| 520 | trackerLabels = result.user.tracker.labels.results | |
| 521 | } catch { | |
| 522 | self.error = error.localizedDescription | |
| 523 | } | |
| 524 | } | |
| 525 | ||
| 526 | func createLabel(name: String, backgroundColor: String, foregroundColor: String) async { | |
| 527 | guard !isPerformingAction else { return } | |
| 528 | isPerformingAction = true | |
| 529 | error = nil | |
| 530 | ||
| 531 | do { | |
| 532 | let result = try await client.execute( | |
| 533 | service: .todo, | |
| 534 | query: Self.createLabelMutation, | |
| 535 | variables: [ | |
| 536 | "trackerId": trackerId, | |
| 537 | "name": name, | |
| 538 | "backgroundColor": backgroundColor, | |
| 539 | "foregroundColor": foregroundColor | |
| 540 | ], | |
| 541 | responseType: CreateLabelResponse.self | |
| 542 | ) | |
| 543 | trackerLabels.append(result.createLabel) | |
| 544 | } catch { | |
| 545 | self.error = error.localizedDescription | |
| 546 | } | |
| 547 | ||
| 548 | isPerformingAction = false | |
| 549 | } | |
| 550 | ||
| 551 | } | |
Hutch/Views/Tickets/TicketListView.swift added +389
| @@ -0,0 +1,389 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct TicketListView: View { | |
| 4 | let ownerUsername: String | |
| 5 | let trackerName: String | |
| 6 | let trackerId: Int | |
| 7 | let trackerRid: String | |
| 8 | ||
| 9 | @Environment(AppState.self) private var appState | |
| 10 | @State private var viewModel: TicketListViewModel? | |
| 11 | @State private var showCreateTicketSheet = false | |
| 12 | @State private var createdTicket: TicketSummary? | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | Group { | |
| 16 | if let viewModel { | |
| 17 | listContent(viewModel) | |
| 18 | } else { | |
| 19 | SRHTLoadingStateView(message: "Loading tickets…") | |
| 20 | } | |
| 21 | } | |
| 22 | .navigationTitle(trackerName) | |
| 23 | .navigationBarTitleDisplayMode(.inline) | |
| 24 | .toolbar { | |
| 25 | ToolbarItemGroup(placement: .topBarTrailing) { | |
| 26 | SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) { | |
| 27 | Image(systemName: "square.and.arrow.up") | |
| 28 | } | |
| 29 | ||
| 30 | if viewModel != nil { | |
| 31 | Button { | |
| 32 | showCreateTicketSheet = true | |
| 33 | } label: { | |
| 34 | Image(systemName: "plus") | |
| 35 | } | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | .sheet(isPresented: $showCreateTicketSheet) { | |
| 40 | if let viewModel { | |
| 41 | CreateTicketSheet(viewModel: viewModel) { ticket in | |
| 42 | showCreateTicketSheet = false | |
| 43 | createdTicket = ticket | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | .navigationDestination(isPresented: Binding( | |
| 48 | get: { createdTicket != nil }, | |
| 49 | set: { isPresented in | |
| 50 | if !isPresented { | |
| 51 | createdTicket = nil | |
| 52 | } | |
| 53 | } | |
| 54 | )) { | |
| 55 | if let createdTicket { | |
| 56 | TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id) | |
| 57 | } | |
| 58 | } | |
| 59 | .task { | |
| 60 | if viewModel == nil { | |
| 61 | let vm = TicketListViewModel( | |
| 62 | ownerUsername: ownerUsername, | |
| 63 | trackerName: trackerName, | |
| 64 | trackerId: trackerId, | |
| 65 | client: appState.client | |
| 66 | ) | |
| 67 | viewModel = vm | |
| 68 | await vm.loadTickets() | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | @ViewBuilder | |
| 74 | private func listContent(_ viewModel: TicketListViewModel) -> some View { | |
| 75 | @Bindable var vm = viewModel | |
| 76 | ||
| 77 | List { | |
| 78 | // Filter picker | |
| 79 | Section { | |
| 80 | Picker("Filter", selection: $vm.filter) { | |
| 81 | ForEach(TicketFilter.allCases, id: \.self) { filter in | |
| 82 | Text(filter.rawValue).tag(filter) | |
| 83 | } | |
| 84 | } | |
| 85 | .pickerStyle(.segmented) | |
| 86 | .listRowBackground(Color.clear) | |
| 87 | .listRowInsets(EdgeInsets()) | |
| 88 | } | |
| 89 | ||
| 90 | // Tickets | |
| 91 | ForEach(viewModel.filteredTickets) { ticket in | |
| 92 | NavigationLink(value: ticket) { | |
| 93 | TicketRowView(ticket: ticket) | |
| 94 | } | |
| 95 | .task { | |
| 96 | await viewModel.loadMoreIfNeeded(currentItem: ticket) | |
| 97 | } | |
| 98 | } | |
| 99 | ||
| 100 | if viewModel.isLoadingMore { | |
| 101 | HStack { | |
| 102 | Spacer() | |
| 103 | ProgressView() | |
| 104 | Spacer() | |
| 105 | } | |
| 106 | .listRowSeparator(.hidden) | |
| 107 | } | |
| 108 | } | |
| 109 | .listStyle(.plain) | |
| 110 | .overlay { | |
| 111 | if viewModel.isLoading, viewModel.tickets.isEmpty { | |
| 112 | SRHTLoadingStateView(message: "Loading tickets…") | |
| 113 | } else if let error = viewModel.error, viewModel.tickets.isEmpty { | |
| 114 | SRHTErrorStateView( | |
| 115 | title: "Couldn't Load Tickets", | |
| 116 | message: error, | |
| 117 | retryAction: { await viewModel.loadTickets() } | |
| 118 | ) | |
| 119 | } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil { | |
| 120 | ContentUnavailableView( | |
| 121 | "No Tickets", | |
| 122 | systemImage: "ticket", | |
| 123 | description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.") | |
| 124 | ) | |
| 125 | } | |
| 126 | } | |
| 127 | .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) { | |
| 128 | await viewModel.loadTickets() | |
| 129 | } | |
| 130 | .srhtErrorBanner(error: $vm.error) | |
| 131 | .refreshable { | |
| 132 | await viewModel.loadTickets() | |
| 133 | } | |
| 134 | .navigationDestination(for: TicketSummary.self) { ticket in | |
| 135 | TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id) | |
| 136 | } | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | private struct CreateTicketSheet: View { | |
| 141 | let viewModel: TicketListViewModel | |
| 142 | let onCreated: (TicketSummary) -> Void | |
| 143 | ||
| 144 | @Environment(\.dismiss) private var dismiss | |
| 145 | @State private var subject = "" | |
| 146 | @State private var descriptionText = "" | |
| 147 | ||
| 148 | var body: some View { | |
| 149 | NavigationStack { | |
| 150 | Form { | |
| 151 | Section("Ticket Details") { | |
| 152 | TextField("Title", text: $subject) | |
| 153 | TextField("Description (optional)", text: $descriptionText, axis: .vertical) | |
| 154 | .lineLimit(6...12) | |
| 155 | } | |
| 156 | } | |
| 157 | .navigationTitle("New Ticket") | |
| 158 | .navigationBarTitleDisplayMode(.inline) | |
| 159 | .toolbar { | |
| 160 | ToolbarItem(placement: .cancellationAction) { | |
| 161 | Button("Cancel") { dismiss() } | |
| 162 | } | |
| 163 | ToolbarItem(placement: .confirmationAction) { | |
| 164 | Button { | |
| 165 | Task { | |
| 166 | if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) { | |
| 167 | onCreated(ticket) | |
| 168 | } | |
| 169 | } | |
| 170 | } label: { | |
| 171 | if viewModel.isCreatingTicket { | |
| 172 | ProgressView() | |
| 173 | .controlSize(.small) | |
| 174 | } else { | |
| 175 | Text("Create Ticket") | |
| 176 | } | |
| 177 | } | |
| 178 | .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket) | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | } | |
| 184 | ||
| 185 | // MARK: - Ticket Row | |
| 186 | ||
| 187 | private struct TicketRowView: View { | |
| 188 | let ticket: TicketSummary | |
| 189 | ||
| 190 | var body: some View { | |
| 191 | VStack(alignment: .leading, spacing: 6) { | |
| 192 | HStack(alignment: .top) { | |
| 193 | TicketStatusIcon(status: ticket.status) | |
| 194 | .frame(width: 20) | |
| 195 | ||
| 196 | VStack(alignment: .leading, spacing: 2) { | |
| 197 | Text("#\(ticket.id)") | |
| 198 | .font(.caption.monospaced()) | |
| 199 | .foregroundStyle(.secondary) | |
| 200 | + Text(" ") | |
| 201 | + Text(ticket.title) | |
| 202 | .font(.subheadline) | |
| 203 | } | |
| 204 | ||
| 205 | Spacer() | |
| 206 | } | |
| 207 | ||
| 208 | HStack(spacing: 8) { | |
| 209 | Text(ticket.submitter.canonicalName) | |
| 210 | .font(.caption) | |
| 211 | .foregroundStyle(.secondary) | |
| 212 | ||
| 213 | Spacer() | |
| 214 | ||
| 215 | Text(ticket.created.relativeDescription) | |
| 216 | .font(.caption) | |
| 217 | .foregroundStyle(.tertiary) | |
| 218 | } | |
| 219 | ||
| 220 | if !ticket.labels.isEmpty { | |
| 221 | FlowLayout(spacing: 4) { | |
| 222 | ForEach(ticket.labels) { label in | |
| 223 | LabelPill(label: label) | |
| 224 | } | |
| 225 | } | |
| 226 | } | |
| 227 | ||
| 228 | if !ticket.assignees.isEmpty { | |
| 229 | HStack(spacing: 4) { | |
| 230 | Image(systemName: "person.fill") | |
| 231 | .font(.caption2) | |
| 232 | .foregroundStyle(.secondary) | |
| 233 | Text(ticket.assignees.map(\.canonicalName).joined(separator: ", ")) | |
| 234 | .font(.caption) | |
| 235 | .foregroundStyle(.secondary) | |
| 236 | .lineLimit(1) | |
| 237 | } | |
| 238 | } | |
| 239 | } | |
| 240 | .padding(.vertical, 2) | |
| 241 | } | |
| 242 | } | |
| 243 | ||
| 244 | // MARK: - Ticket Status Icon | |
| 245 | ||
| 246 | struct TicketStatusIcon: View { | |
| 247 | let status: TicketStatus | |
| 248 | ||
| 249 | var body: some View { | |
| 250 | Image(systemName: iconName) | |
| 251 | .foregroundStyle(color) | |
| 252 | } | |
| 253 | ||
| 254 | private var iconName: String { | |
| 255 | switch status { | |
| 256 | case .reported: "circle" | |
| 257 | case .confirmed: "circle.inset.filled" | |
| 258 | case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90" | |
| 259 | case .pending: "clock.fill" | |
| 260 | case .resolved: "checkmark.circle.fill" | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | private var color: Color { | |
| 265 | switch status { | |
| 266 | case .reported: .gray | |
| 267 | case .confirmed: .blue | |
| 268 | case .inProgress: .yellow | |
| 269 | case .pending: .orange | |
| 270 | case .resolved: .green | |
| 271 | } | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | // MARK: - Label Pill | |
| 276 | ||
| 277 | struct LabelPill: View { | |
| 278 | let label: TicketLabel | |
| 279 | ||
| 280 | var body: some View { | |
| 281 | Text(label.name) | |
| 282 | .font(.caption2.weight(.medium)) | |
| 283 | .padding(.horizontal, 6) | |
| 284 | .padding(.vertical, 2) | |
| 285 | .background(backgroundColor) | |
| 286 | .foregroundStyle(foregroundColor) | |
| 287 | .clipShape(Capsule()) | |
| 288 | } | |
| 289 | ||
| 290 | private var backgroundColor: Color { | |
| 291 | Color(hex: label.backgroundColor) ?? .gray.opacity(0.2) | |
| 292 | } | |
| 293 | ||
| 294 | private var foregroundColor: Color { | |
| 295 | Color(hex: label.foregroundColor) ?? .primary | |
| 296 | } | |
| 297 | } | |
| 298 | ||
| 299 | // MARK: - Color from hex string | |
| 300 | ||
| 301 | extension Color { | |
| 302 | init?(hex: String) { | |
| 303 | var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 304 | if hexString.hasPrefix("#") { | |
| 305 | hexString.removeFirst() | |
| 306 | } | |
| 307 | ||
| 308 | guard hexString.count == 6, | |
| 309 | let hexNumber = UInt64(hexString, radix: 16) else { | |
| 310 | return nil | |
| 311 | } | |
| 312 | ||
| 313 | let r = Double((hexNumber & 0xFF0000) >> 16) / 255 | |
| 314 | let g = Double((hexNumber & 0x00FF00) >> 8) / 255 | |
| 315 | let b = Double(hexNumber & 0x0000FF) / 255 | |
| 316 | ||
| 317 | self.init(red: r, green: g, blue: b) | |
| 318 | } | |
| 319 | ||
| 320 | /// Returns a `#rrggbb` hex string for this color. | |
| 321 | var hexString: String { | |
| 322 | let resolved = resolve(in: .init()) | |
| 323 | let r = Int(max(0, min(1, resolved.red)) * 255) | |
| 324 | let g = Int(max(0, min(1, resolved.green)) * 255) | |
| 325 | let b = Int(max(0, min(1, resolved.blue)) * 255) | |
| 326 | return String(format: "#%02x%02x%02x", r, g, b) | |
| 327 | } | |
| 328 | } | |
| 329 | ||
| 330 | // MARK: - Flow Layout (for label pills) | |
| 331 | ||
| 332 | struct FlowLayout: Layout { | |
| 333 | var spacing: CGFloat = 4 | |
| 334 | ||
| 335 | func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { | |
| 336 | let result = layoutSubviews(proposal: proposal, subviews: subviews) | |
| 337 | return result.size | |
| 338 | } | |
| 339 | ||
| 340 | func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { | |
| 341 | let result = layoutSubviews(proposal: proposal, subviews: subviews) | |
| 342 | for (index, position) in result.positions.enumerated() { | |
| 343 | subviews[index].place( | |
| 344 | at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), | |
| 345 | proposal: ProposedViewSize(result.sizes[index]) | |
| 346 | ) | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | private struct LayoutResult { | |
| 351 | var positions: [CGPoint] | |
| 352 | var sizes: [CGSize] | |
| 353 | var size: CGSize | |
| 354 | } | |
| 355 | ||
| 356 | private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult { | |
| 357 | let maxWidth = proposal.width ?? .infinity | |
| 358 | var positions: [CGPoint] = [] | |
| 359 | var sizes: [CGSize] = [] | |
| 360 | var currentX: CGFloat = 0 | |
| 361 | var currentY: CGFloat = 0 | |
| 362 | var lineHeight: CGFloat = 0 | |
| 363 | var totalHeight: CGFloat = 0 | |
| 364 | var totalWidth: CGFloat = 0 | |
| 365 | ||
| 366 | for subview in subviews { | |
| 367 | let size = subview.sizeThatFits(.unspecified) | |
| 368 | sizes.append(size) | |
| 369 | ||
| 370 | if currentX + size.width > maxWidth, currentX > 0 { | |
| 371 | currentX = 0 | |
| 372 | currentY += lineHeight + spacing | |
| 373 | lineHeight = 0 | |
| 374 | } | |
| 375 | ||
| 376 | positions.append(CGPoint(x: currentX, y: currentY)) | |
| 377 | lineHeight = max(lineHeight, size.height) | |
| 378 | currentX += size.width + spacing | |
| 379 | totalWidth = max(totalWidth, currentX - spacing) | |
| 380 | totalHeight = currentY + lineHeight | |
| 381 | } | |
| 382 | ||
| 383 | return LayoutResult( | |
| 384 | positions: positions, | |
| 385 | sizes: sizes, | |
| 386 | size: CGSize(width: totalWidth, height: totalHeight) | |
| 387 | ) | |
| 388 | } | |
| 389 | } | |
Hutch/Views/Tickets/TicketListViewModel.swift added +215
| @@ -0,0 +1,215 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct TrackerTicketsResponse: Decodable, Sendable { | |
| 6 | let user: UserTrackerWrapper | |
| 7 | } | |
| 8 | ||
| 9 | private struct UserTrackerWrapper: Decodable, Sendable { | |
| 10 | let tracker: TrackerTicketsWrapper | |
| 11 | } | |
| 12 | ||
| 13 | private struct TrackerTicketsWrapper: Decodable, Sendable { | |
| 14 | let tickets: TicketsPage | |
| 15 | } | |
| 16 | ||
| 17 | private struct TicketsPage: Decodable, Sendable { | |
| 18 | let results: [TicketSummary] | |
| 19 | let cursor: String? | |
| 20 | } | |
| 21 | ||
| 22 | // MARK: - Filter | |
| 23 | ||
| 24 | enum TicketFilter: String, CaseIterable, Sendable { | |
| 25 | case open = "Open" | |
| 26 | case resolved = "Resolved" | |
| 27 | case all = "All" | |
| 28 | } | |
| 29 | ||
| 30 | // MARK: - View Model | |
| 31 | ||
| 32 | @Observable | |
| 33 | @MainActor | |
| 34 | final class TicketListViewModel { | |
| 35 | let ownerUsername: String | |
| 36 | let trackerName: String | |
| 37 | let trackerId: Int | |
| 38 | ||
| 39 | private(set) var tickets: [TicketSummary] = [] | |
| 40 | private(set) var isLoading = false | |
| 41 | private(set) var isLoadingMore = false | |
| 42 | private(set) var isCreatingTicket = false | |
| 43 | var error: String? | |
| 44 | var filter: TicketFilter = .open | |
| 45 | ||
| 46 | private var cursor: String? | |
| 47 | private var hasMore = true | |
| 48 | private let client: SRHTClient | |
| 49 | ||
| 50 | init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) { | |
| 51 | self.ownerUsername = ownerUsername | |
| 52 | self.trackerName = trackerName | |
| 53 | self.trackerId = trackerId | |
| 54 | self.client = client | |
| 55 | } | |
| 56 | ||
| 57 | // MARK: - Query | |
| 58 | ||
| 59 | private static let query = """ | |
| 60 | query tickets($owner: String!, $tracker: String!, $cursor: Cursor) { | |
| 61 | user(username: $owner) { | |
| 62 | tracker(name: $tracker) { | |
| 63 | tickets(cursor: $cursor) { | |
| 64 | results { | |
| 65 | id | |
| 66 | title: subject | |
| 67 | status | |
| 68 | resolution | |
| 69 | created | |
| 70 | submitter { canonicalName } | |
| 71 | labels { id name backgroundColor foregroundColor } | |
| 72 | assignees { canonicalName } | |
| 73 | } | |
| 74 | cursor | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | } | |
| 79 | """ | |
| 80 | ||
| 81 | private static let submitTicketMutation = """ | |
| 82 | mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) { | |
| 83 | submitTicket(trackerId: $trackerId, input: $input) { | |
| 84 | id | |
| 85 | title: subject | |
| 86 | status | |
| 87 | resolution | |
| 88 | created | |
| 89 | submitter { canonicalName } | |
| 90 | labels { id name backgroundColor foregroundColor } | |
| 91 | assignees { canonicalName } | |
| 92 | } | |
| 93 | } | |
| 94 | """ | |
| 95 | ||
| 96 | // MARK: - Computed | |
| 97 | ||
| 98 | /// Tickets filtered by the selected status filter. | |
| 99 | var filteredTickets: [TicketSummary] { | |
| 100 | switch filter { | |
| 101 | case .open: | |
| 102 | tickets.filter { $0.status.isOpen } | |
| 103 | case .resolved: | |
| 104 | tickets.filter { !$0.status.isOpen } | |
| 105 | case .all: | |
| 106 | tickets | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | // MARK: - Public API | |
| 111 | ||
| 112 | func loadTickets() async { | |
| 113 | isLoading = true | |
| 114 | error = nil | |
| 115 | cursor = nil | |
| 116 | hasMore = true | |
| 117 | ||
| 118 | do { | |
| 119 | let page = try await fetchPage(cursor: nil) | |
| 120 | tickets = page.results | |
| 121 | cursor = page.cursor | |
| 122 | hasMore = page.cursor != nil | |
| 123 | } catch { | |
| 124 | self.error = error.localizedDescription | |
| 125 | } | |
| 126 | ||
| 127 | isLoading = false | |
| 128 | } | |
| 129 | ||
| 130 | func loadMoreIfNeeded(currentItem: TicketSummary) async { | |
| 131 | guard let last = tickets.last, | |
| 132 | last.id == currentItem.id, | |
| 133 | hasMore, | |
| 134 | !isLoadingMore else { | |
| 135 | return | |
| 136 | } | |
| 137 | ||
| 138 | isLoadingMore = true | |
| 139 | ||
| 140 | do { | |
| 141 | let page = try await fetchPage(cursor: cursor) | |
| 142 | tickets.append(contentsOf: page.results) | |
| 143 | cursor = page.cursor | |
| 144 | hasMore = page.cursor != nil | |
| 145 | } catch { | |
| 146 | self.error = error.localizedDescription | |
| 147 | } | |
| 148 | ||
| 149 | isLoadingMore = false | |
| 150 | } | |
| 151 | ||
| 152 | func createTicket(subject: String, body: String) async -> TicketSummary? { | |
| 153 | guard !isCreatingTicket else { return nil } | |
| 154 | ||
| 155 | let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 156 | guard !trimmedSubject.isEmpty else { | |
| 157 | error = "Enter a ticket title." | |
| 158 | return nil | |
| 159 | } | |
| 160 | ||
| 161 | isCreatingTicket = true | |
| 162 | error = nil | |
| 163 | defer { isCreatingTicket = false } | |
| 164 | ||
| 165 | var input: [String: any Sendable] = [ | |
| 166 | "subject": trimmedSubject | |
| 167 | ] | |
| 168 | let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 169 | if !trimmedBody.isEmpty { | |
| 170 | input["body"] = trimmedBody | |
| 171 | } | |
| 172 | let variables: [String: any Sendable] = [ | |
| 173 | "trackerId": trackerId, | |
| 174 | "input": input | |
| 175 | ] | |
| 176 | ||
| 177 | do { | |
| 178 | let result = try await client.execute( | |
| 179 | service: .todo, | |
| 180 | query: Self.submitTicketMutation, | |
| 181 | variables: variables, | |
| 182 | responseType: SubmitTicketResponse.self | |
| 183 | ) | |
| 184 | let ticket = result.submitTicket | |
| 185 | tickets.insert(ticket, at: 0) | |
| 186 | return ticket | |
| 187 | } catch { | |
| 188 | self.error = "Couldn’t create the ticket. \(error.localizedDescription)" | |
| 189 | return nil | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | // MARK: - Private | |
| 194 | ||
| 195 | private func fetchPage(cursor: String?) async throws -> TicketsPage { | |
| 196 | var variables: [String: any Sendable] = [ | |
| 197 | "owner": ownerUsername, | |
| 198 | "tracker": trackerName | |
| 199 | ] | |
| 200 | if let cursor { | |
| 201 | variables["cursor"] = cursor | |
| 202 | } | |
| 203 | let result = try await client.execute( | |
| 204 | service: .todo, | |
| 205 | query: Self.query, | |
| 206 | variables: variables, | |
| 207 | responseType: TrackerTicketsResponse.self | |
| 208 | ) | |
| 209 | return result.user.tracker.tickets | |
| 210 | } | |
| 211 | ||
| 212 | private struct SubmitTicketResponse: Decodable, Sendable { | |
| 213 | let submitTicket: TicketSummary | |
| 214 | } | |
| 215 | } | |
Hutch/Views/Tickets/TrackerListView.swift added +214
| @@ -0,0 +1,214 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct TrackerListView: View { | |
| 4 | @Environment(AppState.self) private var appState | |
| 5 | @State private var viewModel: TrackerListViewModel? | |
| 6 | @State private var showCreateTrackerSheet = false | |
| 7 | @State private var createdTracker: TrackerSummary? | |
| 8 | ||
| 9 | var body: some View { | |
| 10 | Group { | |
| 11 | if let viewModel { | |
| 12 | listContent(viewModel) | |
| 13 | } else { | |
| 14 | SRHTLoadingStateView(message: "Loading trackers…") | |
| 15 | } | |
| 16 | } | |
| 17 | .navigationTitle("Trackers") | |
| 18 | .toolbar { | |
| 19 | if viewModel != nil { | |
| 20 | ToolbarItem(placement: .topBarTrailing) { | |
| 21 | Button { | |
| 22 | showCreateTrackerSheet = true | |
| 23 | } label: { | |
| 24 | Image(systemName: "plus") | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | } | |
| 29 | .sheet(isPresented: $showCreateTrackerSheet) { | |
| 30 | if let viewModel { | |
| 31 | CreateTrackerSheet(viewModel: viewModel) { tracker in | |
| 32 | showCreateTrackerSheet = false | |
| 33 | createdTracker = tracker | |
| 34 | } | |
| 35 | } | |
| 36 | } | |
| 37 | .navigationDestination(isPresented: Binding( | |
| 38 | get: { createdTracker != nil }, | |
| 39 | set: { isPresented in | |
| 40 | if !isPresented { | |
| 41 | createdTracker = nil | |
| 42 | } | |
| 43 | } | |
| 44 | )) { | |
| 45 | if let createdTracker { | |
| 46 | TicketListView( | |
| 47 | ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()), | |
| 48 | trackerName: createdTracker.name, | |
| 49 | trackerId: createdTracker.id, | |
| 50 | trackerRid: createdTracker.rid | |
| 51 | ) | |
| 52 | } | |
| 53 | } | |
| 54 | .task { | |
| 55 | if viewModel == nil { | |
| 56 | let vm = TrackerListViewModel(client: appState.client) | |
| 57 | viewModel = vm | |
| 58 | await vm.loadTrackers() | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | @ViewBuilder | |
| 64 | private func listContent(_ viewModel: TrackerListViewModel) -> some View { | |
| 65 | @Bindable var vm = viewModel | |
| 66 | ||
| 67 | List { | |
| 68 | ForEach(viewModel.trackers) { tracker in | |
| 69 | NavigationLink(value: tracker) { | |
| 70 | TrackerRowView(tracker: tracker) | |
| 71 | } | |
| 72 | .task { | |
| 73 | await viewModel.loadMoreIfNeeded(currentItem: tracker) | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | if viewModel.isLoadingMore { | |
| 78 | HStack { | |
| 79 | Spacer() | |
| 80 | ProgressView() | |
| 81 | Spacer() | |
| 82 | } | |
| 83 | .listRowSeparator(.hidden) | |
| 84 | } | |
| 85 | } | |
| 86 | .listStyle(.plain) | |
| 87 | .overlay { | |
| 88 | if viewModel.isLoading, viewModel.trackers.isEmpty { | |
| 89 | SRHTLoadingStateView(message: "Loading trackers…") | |
| 90 | } else if let error = viewModel.error, viewModel.trackers.isEmpty { | |
| 91 | SRHTErrorStateView( | |
| 92 | title: "Couldn't Load Trackers", | |
| 93 | message: error, | |
| 94 | retryAction: { await viewModel.loadTrackers() } | |
| 95 | ) | |
| 96 | } else if viewModel.trackers.isEmpty, viewModel.error == nil { | |
| 97 | ContentUnavailableView( | |
| 98 | "No Trackers", | |
| 99 | systemImage: "checklist", | |
| 100 | description: Text("Your bug trackers will appear here.") | |
| 101 | ) | |
| 102 | } | |
| 103 | } | |
| 104 | .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) { | |
| 105 | await viewModel.loadTrackers() | |
| 106 | } | |
| 107 | .srhtErrorBanner(error: $vm.error) | |
| 108 | .refreshable { | |
| 109 | await viewModel.loadTrackers() | |
| 110 | } | |
| 111 | .navigationDestination(for: TrackerSummary.self) { tracker in | |
| 112 | TicketListView( | |
| 113 | ownerUsername: String(tracker.owner.canonicalName.dropFirst()), | |
| 114 | trackerName: tracker.name, | |
| 115 | trackerId: tracker.id, | |
| 116 | trackerRid: tracker.rid | |
| 117 | ) | |
| 118 | } | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | private struct CreateTrackerSheet: View { | |
| 123 | let viewModel: TrackerListViewModel | |
| 124 | let onCreated: (TrackerSummary) -> Void | |
| 125 | ||
| 126 | @Environment(\.dismiss) private var dismiss | |
| 127 | @State private var name = "" | |
| 128 | @State private var description = "" | |
| 129 | @State private var visibility: Visibility = .public | |
| 130 | ||
| 131 | var body: some View { | |
| 132 | NavigationStack { | |
| 133 | Form { | |
| 134 | Section("Tracker Details") { | |
| 135 | TextField("Tracker name", text: $name) | |
| 136 | .textInputAutocapitalization(.never) | |
| 137 | .autocorrectionDisabled() | |
| 138 | TextField("Short description (optional)", text: $description, axis: .vertical) | |
| 139 | .lineLimit(2...4) | |
| 140 | Picker("Visibility", selection: $visibility) { | |
| 141 | Text("Public").tag(Visibility.public) | |
| 142 | Text("Unlisted").tag(Visibility.unlisted) | |
| 143 | Text("Private").tag(Visibility.private) | |
| 144 | } | |
| 145 | } | |
| 146 | } | |
| 147 | .navigationTitle("New Tracker") | |
| 148 | .navigationBarTitleDisplayMode(.inline) | |
| 149 | .toolbar { | |
| 150 | ToolbarItem(placement: .cancellationAction) { | |
| 151 | Button("Cancel") { dismiss() } | |
| 152 | } | |
| 153 | ToolbarItem(placement: .confirmationAction) { | |
| 154 | Button { | |
| 155 | Task { | |
| 156 | if let tracker = await viewModel.createTracker( | |
| 157 | name: name, | |
| 158 | description: description, | |
| 159 | visibility: visibility | |
| 160 | ) { | |
| 161 | onCreated(tracker) | |
| 162 | } | |
| 163 | } | |
| 164 | } label: { | |
| 165 | if viewModel.isCreatingTracker { | |
| 166 | ProgressView() | |
| 167 | .controlSize(.small) | |
| 168 | } else { | |
| 169 | Text("Create Tracker") | |
| 170 | } | |
| 171 | } | |
| 172 | .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker) | |
| 173 | } | |
| 174 | } | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
| 178 | ||
| 179 | // MARK: - Tracker Row | |
| 180 | ||
| 181 | private struct TrackerRowView: View { | |
| 182 | let tracker: TrackerSummary | |
| 183 | ||
| 184 | var body: some View { | |
| 185 | VStack(alignment: .leading, spacing: 4) { | |
| 186 | HStack { | |
| 187 | Text(tracker.name) | |
| 188 | .font(.subheadline.weight(.medium)) | |
| 189 | ||
| 190 | Spacer() | |
| 191 | ||
| 192 | VisibilityBadge(visibility: tracker.visibility) | |
| 193 | } | |
| 194 | ||
| 195 | if let owner = tracker.owner.canonicalName.split(separator: "~").last { | |
| 196 | Text("~\(owner)") | |
| 197 | .font(.caption) | |
| 198 | .foregroundStyle(.secondary) | |
| 199 | } | |
| 200 | ||
| 201 | if let description = tracker.description, !description.isEmpty { | |
| 202 | Text(description) | |
| 203 | .font(.caption) | |
| 204 | .foregroundStyle(.secondary) | |
| 205 | .lineLimit(2) | |
| 206 | } | |
| 207 | ||
| 208 | Text(tracker.updated.relativeDescription) | |
| 209 | .font(.caption2) | |
| 210 | .foregroundStyle(.tertiary) | |
| 211 | } | |
| 212 | .padding(.vertical, 2) | |
| 213 | } | |
| 214 | } | |
Hutch/Views/Tickets/TrackerListViewModel.swift added +166
| @@ -0,0 +1,166 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // MARK: - Response types (file-private to avoid @MainActor Decodable issues) | |
| 4 | ||
| 5 | private struct TrackersResponse: Decodable, Sendable { | |
| 6 | let trackers: TrackersPage | |
| 7 | } | |
| 8 | ||
| 9 | private struct TrackersPage: Decodable, Sendable { | |
| 10 | let results: [TrackerSummary] | |
| 11 | let cursor: String? | |
| 12 | } | |
| 13 | ||
| 14 | // MARK: - View Model | |
| 15 | ||
| 16 | @Observable | |
| 17 | @MainActor | |
| 18 | final class TrackerListViewModel { | |
| 19 | ||
| 20 | private(set) var trackers: [TrackerSummary] = [] | |
| 21 | private(set) var isLoading = false | |
| 22 | private(set) var isLoadingMore = false | |
| 23 | private(set) var isCreatingTracker = false | |
| 24 | var error: String? | |
| 25 | ||
| 26 | private var cursor: String? | |
| 27 | private var hasMore = true | |
| 28 | private let client: SRHTClient | |
| 29 | ||
| 30 | init(client: SRHTClient) { | |
| 31 | self.client = client | |
| 32 | } | |
| 33 | ||
| 34 | // MARK: - Query | |
| 35 | ||
| 36 | private static let query = """ | |
| 37 | query trackers($cursor: Cursor) { | |
| 38 | trackers(cursor: $cursor) { | |
| 39 | results { | |
| 40 | id | |
| 41 | rid | |
| 42 | name | |
| 43 | description | |
| 44 | visibility | |
| 45 | updated | |
| 46 | owner { canonicalName } | |
| 47 | } | |
| 48 | cursor | |
| 49 | } | |
| 50 | } | |
| 51 | """ | |
| 52 | ||
| 53 | private static let createTrackerMutation = """ | |
| 54 | mutation createTracker($name: String!, $visibility: Visibility!, $description: String) { | |
| 55 | createTracker(name: $name, visibility: $visibility, description: $description) { | |
| 56 | id | |
| 57 | rid | |
| 58 | name | |
| 59 | description | |
| 60 | visibility | |
| 61 | updated | |
| 62 | owner { canonicalName } | |
| 63 | } | |
| 64 | } | |
| 65 | """ | |
| 66 | ||
| 67 | // MARK: - Public API | |
| 68 | ||
| 69 | func loadTrackers() async { | |
| 70 | isLoading = true | |
| 71 | error = nil | |
| 72 | cursor = nil | |
| 73 | hasMore = true | |
| 74 | ||
| 75 | do { | |
| 76 | let page = try await fetchPage(cursor: nil) | |
| 77 | trackers = page.results | |
| 78 | cursor = page.cursor | |
| 79 | hasMore = page.cursor != nil | |
| 80 | } catch { | |
| 81 | self.error = error.localizedDescription | |
| 82 | } | |
| 83 | ||
| 84 | isLoading = false | |
| 85 | } | |
| 86 | ||
| 87 | func loadMoreIfNeeded(currentItem: TrackerSummary) async { | |
| 88 | guard let last = trackers.last, | |
| 89 | last.id == currentItem.id, | |
| 90 | hasMore, | |
| 91 | !isLoadingMore else { | |
| 92 | return | |
| 93 | } | |
| 94 | ||
| 95 | isLoadingMore = true | |
| 96 | ||
| 97 | do { | |
| 98 | let page = try await fetchPage(cursor: cursor) | |
| 99 | trackers.append(contentsOf: page.results) | |
| 100 | cursor = page.cursor | |
| 101 | hasMore = page.cursor != nil | |
| 102 | } catch { | |
| 103 | self.error = error.localizedDescription | |
| 104 | } | |
| 105 | ||
| 106 | isLoadingMore = false | |
| 107 | } | |
| 108 | ||
| 109 | func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? { | |
| 110 | guard !isCreatingTracker else { return nil } | |
| 111 | ||
| 112 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 113 | guard !trimmedName.isEmpty else { | |
| 114 | error = "Enter a tracker name." | |
| 115 | return nil | |
| 116 | } | |
| 117 | ||
| 118 | isCreatingTracker = true | |
| 119 | error = nil | |
| 120 | defer { isCreatingTracker = false } | |
| 121 | ||
| 122 | var variables: [String: any Sendable] = [ | |
| 123 | "name": trimmedName, | |
| 124 | "visibility": visibility.rawValue | |
| 125 | ] | |
| 126 | let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 127 | if !trimmedDescription.isEmpty { | |
| 128 | variables["description"] = trimmedDescription | |
| 129 | } | |
| 130 | ||
| 131 | do { | |
| 132 | let result = try await client.execute( | |
| 133 | service: .todo, | |
| 134 | query: Self.createTrackerMutation, | |
| 135 | variables: variables, | |
| 136 | responseType: CreateTrackerResponse.self | |
| 137 | ) | |
| 138 | let tracker = result.createTracker | |
| 139 | trackers.insert(tracker, at: 0) | |
| 140 | return tracker | |
| 141 | } catch { | |
| 142 | self.error = "Couldn’t create the tracker. \(error.localizedDescription)" | |
| 143 | return nil | |
| 144 | } | |
| 145 | } | |
| 146 | ||
| 147 | // MARK: - Private | |
| 148 | ||
| 149 | private func fetchPage(cursor: String?) async throws -> TrackersPage { | |
| 150 | var variables: [String: any Sendable] = [:] | |
| 151 | if let cursor { | |
| 152 | variables["cursor"] = cursor | |
| 153 | } | |
| 154 | let result = try await client.execute( | |
| 155 | service: .todo, | |
| 156 | query: Self.query, | |
| 157 | variables: variables.isEmpty ? nil : variables, | |
| 158 | responseType: TrackersResponse.self | |
| 159 | ) | |
| 160 | return result.trackers | |
| 161 | } | |
| 162 | ||
| 163 | private struct CreateTrackerResponse: Decodable, Sendable { | |
| 164 | let createTracker: TrackerSummary | |
| 165 | } | |
| 166 | } | |
README.md added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | # Hutch | |
| 2 | ||
| 3 | <div style="display: flex; align-items: center; border: 1px solid #ccc; border-radius: 10px; padding: 10px; width: 300px; background-color: #f9f9f9;"> | |
| 4 | <img src="https://img.cleberg.net/apps/hutch/logo/hutch_light.png" alt="Hutch App Logo" style="border-radius: 50%; width: 60px; height: 60px; margin-right: 10px;"> | |
| 5 | <div> | |
| 6 | <h2 style="margin: 0; font-size: 20px; color: #333;">Hutch</h2> | |
| 7 | <h3 style="margin: 0; font-size: 14px; color: #777;">An iOS client for Sourcehut</h3> | |
| 8 | </div> | |
| 9 | </div> | |
| 10 | ||
| 11 | ||
| 12 | ![Hutch App Logo]() | |
| 13 | ||
| 14 | ## Table of Contents | |
| 15 | ||
| 16 | - [Introduction](#introduction) | |
| 17 | <!-- - [Features](#features) | |
| 18 | - [Screenshots](#screenshots) --> | |
| 19 | - [Installation](#installation) | |
| 20 | - [Usage](#usage) | |
| 21 | - [Contributing](#contributing) | |
| 22 | - [License](#license) | |
| 23 | - [Contact](#contact) | |
| 24 | ||
| 25 | ## Introduction | |
| 26 | ||
| 27 | **Hutch** is a native Swift client on iOS for [Sourcehut](https://sr.ht). | |
| 28 | ||
| 29 | <!-- TODO | |
| 30 | ||
| 31 | ## Features | |
| 32 | ||
| 33 | - **Seamless Integration**: Effortlessly connect to your sr.ht account. | |
| 34 | - **Repository Access**: View and manage your repositories in one convenient | |
| 35 | location. | |
| 36 | - **Ticket Management**: Create and track tickets associated with your projects. | |
| 37 | - **Profile Management**: Update your account description, keys, and | |
| 38 | preferences. | |
| 39 | ||
| 40 | For a complete list of features, visit [here](http://example.com/features). | |
| 41 | ||
| 42 | ## Roadmap | |
| 43 | ||
| 44 | - **Build Notifications**: Get real-time updates on your builds and status | |
| 45 | changes. | |
| 46 | - **Dashboard Access**: View and manage your projects in one convenient | |
| 47 | location. | |
| 48 | ||
| 49 | To see future planned features and updates, check out the project roadmap | |
| 50 | [here](http://example.com/roadmap). | |
| 51 | ||
| 52 | ## Screenshots | |
| 53 | ||
| 54 | Here are some screenshots of the app in action: | |
| 55 | ||
| 56 |  | |
| 57 |  | |
| 58 |  | |
| 59 |  | |
| 60 | ||
| 61 | --> | |
| 62 | ||
| 63 | ## Installation | |
| 64 | ||
| 65 | To get started with Hutch, follow these steps: | |
| 66 | ||
| 67 | 1. Clone the repository: `git clone | |
| 68 | https://github.com/ccleberg/Hutch.git` | |
| 69 | 2. Open the project in Xcode. | |
| 70 | 3. Run the app on your device or simulator. | |
| 71 | ||
| 72 | ## Usage | |
| 73 | ||
| 74 | Provide instructions on how to use the app. You might include: | |
| 75 | ||
| 76 | - **Step 1**: To login, use a PAT in Sourcehut's portal: | |
| 77 | https://meta.sr.ht/oauth2/personal-token | |
| 78 | - **Step 2**: Navigate the app the same way you'd navigate Sourcehut. | |
| 79 | - **Step 3**: If something doesn't work as expected, send us an email! | |
| 80 | ||
| 81 | ## Contributing | |
| 82 | ||
| 83 | Contributions are welcome! Please follow these steps: | |
| 84 | ||
| 85 | 1. Fork the repository. | |
| 86 | 2. Create a new branch for your feature (`git checkout -b feature-xyz`). | |
| 87 | 3. Make your changes. | |
| 88 | 4. Commit your changes and push to the branch. | |
| 89 | 5. Open a pull request. | |
| 90 | ||
| 91 | ## License | |
| 92 | ||
| 93 | This project is licensed under the GPL 3.0 or later License. See the | |
| 94 | [LICENSE](LICENSE) file for more information. | |
| 95 | ||
| 96 | ## Contact | |
| 97 | ||
| 98 | For questions or feedback, please reach out to [hello@cleberg.net]. | |
| 99 | ||
| 100 | --- | |
| 101 | ||
| 102 | Thank you for checking out **Hutch**! We hope you find it useful and enjoyable. | |
TODO.md added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | # Hutch TODO | |
| 2 | ||
| 3 | ## Claude Code, Codex, & Mistral Prompting Notes | |
| 4 | ||
| 5 | - **Always start a new session with the project context block** (see | |
| 6 | hutch-ios-prompts.md prompt 0) so the agent knows the API conventions. | |
| 7 | - **Keep prompts short and specific.** Long prompts waste context. One bug or | |
| 8 | feature per prompt. Paste console output and error messages directly instead | |
| 9 | of describing them. | |
| 10 | - **When a fix fails twice, ask the agent to print raw state first** (raw API | |
| 11 | response, current file contents, exact variable values) before attempting | |
| 12 | another fix. Blind retries consume context without progress. | |
| 13 | - **Never paste full file contents into the prompt.** Ask the agent to read | |
| 14 | the file itself using its file tools. | |
| 15 | - **Batch only tightly related changes.** Unrelated changes in one prompt | |
| 16 | increase the chance of partial failure and wasted context on rollback. | |
| 17 | ||
| 18 | ## Features | |
| 19 | ||
| 20 | - **Exact repository lookup**: Since public repository discovery/search is not | |
| 21 | exposed in the public sr.ht API, add a manual `~owner/repo` lookup flow that | |
| 22 | can open git.sr.ht or hg.sr.ht repositories directly. | |
| 23 | ||
| 24 | - **paste.sr.ht tab**: Paste support. Endpoint: | |
| 25 | https://paste.sr.ht/graphql. Show the authenticated user's pastes, allow | |
| 26 | viewing individual pastes, and support creating new pastes. Required scope: | |
| 27 | PASTES:RO for browsing, PASTES:RW for creation/editing. | |
| 28 | ||
| 29 | - **lists.sr.ht tab**: Mailing list support. Endpoint: | |
| 30 | https://lists.sr.ht/graphql. Show the authenticated user's mailing lists, | |
| 31 | allow browsing email threads, and display individual emails as plain text. | |
| 32 | Required scope: LISTS:RO. | |
| 33 | ||
| 34 | - **pages.sr.ht tab**: Site management support. Endpoint: | |
| 35 | https://pages.sr.ht/graphql. Show the authenticated user's published sites, | |
| 36 | site metadata, publishing status, and access control where supported. This is | |
| 37 | for managing Pages sites, not browsing public project discovery. | |
| 38 | ||
| 39 | - **Donation page / IAP**: Add a donation/support page once the App Store / | |
| 40 | StoreKit setup is ready. | |
| 41 | ||
| 42 | ## Out of Scope | |
| 43 | ||
| 44 | - **Universal links** (e.g. tapping a git.sr.ht URL in Safari opens Hutch): | |
| 45 | Requires Sourcehut to host an apple-app-site-association file on their | |
| 46 | servers. This is outside our control. The hutch:// custom URL scheme works as | |
| 47 | a fallback for links we generate ourselves. | |
| 48 | ||
| 49 | - **Push notifications for builds and tickets**: The sr.ht API only supports | |
| 50 | server-side webhooks (HTTP POST to a URL you control). Delivering push | |
| 51 | notifications to iOS would require a backend relay server to receive webhooks | |
| 52 | and forward them via APNs. Out of scope for a personal app with no backend. | |
| 53 | ||
| 54 | - **Contribution activity / GitHub-style heatmap**: No aggregate contributions | |
| 55 | endpoint exists in the sr.ht GraphQL API. Computing this would require | |
| 56 | paginating every repository's full commit log and filtering by author -- | |
| 57 | extremely slow and not practical. | |
| 58 | ||
| 59 | - **Explore / search (hub.sr.ht)**: The public hub.sr.ht GraphQL API does not | |
| 60 | expose repository discovery/search or a projects feed comparable to | |
| 61 | https://sr.ht/projects. | |
| 62 | ||
| 63 | - **Pronouns on profile**: Not exposed in the meta.sr.ht GraphQL schema. The | |
| 64 | field exists in the web UI but is not available to external API clients. | |
| 65 | ||
| 66 | - **Revoke personal access tokens**: The revokePersonalAccessToken mutation is | |
| 67 | marked @internal in the meta.sr.ht schema and is not accessible to external | |
| 68 | clients. | |