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 | static func build(jobId: Int, ownerCanonicalName: String) -> URL? { | 47 | static func build(jobId: Int, ownerCanonicalName: String) -> URL? { |
| 13 | userScopedURL( | 48 | userScopedURL( |
| 14 | host: "builds.sr.ht", | 49 | host: "builds.sr.ht", |
| @@ -35,7 +70,7 @@ enum SRHTWebURL { | |||
| 35 | 70 | ||
| 36 | static func profile(canonicalName: String) -> URL? { | 71 | static func profile(canonicalName: String) -> URL? { |
| 37 | userScopedURL( | 72 | userScopedURL( |
| 38 | host: "meta.sr.ht", | 73 | host: "sr.ht", |
| 39 | ownerCanonicalName: canonicalName, | 74 | ownerCanonicalName: canonicalName, |
| 40 | pathComponents: [] | 75 | pathComponents: [] |
| 41 | ) | 76 | ) |
| @@ -76,9 +111,22 @@ enum SRHTWebURL { | |||
| 76 | return canonicalName | 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 | private static let pathComponentCharacterSet: CharacterSet = { | 123 | private static let pathComponentCharacterSet: CharacterSet = { |
| 80 | var characterSet = CharacterSet.urlPathAllowed | 124 | var characterSet = CharacterSet.urlPathAllowed |
| 81 | characterSet.remove(charactersIn: "/") | 125 | characterSet.remove(charactersIn: "/") |
| 82 | return characterSet | 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 | @State private var viewModel: HgRepositoryDetailViewModel? | 11 | @State private var viewModel: HgRepositoryDetailViewModel? |
| 12 | @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary | 12 | @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary |
| 13 | @State private var showSettings = false | 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 | var body: some View { | 26 | var body: some View { |
| 16 | Group { | 27 | Group { |
| 17 | if let viewModel { | 28 | if let viewModel { |
| 18 | content(viewModel) | 29 | content(viewModel) |
| 19 | } else { | 30 | } else { |
| 20 | ProgressView() | 31 | SRHTLoadingStateView(message: "Loading repository…") |
| 21 | } | 32 | } |
| 22 | } | 33 | } |
| 23 | .navigationTitle(repository.name) | 34 | .navigationTitle(repository.name) |
| 24 | .navigationBarTitleDisplayMode(.inline) | 35 | .navigationBarTitleDisplayMode(.inline) |
| 25 | .toolbar { | 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 | Button { | 54 | Button { |
| 28 | showSettings = true | 55 | showSettings = true |
| 29 | } label: { | 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 | .task { | 76 | .task { |
| 45 | if viewModel == nil { | 77 | if viewModel == nil { |
| 46 | let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client) | 78 | let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client) |
| @@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View { | |||
| 82 | revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.") | 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)) { | 117 | .srhtErrorBanner(error: Binding( |
| 86 | Button("OK") { viewModel.error = nil } | 118 | get: { viewModel.error }, |
| 87 | } message: { | 119 | set: { viewModel.error = $0 } |
| 88 | if let error = viewModel.error { | 120 | )) |
| 89 | Text(error) | ||
| 90 | } | ||
| 91 | } | ||
| 92 | } | 121 | } |
| 93 | 122 | ||
| 94 | @ViewBuilder | 123 | @ViewBuilder |
| 95 | private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View { | 124 | private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 96 | if viewModel.isLoadingSummary && !viewModel.summaryLoaded { | 125 | ScrollView { |
| 97 | ProgressView() | 126 | VStack(alignment: .leading, spacing: 16) { |
| 98 | .frame(maxWidth: .infinity, maxHeight: .infinity) | 127 | headerSection |
| 99 | } else { | 128 | metadataSection(viewModel) |
| 100 | ScrollView { | 129 | repositoryDetailsSection(viewModel) |
| 101 | VStack(alignment: .leading, spacing: 16) { | 130 | latestChangeSection(viewModel) |
| 102 | summaryCards(viewModel) | 131 | readmeSection(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() | ||
| 115 | } | 132 | } |
| 116 | .refreshable { | 133 | .padding() |
| 117 | await viewModel.loadSummary() | 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 { | 151 | private var headerSection: some View { |
| 123 | VStack(alignment: .leading, spacing: 12) { | 152 | VStack(alignment: .leading, spacing: 6) { |
| 124 | LabeledContent("Visibility", value: visibilityLabel(repository.visibility)) | 153 | Text(repository.owner.canonicalName) |
| 125 | LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing") | 154 | .font(.subheadline) |
| 126 | 155 | .foregroundStyle(.secondary) | |
| 156 | Text(repository.name) | ||
| 157 | .font(.largeTitle.weight(.semibold)) | ||
| 127 | if let description = repository.description, !description.isEmpty { | 158 | if let description = repository.description, !description.isEmpty { |
| 128 | VStack(alignment: .leading, spacing: 4) { | 159 | Text(description) |
| 129 | Text("Description") | 160 | .font(.body) |
| 130 | .font(.caption.weight(.semibold)) | ||
| 131 | .foregroundStyle(.secondary) | ||
| 132 | .textCase(.uppercase) | ||
| 133 | Text(description) | ||
| 134 | } | ||
| 135 | } | 161 | } |
| 162 | } | ||
| 163 | } | ||
| 136 | 164 | ||
| 137 | if let tip = viewModel.tip { | 165 | @ViewBuilder |
| 138 | VStack(alignment: .leading, spacing: 6) { | 166 | private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 139 | Text("Tip") | 167 | VStack(alignment: .leading, spacing: 10) { |
| 140 | .font(.caption.weight(.semibold)) | 168 | SummaryMetadataRow( |
| 141 | .foregroundStyle(.secondary) | 169 | icon: "arrow.triangle.branch", |
| 142 | .textCase(.uppercase) | 170 | title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility) |
| 143 | Text(tip.title) | 171 | ) |
| 144 | .font(.headline) | 172 | |
| 145 | HStack { | 173 | if let readmePath = viewModel.readmePath { |
| 146 | Text(tip.displayShortId) | 174 | SummaryMetadataRow( |
| 147 | .font(.caption.monospaced()) | 175 | icon: "doc.text", |
| 148 | Spacer() | 176 | title: readmePath |
| 149 | Text(tip.author.time.relativeDescription) | 177 | ) |
| 150 | .font(.caption) | ||
| 151 | .foregroundStyle(.secondary) | ||
| 152 | } | ||
| 153 | Text(tip.author.name) | ||
| 154 | .font(.subheadline) | ||
| 155 | .foregroundStyle(.secondary) | ||
| 156 | } | ||
| 157 | } | 178 | } |
| 158 | } | 179 | } |
| 159 | .padding() | 180 | } |
| 160 | .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) | 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 | @ViewBuilder | 247 | @ViewBuilder |
| @@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View { | |||
| 166 | browseBreadcrumbs(viewModel) | 250 | browseBreadcrumbs(viewModel) |
| 167 | Divider() | 251 | Divider() |
| 168 | 252 | ||
| 169 | if viewModel.isLoadingBrowse { | 253 | if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil { |
| 170 | Spacer() | 254 | SRHTLoadingStateView(message: "Loading files…") |
| 171 | ProgressView() | ||
| 172 | Spacer() | ||
| 173 | } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent { | 255 | } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent { |
| 174 | GeometryReader { geometry in | 256 | VStack(spacing: 0) { |
| 175 | ScrollView([.vertical, .horizontal]) { | 257 | HStack { |
| 176 | Text(fileContent) | 258 | Spacer() |
| 177 | .font(.system(.body, design: .monospaced)) | 259 | SRHTShareButton(url: shareURL, target: .file) { |
| 178 | .frame( | 260 | Label("Share File", systemImage: "square.and.arrow.up") |
| 179 | minWidth: geometry.size.width, | 261 | } |
| 180 | minHeight: geometry.size.height, | 262 | .buttonStyle(.bordered) |
| 181 | alignment: .topLeading | 263 | } |
| 182 | ) | 264 | .padding(.horizontal) |
| 183 | .padding() | 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 | .safeAreaInset(edge: .bottom) { | 280 | .safeAreaInset(edge: .bottom) { |
| @@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View { | |||
| 193 | .background(.bar) | 287 | .background(.bar) |
| 194 | } | 288 | } |
| 195 | .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name) | 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 | } else if viewModel.files.isEmpty { | 296 | } else if viewModel.files.isEmpty { |
| 197 | ContentUnavailableView( | 297 | ContentUnavailableView( |
| 198 | "No Files", | 298 | "No Files", |
| @@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View { | |||
| 201 | ) | 301 | ) |
| 202 | } else { | 302 | } else { |
| 203 | List(viewModel.files) { file in | 303 | List(viewModel.files) { file in |
| 204 | Label { | 304 | HgFileRow(file: file) |
| 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 | } | ||
| 211 | .contentShape(Rectangle()) | 305 | .contentShape(Rectangle()) |
| 212 | .onTapGesture { | 306 | .onTapGesture { |
| 213 | Task { await viewModel.openFile(file) } | 307 | Task { await viewModel.openFile(file) } |
| @@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View { | |||
| 282 | } | 376 | } |
| 283 | .listStyle(.plain) | 377 | .listStyle(.plain) |
| 284 | .overlay { | 378 | .overlay { |
| 285 | if viewModel.isLoadingLog { | 379 | if viewModel.isLoadingLog, viewModel.log.isEmpty { |
| 286 | ProgressView() | 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 | } else if viewModel.log.isEmpty { | 387 | } else if viewModel.log.isEmpty { |
| 288 | ContentUnavailableView( | 388 | ContentUnavailableView( |
| 289 | "No Revisions", | 389 | "No Revisions", |
| @@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View { | |||
| 298 | } | 398 | } |
| 299 | 399 | ||
| 300 | @ViewBuilder | 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 | if revisions.isEmpty { | 402 | if revisions.isEmpty { |
| 303 | ContentUnavailableView( | 403 | ContentUnavailableView( |
| 304 | emptyTitle, | 404 | emptyTitle, |
| @@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View { | |||
| 307 | ) | 407 | ) |
| 308 | } else { | 408 | } else { |
| 309 | List(revisions) { revision in | 409 | List(revisions) { revision in |
| 310 | revisionRow(revision) | 410 | namedRevisionRow(revision) |
| 311 | } | 411 | } |
| 312 | .listStyle(.plain) | 412 | .listStyle(.plain) |
| 313 | } | 413 | } |
| @@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View { | |||
| 335 | } | 435 | } |
| 336 | 436 | ||
| 337 | HStack { | 437 | HStack { |
| 338 | Text(revision.author.name) | 438 | Text(revision.author) |
| 339 | Spacer() | ||
| 340 | Text(revision.author.time.relativeDescription) | ||
| 341 | } | 439 | } |
| 342 | .font(.caption) | 440 | .font(.caption) |
| 343 | .foregroundStyle(.secondary) | 441 | .foregroundStyle(.secondary) |
| @@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View { | |||
| 345 | .padding(.vertical, 4) | 443 | .padding(.vertical, 4) |
| 346 | } | 444 | } |
| 347 | 445 | ||
| 348 | @ViewBuilder | 446 | private func namedRevisionRow(_ revision: HgNamedRevision) -> some View { |
| 349 | private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? { | 447 | HStack(alignment: .firstTextBaseline) { |
| 350 | let imageURLResolver = makeImageURLResolver(viewModel) | 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 | guard let content = viewModel.readmeContent else { | 459 | guard let content = viewModel.readmeContent else { |
| 353 | return nil | 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 | switch content { | 483 | switch content { |
| 357 | case .html(let html): | 484 | case .html(let html): |
| 358 | return AnyView( | 485 | .html(html) |
| 359 | HTMLWebView(html: html, colorScheme: colorScheme) | ||
| 360 | .frame(minHeight: 400) | ||
| 361 | ) | ||
| 362 | case .markdown(let text): | 486 | case .markdown(let text): |
| 363 | return AnyView( | 487 | .markdown(text) |
| 364 | HTMLWebView( | ||
| 365 | html: markdownToHTML(text, imageURLResolver: imageURLResolver), | ||
| 366 | colorScheme: colorScheme | ||
| 367 | ) | ||
| 368 | .frame(minHeight: 400) | ||
| 369 | ) | ||
| 370 | case .org(let text): | 488 | case .org(let text): |
| 371 | return AnyView( | 489 | .org(text) |
| 372 | HTMLWebView( | ||
| 373 | html: orgToHTML(text, imageURLResolver: imageURLResolver), | ||
| 374 | colorScheme: colorScheme | ||
| 375 | ) | ||
| 376 | .frame(minHeight: 400) | ||
| 377 | ) | ||
| 378 | case .plainText(let text): | 490 | case .plainText(let text): |
| 379 | return AnyView( | 491 | .plainText(text) |
| 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 | ) | ||
| 386 | } | 492 | } |
| 387 | } | 493 | } |
| 388 | 494 | ||
| 389 | private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? { | 495 | private func displayFileName(_ name: String) -> String { |
| 390 | let owner = repository.owner.canonicalName | 496 | name.hasSuffix("/") ? String(name.dropLast()) : name |
| 391 | let repositoryName = repository.name | 497 | } |
| 392 | let readmePath = viewModel.readmePath | 498 | |
| 393 | 499 | } | |
| 394 | return { source in | 500 | |
| 395 | resolveRepositoryAssetURL( | 501 | private struct HgBrowseRefPickerSheet: View { |
| 396 | source, | 502 | let viewModel: HgRepositoryDetailViewModel |
| 397 | owner: owner, | 503 | @Binding var isPresented: Bool |
| 398 | repositoryName: repositoryName, | 504 | |
| 399 | readmePath: readmePath | 505 | var body: some View { |
| 400 | )? | 506 | NavigationStack { |
| 401 | .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht") | 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 { | 601 | private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View { |
| 406 | name.hasSuffix("/") ? String(name.dropLast()) : name | 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 { | 622 | private struct HgFileRow: View { |
| 410 | switch visibility { | 623 | let file: HgFile |
| 411 | case .public: | 624 | |
| 412 | return "Public" | 625 | var body: some View { |
| 413 | case .unlisted: | 626 | Label { |
| 414 | return "Unlisted" | 627 | Text(displayName) |
| 415 | case .private: | 628 | .font(.body.monospaced()) |
| 416 | return "Private" | 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 | @Environment(\.dismiss) private var dismiss | 10 | @Environment(\.dismiss) private var dismiss |
| 11 | @State private var viewModel: RepositorySettingsViewModel? | 11 | @State private var viewModel: RepositorySettingsViewModel? |
| 12 | @State private var showDeleteConfirmation = false | 12 | @State private var showDeleteConfirmation = false |
| 13 | @State private var pendingACLDeletion: ACLEntry? | ||
| 13 | 14 | ||
| 14 | var body: some View { | 15 | var body: some View { |
| 15 | NavigationStack { | 16 | NavigationStack { |
| @@ -17,7 +18,7 @@ struct RepositorySettingsView: View { | |||
| 17 | if let viewModel { | 18 | if let viewModel { |
| 18 | settingsForm(viewModel) | 19 | settingsForm(viewModel) |
| 19 | } else { | 20 | } else { |
| 20 | ProgressView() | 21 | SRHTLoadingStateView(message: "Loading settings…") |
| 21 | } | 22 | } |
| 22 | } | 23 | } |
| 23 | .navigationTitle("Settings") | 24 | .navigationTitle("Settings") |
| @@ -51,13 +52,7 @@ struct RepositorySettingsView: View { | |||
| 51 | accessSection(viewModel) | 52 | accessSection(viewModel) |
| 52 | deleteSection(viewModel) | 53 | deleteSection(viewModel) |
| 53 | } | 54 | } |
| 54 | .alert("Error", isPresented: .constant(viewModel.error != nil)) { | 55 | .srhtErrorBanner(error: $vm.error) |
| 55 | Button("OK") { viewModel.error = nil } | ||
| 56 | } message: { | ||
| 57 | if let error = viewModel.error { | ||
| 58 | Text(error) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .alert( | 56 | .alert( |
| 62 | "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", | 57 | "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?", |
| 63 | isPresented: $showDeleteConfirmation | 58 | isPresented: $showDeleteConfirmation |
| @@ -75,6 +70,27 @@ struct RepositorySettingsView: View { | |||
| 75 | } message: { | 70 | } message: { |
| 76 | Text("This cannot be undone.") | 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 | // MARK: - Info Section | 96 | // MARK: - Info Section |
| @@ -82,6 +98,11 @@ struct RepositorySettingsView: View { | |||
| 82 | @ViewBuilder | 98 | @ViewBuilder |
| 83 | private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { | 99 | private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View { |
| 84 | Section("Info") { | 100 | Section("Info") { |
| 101 | LabeledContent("Name") { | ||
| 102 | Text(repository.name) | ||
| 103 | .font(.body.monospaced()) | ||
| 104 | } | ||
| 105 | |||
| 85 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) | 106 | TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical) |
| 86 | .lineLimit(3...6) | 107 | .lineLimit(3...6) |
| 87 | 108 | ||
| @@ -107,7 +128,7 @@ struct RepositorySettingsView: View { | |||
| 107 | ProgressView() | 128 | ProgressView() |
| 108 | .frame(maxWidth: .infinity) | 129 | .frame(maxWidth: .infinity) |
| 109 | } else { | 130 | } else { |
| 110 | Text("Save") | 131 | Text("Save Changes") |
| 111 | .frame(maxWidth: .infinity) | 132 | .frame(maxWidth: .infinity) |
| 112 | } | 133 | } |
| 113 | } | 134 | } |
| @@ -120,7 +141,7 @@ struct RepositorySettingsView: View { | |||
| 120 | @ViewBuilder | 141 | @ViewBuilder |
| 121 | private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { | 142 | private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View { |
| 122 | Section { | 143 | Section { |
| 123 | TextField("Repository Name", text: Bindable(viewModel).editedName) | 144 | TextField("New repository name", text: Bindable(viewModel).editedName) |
| 124 | .autocorrectionDisabled() | 145 | .autocorrectionDisabled() |
| 125 | .textInputAutocapitalization(.never) | 146 | .textInputAutocapitalization(.never) |
| 126 | 147 | ||
| @@ -141,7 +162,7 @@ struct RepositorySettingsView: View { | |||
| 141 | ProgressView() | 162 | ProgressView() |
| 142 | .frame(maxWidth: .infinity) | 163 | .frame(maxWidth: .infinity) |
| 143 | } else { | 164 | } else { |
| 144 | Text("Rename") | 165 | Text("Rename Repository") |
| 145 | .frame(maxWidth: .infinity) | 166 | .frame(maxWidth: .infinity) |
| 146 | } | 167 | } |
| 147 | } | 168 | } |
| @@ -163,7 +184,7 @@ struct RepositorySettingsView: View { | |||
| 163 | Spacer() | 184 | Spacer() |
| 164 | } | 185 | } |
| 165 | } else if viewModel.acls.isEmpty { | 186 | } else if viewModel.acls.isEmpty { |
| 166 | Text("No access control entries.") | 187 | Text("No access entries yet.") |
| 167 | .foregroundStyle(.secondary) | 188 | .foregroundStyle(.secondary) |
| 168 | } else { | 189 | } else { |
| 169 | ForEach(viewModel.acls) { entry in | 190 | ForEach(viewModel.acls) { entry in |
| @@ -174,11 +195,11 @@ struct RepositorySettingsView: View { | |||
| 174 | .font(.caption.monospaced()) | 195 | .font(.caption.monospaced()) |
| 175 | .foregroundStyle(.secondary) | 196 | .foregroundStyle(.secondary) |
| 176 | } | 197 | } |
| 177 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { | 198 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { |
| 178 | Button(role: .destructive) { | 199 | Button(role: .destructive) { |
| 179 | Task { await viewModel.deleteACL(entry) } | 200 | pendingACLDeletion = entry |
| 180 | } label: { | 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 | // Add ACL form | 208 | // Add ACL form |
| 188 | HStack { | 209 | HStack { |
| 189 | TextField("Username", text: Bindable(viewModel).newACLEntity) | 210 | TextField("Username or ~username", text: Bindable(viewModel).newACLEntity) |
| 190 | .autocorrectionDisabled() | 211 | .autocorrectionDisabled() |
| 191 | .textInputAutocapitalization(.never) | 212 | .textInputAutocapitalization(.never) |
| 192 | 213 | ||
| @@ -203,11 +224,14 @@ struct RepositorySettingsView: View { | |||
| 203 | if viewModel.isAddingACL { | 224 | if viewModel.isAddingACL { |
| 204 | ProgressView() | 225 | ProgressView() |
| 205 | } else { | 226 | } else { |
| 206 | Image(systemName: "plus.circle.fill") | 227 | Text("Add") |
| 207 | } | 228 | } |
| 208 | } | 229 | } |
| 209 | .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) | 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 | } header: { | 235 | } header: { |
| 212 | Text("Access") | 236 | Text("Access") |
| 213 | } | 237 | } |
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +18 −6
| @@ -63,6 +63,7 @@ final class RepositorySettingsViewModel { | |||
| 63 | 63 | ||
| 64 | let repositoryId: Int | 64 | let repositoryId: Int |
| 65 | let repositoryRid: String | 65 | let repositoryRid: String |
| 66 | let service: SRHTService | ||
| 66 | private let client: SRHTClient | 67 | private let client: SRHTClient |
| 67 | 68 | ||
| 68 | // MARK: - Info fields | 69 | // MARK: - Info fields |
| @@ -107,6 +108,7 @@ final class RepositorySettingsViewModel { | |||
| 107 | ) { | 108 | ) { |
| 108 | self.repositoryId = repository.id | 109 | self.repositoryId = repository.id |
| 109 | self.repositoryRid = repository.rid | 110 | self.repositoryRid = repository.rid |
| 111 | self.service = repository.service | ||
| 110 | self.client = client | 112 | self.client = client |
| 111 | self.editedDescription = repository.description ?? "" | 113 | self.editedDescription = repository.description ?? "" |
| 112 | self.editedVisibility = repository.visibility | 114 | self.editedVisibility = repository.visibility |
| @@ -143,7 +145,7 @@ final class RepositorySettingsViewModel { | |||
| 143 | "HEAD": editedHead | 145 | "HEAD": editedHead |
| 144 | ] | 146 | ] |
| 145 | _ = try await client.execute( | 147 | _ = try await client.execute( |
| 146 | service: .git, | 148 | service: service, |
| 147 | query: Self.updateRepoMutation, | 149 | query: Self.updateRepoMutation, |
| 148 | variables: ["id": repositoryId, "input": input], | 150 | variables: ["id": repositoryId, "input": input], |
| 149 | responseType: UpdateRepoResponse.self | 151 | responseType: UpdateRepoResponse.self |
| @@ -165,7 +167,7 @@ final class RepositorySettingsViewModel { | |||
| 165 | "name": editedName | 167 | "name": editedName |
| 166 | ] | 168 | ] |
| 167 | let result = try await client.execute( | 169 | let result = try await client.execute( |
| 168 | service: .git, | 170 | service: service, |
| 169 | query: Self.updateRepoMutation, | 171 | query: Self.updateRepoMutation, |
| 170 | variables: ["id": repositoryId, "input": input], | 172 | variables: ["id": repositoryId, "input": input], |
| 171 | responseType: UpdateRepoResponse.self | 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 | func loadACLs() async { | 222 | func loadACLs() async { |
| 211 | guard !isLoadingACLs else { return } | 223 | guard !isLoadingACLs else { return } |
| 212 | isLoadingACLs = true | 224 | isLoadingACLs = true |
| @@ -214,7 +226,7 @@ final class RepositorySettingsViewModel { | |||
| 214 | 226 | ||
| 215 | do { | 227 | do { |
| 216 | let result = try await client.execute( | 228 | let result = try await client.execute( |
| 217 | service: .git, | 229 | service: service, |
| 218 | query: Self.aclsQuery, | 230 | query: Self.aclsQuery, |
| 219 | variables: ["rid": repositoryRid], | 231 | variables: ["rid": repositoryRid], |
| 220 | responseType: ACLResponse.self | 232 | responseType: ACLResponse.self |
| @@ -234,7 +246,7 @@ final class RepositorySettingsViewModel { | |||
| 234 | 246 | ||
| 235 | do { | 247 | do { |
| 236 | let result = try await client.execute( | 248 | let result = try await client.execute( |
| 237 | service: .git, | 249 | service: service, |
| 238 | query: Self.updateACLMutation, | 250 | query: Self.updateACLMutation, |
| 239 | variables: [ | 251 | variables: [ |
| 240 | "repoId": repositoryId, | 252 | "repoId": repositoryId, |
| @@ -262,7 +274,7 @@ final class RepositorySettingsViewModel { | |||
| 262 | 274 | ||
| 263 | do { | 275 | do { |
| 264 | _ = try await client.execute( | 276 | _ = try await client.execute( |
| 265 | service: .git, | 277 | service: service, |
| 266 | query: Self.deleteACLMutation, | 278 | query: Self.deleteACLMutation, |
| 267 | variables: ["id": entry.id], | 279 | variables: ["id": entry.id], |
| 268 | responseType: DeleteACLResponse.self | 280 | responseType: DeleteACLResponse.self |
| @@ -288,7 +300,7 @@ final class RepositorySettingsViewModel { | |||
| 288 | 300 | ||
| 289 | do { | 301 | do { |
| 290 | _ = try await client.execute( | 302 | _ = try await client.execute( |
| 291 | service: .git, | 303 | service: service, |
| 292 | query: Self.deleteRepoMutation, | 304 | query: Self.deleteRepoMutation, |
| 293 | variables: ["id": repositoryId], | 305 | variables: ["id": repositoryId], |
| 294 | responseType: DeleteRepoResponse.self | 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 | let title: String | 44 | let title: String |
| 44 | @ViewBuilder let content: Content | 45 | var subtitle: String? = nil |
| 45 | |||
| 46 | init(_ title: String, @ViewBuilder content: () -> Content) { | ||
| 47 | self.title = title | ||
| 48 | self.content = content() | ||
| 49 | } | ||
| 50 | 46 | ||
| 51 | var body: some View { | 47 | var body: some View { |
| 52 | VStack(alignment: .leading, spacing: 12) { | 48 | HStack(alignment: .top, spacing: 10) { |
| 53 | Text(title) | 49 | Image(systemName: icon) |
| 54 | .font(.caption.weight(.semibold)) | ||
| 55 | .foregroundStyle(.secondary) | 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 | let label: String | 66 | let label: String |
| 68 | let value: String | 67 | let value: String |
| 69 | var monospace: Bool = false | 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. | ||