krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

32ad6cab8d58d99ebd8a28e8fa6e6f4e587cb1e5

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-03-18T04:19:43Z

v1.0
 Hutch.xcodeproj/project.pbxproj                    |  377 +++++++
 .../project.xcworkspace/contents.xcworkspacedata   |    7 +
 Hutch/API_REFERENCE.md                             |  147 +++
 Hutch/App/AppState.swift                           |  222 ++++
 Hutch/App/DeepLink.swift                           |   54 +
 Hutch/App/HutchApp.swift                           |   20 +
 Hutch/App/RootView.swift                           |  197 ++++
 .../AccentColor.colorset/Contents.json             |   11 +
 Hutch/Assets.xcassets/AppIcon.appiconset/1.png     |  Bin 0 -> 47870 bytes
 Hutch/Assets.xcassets/AppIcon.appiconset/2.png     |  Bin 0 -> 42089 bytes
 .../AppIcon.appiconset/Contents.json               |   37 +
 Hutch/Assets.xcassets/Contents.json                |    6 +
 Hutch/Extensions/Date+Relative.swift               |   10 +
 Hutch/Extensions/DateFormatter+SRHT.swift          |   39 +
 Hutch/Extensions/ErrorViews.swift                  |  159 +++
 Hutch/Extensions/Int+ByteFormatting.swift          |    8 +
 Hutch/Extensions/KeychainHelper.swift              |  103 ++
 Hutch/Extensions/SRHTWebURL.swift                  |   50 +-
 Hutch/Info.plist                                   |   23 +
 Hutch/Models/ArtifactInfo.swift                    |   22 +
 Hutch/Models/Builds.swift                          |  111 ++
 Hutch/Models/CommitDetail.swift                    |   65 ++
 Hutch/Models/CommitSummary.swift                   |   22 +
 Hutch/Models/Git.swift                             |  241 +++++
 Hutch/Models/Meta.swift                            |   65 ++
 Hutch/Models/RepositorySummary.swift               |   57 +
 Hutch/Models/Todo.swift                            |  234 ++++
 Hutch/Models/User.swift                            |   10 +
 Hutch/Networking/GraphQLRequest.swift              |   45 +
 Hutch/Networking/NetworkMonitor.swift              |   48 +
 Hutch/Networking/Pagination.swift                  |  176 +++
 Hutch/Networking/ResponseCache.swift               |   33 +
 Hutch/Networking/SRHTClient.swift                  |  521 +++++++++
 Hutch/Networking/SRHTError.swift                   |   63 ++
 Hutch/Networking/SRHTService.swift                 |   34 +
 Hutch/Views/Auth/AuthView.swift                    |   95 ++
 Hutch/Views/Builds/BuildDetailView.swift           |  383 +++++++
 Hutch/Views/Builds/BuildDetailViewModel.swift      |  235 ++++
 Hutch/Views/Builds/BuildListView.swift             |  217 ++++
 Hutch/Views/Builds/BuildListViewModel.swift        |  220 ++++
 Hutch/Views/Builds/BuildRowView.swift              |  106 ++
 Hutch/Views/Repositories/ArtifactsView.swift       |   73 ++
 Hutch/Views/Repositories/CommitDetailView.swift    |  332 ++++++
 .../Views/Repositories/CommitDetailViewModel.swift |  102 ++
 Hutch/Views/Repositories/CommitLogView.swift       |   59 +
 Hutch/Views/Repositories/CommitRowView.swift       |   30 +
 Hutch/Views/Repositories/DiffView.swift            |   79 ++
 Hutch/Views/Repositories/FileTreeView.swift        |  446 ++++++++
 Hutch/Views/Repositories/FileTreeViewModel.swift   |  524 +++++++++
 .../Repositories/HgRepositoryDetailView.swift      |  506 ++++++---
 .../Repositories/HgRepositoryDetailViewModel.swift |  545 ++++++++++
 .../Repositories/HgRepositorySettingsView.swift    |  239 +++++
 .../HgRepositorySettingsViewModel.swift            |  287 +++++
 Hutch/Views/Repositories/ReadmeView.swift          | 1125 ++++++++++++++++++++
 Hutch/Views/Repositories/ReferencesListView.swift  |   90 ++
 .../Views/Repositories/RepositoryDetailView.swift  |  107 ++
 .../Repositories/RepositoryDetailViewModel.swift   |  397 +++++++
 Hutch/Views/Repositories/RepositoryListView.swift  |  224 ++++
 .../Repositories/RepositoryListViewModel.swift     |  548 ++++++++++
 Hutch/Views/Repositories/RepositoryRowView.swift   |   85 ++
 .../Repositories/RepositorySettingsView.swift      |   58 +-
 .../Repositories/RepositorySettingsViewModel.swift |   24 +-
 .../Repositories/RepositorySummarySupport.swift    |   51 +-
 Hutch/Views/Settings/SettingsView.swift            |  724 +++++++++++++
 Hutch/Views/Settings/SettingsViewModel.swift       |  394 +++++++
 Hutch/Views/Tickets/TicketDetailView.swift         |  828 ++++++++++++++
 Hutch/Views/Tickets/TicketDetailViewModel.swift    |  551 ++++++++++
 Hutch/Views/Tickets/TicketListView.swift           |  389 +++++++
 Hutch/Views/Tickets/TicketListViewModel.swift      |  215 ++++
 Hutch/Views/Tickets/TrackerListView.swift          |  214 ++++
 Hutch/Views/Tickets/TrackerListViewModel.swift     |  166 +++
 README.md                                          |  102 ++
 TODO.md                                            |   68 ++
 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
Binary files /dev/null and b/Hutch/Assets.xcassets/AppIcon.appiconset/1.png 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
Binary files /dev/null and b/Hutch/Assets.xcassets/AppIcon.appiconset/2.png 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:hello@cleberg.net")!) {
+                    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 [hello@cleberg.net].
+
+---
+
+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.