aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
committerChristian Cleberg <[email protected]>2026-03-17 23:19:43 -0500
commit32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5 (patch)
treeee36d421d704e508d5617d803b4c8cbdb4804fe1
parent8f2057c53e9009c2529c9c4849c914666c0e4b40 (diff)
downloadhutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.gz
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.tar.bz2
hutch-32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5.zip
v1.0
-rw-r--r--Hutch.xcodeproj/project.pbxproj377
-rw-r--r--Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata7
-rw-r--r--Hutch/API_REFERENCE.md147
-rw-r--r--Hutch/App/AppState.swift222
-rw-r--r--Hutch/App/DeepLink.swift54
-rw-r--r--Hutch/App/HutchApp.swift20
-rw-r--r--Hutch/App/RootView.swift197
-rw-r--r--Hutch/Assets.xcassets/AccentColor.colorset/Contents.json11
-rw-r--r--Hutch/Assets.xcassets/AppIcon.appiconset/1.pngbin0 -> 47870 bytes
-rw-r--r--Hutch/Assets.xcassets/AppIcon.appiconset/2.pngbin0 -> 42089 bytes
-rw-r--r--Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json37
-rw-r--r--Hutch/Assets.xcassets/Contents.json6
-rw-r--r--Hutch/Extensions/Date+Relative.swift10
-rw-r--r--Hutch/Extensions/DateFormatter+SRHT.swift39
-rw-r--r--Hutch/Extensions/ErrorViews.swift159
-rw-r--r--Hutch/Extensions/Int+ByteFormatting.swift8
-rw-r--r--Hutch/Extensions/KeychainHelper.swift103
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift50
-rw-r--r--Hutch/Info.plist23
-rw-r--r--Hutch/Models/ArtifactInfo.swift22
-rw-r--r--Hutch/Models/Builds.swift111
-rw-r--r--Hutch/Models/CommitDetail.swift65
-rw-r--r--Hutch/Models/CommitSummary.swift22
-rw-r--r--Hutch/Models/Git.swift241
-rw-r--r--Hutch/Models/Meta.swift65
-rw-r--r--Hutch/Models/RepositorySummary.swift57
-rw-r--r--Hutch/Models/Todo.swift234
-rw-r--r--Hutch/Models/User.swift10
-rw-r--r--Hutch/Networking/GraphQLRequest.swift45
-rw-r--r--Hutch/Networking/NetworkMonitor.swift48
-rw-r--r--Hutch/Networking/Pagination.swift176
-rw-r--r--Hutch/Networking/ResponseCache.swift33
-rw-r--r--Hutch/Networking/SRHTClient.swift521
-rw-r--r--Hutch/Networking/SRHTError.swift63
-rw-r--r--Hutch/Networking/SRHTService.swift34
-rw-r--r--Hutch/Views/Auth/AuthView.swift95
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift383
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift235
-rw-r--r--Hutch/Views/Builds/BuildListView.swift217
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift220
-rw-r--r--Hutch/Views/Builds/BuildRowView.swift106
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift73
-rw-r--r--Hutch/Views/Repositories/CommitDetailView.swift332
-rw-r--r--Hutch/Views/Repositories/CommitDetailViewModel.swift102
-rw-r--r--Hutch/Views/Repositories/CommitLogView.swift59
-rw-r--r--Hutch/Views/Repositories/CommitRowView.swift30
-rw-r--r--Hutch/Views/Repositories/DiffView.swift79
-rw-r--r--Hutch/Views/Repositories/FileTreeView.swift446
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift524
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift506
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift545
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsView.swift239
-rw-r--r--Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift287
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift1125
-rw-r--r--Hutch/Views/Repositories/ReferencesListView.swift90
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift107
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift397
-rw-r--r--Hutch/Views/Repositories/RepositoryListView.swift224
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift548
-rw-r--r--Hutch/Views/Repositories/RepositoryRowView.swift85
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsView.swift58
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift24
-rw-r--r--Hutch/Views/Repositories/RepositorySummarySupport.swift51
-rw-r--r--Hutch/Views/Settings/SettingsView.swift724
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift394
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift828
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift551
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift389
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift215
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift214
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift166
-rw-r--r--README.md102
-rw-r--r--TODO.md68
73 files changed, 13852 insertions, 203 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8aab8bb
--- /dev/null
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -0,0 +1,377 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89612F69DEA900FC0253 /* TODO.md */; };
+ 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
+ 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
+ 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; };
+ 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
+ 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
+ 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
+ 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 8B4B28D02F6704280045FA19 /* Hutch */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 8B4B28D32F6704280045FA19 /* Hutch */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */,
+ );
+ path = Hutch;
+ sourceTree = "<group>";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 8B4B28CE2F6704280045FA19 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 8B4B28C82F6704280045FA19 = {
+ isa = PBXGroup;
+ children = (
+ 8B2F89632F69DEB900FC0253 /* LICENSE */,
+ 8B2F89642F69DEB900FC0253 /* README.md */,
+ 8B2F89652F69DEB900FC0253 /* SECURITY.md */,
+ 8B2F89612F69DEA900FC0253 /* TODO.md */,
+ 8B4B28D32F6704280045FA19 /* Hutch */,
+ 8B4B28D22F6704280045FA19 /* Products */,
+ );
+ sourceTree = "<group>";
+ };
+ 8B4B28D22F6704280045FA19 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 8B4B28D12F6704280045FA19 /* Hutch.app */,
+ );
+ name = Products;
+ sourceTree = "<group>";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 8B4B28D02F6704280045FA19 /* Hutch */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */;
+ buildPhases = (
+ 8B4B28CD2F6704280045FA19 /* Sources */,
+ 8B4B28CE2F6704280045FA19 /* Frameworks */,
+ 8B4B28CF2F6704280045FA19 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 8B4B28D32F6704280045FA19 /* Hutch */,
+ );
+ name = Hutch;
+ packageProductDependencies = (
+ );
+ productName = Hutch;
+ productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 8B4B28C92F6704280045FA19 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2630;
+ LastUpgradeCheck = 2630;
+ TargetAttributes = {
+ 8B4B28D02F6704280045FA19 = {
+ CreatedOnToolsVersion = 26.3;
+ };
+ };
+ };
+ buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 8B4B28C82F6704280045FA19;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 8B4B28D02F6704280045FA19 /* Hutch */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 8B4B28CF2F6704280045FA19 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */,
+ 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
+ 8B2F89672F69DEB900FC0253 /* README.md in Resources */,
+ 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 8B4B28CD2F6704280045FA19 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 8B4B28DA2F6704290045FA19 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 8B4B28DB2F6704290045FA19 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.2;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 8B4B28DD2F6704290045FA19 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Hutch/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Hutch;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 8B4B28DE2F6704290045FA19 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_PREVIEWS = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = Hutch/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Hutch;
+ INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ IPHONEOS_DEPLOYMENT_TARGET = 17.6;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8B4B28DA2F6704290045FA19 /* Debug */,
+ 8B4B28DB2F6704290045FA19 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8B4B28DD2F6704290045FA19 /* Debug */,
+ 8B4B28DE2F6704290045FA19 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
+}
diff --git a/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+ version = "1.0">
+ <FileRef
+ location = "self:">
+ </FileRef>
+</Workspace>
diff --git a/Hutch/API_REFERENCE.md b/Hutch/API_REFERENCE.md
new file mode 100644
index 0000000..909316c
--- /dev/null
+++ b/Hutch/API_REFERENCE.md
@@ -0,0 +1,147 @@
+# Sourcehut API Reference
+
+This document provides context and references for working with Sourcehut's APIs.
+
+## Official Documentation
+
+The official Sourcehut API documentation can be found at:
+- **https://man.sr.ht** - Contains links to API reference pages for all *.sr.ht services
+
+## Services and Their APIs
+
+### git.sr.ht (Git Repositories)
+- **GraphQL API**: https://git.sr.ht/graphql
+- **REST API**: https://git.sr.ht/api
+- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write)
+
+### todo.sr.ht (Ticket Tracking)
+- **GraphQL API**: https://todo.sr.ht/graphql
+- **REST API**: https://todo.sr.ht/api
+- **Scope required**: `TICKETS:RO` (read-only), `TICKETS:RW` (read-write)
+
+### hg.sr.ht (Mercurial Repositories)
+- **GraphQL API**: https://hg.sr.ht/graphql
+- **REST API**: https://hg.sr.ht/api
+- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write)
+
+### lists.sr.ht (Mailing Lists)
+- **GraphQL API**: https://lists.sr.ht/graphql
+- **REST API**: https://lists.sr.ht/api
+- **Scope required**: `LISTS:RO` (read-only), `LISTS:RW` (read-write)
+
+### builds.sr.ht (CI/CD)
+- **GraphQL API**: https://builds.sr.ht/graphql
+- **REST API**: https://builds.sr.ht/api
+- **Scope required**: `BUILDS:RO` (read-only), `BUILDS:RW` (read-write)
+
+### meta.sr.ht (User Accounts)
+- **GraphQL API**: https://meta.sr.ht/graphql
+- **REST API**: https://meta.sr.ht/api
+- **Scope required**: `ACCOUNT:RO` (read-only), `ACCOUNT:RW` (read-write)
+
+## Authentication
+
+All API requests require a **Personal Access Token** with the appropriate scopes.
+
+- **Token creation**: https://meta.sr.ht/oauth2/personal-token
+- **Token format**: `Bearer <your-token>` in the Authorization header
+
+## GraphQL Conventions
+
+### Common Types
+
+```graphql
+# Cursor-based pagination
+type Query {
+ items(cursor: Cursor, filter: Filter): ItemsPage
+}
+
+type ItemsPage {
+ results: [Item!]!
+ cursor: Cursor
+}
+
+# Filter structure (varies by service)
+input Filter {
+ search: String
+ # ... other service-specific filters
+}
+```
+
+### Error Handling
+
+Sourcehut APIs return GraphQL errors in the following format:
+
+```json
+{
+ "data": null,
+ "errors": [
+ {
+ "message": "Error message",
+ "path": ["query", "field"],
+ "extensions": {
+ "code": "ERROR_CODE"
+ }
+ }
+ ]
+}
+```
+
+## Rate Limiting
+
+- **Rate limits**: Vary by service and authentication status
+- **Headers**: Check `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
+- **Unauthenticated**: Lower limits (typically 60 requests/hour)
+- **Authenticated**: Higher limits (typically 5000 requests/hour)
+
+## Development Notes
+
+### API Exploration
+
+Use GraphiQL interfaces available at each service's `/graphql` endpoint to:
+- Explore the schema
+- Test queries
+- Understand available fields and types
+
+### Common Issues
+
+1. **Filtering**: Some services may not support all filter operations. Check the schema.
+2. **Pagination**: Always handle `cursor` properly for pagination.
+3. **Caching**: Sourcehut APIs may have aggressive caching. Use cache headers appropriately.
+
+### Example Query Structure
+
+```graphql
+query {
+ repositories(cursor: $cursor, filter: $filter) {
+ results {
+ id
+ name
+ description
+ # ... other fields
+ }
+ cursor
+ }
+}
+
+# Variables
+{
+ "filter": {
+ "search": "query string"
+ }
+}
+```
+
+## Troubleshooting
+
+1. **401 Unauthorized**: Check token scopes and expiration
+2. **403 Forbidden**: Verify you have access to the requested resource
+3. **429 Too Many Requests**: Implement proper rate limiting in your client
+4. **500 Internal Server Error**: Check if the API is temporarily down
+
+## Additional Resources
+
+- Sourcehut API Status: https://status.sr.ht
+- Sourcehut Meta (announcements): https://meta.sr.ht
+- Sourcehut GitHub Mirror: https://github.com/sourcehut
+- IRC: #sourcehut on Libera.Chat \ No newline at end of file
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
new file mode 100644
index 0000000..624c78d
--- /dev/null
+++ b/Hutch/App/AppState.swift
@@ -0,0 +1,222 @@
+import Foundation
+import SwiftUI
+import WebKit
+
+/// Central application state shared across the view hierarchy.
+@Observable
+@MainActor
+final class AppState {
+
+ enum AuthPhase {
+ /// App just launched, checking for an existing token.
+ case launching
+ /// No valid token — show the token entry screen.
+ case unauthenticated
+ /// Token validated, user is signed in.
+ case authenticated
+ }
+
+ // MARK: - Authentication
+
+ private(set) var authPhase: AuthPhase = .launching
+
+ /// Convenience for views that need a simple bool.
+ var isAuthenticated: Bool {
+ authPhase == .authenticated && currentUser != nil
+ }
+
+ // MARK: - Current user (populated after successful validation)
+
+ private(set) var currentUser: User?
+
+ // MARK: - Networking
+
+ let client: SRHTClient
+
+ // MARK: - Deep link pending navigation
+
+ /// Set by the deep link handler; consumed by RootView to drive navigation.
+ var pendingDeepLink: DeepLink?
+
+ // MARK: - Init
+
+ init() {
+ let token = KeychainHelper.loadToken()
+ self.client = SRHTClient(token: token)
+ }
+
+ // MARK: - Launch validation
+
+ /// Called once at app launch. If a token exists in Keychain, validates it
+ /// silently. On failure, clears the token and falls through to unauthenticated.
+ func validateOnLaunch() async {
+ guard client.hasToken else {
+ authPhase = .unauthenticated
+ return
+ }
+
+ do {
+ let user = try await fetchMe()
+ currentUser = user
+ authPhase = .authenticated
+ } catch {
+ try? KeychainHelper.deleteToken()
+ client.setToken(nil)
+ currentUser = nil
+ authPhase = .unauthenticated
+ }
+ }
+
+ // MARK: - Token management
+
+ /// Validate a new token by querying meta.sr.ht, then persist it.
+ /// Throws on network/GraphQL errors so the caller can display the message.
+ func connect(with token: String) async throws {
+ // Temporarily set the token so the client can use it for the request.
+ client.setToken(token)
+
+ do {
+ let user = try await fetchMe()
+ try KeychainHelper.saveToken(token)
+ currentUser = user
+ authPhase = .authenticated
+ } catch {
+ // Roll back — don't leave an invalid token in the client.
+ client.setToken(nil)
+ throw error
+ }
+ }
+
+ func signOut() async {
+ clearSessionState()
+ URLCache.shared.removeAllCachedResponses()
+ HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
+ await clearWebData()
+ clearWebContentRenderCaches()
+ authPhase = .unauthenticated
+ }
+
+ func resetAppData() async {
+ clearSessionState()
+
+ if let bundleIdentifier = Bundle.main.bundleIdentifier {
+ UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
+ }
+ URLCache.shared.removeAllCachedResponses()
+ HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
+ await clearWebData()
+ clearWebContentRenderCaches()
+
+ authPhase = .unauthenticated
+ }
+
+ // MARK: - Deep link resolution
+
+ /// Resolve a repository by owner and name for deep linking.
+ func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
+ let result = try await client.execute(
+ service: .git,
+ query: Self.repoLookupQuery,
+ variables: ["owner": owner, "name": name],
+ responseType: RepoLookupResponse.self
+ )
+ return result.user.repository
+ }
+
+ /// Resolve a tracker by owner and name for deep linking.
+ func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackerLookupQuery,
+ variables: ["owner": owner, "name": name],
+ responseType: TrackerLookupResponse.self
+ )
+ return result.user.tracker
+ }
+
+ // MARK: - Private
+
+ private static let meQuery = """
+ {
+ me {
+ id
+ username
+ canonicalName
+ email
+ avatar
+ }
+ }
+ """
+
+ private struct MeResponse: Decodable {
+ let me: User
+ }
+
+ private func fetchMe() async throws -> User {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.meQuery,
+ responseType: MeResponse.self
+ )
+ return result.me
+ }
+
+ // MARK: - Deep link queries
+
+ private static let repoLookupQuery = """
+ query repoLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ repository(name: $name) {
+ id rid name description visibility updated
+ owner { canonicalName }
+ HEAD { name target }
+ }
+ }
+ }
+ """
+
+ private struct RepoLookupResponse: Decodable, Sendable {
+ let user: RepoLookupUser
+ }
+
+ private struct RepoLookupUser: Decodable, Sendable {
+ let repository: RepositorySummary
+ }
+
+ private static let trackerLookupQuery = """
+ query trackerLookup($owner: String!, $name: String!) {
+ user(username: $owner) {
+ tracker(name: $name) {
+ id rid name description visibility updated
+ owner { canonicalName }
+ }
+ }
+ }
+ """
+
+ private struct TrackerLookupResponse: Decodable, Sendable {
+ let user: TrackerLookupUser
+ }
+
+ private struct TrackerLookupUser: Decodable, Sendable {
+ let tracker: TrackerSummary
+ }
+
+ private func clearSessionState() {
+ try? KeychainHelper.deleteAll()
+ client.setToken(nil)
+ client.responseCache.clear()
+ currentUser = nil
+ pendingDeepLink = nil
+ }
+
+ private func clearWebData() async {
+ await withCheckedContinuation { continuation in
+ let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
+ let since = Date(timeIntervalSince1970: 0)
+ WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
+ continuation.resume()
+ }
+ }
+ }
+}
diff --git a/Hutch/App/DeepLink.swift b/Hutch/App/DeepLink.swift
new file mode 100644
index 0000000..a4b8269
--- /dev/null
+++ b/Hutch/App/DeepLink.swift
@@ -0,0 +1,54 @@
+import Foundation
+
+/// Represents a parsed `hutch://` deep link.
+enum DeepLink: Equatable {
+ /// hutch://git/<owner>/<repo>
+ case repository(owner: String, repo: String)
+ /// hutch://todo/<owner>/<tracker>/<ticketId>
+ case ticket(owner: String, tracker: String, ticketId: Int)
+ /// hutch://builds/<jobId>
+ case build(jobId: Int)
+
+ /// Attempt to parse a URL into a DeepLink.
+ /// Expected format: hutch://<path>
+ init?(url: URL) {
+ guard url.scheme == "hutch" else { return nil }
+
+ // url.host gives the first path component for opaque URLs;
+ // use standardized path components from the full string.
+ let components = url.pathComponents(fromScheme: "hutch")
+
+ switch components.first {
+ case "git" where components.count >= 3:
+ let owner = components[1]
+ let repo = components[2]
+ self = .repository(owner: owner, repo: repo)
+
+ case "todo" where components.count >= 4:
+ let owner = components[1]
+ let tracker = components[2]
+ guard let ticketId = Int(components[3]) else { return nil }
+ self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
+
+ case "builds" where components.count >= 2:
+ guard let jobId = Int(components[1]) else { return nil }
+ self = .build(jobId: jobId)
+
+ default:
+ return nil
+ }
+ }
+}
+
+private extension URL {
+ /// Parse path components from a custom-scheme URL.
+ /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`.
+ func pathComponents(fromScheme scheme: String) -> [String] {
+ // Remove scheme prefix and split by "/"
+ var str = absoluteString
+ if str.hasPrefix("\(scheme)://") {
+ str = String(str.dropFirst("\(scheme)://".count))
+ }
+ return str.split(separator: "/").map(String.init)
+ }
+}
diff --git a/Hutch/App/HutchApp.swift b/Hutch/App/HutchApp.swift
new file mode 100644
index 0000000..fda0f06
--- /dev/null
+++ b/Hutch/App/HutchApp.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+@main
+struct HutchApp: App {
+ @State private var appState = AppState()
+ @State private var networkMonitor = NetworkMonitor()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environment(appState)
+ .environment(networkMonitor)
+ .onOpenURL { url in
+ if let link = DeepLink(url: url) {
+ appState.pendingDeepLink = link
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
new file mode 100644
index 0000000..7af162d
--- /dev/null
+++ b/Hutch/App/RootView.swift
@@ -0,0 +1,197 @@
+import SwiftUI
+
+/// The root view of the app. Shows a TabView when authenticated, or a
+/// full-screen sheet for token entry on first launch.
+struct RootView: View {
+ @Environment(AppState.self) private var appState
+
+ enum Tab: Hashable {
+ case repositories
+ case builds
+ case tickets
+ case settings
+ }
+
+ @State private var selectedTab: Tab = .repositories
+ @State private var repoPath = NavigationPath()
+ @State private var buildsPath = NavigationPath()
+ @State private var ticketsPath = NavigationPath()
+ @State private var isResolvingDeepLink = false
+
+ var body: some View {
+ Group {
+ switch appState.authPhase {
+ case .launching:
+ ProgressView("Connecting…")
+ .task {
+ await appState.validateOnLaunch()
+ }
+
+ case .unauthenticated:
+ // Full-screen token entry that cannot be dismissed.
+ TokenEntryView()
+
+ case .authenticated:
+ tabContent
+ }
+ }
+ .onChange(of: appState.pendingDeepLink) { _, newValue in
+ consumePendingDeepLinkIfPossible(newValue)
+ }
+ .onChange(of: appState.authPhase) { _, newPhase in
+ handleAuthPhaseChange(newPhase)
+ }
+ }
+
+ // MARK: - Tab View
+
+ private var tabContent: some View {
+ TabView(selection: $selectedTab) {
+ NavigationStack(path: $repoPath) {
+ RepositoryListView()
+ }
+ .tag(Tab.repositories)
+ .tabItem {
+ Label("Repositories", systemImage: "book.closed")
+ }
+
+ NavigationStack(path: $buildsPath) {
+ BuildListView()
+ // Int destination used by deep links (hutch://builds/<id>).
+ // JobSummary destination is registered inside BuildListView.
+ .navigationDestination(for: Int.self) { jobId in
+ BuildDetailView(jobId: jobId)
+ }
+ }
+ .tag(Tab.builds)
+ .tabItem {
+ Label("Builds", systemImage: "hammer")
+ }
+
+ NavigationStack(path: $ticketsPath) {
+ TrackerListView()
+ // Deep link destination for jumping straight to a ticket.
+ .navigationDestination(for: TicketDeepLinkTarget.self) { target in
+ TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
+ }
+ }
+ .tag(Tab.tickets)
+ .tabItem {
+ Label("Tickets", systemImage: "ticket")
+ }
+
+ SettingsView()
+ .tag(Tab.settings)
+ .tabItem {
+ Label("Settings", systemImage: "gear")
+ }
+ }
+ .overlay {
+ if isResolvingDeepLink {
+ ZStack {
+ Color.black.opacity(0.3)
+ .ignoresSafeArea()
+ ProgressView("Opening link…")
+ .padding()
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+ }
+ }
+ }
+
+ // MARK: - Deep Link Handling
+
+ private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
+ switch newPhase {
+ case .launching:
+ break
+ case .unauthenticated:
+ repoPath = NavigationPath()
+ buildsPath = NavigationPath()
+ ticketsPath = NavigationPath()
+ selectedTab = .repositories
+ isResolvingDeepLink = false
+ case .authenticated:
+ consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
+ }
+ }
+
+ private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
+ guard appState.isAuthenticated, let link else { return }
+ handleDeepLink(link)
+ appState.pendingDeepLink = nil
+ }
+
+ private func handleDeepLink(_ link: DeepLink) {
+ guard appState.isAuthenticated else { return }
+
+ switch link {
+ case .repository(let owner, let repo):
+ resolveRepositoryLink(owner: owner, repo: repo)
+
+ case .build(let jobId):
+ // Reset the builds navigation and push the detail
+ buildsPath = NavigationPath()
+ selectedTab = .builds
+ // Defer the push slightly so the tab switch takes effect
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ buildsPath.append(jobId)
+ }
+
+ case .ticket(let owner, let tracker, let ticketId):
+ resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
+ }
+ }
+
+ private func resolveRepositoryLink(owner: String, repo: String) {
+ isResolvingDeepLink = true
+ Task {
+ defer { isResolvingDeepLink = false }
+ do {
+ let summary = try await appState.resolveRepository(owner: owner, name: repo)
+ repoPath = NavigationPath()
+ selectedTab = .repositories
+ try? await Task.sleep(for: .milliseconds(100))
+ repoPath.append(summary)
+ } catch {
+ // Silently fail — the repo may not exist or be inaccessible
+ }
+ }
+ }
+
+ private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
+ isResolvingDeepLink = true
+ Task {
+ defer { isResolvingDeepLink = false }
+ do {
+ let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
+ ticketsPath = NavigationPath()
+ selectedTab = .tickets
+ try? await Task.sleep(for: .milliseconds(100))
+ ticketsPath.append(trackerSummary)
+ try? await Task.sleep(for: .milliseconds(100))
+ ticketsPath.append(TicketDeepLinkTarget(
+ ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
+ trackerName: trackerSummary.name,
+ trackerId: trackerSummary.id,
+ trackerRid: trackerSummary.rid,
+ ticketId: ticketId
+ ))
+ } catch {
+ // Silently fail
+ }
+ }
+ }
+}
+
+// MARK: - Ticket Deep Link Navigation Target
+
+/// Hashable wrapper to push a ticket detail view from a deep link.
+struct TicketDeepLinkTarget: Hashable {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+}
diff --git a/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json b/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..eb87897
--- /dev/null
+++ b/Hutch/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/1.png b/Hutch/Assets.xcassets/AppIcon.appiconset/1.png
new file mode 100644
index 0000000..cc1ce81
--- /dev/null
+++ b/Hutch/Assets.xcassets/AppIcon.appiconset/1.png
Binary files differ
diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/2.png b/Hutch/Assets.xcassets/AppIcon.appiconset/2.png
new file mode 100644
index 0000000..89e0d2b
--- /dev/null
+++ b/Hutch/Assets.xcassets/AppIcon.appiconset/2.png
Binary files differ
diff --git a/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json b/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..5e229c5
--- /dev/null
+++ b/Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,37 @@
+{
+ "images" : [
+ {
+ "filename" : "1.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "filename" : "2.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Hutch/Assets.xcassets/Contents.json b/Hutch/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Hutch/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Hutch/Extensions/Date+Relative.swift b/Hutch/Extensions/Date+Relative.swift
new file mode 100644
index 0000000..f06ba59
--- /dev/null
+++ b/Hutch/Extensions/Date+Relative.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+extension Date {
+ /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025".
+ var relativeDescription: String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .abbreviated
+ return formatter.localizedString(for: self, relativeTo: .now)
+ }
+}
diff --git a/Hutch/Extensions/DateFormatter+SRHT.swift b/Hutch/Extensions/DateFormatter+SRHT.swift
new file mode 100644
index 0000000..c017b11
--- /dev/null
+++ b/Hutch/Extensions/DateFormatter+SRHT.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+extension DateFormatter {
+ /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC).
+ static let srht: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ return formatter
+ }()
+
+ /// Fallback formatter that also accepts fractional seconds.
+ static let srhtFractional: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "UTC")
+ return formatter
+ }()
+}
+
+extension JSONDecoder.DateDecodingStrategy {
+ /// Tries the primary sr.ht format first, then falls back to fractional seconds.
+ static let srhtFlexible: JSONDecoder.DateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let string = try container.decode(String.self)
+ if let date = DateFormatter.srht.date(from: string) {
+ return date
+ }
+ if let date = DateFormatter.srhtFractional.date(from: string) {
+ return date
+ }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Cannot decode date string: \(string)"
+ )
+ }
+}
diff --git a/Hutch/Extensions/ErrorViews.swift b/Hutch/Extensions/ErrorViews.swift
new file mode 100644
index 0000000..086c0de
--- /dev/null
+++ b/Hutch/Extensions/ErrorViews.swift
@@ -0,0 +1,159 @@
+import SwiftUI
+
+// MARK: - SRHTErrorBanner ViewModifier
+
+/// Displays a dismissible error banner at the top of the screen.
+/// Attach to any view with `.srhtErrorBanner(error:)`.
+struct SRHTErrorBanner: ViewModifier {
+ @Binding var error: String?
+
+ func body(content: Content) -> some View {
+ content
+ .overlay(alignment: .top) {
+ if let message = error {
+ banner(message)
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+ }
+ .animation(.easeInOut(duration: 0.3), value: error)
+ }
+
+ @ViewBuilder
+ private func banner(_ message: String) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.white)
+
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(.white)
+ .lineLimit(3)
+
+ Spacer()
+
+ Button {
+ error = nil
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.white.opacity(0.8))
+ }
+ }
+ .padding(12)
+ .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
+ .padding(.horizontal, 12)
+ .padding(.top, 4)
+ }
+}
+
+extension View {
+ /// Attach an error banner that shows at the top when `error` is non-nil.
+ func srhtErrorBanner(error: Binding<String?>) -> some View {
+ modifier(SRHTErrorBanner(error: error))
+ }
+}
+
+// MARK: - Shared Screen States
+
+struct SRHTLoadingStateView: View {
+ let message: String
+
+ var body: some View {
+ VStack(spacing: 12) {
+ ProgressView()
+ Text(message)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+struct SRHTErrorStateView: View {
+ let title: String
+ let message: String
+ let retryAction: (() async -> Void)?
+
+ var body: some View {
+ ContentUnavailableView {
+ SwiftUI.Label(title, systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(message)
+ } actions: {
+ if let retryAction {
+ Button("Retry") {
+ Task { await retryAction() }
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+ }
+}
+
+// MARK: - NoConnectionView
+
+/// Empty state view shown when the device is offline. Includes a retry button.
+struct NoConnectionView: View {
+ var retryAction: () async -> Void
+
+ var body: some View {
+ ContentUnavailableView {
+ SwiftUI.Label("No Connection", systemImage: "wifi.slash")
+ } description: {
+ Text("Check your internet connection and try again.")
+ } actions: {
+ Button {
+ Task { await retryAction() }
+ } label: {
+ Text("Retry")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+ }
+}
+
+// MARK: - Connectivity Overlay
+
+/// ViewModifier that shows NoConnectionView when the device is offline and
+/// there is no content to display. When content exists, shows a subtle
+/// offline indicator instead.
+struct ConnectivityOverlay: ViewModifier {
+ @Environment(NetworkMonitor.self) private var networkMonitor
+ let hasContent: Bool
+ var retryAction: () async -> Void
+
+ func body(content: Content) -> some View {
+ content
+ .overlay {
+ if !networkMonitor.isConnected, !hasContent {
+ NoConnectionView(retryAction: retryAction)
+ }
+ }
+ .safeAreaInset(edge: .bottom) {
+ if !networkMonitor.isConnected, hasContent {
+ offlineBadge
+ }
+ }
+ }
+
+ private var offlineBadge: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "wifi.slash")
+ .font(.caption2)
+ Text("Offline — showing cached data")
+ .font(.caption2)
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(.orange.gradient, in: Capsule())
+ .padding(.bottom, 4)
+ }
+}
+
+extension View {
+ /// Overlay a no-connection view when offline with no content, or a
+ /// subtle offline badge when showing cached data.
+ func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View {
+ modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction))
+ }
+}
diff --git a/Hutch/Extensions/Int+ByteFormatting.swift b/Hutch/Extensions/Int+ByteFormatting.swift
new file mode 100644
index 0000000..a17ece4
--- /dev/null
+++ b/Hutch/Extensions/Int+ByteFormatting.swift
@@ -0,0 +1,8 @@
+import Foundation
+
+extension Int {
+ /// Human-readable byte size string (e.g. "1.2 MB", "340 KB").
+ var formattedByteCount: String {
+ ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file)
+ }
+}
diff --git a/Hutch/Extensions/KeychainHelper.swift b/Hutch/Extensions/KeychainHelper.swift
new file mode 100644
index 0000000..01f8e5a
--- /dev/null
+++ b/Hutch/Extensions/KeychainHelper.swift
@@ -0,0 +1,103 @@
+import Foundation
+@preconcurrency import Security
+
+enum KeychainHelper: Sendable {
+
+ private static let service = "net.cleberg.Hutch"
+ private static let tokenAccount = "srht-access-token"
+
+ // MARK: - Save
+
+ static func saveToken(_ token: String) throws {
+ guard let data = token.data(using: .utf8) else {
+ throw KeychainError.encodingFailed
+ }
+
+ // Delete any existing item first
+ let deleteQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount
+ ]
+ SecItemDelete(deleteQuery as CFDictionary)
+
+ let addQuery: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount,
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ ]
+
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw KeychainError.saveFailed(status)
+ }
+ }
+
+ // MARK: - Load
+
+ static func loadToken() -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let token = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return token
+ }
+
+ // MARK: - Delete
+
+ static func deleteToken() throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: tokenAccount
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.deleteFailed(status)
+ }
+ }
+
+ static func deleteAll() throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.deleteFailed(status)
+ }
+ }
+}
+
+enum KeychainError: LocalizedError {
+ case encodingFailed
+ case saveFailed(OSStatus)
+ case deleteFailed(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .encodingFailed:
+ "Failed to encode token data."
+ case .saveFailed(let status):
+ "Keychain save failed with status \(status)."
+ case .deleteFailed(let status):
+ "Keychain delete failed with status \(status)."
+ }
+ }
+}
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index 53556de..f63352b 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -9,6 +9,41 @@ enum SRHTWebURL {
)
}
+ static func commit(repository: RepositorySummary, commitId: String) -> URL? {
+ userScopedURL(
+ host: "\(repository.service.rawValue).sr.ht",
+ ownerCanonicalName: repository.owner.canonicalName,
+ pathComponents: [repository.name, commitPathComponent(for: repository.service), commitId]
+ )
+ }
+
+ static func file(repository: RepositorySummary, revspec: String, path: String) -> URL? {
+ switch repository.service {
+ case .git:
+ return userScopedURL(
+ host: "git.sr.ht",
+ ownerCanonicalName: repository.owner.canonicalName,
+ pathComponents: [repository.name, "tree", revspec, "item"] + pathComponents(from: path)
+ )
+ case .hg:
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "hg.sr.ht"
+
+ let ownerUsername = username(from: repository.owner.canonicalName)
+ let encodedRepository = repository.name.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? repository.name
+ let encodedPath = path.split(separator: "/").map {
+ String($0).addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? String($0)
+ }.joined(separator: "/")
+
+ components.percentEncodedPath = "/~\(ownerUsername)/\(encodedRepository)/browse/\(encodedPath)"
+ components.queryItems = [URLQueryItem(name: "rev", value: revspec)]
+ return components.url
+ default:
+ return nil
+ }
+ }
+
static func build(jobId: Int, ownerCanonicalName: String) -> URL? {
userScopedURL(
host: "builds.sr.ht",
@@ -35,7 +70,7 @@ enum SRHTWebURL {
static func profile(canonicalName: String) -> URL? {
userScopedURL(
- host: "meta.sr.ht",
+ host: "sr.ht",
ownerCanonicalName: canonicalName,
pathComponents: []
)
@@ -76,9 +111,22 @@ enum SRHTWebURL {
return canonicalName
}
+ private static func commitPathComponent(for service: SRHTService) -> String {
+ switch service {
+ case .hg:
+ return "rev"
+ default:
+ return "commit"
+ }
+ }
+
private static let pathComponentCharacterSet: CharacterSet = {
var characterSet = CharacterSet.urlPathAllowed
characterSet.remove(charactersIn: "/")
return characterSet
}()
+
+ private static func pathComponents(from path: String) -> [String] {
+ path.split(separator: "/").map(String.init)
+ }
}
diff --git a/Hutch/Info.plist b/Hutch/Info.plist
new file mode 100644
index 0000000..8360406
--- /dev/null
+++ b/Hutch/Info.plist
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>CFBundleDisplayName</key>
+ <string>Hutch</string>
+ <key>CFBundleName</key>
+ <string>Hutch</string>
+ <key>CFBundleURLTypes</key>
+ <array>
+ <dict>
+ <key>CFBundleTypeRole</key>
+ <string>Editor</string>
+ <key>CFBundleURLName</key>
+ <string>.cleberg.Hutch</string>
+ <key>CFBundleURLSchemes</key>
+ <array>
+ <string>hutch</string>
+ </array>
+ </dict>
+ </array>
+</dict>
+</plist>
diff --git a/Hutch/Models/ArtifactInfo.swift b/Hutch/Models/ArtifactInfo.swift
new file mode 100644
index 0000000..dbe9d96
--- /dev/null
+++ b/Hutch/Models/ArtifactInfo.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// An artifact with its parent reference name, used in the artifacts tab.
+struct ArtifactInfo: Codable, Sendable, Identifiable {
+ let id: Int
+ let filename: String
+ let checksum: String
+ let size: Int
+ let url: URL
+}
+
+struct ArtifactPage: Codable, Sendable {
+ let results: [ArtifactInfo]
+ let cursor: String?
+}
+
+/// A reference (tag) that has associated artifacts.
+struct ReferenceWithArtifacts: Codable, Sendable, Identifiable {
+ var id: String { name }
+ let name: String
+ let artifacts: [ArtifactInfo]
+}
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
new file mode 100644
index 0000000..6694c35
--- /dev/null
+++ b/Hutch/Models/Builds.swift
@@ -0,0 +1,111 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Status of a build job.
+enum JobStatus: String, Codable, Sendable {
+ case pending = "PENDING"
+ case queued = "QUEUED"
+ case running = "RUNNING"
+ case success = "SUCCESS"
+ case failed = "FAILED"
+ case cancelled = "CANCELLED"
+ case timeout = "TIMEOUT"
+
+ /// Whether the job can be cancelled.
+ var isCancellable: Bool {
+ switch self {
+ case .pending, .queued, .running: true
+ default: false
+ }
+ }
+}
+
+/// Status of a single build task within a job.
+enum TaskStatus: String, Codable, Sendable {
+ case pending = "PENDING"
+ case running = "RUNNING"
+ case success = "SUCCESS"
+ case failed = "FAILED"
+ case skipped = "SKIPPED"
+}
+
+// MARK: - Build Task
+
+/// A single task within a build job.
+struct BuildTask: Codable, Sendable, Identifiable {
+ var id: String { name }
+ let name: String
+ let status: TaskStatus
+ let log: BuildLog?
+}
+
+// MARK: - Job Summary (for list view)
+
+/// Lightweight job model matching the fields returned by the jobs list query.
+struct JobSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let tasks: [JobTaskSummary]
+
+ /// Number of completed (success) tasks.
+ var completedTaskCount: Int {
+ tasks.filter { $0.status == .success }.count
+ }
+
+ /// Display label: note if available, otherwise tags joined.
+ var displayLabel: String {
+ if let note, !note.isEmpty {
+ return note
+ }
+ if !tags.isEmpty {
+ return tags.joined(separator: ", ")
+ }
+ return "Job #\(id)"
+ }
+}
+
+/// Minimal task info for the list query.
+struct JobTaskSummary: Codable, Sendable, Hashable {
+ let name: String
+ let status: TaskStatus
+}
+
+// MARK: - Job Detail (for detail view)
+
+/// Full job model with all fields for the detail view.
+struct JobDetail: Codable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let status: JobStatus
+ let note: String?
+ let tags: [String]
+ let visibility: Visibility?
+ let image: String?
+ let manifest: String?
+ let tasks: [BuildTask]
+ let log: BuildLog?
+ let owner: Entity
+}
+
+/// The log associated with a build job.
+struct BuildLog: Codable, Sendable {
+ let fullURL: String
+}
+
+// MARK: - Job Group
+
+/// A group of related build jobs.
+struct JobGroup: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let note: String?
+ let jobs: [JobSummary]
+}
diff --git a/Hutch/Models/CommitDetail.swift b/Hutch/Models/CommitDetail.swift
new file mode 100644
index 0000000..c95199d
--- /dev/null
+++ b/Hutch/Models/CommitDetail.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+/// Full commit detail returned by the revparse_single query.
+struct CommitDetail: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let author: CommitAuthor
+ let committer: CommitAuthor
+ let message: String
+ let diff: String?
+ let trailers: [CommitTrailer]
+ let parents: [ParentCommit]
+ let tree: CommitTree?
+
+ /// First line of the commit message.
+ var title: String {
+ message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
+ }
+
+ /// The commit message body (everything after the first line), if any.
+ var body: String? {
+ guard let newlineIndex = message.firstIndex(of: "\n") else { return nil }
+ let body = message[message.index(after: newlineIndex)...]
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return body.isEmpty ? nil : body
+ }
+}
+
+struct CommitTrailer: Codable, Sendable, Identifiable {
+ var id: String { "\(name):\(value)" }
+ let name: String
+ let value: String
+}
+
+struct ParentCommit: Codable, Sendable, Identifiable, Hashable {
+ let id: String
+ let shortId: String
+ let author: ParentAuthor
+}
+
+struct ParentAuthor: Codable, Sendable, Hashable {
+ let name: String
+}
+
+struct CommitTree: Codable, Sendable {
+ let entries: CommitTreeEntries
+}
+
+struct CommitTreeEntries: Codable, Sendable {
+ let results: [CommitTreeEntry]
+ let cursor: String?
+}
+
+struct CommitTreeEntry: Codable, Sendable, Identifiable {
+ let id: String
+ let name: String
+ let mode: Int
+ let object: CommitTreeObject?
+}
+
+struct CommitTreeObject: Codable, Sendable {
+ let type: String?
+ let id: String?
+ let shortId: String?
+}
diff --git a/Hutch/Models/CommitSummary.swift b/Hutch/Models/CommitSummary.swift
new file mode 100644
index 0000000..88c3e7d
--- /dev/null
+++ b/Hutch/Models/CommitSummary.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+/// Lightweight commit model for list views. Matches the subset of fields
+/// returned by the repository log query.
+struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: String
+ let shortId: String
+ let author: CommitAuthor
+ let message: String
+
+ /// First line of the commit message.
+ var title: String {
+ message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
+ }
+}
+
+/// A compact author representation used in commit list responses.
+struct CommitAuthor: Codable, Sendable, Hashable {
+ let name: String
+ let email: String?
+ let time: Date
+}
diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift
new file mode 100644
index 0000000..19ec31b
--- /dev/null
+++ b/Hutch/Models/Git.swift
@@ -0,0 +1,241 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Repository visibility level.
+enum Visibility: String, Codable, Sendable {
+ case `public` = "PUBLIC"
+ case unlisted = "UNLISTED"
+ case `private` = "PRIVATE"
+}
+
+/// Repository access mode.
+enum AccessMode: String, Codable, Sendable {
+ case ro = "RO"
+ case rw = "RW"
+}
+
+// MARK: - Entity
+
+/// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a
+/// resource (typically a user).
+struct Entity: Codable, Sendable, Hashable {
+ let canonicalName: String
+}
+
+// MARK: - Repository
+
+/// A git repository from git.sr.ht.
+struct Repository: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let readme: String?
+ let accessMode: AccessMode
+ let owner: Entity
+
+ enum CodingKeys: String, CodingKey {
+ case id, created, updated, name, description, visibility, readme
+ case accessMode = "access"
+ case owner
+ }
+}
+
+// MARK: - Signature
+
+/// A Git commit/tag signature (author or committer).
+struct Signature: Codable, Sendable {
+ let name: String
+ let email: String
+ let time: Date
+}
+
+// MARK: - Trailer
+
+/// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by").
+struct Trailer: Codable, Sendable {
+ let name: String
+ let value: String
+}
+
+// MARK: - Commit
+
+/// A Git commit from git.sr.ht.
+struct Commit: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let author: Signature
+ let committer: Signature
+ let message: String
+ let diff: String?
+ let trailers: [Trailer]
+}
+
+// MARK: - Reference
+
+/// A Git reference (branch or tag name).
+struct Reference: Codable, Sendable, Hashable {
+ let name: String
+ let target: String?
+}
+
+// MARK: - TreeEntry
+
+/// An entry in a Git tree (file or directory).
+struct TreeEntry: Codable, Sendable, Identifiable {
+ let id: String
+ let name: String
+ let mode: Int?
+ let object: GitObject?
+}
+
+/// A Git object returned by the git.sr.ht GraphQL API.
+/// The API returns `type` as "TREE", "BLOB", "COMMIT", or "TAG".
+/// Both TextBlob and BinaryBlob have type == "BLOB"; they are
+/// distinguished by the presence of the "text" key (TextBlob) vs
+/// the "content" key (BinaryBlob).
+enum GitObject: Sendable {
+ case tree(GitTree)
+ case textBlob(GitTextBlob)
+ case binaryBlob(GitBinaryBlob)
+ case unknown
+}
+
+struct GitTree: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let entries: GitTreeEntryPage?
+}
+
+struct GitTextBlob: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let text: String
+ let size: Int?
+}
+
+struct GitBinaryBlob: Codable, Sendable {
+ let id: String?
+ let shortId: String?
+ let size: Int?
+ let content: String?
+}
+
+struct GitTreeEntryPage: Codable, Sendable {
+ let results: [TreeEntry]
+ let cursor: String?
+}
+
+// MARK: - GitObject Codable
+
+extension GitObject: Codable {
+ private enum CodingKeys: String, CodingKey {
+ case type, id, shortId, entries, text, size, content
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ let type = try container.decodeIfPresent(String.self, forKey: .type)
+
+ switch type {
+ case "TREE":
+ let tree = GitTree(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ entries: try container.decodeIfPresent(GitTreeEntryPage.self, forKey: .entries)
+ )
+ self = .tree(tree)
+
+ case "BLOB":
+ // TextBlob has a "text" key; BinaryBlob does not
+ if container.contains(.text) {
+ let blob = GitTextBlob(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ text: try container.decode(String.self, forKey: .text),
+ size: try container.decodeIfPresent(Int.self, forKey: .size)
+ )
+ self = .textBlob(blob)
+ } else {
+ let blob = GitBinaryBlob(
+ id: try container.decodeIfPresent(String.self, forKey: .id),
+ shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
+ size: try container.decodeIfPresent(Int.self, forKey: .size),
+ content: try container.decodeIfPresent(String.self, forKey: .content)
+ )
+ self = .binaryBlob(blob)
+ }
+
+ default:
+ self = .unknown
+ }
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ switch self {
+ case .tree(let tree):
+ try container.encode("TREE", forKey: .type)
+ try container.encodeIfPresent(tree.id, forKey: .id)
+ try container.encodeIfPresent(tree.shortId, forKey: .shortId)
+ try container.encodeIfPresent(tree.entries, forKey: .entries)
+ case .textBlob(let blob):
+ try container.encode("BLOB", forKey: .type)
+ try container.encodeIfPresent(blob.id, forKey: .id)
+ try container.encodeIfPresent(blob.shortId, forKey: .shortId)
+ try container.encode(blob.text, forKey: .text)
+ try container.encodeIfPresent(blob.size, forKey: .size)
+ case .binaryBlob(let blob):
+ try container.encode("BLOB", forKey: .type)
+ try container.encodeIfPresent(blob.id, forKey: .id)
+ try container.encodeIfPresent(blob.shortId, forKey: .shortId)
+ try container.encodeIfPresent(blob.size, forKey: .size)
+ try container.encodeIfPresent(blob.content, forKey: .content)
+ case .unknown:
+ break
+ }
+ }
+}
+
+/// Convenience helpers for checking object type.
+extension GitObject {
+ var isTree: Bool {
+ if case .tree = self { return true }
+ return false
+ }
+
+ var treeId: String? {
+ switch self {
+ case .tree(let t): t.id
+ case .textBlob(let b): b.id
+ case .binaryBlob(let b): b.id
+ case .unknown: nil
+ }
+ }
+}
+
+// MARK: - Tag
+
+/// An annotated Git tag from git.sr.ht.
+struct Tag: Codable, Sendable, Identifiable {
+ let id: String
+ let shortId: String
+ let name: String
+ let message: String?
+ let tagger: Signature?
+}
+
+// MARK: - Artifact
+
+/// A release artifact attached to a Git tag.
+struct Artifact: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let filename: String
+ let checksum: String
+ let size: Int
+ let url: URL
+}
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
new file mode 100644
index 0000000..c0434bc
--- /dev/null
+++ b/Hutch/Models/Meta.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+// MARK: - User Profile (full)
+
+/// Extended user profile from meta.sr.ht with all fields from the `me` query.
+struct UserProfile: Codable, Sendable {
+ let username: String
+ let canonicalName: String
+ let email: String
+ let url: String?
+ let location: String?
+ let bio: String?
+ let avatar: String?
+ let userType: String?
+ let sshKeys: SSHKeyPage
+ let pgpKeys: PGPKeyPage
+ let paymentStatus: String?
+ let subscription: Subscription?
+}
+
+// MARK: - SSH Key
+
+struct SSHKey: Codable, Sendable, Identifiable {
+ let id: Int
+ let fingerprint: String
+ let comment: String?
+ let created: Date
+ let lastUsed: Date?
+}
+
+struct SSHKeyPage: Codable, Sendable {
+ let results: [SSHKey]
+ let cursor: String?
+}
+
+// MARK: - PGP Key
+
+struct PGPKey: Codable, Sendable, Identifiable {
+ let id: Int
+ let fingerprint: String
+ let created: Date
+}
+
+struct PGPKeyPage: Codable, Sendable {
+ let results: [PGPKey]
+ let cursor: String?
+}
+
+// MARK: - Subscription
+
+struct Subscription: Codable, Sendable {
+ let status: String?
+ let autorenew: Bool?
+ let interval: String?
+}
+
+// MARK: - Personal Access Token
+
+struct PersonalAccessToken: Codable, Sendable, Identifiable {
+ let id: Int
+ let issued: Date
+ let expires: Date?
+ let comment: String?
+ let grants: String?
+}
diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift
new file mode 100644
index 0000000..f4ecc6e
--- /dev/null
+++ b/Hutch/Models/RepositorySummary.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+/// Lightweight repository model matching the fields returned by the
+/// repositories list query. Avoids optionalizing all fields on the full
+/// `Repository` model.
+struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ /// GraphQL resource identifier used by `repository(rid:)` queries.
+ let rid: String
+ let service: SRHTService
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, service, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ init(
+ id: Int,
+ rid: String,
+ service: SRHTService,
+ name: String,
+ description: String?,
+ visibility: Visibility,
+ updated: Date,
+ owner: Entity,
+ head: Reference?
+ ) {
+ self.id = id
+ self.rid = rid
+ self.service = service
+ self.name = name
+ self.description = description
+ self.visibility = visibility
+ self.updated = updated
+ self.owner = owner
+ self.head = head
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = try container.decode(Int.self, forKey: .id)
+ self.rid = try container.decode(String.self, forKey: .rid)
+ self.service = try container.decodeIfPresent(SRHTService.self, forKey: .service) ?? .git
+ self.name = try container.decode(String.self, forKey: .name)
+ self.description = try container.decodeIfPresent(String.self, forKey: .description)
+ self.visibility = try container.decode(Visibility.self, forKey: .visibility)
+ self.updated = try container.decode(Date.self, forKey: .updated)
+ self.owner = try container.decode(Entity.self, forKey: .owner)
+ self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
+ }
+}
diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift
new file mode 100644
index 0000000..8a2b06c
--- /dev/null
+++ b/Hutch/Models/Todo.swift
@@ -0,0 +1,234 @@
+import Foundation
+
+// MARK: - Enums
+
+/// Status of a ticket.
+enum TicketStatus: String, Codable, Sendable, CaseIterable {
+ case reported = "REPORTED"
+ case confirmed = "CONFIRMED"
+ case inProgress = "IN_PROGRESS"
+ case pending = "PENDING"
+ case resolved = "RESOLVED"
+
+ /// Whether this status represents an open (unresolved) ticket.
+ var isOpen: Bool {
+ switch self {
+ case .reported, .confirmed, .inProgress, .pending: true
+ case .resolved: false
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .reported: "Reported"
+ case .confirmed: "Confirmed"
+ case .inProgress: "In Progress"
+ case .pending: "Pending"
+ case .resolved: "Resolved"
+ }
+ }
+}
+
+/// Resolution of a ticket.
+enum TicketResolution: String, Codable, Sendable {
+ case unresolved = "UNRESOLVED"
+ case fixed = "FIXED"
+ case implemented = "IMPLEMENTED"
+ case wontFix = "WONT_FIX"
+ case byDesign = "BY_DESIGN"
+ case invalid = "INVALID"
+ case duplicate = "DUPLICATE"
+ case notOurBug = "NOT_OUR_BUG"
+ case closed = "CLOSED"
+ case notApplicable = "NOT_APPLICABLE"
+
+ init(from decoder: Decoder) throws {
+ let value = try decoder.singleValueContainer().decode(String.self)
+ self = TicketResolution(rawValue: value) ?? .unresolved
+ }
+
+ var displayName: String {
+ switch self {
+ case .unresolved: "Unresolved"
+ case .fixed: "Fixed"
+ case .implemented: "Implemented"
+ case .wontFix: "Won't Fix"
+ case .byDesign: "By Design"
+ case .invalid: "Invalid"
+ case .duplicate: "Duplicate"
+ case .notOurBug: "Not Our Bug"
+ case .closed: "Closed"
+ case .notApplicable: "Not Applicable"
+ }
+ }
+}
+
+/// Authenticity of a ticket or comment.
+enum Authenticity: String, Codable, Sendable {
+ case authentic = "AUTHENTIC"
+ case tampered = "TAMPERED"
+ case unauthenticated = "UNAUTHENTICATED"
+}
+
+// MARK: - Label
+
+/// A label that can be applied to tickets.
+/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
+struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let name: String
+ let backgroundColor: String
+ let foregroundColor: String
+}
+
+// MARK: - Tracker
+
+/// A bug tracker from todo.sr.ht.
+struct Tracker: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let owner: Entity
+
+ static func == (lhs: Tracker, rhs: Tracker) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Tracker Summary (for list view)
+
+/// Lightweight tracker model matching the fields returned by the trackers list query.
+struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ /// GraphQL resource identifier used by `tracker(id:)` queries.
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+}
+
+// MARK: - Ticket Summary (for list view)
+
+/// Lightweight ticket model for the list query.
+struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
+ let id: Int
+ let title: String
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let created: Date
+ let submitter: Entity
+ let labels: [TicketLabel]
+ let assignees: [Entity]
+
+ static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool {
+ lhs.id == rhs.id
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+}
+
+// MARK: - Ticket Detail
+
+/// Full ticket model for the detail view.
+struct TicketDetail: Codable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+}
+
+// MARK: - Event
+
+/// A timeline event on a ticket. Each event contains one or more changes.
+struct TicketEvent: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ var changes: [EventChange]
+}
+
+/// A single change within an event, decoded from the polymorphic EventDetail
+/// interface using inline fragments.
+struct EventChange: Codable, Sendable, Identifiable {
+ let id: UUID
+
+ let eventType: String
+
+ // Comment fields
+ let author: Entity?
+ var text: String?
+ let authenticity: Authenticity?
+
+ // StatusChange fields
+ let oldStatus: TicketStatus?
+ let newStatus: TicketStatus?
+
+ // LabelUpdate fields
+ let labeler: Entity?
+ let label: EventLabel?
+
+ // Assignment fields
+ let assigner: Entity?
+ let assignee: Entity?
+
+ // Mention fields
+ // TicketMention: mentioned is a Ticket with { id }
+ // UserMention: mentioned is an Entity with { canonicalName }
+ let mentioned: MentionTarget?
+
+ // Created fields (author is shared with Comment)
+
+ private enum CodingKeys: String, CodingKey {
+ case eventType
+ case author, text, authenticity
+ case oldStatus, newStatus
+ case labeler, label
+ case assigner, assignee
+ case mentioned
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.id = UUID()
+ self.eventType = try container.decode(String.self, forKey: .eventType)
+ self.author = try container.decodeIfPresent(Entity.self, forKey: .author)
+ self.text = try container.decodeIfPresent(String.self, forKey: .text)
+ self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity)
+ self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus)
+ self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus)
+ self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler)
+ self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label)
+ self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner)
+ self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee)
+ self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned)
+ }
+}
+
+/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
+/// an Entity (with `canonicalName`) depending on the event type.
+struct MentionTarget: Codable, Sendable {
+ let id: Int?
+ let canonicalName: String?
+}
+
+/// Label info as returned within a LabelUpdate event change.
+struct EventLabel: Codable, Sendable {
+ let name: String
+}
diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift
new file mode 100644
index 0000000..a9350a3
--- /dev/null
+++ b/Hutch/Models/User.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+/// A Sourcehut user from meta.sr.ht.
+struct User: Decodable, Sendable {
+ let id: Int
+ let username: String
+ let canonicalName: String
+ let email: String
+ let avatar: String?
+}
diff --git a/Hutch/Networking/GraphQLRequest.swift b/Hutch/Networking/GraphQLRequest.swift
new file mode 100644
index 0000000..bce7b40
--- /dev/null
+++ b/Hutch/Networking/GraphQLRequest.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+/// The JSON body sent with every GraphQL request.
+struct GraphQLRequestBody: Encodable, Sendable {
+ let query: String
+ let variables: [String: AnyCodable]?
+}
+
+/// The top-level shape of every GraphQL response.
+struct GraphQLResponse<T: Decodable>: Decodable {
+ let data: T?
+ let errors: [GraphQLError]?
+}
+
+// MARK: - AnyCodable
+
+/// A type-erased `Codable` wrapper so callers can pass `[String: Any]` variables
+/// without losing type information at the encoding boundary.
+struct AnyCodable: Sendable, Encodable {
+ let value: any Sendable
+
+ init(_ value: any Sendable) {
+ self.value = value
+ }
+
+ func encode(to encoder: any Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch value {
+ case let v as String:
+ try container.encode(v)
+ case let v as Int:
+ try container.encode(v)
+ case let v as Double:
+ try container.encode(v)
+ case let v as Bool:
+ try container.encode(v)
+ case let v as [any Sendable]:
+ try container.encode(v.map { AnyCodable($0) })
+ case let v as [String: any Sendable]:
+ try container.encode(v.mapValues { AnyCodable($0) })
+ default:
+ try container.encodeNil()
+ }
+ }
+}
diff --git a/Hutch/Networking/NetworkMonitor.swift b/Hutch/Networking/NetworkMonitor.swift
new file mode 100644
index 0000000..4d5d364
--- /dev/null
+++ b/Hutch/Networking/NetworkMonitor.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Network
+
+/// Observes network connectivity using `NWPathMonitor`.
+/// Shared singleton injected into the environment.
+@Observable
+@MainActor
+final class NetworkMonitor {
+
+ private(set) var isConnected = true
+ private(set) var connectionType: ConnectionType = .unknown
+
+ enum ConnectionType: Sendable {
+ case wifi
+ case cellular
+ case wiredEthernet
+ case unknown
+ }
+
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "net.cleberg.Hutch.NetworkMonitor")
+
+ init() {
+ startMonitoring()
+ }
+
+ private func startMonitoring() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ self.isConnected = path.status == .satisfied
+ self.connectionType = self.resolveConnectionType(path)
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ private nonisolated func resolveConnectionType(_ path: NWPath) -> ConnectionType {
+ if path.usesInterfaceType(.wifi) { return .wifi }
+ if path.usesInterfaceType(.cellular) { return .cellular }
+ if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
+ return .unknown
+ }
+
+ deinit {
+ monitor.cancel()
+ }
+}
diff --git a/Hutch/Networking/Pagination.swift b/Hutch/Networking/Pagination.swift
new file mode 100644
index 0000000..8adeaee
--- /dev/null
+++ b/Hutch/Networking/Pagination.swift
@@ -0,0 +1,176 @@
+import Foundation
+
+/// The standard paginated response shape used by all sr.ht GraphQL APIs.
+/// { results: [T], cursor: String? }
+/// A null cursor means the list is exhausted.
+struct CursorPage<Element: Decodable & Sendable>: Decodable, Sendable {
+ let results: [Element]
+ let cursor: String?
+}
+
+/// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL
+/// query. Each element yielded is a single `Element` from the `results` array.
+///
+/// The sequence re-issues the query with an updated `$cursor` variable on each
+/// page until the server returns a null cursor.
+///
+/// Usage:
+/// ```swift
+/// let sequence = SRHTPaginatedSequence<Repository>(
+/// client: client,
+/// service: .git,
+/// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }",
+/// variables: nil,
+/// resultKeyPath: "me.repositories"
+/// )
+/// for try await repo in sequence {
+/// print(repo.name)
+/// }
+/// ```
+struct SRHTPaginatedSequence<Element: Decodable & Sendable>: AsyncSequence, Sendable {
+ let client: SRHTClient
+ let service: SRHTService
+ let query: String
+ let variables: [String: any Sendable]?
+ let resultKeyPath: String
+
+ func makeAsyncIterator() -> Iterator {
+ Iterator(
+ client: client,
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath
+ )
+ }
+
+ struct Iterator: AsyncIteratorProtocol {
+ private let client: SRHTClient
+ private let service: SRHTService
+ private let query: String
+ private let baseVariables: [String: any Sendable]?
+ private let resultKeyPath: String
+
+ /// Buffer of elements from the current page.
+ private var buffer: [Element] = []
+ /// Index into the current buffer.
+ private var bufferIndex = 0
+ /// The cursor for the next page. Nil means we haven't started or are done.
+ private var nextCursor: String? = nil
+ /// Whether we've exhausted all pages.
+ private var isFinished = false
+
+ init(
+ client: SRHTClient,
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]?,
+ resultKeyPath: String
+ ) {
+ self.client = client
+ self.service = service
+ self.query = query
+ self.baseVariables = variables
+ self.resultKeyPath = resultKeyPath
+ }
+
+ mutating func next() async throws -> Element? {
+ // Yield buffered elements first.
+ if bufferIndex < buffer.count {
+ let element = buffer[bufferIndex]
+ bufferIndex += 1
+ return element
+ }
+
+ // If we already know there are no more pages, stop.
+ if isFinished {
+ return nil
+ }
+
+ // Fetch the next page.
+ var vars = baseVariables ?? [:]
+ if let cursor = nextCursor {
+ vars["cursor"] = cursor
+ }
+
+ let page = try await fetchPage(variables: vars)
+
+ if let cursor = page.cursor {
+ nextCursor = cursor
+ } else {
+ isFinished = true
+ }
+
+ buffer = page.results
+ bufferIndex = 0
+
+ guard bufferIndex < buffer.count else {
+ return nil
+ }
+
+ let element = buffer[bufferIndex]
+ bufferIndex += 1
+ return element
+ }
+
+ private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage<Element> {
+ // We decode the raw JSON and navigate the key path manually,
+ // since the paginated object can be nested arbitrarily
+ // (e.g. "me.repositories" or just "repositories").
+ let raw = try await client.execute(
+ service: service,
+ query: query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RawJSON.self
+ )
+
+ // Walk the key path to find the paginated object.
+ let pathComponents = resultKeyPath.split(separator: ".").map(String.init)
+ var current = raw.value
+ for component in pathComponents {
+ guard let dict = current as? [String: Any],
+ let next = dict[component] else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)"))
+ )
+ }
+ current = next
+ }
+
+ // Re-serialize the nested object and decode as CursorPage<Element>.
+ let pageData = try JSONSerialization.data(withJSONObject: current)
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .formatted(.srht)
+ return try decoder.decode(CursorPage<Element>.self, from: pageData)
+ }
+ }
+}
+
+// MARK: - RawJSON
+
+/// A Decodable wrapper that preserves the raw JSON structure as Foundation objects
+/// so we can navigate dynamic key paths at runtime.
+struct RawJSON: Decodable, Sendable {
+ let value: Any
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if let dict = try? container.decode([String: RawJSON].self) {
+ value = dict.mapValues(\.value)
+ } else if let array = try? container.decode([RawJSON].self) {
+ value = array.map(\.value)
+ } else if let string = try? container.decode(String.self) {
+ value = string
+ } else if let int = try? container.decode(Int.self) {
+ value = int
+ } else if let double = try? container.decode(Double.self) {
+ value = double
+ } else if let bool = try? container.decode(Bool.self) {
+ value = bool
+ } else if container.decodeNil() {
+ value = NSNull()
+ } else {
+ throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
+ }
+ }
+}
diff --git a/Hutch/Networking/ResponseCache.swift b/Hutch/Networking/ResponseCache.swift
new file mode 100644
index 0000000..a6cc269
--- /dev/null
+++ b/Hutch/Networking/ResponseCache.swift
@@ -0,0 +1,33 @@
+import Foundation
+import os
+
+/// Thread-safe in-memory cache for raw GraphQL response data.
+/// Keyed by a caller-provided string (typically service name + query hash).
+final class ResponseCache: Sendable {
+
+ private let storage: OSAllocatedUnfairLock<[String: Data]>
+
+ init() {
+ self.storage = OSAllocatedUnfairLock(initialState: [:])
+ }
+
+ /// Store raw response data under a cache key.
+ func set(_ data: Data, forKey key: String) {
+ storage.withLock { $0[key] = data }
+ }
+
+ /// Retrieve cached response data. Returns nil on cache miss.
+ func get(forKey key: String) -> Data? {
+ storage.withLock { $0[key] }
+ }
+
+ /// Remove a specific entry.
+ func remove(forKey key: String) {
+ storage.withLock { _ = $0.removeValue(forKey: key) }
+ }
+
+ /// Clear all cached data (e.g. on sign-out).
+ func clear() {
+ storage.withLock { $0.removeAll() }
+ }
+}
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
new file mode 100644
index 0000000..a24fe24
--- /dev/null
+++ b/Hutch/Networking/SRHTClient.swift
@@ -0,0 +1,521 @@
+import Foundation
+import os
+
+private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
+
+/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
+private struct EmptyData: Decodable {}
+
+/// A lightweight GraphQL client for Sourcehut services.
+/// All requests require a personal access token set via ``token``.
+final class SRHTClient: Sendable {
+
+ private let session: URLSession
+ private let decoder: JSONDecoder
+ private let encoder: JSONEncoder
+
+ /// The personal access token used for `Authorization: Bearer` headers.
+ /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
+ private let _token: OSAllocatedUnfairLock<String?>
+
+ /// In-memory response cache for stale-while-revalidate pattern.
+ let responseCache = ResponseCache()
+
+ var hasToken: Bool {
+ _token.withLock { $0 != nil }
+ }
+
+ init(session: URLSession = .shared, token: String? = nil) {
+ self.session = session
+ self.decoder = JSONDecoder()
+ self.decoder.dateDecodingStrategy = .srhtFlexible
+ self.encoder = JSONEncoder()
+ self._token = OSAllocatedUnfairLock(initialState: token)
+ }
+
+ /// Update the stored token (e.g. after the user saves a new one in Keychain).
+ func setToken(_ token: String?) {
+ _token.withLock { $0 = token }
+ }
+
+ /// Execute a GraphQL query or mutation against a Sourcehut service.
+ ///
+ /// - Parameters:
+ /// - service: The target Sourcehut service (determines the endpoint URL).
+ /// - query: The GraphQL query or mutation string.
+ /// - variables: Optional dictionary of GraphQL variables.
+ /// - responseType: The expected `Decodable` type nested under `data`.
+ /// - Returns: The decoded `data` payload.
+ func execute<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ // Build request
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = GraphQLRequestBody(
+ query: query,
+ variables: variables?.mapValues { AnyCodable($0) }
+ )
+ request.httpBody = try encoder.encode(body)
+
+ // Execute
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ // Check HTTP status
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ // Try to extract GraphQL errors from the response body even on non-2xx
+ if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
+ let errors = gqlResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ // Decode GraphQL response envelope
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ // Surface GraphQL-level errors
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Multipart Upload
+
+ /// Execute a GraphQL mutation with a file upload using the
+ /// graphql-multipart-request-spec (multipart/form-data).
+ ///
+ /// - Parameters:
+ /// - service: The target Sourcehut service.
+ /// - query: The GraphQL mutation string.
+ /// - variables: Variables dict; the file variable should be set to `nil`.
+ /// - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar").
+ /// - fileData: The raw file data (e.g. JPEG).
+ /// - fileName: The file name to send (e.g. "avatar.jpg").
+ /// - mimeType: The MIME type (e.g. "image/jpeg").
+ /// - responseType: The expected `Decodable` type nested under `data`.
+ func executeMultipart<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable],
+ fileVariablePath: String,
+ fileData: Data,
+ fileName: String,
+ mimeType: String,
+ responseType: T.Type
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ let boundary = "Boundary-\(UUID().uuidString)"
+
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
+
+ // Build the operations JSON (file variable mapped to null)
+ let operationsBody = GraphQLRequestBody(
+ query: query,
+ variables: variables.mapValues { AnyCodable($0) }
+ )
+ let operationsData = try encoder.encode(operationsBody)
+
+ // Build the map JSON: { "0": ["variables.<fileVariablePath>"] }
+ let mapDict = ["0": ["variables.\(fileVariablePath)"]]
+ let mapData = try encoder.encode(mapDict)
+
+ // Assemble multipart body
+ var body = Data()
+
+ // Part: operations
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(operationsData)
+ body.append("\r\n")
+
+ // Part: map
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"map\"\r\n")
+ body.append("Content-Type: application/json\r\n\r\n")
+ body.append(mapData)
+ body.append("\r\n")
+
+ // Part: file
+ body.append("--\(boundary)\r\n")
+ body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n")
+ body.append("Content-Type: \(mimeType)\r\n\r\n")
+ body.append(fileData)
+ body.append("\r\n")
+
+ // Closing boundary
+ body.append("--\(boundary)--\r\n")
+
+ request.httpBody = body
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Cached Execute
+
+ /// Execute a query and cache the raw response data. Returns cached data
+ /// immediately on cache hit, then refreshes in the background via the
+ /// `onRefresh` callback.
+ func executeCached<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type,
+ cacheKey: String
+ ) async throws -> T {
+ // Try cache first
+ if let cachedData = responseCache.get(forKey: cacheKey) {
+ if let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
+ let data = cached.data {
+ return data
+ }
+ }
+
+ // No cache hit — fetch normally
+ return try await executeAndCache(
+ service: service,
+ query: query,
+ variables: variables,
+ responseType: responseType,
+ cacheKey: cacheKey
+ )
+ }
+
+ /// Execute a query, cache the raw data, and return the decoded result.
+ func executeAndCache<T: Decodable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ responseType: T.Type,
+ cacheKey: String
+ ) async throws -> T {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: service.url)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = GraphQLRequestBody(
+ query: query,
+ variables: variables?.mapValues { AnyCodable($0) }
+ )
+ request.httpBody = try encoder.encode(body)
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ // Cache the raw response data before decoding
+ responseCache.set(data, forKey: cacheKey)
+
+ let graphQLResponse: GraphQLResponse<T>
+ do {
+ graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
+ } catch {
+ #if DEBUG
+ let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
+ let variablesDescription = String(describing: variables)
+ if let decodingError = error as? DecodingError {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ decodingError:
+ \(String(describing: decodingError), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ } else {
+ logger.error(
+ """
+ Decoding failed for \(String(describing: T.self), privacy: .public)
+ service: \(service.rawValue, privacy: .public)
+ query:
+ \(query, privacy: .public)
+ variables:
+ \(variablesDescription, privacy: .public)
+ error:
+ \(String(describing: error), privacy: .public)
+ response:
+ \(responseBody, privacy: .public)
+ """
+ )
+ }
+ #else
+ logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
+ #endif
+ throw SRHTError.decodingError(error)
+ }
+
+ if let errors = graphQLResponse.errors, !errors.isEmpty {
+ throw SRHTError.graphQLErrors(errors)
+ }
+
+ guard let result = graphQLResponse.data else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
+ )
+ }
+
+ return result
+ }
+
+ // MARK: - Plain-text fetch
+
+ /// Fetch the contents of a URL as plain text, using the same authorization header.
+ /// Used for build logs and other non-GraphQL resources.
+ func fetchText(url: URL) async throws -> String {
+ guard let token = _token.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ guard let text = String(data: data, encoding: .utf8) else {
+ throw SRHTError.decodingError(
+ DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
+ )
+ }
+
+ return text
+ }
+
+ // MARK: - Pagination
+
+ /// Returns an `AsyncSequence` that lazily iterates through all pages of a
+ /// paginated sr.ht GraphQL query.
+ ///
+ /// The query must accept a `$cursor: String` variable and return the standard
+ /// `{ results: [T], cursor: String? }` shape at the given key path.
+ func paginated<T: Decodable & Sendable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ resultKeyPath: String,
+ type: T.Type
+ ) -> SRHTPaginatedSequence<T> {
+ SRHTPaginatedSequence(
+ client: self,
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath
+ )
+ }
+
+ /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
+ /// collected results.
+ func fetchAll<T: Decodable & Sendable>(
+ service: SRHTService,
+ query: String,
+ variables: [String: any Sendable]? = nil,
+ resultKeyPath: String,
+ type: T.Type
+ ) async throws -> [T] {
+ var all: [T] = []
+ for try await element in paginated(
+ service: service,
+ query: query,
+ variables: variables,
+ resultKeyPath: resultKeyPath,
+ type: type
+ ) {
+ all.append(element)
+ }
+ return all
+ }
+}
+
+// MARK: - Data Helper
+
+private extension Data {
+ mutating func append(_ string: String) {
+ if let data = string.data(using: .utf8) {
+ append(data)
+ }
+ }
+}
diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift
new file mode 100644
index 0000000..f148c00
--- /dev/null
+++ b/Hutch/Networking/SRHTError.swift
@@ -0,0 +1,63 @@
+import Foundation
+
+/// Errors produced by the Sourcehut GraphQL client.
+enum SRHTError: LocalizedError, Sendable {
+ /// The server returned one or more GraphQL-level errors.
+ case graphQLErrors([GraphQLError])
+ /// The HTTP response had a non-2xx status code.
+ case httpError(Int)
+ /// The response data could not be decoded.
+ case decodingError(any Error)
+ /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
+ case networkError(any Error)
+ /// 401 or no authentication token configured.
+ case unauthorized
+
+ var errorDescription: String? {
+ switch self {
+ case .graphQLErrors(let errors):
+ let messages = errors.map(\.message).joined(separator: "\n")
+ return "GraphQL error: \(messages)"
+ case .httpError(let code):
+ return "Server returned HTTP \(code)."
+ case .decodingError(let error):
+ return "Failed to decode response: \(error.localizedDescription)"
+ case .networkError(let error):
+ return "Network error: \(error.localizedDescription)"
+ case .unauthorized:
+ return "Authentication required. Please sign in again."
+ }
+ }
+
+ /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
+ var isConnectivityError: Bool {
+ switch self {
+ case .networkError(let error):
+ let nsError = error as NSError
+ let connectivityCodes: Set<Int> = [
+ NSURLErrorNotConnectedToInternet,
+ NSURLErrorNetworkConnectionLost,
+ NSURLErrorTimedOut,
+ NSURLErrorCannotFindHost,
+ NSURLErrorCannotConnectToHost,
+ NSURLErrorDNSLookupFailed,
+ NSURLErrorInternationalRoamingOff,
+ NSURLErrorDataNotAllowed
+ ]
+ return connectivityCodes.contains(nsError.code)
+ default:
+ return false
+ }
+ }
+}
+
+/// A single error entry from the GraphQL `errors` array.
+struct GraphQLError: Decodable, Sendable {
+ let message: String
+ let locations: [GraphQLErrorLocation]?
+}
+
+struct GraphQLErrorLocation: Decodable, Sendable {
+ let line: Int
+ let column: Int
+}
diff --git a/Hutch/Networking/SRHTService.swift b/Hutch/Networking/SRHTService.swift
new file mode 100644
index 0000000..5db06dd
--- /dev/null
+++ b/Hutch/Networking/SRHTService.swift
@@ -0,0 +1,34 @@
+import Foundation
+
+/// Each Sourcehut service exposes its own GraphQL endpoint.
+enum SRHTService: String, Codable, Sendable, CaseIterable {
+ case meta
+ case git
+ case hg
+ case builds
+ case lists
+ case todo
+ case paste
+ case pages
+ case man
+
+ /// The GraphQL endpoint URL for this service.
+ var url: URL {
+ // Force-unwrap is safe here — these are compile-time constant strings.
+ URL(string: "https://\(rawValue).sr.ht/query")!
+ }
+
+ var displayName: String {
+ switch self {
+ case .meta: "Meta"
+ case .git: "Git"
+ case .hg: "Mercurial"
+ case .builds: "Builds"
+ case .lists: "Lists"
+ case .todo: "Todo"
+ case .paste: "Paste"
+ case .pages: "Pages"
+ case .man: "Man"
+ }
+ }
+}
diff --git a/Hutch/Views/Auth/AuthView.swift b/Hutch/Views/Auth/AuthView.swift
new file mode 100644
index 0000000..cbf8669
--- /dev/null
+++ b/Hutch/Views/Auth/AuthView.swift
@@ -0,0 +1,95 @@
+import SwiftUI
+
+/// Token entry screen shown when the user is not authenticated.
+struct TokenEntryView: View {
+ private let createAccountURL = URL(string: "https://meta.sr.ht/register")!
+ private let personalAccessTokensURL = URL(string: "https://meta.sr.ht/oauth/personal-access-tokens")!
+
+ @Environment(AppState.self) private var appState
+ @State private var token = ""
+ @State private var isConnecting = false
+ @State private var errorMessage: String?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ Text("Enter your SourceHut personal access token to connect.")
+ } header: {
+ Text("Welcome to Hutch")
+ } footer: {
+ Text("Hutch stores your SourceHut personal access token securely in the iOS keychain.")
+ }
+
+ Section {
+ SecureField("Personal Access Token", text: $token)
+ .textContentType(.password)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .disabled(isConnecting)
+ } header: {
+ Text("Token")
+ }
+
+ if let errorMessage {
+ Section {
+ Label {
+ Text(errorMessage)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+
+ Section {
+ Button {
+ connect()
+ } label: {
+ HStack {
+ Text("Connect")
+ if isConnecting {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(tokenTrimmed.isEmpty || isConnecting)
+ }
+
+ Section {
+ Link(destination: createAccountURL) {
+ Label("Create SourceHut account", systemImage: "person.badge.plus")
+ }
+
+ Link(destination: personalAccessTokensURL) {
+ Label("Create Personal Access Token", systemImage: "key")
+ }
+ } header: {
+ Text("Need an account?")
+ } footer: {
+ Text("These links open SourceHut in your browser. After signing up, create a Personal Access Token there and paste it here.")
+ }
+ }
+ .navigationTitle("Hutch")
+ }
+ }
+
+ private var tokenTrimmed: String {
+ token.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private func connect() {
+ errorMessage = nil
+ isConnecting = true
+ Task {
+ do {
+ try await appState.connect(with: tokenTrimmed)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isConnecting = false
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
new file mode 100644
index 0000000..977cd87
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -0,0 +1,383 @@
+import SwiftUI
+
+struct BuildDetailView: View {
+ let jobId: Int
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildDetailViewModel?
+ @State private var rebuiltJobId: Int?
+ @State private var showEditResubmitSheet = false
+ @State private var showCancelConfirmation = false
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading build…")
+ }
+ }
+ .navigationTitle("Job #\(jobId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(
+ url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
+ target: .build
+ ) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { rebuiltJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ rebuiltJobId = nil
+ }
+ }
+ )) {
+ if let rebuiltJobId {
+ BuildDetailView(jobId: rebuiltJobId)
+ }
+ }
+ .sheet(isPresented: $showEditResubmitSheet) {
+ if let viewModel, let job = viewModel.job {
+ EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
+ showEditResubmitSheet = false
+ rebuiltJobId = jobId
+ }
+ }
+ }
+ .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
+ Button("Keep Running", role: .cancel) {}
+ Button("Cancel Build", role: .destructive) {
+ Task { await viewModel?.cancelJob() }
+ }
+ } message: {
+ Text("The build will stop as soon as possible.")
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
+ viewModel = vm
+ await vm.loadJob()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
+ if viewModel.isLoading, viewModel.job == nil {
+ SRHTLoadingStateView(message: "Loading build…")
+ } else if let error = viewModel.error, viewModel.job == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Build",
+ message: error,
+ retryAction: { await viewModel.loadJob() }
+ )
+ } else if let job = viewModel.job {
+ List {
+ // Status & metadata
+ Section("Details") {
+ HStack {
+ Text("Status")
+ Spacer()
+ HStack(spacing: 6) {
+ JobStatusIcon(status: job.status)
+ Text(job.status.rawValue)
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ if let note = job.note, !note.isEmpty {
+ LabeledContent("Note", value: note)
+ }
+
+ if let image = job.image {
+ LabeledContent("Image", value: image)
+ }
+
+ if !job.tags.isEmpty {
+ LabeledContent("Tags", value: job.tags.joined(separator: ", "))
+ }
+
+ if let visibility = job.visibility {
+ LabeledContent("Visibility", value: visibility.rawValue.capitalized)
+ }
+
+ LabeledContent("Owner", value: job.owner.canonicalName)
+ LabeledContent("Created", value: job.created.relativeDescription)
+ LabeledContent("Updated", value: job.updated.relativeDescription)
+ }
+
+ // Per-task logs
+ if !job.tasks.isEmpty {
+ ForEach(job.tasks) { task in
+ Section {
+ TaskLogSection(task: task, viewModel: viewModel)
+ } header: {
+ HStack(spacing: 6) {
+ TaskStatusIcon(status: task.status)
+ Text(task.name)
+ }
+ }
+ }
+ }
+
+ // Cancel button
+ if job.status.isCancellable {
+ Section {
+ Button(role: .destructive) {
+ showCancelConfirmation = true
+ } label: {
+ HStack {
+ Text("Cancel Build")
+ if viewModel.isCancelling {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isCancelling)
+ }
+ }
+
+ if let manifest = job.manifest,
+ !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Section {
+ Button {
+ Task {
+ rebuiltJobId = await viewModel.rebuildJob()
+ }
+ } label: {
+ HStack {
+ Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
+ if viewModel.isRebuilding {
+ Spacer()
+ ProgressView()
+ }
+ }
+ }
+ .disabled(viewModel.isRebuilding)
+
+ Button {
+ showEditResubmitSheet = true
+ } label: {
+ Text("Edit & Resubmit")
+ }
+ .disabled(viewModel.isSubmittingEditedBuild)
+ } footer: {
+ Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
+ }
+ }
+ }
+ .refreshable {
+ await viewModel.loadJob()
+ }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ }
+ }
+}
+
+private struct EditResubmitBuildSheet: View {
+ let viewModel: BuildDetailViewModel
+ let job: JobDetail
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildDetailViewModel
+ @State private var manifest: String
+ @State private var tagsText: String
+ @State private var note: String
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility
+
+ init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.job = job
+ self.onSubmitted = onSubmitted
+ _manifest = State(initialValue: job.manifest ?? "")
+ _tagsText = State(initialValue: job.tags.joined(separator: ", "))
+ _note = State(initialValue: job.note ?? "")
+ _visibility = State(initialValue: job.visibility ?? .public)
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ 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.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Edit & Resubmit")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmittingEditedBuild {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Task Log Section
+
+private struct TaskLogSection: View {
+ let task: BuildTask
+ let viewModel: BuildDetailViewModel
+
+ @State private var isExpanded: Bool
+
+ init(task: BuildTask, viewModel: BuildDetailViewModel) {
+ self.task = task
+ self.viewModel = viewModel
+ self._isExpanded = State(initialValue: task.status == .failed)
+ }
+
+ var body: some View {
+ DisclosureGroup(isExpanded: $isExpanded) {
+ if viewModel.loadingTaskLogs.contains(task.name) {
+ HStack {
+ Spacer()
+ ProgressView("Loading log…")
+ Spacer()
+ }
+ } else if let logText = viewModel.taskLogs[task.name] {
+ ScrollView(.horizontal, showsIndicators: false) {
+ Text(logText)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ } else if task.log == nil {
+ Text("No log available.")
+ .foregroundStyle(.secondary)
+ }
+ } label: {
+ HStack {
+ Text(task.status.rawValue)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .task {
+ if isExpanded {
+ await viewModel.loadTaskLog(task: task)
+ }
+ }
+ .onChange(of: isExpanded) { _, expanded in
+ if expanded {
+ Task { await viewModel.loadTaskLog(task: task) }
+ }
+ }
+ }
+}
+
+// MARK: - Task Status Icon
+
+private struct TaskStatusIcon: View {
+ let status: TaskStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .frame(width: 20)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "circle.dashed"
+ case .skipped: "forward.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed: .red
+ case .running: .yellow
+ case .pending: .gray
+ case .skipped: .secondary
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
new file mode 100644
index 0000000..c14e47b
--- /dev/null
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -0,0 +1,235 @@
+import Foundation
+
+// MARK: - Response types
+
+private struct JobDetailResponse: Decodable, Sendable {
+ let job: JobDetail
+}
+
+private struct CancelResponse: Decodable, Sendable {
+ let cancel: CancelResult
+}
+
+private struct CancelResult: Decodable, Sendable {
+ let id: Int
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildDetailViewModel {
+
+ let jobId: Int
+ private let client: SRHTClient
+
+ private(set) var job: JobDetail?
+ private(set) var isLoading = false
+ private(set) var taskLogs: [String: String] = [:]
+ private(set) var loadingTaskLogs: Set<String> = []
+ private(set) var isCancelling = false
+ private(set) var isRebuilding = false
+ private(set) var isSubmittingEditedBuild = false
+ var error: String?
+
+ init(jobId: Int, client: SRHTClient) {
+ self.jobId = jobId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query job($id: Int!) {
+ job(id: $id) {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ manifest
+ tasks { name status log { fullURL } }
+ log { fullURL }
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let cancelMutation = """
+ mutation cancel($id: Int!) {
+ cancel(jobId: $id) {
+ id
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ private static let editableSubmitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadJob() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.detailQuery,
+ variables: ["id": jobId],
+ responseType: JobDetailResponse.self
+ )
+ job = result.job
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadTaskLog(task: BuildTask) async {
+ guard let log = task.log,
+ let logURL = URL(string: log.fullURL),
+ !loadingTaskLogs.contains(task.name),
+ taskLogs[task.name] == nil else { return }
+ loadingTaskLogs.insert(task.name)
+
+ do {
+ taskLogs[task.name] = try await client.fetchText(url: logURL)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ loadingTaskLogs.remove(task.name)
+ }
+
+ func cancelJob() async {
+ guard let job, job.status.isCancellable, !isCancelling else { return }
+ isCancelling = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .builds,
+ query: Self.cancelMutation,
+ variables: ["id": jobId],
+ responseType: CancelResponse.self
+ )
+ // Reload job to get updated status.
+ await loadJob()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isCancelling = false
+ }
+
+ func rebuildJob() async -> Int? {
+ guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
+ return nil
+ }
+
+ isRebuilding = true
+ error = nil
+ defer { isRebuilding = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ ]
+ if !job.tags.isEmpty {
+ variables["tags"] = job.tags
+ }
+ if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let visibility = job.visibility {
+ variables["visibility"] = visibility.rawValue
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = error.localizedDescription
+ return nil
+ }
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmittingEditedBuild else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmittingEditedBuild = true
+ error = nil
+ defer { isSubmittingEditedBuild = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.editableSubmitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListView.swift b/Hutch/Views/Builds/BuildListView.swift
new file mode 100644
index 0000000..875a836
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListView.swift
@@ -0,0 +1,217 @@
+import SwiftUI
+
+struct BuildListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: BuildListViewModel?
+ @State private var showSubmitSheet = false
+ @State private var submittedJobId: Int?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading builds…")
+ }
+ }
+ .navigationTitle("Builds")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showSubmitSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showSubmitSheet) {
+ if let viewModel {
+ SubmitBuildSheet(viewModel: viewModel) { jobId in
+ showSubmitSheet = false
+ submittedJobId = jobId
+ }
+ }
+ }
+ .navigationDestination(for: JobSummary.self) { job in
+ BuildDetailView(jobId: job.id)
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { submittedJobId != nil },
+ set: { isPresented in
+ if !isPresented {
+ submittedJobId = nil
+ }
+ }
+ )) {
+ if let submittedJobId {
+ BuildDetailView(jobId: submittedJobId)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = BuildListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadJobs()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: BuildListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.jobs) { job in
+ NavigationLink(value: job) {
+ BuildRowView(job: job)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: job)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.jobs.isEmpty {
+ SRHTLoadingStateView(message: "Loading builds…")
+ } else if let error = viewModel.error, viewModel.jobs.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Builds",
+ message: error,
+ retryAction: { await viewModel.loadJobs() }
+ )
+ } else if viewModel.jobs.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Builds",
+ systemImage: "hammer",
+ description: Text("Your build jobs will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
+ await viewModel.loadJobs()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadJobs()
+ }
+ }
+}
+
+private struct SubmitBuildSheet: View {
+ let viewModel: BuildListViewModel
+ let onSubmitted: (Int) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: BuildListViewModel
+ @State private var manifest = ""
+ @State private var tagsText = ""
+ @State private var note = ""
+ @State private var secrets = false
+ @State private var execute = true
+ @State private var visibility: Visibility = .public
+
+ init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.onSubmitted = onSubmitted
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Build Manifest") {
+ TextField("Paste a build manifest", text: $manifest, axis: .vertical)
+ .font(.system(.body, design: .monospaced))
+ .lineLimit(12...24)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ }
+
+ Section("Build Options") {
+ TextField("Note (optional)", text: $note)
+ TextField("Tags (comma-separated, optional)", text: $tagsText)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ Toggle("Start build now", isOn: $execute)
+ Toggle("Allow build secrets", isOn: $secrets)
+ }
+
+ Section {
+ Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ .navigationTitle("Submit Build")
+ .navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ let tags = tagsText
+ .split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ if let jobId = await viewModel.submitBuild(
+ manifest: manifest,
+ tags: tags,
+ note: note,
+ secrets: secrets,
+ execute: execute,
+ visibility: visibility
+ ) {
+ onSubmitted(jobId)
+ }
+ }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Submit Build")
+ }
+ }
+ .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift
new file mode 100644
index 0000000..8d0f961
--- /dev/null
+++ b/Hutch/Views/Builds/BuildListViewModel.swift
@@ -0,0 +1,220 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct JobsResponse: Decodable, Sendable {
+ let jobs: JobsPage
+}
+
+private struct JobsPage: Decodable, Sendable {
+ let results: [JobSummary]
+ let cursor: String?
+}
+
+private struct SubmitJobResponse: Decodable, Sendable {
+ let submit: SubmittedJob
+}
+
+private struct SubmittedJob: Decodable, Sendable {
+ let id: Int
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class BuildListViewModel {
+
+ private(set) var jobs: [JobSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ private(set) var isSubmitting = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ private static let cacheKey = "builds.jobs"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query jobs($cursor: Cursor) {
+ jobs(cursor: $cursor) {
+ results {
+ id
+ created
+ updated
+ status
+ note
+ tags
+ visibility
+ image
+ tasks { name status }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let submitMutation = """
+ mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
+ submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
+ id
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of jobs. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ func loadJobs() async {
+ // Show cached data immediately on first load
+ if jobs.isEmpty {
+ loadFromCache()
+ }
+
+ if jobs.isEmpty {
+ isLoading = true
+ } else {
+ isRefreshing = true
+ }
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil, useCache: true)
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ if jobs.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ func loadMoreIfNeeded(currentItem: JobSummary) async {
+ guard let last = jobs.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor, useCache: false)
+ jobs.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func submitBuild(
+ manifest: String,
+ tags: [String],
+ note: String,
+ secrets: Bool,
+ execute: Bool,
+ visibility: Visibility
+ ) async -> Int? {
+ guard !isSubmitting else { return nil }
+
+ let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedManifest.isEmpty else {
+ error = "Paste a build manifest."
+ return nil
+ }
+
+ isSubmitting = true
+ error = nil
+ defer { isSubmitting = false }
+
+ var variables: [String: any Sendable] = [
+ "manifest": trimmedManifest,
+ "secrets": secrets,
+ "execute": execute,
+ "visibility": visibility.rawValue
+ ]
+ if !tags.isEmpty {
+ variables["tags"] = tags
+ }
+ let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedNote.isEmpty {
+ variables["note"] = trimmedNote
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.submitMutation,
+ variables: variables,
+ responseType: SubmitJobResponse.self
+ )
+ await loadJobs()
+ return result.submit.id
+ } catch {
+ self.error = "Couldn’t submit the build. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ if useCache && cursor == nil {
+ let result = try await client.executeAndCache(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self,
+ cacheKey: Self.cacheKey
+ )
+ return result.jobs
+ } else {
+ let result = try await client.execute(
+ service: .builds,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: JobsResponse.self
+ )
+ return result.jobs
+ }
+ }
+
+ private func loadFromCache() {
+ guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ if let response = try? decoder.decode(
+ GraphQLResponse<JobsResponse>.self,
+ from: data
+ ), let page = response.data?.jobs {
+ jobs = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ }
+ }
+}
diff --git a/Hutch/Views/Builds/BuildRowView.swift b/Hutch/Views/Builds/BuildRowView.swift
new file mode 100644
index 0000000..e2afd27
--- /dev/null
+++ b/Hutch/Views/Builds/BuildRowView.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+
+struct BuildRowView: View {
+ let job: JobSummary
+
+ var body: some View {
+ HStack(spacing: 12) {
+ JobStatusIcon(status: job.status)
+ .frame(width: 28)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(job.displayLabel)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ if let image = job.image {
+ Text(image)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(job.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !job.tasks.isEmpty {
+ TaskProgressView(tasks: job.tasks)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Job Status Icon
+
+struct JobStatusIcon: View {
+ let status: JobStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ .symbolEffect(.pulse, isActive: status == .running)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .success: "checkmark.circle.fill"
+ case .failed, .timeout: "xmark.circle.fill"
+ case .running: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .queued: "clock.fill"
+ case .pending: "circle.dashed"
+ case .cancelled: "minus.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .success: .green
+ case .failed, .timeout: .red
+ case .running: .yellow
+ case .queued: .orange
+ case .pending, .cancelled: .gray
+ }
+ }
+}
+
+// MARK: - Task Progress
+
+struct TaskProgressView: View {
+ let tasks: [JobTaskSummary]
+
+ var body: some View {
+ HStack(spacing: 6) {
+ ProgressView(value: progress, total: 1.0)
+ .tint(progressColor)
+ .frame(maxWidth: 80)
+
+ Text("\(completedCount)/\(tasks.count) tasks")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var completedCount: Int {
+ tasks.filter { $0.status == .success }.count
+ }
+
+ private var progress: Double {
+ tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count)
+ }
+
+ private var progressColor: Color {
+ if tasks.contains(where: { $0.status == .failed }) {
+ return .red
+ }
+ if completedCount == tasks.count {
+ return .green
+ }
+ return .blue
+ }
+}
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
new file mode 100644
index 0000000..ef3b972
--- /dev/null
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -0,0 +1,73 @@
+import SwiftUI
+
+struct ArtifactsView: View {
+ let viewModel: RepositoryDetailViewModel
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ List {
+ ForEach(viewModel.referenceArtifacts) { refArtifacts in
+ Section(refArtifacts.name) {
+ ForEach(refArtifacts.artifacts) { artifact in
+ ArtifactRow(artifact: artifact) {
+ openURL(artifact.url)
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
+ SRHTLoadingStateView(message: "Loading artifacts…")
+ } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Artifacts",
+ message: error,
+ retryAction: { await viewModel.loadArtifacts() }
+ )
+ } else if viewModel.referenceArtifacts.isEmpty {
+ ContentUnavailableView(
+ "No Artifacts",
+ systemImage: "archivebox",
+ description: Text("This repository has no release artifacts.")
+ )
+ }
+ }
+ .task {
+ if viewModel.referenceArtifacts.isEmpty {
+ await viewModel.loadArtifacts()
+ }
+ }
+ .refreshable {
+ await viewModel.loadArtifacts()
+ }
+ }
+}
+
+private struct ArtifactRow: View {
+ let artifact: ArtifactInfo
+ let onDownload: () -> Void
+
+ var body: some View {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(artifact.filename)
+ .font(.subheadline)
+
+ Text(artifact.size.formattedByteCount)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button {
+ onDownload()
+ } label: {
+ Image(systemName: "arrow.down.circle")
+ .imageScale(.large)
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitDetailView.swift b/Hutch/Views/Repositories/CommitDetailView.swift
new file mode 100644
index 0000000..d303542
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitDetailView.swift
@@ -0,0 +1,332 @@
+import SwiftUI
+
+struct CommitDetailView: View {
+ let commitSummary: CommitSummary
+ let repository: RepositorySummary
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: CommitDetailViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ commitContent(viewModel)
+ } else {
+ ProgressView()
+ }
+ }
+ .navigationTitle(commitSummary.shortId)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
+ Image(systemName: "square.and.arrow.up")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = CommitDetailViewModel(
+ repositoryRid: repository.rid,
+ service: repository.service,
+ commitId: commitSummary.id,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadCommit()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func commitContent(_ viewModel: CommitDetailViewModel) -> some View {
+ if viewModel.isLoading {
+ ProgressView()
+ } else if let error = viewModel.error {
+ ContentUnavailableView {
+ Label("Error", systemImage: "exclamationmark.triangle")
+ } description: {
+ Text(error)
+ } actions: {
+ Button("Retry") {
+ Task { await viewModel.loadCommit() }
+ }
+ }
+ } else if let commit = viewModel.commit {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 0) {
+ // Header
+ commitHeader(commit)
+
+ sectionDivider
+
+ // Message
+ commitMessage(commit)
+
+ // Trailers
+ if !commit.trailers.isEmpty {
+ sectionDivider
+ trailersSection(commit.trailers)
+ }
+
+ // Parents
+ if !commit.parents.isEmpty {
+ sectionDivider
+ parentsSection(commit.parents)
+ }
+
+ // Diff
+ if let diff = commit.diff, !diff.isEmpty {
+ sectionDivider
+ diffSection(diff)
+ }
+
+ // Tree
+ if let tree = commit.tree, !tree.entries.results.isEmpty {
+ sectionDivider
+ treeSection(tree.entries.results)
+ }
+ }
+ }
+ .navigationDestination(for: ParentCommit.self) { parent in
+ CommitDetailView(
+ commitSummary: CommitSummary(
+ id: parent.id,
+ shortId: parent.shortId,
+ author: CommitAuthor(name: parent.author.name, email: nil, time: .now),
+ message: ""
+ ),
+ repository: repository
+ )
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func commitHeader(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ // Full hash — tappable to copy
+ Button {
+ UIPasteboard.general.string = commit.id
+ } label: {
+ HStack(spacing: 4) {
+ Text(commit.id)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+ Image(systemName: "doc.on.doc")
+ .font(.caption2)
+ }
+ .foregroundStyle(.secondary)
+ }
+
+ // Author
+ HStack {
+ Label(commit.author.name, systemImage: "person")
+ Spacer()
+ Text(commit.author.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+
+ // Committer (if different from author)
+ if commit.committer.name != commit.author.name
+ || commit.committer.email != commit.author.email {
+ HStack {
+ Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
+ Spacer()
+ Text(commit.committer.time.relativeDescription)
+ .foregroundStyle(.secondary)
+ }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Message
+
+ @ViewBuilder
+ private func commitMessage(_ commit: CommitDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Message")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ Text(commit.title)
+ .font(.headline)
+
+ if let body = commit.body {
+ Text(body)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Trailers
+
+ @ViewBuilder
+ private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Trailers")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(trailers) { trailer in
+ HStack(alignment: .top, spacing: 4) {
+ Text("\(trailer.name):")
+ .font(.subheadline.monospaced().weight(.medium))
+ Text(trailer.value)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Parents
+
+ @ViewBuilder
+ private func parentsSection(_ parents: [ParentCommit]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Parents")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(parents) { parent in
+ NavigationLink(value: parent) {
+ HStack {
+ Text(parent.shortId)
+ .font(.subheadline.monospaced())
+ Text(parent.author.name)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Diff
+
+ @ViewBuilder
+ private func diffSection(_ diff: String) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Diff")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+ .padding(.horizontal)
+ .padding(.top)
+
+ DiffView(diff: invertDiff(diff))
+ .padding(.bottom)
+ }
+ }
+
+ /// The sr.ht API returns diffs comparing current→parent (inverted).
+ /// This swaps +/- prefixes so the diff reads as parent→current.
+ private func invertDiff(_ diff: String) -> String {
+ diff.split(separator: "\n", omittingEmptySubsequences: false)
+ .map { line in
+ let s = String(line)
+ if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
+ return s
+ }
+ if s.hasPrefix("---") {
+ return "+++" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+++") {
+ return "---" + s.dropFirst(3)
+ }
+ if s.hasPrefix("+") {
+ return "-" + s.dropFirst(1)
+ }
+ if s.hasPrefix("-") {
+ return "+" + s.dropFirst(1)
+ }
+ return s
+ }
+ .joined(separator: "\n")
+ }
+
+ // MARK: - Tree
+
+ @ViewBuilder
+ private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Tree")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .textCase(.uppercase)
+
+ ForEach(entries) { entry in
+ HStack(spacing: 8) {
+ Image(systemName: treeEntryIcon(for: entry))
+ .foregroundStyle(treeEntryColor(for: entry))
+ .frame(width: 20)
+ Text(entry.name)
+ .font(.subheadline.monospaced())
+ Spacer()
+ if let obj = entry.object, let shortId = obj.shortId {
+ Text(shortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: - Helpers
+
+ private var sectionDivider: some View {
+ Divider().padding(.horizontal)
+ }
+
+ private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
+ guard let type = entry.object?.type else {
+ return "doc"
+ }
+ switch type {
+ case "tree": return "folder"
+ case "blob": return "doc.text"
+ case "tag": return "tag"
+ case "commit": return "arrow.triangle.branch"
+ default: return "doc"
+ }
+ }
+
+ private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
+ guard let type = entry.object?.type else {
+ return .secondary
+ }
+ switch type {
+ case "tree": return .blue
+ case "blob": return .secondary
+ case "tag": return .orange
+ case "commit": return .purple
+ default: return .secondary
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitDetailViewModel.swift b/Hutch/Views/Repositories/CommitDetailViewModel.swift
new file mode 100644
index 0000000..5a8a23d
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitDetailViewModel.swift
@@ -0,0 +1,102 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct CommitResponse: Decodable, Sendable {
+ let repository: CommitRepository?
+}
+
+private struct CommitRepository: Decodable, Sendable {
+ // swiftlint:disable:next identifier_name
+ let revparse_single: CommitDetail
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class CommitDetailViewModel {
+
+ let repositoryRid: String
+ let service: SRHTService
+ private let client: SRHTClient
+
+ private(set) var commit: CommitDetail?
+ private(set) var isLoading = false
+ var error: String?
+
+ init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ self.commitId = commitId
+ self.client = client
+ }
+
+ private let commitId: String
+
+ // MARK: - Query
+
+ private static let query = """
+ query commit($rid: ID!, $id: String!) {
+ repository(rid: $rid) {
+ revparse_single(revspec: $id) {
+ id
+ shortId
+ author { name email time }
+ committer { name email time }
+ message
+ diff
+ trailers { name value }
+ parents { id shortId author { name } }
+ tree {
+ entries {
+ results { id name mode object { type id shortId } }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ func loadCommit() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await executeWithRetry()
+ commit = result.repository?.revparse_single
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ /// Execute the commit query, retrying once after a 1-second delay on 502/503.
+ private func executeWithRetry() async throws -> CommitResponse {
+ do {
+ return try await client.execute(
+ service: service,
+ query: Self.query,
+ variables: [
+ "rid": repositoryRid,
+ "id": commitId
+ ],
+ responseType: CommitResponse.self
+ )
+ } catch let SRHTError.httpError(code) where code == 502 || code == 503 {
+ try await Task.sleep(for: .seconds(1))
+ return try await client.execute(
+ service: service,
+ query: Self.query,
+ variables: [
+ "rid": repositoryRid,
+ "id": commitId
+ ],
+ responseType: CommitResponse.self
+ )
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitLogView.swift b/Hutch/Views/Repositories/CommitLogView.swift
new file mode 100644
index 0000000..d63c5eb
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitLogView.swift
@@ -0,0 +1,59 @@
+import SwiftUI
+
+struct CommitLogView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ var body: some View {
+ List {
+ ForEach(viewModel.commits) { commit in
+ NavigationLink(value: commit) {
+ CommitRowView(commit: commit)
+ }
+ .task {
+ await viewModel.loadMoreCommitsIfNeeded(currentItem: commit)
+ }
+ }
+
+ if viewModel.isLoadingMoreCommits {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoadingCommits, viewModel.commits.isEmpty {
+ SRHTLoadingStateView(message: "Loading commits…")
+ } else if let error = viewModel.error, viewModel.commits.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Commits",
+ message: error,
+ retryAction: { await viewModel.loadCommits() }
+ )
+ } else if viewModel.commits.isEmpty {
+ ContentUnavailableView(
+ "No Commits",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository has no commit history.")
+ )
+ }
+ }
+ .task {
+ if viewModel.commits.isEmpty {
+ await viewModel.loadCommits()
+ }
+ }
+ .refreshable {
+ await viewModel.loadCommits()
+ }
+ .navigationDestination(for: CommitSummary.self) { commit in
+ CommitDetailView(
+ commitSummary: commit,
+ repository: viewModel.repository
+ )
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/CommitRowView.swift b/Hutch/Views/Repositories/CommitRowView.swift
new file mode 100644
index 0000000..b5eb31d
--- /dev/null
+++ b/Hutch/Views/Repositories/CommitRowView.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+
+struct CommitRowView: View {
+ let commit: CommitSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(commit.title)
+ .font(.subheadline)
+ .lineLimit(1)
+
+ HStack(spacing: 8) {
+ Text(commit.shortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+
+ Text(commit.author.name)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(commit.author.time.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Repositories/DiffView.swift b/Hutch/Views/Repositories/DiffView.swift
new file mode 100644
index 0000000..b1db464
--- /dev/null
+++ b/Hutch/Views/Repositories/DiffView.swift
@@ -0,0 +1,79 @@
+import SwiftUI
+
+/// Renders a unified diff string with syntax highlighting:
+/// - Green background for added lines (+)
+/// - Red background for removed lines (-)
+/// - Gray for hunk headers (@@)
+/// - File headers (--- / +++ / diff) in bold
+struct DiffView: View {
+ let diff: String
+
+ var body: some View {
+ let lines = diff.components(separatedBy: "\n")
+
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
+ DiffLineView(line: line)
+ }
+ }
+ .font(.caption.monospaced())
+ }
+}
+
+private struct DiffLineView: View {
+ let line: String
+
+ var body: some View {
+ Text(line.isEmpty ? " " : line)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 1)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .fontWeight(isHeader ? .semibold : .regular)
+ }
+
+ private var kind: DiffLineKind {
+ if line.hasPrefix("@@") { return .hunk }
+ if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader }
+ if line.hasPrefix("diff ") { return .fileHeader }
+ if line.hasPrefix("index ") { return .meta }
+ if line.hasPrefix("+") { return .added }
+ if line.hasPrefix("-") { return .removed }
+ return .context
+ }
+
+ private var backgroundColor: Color {
+ switch kind {
+ case .added: .green.opacity(0.15)
+ case .removed: .red.opacity(0.15)
+ case .hunk: .gray.opacity(0.12)
+ case .fileHeader: .gray.opacity(0.08)
+ case .meta: .gray.opacity(0.05)
+ case .context: .clear
+ }
+ }
+
+ private var foregroundColor: Color {
+ switch kind {
+ case .added: .green
+ case .removed: .red
+ case .hunk: .secondary
+ case .meta: .secondary
+ default: .primary
+ }
+ }
+
+ private var isHeader: Bool {
+ kind == .fileHeader
+ }
+}
+
+private enum DiffLineKind {
+ case added
+ case removed
+ case hunk
+ case fileHeader
+ case meta
+ case context
+}
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
new file mode 100644
index 0000000..3750adb
--- /dev/null
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -0,0 +1,446 @@
+import SwiftUI
+
+struct FileTreeView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+
+ @State private var viewModel: FileTreeViewModel?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ FileTreeContentView(repository: repository, viewModel: viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading files…")
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = FileTreeViewModel(
+ repositoryRid: repository.rid,
+ service: repository.service,
+ client: client
+ )
+ viewModel = vm
+ async let loadTree: () = vm.loadRootTree()
+ async let loadRefs: () = vm.loadReferences()
+ _ = await (loadTree, loadRefs)
+ }
+ }
+ }
+}
+
+// MARK: - Content View
+
+private struct FileTreeContentView: View {
+ let repository: RepositorySummary
+ let viewModel: FileTreeViewModel
+
+ @State private var showRefPicker = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ breadcrumbBar
+ Divider()
+ contentArea
+ }
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showRefPicker = true
+ } label: {
+ Label(
+ revspecLabel,
+ systemImage: "arrow.triangle.branch"
+ )
+ .font(.subheadline)
+ }
+ }
+ }
+ .sheet(isPresented: $showRefPicker) {
+ RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker)
+ }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ .refreshable {
+ await viewModel.loadRootTree()
+ }
+ }
+
+ private var shareURL: URL? {
+ guard let viewingEntry = viewModel.viewingEntry else { return nil }
+ return SRHTWebURL.file(
+ repository: repository,
+ revspec: viewModel.revspec,
+ path: currentFilePath(for: viewingEntry)
+ )
+ }
+
+ private var revspecLabel: String {
+ let revspec = viewModel.revspec
+ if revspec == "HEAD" {
+ return "HEAD"
+ }
+ if revspec.hasPrefix("refs/heads/") {
+ return String(revspec.dropFirst("refs/heads/".count))
+ } else if revspec.hasPrefix("refs/tags/") {
+ return String(revspec.dropFirst("refs/tags/".count))
+ }
+ return revspec
+ }
+
+ // MARK: - Breadcrumb Bar
+
+ private var breadcrumbBar: some View {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 12) {
+ HStack(spacing: 4) {
+ ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in
+ if index > 0 {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ Button {
+ Task {
+ await viewModel.navigateToBreadcrumb(at: index)
+ }
+ } label: {
+ Text(navEntry.name)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(
+ index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil
+ ? .primary : .secondary
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if let viewing = viewModel.viewingEntry {
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+
+ Text(viewing.name)
+ .font(.subheadline.monospaced())
+ .foregroundStyle(.primary)
+ }
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ }
+ .background(.bar)
+ }
+
+ private func currentFilePath(for entry: TreeEntry) -> String {
+ let directoryComponents = viewModel.navStack
+ .dropFirst()
+ .map(\.name)
+ return (directoryComponents + [entry.name]).joined(separator: "/")
+ }
+
+ // MARK: - Content Area
+
+ @ViewBuilder
+ private var contentArea: some View {
+ if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil {
+ SRHTLoadingStateView(message: "Loading files…")
+ } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject {
+ // Viewing a file
+ fileContentView(entry: entry, object: object)
+ } else if let error = viewModel.error, viewModel.entries.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Files",
+ message: error,
+ retryAction: { await viewModel.loadRootTree() }
+ )
+ } else if !viewModel.entries.isEmpty {
+ // Viewing a directory listing
+ treeListView
+ } else if viewModel.navStack.isEmpty {
+ ContentUnavailableView(
+ "No Files",
+ systemImage: "folder",
+ description: Text("This repository could not be loaded.")
+ )
+ } else {
+ ContentUnavailableView(
+ "Empty Directory",
+ systemImage: "folder",
+ description: Text("This directory has no files.")
+ )
+ }
+ }
+
+ // MARK: - File Content View
+
+ @ViewBuilder
+ private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
+ switch object {
+ case .textBlob(let blob):
+ textBlobView(entry: entry, blob: blob)
+ case .binaryBlob(let blob):
+ binaryBlobView(entry: entry, blob: blob)
+ default:
+ ContentUnavailableView(
+ "Unknown Object",
+ systemImage: "questionmark.folder",
+ description: Text("Cannot display this object type.")
+ )
+ }
+ }
+
+ // MARK: - Tree List
+
+ private var treeListView: some View {
+ let sorted = viewModel.entries.sorted { a, b in
+ let aIsTree = a.object?.isTree == true
+ let bIsTree = b.object?.isTree == true
+ if aIsTree != bIsTree { return aIsTree }
+ return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
+ }
+
+ return List(sorted) { entry in
+ TreeEntryRow(entry: entry)
+ .contentShape(Rectangle())
+ .onTapGesture {
+ Task {
+ await viewModel.navigateInto(entry: entry)
+ }
+ }
+ }
+ .listStyle(.plain)
+ }
+
+ // MARK: - Text Blob
+
+ @ViewBuilder
+ private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View {
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.horizontal)
+ .padding(.top, 12)
+
+ GeometryReader { geometry in
+ ScrollView([.vertical, .horizontal]) {
+ Text(blob.text)
+ .font(.system(.body, design: .monospaced))
+ .multilineTextAlignment(.leading)
+ .fixedSize(horizontal: true, vertical: false)
+ .frame(minWidth: geometry.size.width,
+ minHeight: geometry.size.height,
+ alignment: .topLeading)
+ .padding()
+ }
+ .frame(width: geometry.size.width, height: geometry.size.height)
+ }
+ }
+ }
+
+ // MARK: - Binary Blob
+
+ @ViewBuilder
+ private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
+ VStack(spacing: 16) {
+ Spacer()
+
+ Image(systemName: "doc.zipper")
+ .font(.system(size: 48))
+ .foregroundStyle(.secondary)
+
+ Text(entry.name)
+ .font(.headline)
+
+ if let size = blob.size {
+ Text(formatBytes(size))
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("Binary file — cannot be displayed inline.")
+ .font(.subheadline)
+ .foregroundStyle(.tertiary)
+
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+
+ if let content = blob.content, let url = URL(string: content) {
+ Link(destination: url) {
+ Label("Open in Safari", systemImage: "safari")
+ }
+ .buttonStyle(.borderedProminent)
+ }
+
+ Button {
+ viewModel.dismissFileView()
+ } label: {
+ Text("Back to directory")
+ }
+
+ Spacer()
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ // MARK: - Helpers
+
+ private func formatBytes(_ bytes: Int) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: Int64(bytes))
+ }
+}
+
+// MARK: - Tree Entry Row
+
+private struct TreeEntryRow: View {
+ let entry: TreeEntry
+
+ var body: some View {
+ Label {
+ Text(entry.name)
+ .font(.body.monospaced())
+ .lineLimit(1)
+ } icon: {
+ Image(systemName: iconName)
+ .foregroundStyle(iconColor)
+ }
+ }
+
+ private var iconName: String {
+ switch entry.object {
+ case .tree: "folder.fill"
+ case .unknown: "questionmark.circle"
+ default: "doc"
+ }
+ }
+
+ private var iconColor: Color {
+ switch entry.object {
+ case .tree: .blue
+ case .unknown: .orange
+ default: .secondary
+ }
+ }
+}
+
+// MARK: - Ref Picker Sheet
+
+private struct RefPickerSheet: View {
+ let viewModel: FileTreeViewModel
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Button {
+ Task {
+ await viewModel.changeRevspec("HEAD")
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: "HEAD",
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.revspec == "HEAD"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches, id: \.name) { ref in
+ Button {
+ Task {
+ await viewModel.changeRevspec(ref.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.revspec == ref.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags, id: \.name) { ref in
+ Button {
+ Task {
+ await viewModel.changeRevspec(ref.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
+ systemImage: "tag",
+ color: .orange,
+ isSelected: viewModel.revspec == ref.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Select Ref")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isPresented = false
+ }
+ }
+ }
+ .overlay {
+ if viewModel.isLoadingRefs {
+ SRHTLoadingStateView(message: "Loading references…")
+ }
+ }
+ }
+ }
+
+ private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(color)
+
+ Text(title)
+ .font(.body.monospaced())
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
+ }
+}
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
new file mode 100644
index 0000000..f270779
--- /dev/null
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -0,0 +1,524 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct RevparseResponse: Decodable, Sendable {
+ let repository: RevparseRepository?
+}
+
+private struct RevparseRepository: Decodable, Sendable {
+ let revparse_single: RevparseCommit?
+}
+
+private struct RevparseCommit: Decodable, Sendable {
+ let tree: GitTree?
+}
+
+private struct SubtreeResponse: Decodable, Sendable {
+ let repository: SubtreeRepository?
+}
+
+private struct SubtreeRepository: Decodable, Sendable {
+ let object: SubtreeObject?
+}
+
+/// Dedicated decoding struct for the subtree query response.
+/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment.
+private struct SubtreeObject: Decodable, Sendable {
+ let entries: GitTreeEntryPage?
+}
+
+private struct BlobResponse: Decodable, Sendable {
+ let repository: BlobRepository?
+}
+
+private struct BlobRepository: Decodable, Sendable {
+ let object: GitObject?
+}
+
+// MARK: - Navigation Stack Entry
+
+struct FileNavEntry: Hashable {
+ let name: String
+ let treeId: String
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class FileTreeViewModel {
+
+ let repositoryRid: String
+ let service: SRHTService
+ private let client: SRHTClient
+
+ /// The current revspec. "HEAD" by default, or a full ref name.
+ var revspec: String = "HEAD"
+
+ /// Navigation stack: each entry is a (name, treeId) pair.
+ /// The first entry is always the root.
+ private(set) var navStack: [FileNavEntry] = []
+
+ /// The tree entries at the current directory level.
+ private(set) var entries: [TreeEntry] = []
+
+ /// When viewing a file (text blob or binary blob), this holds the object.
+ private(set) var viewingEntry: TreeEntry?
+ private(set) var viewingObject: GitObject?
+
+ private(set) var isLoading = false
+ var error: String?
+
+ // Available references for the branch/tag picker
+ private(set) var branches: [Reference] = []
+ private(set) var tags: [Reference] = []
+ private(set) var isLoadingRefs = false
+
+ init(repositoryRid: String, service: SRHTService, client: SRHTClient) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let rootTreeQuery = """
+ query files($rid: ID!, $revspec: String!) {
+ repository(rid: $rid) {
+ revparse_single(revspec: $revspec) {
+ tree {
+ id
+ entries {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ /// Query to fetch additional pages of tree entries by tree ID + cursor.
+ private static let treeEntriesPageQuery = """
+ query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ object(id: $treeId) {
+ type
+ id
+ ... on Tree {
+ entries(cursor: $cursor) {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let subtreeQuery = """
+ query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ object(id: $treeId) {
+ type
+ id
+ ... on Tree {
+ entries(cursor: $cursor) {
+ results {
+ id
+ name
+ mode
+ object {
+ type
+ id
+ shortId
+ ... on Tree {
+ entries {
+ results {
+ id
+ name
+ mode
+ object { type id shortId }
+ }
+ cursor
+ }
+ }
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let blobQuery = """
+ query blob($rid: ID!, $blobId: String!) {
+ repository(rid: $rid) {
+ object(id: $blobId) {
+ type
+ id
+ ... on TextBlob {
+ text
+ size
+ }
+ ... on BinaryBlob {
+ size
+ content
+ }
+ }
+ }
+ }
+ """
+
+ private static let refsQuery = """
+ query refs($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results { name target }
+ cursor
+ }
+ }
+ }
+ """
+
+ // MARK: - Load Root Tree
+
+ func loadRootTree() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ viewingEntry = nil
+ viewingObject = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "revspec": revspec
+ ]
+
+ do {
+ let result: RevparseResponse
+ do {
+ result = try await client.execute(
+ service: service,
+ query: Self.rootTreeQuery,
+ variables: variables,
+ responseType: RevparseResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ navStack = [FileNavEntry(name: "root", treeId: "")]
+ entries = []
+ return
+ }
+ throw error
+ }
+ if let tree = result.repository?.revparse_single?.tree,
+ let rootId = tree.id {
+ navStack = [FileNavEntry(name: "root", treeId: rootId)]
+ var allEntries = tree.entries?.results ?? []
+ var cursor = tree.entries?.cursor
+ // Follow cursor pagination for remaining pages
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } else {
+ navStack = []
+ entries = []
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Navigate Into Folder
+
+ func navigateInto(entry: TreeEntry) async {
+ guard let object = entry.object else { return }
+
+ switch object {
+ case .tree(let tree):
+ // Use the git object SHA from entry.object, NOT entry.id
+ guard let objectSHA = tree.id else { return }
+ // If we already have the entries inline and no further pages, use them directly
+ if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil {
+ navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA))
+ entries = inlineEntries
+ viewingEntry = nil
+ viewingObject = nil
+ return
+ }
+ // Otherwise fetch the subtree (handles pagination)
+ await loadSubtree(name: entry.name, treeId: objectSHA)
+
+ case .textBlob:
+ viewingEntry = entry
+ viewingObject = object
+
+ case .binaryBlob(let blob):
+ if blob.content != nil || blob.size != nil {
+ viewingEntry = entry
+ viewingObject = object
+ } else if let blobId = blob.id {
+ await loadBlob(entry: entry, blobId: blobId)
+ }
+
+ case .unknown:
+ break
+ }
+ }
+
+ private func loadSubtree(name: String, treeId: String) async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ viewingEntry = nil
+ viewingObject = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": treeId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.subtreeQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ navStack.append(FileNavEntry(name: name, treeId: treeId))
+ var allEntries = result.repository?.object?.entries?.results ?? []
+ var cursor = result.repository?.object?.entries?.cursor
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func loadBlob(entry: TreeEntry, blobId: String) async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "blobId": blobId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.blobQuery,
+ variables: variables,
+ responseType: BlobResponse.self
+ )
+ viewingEntry = entry
+ viewingObject = result.repository?.object ?? .unknown
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Navigate to Breadcrumb
+
+ func navigateToBreadcrumb(at index: Int) async {
+ guard index >= 0, index < navStack.count else { return }
+
+ // If tapping current level, do nothing
+ if index == navStack.count - 1, viewingEntry == nil {
+ return
+ }
+
+ // Clear file view
+ viewingEntry = nil
+ viewingObject = nil
+
+ // Trim the stack
+ let targetEntry = navStack[index]
+ navStack = Array(navStack.prefix(index + 1))
+
+ if index == 0 {
+ // Go back to root — reload from revparse_single
+ await loadRootTree()
+ } else {
+ // Load the subtree at this level
+ isLoading = true
+ error = nil
+ defer { isLoading = false }
+
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": targetEntry.treeId
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.subtreeQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ var allEntries = result.repository?.object?.entries?.results ?? []
+ var cursor = result.repository?.object?.entries?.cursor
+ while let nextCursor = cursor {
+ let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
+ allEntries.append(contentsOf: pageEntries.results)
+ cursor = pageEntries.cursor
+ }
+ entries = allEntries
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ /// Fetch a single page of tree entries by tree ID and cursor.
+ private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
+ let variables: [String: any Sendable] = [
+ "rid": repositoryRid,
+ "treeId": treeId,
+ "cursor": cursor
+ ]
+ let result = try await client.execute(
+ service: service,
+ query: Self.treeEntriesPageQuery,
+ variables: variables,
+ responseType: SubtreeResponse.self
+ )
+ return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
+ }
+
+ /// Dismiss the file view and go back to the directory listing.
+ func dismissFileView() {
+ viewingEntry = nil
+ viewingObject = nil
+ }
+
+ // MARK: - Change Revspec
+
+ func changeRevspec(_ newRevspec: String) async {
+ revspec = newRevspec
+ await loadRootTree()
+ }
+
+ // MARK: - Load References
+
+ func loadReferences() async {
+ guard !isLoadingRefs else { return }
+ isLoadingRefs = true
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.refsQuery,
+ variables: ["rid": repositoryRid],
+ responseType: RefsResponseLocal.self
+ )
+ let allRefs = result.repository?.references.results ?? []
+ branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
+ tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
+ } catch {
+ // Silently fail for refs — non-critical
+ }
+
+ isLoadingRefs = false
+ }
+
+ private func isMissingGitReferenceError(_ error: Error) -> Bool {
+ guard let srhtError = error as? SRHTError else { return false }
+ guard case .graphQLErrors(let errors) = srhtError else { return false }
+ return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ }
+}
+
+// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
+private struct RefsResponseLocal: Decodable, Sendable {
+ let repository: RefsRepoLocal?
+}
+
+private struct RefsRepoLocal: Decodable, Sendable {
+ let references: RefsPageLocal
+}
+
+private struct RefsPageLocal: Decodable, Sendable {
+ let results: [Reference]
+ let cursor: String?
+}
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
index 8e47d33..6779165 100644
--- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift
@@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View {
@State private var viewModel: HgRepositoryDetailViewModel?
@State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
+ @State private var isShowingRepositoryDetails = false
+ @State private var showBrowseRefPicker = false
+
+ private var shareURL: URL? {
+ guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
+ return SRHTWebURL.file(
+ repository: repository,
+ revspec: viewModel.browseRevspec,
+ path: selectedFilePath
+ )
+ }
var body: some View {
Group {
if let viewModel {
content(viewModel)
} else {
- ProgressView()
+ SRHTLoadingStateView(message: "Loading repository…")
}
}
.navigationTitle(repository.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
- ToolbarItem(placement: .topBarTrailing) {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ if selectedTab == .browse, let viewModel {
+ Button {
+ showBrowseRefPicker = true
+ } label: {
+ Label(
+ browseRevspecLabel(viewModel.browseRevspec),
+ systemImage: "arrow.triangle.branch"
+ )
+ .font(.subheadline)
+ }
+ }
+
+ SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
Button {
showSettings = true
} label: {
@@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View {
}
)
}
+ .sheet(isPresented: $showBrowseRefPicker) {
+ if let viewModel {
+ HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker)
+ }
+ }
.task {
if viewModel == nil {
let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
@@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View {
revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
}
}
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
- } message: {
- if let error = viewModel.error {
- Text(error)
- }
- }
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
}
@ViewBuilder
private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
- if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
- ProgressView()
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- } else {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- summaryCards(viewModel)
-
- if let readmeView = readmeContentView(viewModel) {
- readmeView
- } else {
- ContentUnavailableView(
- "No README",
- systemImage: "doc.text",
- description: Text("This repository does not have a README file.")
- )
- }
- }
- .padding()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ headerSection
+ metadataSection(viewModel)
+ repositoryDetailsSection(viewModel)
+ latestChangeSection(viewModel)
+ readmeSection(viewModel)
}
- .refreshable {
- await viewModel.loadSummary()
+ .padding()
+ }
+ .overlay {
+ if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
+ SRHTLoadingStateView(message: "Loading repository…")
+ } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Repository",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
}
}
+ .refreshable {
+ await viewModel.loadSummary()
+ }
}
- private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View {
- VStack(alignment: .leading, spacing: 12) {
- LabeledContent("Visibility", value: visibilityLabel(repository.visibility))
- LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
-
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Text(repository.name)
+ .font(.largeTitle.weight(.semibold))
if let description = repository.description, !description.isEmpty {
- VStack(alignment: .leading, spacing: 4) {
- Text("Description")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- .textCase(.uppercase)
- Text(description)
- }
+ Text(description)
+ .font(.body)
}
+ }
+ }
- if let tip = viewModel.tip {
- VStack(alignment: .leading, spacing: 6) {
- Text("Tip")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- .textCase(.uppercase)
- Text(tip.title)
- .font(.headline)
- HStack {
- Text(tip.displayShortId)
- .font(.caption.monospaced())
- Spacer()
- Text(tip.author.time.relativeDescription)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- Text(tip.author.name)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
+ @ViewBuilder
+ private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
+ )
+
+ if let readmePath = viewModel.readmePath {
+ SummaryMetadataRow(
+ icon: "doc.text",
+ title: readmePath
+ )
}
}
- .padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+
+ private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
+ VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
+ SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
+ SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
+ SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true)
+ SummaryDetailRow(label: "RID", value: repository.rid, monospace: true)
+ }
+ .padding(.top, 8)
+ } label: {
+ Text("Repository Details")
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ @ViewBuilder
+ private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if viewModel.isLoadingSummary && viewModel.tip == nil {
+ SRHTLoadingStateView(message: "Loading latest change…")
+ .frame(maxWidth: .infinity)
+ } else if let tip = viewModel.tip {
+ SummaryMetadataRow(
+ icon: "arrow.trianglehead.clockwise",
+ title: tip.title,
+ subtitle: "\(tip.displayShortId) — \(tip.author)"
+ )
+ } else if let error = viewModel.error, !viewModel.summaryLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load Latest Change",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No Recent Revisions",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository does not have any revision history yet.")
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
+ if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
+ SRHTLoadingStateView(message: "Loading README…")
+ } else if let readmeView = readmeContentView(viewModel) {
+ readmeView
+ } else if let error = viewModel.error, !viewModel.summaryLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load README",
+ message: error,
+ retryAction: { await viewModel.loadSummary() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
}
@ViewBuilder
@@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View {
browseBreadcrumbs(viewModel)
Divider()
- if viewModel.isLoadingBrowse {
- Spacer()
- ProgressView()
- Spacer()
+ if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil {
+ SRHTLoadingStateView(message: "Loading files…")
} else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
- GeometryReader { geometry in
- ScrollView([.vertical, .horizontal]) {
- Text(fileContent)
- .font(.system(.body, design: .monospaced))
- .frame(
- minWidth: geometry.size.width,
- minHeight: geometry.size.height,
- alignment: .topLeading
- )
- .padding()
+ VStack(spacing: 0) {
+ HStack {
+ Spacer()
+ SRHTShareButton(url: shareURL, target: .file) {
+ Label("Share File", systemImage: "square.and.arrow.up")
+ }
+ .buttonStyle(.bordered)
+ }
+ .padding(.horizontal)
+ .padding(.top, 12)
+
+ GeometryReader { geometry in
+ ScrollView([.vertical, .horizontal]) {
+ Text(fileContent)
+ .font(.system(.body, design: .monospaced))
+ .frame(
+ minWidth: geometry.size.width,
+ minHeight: geometry.size.height,
+ alignment: .topLeading
+ )
+ .padding()
+ }
}
}
.safeAreaInset(edge: .bottom) {
@@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View {
.background(.bar)
}
.navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
+ } else if let error = viewModel.error, viewModel.files.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Files",
+ message: error,
+ retryAction: { await viewModel.loadBrowseRoot() }
+ )
} else if viewModel.files.isEmpty {
ContentUnavailableView(
"No Files",
@@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View {
)
} else {
List(viewModel.files) { file in
- Label {
- Text(displayFileName(file.name))
- .font(.body.monospaced())
- } icon: {
- Image(systemName: file.isDirectory ? "folder.fill" : "doc")
- .foregroundStyle(file.isDirectory ? .blue : .secondary)
- }
+ HgFileRow(file: file)
.contentShape(Rectangle())
.onTapGesture {
Task { await viewModel.openFile(file) }
@@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View {
}
.listStyle(.plain)
.overlay {
- if viewModel.isLoadingLog {
- ProgressView()
+ if viewModel.isLoadingLog, viewModel.log.isEmpty {
+ SRHTLoadingStateView(message: "Loading revisions…")
+ } else if let error = viewModel.error, viewModel.log.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Revisions",
+ message: error,
+ retryAction: { await viewModel.loadLog() }
+ )
} else if viewModel.log.isEmpty {
ContentUnavailableView(
"No Revisions",
@@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View {
}
@ViewBuilder
- private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
+ private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
if revisions.isEmpty {
ContentUnavailableView(
emptyTitle,
@@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View {
)
} else {
List(revisions) { revision in
- revisionRow(revision)
+ namedRevisionRow(revision)
}
.listStyle(.plain)
}
@@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View {
}
HStack {
- Text(revision.author.name)
- Spacer()
- Text(revision.author.time.relativeDescription)
+ Text(revision.author)
}
.font(.caption)
.foregroundStyle(.secondary)
@@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View {
.padding(.vertical, 4)
}
- @ViewBuilder
- private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
- let imageURLResolver = makeImageURLResolver(viewModel)
+ private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(revision.name)
+ .font(.headline)
+ Spacer()
+ Text(revision.displayShortId)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 6)
+ }
+ private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
guard let content = viewModel.readmeContent else {
return nil
}
+ return AnyView(
+ RenderedMarkupContentView(
+ content: sharedReadmeContent(from: content),
+ readmePath: viewModel.readmePath,
+ colorScheme: colorScheme,
+ ownerCanonicalName: repository.owner.canonicalName,
+ repositoryName: repository.name,
+ repositoryHost: "hg.sr.ht"
+ )
+ )
+ }
+
+ private func browseRevspecLabel(_ revspec: String) -> String {
+ if revspec == "tip" {
+ return "tip"
+ }
+ return revspec
+ }
+
+ private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
switch content {
case .html(let html):
- return AnyView(
- HTMLWebView(html: html, colorScheme: colorScheme)
- .frame(minHeight: 400)
- )
+ .html(html)
case .markdown(let text):
- return AnyView(
- HTMLWebView(
- html: markdownToHTML(text, imageURLResolver: imageURLResolver),
- colorScheme: colorScheme
- )
- .frame(minHeight: 400)
- )
+ .markdown(text)
case .org(let text):
- return AnyView(
- HTMLWebView(
- html: orgToHTML(text, imageURLResolver: imageURLResolver),
- colorScheme: colorScheme
- )
- .frame(minHeight: 400)
- )
+ .org(text)
case .plainText(let text):
- return AnyView(
- Text(text)
- .font(.system(.body, design: .monospaced))
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
- )
+ .plainText(text)
}
}
- private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? {
- let owner = repository.owner.canonicalName
- let repositoryName = repository.name
- let readmePath = viewModel.readmePath
-
- return { source in
- resolveRepositoryAssetURL(
- source,
- owner: owner,
- repositoryName: repositoryName,
- readmePath: readmePath
- )?
- .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht")
+ private func displayFileName(_ name: String) -> String {
+ name.hasSuffix("/") ? String(name.dropLast()) : name
+ }
+
+}
+
+private struct HgBrowseRefPickerSheet: View {
+ let viewModel: HgRepositoryDetailViewModel
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ NavigationStack {
+ List {
+ Section {
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec("tip")
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: "tip",
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.browseRevspec == "tip"
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "arrow.triangle.branch",
+ color: .blue,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "tag",
+ color: .orange,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+
+ if !viewModel.bookmarks.isEmpty {
+ Section("Bookmarks") {
+ ForEach(viewModel.bookmarks) { revision in
+ Button {
+ Task {
+ await viewModel.changeBrowseRevspec(revision.name)
+ isPresented = false
+ }
+ } label: {
+ refRow(
+ title: revision.name,
+ systemImage: "bookmark",
+ color: .purple,
+ isSelected: viewModel.browseRevspec == revision.name
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .navigationTitle("Select Ref")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ isPresented = false
+ }
+ }
+ }
}
}
- private func displayFileName(_ name: String) -> String {
- name.hasSuffix("/") ? String(name.dropLast()) : name
+ private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
+ HStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .foregroundStyle(color)
+
+ Text(title)
+ .font(.body.monospaced())
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.tint)
+ }
+ }
+ .contentShape(Rectangle())
}
+}
- private func visibilityLabel(_ visibility: Visibility) -> String {
- switch visibility {
- case .public:
- return "Public"
- case .unlisted:
- return "Unlisted"
- case .private:
- return "Private"
+private struct HgFileRow: View {
+ let file: HgFile
+
+ var body: some View {
+ Label {
+ Text(displayName)
+ .font(.body.monospaced())
+ .lineLimit(1)
+ } icon: {
+ Image(systemName: file.isDirectory ? "folder.fill" : "doc")
+ .foregroundStyle(file.isDirectory ? .blue : .secondary)
}
}
+
+ private var displayName: String {
+ file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
+ }
}
diff --git a/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
new file mode 100644
index 0000000..30ee3bb
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift
@@ -0,0 +1,545 @@
+import Foundation
+
+private struct HgRepositorySummaryResponse: Decodable, Sendable {
+ let repository: HgRepositorySummaryPayload?
+}
+
+private struct HgRepositorySummaryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let readme: String?
+ let nonPublishing: Bool?
+ let tip: HgSummaryTip?
+ let branches: HgNamedRevisionPage?
+ let tags: HgNamedRevisionPage?
+ let bookmarks: HgNamedRevisionPage?
+}
+
+private struct HgSummaryTip: Decodable, Sendable {
+ let id: String?
+ let author: String?
+ let description: String?
+ let branch: String?
+ let tags: [String]?
+
+ var resolvedRevision: HgRevision? {
+ guard
+ let id,
+ let author,
+ let description
+ else {
+ return nil
+ }
+
+ return HgRevision(
+ id: id,
+ author: author,
+ description: description,
+ branch: branch,
+ tags: tags
+ )
+ }
+}
+
+private struct HgRevisionLogResponse: Decodable, Sendable {
+ let repository: HgRevisionLogRepository?
+}
+
+private struct HgRevisionLogRepository: Decodable, Sendable {
+ let log: HgRevisionPage?
+}
+
+private struct HgReadmeFileResponse: Decodable, Sendable {
+ let repository: HgReadmeFileRepository?
+}
+
+private struct HgReadmeFileRepository: Decodable, Sendable {
+ let readme: String?
+}
+
+private struct HgFilesResponse: Decodable, Sendable {
+ let repository: HgFilesRepository?
+}
+
+private struct HgFilesRepository: Decodable, Sendable {
+ let files: HgFilePage?
+}
+
+private struct HgFilePage: Decodable, Sendable {
+ let results: [HgFile]
+ let cursor: String?
+}
+
+private struct HgNamedRevisionPage: Decodable, Sendable {
+ let results: [HgNamedRevision]
+ let cursor: String?
+
+ private enum CodingKeys: String, CodingKey {
+ case results
+ case cursor
+ }
+
+ init(results: [HgNamedRevision], cursor: String?) {
+ self.results = results
+ self.cursor = cursor
+ }
+
+ init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? []
+ self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
+ }
+}
+
+private struct HgCatResponse: Decodable, Sendable {
+ let repository: HgCatRepository?
+}
+
+private struct HgCatRepository: Decodable, Sendable {
+ let cat: String?
+}
+
+struct HgRevisionPage: Decodable, Sendable {
+ let results: [HgRevision]
+ let cursor: String?
+}
+
+struct HgRevision: Decodable, Sendable, Identifiable, Hashable {
+ let id: String
+ let author: String
+ let description: String
+ let branch: String?
+ let tags: [String]?
+
+ var displayShortId: String {
+ String(id.prefix(12))
+ }
+
+ var title: String {
+ description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ var body: String? {
+ let body = description
+ .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
+ .dropFirst()
+ .first
+ .map(String.init)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ return body?.isEmpty == false ? body : nil
+ }
+
+ var primaryName: String {
+ if let tag = tags?.first, !tag.isEmpty {
+ return tag
+ }
+ if let branch, !branch.isEmpty {
+ return branch
+ }
+ return displayShortId
+ }
+}
+
+struct HgFile: Decodable, Sendable, Hashable, Identifiable {
+ let name: String
+
+ var id: String { name }
+
+ var isDirectory: Bool {
+ name.hasSuffix("/")
+ }
+}
+
+struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable {
+ let name: String
+ let id: String
+
+ var displayShortId: String {
+ String(id.prefix(12))
+ }
+}
+
+@Observable
+@MainActor
+final class HgRepositoryDetailViewModel {
+ enum Tab: String, CaseIterable {
+ case summary = "Summary"
+ case browse = "Browse"
+ case log = "Log"
+ case tags = "Tags"
+ case branches = "Branches"
+ case bookmarks = "Bookmarks"
+ }
+
+ enum ReadmeContent {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+ }
+
+ let repository: RepositorySummary
+ private let client: SRHTClient
+
+ private(set) var summaryLoaded = false
+ private(set) var isLoadingSummary = false
+ private(set) var readmeContent: ReadmeContent?
+ private(set) var readmePath: String?
+ private(set) var nonPublishing = false
+ private(set) var tip: HgRevision?
+ private(set) var branches: [HgNamedRevision] = []
+ private(set) var tags: [HgNamedRevision] = []
+ private(set) var bookmarks: [HgNamedRevision] = []
+
+ private(set) var log: [HgRevision] = []
+ private(set) var isLoadingLog = false
+ private(set) var isLoadingMoreLog = false
+ private var logCursor: String?
+ private var hasMoreLog = true
+
+ private(set) var currentBrowsePath = ""
+ private(set) var pathStack: [String] = []
+ private(set) var files: [HgFile] = []
+ private(set) var fileContent: String?
+ private(set) var selectedFilePath: String?
+ private(set) var isLoadingBrowse = false
+ private(set) var browseRevspec = "tip"
+
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repository = repository
+ self.client = client
+ }
+
+ private static let summaryQuery = """
+ query hgRepositorySummary($rid: ID!) {
+ repository(rid: $rid) {
+ id
+ rid
+ name
+ description
+ visibility
+ readme
+ nonPublishing
+ tip {
+ id
+ author
+ description
+ branch
+ tags
+ }
+ branches {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ tags {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ bookmarks {
+ results {
+ name
+ id
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let logQuery = """
+ query hgRepositoryLog($rid: ID!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ log(cursor: $cursor) {
+ results {
+ id
+ author
+ description
+ branch
+ tags
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static func readmeFileQuery(filename: String) -> String {
+ """
+ query hgReadmeFile($rid: ID!) {
+ repository(rid: $rid) {
+ readme: cat(path: "\(filename)", revspec: "tip")
+ }
+ }
+ """
+ }
+
+ private static let readmeFilenames = [
+ "README.md", "README.org", "README.txt", "README",
+ "readme.md", "readme.org"
+ ]
+
+ private static let filesQuery = """
+ query hgFiles($rid: ID!, $path: String!, $revspec: String!) {
+ repository(rid: $rid) {
+ files(path: $path, revspec: $revspec) {
+ results {
+ name
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let catQuery = """
+ query hgCat($rid: ID!, $path: String!, $revspec: String!) {
+ repository(rid: $rid) {
+ cat(path: $path, revspec: $revspec)
+ }
+ }
+ """
+
+ func loadSummary() async {
+ guard !isLoadingSummary, !summaryLoaded else { return }
+ isLoadingSummary = true
+ defer { isLoadingSummary = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.summaryQuery,
+ variables: ["rid": repository.rid],
+ responseType: HgRepositorySummaryResponse.self
+ )
+
+ guard let repository = result.repository else {
+ summaryLoaded = true
+ return
+ }
+
+ tip = repository.tip?.resolvedRevision
+ branches = repository.branches?.results ?? []
+ tags = repository.tags?.results ?? []
+ bookmarks = repository.bookmarks?.results ?? []
+ nonPublishing = repository.nonPublishing ?? false
+
+ if let html = repository.readme, !html.isEmpty {
+ readmePath = nil
+ readmeContent = .html(html)
+ } else {
+ await loadReadmeFile()
+ }
+
+ summaryLoaded = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func loadLog() async {
+ guard !isLoadingLog else { return }
+ isLoadingLog = true
+ defer { isLoadingLog = false }
+ error = nil
+ logCursor = nil
+ hasMoreLog = true
+
+ do {
+ let page = try await fetchLogPage(cursor: nil)
+ log = page.results
+ logCursor = page.cursor
+ hasMoreLog = page.cursor != nil
+ } catch {
+ if isEmptyRepositoryError(error) {
+ log = []
+ logCursor = nil
+ hasMoreLog = false
+ } else {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ func loadMoreLogIfNeeded(currentItem: HgRevision) async {
+ guard let last = log.last,
+ last.id == currentItem.id,
+ hasMoreLog,
+ !isLoadingMoreLog else {
+ return
+ }
+
+ isLoadingMoreLog = true
+ defer { isLoadingMoreLog = false }
+
+ do {
+ let page = try await fetchLogPage(cursor: logCursor)
+ log.append(contentsOf: page.results)
+ logCursor = page.cursor
+ hasMoreLog = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage {
+ var variables: [String: any Sendable] = ["rid": repository.rid]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.logQuery,
+ variables: variables,
+ responseType: HgRevisionLogResponse.self
+ )
+ return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil)
+ }
+
+ private func loadReadmeFile() async {
+ for filename in Self.readmeFilenames {
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.readmeFileQuery(filename: filename),
+ variables: ["rid": repository.rid],
+ responseType: HgReadmeFileResponse.self
+ )
+
+ if let text = result.repository?.readme, !text.isEmpty {
+ readmePath = filename
+ if filename.hasSuffix(".md") {
+ readmeContent = .markdown(text)
+ } else if filename.hasSuffix(".org") {
+ readmeContent = .org(text)
+ } else {
+ readmeContent = .plainText(text)
+ }
+ return
+ }
+ } catch {
+ if isEmptyRepositoryError(error) {
+ readmeContent = nil
+ readmePath = nil
+ return
+ }
+ continue
+ }
+ }
+ }
+
+ func loadBrowseRoot() async {
+ await loadFiles(at: "")
+ }
+
+ func openFile(_ file: HgFile) async {
+ let path = joinedPath(for: file.name)
+ if file.isDirectory {
+ await loadFiles(at: path)
+ return
+ }
+
+ isLoadingBrowse = true
+ defer { isLoadingBrowse = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.catQuery,
+ variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
+ responseType: HgCatResponse.self
+ )
+
+ if let text = result.repository?.cat {
+ selectedFilePath = path
+ fileContent = text
+ } else {
+ await loadFiles(at: path)
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func navigateToPath(index: Int) async {
+ guard index >= 0, index <= pathStack.count else { return }
+ let targetPath = Array(pathStack.prefix(index)).joined(separator: "/")
+ await loadFiles(at: targetPath)
+ }
+
+ func dismissFileView() {
+ selectedFilePath = nil
+ fileContent = nil
+ }
+
+ func changeBrowseRevspec(_ newRevspec: String) async {
+ guard browseRevspec != newRevspec else { return }
+ browseRevspec = newRevspec
+ await loadBrowseRoot()
+ }
+
+ private func loadFiles(at path: String) async {
+ isLoadingBrowse = true
+ defer { isLoadingBrowse = false }
+ error = nil
+ selectedFilePath = nil
+ fileContent = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.filesQuery,
+ variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
+ responseType: HgFilesResponse.self
+ )
+
+ currentBrowsePath = path
+ pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
+ files = result.repository?.files?.results ?? []
+ } catch {
+ if isEmptyRepositoryError(error) {
+ currentBrowsePath = path
+ pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
+ files = []
+ } else {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+
+ private func joinedPath(for name: String) -> String {
+ let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name
+ return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)"
+ }
+
+ private func isEmptyRepositoryError(_ error: Error) -> Bool {
+ if let srhtError = error as? SRHTError,
+ case .graphQLErrors(let errors) = srhtError {
+ return errors.contains {
+ let message = $0.message.localizedLowercase
+ return message.contains("missing")
+ || message.contains("not found")
+ || message.contains("unknown revision")
+ || message.contains("unknown revision or path not in the working tree")
+ }
+ }
+
+ let message = error.localizedDescription.localizedLowercase
+ return message.contains("missing")
+ || message.contains("not found")
+ || message.contains("unknown revision")
+ }
+}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsView.swift b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
new file mode 100644
index 0000000..3894b9d
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositorySettingsView.swift
@@ -0,0 +1,239 @@
+import SwiftUI
+
+struct HgRepositorySettingsView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ let onDeleted: () -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: HgRepositorySettingsViewModel?
+ @State private var showDeleteConfirmation = false
+ @State private var pendingACLDeletion: HgACLEntry?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ settingsForm(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading settings…")
+ }
+ }
+ .navigationTitle("Settings")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
+ viewModel = vm
+ async let info: () = vm.loadRepositoryInfo()
+ async let acls: () = vm.loadACLs()
+ _ = await (info, acls)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ infoSection(viewModel)
+ accessSection(viewModel)
+ featuresSection(viewModel)
+ histeditSection(viewModel)
+ deleteSection(viewModel)
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .alert(
+ "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
+ isPresented: $showDeleteConfirmation
+ ) {
+ Button("Cancel", role: .cancel) {}
+ Button("Delete", role: .destructive) {
+ Task {
+ await viewModel.deleteRepository()
+ if viewModel.didDelete {
+ dismiss()
+ onDeleted()
+ }
+ }
+ }
+ } message: {
+ Text("This cannot be undone.")
+ }
+ .alert("Remove Access?", isPresented: Binding(
+ get: { pendingACLDeletion != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingACLDeletion = nil
+ }
+ }
+ )) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Access", role: .destructive) {
+ guard let entry = pendingACLDeletion else { return }
+ Task {
+ await viewModel.deleteACL(entry)
+ pendingACLDeletion = nil
+ }
+ }
+ } message: {
+ if let entry = pendingACLDeletion {
+ Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Info") {
+ LabeledContent("Name") {
+ Text(repository.name)
+ .font(.body.monospaced())
+ }
+
+ TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
+ .lineLimit(3...6)
+
+ Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+
+ Button {
+ Task { await viewModel.saveInfo() }
+ } label: {
+ if viewModel.isSavingInfo {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Save Changes")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isSavingInfo)
+ }
+ }
+
+ @ViewBuilder
+ private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Access") {
+ if viewModel.isLoadingACLs {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if viewModel.acls.isEmpty {
+ Text("No access entries yet.")
+ .foregroundStyle(.secondary)
+ } else {
+ ForEach(viewModel.acls) { entry in
+ HStack {
+ Text(entry.entity.canonicalName)
+ Spacer()
+ Text(entry.mode)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ pendingACLDeletion = entry
+ } label: {
+ Label("Remove Access", systemImage: "trash")
+ }
+ }
+ }
+ }
+
+ HStack {
+ TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Picker("", selection: Bindable(viewModel).newACLMode) {
+ Text("RO").tag("RO")
+ Text("RW").tag("RW")
+ }
+ .pickerStyle(.segmented)
+ .frame(width: 100)
+
+ Button {
+ Task { await viewModel.addACL() }
+ } label: {
+ if viewModel.isAddingACL {
+ ProgressView()
+ } else {
+ Text("Add")
+ }
+ }
+ .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ Text("Add a SourceHut user and choose read-only or read/write access.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Features") {
+ Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
+
+ Button {
+ Task { await viewModel.saveInfo() }
+ } label: {
+ if viewModel.isSavingInfo {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Save Changes")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isSavingInfo)
+ }
+ }
+
+ @ViewBuilder
+ private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section("Histedit") {
+ TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ .disabled(true)
+
+ Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Button("Remove Revision", role: .destructive) {}
+ .disabled(true)
+ }
+ }
+
+ @ViewBuilder
+ private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
+ Section {
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ if viewModel.isDeleting {
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ } else {
+ Text("Delete Repository")
+ .frame(maxWidth: .infinity)
+ }
+ }
+ .disabled(viewModel.isDeleting)
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
new file mode 100644
index 0000000..3e431f8
--- /dev/null
+++ b/Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift
@@ -0,0 +1,287 @@
+import Foundation
+
+private struct HgUpdateRepositoryResponse: Decodable, Sendable {
+ let updateRepository: HgUpdatedRepository
+}
+
+private struct HgUpdatedRepository: Decodable, Sendable {
+ let id: Int
+}
+
+private struct HgRepositoryInfoResponse: Decodable, Sendable {
+ let repository: HgRepositoryInfo?
+}
+
+private struct HgRepositoryInfo: Decodable, Sendable {
+ let description: String?
+ let visibility: Visibility
+ let nonPublishing: Bool?
+}
+
+private struct HgACLResponse: Decodable, Sendable {
+ let repository: HgACLRepository?
+}
+
+private struct HgACLRepository: Decodable, Sendable {
+ let accessControlList: HgACLPage
+}
+
+private struct HgACLPage: Decodable, Sendable {
+ let results: [HgACLEntry]
+ let cursor: String?
+}
+
+private struct HgUpdateACLResponse: Decodable, Sendable {
+ let updateACL: HgACLEntry
+}
+
+private struct HgDeleteACLResponse: Decodable, Sendable {
+ let deleteACL: HgDeletedACL
+}
+
+private struct HgDeletedACL: Decodable, Sendable {
+ let id: Int
+}
+
+private struct HgDeleteRepositoryResponse: Decodable, Sendable {
+ let deleteRepository: HgDeletedRepository
+}
+
+private struct HgDeletedRepository: Decodable, Sendable {
+ let id: Int
+}
+
+struct HgACLEntry: Decodable, Sendable, Identifiable {
+ let id: Int
+ let mode: String
+ let entity: Entity
+}
+
+@Observable
+@MainActor
+final class HgRepositorySettingsViewModel {
+ let repositoryId: Int
+ let repositoryRid: String
+ let repositoryName: String
+ private let client: SRHTClient
+
+ var editedDescription: String
+ var editedVisibility: Visibility
+ var editedNonPublishing: Bool
+ var isSavingInfo = false
+
+ private(set) var acls: [HgACLEntry] = []
+ private(set) var isLoadingACLs = false
+ var newACLEntity = ""
+ var newACLMode = "RO"
+ var isAddingACL = false
+ var isDeletingACL = false
+
+ var histeditRevision = ""
+ var isDeleting = false
+ var didDelete = false
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repositoryId = repository.id
+ self.repositoryRid = repository.rid
+ self.repositoryName = repository.name
+ self.client = client
+ self.editedDescription = repository.description ?? ""
+ self.editedVisibility = repository.visibility
+ self.editedNonPublishing = false
+ }
+
+ private static let updateRepositoryMutation = """
+ mutation updateRepository($id: Int!, $input: RepoInput!) {
+ updateRepository(id: $id, input: $input) {
+ id
+ }
+ }
+ """
+
+ private static let accessControlListQuery = """
+ query hgAccessControlList($rid: ID!) {
+ repository(rid: $rid) {
+ accessControlList {
+ results {
+ id
+ mode
+ entity { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ private static let updateACLMutation = """
+ mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
+ updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
+ id
+ mode
+ entity { canonicalName }
+ }
+ }
+ """
+
+ private static let deleteACLMutation = """
+ mutation deleteACL($id: Int!) {
+ deleteACL(id: $id) { id }
+ }
+ """
+
+ private static let deleteRepositoryMutation = """
+ mutation deleteRepository($id: Int!) {
+ deleteRepository(id: $id) { id }
+ }
+ """
+
+ private static let repositoryInfoQuery = """
+ query hgRepositoryInfo($rid: ID!) {
+ repository(rid: $rid) {
+ description
+ visibility
+ nonPublishing
+ }
+ }
+ """
+
+ func loadRepositoryInfo() async {
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.repositoryInfoQuery,
+ variables: ["rid": repositoryRid],
+ responseType: HgRepositoryInfoResponse.self
+ )
+
+ if let repository = result.repository {
+ editedDescription = repository.description ?? ""
+ editedVisibility = repository.visibility
+ editedNonPublishing = repository.nonPublishing ?? false
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func saveInfo() async {
+ isSavingInfo = true
+ defer { isSavingInfo = false }
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "description": editedDescription,
+ "visibility": editedVisibility.rawValue,
+ "nonPublishing": editedNonPublishing
+ ]
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.updateRepositoryMutation,
+ variables: ["id": repositoryId, "input": input],
+ responseType: HgUpdateRepositoryResponse.self
+ )
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func loadACLs() async {
+ guard !isLoadingACLs else { return }
+ isLoadingACLs = true
+ defer { isLoadingACLs = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.accessControlListQuery,
+ variables: ["rid": repositoryRid],
+ responseType: HgACLResponse.self
+ )
+ acls = result.repository?.accessControlList.results ?? []
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func addACL() async {
+ let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !rawEntity.isEmpty else { return }
+ let entity = hgCanonicalEntity(from: rawEntity)
+ isAddingACL = true
+ defer { isAddingACL = false }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.updateACLMutation,
+ variables: [
+ "repoId": repositoryId,
+ "mode": newACLMode,
+ "entity": entity
+ ],
+ responseType: HgUpdateACLResponse.self
+ )
+ if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
+ acls[index] = result.updateACL
+ } else {
+ acls.append(result.updateACL)
+ }
+ newACLEntity = ""
+ } catch {
+ let message = error.localizedDescription
+ if message.localizedCaseInsensitiveContains("No such repository or user found") {
+ self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
+ } else {
+ self.error = message
+ }
+ }
+ }
+
+ private func hgCanonicalEntity(from input: String) -> String {
+ let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
+ return "~\(username)"
+ }
+
+ func deleteACL(_ entry: HgACLEntry) async {
+ isDeletingACL = true
+ defer { isDeletingACL = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.deleteACLMutation,
+ variables: ["id": entry.id],
+ responseType: HgDeleteACLResponse.self
+ )
+ acls.removeAll { $0.id == entry.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteRepository() async {
+ isDeleting = true
+ defer { isDeleting = false }
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .hg,
+ query: Self.deleteRepositoryMutation,
+ variables: ["id": repositoryId],
+ responseType: HgDeleteRepositoryResponse.self
+ )
+ didDelete = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
new file mode 100644
index 0000000..b53885e
--- /dev/null
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -0,0 +1,1125 @@
+import SwiftUI
+import WebKit
+
+struct ReadmeView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var isShowingRepositoryDetails = false
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ headerSection
+ metadataSection
+ repositoryDetailsSection
+ latestChangeSection
+ readmeSection
+ }
+ .padding()
+ }
+ .task {
+ async let readme: () = viewModel.loadReadme()
+ async let commits: () = viewModel.loadCommits()
+ async let refs: () = viewModel.loadReferences()
+ _ = await (readme, commits, refs)
+ }
+ .navigationDestination(for: CommitSummary.self) { commit in
+ CommitDetailView(
+ commitSummary: commit,
+ repository: viewModel.repository
+ )
+ }
+ }
+
+ @ViewBuilder
+ private var headerSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(viewModel.repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Text(viewModel.repository.name)
+ .font(.largeTitle.weight(.semibold))
+ if let description = viewModel.repository.description, !description.isEmpty {
+ Text(description)
+ .font(.body)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var metadataSection: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ SummaryMetadataRow(
+ icon: "arrow.triangle.branch",
+ title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
+ )
+
+ if let readmePath = viewModel.readmePath {
+ SummaryMetadataRow(
+ icon: "doc.text",
+ title: readmePath
+ )
+ }
+ }
+ }
+
+ private var repositoryDetailsSection: some View {
+ DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
+ VStack(alignment: .leading, spacing: 12) {
+ SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
+ SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
+ SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
+ SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
+ }
+ .padding(.top, 8)
+ } label: {
+ Text("Repository Details")
+ .font(.subheadline.weight(.medium))
+ }
+ }
+
+ @ViewBuilder
+ private var latestChangeSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
+ SRHTLoadingStateView(message: "Loading latest change…")
+ .frame(maxWidth: .infinity)
+ } else if let commit = viewModel.commits.first {
+ NavigationLink(value: commit) {
+ SummaryMetadataRow(
+ icon: "arrow.trianglehead.clockwise",
+ title: commit.title,
+ subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ } else if let error = viewModel.error, viewModel.commits.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Latest Change",
+ message: error,
+ retryAction: { await viewModel.loadCommits() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No Recent Commits",
+ systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
+ description: Text("This repository does not have any commit history yet.")
+ )
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var readmeSection: some View {
+ if viewModel.isLoadingReadme {
+ SRHTLoadingStateView(message: "Loading README…")
+ } else if let content = viewModel.readmeContent {
+ RenderedMarkupContentView(
+ content: sharedReadmeContent(from: content),
+ readmePath: viewModel.readmePath,
+ colorScheme: colorScheme,
+ ownerCanonicalName: viewModel.repository.owner.canonicalName,
+ repositoryName: viewModel.repository.name
+ )
+ } else if let error = viewModel.error, !viewModel.readmeLoaded {
+ SRHTErrorStateView(
+ title: "Couldn't Load README",
+ message: error,
+ retryAction: { await viewModel.loadReadme() }
+ )
+ } else {
+ ContentUnavailableView(
+ "No README",
+ systemImage: "doc.text",
+ description: Text("This repository does not have a README file.")
+ )
+ }
+ }
+
+ private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
+ switch content {
+ case .html(let html):
+ .html(html)
+ case .markdown(let text):
+ .markdown(text)
+ case .org(let text):
+ .org(text)
+ case .plainText(let text):
+ .plainText(text)
+ }
+ }
+}
+
+enum RenderedMarkupContent: Sendable {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+}
+
+struct RenderedMarkupContentView: View {
+ let content: RenderedMarkupContent
+ let readmePath: String?
+ let colorScheme: ColorScheme
+ let ownerCanonicalName: String
+ let repositoryName: String
+ var repositoryHost = "git.sr.ht"
+
+ @State private var renderedHTML: String?
+
+ private var cacheKey: String {
+ switch content {
+ case .html(let html):
+ "html:\(readmePath ?? "custom"):\(html)"
+ case .markdown(let text):
+ "markdown:\(readmePath ?? ""):\(text)"
+ case .org(let text):
+ "org:\(readmePath ?? ""):\(text)"
+ case .plainText(let text):
+ "plain:\(readmePath ?? ""):\(text)"
+ }
+ }
+
+ var body: some View {
+ Group {
+ switch content {
+ case .html(let html):
+ HTMLWebView(html: html, colorScheme: colorScheme)
+ case .markdown, .org:
+ if let renderedHTML {
+ HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
+ } else {
+ SRHTLoadingStateView(message: "Preparing README…")
+ }
+ case .plainText(let text):
+ Text(text)
+ .font(.system(.body, design: .monospaced))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ .task(id: cacheKey) {
+ await prepareHTMLIfNeeded()
+ }
+ }
+
+ private func prepareHTMLIfNeeded() async {
+ switch content {
+ case .html, .plainText:
+ renderedHTML = nil
+ case .markdown(let text):
+ if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
+ renderedHTML = cached
+ return
+ }
+ let html = await Task.detached(priority: .userInitiated) {
+ markdownToHTML(text) { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: ownerCanonicalName,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+ }
+ }.value
+ RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ case .org(let text):
+ if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
+ renderedHTML = cached
+ return
+ }
+ let html = await Task.detached(priority: .userInitiated) {
+ orgToHTML(text) { source in
+ resolveRepositoryAssetURL(
+ source,
+ owner: ownerCanonicalName,
+ repositoryName: repositoryName,
+ readmePath: readmePath
+ )?
+ .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
+ }
+ }.value
+ RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ }
+ }
+}
+
+private final class RenderedReadmeHTMLCache: @unchecked Sendable {
+ static let shared = RenderedReadmeHTMLCache()
+
+ private let storage = NSCache<NSString, NSString>()
+
+ func html(forKey key: String) -> String? {
+ storage.object(forKey: key as NSString) as String?
+ }
+
+ func setHTML(_ html: String, forKey key: String) {
+ storage.setObject(html as NSString, forKey: key as NSString)
+ }
+
+ func removeAll() {
+ storage.removeAllObjects()
+ }
+}
+
+@MainActor
+func clearWebContentRenderCaches() {
+ RenderedReadmeHTMLCache.shared.removeAll()
+ HTMLWebViewCoordinator.heightCache.removeAllObjects()
+}
+
+// MARK: - Markdown to HTML
+
+nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ let normalizedText = text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var html = ""
+ var inCodeBlock = false
+ var inList = false
+ var paragraph: [String] = []
+
+ func flushParagraph() {
+ if !paragraph.isEmpty {
+ let normalizedParagraph = paragraph
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ html += "<p>" + normalizedParagraph + "</p>\n"
+ paragraph = []
+ }
+ }
+
+ func closeList() {
+ if inList {
+ html += "</ul>\n"
+ inList = false
+ }
+ }
+
+ for line in lines {
+ // Fenced code blocks
+ if line.hasPrefix("```") {
+ if inCodeBlock {
+ html += "</code></pre>\n"
+ inCodeBlock = false
+ } else {
+ flushParagraph()
+ closeList()
+ html += "<pre><code>"
+ inCodeBlock = true
+ }
+ continue
+ }
+
+ if inCodeBlock {
+ html += escapeHTML(line) + "\n"
+ continue
+ }
+
+ // Headings
+ if line.hasPrefix("### ") {
+ flushParagraph()
+ closeList()
+ html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
+ continue
+ }
+ if line.hasPrefix("## ") {
+ flushParagraph()
+ closeList()
+ html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
+ continue
+ }
+ if line.hasPrefix("# ") {
+ flushParagraph()
+ closeList()
+ html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
+ continue
+ }
+
+ // List items
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
+ flushParagraph()
+ if !inList {
+ html += "<ul>\n"
+ inList = true
+ }
+ html += "<li>" + renderTaskListItem(
+ String(trimmed.dropFirst(2)),
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ // Blank line
+ if trimmed.isEmpty {
+ flushParagraph()
+ closeList()
+ continue
+ }
+
+ // Regular text — accumulate into paragraph
+ paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
+ }
+
+ // Flush remaining state
+ if inCodeBlock {
+ html += "</code></pre>\n"
+ }
+ flushParagraph()
+ closeList()
+
+ return html
+}
+
+nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ var result = escapeHTML(text)
+
+ // Images: ![alt](url)
+ result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
+ let alt = nsText.substring(with: match.range(at: 1))
+ let source = nsText.substring(with: match.range(at: 2))
+ let resolvedSource = imageURLResolver?(source) ?? source
+ return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
+ }
+ // Links: [text](url)
+ result = result.replacingOccurrences(
+ of: #"\[([^\]]+)\]\(([^)]+)\)"#,
+ with: #"<a href="$2">$1</a>"#,
+ options: .regularExpression
+ )
+ // Bold: **text**
+ result = result.replacingOccurrences(
+ of: #"\*\*(.+?)\*\*"#,
+ with: "<strong>$1</strong>",
+ options: .regularExpression
+ )
+ // Italic: *text*
+ result = result.replacingOccurrences(
+ of: #"\*(.+?)\*"#,
+ with: "<em>$1</em>",
+ options: .regularExpression
+ )
+ // Inline code: `text`
+ result = result.replacingOccurrences(
+ of: #"`([^`]+)`"#,
+ with: "<code>$1</code>",
+ options: .regularExpression
+ )
+
+ return result
+}
+
+// MARK: - Org-mode to HTML
+
+nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ let normalizedText = text
+ .replacingOccurrences(of: "\r\n", with: "\n")
+ .replacingOccurrences(of: "\r", with: "\n")
+ let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var html = ""
+ var listType: OrgListType?
+ var inQuoteBlock = false
+ var inPropertyDrawer = false
+ var srcLanguage: String?
+ var paragraph: [String] = []
+ var tableRows: [[String]] = []
+ var propertyRows: [(String, String)] = []
+
+ func flushParagraph() {
+ if !paragraph.isEmpty {
+ let normalizedParagraph = paragraph
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
+ paragraph = []
+ }
+ }
+
+ func closeList() {
+ switch listType {
+ case .unordered:
+ html += "</ul>\n"
+ case .ordered:
+ html += "</ol>\n"
+ case nil:
+ break
+ }
+ listType = nil
+ }
+
+ func flushTable() {
+ guard !tableRows.isEmpty else { return }
+ let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
+ let headerRow = tableRows.first ?? []
+ let bodyRows: [[String]]
+
+ html += "<table>\n"
+ if hasHeaderSeparator {
+ html += "<thead><tr>"
+ for cell in headerRow {
+ html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
+ }
+ html += "</tr></thead>\n<tbody>\n"
+ bodyRows = Array(tableRows.dropFirst(2))
+ } else {
+ bodyRows = tableRows
+ }
+
+ for row in bodyRows {
+ html += "<tr>"
+ for cell in row {
+ html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
+ }
+ html += "</tr>\n"
+ }
+
+ if hasHeaderSeparator {
+ html += "</tbody>\n"
+ }
+ html += "</table>\n"
+ tableRows = []
+ }
+
+ func flushPropertyDrawer() {
+ guard !propertyRows.isEmpty else { return }
+ html += "<dl class=\"org-properties\">\n"
+ for (key, value) in propertyRows {
+ html += "<dt>" + escapeHTML(key) + "</dt>"
+ html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
+ }
+ html += "</dl>\n"
+ propertyRows = []
+ }
+
+ func closeQuoteBlock() {
+ if inQuoteBlock {
+ flushParagraph()
+ html += "</blockquote>\n"
+ inQuoteBlock = false
+ }
+ }
+
+ func closeSourceBlock() {
+ if srcLanguage != nil {
+ html += "</code></pre>\n"
+ srcLanguage = nil
+ }
+ }
+
+ func flushBlockState() {
+ flushParagraph()
+ closeList()
+ flushTable()
+ flushPropertyDrawer()
+ }
+
+ for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+
+ if srcLanguage != nil {
+ if trimmed.lowercased() == "#+end_src" {
+ closeSourceBlock()
+ } else {
+ html += escapeHTML(line) + "\n"
+ }
+ continue
+ }
+
+ if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
+ closeQuoteBlock()
+ continue
+ }
+
+ if trimmed.lowercased().hasPrefix("#+begin_src") {
+ closeQuoteBlock()
+ flushBlockState()
+ let language = trimmed
+ .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
+ .dropFirst()
+ .first
+ .map(String.init)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
+ html += "<pre><code\(classAttribute)>"
+ srcLanguage = language ?? ""
+ continue
+ }
+
+ if trimmed.lowercased() == "#+begin_quote" {
+ flushBlockState()
+ html += "<blockquote>\n"
+ inQuoteBlock = true
+ continue
+ }
+
+ if trimmed == ":PROPERTIES:" {
+ closeQuoteBlock()
+ flushBlockState()
+ inPropertyDrawer = true
+ continue
+ }
+
+ if trimmed == ":END:", inPropertyDrawer {
+ flushPropertyDrawer()
+ inPropertyDrawer = false
+ continue
+ }
+
+ if inPropertyDrawer,
+ trimmed.hasPrefix(":"),
+ let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
+ let keyStart = trimmed.index(after: trimmed.startIndex)
+ let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
+ let valueStart = trimmed.index(after: secondColonIndex)
+ let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
+ if !key.isEmpty {
+ propertyRows.append((key, value))
+ continue
+ }
+ }
+
+ if isOrgTableLine(trimmed) {
+ closeQuoteBlock()
+ flushParagraph()
+ closeList()
+ tableRows.append(parseOrgTableRow(trimmed))
+ continue
+ } else {
+ flushTable()
+ }
+
+ // Org headings: * heading, ** heading, *** heading
+ if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
+ closeQuoteBlock()
+ flushBlockState()
+ let level = match.1.count
+ let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
+ html += "<h\(level)>" + content + "</h\(level)>\n"
+ continue
+ }
+
+ // List items: - item
+ if trimmed.hasPrefix("- ") {
+ flushParagraph()
+ flushPropertyDrawer()
+ if listType != .unordered {
+ closeList()
+ html += "<ul>\n"
+ listType = .unordered
+ }
+ html += "<li>" + renderTaskListItem(
+ String(trimmed.dropFirst(2)),
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ if let orderedItem = orderedListItem(in: trimmed) {
+ flushParagraph()
+ flushPropertyDrawer()
+ if listType != .ordered {
+ closeList()
+ html += "<ol>\n"
+ listType = .ordered
+ }
+ html += "<li>" + renderTaskListItem(
+ orderedItem,
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ continue
+ }
+
+ // Blank line
+ if trimmed.isEmpty {
+ if inQuoteBlock {
+ flushParagraph()
+ } else {
+ flushBlockState()
+ }
+ continue
+ }
+
+ // Regular text
+ paragraph.append(line)
+ }
+
+ closeSourceBlock()
+ closeQuoteBlock()
+ flushBlockState()
+
+ return html
+}
+
+nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
+ var result = escapeHTML(text)
+ var protectedFragments: [String: String] = [:]
+
+ result = protectMatches(
+ in: result,
+ pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let url = nsText.substring(with: match.range(at: 1))
+ let label = nsText.substring(with: match.range(at: 2))
+ if let imageHTML = makeOrgImageHTML(
+ source: url,
+ alt: label,
+ imageURLResolver: imageURLResolver
+ ) {
+ return imageHTML
+ }
+ return #"<a href="\#(url)">\#(label)</a>"#
+ }
+ result = protectMatches(
+ in: result,
+ pattern: #"\[\[([^\]]+)\]\]"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let url = nsText.substring(with: match.range(at: 1))
+ if let imageHTML = makeOrgImageHTML(
+ source: url,
+ alt: nil,
+ imageURLResolver: imageURLResolver
+ ) {
+ return imageHTML
+ }
+ return #"<a href="\#(url)">\#(url)</a>"#
+ }
+ result = protectMatches(
+ in: result,
+ pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let tildeRange = match.range(at: 1)
+ let equalsRange = match.range(at: 2)
+ let codeText: String
+ if tildeRange.location != NSNotFound {
+ codeText = nsText.substring(with: tildeRange)
+ } else {
+ codeText = nsText.substring(with: equalsRange)
+ }
+ return "<code>\(codeText)</code>"
+ }
+
+ // Bold: *text*
+ result = result.replacingOccurrences(
+ of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
+ with: "<strong>$1</strong>",
+ options: .regularExpression
+ )
+ // Italic: /text/
+ result = result.replacingOccurrences(
+ of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
+ with: "<em>$1</em>",
+ options: .regularExpression
+ )
+
+ for (token, fragment) in protectedFragments {
+ result = result.replacingOccurrences(of: token, with: fragment)
+ }
+
+ return result
+}
+
+// MARK: - HTML Escaping
+
+nonisolated func escapeHTML(_ text: String) -> String {
+ text.replacingOccurrences(of: "&", with: "&amp;")
+ .replacingOccurrences(of: "<", with: "&lt;")
+ .replacingOccurrences(of: ">", with: "&gt;")
+ .replacingOccurrences(of: "\"", with: "&quot;")
+}
+
+nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
+ escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
+}
+
+nonisolated private func isOrgTableLine(_ line: String) -> Bool {
+ line.hasPrefix("|") && line.hasSuffix("|")
+}
+
+nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
+ line
+ .split(separator: "|", omittingEmptySubsequences: false)
+ .dropFirst()
+ .dropLast()
+ .map { String($0).trimmingCharacters(in: .whitespaces) }
+}
+
+nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
+ let trimmed = cell.trimmingCharacters(in: .whitespaces)
+ return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
+}
+
+private enum OrgListType {
+ case unordered
+ case ordered
+}
+
+nonisolated private func orderedListItem(in line: String) -> String? {
+ guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
+ return String(match.2)
+}
+
+nonisolated private func protectMatches(
+ in text: String,
+ pattern: String,
+ protectedFragments: inout [String: String],
+ transform: (NSTextCheckingResult, NSString) -> String
+) -> String {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
+ var result = text
+ let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
+
+ for match in matches.reversed() {
+ let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
+ let nsText = result as NSString
+ protectedFragments[token] = transform(match, nsText)
+ result = nsText.replacingCharacters(in: match.range, with: token)
+ }
+
+ return result
+}
+
+nonisolated private func replaceMatches(
+ in text: String,
+ pattern: String,
+ transform: (NSTextCheckingResult, NSString) -> String
+) -> String {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
+ var result = text
+ let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
+
+ for match in matches.reversed() {
+ let nsText = result as NSString
+ let replacement = transform(match, nsText)
+ result = nsText.replacingCharacters(in: match.range, with: replacement)
+ }
+
+ return result
+}
+
+nonisolated private func makeOrgImageHTML(
+ source: String,
+ alt: String?,
+ imageURLResolver: ((String) -> String?)?
+) -> String? {
+ guard isRenderableImageSource(source) else { return nil }
+ let resolvedSource = imageURLResolver?(source) ?? source
+ let altText = escapeHTMLAttribute(alt ?? "")
+ return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
+}
+
+nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
+ let lowercased = source.lowercased()
+ return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
+ .contains(where: { lowercased.hasSuffix($0) })
+}
+
+nonisolated func resolveRepositoryAssetURL(
+ _ source: String,
+ owner: String,
+ repositoryName: String,
+ readmePath: String?
+) -> String? {
+ let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSource.isEmpty else { return nil }
+
+ if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
+ return trimmedSource
+ }
+
+ let relativePath: String
+ if trimmedSource.hasPrefix("/") {
+ relativePath = String(trimmedSource.dropFirst())
+ } else {
+ let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
+ relativePath = normalizeRepositoryPath(
+ (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
+ )
+ }
+
+ guard !relativePath.isEmpty else { return nil }
+ return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
+}
+
+nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
+ var components: [String] = []
+
+ for part in path.split(separator: "/") {
+ switch part {
+ case ".":
+ continue
+ case "..":
+ if !components.isEmpty {
+ components.removeLast()
+ }
+ default:
+ components.append(String(part))
+ }
+ }
+
+ return components.joined(separator: "/")
+}
+
+nonisolated private func renderTaskListItem(
+ _ text: String,
+ inlineRenderer: (String) -> String
+) -> String {
+ let trimmed = text.trimmingCharacters(in: .whitespaces)
+ guard trimmed.count >= 4 else {
+ return inlineRenderer(text)
+ }
+
+ let prefix = String(trimmed.prefix(4))
+ let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
+
+ switch prefix {
+ case "[ ] ":
+ return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
+ case "[x] ", "[X] ":
+ return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
+ default:
+ return inlineRenderer(text)
+ }
+}
+
+// MARK: - WKWebView Wrapper
+
+/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
+struct HTMLWebView: View {
+ let html: String
+ let colorScheme: ColorScheme
+ var style: HTMLWebViewStyle = .readme
+ @State private var contentHeight: CGFloat = 1
+ @State private var loadError: String?
+ @State private var reloadToken = 0
+
+ var body: some View {
+ Group {
+ if let loadError {
+ SRHTErrorStateView(
+ title: "Couldn't Render Content",
+ message: loadError,
+ retryAction: {
+ await MainActor.run {
+ self.loadError = nil
+ reloadToken += 1
+ }
+ }
+ )
+ } else {
+ HTMLWebViewRepresentable(
+ html: html,
+ colorScheme: colorScheme,
+ style: style,
+ dynamicHeight: $contentHeight,
+ loadError: $loadError,
+ reloadToken: reloadToken
+ )
+ .frame(height: max(contentHeight, 1))
+ }
+ }
+ }
+}
+
+struct HTMLWebViewStyle: Sendable {
+ let bodyFontSize: Int
+ let lineHeight: Double
+ let codeFontSize: Int
+ let viewport: String
+
+ static let readme = HTMLWebViewStyle(
+ bodyFontSize: 16,
+ lineHeight: 1.6,
+ codeFontSize: 13,
+ viewport: "width=device-width, initial-scale=1, maximum-scale=1"
+ )
+
+ static let commentPreview = HTMLWebViewStyle(
+ bodyFontSize: 15,
+ lineHeight: 1.5,
+ codeFontSize: 12,
+ viewport: "width=device-width, initial-scale=1, user-scalable=no"
+ )
+}
+
+private struct HTMLWebViewRepresentable: UIViewRepresentable {
+ let html: String
+ let colorScheme: ColorScheme
+ let style: HTMLWebViewStyle
+ @Binding var dynamicHeight: CGFloat
+ @Binding var loadError: String?
+ let reloadToken: Int
+
+ func makeCoordinator() -> HTMLWebViewCoordinator {
+ HTMLWebViewCoordinator(parent: self)
+ }
+
+ func makeUIView(context: Context) -> WKWebView {
+ let config = WKWebViewConfiguration()
+ config.defaultWebpagePreferences.allowsContentJavaScript = true
+ config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
+ let webView = WKWebView(frame: .zero, configuration: config)
+ webView.isOpaque = false
+ webView.backgroundColor = .clear
+ webView.clipsToBounds = false
+ webView.scrollView.isScrollEnabled = false
+ webView.scrollView.contentInsetAdjustmentBehavior = .never
+ webView.scrollView.clipsToBounds = false
+ webView.navigationDelegate = context.coordinator
+ return webView
+ }
+
+ func updateUIView(_ webView: WKWebView, context: Context) {
+ let textColor = colorScheme == .dark ? "#fff" : "#000"
+ let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
+
+ let wrapped = """
+ <!DOCTYPE html>
+ <html>
+ <head>
+ <meta name="viewport" content="\(style.viewport)">
+ <style>
+ body {
+ font-family: -apple-system, system-ui, sans-serif;
+ font-size: \(style.bodyFontSize)px;
+ line-height: \(style.lineHeight);
+ padding: 0;
+ margin: 0;
+ color: \(textColor);
+ background: transparent;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ max-width: 100%;
+ }
+ * { box-sizing: border-box; }
+ h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
+ p:first-child { margin-top: 0; }
+ p:last-child { margin-bottom: 0; }
+ pre, code {
+ font-family: ui-monospace, Menlo, monospace;
+ font-size: \(style.codeFontSize)px;
+ background: rgba(128, 128, 128, 0.15);
+ padding: 2px 4px;
+ border-radius: 3px;
+ }
+ pre code { padding: 0; background: none; }
+ pre {
+ padding: 8px;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ }
+ img { max-width: 100%; height: auto; }
+ input[type="checkbox"] {
+ margin-right: 0.45rem;
+ vertical-align: middle;
+ }
+ .task-list-item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.1rem;
+ }
+ a { color: \(linkColor); }
+ table { border-collapse: collapse; width: 100%; }
+ td, th { border: 1px solid #ccc; padding: 4px 8px; }
+ </style>
+ </head>
+ <body>\(html)</body>
+ </html>
+ """
+
+ if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
+ let height = CGFloat(cachedHeight)
+ if abs(dynamicHeight - height) > 0.5 {
+ dynamicHeight = height
+ }
+ }
+
+ guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
+ context.coordinator.lastHTML = wrapped
+ context.coordinator.lastReloadToken = reloadToken
+ if loadError != nil {
+ DispatchQueue.main.async {
+ self.loadError = nil
+ }
+ }
+ webView.loadHTMLString(wrapped, baseURL: nil)
+ }
+}
+
+private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
+ static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
+ static let heightCache = NSCache<NSString, NSNumber>()
+
+ let parent: HTMLWebViewRepresentable
+ var lastHTML: String?
+ var lastReloadToken = 0
+
+ init(parent: HTMLWebViewRepresentable) {
+ self.parent = parent
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ DispatchQueue.main.async {
+ self.parent.loadError = nil
+ }
+ updateHeight(for: webView)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
+ guard let self, let webView else { return }
+ self.updateHeight(for: webView)
+ }
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ handleLoadFailure(error)
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ handleLoadFailure(error)
+ }
+
+ private func handleLoadFailure(_ error: Error) {
+ let nsError = error as NSError
+ guard nsError.code != NSURLErrorCancelled else { return }
+ DispatchQueue.main.async {
+ self.parent.loadError = "The content could not be displayed right now."
+ }
+ }
+
+ private func updateHeight(for webView: WKWebView) {
+ let script = """
+ Math.max(
+ document.body.scrollHeight,
+ document.body.offsetHeight,
+ document.documentElement.scrollHeight,
+ document.documentElement.offsetHeight,
+ Math.ceil(document.body.getBoundingClientRect().height),
+ Math.ceil(document.documentElement.getBoundingClientRect().height)
+ )
+ """
+
+ webView.evaluateJavaScript(script) { [weak self] result, _ in
+ guard let value = result as? Double, value > 0 else { return }
+ let height = ceil(value) + 4
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if let html = self.lastHTML {
+ Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
+ }
+ if abs(self.parent.dynamicHeight - height) > 0.5 {
+ self.parent.dynamicHeight = height
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/ReferencesListView.swift b/Hutch/Views/Repositories/ReferencesListView.swift
new file mode 100644
index 0000000..615ba25
--- /dev/null
+++ b/Hutch/Views/Repositories/ReferencesListView.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+struct ReferencesListView: View {
+ let viewModel: RepositoryDetailViewModel
+
+ var body: some View {
+ List {
+ if !viewModel.branches.isEmpty {
+ Section("Branches") {
+ ForEach(viewModel.branches, id: \.name) { ref in
+ ReferenceRow(reference: ref, prefix: "refs/heads/")
+ }
+ }
+ }
+
+ if !viewModel.tags.isEmpty {
+ Section("Tags") {
+ ForEach(viewModel.tags, id: \.name) { ref in
+ ReferenceRow(reference: ref, prefix: "refs/tags/")
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .overlay {
+ if viewModel.isLoadingRefs, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ SRHTLoadingStateView(message: "Loading references…")
+ } else if let error = viewModel.error, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load References",
+ message: error,
+ retryAction: { await viewModel.loadReferences() }
+ )
+ } else if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ ContentUnavailableView(
+ "No References",
+ systemImage: "arrow.triangle.branch",
+ description: Text("This repository has no branches or tags.")
+ )
+ }
+ }
+ .task {
+ if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
+ await viewModel.loadReferences()
+ }
+ }
+ .refreshable {
+ await viewModel.loadReferences()
+ }
+ }
+}
+
+private struct ReferenceRow: View {
+ let reference: Reference
+ let prefix: String
+
+ var body: some View {
+ HStack {
+ Label {
+ Text(shortName)
+ .font(.body.monospaced())
+ } icon: {
+ Image(systemName: icon)
+ .foregroundStyle(iconColor)
+ }
+
+ Spacer()
+
+ Text(String((reference.target ?? "").prefix(8)))
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var shortName: String {
+ if reference.name.hasPrefix(prefix) {
+ String(reference.name.dropFirst(prefix.count))
+ } else {
+ reference.name
+ }
+ }
+
+ private var icon: String {
+ prefix.contains("tags") ? "tag" : "arrow.triangle.branch"
+ }
+
+ private var iconColor: Color {
+ prefix.contains("tags") ? .orange : .blue
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
new file mode 100644
index 0000000..9044e56
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -0,0 +1,107 @@
+import SwiftUI
+
+struct RepositoryDetailView: View {
+ var repository: RepositorySummary
+ var onDeleted: (() -> Void)?
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryDetailViewModel?
+ @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
+ @State private var showSettings = false
+ @State private var displayName: String
+
+ init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
+ self.repository = repository
+ self.onDeleted = onDeleted
+ self._displayName = State(initialValue: repository.name)
+ }
+
+ var body: some View {
+ if repository.service == .hg {
+ HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
+ } else {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading repository…")
+ }
+ }
+ .navigationTitle(displayName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ Button {
+ showSettings = true
+ } label: {
+ Image(systemName: "gear")
+ }
+ }
+ }
+ .sheet(isPresented: $showSettings) {
+ RepositorySettingsView(
+ repository: repository,
+ branches: viewModel?.branches ?? [],
+ client: appState.client,
+ onRenamed: { newName in
+ displayName = newName
+ },
+ onDeleted: {
+ dismiss()
+ onDeleted?()
+ }
+ )
+ }
+ .task {
+ if viewModel == nil {
+ viewModel = RepositoryDetailViewModel(
+ repository: repository,
+ client: appState.client
+ )
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View {
+ VStack(spacing: 0) {
+ Picker("Tab", selection: $selectedTab) {
+ ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
+ Text(tab.rawValue).tag(tab)
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+
+ Divider()
+
+ switch selectedTab {
+ case .summary:
+ ReadmeView(viewModel: viewModel)
+ case .tree:
+ FileTreeView(
+ repository: repository,
+ client: appState.client
+ )
+ case .log:
+ CommitLogView(viewModel: viewModel)
+ case .refs:
+ ReferencesListView(viewModel: viewModel)
+ case .artifacts:
+ ArtifactsView(viewModel: viewModel)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+ .srhtErrorBanner(error: Binding(
+ get: { viewModel.error },
+ set: { viewModel.error = $0 }
+ ))
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
new file mode 100644
index 0000000..afc748d
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -0,0 +1,397 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct LogResponse: Decodable, Sendable {
+ let repository: LogRepository?
+}
+
+private struct LogRepository: Decodable, Sendable {
+ let log: LogPage
+}
+
+private struct LogPage: Decodable, Sendable {
+ let results: [CommitSummary]
+ let cursor: String?
+}
+
+private struct RefsResponse: Decodable, Sendable {
+ let repository: RefsRepository?
+}
+
+private struct RefsRepository: Decodable, Sendable {
+ let references: RefsPage
+}
+
+private struct RefsPage: Decodable, Sendable {
+ let results: [Reference]
+ let cursor: String?
+}
+
+private struct ReadmeResponse: Decodable, Sendable {
+ let repository: ReadmeRepository?
+}
+
+private struct ReadmeRepository: Decodable, Sendable {
+ let readme: String?
+}
+
+private struct PathResponse: Decodable, Sendable {
+ let repository: PathRepository?
+}
+
+private struct PathRepository: Decodable, Sendable {
+ let readme: PathEntry?
+}
+
+private struct PathEntry: Decodable, Sendable {
+ let object: PathObject?
+}
+
+private struct PathObject: Decodable, Sendable {
+ let text: String?
+}
+
+private struct ArtifactsResponse: Decodable, Sendable {
+ let repository: ArtifactsRepository?
+}
+
+private struct ArtifactsRepository: Decodable, Sendable {
+ let references: ArtifactRefsPage
+}
+
+private struct ArtifactRefsPage: Decodable, Sendable {
+ let results: [ArtifactRef]
+ let cursor: String?
+}
+
+private struct ArtifactRef: Decodable, Sendable {
+ let name: String
+ let artifacts: ArtifactPage
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class RepositoryDetailViewModel {
+
+ enum Tab: String, CaseIterable {
+ case summary = "Summary"
+ case tree = "Tree"
+ case log = "Log"
+ case refs = "Refs"
+ case artifacts = "Artifacts"
+ }
+
+ let repository: RepositorySummary
+ private var service: SRHTService { repository.service }
+ private let client: SRHTClient
+
+ // MARK: - Commit log state
+
+ private(set) var commits: [CommitSummary] = []
+ private(set) var isLoadingCommits = false
+ private(set) var isLoadingMoreCommits = false
+ private var commitCursor: String?
+ private var hasMoreCommits = true
+
+ // MARK: - References state
+
+ private(set) var branches: [Reference] = []
+ private(set) var tags: [Reference] = []
+ private(set) var isLoadingRefs = false
+
+ // MARK: - README state
+
+ enum ReadmeContent {
+ case html(String)
+ case markdown(String)
+ case org(String)
+ case plainText(String)
+ }
+
+ private(set) var readmeContent: ReadmeContent?
+ private(set) var readmePath: String?
+ private(set) var isLoadingReadme = false
+ private(set) var readmeLoaded = false
+
+ // MARK: - Artifacts state
+
+ private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
+ private(set) var isLoadingArtifacts = false
+
+ // MARK: - Error
+
+ var error: String?
+
+ init(repository: RepositorySummary, client: SRHTClient) {
+ self.repository = repository
+ self.client = client
+ }
+
+ // MARK: - Commit log
+
+ private static let logQuery = """
+ query repoLog($rid: ID!, $cursor: Cursor) {
+ repository(rid: $rid) {
+ log(cursor: $cursor) {
+ results {
+ id
+ shortId
+ author { name email time }
+ message
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadCommits() async {
+ guard !isLoadingCommits else { return }
+ isLoadingCommits = true
+ error = nil
+ commitCursor = nil
+ hasMoreCommits = true
+
+ do {
+ let page = try await fetchCommitPage(cursor: nil)
+ commits = page.results
+ commitCursor = page.cursor
+ hasMoreCommits = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingCommits = false
+ }
+
+ func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
+ guard let last = commits.last,
+ last.id == currentItem.id,
+ hasMoreCommits,
+ !isLoadingMoreCommits else {
+ return
+ }
+
+ isLoadingMoreCommits = true
+
+ do {
+ let page = try await fetchCommitPage(cursor: commitCursor)
+ commits.append(contentsOf: page.results)
+ commitCursor = page.cursor
+ hasMoreCommits = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMoreCommits = false
+ }
+
+ private func fetchCommitPage(cursor: String?) async throws -> LogPage {
+ var variables: [String: any Sendable] = ["rid": repository.rid]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result: LogResponse
+ do {
+ result = try await client.execute(
+ service: service,
+ query: Self.logQuery,
+ variables: variables,
+ responseType: LogResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ return LogPage(results: [], cursor: nil)
+ }
+ throw error
+ }
+ guard let repo = result.repository else {
+ return LogPage(results: [], cursor: nil)
+ }
+ return repo.log
+ }
+
+ // MARK: - References
+
+ private static let refsQuery = """
+ query refs($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results { name target }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadReferences() async {
+ guard !isLoadingRefs else { return }
+ isLoadingRefs = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.refsQuery,
+ variables: ["rid": repository.rid],
+ responseType: RefsResponse.self
+ )
+ let allRefs = result.repository?.references.results ?? []
+ branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
+ tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingRefs = false
+ }
+
+ // MARK: - README
+
+ private static let readmeQuery = """
+ query readme($rid: ID!) {
+ repository(rid: $rid) {
+ readme
+ }
+ }
+ """
+
+ private static func readmeFileQuery(filename: String) -> String {
+ """
+ query readmeFile($rid: ID!) {
+ repository(rid: $rid) {
+ readme: path(revspec: "HEAD", path: "\(filename)") {
+ object {
+ ... on TextBlob { text }
+ }
+ }
+ }
+ }
+ """
+ }
+
+ private static let readmeFilenames = [
+ "README.md", "README.org", "README.txt", "README",
+ "readme.md", "readme.org"
+ ]
+
+ func loadReadme() async {
+ guard !isLoadingReadme, !readmeLoaded else { return }
+ isLoadingReadme = true
+ defer { isLoadingReadme = false }
+ error = nil
+
+ do {
+ // Step 1: Check the custom HTML readme set via the web UI
+ let result = try await client.execute(
+ service: service,
+ query: Self.readmeQuery,
+ variables: ["rid": repository.rid],
+ responseType: ReadmeResponse.self
+ )
+ if let html = result.repository?.readme, !html.isEmpty {
+ readmePath = nil
+ readmeContent = .html(html)
+ readmeLoaded = true
+ return
+ }
+
+ // Step 2: Try each README filename sequentially
+ for filename in Self.readmeFilenames {
+ let pathResult: PathResponse
+ do {
+ pathResult = try await client.execute(
+ service: service,
+ query: Self.readmeFileQuery(filename: filename),
+ variables: ["rid": repository.rid],
+ responseType: PathResponse.self
+ )
+ } catch {
+ if isMissingGitReferenceError(error) {
+ readmeContent = nil
+ readmePath = nil
+ readmeLoaded = true
+ return
+ }
+ throw error
+ }
+ if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
+ readmePath = filename
+ if filename.hasSuffix(".md") {
+ readmeContent = .markdown(text)
+ } else if filename.hasSuffix(".org") {
+ readmeContent = .org(text)
+ } else {
+ readmeContent = .plainText(text)
+ }
+ readmeLoaded = true
+ return
+ }
+ }
+
+ // Step 3: No readme found
+ readmeContent = nil
+ readmePath = nil
+ readmeLoaded = true
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ private func isMissingGitReferenceError(_ error: Error) -> Bool {
+ guard let srhtError = error as? SRHTError else { return false }
+ guard case .graphQLErrors(let errors) = srhtError else { return false }
+ return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
+ }
+
+ // MARK: - Artifacts
+
+ private static let artifactsQuery = """
+ query artifacts($rid: ID!) {
+ repository(rid: $rid) {
+ references {
+ results {
+ name
+ artifacts {
+ results {
+ id
+ filename
+ checksum
+ size
+ url
+ }
+ cursor
+ }
+ }
+ cursor
+ }
+ }
+ }
+ """
+
+ func loadArtifacts() async {
+ guard !isLoadingArtifacts else { return }
+ isLoadingArtifacts = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: service,
+ query: Self.artifactsQuery,
+ variables: ["rid": repository.rid],
+ responseType: ArtifactsResponse.self
+ )
+ // Only include references that have at least one artifact.
+ referenceArtifacts = (result.repository?.references.results ?? [])
+ .filter { !$0.artifacts.results.isEmpty }
+ .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingArtifacts = false
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryListView.swift b/Hutch/Views/Repositories/RepositoryListView.swift
new file mode 100644
index 0000000..6fcfafa
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryListView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct RepositoryListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: RepositoryListViewModel?
+ @State private var searchTask: Task<Void, Never>?
+ @State private var showCreateRepositorySheet = false
+ @State private var createdRepository: RepositorySummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ }
+ }
+ .navigationTitle("Repositories")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateRepositorySheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateRepositorySheet) {
+ if let viewModel {
+ CreateRepositorySheet(viewModel: viewModel) { repository in
+ showCreateRepositorySheet = false
+ createdRepository = repository
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdRepository != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdRepository = nil
+ }
+ }
+ )) {
+ if let createdRepository {
+ RepositoryDetailView(repository: createdRepository) {
+ viewModel?.removeRepository(id: createdRepository.id)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ viewModel = RepositoryListViewModel(client: appState.client)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: RepositoryListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.repositories) { repo in
+ NavigationLink(value: repo) {
+ RepositoryRowView(repository: repo)
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: repo)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
+ .overlay {
+ if viewModel.isLoading, viewModel.repositories.isEmpty {
+ SRHTLoadingStateView(message: "Loading repositories…")
+ } else if let error = viewModel.error, viewModel.repositories.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Repositories",
+ message: error,
+ retryAction: { await viewModel.loadRepositories() }
+ )
+ } else if viewModel.repositories.isEmpty, viewModel.error == nil {
+ if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ ContentUnavailableView(
+ "No Repositories",
+ systemImage: "book.closed",
+ description: Text("You don't have any repositories yet.")
+ )
+ } else {
+ ContentUnavailableView.search
+ }
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
+ await viewModel.loadRepositories()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadRepositories()
+ }
+ .task {
+ await viewModel.loadRepositories()
+ }
+ .onChange(of: viewModel.searchText) { oldValue, newValue in
+ // Cancel previous search task
+ searchTask?.cancel()
+
+ // Clear results immediately when search text is cleared
+ if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ viewModel.resetSearch()
+ Task {
+ await viewModel.loadRepositories()
+ }
+ return
+ }
+
+ // Debounce search to avoid excessive API calls
+ searchTask = Task {
+ try? await Task.sleep(for: .milliseconds(350))
+ guard !Task.isCancelled else { return }
+ await viewModel.loadRepositories(search: newValue)
+ }
+ }
+ .navigationDestination(for: RepositorySummary.self) { repo in
+ RepositoryDetailView(repository: repo) {
+ viewModel.removeRepository(id: repo.id)
+ }
+ }
+ }
+}
+
+private struct CreateRepositorySheet: View {
+ let viewModel: RepositoryListViewModel
+ let onCreated: (RepositorySummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var description = ""
+ @State private var cloneURL = ""
+ @State private var visibility: Visibility = .public
+ @State private var service: RepositoryCreationService = .git
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Repository Details") {
+ Picker("Version Control", selection: $service) {
+ ForEach(RepositoryCreationService.allCases) { service in
+ Text(service.displayName).tag(service)
+ }
+ }
+ TextField("Repository name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Short description (optional)", text: $description, axis: .vertical)
+ .lineLimit(2...4)
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+
+ Section("Import Existing Repository") {
+ if service == .git {
+ TextField("Remote URL (optional)", text: $cloneURL)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .keyboardType(.URL)
+ Text("Import an existing Git repository from a remote URL.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ } else {
+ Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let repository = await viewModel.createRepository(
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ cloneURL: cloneURL
+ ) {
+ onCreated(repository)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingRepository {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Repository")
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
new file mode 100644
index 0000000..bba0108
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -0,0 +1,548 @@
+import Foundation
+
+enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
+ case git
+ case hg
+
+ var id: String { rawValue }
+
+ var service: SRHTService {
+ switch self {
+ case .git: .git
+ case .hg: .hg
+ }
+ }
+
+ var displayName: String {
+ switch self {
+ case .git: "Git"
+ case .hg: "Mercurial"
+ }
+ }
+}
+
+/// View model for the repository list screen.
+@Observable
+@MainActor
+final class RepositoryListViewModel {
+
+ private(set) var repositories: [RepositorySummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isRefreshing = false
+ var error: String?
+
+ var searchText = ""
+
+ private(set) var cursor: String?
+ private(set) var hasMore = false
+ private(set) var isSearching = false
+ private(set) var isCreatingRepository = false
+ private let client: SRHTClient
+
+ private static let gitCacheKey = "git.repositories"
+ private static let hgCacheKey = "hg.repositories"
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let gitQuery = """
+ query repositories($cursor: Cursor, $filter: Filter) {
+ repositories(cursor: $cursor, filter: $filter) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ HEAD { name }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let hgQuery = """
+ query repositories($cursor: Cursor) {
+ repositories(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ private static let createHgRepositoryMutation = """
+ mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
+ createRepository(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ tip { branch }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ /// Fetch the first page of repositories. Shows cached data instantly if available,
+ /// then refreshes from the network in the background.
+ /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
+ func loadRepositories(search: String? = nil) async {
+ let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ let isSearch = !query.isEmpty
+
+ // Only use cache for non-search, initial loads
+ if !isSearch, repositories.isEmpty {
+ loadFromCache()
+ }
+
+ // During search, never show the full-screen loading overlay (which
+ // would remove the List and dismiss the keyboard). Use "refreshing"
+ // instead so the list stays in the hierarchy.
+ if isSearch {
+ isRefreshing = true
+ isSearching = true
+ } else if repositories.isEmpty {
+ isLoading = true
+ isSearching = false
+ } else {
+ isRefreshing = true
+ isSearching = false
+ }
+ error = nil
+ cursor = nil
+ hasMore = false
+
+ do {
+ var filteredResults: [RepositorySummary]
+
+ if isSearch {
+ // For search queries, fetch all repositories from both services.
+ filteredResults = try await fetchAllRepositories()
+
+ // Perform client-side filtering
+ let lowercasedQuery = query.lowercased()
+ filteredResults = filteredResults.filter { repo in
+ repo.name.lowercased().contains(lowercasedQuery) ||
+ repo.description?.lowercased().contains(lowercasedQuery) ?? false
+ }
+ } else {
+ let repositories = try await fetchAllRepositories(useCache: true)
+ filteredResults = repositories
+ }
+
+ repositories = filteredResults.sorted(by: repositorySortOrder)
+ } catch {
+ // Only show error if we have no cached data to fall back on
+ if repositories.isEmpty {
+ self.error = error.localizedDescription
+ }
+ }
+
+ isLoading = false
+ isRefreshing = false
+ }
+
+ /// Load the next page if available. Called when the user scrolls near the end.
+ /// Note: Pagination is disabled during search (client-side filtering).
+ func loadMoreIfNeeded(currentItem: RepositorySummary) async {
+ _ = currentItem
+ }
+
+ /// Remove a repository from the local list (e.g. after deletion).
+ func removeRepository(id: Int) {
+ repositories.removeAll { $0.id == id }
+ }
+
+ func createRepository(
+ service: RepositoryCreationService,
+ name: String,
+ description: String,
+ visibility: Visibility,
+ cloneURL: String
+ ) async -> RepositorySummary? {
+ guard !isCreatingRepository else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a repository name."
+ return nil
+ }
+
+ isCreatingRepository = true
+ error = nil
+ defer { isCreatingRepository = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+ let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedCloneURL.isEmpty {
+ variables["cloneUrl"] = trimmedCloneURL
+ }
+
+ do {
+ let repository: RepositorySummary
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: .git,
+ query: Self.createRepositoryMutation,
+ variables: variables,
+ responseType: CreateRepositoryResponse.self
+ )
+ repository = result.createRepository
+ case .hg:
+ variables.removeValue(forKey: "cloneUrl")
+ let result = try await client.execute(
+ service: .hg,
+ query: Self.createHgRepositoryMutation,
+ variables: variables,
+ responseType: CreateHGRepositoryResponse.self
+ )
+ repository = result.createRepository.repositorySummary(service: .hg)
+ }
+ repositories.insert(repository, at: 0)
+ return repository
+ } catch {
+ self.error = repositoryCreationErrorMessage(for: error)
+ return nil
+ }
+ }
+
+ private func repositoryCreationErrorMessage(for error: Error) -> String {
+ let message: String
+
+ if let srhtError = error as? SRHTError {
+ switch srhtError {
+ case .graphQLErrors(let errors):
+ message = errors.map(\.message).joined(separator: "\n")
+ default:
+ message = srhtError.localizedDescription
+ }
+ } else {
+ message = error.localizedDescription
+ }
+
+ return "Couldn’t create the repository. \(message)"
+ }
+
+ /// Fetch ALL repositories by paginating through all available pages.
+ /// Used for search functionality to ensure we search through the complete dataset.
+ private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
+ async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
+ async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
+ return try await gitRepositories + hgRepositories
+ }
+
+ /// Reset search state and reload all repositories
+ func resetSearch() {
+ repositories = []
+ cursor = nil
+ hasMore = false
+ isSearching = false
+ }
+
+ // MARK: - Private
+
+ /// Page shape matching the GraphQL response without generic constraints that
+ /// conflict with strict concurrency when used from a @MainActor context.
+ private struct Page: Decodable, Sendable {
+ let results: [RepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct RepositoriesResponse: Decodable, Sendable {
+ let repositories: Page?
+ }
+
+ private struct CreateRepositoryResponse: Decodable, Sendable {
+ let createRepository: RepositorySummary
+ }
+
+ private struct CreateHGRepositoryResponse: Decodable, Sendable {
+ let createRepository: HGRepositoryPayload
+ }
+
+ private struct HGPage: Decodable, Sendable {
+ let results: [HGRepositoryPayload]
+ let cursor: String?
+ }
+
+ private struct HGRepositoriesResponse: Decodable, Sendable {
+ let repositories: HGPage?
+ }
+
+ private static let emptyPage = Page(results: [], cursor: nil)
+
+ private struct RepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let head: Reference?
+
+ enum CodingKeys: String, CodingKey {
+ case id, rid, name, description, visibility, updated, owner
+ case head = "HEAD"
+ }
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: head
+ )
+ }
+ }
+
+ private struct HGRepositoryPayload: Decodable, Sendable {
+ let id: Int
+ let rid: String
+ let name: String
+ let description: String?
+ let visibility: Visibility
+ let updated: Date
+ let owner: Entity
+ let tip: HGTipReference?
+
+ func repositorySummary(service: SRHTService) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: rid,
+ service: service,
+ name: name,
+ description: description,
+ visibility: visibility,
+ updated: updated,
+ owner: owner,
+ head: tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ }
+ }
+
+ private struct HGTipReference: Decodable, Sendable {
+ let branch: String
+ }
+
+ private func fetchPage(
+ service: SRHTService,
+ cursor: String?,
+ search: String? = nil,
+ useCache: Bool
+ ) async throws -> Page {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ variables["filter"] = ["search": trimmed] as [String: any Sendable]
+ }
+
+ if useCache && cursor == nil {
+ switch service {
+ case .git:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.executeAndCache(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self,
+ cacheKey: cacheKey(for: service)
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ } else {
+ switch service {
+ case .git:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ case .hg:
+ let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
+ let result = try await client.execute(
+ service: service,
+ query: Self.hgQuery,
+ variables: hgVariables,
+ responseType: HGRepositoriesResponse.self
+ )
+ return Page(
+ results: result.repositories?.results.map {
+ RepositoryPayload(
+ id: $0.id,
+ rid: $0.rid,
+ name: $0.name,
+ description: $0.description,
+ visibility: $0.visibility,
+ updated: $0.updated,
+ owner: $0.owner,
+ head: $0.tip.map { Reference(name: $0.branch, target: nil) }
+ )
+ } ?? [],
+ cursor: result.repositories?.cursor
+ )
+ default:
+ let result = try await client.execute(
+ service: service,
+ query: Self.gitQuery,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: RepositoriesResponse.self
+ )
+ return result.repositories ?? Self.emptyPage
+ }
+ }
+ }
+
+ private func loadFromCache() {
+ let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
+ guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .srhtFlexible
+ switch service {
+ case .git:
+ if let response = try? decoder.decode(
+ GraphQLResponse<RepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ case .hg:
+ if let response = try? decoder.decode(
+ GraphQLResponse<HGRepositoriesResponse>.self,
+ from: data
+ ), let repos = response.data?.repositories {
+ return repos.results.map { $0.repositorySummary(service: service) }
+ }
+ default:
+ break
+ }
+ return []
+ }
+ if !cachedRepositories.isEmpty {
+ repositories = cachedRepositories.sorted(by: repositorySortOrder)
+ }
+ }
+
+ private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
+ var allRepositories: [RepositorySummary] = []
+ var currentCursor: String? = nil
+
+ while true {
+ let page = try await fetchPage(
+ service: service,
+ cursor: currentCursor,
+ search: nil,
+ useCache: useCache && currentCursor == nil
+ )
+ allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
+ guard let nextCursor = page.cursor else { break }
+ currentCursor = nextCursor
+ }
+
+ return allRepositories
+ }
+
+ private func cacheKey(for service: SRHTService) -> String {
+ switch service {
+ case .git:
+ Self.gitCacheKey
+ case .hg:
+ Self.hgCacheKey
+ default:
+ "\(service.rawValue).repositories"
+ }
+ }
+
+ private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
+ if lhs.updated == rhs.updated {
+ if lhs.service == rhs.service {
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ return lhs.service.rawValue < rhs.service.rawValue
+ }
+ return lhs.updated > rhs.updated
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryRowView.swift b/Hutch/Views/Repositories/RepositoryRowView.swift
new file mode 100644
index 0000000..9f8cd15
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryRowView.swift
@@ -0,0 +1,85 @@
+import SwiftUI
+
+struct RepositoryRowView: View {
+ let repository: RepositorySummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(repository.name)
+ .font(.headline)
+
+ Spacer()
+
+ if repository.service == .hg {
+ Text("HG")
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.cyan.opacity(0.15), in: Capsule())
+ .foregroundStyle(.cyan)
+ }
+
+ VisibilityBadge(visibility: repository.visibility)
+ }
+
+ Text(repository.owner.canonicalName)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ if let description = repository.description,
+ !description.isEmpty {
+ Text(description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack(spacing: 12) {
+ if let head = repository.head {
+ Label(head.name, systemImage: "arrow.triangle.branch")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Text(repository.updated.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - VisibilityBadge
+
+struct VisibilityBadge: View {
+ let visibility: Visibility
+
+ var body: some View {
+ Text(label)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.15), in: Capsule())
+ .foregroundStyle(color)
+ }
+
+ private var label: String {
+ switch visibility {
+ case .public: "PUBLIC"
+ case .unlisted: "UNLISTED"
+ case .private: "PRIVATE"
+ }
+ }
+
+ private var color: Color {
+ switch visibility {
+ case .public: .green
+ case .unlisted: .orange
+ case .private: .red
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositorySettingsView.swift b/Hutch/Views/Repositories/RepositorySettingsView.swift
index 3db434c..5074138 100644
--- a/Hutch/Views/Repositories/RepositorySettingsView.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsView.swift
@@ -10,6 +10,7 @@ struct RepositorySettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: RepositorySettingsViewModel?
@State private var showDeleteConfirmation = false
+ @State private var pendingACLDeletion: ACLEntry?
var body: some View {
NavigationStack {
@@ -17,7 +18,7 @@ struct RepositorySettingsView: View {
if let viewModel {
settingsForm(viewModel)
} else {
- ProgressView()
+ SRHTLoadingStateView(message: "Loading settings…")
}
}
.navigationTitle("Settings")
@@ -51,13 +52,7 @@ struct RepositorySettingsView: View {
accessSection(viewModel)
deleteSection(viewModel)
}
- .alert("Error", isPresented: .constant(viewModel.error != nil)) {
- Button("OK") { viewModel.error = nil }
- } message: {
- if let error = viewModel.error {
- Text(error)
- }
- }
+ .srhtErrorBanner(error: $vm.error)
.alert(
"Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
isPresented: $showDeleteConfirmation
@@ -75,6 +70,27 @@ struct RepositorySettingsView: View {
} message: {
Text("This cannot be undone.")
}
+ .alert("Remove Access?", isPresented: Binding(
+ get: { pendingACLDeletion != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingACLDeletion = nil
+ }
+ }
+ )) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Access", role: .destructive) {
+ guard let entry = pendingACLDeletion else { return }
+ Task {
+ await viewModel.deleteACL(entry)
+ pendingACLDeletion = nil
+ }
+ }
+ } message: {
+ if let entry = pendingACLDeletion {
+ Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
+ }
+ }
}
// MARK: - Info Section
@@ -82,6 +98,11 @@ struct RepositorySettingsView: View {
@ViewBuilder
private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section("Info") {
+ LabeledContent("Name") {
+ Text(repository.name)
+ .font(.body.monospaced())
+ }
+
TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
.lineLimit(3...6)
@@ -107,7 +128,7 @@ struct RepositorySettingsView: View {
ProgressView()
.frame(maxWidth: .infinity)
} else {
- Text("Save")
+ Text("Save Changes")
.frame(maxWidth: .infinity)
}
}
@@ -120,7 +141,7 @@ struct RepositorySettingsView: View {
@ViewBuilder
private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
Section {
- TextField("Repository Name", text: Bindable(viewModel).editedName)
+ TextField("New repository name", text: Bindable(viewModel).editedName)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -141,7 +162,7 @@ struct RepositorySettingsView: View {
ProgressView()
.frame(maxWidth: .infinity)
} else {
- Text("Rename")
+ Text("Rename Repository")
.frame(maxWidth: .infinity)
}
}
@@ -163,7 +184,7 @@ struct RepositorySettingsView: View {
Spacer()
}
} else if viewModel.acls.isEmpty {
- Text("No access control entries.")
+ Text("No access entries yet.")
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.acls) { entry in
@@ -174,11 +195,11 @@ struct RepositorySettingsView: View {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
- .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
- Task { await viewModel.deleteACL(entry) }
+ pendingACLDeletion = entry
} label: {
- Label("Delete", systemImage: "trash")
+ Label("Remove Access", systemImage: "trash")
}
}
}
@@ -186,7 +207,7 @@ struct RepositorySettingsView: View {
// Add ACL form
HStack {
- TextField("Username", text: Bindable(viewModel).newACLEntity)
+ TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
@@ -203,11 +224,14 @@ struct RepositorySettingsView: View {
if viewModel.isAddingACL {
ProgressView()
} else {
- Image(systemName: "plus.circle.fill")
+ Text("Add")
}
}
.disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
+ Text("Add a SourceHut user and choose read-only or read/write access.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
} header: {
Text("Access")
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index bdae87f..d6f3300 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -63,6 +63,7 @@ final class RepositorySettingsViewModel {
let repositoryId: Int
let repositoryRid: String
+ let service: SRHTService
private let client: SRHTClient
// MARK: - Info fields
@@ -107,6 +108,7 @@ final class RepositorySettingsViewModel {
) {
self.repositoryId = repository.id
self.repositoryRid = repository.rid
+ self.service = repository.service
self.client = client
self.editedDescription = repository.description ?? ""
self.editedVisibility = repository.visibility
@@ -143,7 +145,7 @@ final class RepositorySettingsViewModel {
"HEAD": editedHead
]
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateRepoMutation,
variables: ["id": repositoryId, "input": input],
responseType: UpdateRepoResponse.self
@@ -165,7 +167,7 @@ final class RepositorySettingsViewModel {
"name": editedName
]
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateRepoMutation,
variables: ["id": repositoryId, "input": input],
responseType: UpdateRepoResponse.self
@@ -207,6 +209,16 @@ final class RepositorySettingsViewModel {
}
"""
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user(username: $username) {
+ id
+ username
+ canonicalName
+ }
+ }
+ """
+
func loadACLs() async {
guard !isLoadingACLs else { return }
isLoadingACLs = true
@@ -214,7 +226,7 @@ final class RepositorySettingsViewModel {
do {
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.aclsQuery,
variables: ["rid": repositoryRid],
responseType: ACLResponse.self
@@ -234,7 +246,7 @@ final class RepositorySettingsViewModel {
do {
let result = try await client.execute(
- service: .git,
+ service: service,
query: Self.updateACLMutation,
variables: [
"repoId": repositoryId,
@@ -262,7 +274,7 @@ final class RepositorySettingsViewModel {
do {
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.deleteACLMutation,
variables: ["id": entry.id],
responseType: DeleteACLResponse.self
@@ -288,7 +300,7 @@ final class RepositorySettingsViewModel {
do {
_ = try await client.execute(
- service: .git,
+ service: service,
query: Self.deleteRepoMutation,
variables: ["id": repositoryId],
responseType: DeleteRepoResponse.self
diff --git a/Hutch/Views/Repositories/RepositorySummarySupport.swift b/Hutch/Views/Repositories/RepositorySummarySupport.swift
index 8ad3659..a2cf699 100644
--- a/Hutch/Views/Repositories/RepositorySummarySupport.swift
+++ b/Hutch/Views/Repositories/RepositorySummarySupport.swift
@@ -39,31 +39,30 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
}
}
-struct RepositorySummaryCard<Content: View>: View {
+struct SummaryMetadataRow: View {
+ let icon: String
let title: String
- @ViewBuilder let content: Content
-
- init(_ title: String, @ViewBuilder content: () -> Content) {
- self.title = title
- self.content = content()
- }
+ var subtitle: String? = nil
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text(title)
- .font(.caption.weight(.semibold))
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: icon)
.foregroundStyle(.secondary)
- .textCase(.uppercase)
+ .frame(width: 18)
- content
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title)
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding()
- .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
-struct RepositorySummaryField: View {
+struct SummaryDetailRow: View {
let label: String
let value: String
var monospace: Bool = false
@@ -79,23 +78,3 @@ struct RepositorySummaryField: View {
}
}
}
-
-struct RepositorySummaryListRow: View {
- let label: String
- let values: [String]
-
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(label)
- .font(.caption)
- .foregroundStyle(.secondary)
-
- if values.isEmpty {
- Text("None")
- .foregroundStyle(.tertiary)
- } else {
- Text(values.joined(separator: ", "))
- }
- }
- }
-}
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..5bbded8
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -0,0 +1,724 @@
+import PhotosUI
+import SwiftUI
+
+struct SettingsView: View {
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: SettingsViewModel?
+ @State private var pendingDestructiveAction: SettingsDestructiveAction?
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if let viewModel {
+ settingsContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading profile…")
+ }
+ }
+ .navigationTitle("Settings")
+ .task {
+ if viewModel == nil {
+ let vm = SettingsViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadProfile()
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Form {
+ if let profile = viewModel.profile {
+ // Profile section
+ profileSection(profile, viewModel: viewModel)
+
+ // SSH Keys
+ sshKeysSection(viewModel)
+
+ // PGP Keys
+ pgpKeysSection(viewModel)
+
+ // Personal Access Tokens
+ patSection(viewModel)
+ }
+
+ // Token / Sign Out
+ tokenSection()
+
+ aboutSection()
+ }
+ .overlay {
+ if viewModel.isLoading, viewModel.profile == nil {
+ SRHTLoadingStateView(message: "Loading profile…")
+ } else if let error = viewModel.error, viewModel.profile == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Profile",
+ message: error,
+ retryAction: { await viewModel.loadProfile() }
+ )
+ }
+ }
+ .sheet(isPresented: $vm.isEditingProfile) {
+ if let profile = viewModel.profile {
+ EditProfileSheet(
+ profile: profile,
+ viewModel: viewModel
+ )
+ }
+ }
+ .alert("Error", isPresented: Binding(
+ get: { viewModel.error != nil && viewModel.profile != nil },
+ set: { isPresented in
+ if !isPresented {
+ viewModel.error = nil
+ }
+ }
+ )) {
+ Button("OK") { viewModel.error = nil }
+ } message: {
+ if let error = viewModel.error {
+ Text(error)
+ }
+ }
+ .alert(
+ pendingDestructiveAction?.title ?? "",
+ isPresented: Binding(
+ get: { pendingDestructiveAction != nil },
+ set: { isPresented in
+ if !isPresented {
+ pendingDestructiveAction = nil
+ }
+ }
+ )
+ ) {
+ Button("Cancel", role: .cancel) {}
+ Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
+ guard let action = pendingDestructiveAction else { return }
+ pendingDestructiveAction = nil
+ Task {
+ switch action {
+ case .resetAppData:
+ await appState.resetAppData()
+ case .signOut:
+ await appState.signOut()
+ case .deleteSSHKey(let key):
+ await viewModel.deleteSSHKey(key)
+ case .deletePGPKey(let key):
+ await viewModel.deletePGPKey(key)
+ }
+ }
+ }
+ } message: {
+ if let pendingDestructiveAction {
+ Text(pendingDestructiveAction.message)
+ }
+ }
+ .refreshable {
+ await viewModel.loadProfile()
+ }
+ }
+
+ // MARK: - Profile Section
+
+ @ViewBuilder
+ private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
+ Section("Profile") {
+ HStack(spacing: 12) {
+ AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .scaledToFill()
+ default:
+ Image(systemName: "person.crop.circle.fill")
+ .resizable()
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 56, height: 56)
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(profile.canonicalName)
+ .font(.headline)
+ Text(profile.email)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ if let userType = profile.userType {
+ Text(userType.capitalized)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ .padding(.vertical, 4)
+
+ if let bio = profile.bio, !bio.isEmpty {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Bio")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ RenderedMarkupContentView(
+ content: .markdown(bio),
+ readmePath: nil,
+ colorScheme: colorScheme,
+ ownerCanonicalName: "",
+ repositoryName: ""
+ )
+ }
+ }
+
+ if let location = profile.location, !location.isEmpty {
+ LabeledContent("Location", value: location)
+ }
+
+ if let url = profile.url, !url.isEmpty {
+ LabeledContent("URL", value: url)
+ }
+
+ if let status = profile.paymentStatus {
+ LabeledContent("Payment", value: status.capitalized)
+ }
+
+ if let sub = profile.subscription {
+ if let status = sub.status {
+ LabeledContent("Subscription", value: status.capitalized)
+ }
+ if let interval = sub.interval {
+ LabeledContent("Interval", value: interval.capitalized)
+ }
+ }
+
+ Button("Edit Profile") {
+ viewModel.isEditingProfile = true
+ }
+
+ SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
+ SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
+ }
+ }
+ }
+
+ // MARK: - SSH Keys Section
+
+ @ViewBuilder
+ private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Section {
+ ForEach(viewModel.sshKeys) { key in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(key.fingerprint)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ HStack {
+ if let comment = key.comment, !comment.isEmpty {
+ Text(comment)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text(key.created.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+
+ if let lastUsed = key.lastUsed {
+ Text("Last used \(lastUsed.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ pendingDestructiveAction = .deleteSSHKey(key)
+ }
+ }
+ }
+
+ if viewModel.isAddingSSHKey {
+ TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...6)
+
+ HStack {
+ Button("Cancel") {
+ viewModel.isAddingSSHKey = false
+ viewModel.newSSHKey = ""
+ }
+ Spacer()
+ Button("Add") {
+ Task { await viewModel.addSSHKey() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ } else {
+ Button {
+ viewModel.isAddingSSHKey = true
+ } label: {
+ SwiftUI.Label("Add SSH Key", systemImage: "key")
+ }
+ }
+ } header: {
+ Text("SSH Keys")
+ } footer: {
+ Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
+ }
+ }
+
+ // MARK: - PGP Keys Section
+
+ @ViewBuilder
+ private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ Section {
+ ForEach(viewModel.pgpKeys) { key in
+ VStack(alignment: .leading, spacing: 2) {
+ Text(key.fingerprint)
+ .font(.caption.monospaced())
+ .lineLimit(1)
+ .truncationMode(.middle)
+
+ Text(key.created.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button("Delete", role: .destructive) {
+ pendingDestructiveAction = .deletePGPKey(key)
+ }
+ }
+ }
+
+ if viewModel.isAddingPGPKey {
+ TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
+ .font(.caption.monospaced())
+ .lineLimit(3...6)
+
+ HStack {
+ Button("Cancel") {
+ viewModel.isAddingPGPKey = false
+ viewModel.newPGPKey = ""
+ }
+ Spacer()
+ Button("Add") {
+ Task { await viewModel.addPGPKey() }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ } else {
+ Button {
+ viewModel.isAddingPGPKey = true
+ } label: {
+ SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
+ }
+ }
+ } header: {
+ Text("PGP Keys")
+ } footer: {
+ Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
+ }
+ }
+
+ // MARK: - Personal Access Tokens Section
+
+ @ViewBuilder
+ private func patSection(_ viewModel: SettingsViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingPATs {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if viewModel.personalAccessTokens.isEmpty {
+ Button("Load Tokens") {
+ Task { await viewModel.loadPersonalAccessTokens() }
+ }
+ } else {
+ ForEach(viewModel.personalAccessTokens) { token in
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(token.comment ?? "Token #\(token.id)")
+ .font(.subheadline)
+ Spacer()
+ }
+
+ HStack(spacing: 12) {
+ Text("Issued \(token.issued.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ if let expires = token.expires {
+ Text("Expires \(expires.relativeDescription)")
+ .font(.caption2)
+ .foregroundStyle(expires < Date.now ? .red : .secondary)
+ }
+ }
+
+ if let grants = token.grants, !grants.isEmpty {
+ Text(grants)
+ .font(.caption2.monospaced())
+ .foregroundStyle(.tertiary)
+ .lineLimit(2)
+ }
+ }
+ }
+ }
+ } header: {
+ Text("Personal Access Tokens")
+ } footer: {
+ if !viewModel.personalAccessTokens.isEmpty {
+ Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
+ }
+ }
+ }
+
+ // MARK: - Token / Sign Out Section
+
+ @ViewBuilder
+ private func tokenSection() -> some View {
+ Section {
+ HStack {
+ Image(systemName: "key.fill")
+ .foregroundStyle(.secondary)
+ Text("Personal access token in use")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
+
+ Button("Reset App Data", role: .destructive) {
+ pendingDestructiveAction = .resetAppData
+ }
+
+ Button("Sign Out", role: .destructive) {
+ pendingDestructiveAction = .signOut
+ }
+ } header: {
+ Text("Authentication")
+ } footer: {
+ 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.")
+ }
+ }
+
+ @ViewBuilder
+ private func aboutSection() -> some View {
+ Section("App") {
+ NavigationLink {
+ AboutView()
+ } label: {
+ SwiftUI.Label("About Hutch", systemImage: "info.circle")
+ }
+ }
+ }
+}
+
+// MARK: - Edit Profile Sheet
+
+private struct EditProfileSheet: View {
+ let profile: UserProfile
+ let viewModel: SettingsViewModel
+
+ @State private var email: String
+ @State private var url: String
+ @State private var location: String
+ @State private var bio: String
+ @State private var selectedPhoto: PhotosPickerItem?
+ @State private var avatarPreview: UIImage?
+ @State private var isShowingRemoveAvatarConfirmation = false
+
+ @Environment(\.dismiss) private var dismiss
+
+ init(profile: UserProfile, viewModel: SettingsViewModel) {
+ self.profile = profile
+ self.viewModel = viewModel
+ _email = State(initialValue: profile.email)
+ _url = State(initialValue: profile.url ?? "")
+ _location = State(initialValue: profile.location ?? "")
+ _bio = State(initialValue: profile.bio ?? "")
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section {
+ HStack {
+ Spacer()
+ VStack(spacing: 8) {
+ PhotosPicker(selection: $selectedPhoto, matching: .images) {
+ Group {
+ if let avatarPreview {
+ Image(uiImage: avatarPreview)
+ .resizable()
+ .scaledToFill()
+ } else {
+ AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
+ switch phase {
+ case .success(let image):
+ image
+ .resizable()
+ .scaledToFill()
+ default:
+ Image(systemName: "person.crop.circle.fill")
+ .resizable()
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ .frame(width: 80, height: 80)
+ .clipShape(Circle())
+ .overlay(
+ Circle()
+ .stroke(.secondary.opacity(0.3), lineWidth: 1)
+ )
+ }
+
+ Text("Tap to change avatar")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if viewModel.isUploadingAvatar {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ Spacer()
+ }
+ .listRowBackground(Color.clear)
+
+ if profile.avatar != nil || avatarPreview != nil {
+ HStack {
+ Spacer()
+ Button(role: .destructive) {
+ isShowingRemoveAvatarConfirmation = true
+ } label: {
+ Text("Remove Avatar")
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.isUploadingAvatar)
+ Spacer()
+ }
+ .listRowBackground(Color.clear)
+ .listRowSeparator(.hidden)
+ }
+ }
+
+ Section("Edit Profile") {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Email")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter email", text: $email)
+ .textContentType(.emailAddress)
+ .keyboardType(.emailAddress)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("URL")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter URL", text: $url)
+ .textContentType(.URL)
+ .keyboardType(.URL)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Location")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter location", text: $location)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Bio")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ TextField("Enter bio", text: $bio, axis: .vertical)
+ .lineLimit(3...6)
+ }
+ }
+ }
+ .navigationTitle("Edit Profile")
+ .navigationBarTitleDisplayMode(.inline)
+ .onChange(of: selectedPhoto) { _, newItem in
+ guard let newItem else { return }
+ Task {
+ if let data = try? await newItem.loadTransferable(type: Data.self),
+ let image = UIImage(data: data) {
+ avatarPreview = image
+ // Encode as JPEG and upload
+ if let jpegData = image.jpegData(compressionQuality: 0.85) {
+ await viewModel.uploadAvatar(jpegData: jpegData)
+ }
+ }
+ }
+ }
+ .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
+ Button("Cancel", role: .cancel) {}
+ Button("Remove Avatar", role: .destructive) {
+ Task {
+ await viewModel.removeAvatar()
+ if viewModel.error == nil {
+ avatarPreview = nil
+ selectedPhoto = nil
+ }
+ }
+ }
+ } message: {
+ Text("Your profile avatar will be removed from SourceHut.")
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ await viewModel.saveProfile(
+ email: email,
+ url: url,
+ location: location,
+ bio: bio
+ )
+ if viewModel.error == nil {
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isSavingProfile {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Save")
+ }
+ }
+ .disabled(viewModel.isSavingProfile)
+ }
+ }
+ }
+ }
+}
+
+private enum SettingsDestructiveAction {
+ case resetAppData
+ case signOut
+ case deleteSSHKey(SSHKey)
+ case deletePGPKey(PGPKey)
+
+ var title: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data?"
+ case .signOut:
+ "Sign Out?"
+ case .deleteSSHKey:
+ "Remove SSH Key?"
+ case .deletePGPKey:
+ "Remove PGP Key?"
+ }
+ }
+
+ var confirmationLabel: String {
+ switch self {
+ case .resetAppData:
+ "Reset App Data"
+ case .signOut:
+ "Sign Out"
+ case .deleteSSHKey:
+ "Remove SSH Key"
+ case .deletePGPKey:
+ "Remove PGP Key"
+ }
+ }
+
+ var message: String {
+ switch self {
+ case .resetAppData:
+ "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
+ case .signOut:
+ "This signs you out of Hutch and clears saved authentication state on this device."
+ case .deleteSSHKey(let key):
+ "Remove SSH key \(key.fingerprint) from your account?"
+ case .deletePGPKey(let key):
+ "Remove PGP key \(key.fingerprint) from your account?"
+ }
+ }
+}
+
+private struct AboutView: View {
+ private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
+ ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
+ ?? "Hutch"
+ private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? "Unknown"
+ private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+ ?? "Unknown"
+
+ var body: some View {
+ Form {
+ Section {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(appName)
+ .font(.title2.weight(.semibold))
+ Text("A native SourceHut client for iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 4)
+
+ LabeledContent("Version", value: version)
+ LabeledContent("Build", value: build)
+ }
+
+ Section("Links") {
+ Link(destination: URL(string: "https://sr.ht")!) {
+ SwiftUI.Label("SourceHut", systemImage: "link")
+ }
+ Link(destination: URL(string: "https://man.sr.ht")!) {
+ SwiftUI.Label("SourceHut Manuals", systemImage: "book")
+ }
+ Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) {
+ SwiftUI.Label("Project Repository", systemImage: "folder")
+ }
+ }
+
+ Section("Support") {
+ Link(destination: URL(string: "mailto:[email protected]")!) {
+ SwiftUI.Label("Email Support", systemImage: "envelope")
+ }
+ }
+
+ Section("Privacy") {
+ Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Section("Acknowledgements") {
+ Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .navigationTitle("About")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+}
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
new file mode 100644
index 0000000..204afbf
--- /dev/null
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -0,0 +1,394 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct MeProfileResponse: Decodable, Sendable {
+ let me: UserProfile
+}
+
+private struct UpdateUserResponse: Decodable, Sendable {
+ let updateUser: UpdatedUser
+}
+
+private struct UpdatedUser: Decodable, Sendable {
+ let username: String
+ let email: String
+ let url: String?
+ let location: String?
+ let bio: String?
+ let avatar: String?
+}
+
+private struct CreateSSHKeyResponse: Decodable, Sendable {
+ let createSSHKey: SSHKey
+}
+
+private struct DeleteSSHKeyResponse: Decodable, Sendable {
+ let deleteSSHKey: DeleteResult
+}
+
+private struct CreatePGPKeyResponse: Decodable, Sendable {
+ let createPGPKey: PGPKey
+}
+
+private struct DeletePGPKeyResponse: Decodable, Sendable {
+ let deletePGPKey: DeleteResult
+}
+
+private struct DeleteResult: Decodable, Sendable {
+ let id: Int?
+}
+
+private struct PATListResponse: Decodable, Sendable {
+ let personalAccessTokens: [PersonalAccessToken]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class SettingsViewModel {
+
+ private(set) var profile: UserProfile?
+ private(set) var sshKeys: [SSHKey] = []
+ private(set) var pgpKeys: [PGPKey] = []
+ private(set) var personalAccessTokens: [PersonalAccessToken] = []
+
+ private(set) var isLoading = false
+ private(set) var isLoadingPATs = false
+ private(set) var isSavingProfile = false
+ private(set) var isUploadingAvatar = false
+ var error: String?
+
+ var isEditingProfile = false
+
+ // Add SSH key fields
+ var newSSHKey = ""
+ var isAddingSSHKey = false
+
+ // Add PGP key fields
+ var newPGPKey = ""
+ var isAddingPGPKey = false
+
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let profileQuery = """
+ query me {
+ me {
+ username
+ canonicalName
+ email
+ url
+ location
+ bio
+ avatar
+ userType
+ sshKeys {
+ results { id fingerprint comment created lastUsed }
+ cursor
+ }
+ pgpKeys {
+ results { id fingerprint created }
+ cursor
+ }
+ paymentStatus
+ subscription { status autorenew interval }
+ }
+ }
+ """
+
+ private static let updateUserMutation = """
+ mutation updateUser($input: UserInput!) {
+ updateUser(input: $input) {
+ username email url location bio avatar
+ }
+ }
+ """
+
+ private static let createSSHKeyMutation = """
+ mutation createSSHKey($key: String!) {
+ createSSHKey(key: $key) {
+ id fingerprint comment created lastUsed
+ }
+ }
+ """
+
+ private static let deleteSSHKeyMutation = """
+ mutation deleteSSHKey($id: Int!) {
+ deleteSSHKey(id: $id) { id }
+ }
+ """
+
+ private static let createPGPKeyMutation = """
+ mutation createPGPKey($key: String!) {
+ createPGPKey(key: $key) {
+ id fingerprint created
+ }
+ }
+ """
+
+ private static let deletePGPKeyMutation = """
+ mutation deletePGPKey($id: Int!) {
+ deletePGPKey(id: $id) { id }
+ }
+ """
+
+ private static let personalAccessTokensQuery = """
+ query personalAccessTokens {
+ personalAccessTokens { id issued expires comment grants }
+ }
+ """
+
+ // MARK: - Load Profile
+
+ func loadProfile() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.profileQuery,
+ responseType: MeProfileResponse.self
+ )
+ profile = result.me
+ sshKeys = result.me.sshKeys.results
+ pgpKeys = result.me.pgpKeys.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ // MARK: - Update Profile
+
+ func saveProfile(email: String, url: String, location: String, bio: String) async {
+ guard !isSavingProfile else { return }
+ isSavingProfile = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "email": email,
+ "url": url.isEmpty ? nil as String? as Any : url,
+ "location": location.isEmpty ? nil as String? as Any : location,
+ "bio": bio.isEmpty ? nil as String? as Any : bio
+ ]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ isEditingProfile = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSavingProfile = false
+ }
+
+ // MARK: - Avatar
+
+ func uploadAvatar(jpegData: Data) async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ // The input variable has avatar set to null; the actual file
+ // is sent as a separate multipart part per graphql-multipart-request-spec.
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.executeMultipart(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ fileVariablePath: "input.avatar",
+ fileData: jpegData,
+ fileName: "avatar.jpg",
+ mimeType: "image/jpeg",
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: updated.avatar ?? p.avatar,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ func removeAvatar() async {
+ guard !isUploadingAvatar else { return }
+ isUploadingAvatar = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.updateUserMutation,
+ variables: ["input": input],
+ responseType: UpdateUserResponse.self
+ )
+ let updated = result.updateUser
+ if let p = profile {
+ profile = UserProfile(
+ username: p.username,
+ canonicalName: p.canonicalName,
+ email: updated.email,
+ url: updated.url,
+ location: updated.location,
+ bio: updated.bio,
+ avatar: nil,
+ userType: p.userType,
+ sshKeys: p.sshKeys,
+ pgpKeys: p.pgpKeys,
+ paymentStatus: p.paymentStatus,
+ subscription: p.subscription
+ )
+ }
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isUploadingAvatar = false
+ }
+
+ // MARK: - SSH Keys
+
+ func addSSHKey() async {
+ let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createSSHKeyMutation,
+ variables: ["key": key],
+ responseType: CreateSSHKeyResponse.self
+ )
+ sshKeys.append(result.createSSHKey)
+ newSSHKey = ""
+ isAddingSSHKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deleteSSHKey(_ key: SSHKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deleteSSHKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeleteSSHKeyResponse.self
+ )
+ sshKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - PGP Keys
+
+ func addPGPKey() async {
+ let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return }
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.createPGPKeyMutation,
+ variables: ["key": key],
+ responseType: CreatePGPKeyResponse.self
+ )
+ pgpKeys.append(result.createPGPKey)
+ newPGPKey = ""
+ isAddingPGPKey = false
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func deletePGPKey(_ key: PGPKey) async {
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .meta,
+ query: Self.deletePGPKeyMutation,
+ variables: ["id": key.id],
+ responseType: DeletePGPKeyResponse.self
+ )
+ pgpKeys.removeAll { $0.id == key.id }
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ // MARK: - Personal Access Tokens
+
+ func loadPersonalAccessTokens() async {
+ guard !isLoadingPATs else { return }
+ isLoadingPATs = true
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.personalAccessTokensQuery,
+ responseType: PATListResponse.self
+ )
+ personalAccessTokens = result.personalAccessTokens
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingPATs = false
+ }
+
+}
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
new file mode 100644
index 0000000..6b2be4f
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -0,0 +1,828 @@
+import SwiftUI
+import WebKit
+
+struct TicketDetailView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ @Environment(AppState.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var viewModel: TicketDetailViewModel?
+
+ // Sheet state
+ @State private var showResolveSheet = false
+ @State private var showAssignSheet = false
+ @State private var showLabelsSheet = false
+
+ // Comment composer mode
+ @State private var commentMode: CommentMode = .write
+
+ private enum CommentMode: String, CaseIterable {
+ case write = "Write"
+ case preview = "Preview"
+ }
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ detailContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ }
+ }
+ .navigationTitle("#\(ticketId)")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if let viewModel, viewModel.ticket != nil {
+ actionsMenu(viewModel)
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketDetailViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ trackerRid: trackerRid,
+ ticketId: ticketId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Actions Menu
+
+ @ViewBuilder
+ private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
+ Menu {
+ if let ticket = viewModel.ticket {
+ if ticket.status == .resolved {
+ Button {
+ Task {
+ await viewModel.updateStatus(
+ status: .reported,
+ resolution: .unresolved
+ )
+ }
+ } label: {
+ SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
+ }
+ } else {
+ Button {
+ showResolveSheet = true
+ } label: {
+ SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
+ }
+ }
+ }
+
+ Button {
+ showAssignSheet = true
+ } label: {
+ SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
+ }
+
+ Button {
+ showLabelsSheet = true
+ Task { await viewModel.loadTrackerLabels() }
+ } label: {
+ SwiftUI.Label("Manage Labels", systemImage: "tag")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .sheet(isPresented: $showResolveSheet) {
+ ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showAssignSheet) {
+ AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
+ .presentationDetents([.medium])
+ }
+ .sheet(isPresented: $showLabelsSheet) {
+ LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
+ .presentationDetents([.medium])
+ }
+ }
+
+ // MARK: - Detail Content
+
+ @ViewBuilder
+ private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ if viewModel.isLoading, viewModel.ticket == nil {
+ SRHTLoadingStateView(message: "Loading ticket…")
+ } else if let error = viewModel.error, viewModel.ticket == nil {
+ SRHTErrorStateView(
+ title: "Couldn't Load Ticket",
+ message: error,
+ retryAction: { await viewModel.loadTicket() }
+ )
+ } else if let ticket = viewModel.ticket {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header
+ ticketHeader(ticket)
+
+ Divider()
+ .padding(.vertical, 12)
+
+ // Description
+ if let description = ticket.description, !description.isEmpty {
+ MarkdownContentView(markdown: description)
+ .padding(.horizontal)
+ .padding(.bottom, 16)
+
+ Divider()
+ .padding(.bottom, 12)
+ }
+
+ // Event timeline
+ if !viewModel.events.isEmpty {
+ Text("Activity")
+ .font(.headline)
+ .padding(.horizontal)
+ .padding(.bottom, 8)
+
+ LazyVStack(alignment: .leading, spacing: 0) {
+ ForEach(viewModel.events) { event in
+ EventRow(
+ event: event,
+ ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
+ ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
+ )
+ if event.id != viewModel.events.last?.id {
+ Divider()
+ .padding(.leading, 40)
+ }
+ }
+ }
+
+ Divider()
+ .padding(.vertical, 12)
+ }
+
+ // Comment input
+ commentInput(viewModel)
+ }
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTicket()
+ }
+ }
+ }
+
+ // MARK: - Header
+
+ @ViewBuilder
+ private func ticketHeader(_ ticket: TicketDetail) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(ticket.title)
+ .font(.title3.weight(.semibold))
+
+ HStack(spacing: 8) {
+ TicketStatusIcon(status: ticket.status)
+ Text(ticket.status.displayName)
+ .font(.subheadline.weight(.medium))
+
+ if ticket.status == .resolved, let resolution = ticket.resolution {
+ Text("(\(resolution.displayName))")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ HStack(spacing: 4) {
+ Text("Opened by")
+ .foregroundStyle(.secondary)
+ Text(ticket.submitter.canonicalName)
+ .fontWeight(.medium)
+ Text(ticket.created.relativeDescription)
+ .foregroundStyle(.tertiary)
+ }
+ .font(.caption)
+
+ if !ticket.assignees.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "person.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+ }
+ .padding()
+ }
+
+ // MARK: - Comment Input
+
+ @ViewBuilder
+ private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("New Comment")
+ .font(.headline)
+
+ Picker("Mode", selection: $commentMode) {
+ ForEach(CommentMode.allCases, id: \.self) { mode in
+ Text(mode.rawValue).tag(mode)
+ }
+ }
+ .pickerStyle(.segmented)
+
+ if commentMode == .write {
+ TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
+ .textFieldStyle(.roundedBorder)
+ .lineLimit(3...8)
+ } else {
+ // Markdown preview
+ if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ Text("Nothing to preview")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
+ .background(Color(.secondarySystemBackground))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ } else {
+ MarkdownContentView(markdown: viewModel.commentText)
+ .frame(minHeight: 80, maxHeight: 200)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ }
+
+ HStack {
+ Spacer()
+ Button {
+ Task { await viewModel.submitComment() }
+ } label: {
+ if viewModel.isSubmitting {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Post Comment")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
+ }
+ }
+ .padding()
+ }
+}
+
+// MARK: - Self-Sizing Markdown Web View
+
+/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
+/// fit the rendered content. Reuses the same `markdownToHTML` converter and
+/// styling as the README renderer.
+private struct MarkdownContentView: View {
+ let markdown: String
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var renderedHTML: String?
+
+ var body: some View {
+ Group {
+ if let renderedHTML {
+ HTMLWebView(
+ html: renderedHTML,
+ colorScheme: colorScheme,
+ style: .commentPreview
+ )
+ } else {
+ SRHTLoadingStateView(message: "Preparing content…")
+ .frame(minHeight: 80)
+ }
+ }
+ .task(id: markdown) {
+ if renderedHTML != nil {
+ try? await Task.sleep(for: .milliseconds(150))
+ guard !Task.isCancelled else { return }
+ }
+
+ let html = await Task.detached(priority: .userInitiated) {
+ markdownToHTML(markdown)
+ }.value
+ guard !Task.isCancelled else { return }
+ renderedHTML = html
+ }
+ }
+}
+
+// MARK: - Event Row
+
+private struct EventRow: View {
+ let event: TicketEvent
+ let ticketSubmitter: String?
+ let ticketAssignees: [String]?
+ @State private var isShowingSystemStatusInfo = false
+
+ var body: some View {
+ ForEach(event.changes) { change in
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: icon(for: change))
+ .foregroundStyle(color(for: change))
+ .frame(width: 24)
+ .padding(.top, 2)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ if kind(for: change) == .comment {
+ Text(change.author?.canonicalName ?? "")
+ .font(.subheadline.weight(.medium))
+ } else {
+ let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
+ if descriptionText.hasPrefix("System") {
+ HStack(spacing: 4) {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Button {
+ isShowingSystemStatusInfo = true
+ } label: {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.gray)
+ }
+ .buttonStyle(.plain)
+ }
+ } else {
+ Text(descriptionText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ Text(event.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ if kind(for: change) == .comment, let text = change.text {
+ MarkdownContentView(markdown: text)
+ }
+ }
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 8)
+ .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
+ Button("OK", role: .cancel) {}
+ } message: {
+ Text("This status change was recorded automatically or without a named user attached to the event.")
+ }
+ }
+ }
+
+ private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
+ let eventKind = kind(for: change)
+ let authorName: String
+
+ if let commentAuthor = event.changes.first(where: {
+ kind(for: $0) == .comment && $0.author != nil
+ })?.author?.canonicalName {
+ authorName = commentAuthor
+ } else {
+ switch eventKind {
+ case .created:
+ authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
+ case .statusChange:
+ authorName = "System"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ authorName = change.labeler?.canonicalName ?? "Someone"
+ case .assigned, .unassigned:
+ authorName = change.assigner?.canonicalName ?? "Someone"
+ case .comment:
+ authorName = change.author?.canonicalName ?? "Someone"
+ case .ticketMention, .userMention:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? "Someone"
+ case .unknown:
+ authorName = change.author?.canonicalName
+ ?? change.assigner?.canonicalName
+ ?? change.labeler?.canonicalName
+ ?? change.assignee?.canonicalName
+ ?? change.mentioned?.canonicalName
+ ?? ticketAssignees?.first
+ ?? "Someone"
+ }
+ }
+
+ switch eventKind {
+ case .statusChange:
+ let oldStatus = change.oldStatus?.displayName ?? "unknown"
+ let newStatus = change.newStatus?.displayName ?? "unknown"
+ return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
+ case .labelUpdated, .labelAdded:
+ let labelName = change.label?.name ?? "a label"
+ let verb = eventKind == .labelAdded ? "added" : "updated"
+ return "\(authorName) \(verb) label \"\(labelName)\""
+ case .labelRemoved:
+ let labelName = change.label?.name ?? "a label"
+ return "\(authorName) removed label \"\(labelName)\""
+ case .assigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) assigned \(assigneeName)"
+ case .unassigned:
+ let assigneeName = change.assignee?.canonicalName ?? "someone"
+ return "\(authorName) unassigned \(assigneeName)"
+ case .ticketMention:
+ if let ticketId = change.mentioned?.id {
+ return "\(authorName) mentioned ticket #\(ticketId)"
+ }
+ return "\(authorName) mentioned another ticket"
+ case .userMention:
+ let user = change.mentioned?.canonicalName ?? "someone"
+ return "\(authorName) mentioned \(user)"
+ case .created:
+ return "\(authorName) opened this ticket"
+ case .comment:
+ return "\(authorName) commented"
+ case .unknown:
+ return "\(authorName) updated this ticket"
+ }
+ }
+
+ private func icon(for change: EventChange) -> String {
+ switch kind(for: change) {
+ case .comment:
+ "text.bubble"
+ case .statusChange:
+ "arrow.triangle.2.circlepath"
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ "tag"
+ case .assigned:
+ "person.badge.plus"
+ case .unassigned:
+ "person.badge.minus"
+ case .ticketMention, .userMention:
+ "at"
+ case .created:
+ "plus.circle"
+ case .unknown:
+ "circle.fill"
+ }
+ }
+
+ private func color(for change: EventChange) -> Color {
+ switch kind(for: change) {
+ case .comment:
+ .blue
+ case .statusChange:
+ change.newStatus == .resolved ? .green : .orange
+ case .labelAdded, .labelRemoved, .labelUpdated:
+ .purple
+ case .assigned, .unassigned:
+ .cyan
+ case .ticketMention, .userMention:
+ .indigo
+ case .created:
+ .green
+ case .unknown:
+ .gray
+ }
+ }
+
+ private func kind(for change: EventChange) -> EventKind {
+ switch change.eventType {
+ case "COMMENT", "Comment":
+ .comment
+ case "STATUS_CHANGE", "StatusChange":
+ .statusChange
+ case "LABEL_UPDATE", "LabelUpdate":
+ .labelUpdated
+ case "LABEL_ADDED", "LabelAdded":
+ .labelAdded
+ case "LABEL_REMOVED", "LabelRemoved":
+ .labelRemoved
+ case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
+ .assigned
+ case "UNASSIGNED_USER", "UnassignedUser":
+ .unassigned
+ case "TICKET_MENTION", "TicketMention":
+ .ticketMention
+ case "USER_MENTION", "UserMention":
+ .userMention
+ case "CREATED", "Created":
+ .created
+ default:
+ .unknown
+ }
+ }
+
+ private enum EventKind: Equatable {
+ case comment
+ case statusChange
+ case labelUpdated
+ case labelAdded
+ case labelRemoved
+ case assigned
+ case unassigned
+ case ticketMention
+ case userMention
+ case created
+ case unknown
+ }
+}
+
+// MARK: - Resolve Sheet
+
+private struct ResolveSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var selectedResolution: TicketResolution = .fixed
+
+ private static let resolutionOptions: [TicketResolution] = [
+ .closed, .fixed, .implemented, .wontFix,
+ .byDesign, .invalid, .duplicate, .notOurBug
+ ]
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Resolution") {
+ Picker("Resolution", selection: $selectedResolution) {
+ ForEach(Self.resolutionOptions, id: \.self) { resolution in
+ Text(resolution.displayName).tag(resolution)
+ }
+ }
+ .pickerStyle(.inline)
+ .labelsHidden()
+ }
+ }
+ .navigationTitle("Resolve Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Mark Resolved") {
+ Task {
+ await viewModel.updateStatus(
+ status: .resolved,
+ resolution: selectedResolution
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Assign Sheet
+
+private struct AssignSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var username = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ // Current assignees with remove buttons
+ if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
+ Section("Current Assignees") {
+ ForEach(ticket.assignees, id: \.canonicalName) { assignee in
+ HStack {
+ Text(assignee.canonicalName)
+ Spacer()
+ Button(role: .destructive) {
+ Task {
+ await viewModel.unassignUser(
+ username: assignee.canonicalName
+ )
+ }
+ } label: {
+ Image(systemName: "minus.circle.fill")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ Section("Add Assignee") {
+ TextField("Username or ~username", text: $username)
+ .textContentType(.username)
+ .autocorrectionDisabled()
+ .textInputAutocapitalization(.never)
+
+ Button("Add Assignee") {
+ let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
+ Task {
+ await viewModel.assignUser(username: name)
+ username = ""
+ }
+ }
+ .disabled(
+ username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .navigationTitle("Assignees")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") { isPresented = false }
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Labels Sheet
+
+private struct LabelsSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var showCreateLabel = false
+
+ var body: some View {
+ NavigationStack {
+ Group {
+ if viewModel.trackerLabels.isEmpty {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ } else {
+ ContentUnavailableView(
+ "No Labels",
+ systemImage: "tag",
+ description: Text("This tracker has no labels defined.")
+ )
+ }
+ } else {
+ List {
+ ForEach(viewModel.trackerLabels) { label in
+ LabelToggleRow(
+ label: label,
+ isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
+ isLoading: viewModel.isPerformingAction
+ ) { shouldApply in
+ Task {
+ if shouldApply {
+ await viewModel.labelTicket(labelId: label.id)
+ } else {
+ await viewModel.unlabelTicket(labelId: label.id)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigationTitle("Labels")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { isPresented = false }
+ }
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ showCreateLabel = true
+ } label: {
+ SwiftUI.Label("New Label", systemImage: "plus")
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateLabel) {
+ CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
+ .presentationDetents([.medium])
+ }
+ }
+ }
+}
+
+// MARK: - Create Label Sheet
+
+private struct CreateLabelSheet: View {
+ let viewModel: TicketDetailViewModel
+ @Binding var isPresented: Bool
+ @State private var labelName = ""
+ @State private var backgroundColor = Color.blue
+ @State private var foregroundColor = Color.white
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Label Details") {
+ TextField("Label name", text: $labelName)
+ .autocorrectionDisabled()
+ }
+
+ Section("Colors") {
+ ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
+ ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
+ }
+
+ Section("Preview") {
+ HStack {
+ Spacer()
+ Text(labelName.isEmpty ? "Label" : labelName)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ Spacer()
+ }
+ }
+ }
+ .navigationTitle("New Label")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Create Label") {
+ Task {
+ await viewModel.createLabel(
+ name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ backgroundColor: backgroundColor.hexString,
+ foregroundColor: foregroundColor.hexString
+ )
+ if viewModel.error == nil {
+ isPresented = false
+ }
+ }
+ }
+ .disabled(
+ labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || viewModel.isPerformingAction
+ )
+ }
+ }
+ .overlay {
+ if viewModel.isPerformingAction {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
+
+private struct LabelToggleRow: View {
+ let label: TicketLabel
+ let isApplied: Bool
+ let isLoading: Bool
+ let onToggle: (Bool) -> Void
+
+ var body: some View {
+ Button {
+ onToggle(!isApplied)
+ } label: {
+ HStack {
+ LabelPill(label: label)
+ Spacer()
+ if isApplied {
+ Image(systemName: "checkmark")
+ .foregroundStyle(.blue)
+ }
+ }
+ }
+ .disabled(isLoading)
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
new file mode 100644
index 0000000..70eaf0a
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -0,0 +1,551 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TicketDetailResponse: Decodable, Sendable {
+ let user: UserTrackerTicketWrapper
+}
+
+private struct UserTrackerTicketWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketWrapper
+}
+
+private struct TrackerTicketWrapper: Decodable, Sendable {
+ let ticket: TicketDetailPayload
+}
+
+private struct TicketDetailPayload: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let updated: Date
+ let title: String
+ let description: String?
+ let status: TicketStatus
+ let resolution: TicketResolution?
+ let authenticity: Authenticity
+ let submitter: Entity
+ let assignees: [Entity]
+ let labels: [TicketLabel]
+ let events: EventsPage
+}
+
+private struct EventsPage: Decodable, Sendable {
+ let results: [TicketEvent]
+ let cursor: String?
+}
+
+private struct SubmitCommentResponse: Decodable, Sendable {
+ let submitComment: SubmittedEvent
+}
+
+private struct SubmittedEvent: Decodable, Sendable {
+ let id: Int
+ let created: Date
+ let changes: [EventChange]
+}
+
+private struct MutationEventResponse: Decodable, Sendable {
+ let id: Int
+}
+
+private struct UpdateStatusResponse: Decodable, Sendable {
+ let updateTicketStatus: UpdatedStatusEvent
+}
+
+private struct UpdatedStatusEvent: Decodable, Sendable {
+ let eventType: String
+}
+
+private struct AssignUserResponse: Decodable, Sendable {
+ let assignUser: MutationEventResponse
+}
+
+private struct UnassignUserResponse: Decodable, Sendable {
+ let unassignUser: MutationEventResponse
+}
+
+private struct LabelTicketResponse: Decodable, Sendable {
+ let labelTicket: MutationEventResponse
+}
+
+private struct UnlabelTicketResponse: Decodable, Sendable {
+ let unlabelTicket: MutationEventResponse
+}
+
+private struct UserLookupResponse: Decodable, Sendable {
+ let user: UserIdPayload
+}
+
+private struct UserIdPayload: Decodable, Sendable {
+ let id: Int
+}
+
+private struct CreateLabelResponse: Decodable, Sendable {
+ let createLabel: TicketLabel
+}
+
+private struct TrackerLabelsResponse: Decodable, Sendable {
+ let user: UserTrackerLabelsWrapper
+}
+
+private struct UserTrackerLabelsWrapper: Decodable, Sendable {
+ let tracker: TrackerLabelsWrapper
+}
+
+private struct TrackerLabelsWrapper: Decodable, Sendable {
+ let labels: LabelsPage
+}
+
+private struct LabelsPage: Decodable, Sendable {
+ let results: [TicketLabel]
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketDetailViewModel {
+
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+ let ticketId: Int
+
+ private(set) var ticket: TicketDetail?
+ private(set) var events: [TicketEvent] = []
+ private(set) var isLoading = false
+ private(set) var isSubmitting = false
+ private(set) var isPerformingAction = false
+ private(set) var trackerLabels: [TicketLabel] = []
+ var commentText = ""
+ var error: String?
+
+ private let client: SRHTClient
+
+ private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
+ if lhs.created == rhs.created {
+ return lhs.id < rhs.id
+ }
+ return lhs.created < rhs.created
+ }
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.trackerRid = trackerRid
+ self.ticketId = ticketId
+ self.client = client
+ }
+
+ // MARK: - Queries
+
+ private static let detailQuery = """
+ query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ ticket(id: $ticketId) {
+ id
+ created
+ updated
+ title: subject
+ description: body
+ status
+ resolution
+ authenticity
+ submitter { canonicalName }
+ assignees { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ events {
+ results {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ ... on StatusChange {
+ oldStatus
+ newStatus
+ }
+ ... on LabelUpdate {
+ labeler { canonicalName }
+ label { name }
+ }
+ ... on Assignment {
+ assigner { canonicalName }
+ assignee { canonicalName }
+ }
+ ... on TicketMention {
+ mentioned { id }
+ }
+ ... on UserMention {
+ mentioned { canonicalName }
+ }
+ ... on Created {
+ author { canonicalName }
+ }
+ }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitCommentMutation = """
+ mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
+ submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ id
+ created
+ changes {
+ eventType
+ ... on Comment {
+ author { canonicalName }
+ text
+ authenticity
+ }
+ }
+ }
+ }
+ """
+
+ private static let updateStatusMutation = """
+ mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
+ updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
+ eventType: __typename
+ }
+ }
+ """
+
+ private static let assignUserMutation = """
+ mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let unassignUserMutation = """
+ mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
+ unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
+ }
+ """
+
+ private static let labelTicketMutation = """
+ mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let unlabelTicketMutation = """
+ mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
+ unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
+ }
+ """
+
+ private static let userLookupQuery = """
+ query userLookup($username: String!) {
+ user(username: $username) { id }
+ }
+ """
+
+ private static let trackerLabelsQuery = """
+ query trackerLabels($owner: String!, $tracker: String!) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ labels {
+ results { id name backgroundColor foregroundColor }
+ }
+ }
+ }
+ }
+ """
+
+ private static let createLabelMutation = """
+ mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
+ createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
+ id
+ name
+ backgroundColor
+ foregroundColor
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTicket() async {
+ guard !isLoading else { return }
+ isLoading = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.detailQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName,
+ "ticketId": ticketId
+ ],
+ responseType: TicketDetailResponse.self
+ )
+ let payload = result.user.tracker.ticket
+ ticket = TicketDetail(
+ id: payload.id,
+ created: payload.created,
+ updated: payload.updated,
+ title: payload.title,
+ description: payload.description,
+ status: payload.status,
+ resolution: payload.resolution,
+ authenticity: payload.authenticity,
+ submitter: payload.submitter,
+ assignees: payload.assignees,
+ labels: payload.labels
+ )
+ events = payload.events.results.sorted(by: Self.timelineOrder)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func submitComment() async {
+ let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty, !isSubmitting else { return }
+ isSubmitting = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = ["text": text]
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitCommentMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: SubmitCommentResponse.self
+ )
+ // Append the returned event so the comment shows immediately.
+ let submitted = result.submitComment
+ let event = TicketEvent(
+ id: submitted.id,
+ created: submitted.created,
+ changes: submitted.changes
+ )
+ events.append(event)
+ events.sort(by: Self.timelineOrder)
+ commentText = ""
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isSubmitting = false
+ }
+
+ func updateComment(commentId: Int, text: String) async {
+ _ = commentId
+ _ = text
+ error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
+ }
+
+ // MARK: - Ticket Actions
+
+ func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let input: [String: any Sendable] = [
+ "status": status.rawValue,
+ "resolution": resolution.rawValue
+ ]
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.updateStatusMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "input": input
+ ],
+ responseType: UpdateStatusResponse.self
+ )
+ // Re-fetch the ticket to get updated status/resolution
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func assignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": username],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.assignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: AssignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unassignUser(username: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ // Resolve username to user ID
+ let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
+ let userResult = try await client.execute(
+ service: .todo,
+ query: Self.userLookupQuery,
+ variables: ["username": stripped],
+ responseType: UserLookupResponse.self
+ )
+ let userId = userResult.user.id
+
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unassignUserMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "userId": userId
+ ],
+ responseType: UnassignUserResponse.self
+ )
+ // Reload to reflect the change
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func labelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.labelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: LabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func unlabelTicket(labelId: Int) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ _ = try await client.execute(
+ service: .todo,
+ query: Self.unlabelTicketMutation,
+ variables: [
+ "trackerId": trackerId,
+ "ticketId": ticketId,
+ "labelId": labelId
+ ],
+ responseType: UnlabelTicketResponse.self
+ )
+ await loadTicket()
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+ func loadTrackerLabels() async {
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.trackerLabelsQuery,
+ variables: [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ],
+ responseType: TrackerLabelsResponse.self
+ )
+ trackerLabels = result.user.tracker.labels.results
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+
+ func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
+ guard !isPerformingAction else { return }
+ isPerformingAction = true
+ error = nil
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createLabelMutation,
+ variables: [
+ "trackerId": trackerId,
+ "name": name,
+ "backgroundColor": backgroundColor,
+ "foregroundColor": foregroundColor
+ ],
+ responseType: CreateLabelResponse.self
+ )
+ trackerLabels.append(result.createLabel)
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isPerformingAction = false
+ }
+
+}
diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift
new file mode 100644
index 0000000..7e16276
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListView.swift
@@ -0,0 +1,389 @@
+import SwiftUI
+
+struct TicketListView: View {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+ let trackerRid: String
+
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TicketListViewModel?
+ @State private var showCreateTicketSheet = false
+ @State private var createdTicket: TicketSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ }
+ }
+ .navigationTitle(trackerName)
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
+ Image(systemName: "square.and.arrow.up")
+ }
+
+ if viewModel != nil {
+ Button {
+ showCreateTicketSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTicketSheet) {
+ if let viewModel {
+ CreateTicketSheet(viewModel: viewModel) { ticket in
+ showCreateTicketSheet = false
+ createdTicket = ticket
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTicket != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTicket = nil
+ }
+ }
+ )) {
+ if let createdTicket {
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TicketListViewModel(
+ ownerUsername: ownerUsername,
+ trackerName: trackerName,
+ trackerId: trackerId,
+ client: appState.client
+ )
+ viewModel = vm
+ await vm.loadTickets()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TicketListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ // Filter picker
+ Section {
+ Picker("Filter", selection: $vm.filter) {
+ ForEach(TicketFilter.allCases, id: \.self) { filter in
+ Text(filter.rawValue).tag(filter)
+ }
+ }
+ .pickerStyle(.segmented)
+ .listRowBackground(Color.clear)
+ .listRowInsets(EdgeInsets())
+ }
+
+ // Tickets
+ ForEach(viewModel.filteredTickets) { ticket in
+ NavigationLink(value: ticket) {
+ TicketRowView(ticket: ticket)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: ticket)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.tickets.isEmpty {
+ SRHTLoadingStateView(message: "Loading tickets…")
+ } else if let error = viewModel.error, viewModel.tickets.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Tickets",
+ message: error,
+ retryAction: { await viewModel.loadTickets() }
+ )
+ } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Tickets",
+ systemImage: "ticket",
+ description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
+ await viewModel.loadTickets()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTickets()
+ }
+ .navigationDestination(for: TicketSummary.self) { ticket in
+ TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
+ }
+ }
+}
+
+private struct CreateTicketSheet: View {
+ let viewModel: TicketListViewModel
+ let onCreated: (TicketSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var subject = ""
+ @State private var descriptionText = ""
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Ticket Details") {
+ TextField("Title", text: $subject)
+ TextField("Description (optional)", text: $descriptionText, axis: .vertical)
+ .lineLimit(6...12)
+ }
+ }
+ .navigationTitle("New Ticket")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
+ onCreated(ticket)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTicket {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Ticket")
+ }
+ }
+ .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Ticket Row
+
+private struct TicketRowView: View {
+ let ticket: TicketSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .top) {
+ TicketStatusIcon(status: ticket.status)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("#\(ticket.id)")
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ + Text(" ")
+ + Text(ticket.title)
+ .font(.subheadline)
+ }
+
+ Spacer()
+ }
+
+ HStack(spacing: 8) {
+ Text(ticket.submitter.canonicalName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ Text(ticket.created.relativeDescription)
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ if !ticket.labels.isEmpty {
+ FlowLayout(spacing: 4) {
+ ForEach(ticket.labels) { label in
+ LabelPill(label: label)
+ }
+ }
+ }
+
+ if !ticket.assignees.isEmpty {
+ HStack(spacing: 4) {
+ Image(systemName: "person.fill")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+// MARK: - Ticket Status Icon
+
+struct TicketStatusIcon: View {
+ let status: TicketStatus
+
+ var body: some View {
+ Image(systemName: iconName)
+ .foregroundStyle(color)
+ }
+
+ private var iconName: String {
+ switch status {
+ case .reported: "circle"
+ case .confirmed: "circle.inset.filled"
+ case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
+ case .pending: "clock.fill"
+ case .resolved: "checkmark.circle.fill"
+ }
+ }
+
+ private var color: Color {
+ switch status {
+ case .reported: .gray
+ case .confirmed: .blue
+ case .inProgress: .yellow
+ case .pending: .orange
+ case .resolved: .green
+ }
+ }
+}
+
+// MARK: - Label Pill
+
+struct LabelPill: View {
+ let label: TicketLabel
+
+ var body: some View {
+ Text(label.name)
+ .font(.caption2.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(backgroundColor)
+ .foregroundStyle(foregroundColor)
+ .clipShape(Capsule())
+ }
+
+ private var backgroundColor: Color {
+ Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
+ }
+
+ private var foregroundColor: Color {
+ Color(hex: label.foregroundColor) ?? .primary
+ }
+}
+
+// MARK: - Color from hex string
+
+extension Color {
+ init?(hex: String) {
+ var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+ if hexString.hasPrefix("#") {
+ hexString.removeFirst()
+ }
+
+ guard hexString.count == 6,
+ let hexNumber = UInt64(hexString, radix: 16) else {
+ return nil
+ }
+
+ let r = Double((hexNumber & 0xFF0000) >> 16) / 255
+ let g = Double((hexNumber & 0x00FF00) >> 8) / 255
+ let b = Double(hexNumber & 0x0000FF) / 255
+
+ self.init(red: r, green: g, blue: b)
+ }
+
+ /// Returns a `#rrggbb` hex string for this color.
+ var hexString: String {
+ let resolved = resolve(in: .init())
+ let r = Int(max(0, min(1, resolved.red)) * 255)
+ let g = Int(max(0, min(1, resolved.green)) * 255)
+ let b = Int(max(0, min(1, resolved.blue)) * 255)
+ return String(format: "#%02x%02x%02x", r, g, b)
+ }
+}
+
+// MARK: - Flow Layout (for label pills)
+
+struct FlowLayout: Layout {
+ var spacing: CGFloat = 4
+
+ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ return result.size
+ }
+
+ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
+ let result = layoutSubviews(proposal: proposal, subviews: subviews)
+ for (index, position) in result.positions.enumerated() {
+ subviews[index].place(
+ at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
+ proposal: ProposedViewSize(result.sizes[index])
+ )
+ }
+ }
+
+ private struct LayoutResult {
+ var positions: [CGPoint]
+ var sizes: [CGSize]
+ var size: CGSize
+ }
+
+ private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
+ let maxWidth = proposal.width ?? .infinity
+ var positions: [CGPoint] = []
+ var sizes: [CGSize] = []
+ var currentX: CGFloat = 0
+ var currentY: CGFloat = 0
+ var lineHeight: CGFloat = 0
+ var totalHeight: CGFloat = 0
+ var totalWidth: CGFloat = 0
+
+ for subview in subviews {
+ let size = subview.sizeThatFits(.unspecified)
+ sizes.append(size)
+
+ if currentX + size.width > maxWidth, currentX > 0 {
+ currentX = 0
+ currentY += lineHeight + spacing
+ lineHeight = 0
+ }
+
+ positions.append(CGPoint(x: currentX, y: currentY))
+ lineHeight = max(lineHeight, size.height)
+ currentX += size.width + spacing
+ totalWidth = max(totalWidth, currentX - spacing)
+ totalHeight = currentY + lineHeight
+ }
+
+ return LayoutResult(
+ positions: positions,
+ sizes: sizes,
+ size: CGSize(width: totalWidth, height: totalHeight)
+ )
+ }
+}
diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift
new file mode 100644
index 0000000..916037e
--- /dev/null
+++ b/Hutch/Views/Tickets/TicketListViewModel.swift
@@ -0,0 +1,215 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackerTicketsResponse: Decodable, Sendable {
+ let user: UserTrackerWrapper
+}
+
+private struct UserTrackerWrapper: Decodable, Sendable {
+ let tracker: TrackerTicketsWrapper
+}
+
+private struct TrackerTicketsWrapper: Decodable, Sendable {
+ let tickets: TicketsPage
+}
+
+private struct TicketsPage: Decodable, Sendable {
+ let results: [TicketSummary]
+ let cursor: String?
+}
+
+// MARK: - Filter
+
+enum TicketFilter: String, CaseIterable, Sendable {
+ case open = "Open"
+ case resolved = "Resolved"
+ case all = "All"
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TicketListViewModel {
+ let ownerUsername: String
+ let trackerName: String
+ let trackerId: Int
+
+ private(set) var tickets: [TicketSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTicket = false
+ var error: String?
+ var filter: TicketFilter = .open
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
+ self.ownerUsername = ownerUsername
+ self.trackerName = trackerName
+ self.trackerId = trackerId
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
+ user(username: $owner) {
+ tracker(name: $tracker) {
+ tickets(cursor: $cursor) {
+ results {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ cursor
+ }
+ }
+ }
+ }
+ """
+
+ private static let submitTicketMutation = """
+ mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
+ submitTicket(trackerId: $trackerId, input: $input) {
+ id
+ title: subject
+ status
+ resolution
+ created
+ submitter { canonicalName }
+ labels { id name backgroundColor foregroundColor }
+ assignees { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Computed
+
+ /// Tickets filtered by the selected status filter.
+ var filteredTickets: [TicketSummary] {
+ switch filter {
+ case .open:
+ tickets.filter { $0.status.isOpen }
+ case .resolved:
+ tickets.filter { !$0.status.isOpen }
+ case .all:
+ tickets
+ }
+ }
+
+ // MARK: - Public API
+
+ func loadTickets() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ tickets = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TicketSummary) async {
+ guard let last = tickets.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ tickets.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTicket(subject: String, body: String) async -> TicketSummary? {
+ guard !isCreatingTicket else { return nil }
+
+ let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedSubject.isEmpty else {
+ error = "Enter a ticket title."
+ return nil
+ }
+
+ isCreatingTicket = true
+ error = nil
+ defer { isCreatingTicket = false }
+
+ var input: [String: any Sendable] = [
+ "subject": trimmedSubject
+ ]
+ let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedBody.isEmpty {
+ input["body"] = trimmedBody
+ }
+ let variables: [String: any Sendable] = [
+ "trackerId": trackerId,
+ "input": input
+ ]
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.submitTicketMutation,
+ variables: variables,
+ responseType: SubmitTicketResponse.self
+ )
+ let ticket = result.submitTicket
+ tickets.insert(ticket, at: 0)
+ return ticket
+ } catch {
+ self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TicketsPage {
+ var variables: [String: any Sendable] = [
+ "owner": ownerUsername,
+ "tracker": trackerName
+ ]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables,
+ responseType: TrackerTicketsResponse.self
+ )
+ return result.user.tracker.tickets
+ }
+
+ private struct SubmitTicketResponse: Decodable, Sendable {
+ let submitTicket: TicketSummary
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
new file mode 100644
index 0000000..d16246b
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -0,0 +1,214 @@
+import SwiftUI
+
+struct TrackerListView: View {
+ @Environment(AppState.self) private var appState
+ @State private var viewModel: TrackerListViewModel?
+ @State private var showCreateTrackerSheet = false
+ @State private var createdTracker: TrackerSummary?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ listContent(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ }
+ }
+ .navigationTitle("Trackers")
+ .toolbar {
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateTrackerSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .sheet(isPresented: $showCreateTrackerSheet) {
+ if let viewModel {
+ CreateTrackerSheet(viewModel: viewModel) { tracker in
+ showCreateTrackerSheet = false
+ createdTracker = tracker
+ }
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { createdTracker != nil },
+ set: { isPresented in
+ if !isPresented {
+ createdTracker = nil
+ }
+ }
+ )) {
+ if let createdTracker {
+ TicketListView(
+ ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
+ trackerName: createdTracker.name,
+ trackerId: createdTracker.id,
+ trackerRid: createdTracker.rid
+ )
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = TrackerListViewModel(client: appState.client)
+ viewModel = vm
+ await vm.loadTrackers()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func listContent(_ viewModel: TrackerListViewModel) -> some View {
+ @Bindable var vm = viewModel
+
+ List {
+ ForEach(viewModel.trackers) { tracker in
+ NavigationLink(value: tracker) {
+ TrackerRowView(tracker: tracker)
+ }
+ .task {
+ await viewModel.loadMoreIfNeeded(currentItem: tracker)
+ }
+ }
+
+ if viewModel.isLoadingMore {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowSeparator(.hidden)
+ }
+ }
+ .listStyle(.plain)
+ .overlay {
+ if viewModel.isLoading, viewModel.trackers.isEmpty {
+ SRHTLoadingStateView(message: "Loading trackers…")
+ } else if let error = viewModel.error, viewModel.trackers.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Trackers",
+ message: error,
+ retryAction: { await viewModel.loadTrackers() }
+ )
+ } else if viewModel.trackers.isEmpty, viewModel.error == nil {
+ ContentUnavailableView(
+ "No Trackers",
+ systemImage: "checklist",
+ description: Text("Your bug trackers will appear here.")
+ )
+ }
+ }
+ .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
+ await viewModel.loadTrackers()
+ }
+ .srhtErrorBanner(error: $vm.error)
+ .refreshable {
+ await viewModel.loadTrackers()
+ }
+ .navigationDestination(for: TrackerSummary.self) { tracker in
+ TicketListView(
+ ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
+ trackerName: tracker.name,
+ trackerId: tracker.id,
+ trackerRid: tracker.rid
+ )
+ }
+ }
+}
+
+private struct CreateTrackerSheet: View {
+ let viewModel: TrackerListViewModel
+ let onCreated: (TrackerSummary) -> Void
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var name = ""
+ @State private var description = ""
+ @State private var visibility: Visibility = .public
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Tracker Details") {
+ TextField("Tracker name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ TextField("Short description (optional)", text: $description, axis: .vertical)
+ .lineLimit(2...4)
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.public)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.private)
+ }
+ }
+ }
+ .navigationTitle("New Tracker")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if let tracker = await viewModel.createTracker(
+ name: name,
+ description: description,
+ visibility: visibility
+ ) {
+ onCreated(tracker)
+ }
+ }
+ } label: {
+ if viewModel.isCreatingTracker {
+ ProgressView()
+ .controlSize(.small)
+ } else {
+ Text("Create Tracker")
+ }
+ }
+ .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Tracker Row
+
+private struct TrackerRowView: View {
+ let tracker: TrackerSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(tracker.name)
+ .font(.subheadline.weight(.medium))
+
+ Spacer()
+
+ VisibilityBadge(visibility: tracker.visibility)
+ }
+
+ if let owner = tracker.owner.canonicalName.split(separator: "~").last {
+ Text("~\(owner)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let description = tracker.description, !description.isEmpty {
+ Text(description)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Text(tracker.updated.relativeDescription)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 2)
+ }
+}
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
new file mode 100644
index 0000000..9704071
--- /dev/null
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -0,0 +1,166 @@
+import Foundation
+
+// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
+
+private struct TrackersResponse: Decodable, Sendable {
+ let trackers: TrackersPage
+}
+
+private struct TrackersPage: Decodable, Sendable {
+ let results: [TrackerSummary]
+ let cursor: String?
+}
+
+// MARK: - View Model
+
+@Observable
+@MainActor
+final class TrackerListViewModel {
+
+ private(set) var trackers: [TrackerSummary] = []
+ private(set) var isLoading = false
+ private(set) var isLoadingMore = false
+ private(set) var isCreatingTracker = false
+ var error: String?
+
+ private var cursor: String?
+ private var hasMore = true
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ // MARK: - Query
+
+ private static let query = """
+ query trackers($cursor: Cursor) {
+ trackers(cursor: $cursor) {
+ results {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let createTrackerMutation = """
+ mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
+ createTracker(name: $name, visibility: $visibility, description: $description) {
+ id
+ rid
+ name
+ description
+ visibility
+ updated
+ owner { canonicalName }
+ }
+ }
+ """
+
+ // MARK: - Public API
+
+ func loadTrackers() async {
+ isLoading = true
+ error = nil
+ cursor = nil
+ hasMore = true
+
+ do {
+ let page = try await fetchPage(cursor: nil)
+ trackers = page.results
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoading = false
+ }
+
+ func loadMoreIfNeeded(currentItem: TrackerSummary) async {
+ guard let last = trackers.last,
+ last.id == currentItem.id,
+ hasMore,
+ !isLoadingMore else {
+ return
+ }
+
+ isLoadingMore = true
+
+ do {
+ let page = try await fetchPage(cursor: cursor)
+ trackers.append(contentsOf: page.results)
+ cursor = page.cursor
+ hasMore = page.cursor != nil
+ } catch {
+ self.error = error.localizedDescription
+ }
+
+ isLoadingMore = false
+ }
+
+ func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
+ guard !isCreatingTracker else { return nil }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedName.isEmpty else {
+ error = "Enter a tracker name."
+ return nil
+ }
+
+ isCreatingTracker = true
+ error = nil
+ defer { isCreatingTracker = false }
+
+ var variables: [String: any Sendable] = [
+ "name": trimmedName,
+ "visibility": visibility.rawValue
+ ]
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedDescription.isEmpty {
+ variables["description"] = trimmedDescription
+ }
+
+ do {
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.createTrackerMutation,
+ variables: variables,
+ responseType: CreateTrackerResponse.self
+ )
+ let tracker = result.createTracker
+ trackers.insert(tracker, at: 0)
+ return tracker
+ } catch {
+ self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
+ return nil
+ }
+ }
+
+ // MARK: - Private
+
+ private func fetchPage(cursor: String?) async throws -> TrackersPage {
+ var variables: [String: any Sendable] = [:]
+ if let cursor {
+ variables["cursor"] = cursor
+ }
+ let result = try await client.execute(
+ service: .todo,
+ query: Self.query,
+ variables: variables.isEmpty ? nil : variables,
+ responseType: TrackersResponse.self
+ )
+ return result.trackers
+ }
+
+ private struct CreateTrackerResponse: Decodable, Sendable {
+ let createTracker: TrackerSummary
+ }
+}
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..95d8895
--- /dev/null
+++ b/README.md
@@ -0,0 +1,102 @@
+# Hutch
+
+<div style="display: flex; align-items: center; border: 1px solid #ccc; border-radius: 10px; padding: 10px; width: 300px; background-color: #f9f9f9;">
+ <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;">
+ <div>
+ <h2 style="margin: 0; font-size: 20px; color: #333;">Hutch</h2>
+ <h3 style="margin: 0; font-size: 14px; color: #777;">An iOS client for Sourcehut</h3>
+ </div>
+</div>
+
+
+![Hutch App Logo]()
+
+## Table of Contents
+
+- [Introduction](#introduction)
+<!-- - [Features](#features)
+- [Screenshots](#screenshots) -->
+- [Installation](#installation)
+- [Usage](#usage)
+- [Contributing](#contributing)
+- [License](#license)
+- [Contact](#contact)
+
+## Introduction
+
+**Hutch** is a native Swift client on iOS for [Sourcehut](https://sr.ht).
+
+<!-- TODO
+
+## Features
+
+- **Seamless Integration**: Effortlessly connect to your sr.ht account.
+- **Repository Access**: View and manage your repositories in one convenient
+ location.
+- **Ticket Management**: Create and track tickets associated with your projects.
+- **Profile Management**: Update your account description, keys, and
+ preferences.
+
+For a complete list of features, visit [here](http://example.com/features).
+
+## Roadmap
+
+- **Build Notifications**: Get real-time updates on your builds and status
+ changes.
+- **Dashboard Access**: View and manage your projects in one convenient
+ location.
+
+To see future planned features and updates, check out the project roadmap
+[here](http://example.com/roadmap).
+
+## Screenshots
+
+Here are some screenshots of the app in action:
+
+![Screenshot 1](https://img.cleberg.net/apps/hutch/screenshots/repositories.png)
+![Screenshot 2](https://img.cleberg.net/apps/hutch/screenshots/builds.png)
+![Screenshot 3](https://img.cleberg.net/apps/hutch/screenshots/tickets.png)
+![Screenshot 4](https://img.cleberg.net/apps/hutch/screenshots/settings.png)
+
+-->
+
+## Installation
+
+To get started with Hutch, follow these steps:
+
+1. Clone the repository: `git clone
+ https://github.com/ccleberg/Hutch.git`
+2. Open the project in Xcode.
+3. Run the app on your device or simulator.
+
+## Usage
+
+Provide instructions on how to use the app. You might include:
+
+- **Step 1**: To login, use a PAT in Sourcehut's portal:
+ https://meta.sr.ht/oauth2/personal-token
+- **Step 2**: Navigate the app the same way you'd navigate Sourcehut.
+- **Step 3**: If something doesn't work as expected, send us an email!
+
+## Contributing
+
+Contributions are welcome! Please follow these steps:
+
+1. Fork the repository.
+2. Create a new branch for your feature (`git checkout -b feature-xyz`).
+3. Make your changes.
+4. Commit your changes and push to the branch.
+5. Open a pull request.
+
+## License
+
+This project is licensed under the GPL 3.0 or later License. See the
+[LICENSE](LICENSE) file for more information.
+
+## Contact
+
+For questions or feedback, please reach out to [[email protected]].
+
+---
+
+Thank you for checking out **Hutch**! We hope you find it useful and enjoyable.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..946d5f1
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,68 @@
+# Hutch TODO
+
+## Claude Code, Codex, & Mistral Prompting Notes
+
+- **Always start a new session with the project context block** (see
+ hutch-ios-prompts.md prompt 0) so the agent knows the API conventions.
+- **Keep prompts short and specific.** Long prompts waste context. One bug or
+ feature per prompt. Paste console output and error messages directly instead
+ of describing them.
+- **When a fix fails twice, ask the agent to print raw state first** (raw API
+ response, current file contents, exact variable values) before attempting
+ another fix. Blind retries consume context without progress.
+- **Never paste full file contents into the prompt.** Ask the agent to read
+ the file itself using its file tools.
+- **Batch only tightly related changes.** Unrelated changes in one prompt
+ increase the chance of partial failure and wasted context on rollback.
+
+## Features
+
+- **Exact repository lookup**: Since public repository discovery/search is not
+ exposed in the public sr.ht API, add a manual `~owner/repo` lookup flow that
+ can open git.sr.ht or hg.sr.ht repositories directly.
+
+- **paste.sr.ht tab**: Paste support. Endpoint:
+ https://paste.sr.ht/graphql. Show the authenticated user's pastes, allow
+ viewing individual pastes, and support creating new pastes. Required scope:
+ PASTES:RO for browsing, PASTES:RW for creation/editing.
+
+- **lists.sr.ht tab**: Mailing list support. Endpoint:
+ https://lists.sr.ht/graphql. Show the authenticated user's mailing lists,
+ allow browsing email threads, and display individual emails as plain text.
+ Required scope: LISTS:RO.
+
+- **pages.sr.ht tab**: Site management support. Endpoint:
+ https://pages.sr.ht/graphql. Show the authenticated user's published sites,
+ site metadata, publishing status, and access control where supported. This is
+ for managing Pages sites, not browsing public project discovery.
+
+- **Donation page / IAP**: Add a donation/support page once the App Store /
+ StoreKit setup is ready.
+
+## Out of Scope
+
+- **Universal links** (e.g. tapping a git.sr.ht URL in Safari opens Hutch):
+ Requires Sourcehut to host an apple-app-site-association file on their
+ servers. This is outside our control. The hutch:// custom URL scheme works as
+ a fallback for links we generate ourselves.
+
+- **Push notifications for builds and tickets**: The sr.ht API only supports
+ server-side webhooks (HTTP POST to a URL you control). Delivering push
+ notifications to iOS would require a backend relay server to receive webhooks
+ and forward them via APNs. Out of scope for a personal app with no backend.
+
+- **Contribution activity / GitHub-style heatmap**: No aggregate contributions
+ endpoint exists in the sr.ht GraphQL API. Computing this would require
+ paginating every repository's full commit log and filtering by author --
+ extremely slow and not practical.
+
+- **Explore / search (hub.sr.ht)**: The public hub.sr.ht GraphQL API does not
+ expose repository discovery/search or a projects feed comparable to
+ https://sr.ht/projects.
+
+- **Pronouns on profile**: Not exposed in the meta.sr.ht GraphQL schema. The
+ field exists in the web UI but is not available to external API clients.
+
+- **Revoke personal access tokens**: The revokePersonalAccessToken mutation is
+ marked @internal in the meta.sr.ht schema and is not accessible to external
+ clients.