Commit 9954d3a4fa

9954d3a4fa35d962e45878cb6714d2cc2337c246

parent: b9ca700ee0

Unsigned

cmc <hello@cleberg.net> · 2026-03-18 04:19 UTC

v1.0

Layout: unified · split

Hutch.xcodeproj/project.pbxproj added +377
@@ -0,0 +1,377 @@
1// !$*UTF8*$!
2{
3 archiveVersion = 1;
4 classes = {
5 };
6 objectVersion = 77;
7 objects = {
8
9/* Begin PBXBuildFile section */
10 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89612F69DEA900FC0253 /* TODO.md */; };
11 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89632F69DEB900FC0253 /* LICENSE */; };
12 8B2F89672F69DEB900FC0253 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89642F69DEB900FC0253 /* README.md */; };
13 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
14/* End PBXBuildFile section */
15
16/* Begin PBXFileReference section */
17 8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; };
18 8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
19 8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
20 8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
21 8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; };
22/* End PBXFileReference section */
23
24/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
25 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */ = {
26 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
27 membershipExceptions = (
28 Info.plist,
29 );
30 target = 8B4B28D02F6704280045FA19 /* Hutch */;
31 };
32/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
33
34/* Begin PBXFileSystemSynchronizedRootGroup section */
35 8B4B28D32F6704280045FA19 /* Hutch */ = {
36 isa = PBXFileSystemSynchronizedRootGroup;
37 exceptions = (
38 8B4B29632F6715DD0045FA19 /* Exceptions for "Hutch" folder in "Hutch" target */,
39 );
40 path = Hutch;
41 sourceTree = "<group>";
42 };
43/* End PBXFileSystemSynchronizedRootGroup section */
44
45/* Begin PBXFrameworksBuildPhase section */
46 8B4B28CE2F6704280045FA19 /* Frameworks */ = {
47 isa = PBXFrameworksBuildPhase;
48 buildActionMask = 2147483647;
49 files = (
50 );
51 runOnlyForDeploymentPostprocessing = 0;
52 };
53/* End PBXFrameworksBuildPhase section */
54
55/* Begin PBXGroup section */
56 8B4B28C82F6704280045FA19 = {
57 isa = PBXGroup;
58 children = (
59 8B2F89632F69DEB900FC0253 /* LICENSE */,
60 8B2F89642F69DEB900FC0253 /* README.md */,
61 8B2F89652F69DEB900FC0253 /* SECURITY.md */,
62 8B2F89612F69DEA900FC0253 /* TODO.md */,
63 8B4B28D32F6704280045FA19 /* Hutch */,
64 8B4B28D22F6704280045FA19 /* Products */,
65 );
66 sourceTree = "<group>";
67 };
68 8B4B28D22F6704280045FA19 /* Products */ = {
69 isa = PBXGroup;
70 children = (
71 8B4B28D12F6704280045FA19 /* Hutch.app */,
72 );
73 name = Products;
74 sourceTree = "<group>";
75 };
76/* End PBXGroup section */
77
78/* Begin PBXNativeTarget section */
79 8B4B28D02F6704280045FA19 /* Hutch */ = {
80 isa = PBXNativeTarget;
81 buildConfigurationList = 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */;
82 buildPhases = (
83 8B4B28CD2F6704280045FA19 /* Sources */,
84 8B4B28CE2F6704280045FA19 /* Frameworks */,
85 8B4B28CF2F6704280045FA19 /* Resources */,
86 );
87 buildRules = (
88 );
89 dependencies = (
90 );
91 fileSystemSynchronizedGroups = (
92 8B4B28D32F6704280045FA19 /* Hutch */,
93 );
94 name = Hutch;
95 packageProductDependencies = (
96 );
97 productName = Hutch;
98 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
99 productType = "com.apple.product-type.application";
100 };
101/* End PBXNativeTarget section */
102
103/* Begin PBXProject section */
104 8B4B28C92F6704280045FA19 /* Project object */ = {
105 isa = PBXProject;
106 attributes = {
107 BuildIndependentTargetsInParallel = 1;
108 LastSwiftUpdateCheck = 2630;
109 LastUpgradeCheck = 2630;
110 TargetAttributes = {
111 8B4B28D02F6704280045FA19 = {
112 CreatedOnToolsVersion = 26.3;
113 };
114 };
115 };
116 buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */;
117 developmentRegion = en;
118 hasScannedForEncodings = 0;
119 knownRegions = (
120 en,
121 Base,
122 );
123 mainGroup = 8B4B28C82F6704280045FA19;
124 minimizedProjectReferenceProxies = 1;
125 preferredProjectObjectVersion = 77;
126 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
127 projectDirPath = "";
128 projectRoot = "";
129 targets = (
130 8B4B28D02F6704280045FA19 /* Hutch */,
131 );
132 };
133/* End PBXProject section */
134
135/* Begin PBXResourcesBuildPhase section */
136 8B4B28CF2F6704280045FA19 /* Resources */ = {
137 isa = PBXResourcesBuildPhase;
138 buildActionMask = 2147483647;
139 files = (
140 8B2F89622F69DEA900FC0253 /* TODO.md in Resources */,
141 8B2F89662F69DEB900FC0253 /* LICENSE in Resources */,
142 8B2F89672F69DEB900FC0253 /* README.md in Resources */,
143 8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */,
144 );
145 runOnlyForDeploymentPostprocessing = 0;
146 };
147/* End PBXResourcesBuildPhase section */
148
149/* Begin PBXSourcesBuildPhase section */
150 8B4B28CD2F6704280045FA19 /* Sources */ = {
151 isa = PBXSourcesBuildPhase;
152 buildActionMask = 2147483647;
153 files = (
154 );
155 runOnlyForDeploymentPostprocessing = 0;
156 };
157/* End PBXSourcesBuildPhase section */
158
159/* Begin XCBuildConfiguration section */
160 8B4B28DA2F6704290045FA19 /* Debug */ = {
161 isa = XCBuildConfiguration;
162 buildSettings = {
163 ALWAYS_SEARCH_USER_PATHS = NO;
164 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
165 CLANG_ANALYZER_NONNULL = YES;
166 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
167 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
168 CLANG_ENABLE_MODULES = YES;
169 CLANG_ENABLE_OBJC_ARC = YES;
170 CLANG_ENABLE_OBJC_WEAK = YES;
171 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
172 CLANG_WARN_BOOL_CONVERSION = YES;
173 CLANG_WARN_COMMA = YES;
174 CLANG_WARN_CONSTANT_CONVERSION = YES;
175 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
176 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
177 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
178 CLANG_WARN_EMPTY_BODY = YES;
179 CLANG_WARN_ENUM_CONVERSION = YES;
180 CLANG_WARN_INFINITE_RECURSION = YES;
181 CLANG_WARN_INT_CONVERSION = YES;
182 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
183 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
184 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
185 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
186 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
187 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
188 CLANG_WARN_STRICT_PROTOTYPES = YES;
189 CLANG_WARN_SUSPICIOUS_MOVE = YES;
190 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
191 CLANG_WARN_UNREACHABLE_CODE = YES;
192 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
193 COPY_PHASE_STRIP = NO;
194 DEBUG_INFORMATION_FORMAT = dwarf;
195 DEVELOPMENT_TEAM = ZCNAX3VL9D;
196 ENABLE_STRICT_OBJC_MSGSEND = YES;
197 ENABLE_TESTABILITY = YES;
198 ENABLE_USER_SCRIPT_SANDBOXING = YES;
199 GCC_C_LANGUAGE_STANDARD = gnu17;
200 GCC_DYNAMIC_NO_PIC = NO;
201 GCC_NO_COMMON_BLOCKS = YES;
202 GCC_OPTIMIZATION_LEVEL = 0;
203 GCC_PREPROCESSOR_DEFINITIONS = (
204 "DEBUG=1",
205 "$(inherited)",
206 );
207 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
208 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
209 GCC_WARN_UNDECLARED_SELECTOR = YES;
210 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
211 GCC_WARN_UNUSED_FUNCTION = YES;
212 GCC_WARN_UNUSED_VARIABLE = YES;
213 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
214 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
215 MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
216 MTL_FAST_MATH = YES;
217 ONLY_ACTIVE_ARCH = YES;
218 SDKROOT = iphoneos;
219 SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
220 SWIFT_OPTIMIZATION_LEVEL = "-Onone";
221 };
222 name = Debug;
223 };
224 8B4B28DB2F6704290045FA19 /* Release */ = {
225 isa = XCBuildConfiguration;
226 buildSettings = {
227 ALWAYS_SEARCH_USER_PATHS = NO;
228 ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
229 CLANG_ANALYZER_NONNULL = YES;
230 CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
231 CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
232 CLANG_ENABLE_MODULES = YES;
233 CLANG_ENABLE_OBJC_ARC = YES;
234 CLANG_ENABLE_OBJC_WEAK = YES;
235 CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
236 CLANG_WARN_BOOL_CONVERSION = YES;
237 CLANG_WARN_COMMA = YES;
238 CLANG_WARN_CONSTANT_CONVERSION = YES;
239 CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
240 CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
241 CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
242 CLANG_WARN_EMPTY_BODY = YES;
243 CLANG_WARN_ENUM_CONVERSION = YES;
244 CLANG_WARN_INFINITE_RECURSION = YES;
245 CLANG_WARN_INT_CONVERSION = YES;
246 CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
247 CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
248 CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
249 CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
250 CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
251 CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
252 CLANG_WARN_STRICT_PROTOTYPES = YES;
253 CLANG_WARN_SUSPICIOUS_MOVE = YES;
254 CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
255 CLANG_WARN_UNREACHABLE_CODE = YES;
256 CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
257 COPY_PHASE_STRIP = NO;
258 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
259 DEVELOPMENT_TEAM = ZCNAX3VL9D;
260 ENABLE_NS_ASSERTIONS = NO;
261 ENABLE_STRICT_OBJC_MSGSEND = YES;
262 ENABLE_USER_SCRIPT_SANDBOXING = YES;
263 GCC_C_LANGUAGE_STANDARD = gnu17;
264 GCC_NO_COMMON_BLOCKS = YES;
265 GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
266 GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
267 GCC_WARN_UNDECLARED_SELECTOR = YES;
268 GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
269 GCC_WARN_UNUSED_FUNCTION = YES;
270 GCC_WARN_UNUSED_VARIABLE = YES;
271 IPHONEOS_DEPLOYMENT_TARGET = 26.2;
272 LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
273 MTL_ENABLE_DEBUG_INFO = NO;
274 MTL_FAST_MATH = YES;
275 SDKROOT = iphoneos;
276 SWIFT_COMPILATION_MODE = wholemodule;
277 VALIDATE_PRODUCT = YES;
278 };
279 name = Release;
280 };
281 8B4B28DD2F6704290045FA19 /* Debug */ = {
282 isa = XCBuildConfiguration;
283 buildSettings = {
284 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
285 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
286 CODE_SIGN_STYLE = Automatic;
287 CURRENT_PROJECT_VERSION = 1;
288 DEVELOPMENT_TEAM = ZCNAX3VL9D;
289 ENABLE_PREVIEWS = YES;
290 GENERATE_INFOPLIST_FILE = YES;
291 INFOPLIST_FILE = Hutch/Info.plist;
292 INFOPLIST_KEY_CFBundleDisplayName = Hutch;
293 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
294 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
295 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
296 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
297 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
298 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
299 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
300 LD_RUNPATH_SEARCH_PATHS = (
301 "$(inherited)",
302 "@executable_path/Frameworks",
303 );
304 MARKETING_VERSION = 1.0;
305 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
306 PRODUCT_NAME = "$(TARGET_NAME)";
307 STRING_CATALOG_GENERATE_SYMBOLS = YES;
308 SWIFT_APPROACHABLE_CONCURRENCY = YES;
309 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
310 SWIFT_EMIT_LOC_STRINGS = YES;
311 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
312 SWIFT_VERSION = 5.0;
313 TARGETED_DEVICE_FAMILY = "1,2";
314 };
315 name = Debug;
316 };
317 8B4B28DE2F6704290045FA19 /* Release */ = {
318 isa = XCBuildConfiguration;
319 buildSettings = {
320 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
321 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
322 CODE_SIGN_STYLE = Automatic;
323 CURRENT_PROJECT_VERSION = 1;
324 DEVELOPMENT_TEAM = ZCNAX3VL9D;
325 ENABLE_PREVIEWS = YES;
326 GENERATE_INFOPLIST_FILE = YES;
327 INFOPLIST_FILE = Hutch/Info.plist;
328 INFOPLIST_KEY_CFBundleDisplayName = Hutch;
329 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
330 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
331 INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
332 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
333 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
334 INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
335 IPHONEOS_DEPLOYMENT_TARGET = 17.6;
336 LD_RUNPATH_SEARCH_PATHS = (
337 "$(inherited)",
338 "@executable_path/Frameworks",
339 );
340 MARKETING_VERSION = 1.0;
341 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
342 PRODUCT_NAME = "$(TARGET_NAME)";
343 STRING_CATALOG_GENERATE_SYMBOLS = YES;
344 SWIFT_APPROACHABLE_CONCURRENCY = YES;
345 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
346 SWIFT_EMIT_LOC_STRINGS = YES;
347 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
348 SWIFT_VERSION = 5.0;
349 TARGETED_DEVICE_FAMILY = "1,2";
350 };
351 name = Release;
352 };
353/* End XCBuildConfiguration section */
354
355/* Begin XCConfigurationList section */
356 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */ = {
357 isa = XCConfigurationList;
358 buildConfigurations = (
359 8B4B28DA2F6704290045FA19 /* Debug */,
360 8B4B28DB2F6704290045FA19 /* Release */,
361 );
362 defaultConfigurationIsVisible = 0;
363 defaultConfigurationName = Release;
364 };
365 8B4B28DC2F6704290045FA19 /* Build configuration list for PBXNativeTarget "Hutch" */ = {
366 isa = XCConfigurationList;
367 buildConfigurations = (
368 8B4B28DD2F6704290045FA19 /* Debug */,
369 8B4B28DE2F6704290045FA19 /* Release */,
370 );
371 defaultConfigurationIsVisible = 0;
372 defaultConfigurationName = Release;
373 };
374/* End XCConfigurationList section */
375 };
376 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
377}
Hutch.xcodeproj/project.xcworkspace/contents.xcworkspacedata added +7
@@ -0,0 +1,7 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<Workspace
3 version = "1.0">
4 <FileRef
5 location = "self:">
6 </FileRef>
7</Workspace>
Hutch/API_REFERENCE.md added +147
@@ -0,0 +1,147 @@
1# Sourcehut API Reference
2
3This document provides context and references for working with Sourcehut's APIs.
4
5## Official Documentation
6
7The official Sourcehut API documentation can be found at:
8- **https://man.sr.ht** - Contains links to API reference pages for all *.sr.ht services
9
10## Services and Their APIs
11
12### git.sr.ht (Git Repositories)
13- **GraphQL API**: https://git.sr.ht/graphql
14- **REST API**: https://git.sr.ht/api
15- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write)
16
17### todo.sr.ht (Ticket Tracking)
18- **GraphQL API**: https://todo.sr.ht/graphql
19- **REST API**: https://todo.sr.ht/api
20- **Scope required**: `TICKETS:RO` (read-only), `TICKETS:RW` (read-write)
21
22### hg.sr.ht (Mercurial Repositories)
23- **GraphQL API**: https://hg.sr.ht/graphql
24- **REST API**: https://hg.sr.ht/api
25- **Scope required**: `REPOSITORIES:RO` (read-only), `REPOSITORIES:RW` (read-write)
26
27### lists.sr.ht (Mailing Lists)
28- **GraphQL API**: https://lists.sr.ht/graphql
29- **REST API**: https://lists.sr.ht/api
30- **Scope required**: `LISTS:RO` (read-only), `LISTS:RW` (read-write)
31
32### builds.sr.ht (CI/CD)
33- **GraphQL API**: https://builds.sr.ht/graphql
34- **REST API**: https://builds.sr.ht/api
35- **Scope required**: `BUILDS:RO` (read-only), `BUILDS:RW` (read-write)
36
37### meta.sr.ht (User Accounts)
38- **GraphQL API**: https://meta.sr.ht/graphql
39- **REST API**: https://meta.sr.ht/api
40- **Scope required**: `ACCOUNT:RO` (read-only), `ACCOUNT:RW` (read-write)
41
42## Authentication
43
44All API requests require a **Personal Access Token** with the appropriate scopes.
45
46- **Token creation**: https://meta.sr.ht/oauth2/personal-token
47- **Token format**: `Bearer <your-token>` in the Authorization header
48
49## GraphQL Conventions
50
51### Common Types
52
53```graphql
54# Cursor-based pagination
55type Query {
56 items(cursor: Cursor, filter: Filter): ItemsPage
57}
58
59type ItemsPage {
60 results: [Item!]!
61 cursor: Cursor
62}
63
64# Filter structure (varies by service)
65input Filter {
66 search: String
67 # ... other service-specific filters
68}
69```
70
71### Error Handling
72
73Sourcehut APIs return GraphQL errors in the following format:
74
75```json
76{
77 "data": null,
78 "errors": [
79 {
80 "message": "Error message",
81 "path": ["query", "field"],
82 "extensions": {
83 "code": "ERROR_CODE"
84 }
85 }
86 ]
87}
88```
89
90## Rate Limiting
91
92- **Rate limits**: Vary by service and authentication status
93- **Headers**: Check `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
94- **Unauthenticated**: Lower limits (typically 60 requests/hour)
95- **Authenticated**: Higher limits (typically 5000 requests/hour)
96
97## Development Notes
98
99### API Exploration
100
101Use GraphiQL interfaces available at each service's `/graphql` endpoint to:
102- Explore the schema
103- Test queries
104- Understand available fields and types
105
106### Common Issues
107
1081. **Filtering**: Some services may not support all filter operations. Check the schema.
1092. **Pagination**: Always handle `cursor` properly for pagination.
1103. **Caching**: Sourcehut APIs may have aggressive caching. Use cache headers appropriately.
111
112### Example Query Structure
113
114```graphql
115query {
116 repositories(cursor: $cursor, filter: $filter) {
117 results {
118 id
119 name
120 description
121 # ... other fields
122 }
123 cursor
124 }
125}
126
127# Variables
128{
129 "filter": {
130 "search": "query string"
131 }
132}
133```
134
135## Troubleshooting
136
1371. **401 Unauthorized**: Check token scopes and expiration
1382. **403 Forbidden**: Verify you have access to the requested resource
1393. **429 Too Many Requests**: Implement proper rate limiting in your client
1404. **500 Internal Server Error**: Check if the API is temporarily down
141
142## Additional Resources
143
144- Sourcehut API Status: https://status.sr.ht
145- Sourcehut Meta (announcements): https://meta.sr.ht
146- Sourcehut GitHub Mirror: https://github.com/sourcehut
147- IRC: #sourcehut on Libera.Chat
\ No newline at end of file
Hutch/App/AppState.swift added +222
@@ -0,0 +1,222 @@
1import Foundation
2import SwiftUI
3import WebKit
4
5/// Central application state shared across the view hierarchy.
6@Observable
7@MainActor
8final class AppState {
9
10 enum AuthPhase {
11 /// App just launched, checking for an existing token.
12 case launching
13 /// No valid token — show the token entry screen.
14 case unauthenticated
15 /// Token validated, user is signed in.
16 case authenticated
17 }
18
19 // MARK: - Authentication
20
21 private(set) var authPhase: AuthPhase = .launching
22
23 /// Convenience for views that need a simple bool.
24 var isAuthenticated: Bool {
25 authPhase == .authenticated && currentUser != nil
26 }
27
28 // MARK: - Current user (populated after successful validation)
29
30 private(set) var currentUser: User?
31
32 // MARK: - Networking
33
34 let client: SRHTClient
35
36 // MARK: - Deep link pending navigation
37
38 /// Set by the deep link handler; consumed by RootView to drive navigation.
39 var pendingDeepLink: DeepLink?
40
41 // MARK: - Init
42
43 init() {
44 let token = KeychainHelper.loadToken()
45 self.client = SRHTClient(token: token)
46 }
47
48 // MARK: - Launch validation
49
50 /// Called once at app launch. If a token exists in Keychain, validates it
51 /// silently. On failure, clears the token and falls through to unauthenticated.
52 func validateOnLaunch() async {
53 guard client.hasToken else {
54 authPhase = .unauthenticated
55 return
56 }
57
58 do {
59 let user = try await fetchMe()
60 currentUser = user
61 authPhase = .authenticated
62 } catch {
63 try? KeychainHelper.deleteToken()
64 client.setToken(nil)
65 currentUser = nil
66 authPhase = .unauthenticated
67 }
68 }
69
70 // MARK: - Token management
71
72 /// Validate a new token by querying meta.sr.ht, then persist it.
73 /// Throws on network/GraphQL errors so the caller can display the message.
74 func connect(with token: String) async throws {
75 // Temporarily set the token so the client can use it for the request.
76 client.setToken(token)
77
78 do {
79 let user = try await fetchMe()
80 try KeychainHelper.saveToken(token)
81 currentUser = user
82 authPhase = .authenticated
83 } catch {
84 // Roll back — don't leave an invalid token in the client.
85 client.setToken(nil)
86 throw error
87 }
88 }
89
90 func signOut() async {
91 clearSessionState()
92 URLCache.shared.removeAllCachedResponses()
93 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
94 await clearWebData()
95 clearWebContentRenderCaches()
96 authPhase = .unauthenticated
97 }
98
99 func resetAppData() async {
100 clearSessionState()
101
102 if let bundleIdentifier = Bundle.main.bundleIdentifier {
103 UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
104 }
105 URLCache.shared.removeAllCachedResponses()
106 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
107 await clearWebData()
108 clearWebContentRenderCaches()
109
110 authPhase = .unauthenticated
111 }
112
113 // MARK: - Deep link resolution
114
115 /// Resolve a repository by owner and name for deep linking.
116 func resolveRepository(owner: String, name: String) async throws -> RepositorySummary {
117 let result = try await client.execute(
118 service: .git,
119 query: Self.repoLookupQuery,
120 variables: ["owner": owner, "name": name],
121 responseType: RepoLookupResponse.self
122 )
123 return result.user.repository
124 }
125
126 /// Resolve a tracker by owner and name for deep linking.
127 func resolveTracker(owner: String, name: String) async throws -> TrackerSummary {
128 let result = try await client.execute(
129 service: .todo,
130 query: Self.trackerLookupQuery,
131 variables: ["owner": owner, "name": name],
132 responseType: TrackerLookupResponse.self
133 )
134 return result.user.tracker
135 }
136
137 // MARK: - Private
138
139 private static let meQuery = """
140 {
141 me {
142 id
143 username
144 canonicalName
145 email
146 avatar
147 }
148 }
149 """
150
151 private struct MeResponse: Decodable {
152 let me: User
153 }
154
155 private func fetchMe() async throws -> User {
156 let result = try await client.execute(
157 service: .meta,
158 query: Self.meQuery,
159 responseType: MeResponse.self
160 )
161 return result.me
162 }
163
164 // MARK: - Deep link queries
165
166 private static let repoLookupQuery = """
167 query repoLookup($owner: String!, $name: String!) {
168 user(username: $owner) {
169 repository(name: $name) {
170 id rid name description visibility updated
171 owner { canonicalName }
172 HEAD { name target }
173 }
174 }
175 }
176 """
177
178 private struct RepoLookupResponse: Decodable, Sendable {
179 let user: RepoLookupUser
180 }
181
182 private struct RepoLookupUser: Decodable, Sendable {
183 let repository: RepositorySummary
184 }
185
186 private static let trackerLookupQuery = """
187 query trackerLookup($owner: String!, $name: String!) {
188 user(username: $owner) {
189 tracker(name: $name) {
190 id rid name description visibility updated
191 owner { canonicalName }
192 }
193 }
194 }
195 """
196
197 private struct TrackerLookupResponse: Decodable, Sendable {
198 let user: TrackerLookupUser
199 }
200
201 private struct TrackerLookupUser: Decodable, Sendable {
202 let tracker: TrackerSummary
203 }
204
205 private func clearSessionState() {
206 try? KeychainHelper.deleteAll()
207 client.setToken(nil)
208 client.responseCache.clear()
209 currentUser = nil
210 pendingDeepLink = nil
211 }
212
213 private func clearWebData() async {
214 await withCheckedContinuation { continuation in
215 let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
216 let since = Date(timeIntervalSince1970: 0)
217 WKWebsiteDataStore.default().removeData(ofTypes: dataTypes, modifiedSince: since) {
218 continuation.resume()
219 }
220 }
221 }
222}
Hutch/App/DeepLink.swift added +54
@@ -0,0 +1,54 @@
1import Foundation
2
3/// Represents a parsed `hutch://` deep link.
4enum DeepLink: Equatable {
5 /// hutch://git/<owner>/<repo>
6 case repository(owner: String, repo: String)
7 /// hutch://todo/<owner>/<tracker>/<ticketId>
8 case ticket(owner: String, tracker: String, ticketId: Int)
9 /// hutch://builds/<jobId>
10 case build(jobId: Int)
11
12 /// Attempt to parse a URL into a DeepLink.
13 /// Expected format: hutch://<path>
14 init?(url: URL) {
15 guard url.scheme == "hutch" else { return nil }
16
17 // url.host gives the first path component for opaque URLs;
18 // use standardized path components from the full string.
19 let components = url.pathComponents(fromScheme: "hutch")
20
21 switch components.first {
22 case "git" where components.count >= 3:
23 let owner = components[1]
24 let repo = components[2]
25 self = .repository(owner: owner, repo: repo)
26
27 case "todo" where components.count >= 4:
28 let owner = components[1]
29 let tracker = components[2]
30 guard let ticketId = Int(components[3]) else { return nil }
31 self = .ticket(owner: owner, tracker: tracker, ticketId: ticketId)
32
33 case "builds" where components.count >= 2:
34 guard let jobId = Int(components[1]) else { return nil }
35 self = .build(jobId: jobId)
36
37 default:
38 return nil
39 }
40 }
41}
42
43private extension URL {
44 /// Parse path components from a custom-scheme URL.
45 /// For `hutch://git/~user/repo`, returns `["git", "~user", "repo"]`.
46 func pathComponents(fromScheme scheme: String) -> [String] {
47 // Remove scheme prefix and split by "/"
48 var str = absoluteString
49 if str.hasPrefix("\(scheme)://") {
50 str = String(str.dropFirst("\(scheme)://".count))
51 }
52 return str.split(separator: "/").map(String.init)
53 }
54}
Hutch/App/HutchApp.swift added +20
@@ -0,0 +1,20 @@
1import SwiftUI
2
3@main
4struct HutchApp: App {
5 @State private var appState = AppState()
6 @State private var networkMonitor = NetworkMonitor()
7
8 var body: some Scene {
9 WindowGroup {
10 RootView()
11 .environment(appState)
12 .environment(networkMonitor)
13 .onOpenURL { url in
14 if let link = DeepLink(url: url) {
15 appState.pendingDeepLink = link
16 }
17 }
18 }
19 }
20}
Hutch/App/RootView.swift added +197
@@ -0,0 +1,197 @@
1import SwiftUI
2
3/// The root view of the app. Shows a TabView when authenticated, or a
4/// full-screen sheet for token entry on first launch.
5struct RootView: View {
6 @Environment(AppState.self) private var appState
7
8 enum Tab: Hashable {
9 case repositories
10 case builds
11 case tickets
12 case settings
13 }
14
15 @State private var selectedTab: Tab = .repositories
16 @State private var repoPath = NavigationPath()
17 @State private var buildsPath = NavigationPath()
18 @State private var ticketsPath = NavigationPath()
19 @State private var isResolvingDeepLink = false
20
21 var body: some View {
22 Group {
23 switch appState.authPhase {
24 case .launching:
25 ProgressView("Connecting…")
26 .task {
27 await appState.validateOnLaunch()
28 }
29
30 case .unauthenticated:
31 // Full-screen token entry that cannot be dismissed.
32 TokenEntryView()
33
34 case .authenticated:
35 tabContent
36 }
37 }
38 .onChange(of: appState.pendingDeepLink) { _, newValue in
39 consumePendingDeepLinkIfPossible(newValue)
40 }
41 .onChange(of: appState.authPhase) { _, newPhase in
42 handleAuthPhaseChange(newPhase)
43 }
44 }
45
46 // MARK: - Tab View
47
48 private var tabContent: some View {
49 TabView(selection: $selectedTab) {
50 NavigationStack(path: $repoPath) {
51 RepositoryListView()
52 }
53 .tag(Tab.repositories)
54 .tabItem {
55 Label("Repositories", systemImage: "book.closed")
56 }
57
58 NavigationStack(path: $buildsPath) {
59 BuildListView()
60 // Int destination used by deep links (hutch://builds/<id>).
61 // JobSummary destination is registered inside BuildListView.
62 .navigationDestination(for: Int.self) { jobId in
63 BuildDetailView(jobId: jobId)
64 }
65 }
66 .tag(Tab.builds)
67 .tabItem {
68 Label("Builds", systemImage: "hammer")
69 }
70
71 NavigationStack(path: $ticketsPath) {
72 TrackerListView()
73 // Deep link destination for jumping straight to a ticket.
74 .navigationDestination(for: TicketDeepLinkTarget.self) { target in
75 TicketDetailView(ownerUsername: target.ownerUsername, trackerName: target.trackerName, trackerId: target.trackerId, trackerRid: target.trackerRid, ticketId: target.ticketId)
76 }
77 }
78 .tag(Tab.tickets)
79 .tabItem {
80 Label("Tickets", systemImage: "ticket")
81 }
82
83 SettingsView()
84 .tag(Tab.settings)
85 .tabItem {
86 Label("Settings", systemImage: "gear")
87 }
88 }
89 .overlay {
90 if isResolvingDeepLink {
91 ZStack {
92 Color.black.opacity(0.3)
93 .ignoresSafeArea()
94 ProgressView("Opening link…")
95 .padding()
96 .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
97 }
98 }
99 }
100 }
101
102 // MARK: - Deep Link Handling
103
104 private func handleAuthPhaseChange(_ newPhase: AppState.AuthPhase) {
105 switch newPhase {
106 case .launching:
107 break
108 case .unauthenticated:
109 repoPath = NavigationPath()
110 buildsPath = NavigationPath()
111 ticketsPath = NavigationPath()
112 selectedTab = .repositories
113 isResolvingDeepLink = false
114 case .authenticated:
115 consumePendingDeepLinkIfPossible(appState.pendingDeepLink)
116 }
117 }
118
119 private func consumePendingDeepLinkIfPossible(_ link: DeepLink?) {
120 guard appState.isAuthenticated, let link else { return }
121 handleDeepLink(link)
122 appState.pendingDeepLink = nil
123 }
124
125 private func handleDeepLink(_ link: DeepLink) {
126 guard appState.isAuthenticated else { return }
127
128 switch link {
129 case .repository(let owner, let repo):
130 resolveRepositoryLink(owner: owner, repo: repo)
131
132 case .build(let jobId):
133 // Reset the builds navigation and push the detail
134 buildsPath = NavigationPath()
135 selectedTab = .builds
136 // Defer the push slightly so the tab switch takes effect
137 Task { @MainActor in
138 try? await Task.sleep(for: .milliseconds(100))
139 buildsPath.append(jobId)
140 }
141
142 case .ticket(let owner, let tracker, let ticketId):
143 resolveTicketLink(owner: owner, tracker: tracker, ticketId: ticketId)
144 }
145 }
146
147 private func resolveRepositoryLink(owner: String, repo: String) {
148 isResolvingDeepLink = true
149 Task {
150 defer { isResolvingDeepLink = false }
151 do {
152 let summary = try await appState.resolveRepository(owner: owner, name: repo)
153 repoPath = NavigationPath()
154 selectedTab = .repositories
155 try? await Task.sleep(for: .milliseconds(100))
156 repoPath.append(summary)
157 } catch {
158 // Silently fail — the repo may not exist or be inaccessible
159 }
160 }
161 }
162
163 private func resolveTicketLink(owner: String, tracker: String, ticketId: Int) {
164 isResolvingDeepLink = true
165 Task {
166 defer { isResolvingDeepLink = false }
167 do {
168 let trackerSummary = try await appState.resolveTracker(owner: owner, name: tracker)
169 ticketsPath = NavigationPath()
170 selectedTab = .tickets
171 try? await Task.sleep(for: .milliseconds(100))
172 ticketsPath.append(trackerSummary)
173 try? await Task.sleep(for: .milliseconds(100))
174 ticketsPath.append(TicketDeepLinkTarget(
175 ownerUsername: String(trackerSummary.owner.canonicalName.dropFirst()),
176 trackerName: trackerSummary.name,
177 trackerId: trackerSummary.id,
178 trackerRid: trackerSummary.rid,
179 ticketId: ticketId
180 ))
181 } catch {
182 // Silently fail
183 }
184 }
185 }
186}
187
188// MARK: - Ticket Deep Link Navigation Target
189
190/// Hashable wrapper to push a ticket detail view from a deep link.
191struct TicketDeepLinkTarget: Hashable {
192 let ownerUsername: String
193 let trackerName: String
194 let trackerId: Int
195 let trackerRid: String
196 let ticketId: Int
197}
Hutch/Assets.xcassets/AccentColor.colorset/Contents.json added +11
@@ -0,0 +1,11 @@
1{
2 "colors" : [
3 {
4 "idiom" : "universal"
5 }
6 ],
7 "info" : {
8 "author" : "xcode",
9 "version" : 1
10 }
11}
Hutch/Assets.xcassets/AppIcon.appiconset/1.png added

Binary file not shown.

Hutch/Assets.xcassets/AppIcon.appiconset/2.png added

Binary file not shown.

Hutch/Assets.xcassets/AppIcon.appiconset/Contents.json added +37
@@ -0,0 +1,37 @@
1{
2 "images" : [
3 {
4 "filename" : "1.png",
5 "idiom" : "universal",
6 "platform" : "ios",
7 "size" : "1024x1024"
8 },
9 {
10 "appearances" : [
11 {
12 "appearance" : "luminosity",
13 "value" : "dark"
14 }
15 ],
16 "filename" : "2.png",
17 "idiom" : "universal",
18 "platform" : "ios",
19 "size" : "1024x1024"
20 },
21 {
22 "appearances" : [
23 {
24 "appearance" : "luminosity",
25 "value" : "tinted"
26 }
27 ],
28 "idiom" : "universal",
29 "platform" : "ios",
30 "size" : "1024x1024"
31 }
32 ],
33 "info" : {
34 "author" : "xcode",
35 "version" : 1
36 }
37}
Hutch/Assets.xcassets/Contents.json added +6
@@ -0,0 +1,6 @@
1{
2 "info" : {
3 "author" : "xcode",
4 "version" : 1
5 }
6}
Hutch/Extensions/Date+Relative.swift added +10
@@ -0,0 +1,10 @@
1import Foundation
2
3extension Date {
4 /// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025".
5 var relativeDescription: String {
6 let formatter = RelativeDateTimeFormatter()
7 formatter.unitsStyle = .abbreviated
8 return formatter.localizedString(for: self, relativeTo: .now)
9 }
10}
Hutch/Extensions/DateFormatter+SRHT.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2
3extension DateFormatter {
4 /// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC).
5 static let srht: DateFormatter = {
6 let formatter = DateFormatter()
7 formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
8 formatter.locale = Locale(identifier: "en_US_POSIX")
9 formatter.timeZone = TimeZone(identifier: "UTC")
10 return formatter
11 }()
12
13 /// Fallback formatter that also accepts fractional seconds.
14 static let srhtFractional: DateFormatter = {
15 let formatter = DateFormatter()
16 formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
17 formatter.locale = Locale(identifier: "en_US_POSIX")
18 formatter.timeZone = TimeZone(identifier: "UTC")
19 return formatter
20 }()
21}
22
23extension JSONDecoder.DateDecodingStrategy {
24 /// Tries the primary sr.ht format first, then falls back to fractional seconds.
25 static let srhtFlexible: JSONDecoder.DateDecodingStrategy = .custom { decoder in
26 let container = try decoder.singleValueContainer()
27 let string = try container.decode(String.self)
28 if let date = DateFormatter.srht.date(from: string) {
29 return date
30 }
31 if let date = DateFormatter.srhtFractional.date(from: string) {
32 return date
33 }
34 throw DecodingError.dataCorruptedError(
35 in: container,
36 debugDescription: "Cannot decode date string: \(string)"
37 )
38 }
39}
Hutch/Extensions/ErrorViews.swift added +159
@@ -0,0 +1,159 @@
1import SwiftUI
2
3// MARK: - SRHTErrorBanner ViewModifier
4
5/// Displays a dismissible error banner at the top of the screen.
6/// Attach to any view with `.srhtErrorBanner(error:)`.
7struct SRHTErrorBanner: ViewModifier {
8 @Binding var error: String?
9
10 func body(content: Content) -> some View {
11 content
12 .overlay(alignment: .top) {
13 if let message = error {
14 banner(message)
15 .transition(.move(edge: .top).combined(with: .opacity))
16 }
17 }
18 .animation(.easeInOut(duration: 0.3), value: error)
19 }
20
21 @ViewBuilder
22 private func banner(_ message: String) -> some View {
23 HStack(spacing: 8) {
24 Image(systemName: "exclamationmark.triangle.fill")
25 .foregroundStyle(.white)
26
27 Text(message)
28 .font(.subheadline)
29 .foregroundStyle(.white)
30 .lineLimit(3)
31
32 Spacer()
33
34 Button {
35 error = nil
36 } label: {
37 Image(systemName: "xmark.circle.fill")
38 .foregroundStyle(.white.opacity(0.8))
39 }
40 }
41 .padding(12)
42 .background(Color.red.gradient, in: RoundedRectangle(cornerRadius: 12))
43 .padding(.horizontal, 12)
44 .padding(.top, 4)
45 }
46}
47
48extension View {
49 /// Attach an error banner that shows at the top when `error` is non-nil.
50 func srhtErrorBanner(error: Binding<String?>) -> some View {
51 modifier(SRHTErrorBanner(error: error))
52 }
53}
54
55// MARK: - Shared Screen States
56
57struct SRHTLoadingStateView: View {
58 let message: String
59
60 var body: some View {
61 VStack(spacing: 12) {
62 ProgressView()
63 Text(message)
64 .font(.subheadline)
65 .foregroundStyle(.secondary)
66 }
67 .frame(maxWidth: .infinity, maxHeight: .infinity)
68 }
69}
70
71struct SRHTErrorStateView: View {
72 let title: String
73 let message: String
74 let retryAction: (() async -> Void)?
75
76 var body: some View {
77 ContentUnavailableView {
78 SwiftUI.Label(title, systemImage: "exclamationmark.triangle")
79 } description: {
80 Text(message)
81 } actions: {
82 if let retryAction {
83 Button("Retry") {
84 Task { await retryAction() }
85 }
86 .buttonStyle(.borderedProminent)
87 }
88 }
89 }
90}
91
92// MARK: - NoConnectionView
93
94/// Empty state view shown when the device is offline. Includes a retry button.
95struct NoConnectionView: View {
96 var retryAction: () async -> Void
97
98 var body: some View {
99 ContentUnavailableView {
100 SwiftUI.Label("No Connection", systemImage: "wifi.slash")
101 } description: {
102 Text("Check your internet connection and try again.")
103 } actions: {
104 Button {
105 Task { await retryAction() }
106 } label: {
107 Text("Retry")
108 }
109 .buttonStyle(.borderedProminent)
110 }
111 }
112}
113
114// MARK: - Connectivity Overlay
115
116/// ViewModifier that shows NoConnectionView when the device is offline and
117/// there is no content to display. When content exists, shows a subtle
118/// offline indicator instead.
119struct ConnectivityOverlay: ViewModifier {
120 @Environment(NetworkMonitor.self) private var networkMonitor
121 let hasContent: Bool
122 var retryAction: () async -> Void
123
124 func body(content: Content) -> some View {
125 content
126 .overlay {
127 if !networkMonitor.isConnected, !hasContent {
128 NoConnectionView(retryAction: retryAction)
129 }
130 }
131 .safeAreaInset(edge: .bottom) {
132 if !networkMonitor.isConnected, hasContent {
133 offlineBadge
134 }
135 }
136 }
137
138 private var offlineBadge: some View {
139 HStack(spacing: 6) {
140 Image(systemName: "wifi.slash")
141 .font(.caption2)
142 Text("Offline — showing cached data")
143 .font(.caption2)
144 }
145 .foregroundStyle(.white)
146 .padding(.horizontal, 12)
147 .padding(.vertical, 6)
148 .background(.orange.gradient, in: Capsule())
149 .padding(.bottom, 4)
150 }
151}
152
153extension View {
154 /// Overlay a no-connection view when offline with no content, or a
155 /// subtle offline badge when showing cached data.
156 func connectivityOverlay(hasContent: Bool, retryAction: @escaping () async -> Void) -> some View {
157 modifier(ConnectivityOverlay(hasContent: hasContent, retryAction: retryAction))
158 }
159}
Hutch/Extensions/Int+ByteFormatting.swift added +8
@@ -0,0 +1,8 @@
1import Foundation
2
3extension Int {
4 /// Human-readable byte size string (e.g. "1.2 MB", "340 KB").
5 var formattedByteCount: String {
6 ByteCountFormatter.string(fromByteCount: Int64(self), countStyle: .file)
7 }
8}
Hutch/Extensions/KeychainHelper.swift added +103
@@ -0,0 +1,103 @@
1import Foundation
2@preconcurrency import Security
3
4enum KeychainHelper: Sendable {
5
6 private static let service = "net.cleberg.Hutch"
7 private static let tokenAccount = "srht-access-token"
8
9 // MARK: - Save
10
11 static func saveToken(_ token: String) throws {
12 guard let data = token.data(using: .utf8) else {
13 throw KeychainError.encodingFailed
14 }
15
16 // Delete any existing item first
17 let deleteQuery: [String: Any] = [
18 kSecClass as String: kSecClassGenericPassword,
19 kSecAttrService as String: service,
20 kSecAttrAccount as String: tokenAccount
21 ]
22 SecItemDelete(deleteQuery as CFDictionary)
23
24 let addQuery: [String: Any] = [
25 kSecClass as String: kSecClassGenericPassword,
26 kSecAttrService as String: service,
27 kSecAttrAccount as String: tokenAccount,
28 kSecValueData as String: data,
29 kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
30 ]
31
32 let status = SecItemAdd(addQuery as CFDictionary, nil)
33 guard status == errSecSuccess else {
34 throw KeychainError.saveFailed(status)
35 }
36 }
37
38 // MARK: - Load
39
40 static func loadToken() -> String? {
41 let query: [String: Any] = [
42 kSecClass as String: kSecClassGenericPassword,
43 kSecAttrService as String: service,
44 kSecAttrAccount as String: tokenAccount,
45 kSecReturnData as String: true,
46 kSecMatchLimit as String: kSecMatchLimitOne
47 ]
48
49 var result: AnyObject?
50 let status = SecItemCopyMatching(query as CFDictionary, &result)
51
52 guard status == errSecSuccess,
53 let data = result as? Data,
54 let token = String(data: data, encoding: .utf8) else {
55 return nil
56 }
57 return token
58 }
59
60 // MARK: - Delete
61
62 static func deleteToken() throws {
63 let query: [String: Any] = [
64 kSecClass as String: kSecClassGenericPassword,
65 kSecAttrService as String: service,
66 kSecAttrAccount as String: tokenAccount
67 ]
68
69 let status = SecItemDelete(query as CFDictionary)
70 guard status == errSecSuccess || status == errSecItemNotFound else {
71 throw KeychainError.deleteFailed(status)
72 }
73 }
74
75 static func deleteAll() throws {
76 let query: [String: Any] = [
77 kSecClass as String: kSecClassGenericPassword,
78 kSecAttrService as String: service
79 ]
80
81 let status = SecItemDelete(query as CFDictionary)
82 guard status == errSecSuccess || status == errSecItemNotFound else {
83 throw KeychainError.deleteFailed(status)
84 }
85 }
86}
87
88enum KeychainError: LocalizedError {
89 case encodingFailed
90 case saveFailed(OSStatus)
91 case deleteFailed(OSStatus)
92
93 var errorDescription: String? {
94 switch self {
95 case .encodingFailed:
96 "Failed to encode token data."
97 case .saveFailed(let status):
98 "Keychain save failed with status \(status)."
99 case .deleteFailed(let status):
100 "Keychain delete failed with status \(status)."
101 }
102 }
103}
Hutch/Extensions/SRHTWebURL.swift +49 −1
@@ -9,6 +9,41 @@ enum SRHTWebURL {
99 )
1010 }
1111
12 static func commit(repository: RepositorySummary, commitId: String) -> URL? {
13 userScopedURL(
14 host: "\(repository.service.rawValue).sr.ht",
15 ownerCanonicalName: repository.owner.canonicalName,
16 pathComponents: [repository.name, commitPathComponent(for: repository.service), commitId]
17 )
18 }
19
20 static func file(repository: RepositorySummary, revspec: String, path: String) -> URL? {
21 switch repository.service {
22 case .git:
23 return userScopedURL(
24 host: "git.sr.ht",
25 ownerCanonicalName: repository.owner.canonicalName,
26 pathComponents: [repository.name, "tree", revspec, "item"] + pathComponents(from: path)
27 )
28 case .hg:
29 var components = URLComponents()
30 components.scheme = "https"
31 components.host = "hg.sr.ht"
32
33 let ownerUsername = username(from: repository.owner.canonicalName)
34 let encodedRepository = repository.name.addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? repository.name
35 let encodedPath = path.split(separator: "/").map {
36 String($0).addingPercentEncoding(withAllowedCharacters: pathComponentCharacterSet) ?? String($0)
37 }.joined(separator: "/")
38
39 components.percentEncodedPath = "/~\(ownerUsername)/\(encodedRepository)/browse/\(encodedPath)"
40 components.queryItems = [URLQueryItem(name: "rev", value: revspec)]
41 return components.url
42 default:
43 return nil
44 }
45 }
46
1247 static func build(jobId: Int, ownerCanonicalName: String) -> URL? {
1348 userScopedURL(
1449 host: "builds.sr.ht",
@@ -35,7 +70,7 @@ enum SRHTWebURL {
3570
3671 static func profile(canonicalName: String) -> URL? {
3772 userScopedURL(
38 host: "meta.sr.ht",
73 host: "sr.ht",
3974 ownerCanonicalName: canonicalName,
4075 pathComponents: []
4176 )
@@ -76,9 +111,22 @@ enum SRHTWebURL {
76111 return canonicalName
77112 }
78113
114 private static func commitPathComponent(for service: SRHTService) -> String {
115 switch service {
116 case .hg:
117 return "rev"
118 default:
119 return "commit"
120 }
121 }
122
79123 private static let pathComponentCharacterSet: CharacterSet = {
80124 var characterSet = CharacterSet.urlPathAllowed
81125 characterSet.remove(charactersIn: "/")
82126 return characterSet
83127 }()
128
129 private static func pathComponents(from path: String) -> [String] {
130 path.split(separator: "/").map(String.init)
131 }
84132}
Hutch/Info.plist added +23
@@ -0,0 +1,23 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>CFBundleDisplayName</key>
6 <string>Hutch</string>
7 <key>CFBundleName</key>
8 <string>Hutch</string>
9 <key>CFBundleURLTypes</key>
10 <array>
11 <dict>
12 <key>CFBundleTypeRole</key>
13 <string>Editor</string>
14 <key>CFBundleURLName</key>
15 <string>.cleberg.Hutch</string>
16 <key>CFBundleURLSchemes</key>
17 <array>
18 <string>hutch</string>
19 </array>
20 </dict>
21 </array>
22</dict>
23</plist>
Hutch/Models/ArtifactInfo.swift added +22
@@ -0,0 +1,22 @@
1import Foundation
2
3/// An artifact with its parent reference name, used in the artifacts tab.
4struct ArtifactInfo: Codable, Sendable, Identifiable {
5 let id: Int
6 let filename: String
7 let checksum: String
8 let size: Int
9 let url: URL
10}
11
12struct ArtifactPage: Codable, Sendable {
13 let results: [ArtifactInfo]
14 let cursor: String?
15}
16
17/// A reference (tag) that has associated artifacts.
18struct ReferenceWithArtifacts: Codable, Sendable, Identifiable {
19 var id: String { name }
20 let name: String
21 let artifacts: [ArtifactInfo]
22}
Hutch/Models/Builds.swift added +111
@@ -0,0 +1,111 @@
1import Foundation
2
3// MARK: - Enums
4
5/// Status of a build job.
6enum JobStatus: String, Codable, Sendable {
7 case pending = "PENDING"
8 case queued = "QUEUED"
9 case running = "RUNNING"
10 case success = "SUCCESS"
11 case failed = "FAILED"
12 case cancelled = "CANCELLED"
13 case timeout = "TIMEOUT"
14
15 /// Whether the job can be cancelled.
16 var isCancellable: Bool {
17 switch self {
18 case .pending, .queued, .running: true
19 default: false
20 }
21 }
22}
23
24/// Status of a single build task within a job.
25enum TaskStatus: String, Codable, Sendable {
26 case pending = "PENDING"
27 case running = "RUNNING"
28 case success = "SUCCESS"
29 case failed = "FAILED"
30 case skipped = "SKIPPED"
31}
32
33// MARK: - Build Task
34
35/// A single task within a build job.
36struct BuildTask: Codable, Sendable, Identifiable {
37 var id: String { name }
38 let name: String
39 let status: TaskStatus
40 let log: BuildLog?
41}
42
43// MARK: - Job Summary (for list view)
44
45/// Lightweight job model matching the fields returned by the jobs list query.
46struct JobSummary: Codable, Sendable, Identifiable, Hashable {
47 let id: Int
48 let created: Date
49 let updated: Date
50 let status: JobStatus
51 let note: String?
52 let tags: [String]
53 let visibility: Visibility?
54 let image: String?
55 let tasks: [JobTaskSummary]
56
57 /// Number of completed (success) tasks.
58 var completedTaskCount: Int {
59 tasks.filter { $0.status == .success }.count
60 }
61
62 /// Display label: note if available, otherwise tags joined.
63 var displayLabel: String {
64 if let note, !note.isEmpty {
65 return note
66 }
67 if !tags.isEmpty {
68 return tags.joined(separator: ", ")
69 }
70 return "Job #\(id)"
71 }
72}
73
74/// Minimal task info for the list query.
75struct JobTaskSummary: Codable, Sendable, Hashable {
76 let name: String
77 let status: TaskStatus
78}
79
80// MARK: - Job Detail (for detail view)
81
82/// Full job model with all fields for the detail view.
83struct JobDetail: Codable, Sendable {
84 let id: Int
85 let created: Date
86 let updated: Date
87 let status: JobStatus
88 let note: String?
89 let tags: [String]
90 let visibility: Visibility?
91 let image: String?
92 let manifest: String?
93 let tasks: [BuildTask]
94 let log: BuildLog?
95 let owner: Entity
96}
97
98/// The log associated with a build job.
99struct BuildLog: Codable, Sendable {
100 let fullURL: String
101}
102
103// MARK: - Job Group
104
105/// A group of related build jobs.
106struct JobGroup: Codable, Sendable, Identifiable {
107 let id: Int
108 let created: Date
109 let note: String?
110 let jobs: [JobSummary]
111}
Hutch/Models/CommitDetail.swift added +65
@@ -0,0 +1,65 @@
1import Foundation
2
3/// Full commit detail returned by the revparse_single query.
4struct CommitDetail: Codable, Sendable, Identifiable {
5 let id: String
6 let shortId: String
7 let author: CommitAuthor
8 let committer: CommitAuthor
9 let message: String
10 let diff: String?
11 let trailers: [CommitTrailer]
12 let parents: [ParentCommit]
13 let tree: CommitTree?
14
15 /// First line of the commit message.
16 var title: String {
17 message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
18 }
19
20 /// The commit message body (everything after the first line), if any.
21 var body: String? {
22 guard let newlineIndex = message.firstIndex(of: "\n") else { return nil }
23 let body = message[message.index(after: newlineIndex)...]
24 .trimmingCharacters(in: .whitespacesAndNewlines)
25 return body.isEmpty ? nil : body
26 }
27}
28
29struct CommitTrailer: Codable, Sendable, Identifiable {
30 var id: String { "\(name):\(value)" }
31 let name: String
32 let value: String
33}
34
35struct ParentCommit: Codable, Sendable, Identifiable, Hashable {
36 let id: String
37 let shortId: String
38 let author: ParentAuthor
39}
40
41struct ParentAuthor: Codable, Sendable, Hashable {
42 let name: String
43}
44
45struct CommitTree: Codable, Sendable {
46 let entries: CommitTreeEntries
47}
48
49struct CommitTreeEntries: Codable, Sendable {
50 let results: [CommitTreeEntry]
51 let cursor: String?
52}
53
54struct CommitTreeEntry: Codable, Sendable, Identifiable {
55 let id: String
56 let name: String
57 let mode: Int
58 let object: CommitTreeObject?
59}
60
61struct CommitTreeObject: Codable, Sendable {
62 let type: String?
63 let id: String?
64 let shortId: String?
65}
Hutch/Models/CommitSummary.swift added +22
@@ -0,0 +1,22 @@
1import Foundation
2
3/// Lightweight commit model for list views. Matches the subset of fields
4/// returned by the repository log query.
5struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
6 let id: String
7 let shortId: String
8 let author: CommitAuthor
9 let message: String
10
11 /// First line of the commit message.
12 var title: String {
13 message.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespaces)
14 }
15}
16
17/// A compact author representation used in commit list responses.
18struct CommitAuthor: Codable, Sendable, Hashable {
19 let name: String
20 let email: String?
21 let time: Date
22}
Hutch/Models/Git.swift added +241
@@ -0,0 +1,241 @@
1import Foundation
2
3// MARK: - Enums
4
5/// Repository visibility level.
6enum Visibility: String, Codable, Sendable {
7 case `public` = "PUBLIC"
8 case unlisted = "UNLISTED"
9 case `private` = "PRIVATE"
10}
11
12/// Repository access mode.
13enum AccessMode: String, Codable, Sendable {
14 case ro = "RO"
15 case rw = "RW"
16}
17
18// MARK: - Entity
19
20/// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a
21/// resource (typically a user).
22struct Entity: Codable, Sendable, Hashable {
23 let canonicalName: String
24}
25
26// MARK: - Repository
27
28/// A git repository from git.sr.ht.
29struct Repository: Codable, Sendable, Identifiable {
30 let id: Int
31 let created: Date
32 let updated: Date
33 let name: String
34 let description: String?
35 let visibility: Visibility
36 let readme: String?
37 let accessMode: AccessMode
38 let owner: Entity
39
40 enum CodingKeys: String, CodingKey {
41 case id, created, updated, name, description, visibility, readme
42 case accessMode = "access"
43 case owner
44 }
45}
46
47// MARK: - Signature
48
49/// A Git commit/tag signature (author or committer).
50struct Signature: Codable, Sendable {
51 let name: String
52 let email: String
53 let time: Date
54}
55
56// MARK: - Trailer
57
58/// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by").
59struct Trailer: Codable, Sendable {
60 let name: String
61 let value: String
62}
63
64// MARK: - Commit
65
66/// A Git commit from git.sr.ht.
67struct Commit: Codable, Sendable, Identifiable {
68 let id: String
69 let shortId: String
70 let author: Signature
71 let committer: Signature
72 let message: String
73 let diff: String?
74 let trailers: [Trailer]
75}
76
77// MARK: - Reference
78
79/// A Git reference (branch or tag name).
80struct Reference: Codable, Sendable, Hashable {
81 let name: String
82 let target: String?
83}
84
85// MARK: - TreeEntry
86
87/// An entry in a Git tree (file or directory).
88struct TreeEntry: Codable, Sendable, Identifiable {
89 let id: String
90 let name: String
91 let mode: Int?
92 let object: GitObject?
93}
94
95/// A Git object returned by the git.sr.ht GraphQL API.
96/// The API returns `type` as "TREE", "BLOB", "COMMIT", or "TAG".
97/// Both TextBlob and BinaryBlob have type == "BLOB"; they are
98/// distinguished by the presence of the "text" key (TextBlob) vs
99/// the "content" key (BinaryBlob).
100enum GitObject: Sendable {
101 case tree(GitTree)
102 case textBlob(GitTextBlob)
103 case binaryBlob(GitBinaryBlob)
104 case unknown
105}
106
107struct GitTree: Codable, Sendable {
108 let id: String?
109 let shortId: String?
110 let entries: GitTreeEntryPage?
111}
112
113struct GitTextBlob: Codable, Sendable {
114 let id: String?
115 let shortId: String?
116 let text: String
117 let size: Int?
118}
119
120struct GitBinaryBlob: Codable, Sendable {
121 let id: String?
122 let shortId: String?
123 let size: Int?
124 let content: String?
125}
126
127struct GitTreeEntryPage: Codable, Sendable {
128 let results: [TreeEntry]
129 let cursor: String?
130}
131
132// MARK: - GitObject Codable
133
134extension GitObject: Codable {
135 private enum CodingKeys: String, CodingKey {
136 case type, id, shortId, entries, text, size, content
137 }
138
139 init(from decoder: any Decoder) throws {
140 let container = try decoder.container(keyedBy: CodingKeys.self)
141 let type = try container.decodeIfPresent(String.self, forKey: .type)
142
143 switch type {
144 case "TREE":
145 let tree = GitTree(
146 id: try container.decodeIfPresent(String.self, forKey: .id),
147 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
148 entries: try container.decodeIfPresent(GitTreeEntryPage.self, forKey: .entries)
149 )
150 self = .tree(tree)
151
152 case "BLOB":
153 // TextBlob has a "text" key; BinaryBlob does not
154 if container.contains(.text) {
155 let blob = GitTextBlob(
156 id: try container.decodeIfPresent(String.self, forKey: .id),
157 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
158 text: try container.decode(String.self, forKey: .text),
159 size: try container.decodeIfPresent(Int.self, forKey: .size)
160 )
161 self = .textBlob(blob)
162 } else {
163 let blob = GitBinaryBlob(
164 id: try container.decodeIfPresent(String.self, forKey: .id),
165 shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
166 size: try container.decodeIfPresent(Int.self, forKey: .size),
167 content: try container.decodeIfPresent(String.self, forKey: .content)
168 )
169 self = .binaryBlob(blob)
170 }
171
172 default:
173 self = .unknown
174 }
175 }
176
177 func encode(to encoder: any Encoder) throws {
178 var container = encoder.container(keyedBy: CodingKeys.self)
179 switch self {
180 case .tree(let tree):
181 try container.encode("TREE", forKey: .type)
182 try container.encodeIfPresent(tree.id, forKey: .id)
183 try container.encodeIfPresent(tree.shortId, forKey: .shortId)
184 try container.encodeIfPresent(tree.entries, forKey: .entries)
185 case .textBlob(let blob):
186 try container.encode("BLOB", forKey: .type)
187 try container.encodeIfPresent(blob.id, forKey: .id)
188 try container.encodeIfPresent(blob.shortId, forKey: .shortId)
189 try container.encode(blob.text, forKey: .text)
190 try container.encodeIfPresent(blob.size, forKey: .size)
191 case .binaryBlob(let blob):
192 try container.encode("BLOB", forKey: .type)
193 try container.encodeIfPresent(blob.id, forKey: .id)
194 try container.encodeIfPresent(blob.shortId, forKey: .shortId)
195 try container.encodeIfPresent(blob.size, forKey: .size)
196 try container.encodeIfPresent(blob.content, forKey: .content)
197 case .unknown:
198 break
199 }
200 }
201}
202
203/// Convenience helpers for checking object type.
204extension GitObject {
205 var isTree: Bool {
206 if case .tree = self { return true }
207 return false
208 }
209
210 var treeId: String? {
211 switch self {
212 case .tree(let t): t.id
213 case .textBlob(let b): b.id
214 case .binaryBlob(let b): b.id
215 case .unknown: nil
216 }
217 }
218}
219
220// MARK: - Tag
221
222/// An annotated Git tag from git.sr.ht.
223struct Tag: Codable, Sendable, Identifiable {
224 let id: String
225 let shortId: String
226 let name: String
227 let message: String?
228 let tagger: Signature?
229}
230
231// MARK: - Artifact
232
233/// A release artifact attached to a Git tag.
234struct Artifact: Codable, Sendable, Identifiable {
235 let id: Int
236 let created: Date
237 let filename: String
238 let checksum: String
239 let size: Int
240 let url: URL
241}
Hutch/Models/Meta.swift added +65
@@ -0,0 +1,65 @@
1import Foundation
2
3// MARK: - User Profile (full)
4
5/// Extended user profile from meta.sr.ht with all fields from the `me` query.
6struct UserProfile: Codable, Sendable {
7 let username: String
8 let canonicalName: String
9 let email: String
10 let url: String?
11 let location: String?
12 let bio: String?
13 let avatar: String?
14 let userType: String?
15 let sshKeys: SSHKeyPage
16 let pgpKeys: PGPKeyPage
17 let paymentStatus: String?
18 let subscription: Subscription?
19}
20
21// MARK: - SSH Key
22
23struct SSHKey: Codable, Sendable, Identifiable {
24 let id: Int
25 let fingerprint: String
26 let comment: String?
27 let created: Date
28 let lastUsed: Date?
29}
30
31struct SSHKeyPage: Codable, Sendable {
32 let results: [SSHKey]
33 let cursor: String?
34}
35
36// MARK: - PGP Key
37
38struct PGPKey: Codable, Sendable, Identifiable {
39 let id: Int
40 let fingerprint: String
41 let created: Date
42}
43
44struct PGPKeyPage: Codable, Sendable {
45 let results: [PGPKey]
46 let cursor: String?
47}
48
49// MARK: - Subscription
50
51struct Subscription: Codable, Sendable {
52 let status: String?
53 let autorenew: Bool?
54 let interval: String?
55}
56
57// MARK: - Personal Access Token
58
59struct PersonalAccessToken: Codable, Sendable, Identifiable {
60 let id: Int
61 let issued: Date
62 let expires: Date?
63 let comment: String?
64 let grants: String?
65}
Hutch/Models/RepositorySummary.swift added +57
@@ -0,0 +1,57 @@
1import Foundation
2
3/// Lightweight repository model matching the fields returned by the
4/// repositories list query. Avoids optionalizing all fields on the full
5/// `Repository` model.
6struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
7 let id: Int
8 /// GraphQL resource identifier used by `repository(rid:)` queries.
9 let rid: String
10 let service: SRHTService
11 let name: String
12 let description: String?
13 let visibility: Visibility
14 let updated: Date
15 let owner: Entity
16 let head: Reference?
17
18 enum CodingKeys: String, CodingKey {
19 case id, rid, service, name, description, visibility, updated, owner
20 case head = "HEAD"
21 }
22
23 init(
24 id: Int,
25 rid: String,
26 service: SRHTService,
27 name: String,
28 description: String?,
29 visibility: Visibility,
30 updated: Date,
31 owner: Entity,
32 head: Reference?
33 ) {
34 self.id = id
35 self.rid = rid
36 self.service = service
37 self.name = name
38 self.description = description
39 self.visibility = visibility
40 self.updated = updated
41 self.owner = owner
42 self.head = head
43 }
44
45 init(from decoder: any Decoder) throws {
46 let container = try decoder.container(keyedBy: CodingKeys.self)
47 self.id = try container.decode(Int.self, forKey: .id)
48 self.rid = try container.decode(String.self, forKey: .rid)
49 self.service = try container.decodeIfPresent(SRHTService.self, forKey: .service) ?? .git
50 self.name = try container.decode(String.self, forKey: .name)
51 self.description = try container.decodeIfPresent(String.self, forKey: .description)
52 self.visibility = try container.decode(Visibility.self, forKey: .visibility)
53 self.updated = try container.decode(Date.self, forKey: .updated)
54 self.owner = try container.decode(Entity.self, forKey: .owner)
55 self.head = try container.decodeIfPresent(Reference.self, forKey: .head)
56 }
57}
Hutch/Models/Todo.swift added +234
@@ -0,0 +1,234 @@
1import Foundation
2
3// MARK: - Enums
4
5/// Status of a ticket.
6enum TicketStatus: String, Codable, Sendable, CaseIterable {
7 case reported = "REPORTED"
8 case confirmed = "CONFIRMED"
9 case inProgress = "IN_PROGRESS"
10 case pending = "PENDING"
11 case resolved = "RESOLVED"
12
13 /// Whether this status represents an open (unresolved) ticket.
14 var isOpen: Bool {
15 switch self {
16 case .reported, .confirmed, .inProgress, .pending: true
17 case .resolved: false
18 }
19 }
20
21 var displayName: String {
22 switch self {
23 case .reported: "Reported"
24 case .confirmed: "Confirmed"
25 case .inProgress: "In Progress"
26 case .pending: "Pending"
27 case .resolved: "Resolved"
28 }
29 }
30}
31
32/// Resolution of a ticket.
33enum TicketResolution: String, Codable, Sendable {
34 case unresolved = "UNRESOLVED"
35 case fixed = "FIXED"
36 case implemented = "IMPLEMENTED"
37 case wontFix = "WONT_FIX"
38 case byDesign = "BY_DESIGN"
39 case invalid = "INVALID"
40 case duplicate = "DUPLICATE"
41 case notOurBug = "NOT_OUR_BUG"
42 case closed = "CLOSED"
43 case notApplicable = "NOT_APPLICABLE"
44
45 init(from decoder: Decoder) throws {
46 let value = try decoder.singleValueContainer().decode(String.self)
47 self = TicketResolution(rawValue: value) ?? .unresolved
48 }
49
50 var displayName: String {
51 switch self {
52 case .unresolved: "Unresolved"
53 case .fixed: "Fixed"
54 case .implemented: "Implemented"
55 case .wontFix: "Won't Fix"
56 case .byDesign: "By Design"
57 case .invalid: "Invalid"
58 case .duplicate: "Duplicate"
59 case .notOurBug: "Not Our Bug"
60 case .closed: "Closed"
61 case .notApplicable: "Not Applicable"
62 }
63 }
64}
65
66/// Authenticity of a ticket or comment.
67enum Authenticity: String, Codable, Sendable {
68 case authentic = "AUTHENTIC"
69 case tampered = "TAMPERED"
70 case unauthenticated = "UNAUTHENTICATED"
71}
72
73// MARK: - Label
74
75/// A label that can be applied to tickets.
76/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
77struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
78 let id: Int
79 let name: String
80 let backgroundColor: String
81 let foregroundColor: String
82}
83
84// MARK: - Tracker
85
86/// A bug tracker from todo.sr.ht.
87struct Tracker: Codable, Sendable, Identifiable, Hashable {
88 let id: Int
89 let created: Date
90 let updated: Date
91 let name: String
92 let description: String?
93 let visibility: Visibility
94 let owner: Entity
95
96 static func == (lhs: Tracker, rhs: Tracker) -> Bool {
97 lhs.id == rhs.id
98 }
99
100 func hash(into hasher: inout Hasher) {
101 hasher.combine(id)
102 }
103}
104
105// MARK: - Tracker Summary (for list view)
106
107/// Lightweight tracker model matching the fields returned by the trackers list query.
108struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
109 let id: Int
110 /// GraphQL resource identifier used by `tracker(id:)` queries.
111 let rid: String
112 let name: String
113 let description: String?
114 let visibility: Visibility
115 let updated: Date
116 let owner: Entity
117}
118
119// MARK: - Ticket Summary (for list view)
120
121/// Lightweight ticket model for the list query.
122struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
123 let id: Int
124 let title: String
125 let status: TicketStatus
126 let resolution: TicketResolution?
127 let created: Date
128 let submitter: Entity
129 let labels: [TicketLabel]
130 let assignees: [Entity]
131
132 static func == (lhs: TicketSummary, rhs: TicketSummary) -> Bool {
133 lhs.id == rhs.id
134 }
135
136 func hash(into hasher: inout Hasher) {
137 hasher.combine(id)
138 }
139}
140
141// MARK: - Ticket Detail
142
143/// Full ticket model for the detail view.
144struct TicketDetail: Codable, Sendable {
145 let id: Int
146 let created: Date
147 let updated: Date
148 let title: String
149 let description: String?
150 let status: TicketStatus
151 let resolution: TicketResolution?
152 let authenticity: Authenticity
153 let submitter: Entity
154 let assignees: [Entity]
155 let labels: [TicketLabel]
156}
157
158// MARK: - Event
159
160/// A timeline event on a ticket. Each event contains one or more changes.
161struct TicketEvent: Codable, Sendable, Identifiable {
162 let id: Int
163 let created: Date
164 var changes: [EventChange]
165}
166
167/// A single change within an event, decoded from the polymorphic EventDetail
168/// interface using inline fragments.
169struct EventChange: Codable, Sendable, Identifiable {
170 let id: UUID
171
172 let eventType: String
173
174 // Comment fields
175 let author: Entity?
176 var text: String?
177 let authenticity: Authenticity?
178
179 // StatusChange fields
180 let oldStatus: TicketStatus?
181 let newStatus: TicketStatus?
182
183 // LabelUpdate fields
184 let labeler: Entity?
185 let label: EventLabel?
186
187 // Assignment fields
188 let assigner: Entity?
189 let assignee: Entity?
190
191 // Mention fields
192 // TicketMention: mentioned is a Ticket with { id }
193 // UserMention: mentioned is an Entity with { canonicalName }
194 let mentioned: MentionTarget?
195
196 // Created fields (author is shared with Comment)
197
198 private enum CodingKeys: String, CodingKey {
199 case eventType
200 case author, text, authenticity
201 case oldStatus, newStatus
202 case labeler, label
203 case assigner, assignee
204 case mentioned
205 }
206
207 init(from decoder: any Decoder) throws {
208 let container = try decoder.container(keyedBy: CodingKeys.self)
209 self.id = UUID()
210 self.eventType = try container.decode(String.self, forKey: .eventType)
211 self.author = try container.decodeIfPresent(Entity.self, forKey: .author)
212 self.text = try container.decodeIfPresent(String.self, forKey: .text)
213 self.authenticity = try container.decodeIfPresent(Authenticity.self, forKey: .authenticity)
214 self.oldStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .oldStatus)
215 self.newStatus = try container.decodeIfPresent(TicketStatus.self, forKey: .newStatus)
216 self.labeler = try container.decodeIfPresent(Entity.self, forKey: .labeler)
217 self.label = try container.decodeIfPresent(EventLabel.self, forKey: .label)
218 self.assigner = try container.decodeIfPresent(Entity.self, forKey: .assigner)
219 self.assignee = try container.decodeIfPresent(Entity.self, forKey: .assignee)
220 self.mentioned = try container.decodeIfPresent(MentionTarget.self, forKey: .mentioned)
221 }
222}
223
224/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
225/// an Entity (with `canonicalName`) depending on the event type.
226struct MentionTarget: Codable, Sendable {
227 let id: Int?
228 let canonicalName: String?
229}
230
231/// Label info as returned within a LabelUpdate event change.
232struct EventLabel: Codable, Sendable {
233 let name: String
234}
Hutch/Models/User.swift added +10
@@ -0,0 +1,10 @@
1import Foundation
2
3/// A Sourcehut user from meta.sr.ht.
4struct User: Decodable, Sendable {
5 let id: Int
6 let username: String
7 let canonicalName: String
8 let email: String
9 let avatar: String?
10}
Hutch/Networking/GraphQLRequest.swift added +45
@@ -0,0 +1,45 @@
1import Foundation
2
3/// The JSON body sent with every GraphQL request.
4struct GraphQLRequestBody: Encodable, Sendable {
5 let query: String
6 let variables: [String: AnyCodable]?
7}
8
9/// The top-level shape of every GraphQL response.
10struct GraphQLResponse<T: Decodable>: Decodable {
11 let data: T?
12 let errors: [GraphQLError]?
13}
14
15// MARK: - AnyCodable
16
17/// A type-erased `Codable` wrapper so callers can pass `[String: Any]` variables
18/// without losing type information at the encoding boundary.
19struct AnyCodable: Sendable, Encodable {
20 let value: any Sendable
21
22 init(_ value: any Sendable) {
23 self.value = value
24 }
25
26 func encode(to encoder: any Encoder) throws {
27 var container = encoder.singleValueContainer()
28 switch value {
29 case let v as String:
30 try container.encode(v)
31 case let v as Int:
32 try container.encode(v)
33 case let v as Double:
34 try container.encode(v)
35 case let v as Bool:
36 try container.encode(v)
37 case let v as [any Sendable]:
38 try container.encode(v.map { AnyCodable($0) })
39 case let v as [String: any Sendable]:
40 try container.encode(v.mapValues { AnyCodable($0) })
41 default:
42 try container.encodeNil()
43 }
44 }
45}
Hutch/Networking/NetworkMonitor.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Network
3
4/// Observes network connectivity using `NWPathMonitor`.
5/// Shared singleton injected into the environment.
6@Observable
7@MainActor
8final class NetworkMonitor {
9
10 private(set) var isConnected = true
11 private(set) var connectionType: ConnectionType = .unknown
12
13 enum ConnectionType: Sendable {
14 case wifi
15 case cellular
16 case wiredEthernet
17 case unknown
18 }
19
20 private let monitor = NWPathMonitor()
21 private let queue = DispatchQueue(label: "net.cleberg.Hutch.NetworkMonitor")
22
23 init() {
24 startMonitoring()
25 }
26
27 private func startMonitoring() {
28 monitor.pathUpdateHandler = { [weak self] path in
29 Task { @MainActor [weak self] in
30 guard let self else { return }
31 self.isConnected = path.status == .satisfied
32 self.connectionType = self.resolveConnectionType(path)
33 }
34 }
35 monitor.start(queue: queue)
36 }
37
38 private nonisolated func resolveConnectionType(_ path: NWPath) -> ConnectionType {
39 if path.usesInterfaceType(.wifi) { return .wifi }
40 if path.usesInterfaceType(.cellular) { return .cellular }
41 if path.usesInterfaceType(.wiredEthernet) { return .wiredEthernet }
42 return .unknown
43 }
44
45 deinit {
46 monitor.cancel()
47 }
48}
Hutch/Networking/Pagination.swift added +176
@@ -0,0 +1,176 @@
1import Foundation
2
3/// The standard paginated response shape used by all sr.ht GraphQL APIs.
4/// { results: [T], cursor: String? }
5/// A null cursor means the list is exhausted.
6struct CursorPage<Element: Decodable & Sendable>: Decodable, Sendable {
7 let results: [Element]
8 let cursor: String?
9}
10
11/// An `AsyncSequence` that lazily fetches pages from a paginated sr.ht GraphQL
12/// query. Each element yielded is a single `Element` from the `results` array.
13///
14/// The sequence re-issues the query with an updated `$cursor` variable on each
15/// page until the server returns a null cursor.
16///
17/// Usage:
18/// ```swift
19/// let sequence = SRHTPaginatedSequence<Repository>(
20/// client: client,
21/// service: .git,
22/// query: "query($cursor: String) { me { repositories(cursor: $cursor) { results { id name } cursor } } }",
23/// variables: nil,
24/// resultKeyPath: "me.repositories"
25/// )
26/// for try await repo in sequence {
27/// print(repo.name)
28/// }
29/// ```
30struct SRHTPaginatedSequence<Element: Decodable & Sendable>: AsyncSequence, Sendable {
31 let client: SRHTClient
32 let service: SRHTService
33 let query: String
34 let variables: [String: any Sendable]?
35 let resultKeyPath: String
36
37 func makeAsyncIterator() -> Iterator {
38 Iterator(
39 client: client,
40 service: service,
41 query: query,
42 variables: variables,
43 resultKeyPath: resultKeyPath
44 )
45 }
46
47 struct Iterator: AsyncIteratorProtocol {
48 private let client: SRHTClient
49 private let service: SRHTService
50 private let query: String
51 private let baseVariables: [String: any Sendable]?
52 private let resultKeyPath: String
53
54 /// Buffer of elements from the current page.
55 private var buffer: [Element] = []
56 /// Index into the current buffer.
57 private var bufferIndex = 0
58 /// The cursor for the next page. Nil means we haven't started or are done.
59 private var nextCursor: String? = nil
60 /// Whether we've exhausted all pages.
61 private var isFinished = false
62
63 init(
64 client: SRHTClient,
65 service: SRHTService,
66 query: String,
67 variables: [String: any Sendable]?,
68 resultKeyPath: String
69 ) {
70 self.client = client
71 self.service = service
72 self.query = query
73 self.baseVariables = variables
74 self.resultKeyPath = resultKeyPath
75 }
76
77 mutating func next() async throws -> Element? {
78 // Yield buffered elements first.
79 if bufferIndex < buffer.count {
80 let element = buffer[bufferIndex]
81 bufferIndex += 1
82 return element
83 }
84
85 // If we already know there are no more pages, stop.
86 if isFinished {
87 return nil
88 }
89
90 // Fetch the next page.
91 var vars = baseVariables ?? [:]
92 if let cursor = nextCursor {
93 vars["cursor"] = cursor
94 }
95
96 let page = try await fetchPage(variables: vars)
97
98 if let cursor = page.cursor {
99 nextCursor = cursor
100 } else {
101 isFinished = true
102 }
103
104 buffer = page.results
105 bufferIndex = 0
106
107 guard bufferIndex < buffer.count else {
108 return nil
109 }
110
111 let element = buffer[bufferIndex]
112 bufferIndex += 1
113 return element
114 }
115
116 private func fetchPage(variables: [String: any Sendable]) async throws -> CursorPage<Element> {
117 // We decode the raw JSON and navigate the key path manually,
118 // since the paginated object can be nested arbitrarily
119 // (e.g. "me.repositories" or just "repositories").
120 let raw = try await client.execute(
121 service: service,
122 query: query,
123 variables: variables.isEmpty ? nil : variables,
124 responseType: RawJSON.self
125 )
126
127 // Walk the key path to find the paginated object.
128 let pathComponents = resultKeyPath.split(separator: ".").map(String.init)
129 var current = raw.value
130 for component in pathComponents {
131 guard let dict = current as? [String: Any],
132 let next = dict[component] else {
133 throw SRHTError.decodingError(
134 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing key path: \(resultKeyPath)"))
135 )
136 }
137 current = next
138 }
139
140 // Re-serialize the nested object and decode as CursorPage<Element>.
141 let pageData = try JSONSerialization.data(withJSONObject: current)
142 let decoder = JSONDecoder()
143 decoder.dateDecodingStrategy = .formatted(.srht)
144 return try decoder.decode(CursorPage<Element>.self, from: pageData)
145 }
146 }
147}
148
149// MARK: - RawJSON
150
151/// A Decodable wrapper that preserves the raw JSON structure as Foundation objects
152/// so we can navigate dynamic key paths at runtime.
153struct RawJSON: Decodable, Sendable {
154 let value: Any
155
156 init(from decoder: any Decoder) throws {
157 let container = try decoder.singleValueContainer()
158 if let dict = try? container.decode([String: RawJSON].self) {
159 value = dict.mapValues(\.value)
160 } else if let array = try? container.decode([RawJSON].self) {
161 value = array.map(\.value)
162 } else if let string = try? container.decode(String.self) {
163 value = string
164 } else if let int = try? container.decode(Int.self) {
165 value = int
166 } else if let double = try? container.decode(Double.self) {
167 value = double
168 } else if let bool = try? container.decode(Bool.self) {
169 value = bool
170 } else if container.decodeNil() {
171 value = NSNull()
172 } else {
173 throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value")
174 }
175 }
176}
Hutch/Networking/ResponseCache.swift added +33
@@ -0,0 +1,33 @@
1import Foundation
2import os
3
4/// Thread-safe in-memory cache for raw GraphQL response data.
5/// Keyed by a caller-provided string (typically service name + query hash).
6final class ResponseCache: Sendable {
7
8 private let storage: OSAllocatedUnfairLock<[String: Data]>
9
10 init() {
11 self.storage = OSAllocatedUnfairLock(initialState: [:])
12 }
13
14 /// Store raw response data under a cache key.
15 func set(_ data: Data, forKey key: String) {
16 storage.withLock { $0[key] = data }
17 }
18
19 /// Retrieve cached response data. Returns nil on cache miss.
20 func get(forKey key: String) -> Data? {
21 storage.withLock { $0[key] }
22 }
23
24 /// Remove a specific entry.
25 func remove(forKey key: String) {
26 storage.withLock { _ = $0.removeValue(forKey: key) }
27 }
28
29 /// Clear all cached data (e.g. on sign-out).
30 func clear() {
31 storage.withLock { $0.removeAll() }
32 }
33}
Hutch/Networking/SRHTClient.swift added +521
@@ -0,0 +1,521 @@
1import Foundation
2import os
3
4private let logger = Logger(subsystem: "net.cleberg.Hutch", category: "SRHTClient")
5
6/// Placeholder type for decoding GraphQL error responses when the data shape is unknown.
7private struct EmptyData: Decodable {}
8
9/// A lightweight GraphQL client for Sourcehut services.
10/// All requests require a personal access token set via ``token``.
11final class SRHTClient: Sendable {
12
13 private let session: URLSession
14 private let decoder: JSONDecoder
15 private let encoder: JSONEncoder
16
17 /// The personal access token used for `Authorization: Bearer` headers.
18 /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
19 private let _token: OSAllocatedUnfairLock<String?>
20
21 /// In-memory response cache for stale-while-revalidate pattern.
22 let responseCache = ResponseCache()
23
24 var hasToken: Bool {
25 _token.withLock { $0 != nil }
26 }
27
28 init(session: URLSession = .shared, token: String? = nil) {
29 self.session = session
30 self.decoder = JSONDecoder()
31 self.decoder.dateDecodingStrategy = .srhtFlexible
32 self.encoder = JSONEncoder()
33 self._token = OSAllocatedUnfairLock(initialState: token)
34 }
35
36 /// Update the stored token (e.g. after the user saves a new one in Keychain).
37 func setToken(_ token: String?) {
38 _token.withLock { $0 = token }
39 }
40
41 /// Execute a GraphQL query or mutation against a Sourcehut service.
42 ///
43 /// - Parameters:
44 /// - service: The target Sourcehut service (determines the endpoint URL).
45 /// - query: The GraphQL query or mutation string.
46 /// - variables: Optional dictionary of GraphQL variables.
47 /// - responseType: The expected `Decodable` type nested under `data`.
48 /// - Returns: The decoded `data` payload.
49 func execute<T: Decodable>(
50 service: SRHTService,
51 query: String,
52 variables: [String: any Sendable]? = nil,
53 responseType: T.Type
54 ) async throws -> T {
55 guard let token = _token.withLock({ $0 }), !token.isEmpty else {
56 throw SRHTError.unauthorized
57 }
58
59 // Build request
60 var request = URLRequest(url: service.url)
61 request.httpMethod = "POST"
62 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
63 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
64
65 let body = GraphQLRequestBody(
66 query: query,
67 variables: variables?.mapValues { AnyCodable($0) }
68 )
69 request.httpBody = try encoder.encode(body)
70
71 // Execute
72 let (data, response): (Data, URLResponse)
73 do {
74 (data, response) = try await session.data(for: request)
75 } catch {
76 throw SRHTError.networkError(error)
77 }
78
79 // Check HTTP status
80 if let http = response as? HTTPURLResponse {
81 if http.statusCode == 401 {
82 throw SRHTError.unauthorized
83 }
84 if !(200...299).contains(http.statusCode) {
85 // Try to extract GraphQL errors from the response body even on non-2xx
86 if let gqlResponse = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
87 let errors = gqlResponse.errors, !errors.isEmpty {
88 throw SRHTError.graphQLErrors(errors)
89 }
90 throw SRHTError.httpError(http.statusCode)
91 }
92 }
93
94 // Decode GraphQL response envelope
95 let graphQLResponse: GraphQLResponse<T>
96 do {
97 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
98 } catch {
99 #if DEBUG
100 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
101 let variablesDescription = String(describing: variables)
102 if let decodingError = error as? DecodingError {
103 logger.error(
104 """
105 Decoding failed for \(String(describing: T.self), privacy: .public)
106 service: \(service.rawValue, privacy: .public)
107 query:
108 \(query, privacy: .public)
109 variables:
110 \(variablesDescription, privacy: .public)
111 decodingError:
112 \(String(describing: decodingError), privacy: .public)
113 response:
114 \(responseBody, privacy: .public)
115 """
116 )
117 } else {
118 logger.error(
119 """
120 Decoding failed for \(String(describing: T.self), privacy: .public)
121 service: \(service.rawValue, privacy: .public)
122 query:
123 \(query, privacy: .public)
124 variables:
125 \(variablesDescription, privacy: .public)
126 error:
127 \(String(describing: error), privacy: .public)
128 response:
129 \(responseBody, privacy: .public)
130 """
131 )
132 }
133 #else
134 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
135 #endif
136 throw SRHTError.decodingError(error)
137 }
138
139 // Surface GraphQL-level errors
140 if let errors = graphQLResponse.errors, !errors.isEmpty {
141 throw SRHTError.graphQLErrors(errors)
142 }
143
144 guard let result = graphQLResponse.data else {
145 throw SRHTError.decodingError(
146 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
147 )
148 }
149
150 return result
151 }
152
153 // MARK: - Multipart Upload
154
155 /// Execute a GraphQL mutation with a file upload using the
156 /// graphql-multipart-request-spec (multipart/form-data).
157 ///
158 /// - Parameters:
159 /// - service: The target Sourcehut service.
160 /// - query: The GraphQL mutation string.
161 /// - variables: Variables dict; the file variable should be set to `nil`.
162 /// - fileVariablePath: The dot-separated path to the file variable (e.g. "input.avatar").
163 /// - fileData: The raw file data (e.g. JPEG).
164 /// - fileName: The file name to send (e.g. "avatar.jpg").
165 /// - mimeType: The MIME type (e.g. "image/jpeg").
166 /// - responseType: The expected `Decodable` type nested under `data`.
167 func executeMultipart<T: Decodable>(
168 service: SRHTService,
169 query: String,
170 variables: [String: any Sendable],
171 fileVariablePath: String,
172 fileData: Data,
173 fileName: String,
174 mimeType: String,
175 responseType: T.Type
176 ) async throws -> T {
177 guard let token = _token.withLock({ $0 }), !token.isEmpty else {
178 throw SRHTError.unauthorized
179 }
180
181 let boundary = "Boundary-\(UUID().uuidString)"
182
183 var request = URLRequest(url: service.url)
184 request.httpMethod = "POST"
185 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
186 request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
187
188 // Build the operations JSON (file variable mapped to null)
189 let operationsBody = GraphQLRequestBody(
190 query: query,
191 variables: variables.mapValues { AnyCodable($0) }
192 )
193 let operationsData = try encoder.encode(operationsBody)
194
195 // Build the map JSON: { "0": ["variables.<fileVariablePath>"] }
196 let mapDict = ["0": ["variables.\(fileVariablePath)"]]
197 let mapData = try encoder.encode(mapDict)
198
199 // Assemble multipart body
200 var body = Data()
201
202 // Part: operations
203 body.append("--\(boundary)\r\n")
204 body.append("Content-Disposition: form-data; name=\"operations\"\r\n")
205 body.append("Content-Type: application/json\r\n\r\n")
206 body.append(operationsData)
207 body.append("\r\n")
208
209 // Part: map
210 body.append("--\(boundary)\r\n")
211 body.append("Content-Disposition: form-data; name=\"map\"\r\n")
212 body.append("Content-Type: application/json\r\n\r\n")
213 body.append(mapData)
214 body.append("\r\n")
215
216 // Part: file
217 body.append("--\(boundary)\r\n")
218 body.append("Content-Disposition: form-data; name=\"0\"; filename=\"\(fileName)\"\r\n")
219 body.append("Content-Type: \(mimeType)\r\n\r\n")
220 body.append(fileData)
221 body.append("\r\n")
222
223 // Closing boundary
224 body.append("--\(boundary)--\r\n")
225
226 request.httpBody = body
227
228 let (data, response): (Data, URLResponse)
229 do {
230 (data, response) = try await session.data(for: request)
231 } catch {
232 throw SRHTError.networkError(error)
233 }
234
235 if let http = response as? HTTPURLResponse {
236 if http.statusCode == 401 {
237 throw SRHTError.unauthorized
238 }
239 if !(200...299).contains(http.statusCode) {
240 throw SRHTError.httpError(http.statusCode)
241 }
242 }
243
244 let graphQLResponse: GraphQLResponse<T>
245 do {
246 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
247 } catch {
248 #if DEBUG
249 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
250 let variablesDescription = String(describing: variables)
251 if let decodingError = error as? DecodingError {
252 logger.error(
253 """
254 Decoding failed for \(String(describing: T.self), privacy: .public)
255 service: \(service.rawValue, privacy: .public)
256 query:
257 \(query, privacy: .public)
258 variables:
259 \(variablesDescription, privacy: .public)
260 decodingError:
261 \(String(describing: decodingError), privacy: .public)
262 response:
263 \(responseBody, privacy: .public)
264 """
265 )
266 } else {
267 logger.error(
268 """
269 Decoding failed for \(String(describing: T.self), privacy: .public)
270 service: \(service.rawValue, privacy: .public)
271 query:
272 \(query, privacy: .public)
273 variables:
274 \(variablesDescription, privacy: .public)
275 error:
276 \(String(describing: error), privacy: .public)
277 response:
278 \(responseBody, privacy: .public)
279 """
280 )
281 }
282 #else
283 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
284 #endif
285 throw SRHTError.decodingError(error)
286 }
287
288 if let errors = graphQLResponse.errors, !errors.isEmpty {
289 throw SRHTError.graphQLErrors(errors)
290 }
291
292 guard let result = graphQLResponse.data else {
293 throw SRHTError.decodingError(
294 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
295 )
296 }
297
298 return result
299 }
300
301 // MARK: - Cached Execute
302
303 /// Execute a query and cache the raw response data. Returns cached data
304 /// immediately on cache hit, then refreshes in the background via the
305 /// `onRefresh` callback.
306 func executeCached<T: Decodable>(
307 service: SRHTService,
308 query: String,
309 variables: [String: any Sendable]? = nil,
310 responseType: T.Type,
311 cacheKey: String
312 ) async throws -> T {
313 // Try cache first
314 if let cachedData = responseCache.get(forKey: cacheKey) {
315 if let cached = try? decoder.decode(GraphQLResponse<T>.self, from: cachedData),
316 let data = cached.data {
317 return data
318 }
319 }
320
321 // No cache hit — fetch normally
322 return try await executeAndCache(
323 service: service,
324 query: query,
325 variables: variables,
326 responseType: responseType,
327 cacheKey: cacheKey
328 )
329 }
330
331 /// Execute a query, cache the raw data, and return the decoded result.
332 func executeAndCache<T: Decodable>(
333 service: SRHTService,
334 query: String,
335 variables: [String: any Sendable]? = nil,
336 responseType: T.Type,
337 cacheKey: String
338 ) async throws -> T {
339 guard let token = _token.withLock({ $0 }), !token.isEmpty else {
340 throw SRHTError.unauthorized
341 }
342
343 var request = URLRequest(url: service.url)
344 request.httpMethod = "POST"
345 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
346 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
347
348 let body = GraphQLRequestBody(
349 query: query,
350 variables: variables?.mapValues { AnyCodable($0) }
351 )
352 request.httpBody = try encoder.encode(body)
353
354 let (data, response): (Data, URLResponse)
355 do {
356 (data, response) = try await session.data(for: request)
357 } catch {
358 throw SRHTError.networkError(error)
359 }
360
361 if let http = response as? HTTPURLResponse {
362 if http.statusCode == 401 {
363 throw SRHTError.unauthorized
364 }
365 if !(200...299).contains(http.statusCode) {
366 throw SRHTError.httpError(http.statusCode)
367 }
368 }
369
370 // Cache the raw response data before decoding
371 responseCache.set(data, forKey: cacheKey)
372
373 let graphQLResponse: GraphQLResponse<T>
374 do {
375 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
376 } catch {
377 #if DEBUG
378 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
379 let variablesDescription = String(describing: variables)
380 if let decodingError = error as? DecodingError {
381 logger.error(
382 """
383 Decoding failed for \(String(describing: T.self), privacy: .public)
384 service: \(service.rawValue, privacy: .public)
385 query:
386 \(query, privacy: .public)
387 variables:
388 \(variablesDescription, privacy: .public)
389 decodingError:
390 \(String(describing: decodingError), privacy: .public)
391 response:
392 \(responseBody, privacy: .public)
393 """
394 )
395 } else {
396 logger.error(
397 """
398 Decoding failed for \(String(describing: T.self), privacy: .public)
399 service: \(service.rawValue, privacy: .public)
400 query:
401 \(query, privacy: .public)
402 variables:
403 \(variablesDescription, privacy: .public)
404 error:
405 \(String(describing: error), privacy: .public)
406 response:
407 \(responseBody, privacy: .public)
408 """
409 )
410 }
411 #else
412 logger.error("Decoding failed for \(String(describing: T.self), privacy: .public): \(error, privacy: .public)")
413 #endif
414 throw SRHTError.decodingError(error)
415 }
416
417 if let errors = graphQLResponse.errors, !errors.isEmpty {
418 throw SRHTError.graphQLErrors(errors)
419 }
420
421 guard let result = graphQLResponse.data else {
422 throw SRHTError.decodingError(
423 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
424 )
425 }
426
427 return result
428 }
429
430 // MARK: - Plain-text fetch
431
432 /// Fetch the contents of a URL as plain text, using the same authorization header.
433 /// Used for build logs and other non-GraphQL resources.
434 func fetchText(url: URL) async throws -> String {
435 guard let token = _token.withLock({ $0 }), !token.isEmpty else {
436 throw SRHTError.unauthorized
437 }
438
439 var request = URLRequest(url: url)
440 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
441
442 let (data, response): (Data, URLResponse)
443 do {
444 (data, response) = try await session.data(for: request)
445 } catch {
446 throw SRHTError.networkError(error)
447 }
448
449 if let http = response as? HTTPURLResponse {
450 if http.statusCode == 401 {
451 throw SRHTError.unauthorized
452 }
453 if !(200...299).contains(http.statusCode) {
454 throw SRHTError.httpError(http.statusCode)
455 }
456 }
457
458 guard let text = String(data: data, encoding: .utf8) else {
459 throw SRHTError.decodingError(
460 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
461 )
462 }
463
464 return text
465 }
466
467 // MARK: - Pagination
468
469 /// Returns an `AsyncSequence` that lazily iterates through all pages of a
470 /// paginated sr.ht GraphQL query.
471 ///
472 /// The query must accept a `$cursor: String` variable and return the standard
473 /// `{ results: [T], cursor: String? }` shape at the given key path.
474 func paginated<T: Decodable & Sendable>(
475 service: SRHTService,
476 query: String,
477 variables: [String: any Sendable]? = nil,
478 resultKeyPath: String,
479 type: T.Type
480 ) -> SRHTPaginatedSequence<T> {
481 SRHTPaginatedSequence(
482 client: self,
483 service: service,
484 query: query,
485 variables: variables,
486 resultKeyPath: resultKeyPath
487 )
488 }
489
490 /// Fetches all pages of a paginated sr.ht GraphQL query and returns the
491 /// collected results.
492 func fetchAll<T: Decodable & Sendable>(
493 service: SRHTService,
494 query: String,
495 variables: [String: any Sendable]? = nil,
496 resultKeyPath: String,
497 type: T.Type
498 ) async throws -> [T] {
499 var all: [T] = []
500 for try await element in paginated(
501 service: service,
502 query: query,
503 variables: variables,
504 resultKeyPath: resultKeyPath,
505 type: type
506 ) {
507 all.append(element)
508 }
509 return all
510 }
511}
512
513// MARK: - Data Helper
514
515private extension Data {
516 mutating func append(_ string: String) {
517 if let data = string.data(using: .utf8) {
518 append(data)
519 }
520 }
521}
Hutch/Networking/SRHTError.swift added +63
@@ -0,0 +1,63 @@
1import Foundation
2
3/// Errors produced by the Sourcehut GraphQL client.
4enum SRHTError: LocalizedError, Sendable {
5 /// The server returned one or more GraphQL-level errors.
6 case graphQLErrors([GraphQLError])
7 /// The HTTP response had a non-2xx status code.
8 case httpError(Int)
9 /// The response data could not be decoded.
10 case decodingError(any Error)
11 /// A networking error from URLSession (timeout, DNS, connectivity, etc.).
12 case networkError(any Error)
13 /// 401 or no authentication token configured.
14 case unauthorized
15
16 var errorDescription: String? {
17 switch self {
18 case .graphQLErrors(let errors):
19 let messages = errors.map(\.message).joined(separator: "\n")
20 return "GraphQL error: \(messages)"
21 case .httpError(let code):
22 return "Server returned HTTP \(code)."
23 case .decodingError(let error):
24 return "Failed to decode response: \(error.localizedDescription)"
25 case .networkError(let error):
26 return "Network error: \(error.localizedDescription)"
27 case .unauthorized:
28 return "Authentication required. Please sign in again."
29 }
30 }
31
32 /// Whether this error represents a connectivity issue (no internet, timeout, DNS).
33 var isConnectivityError: Bool {
34 switch self {
35 case .networkError(let error):
36 let nsError = error as NSError
37 let connectivityCodes: Set<Int> = [
38 NSURLErrorNotConnectedToInternet,
39 NSURLErrorNetworkConnectionLost,
40 NSURLErrorTimedOut,
41 NSURLErrorCannotFindHost,
42 NSURLErrorCannotConnectToHost,
43 NSURLErrorDNSLookupFailed,
44 NSURLErrorInternationalRoamingOff,
45 NSURLErrorDataNotAllowed
46 ]
47 return connectivityCodes.contains(nsError.code)
48 default:
49 return false
50 }
51 }
52}
53
54/// A single error entry from the GraphQL `errors` array.
55struct GraphQLError: Decodable, Sendable {
56 let message: String
57 let locations: [GraphQLErrorLocation]?
58}
59
60struct GraphQLErrorLocation: Decodable, Sendable {
61 let line: Int
62 let column: Int
63}
Hutch/Networking/SRHTService.swift added +34
@@ -0,0 +1,34 @@
1import Foundation
2
3/// Each Sourcehut service exposes its own GraphQL endpoint.
4enum SRHTService: String, Codable, Sendable, CaseIterable {
5 case meta
6 case git
7 case hg
8 case builds
9 case lists
10 case todo
11 case paste
12 case pages
13 case man
14
15 /// The GraphQL endpoint URL for this service.
16 var url: URL {
17 // Force-unwrap is safe here — these are compile-time constant strings.
18 URL(string: "https://\(rawValue).sr.ht/query")!
19 }
20
21 var displayName: String {
22 switch self {
23 case .meta: "Meta"
24 case .git: "Git"
25 case .hg: "Mercurial"
26 case .builds: "Builds"
27 case .lists: "Lists"
28 case .todo: "Todo"
29 case .paste: "Paste"
30 case .pages: "Pages"
31 case .man: "Man"
32 }
33 }
34}
Hutch/Views/Auth/AuthView.swift added +95
@@ -0,0 +1,95 @@
1import SwiftUI
2
3/// Token entry screen shown when the user is not authenticated.
4struct TokenEntryView: View {
5 private let createAccountURL = URL(string: "https://meta.sr.ht/register")!
6 private let personalAccessTokensURL = URL(string: "https://meta.sr.ht/oauth/personal-access-tokens")!
7
8 @Environment(AppState.self) private var appState
9 @State private var token = ""
10 @State private var isConnecting = false
11 @State private var errorMessage: String?
12
13 var body: some View {
14 NavigationStack {
15 Form {
16 Section {
17 Text("Enter your SourceHut personal access token to connect.")
18 } header: {
19 Text("Welcome to Hutch")
20 } footer: {
21 Text("Hutch stores your SourceHut personal access token securely in the iOS keychain.")
22 }
23
24 Section {
25 SecureField("Personal Access Token", text: $token)
26 .textContentType(.password)
27 .autocorrectionDisabled()
28 .textInputAutocapitalization(.never)
29 .disabled(isConnecting)
30 } header: {
31 Text("Token")
32 }
33
34 if let errorMessage {
35 Section {
36 Label {
37 Text(errorMessage)
38 } icon: {
39 Image(systemName: "exclamationmark.triangle.fill")
40 .foregroundStyle(.red)
41 }
42 .foregroundStyle(.red)
43 }
44 }
45
46 Section {
47 Button {
48 connect()
49 } label: {
50 HStack {
51 Text("Connect")
52 if isConnecting {
53 Spacer()
54 ProgressView()
55 }
56 }
57 }
58 .disabled(tokenTrimmed.isEmpty || isConnecting)
59 }
60
61 Section {
62 Link(destination: createAccountURL) {
63 Label("Create SourceHut account", systemImage: "person.badge.plus")
64 }
65
66 Link(destination: personalAccessTokensURL) {
67 Label("Create Personal Access Token", systemImage: "key")
68 }
69 } header: {
70 Text("Need an account?")
71 } footer: {
72 Text("These links open SourceHut in your browser. After signing up, create a Personal Access Token there and paste it here.")
73 }
74 }
75 .navigationTitle("Hutch")
76 }
77 }
78
79 private var tokenTrimmed: String {
80 token.trimmingCharacters(in: .whitespacesAndNewlines)
81 }
82
83 private func connect() {
84 errorMessage = nil
85 isConnecting = true
86 Task {
87 do {
88 try await appState.connect(with: tokenTrimmed)
89 } catch {
90 errorMessage = error.localizedDescription
91 }
92 isConnecting = false
93 }
94 }
95}
Hutch/Views/Builds/BuildDetailView.swift added +383
@@ -0,0 +1,383 @@
1import SwiftUI
2
3struct BuildDetailView: View {
4 let jobId: Int
5
6 @Environment(AppState.self) private var appState
7 @State private var viewModel: BuildDetailViewModel?
8 @State private var rebuiltJobId: Int?
9 @State private var showEditResubmitSheet = false
10 @State private var showCancelConfirmation = false
11
12 var body: some View {
13 Group {
14 if let viewModel {
15 detailContent(viewModel)
16 } else {
17 SRHTLoadingStateView(message: "Loading build…")
18 }
19 }
20 .navigationTitle("Job #\(jobId)")
21 .navigationBarTitleDisplayMode(.inline)
22 .toolbar {
23 ToolbarItem(placement: .topBarTrailing) {
24 SRHTShareButton(
25 url: viewModel?.job.flatMap { SRHTWebURL.build(jobId: $0.id, ownerCanonicalName: $0.owner.canonicalName) },
26 target: .build
27 ) {
28 Image(systemName: "square.and.arrow.up")
29 }
30 }
31 }
32 .navigationDestination(isPresented: Binding(
33 get: { rebuiltJobId != nil },
34 set: { isPresented in
35 if !isPresented {
36 rebuiltJobId = nil
37 }
38 }
39 )) {
40 if let rebuiltJobId {
41 BuildDetailView(jobId: rebuiltJobId)
42 }
43 }
44 .sheet(isPresented: $showEditResubmitSheet) {
45 if let viewModel, let job = viewModel.job {
46 EditResubmitBuildSheet(viewModel: viewModel, job: job) { jobId in
47 showEditResubmitSheet = false
48 rebuiltJobId = jobId
49 }
50 }
51 }
52 .alert("Cancel Build?", isPresented: $showCancelConfirmation) {
53 Button("Keep Running", role: .cancel) {}
54 Button("Cancel Build", role: .destructive) {
55 Task { await viewModel?.cancelJob() }
56 }
57 } message: {
58 Text("The build will stop as soon as possible.")
59 }
60 .task {
61 if viewModel == nil {
62 let vm = BuildDetailViewModel(jobId: jobId, client: appState.client)
63 viewModel = vm
64 await vm.loadJob()
65 }
66 }
67 }
68
69 @ViewBuilder
70 private func detailContent(_ viewModel: BuildDetailViewModel) -> some View {
71 if viewModel.isLoading, viewModel.job == nil {
72 SRHTLoadingStateView(message: "Loading build…")
73 } else if let error = viewModel.error, viewModel.job == nil {
74 SRHTErrorStateView(
75 title: "Couldn't Load Build",
76 message: error,
77 retryAction: { await viewModel.loadJob() }
78 )
79 } else if let job = viewModel.job {
80 List {
81 // Status & metadata
82 Section("Details") {
83 HStack {
84 Text("Status")
85 Spacer()
86 HStack(spacing: 6) {
87 JobStatusIcon(status: job.status)
88 Text(job.status.rawValue)
89 .font(.subheadline.weight(.medium))
90 }
91 }
92
93 if let note = job.note, !note.isEmpty {
94 LabeledContent("Note", value: note)
95 }
96
97 if let image = job.image {
98 LabeledContent("Image", value: image)
99 }
100
101 if !job.tags.isEmpty {
102 LabeledContent("Tags", value: job.tags.joined(separator: ", "))
103 }
104
105 if let visibility = job.visibility {
106 LabeledContent("Visibility", value: visibility.rawValue.capitalized)
107 }
108
109 LabeledContent("Owner", value: job.owner.canonicalName)
110 LabeledContent("Created", value: job.created.relativeDescription)
111 LabeledContent("Updated", value: job.updated.relativeDescription)
112 }
113
114 // Per-task logs
115 if !job.tasks.isEmpty {
116 ForEach(job.tasks) { task in
117 Section {
118 TaskLogSection(task: task, viewModel: viewModel)
119 } header: {
120 HStack(spacing: 6) {
121 TaskStatusIcon(status: task.status)
122 Text(task.name)
123 }
124 }
125 }
126 }
127
128 // Cancel button
129 if job.status.isCancellable {
130 Section {
131 Button(role: .destructive) {
132 showCancelConfirmation = true
133 } label: {
134 HStack {
135 Text("Cancel Build")
136 if viewModel.isCancelling {
137 Spacer()
138 ProgressView()
139 }
140 }
141 }
142 .disabled(viewModel.isCancelling)
143 }
144 }
145
146 if let manifest = job.manifest,
147 !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
148 Section {
149 Button {
150 Task {
151 rebuiltJobId = await viewModel.rebuildJob()
152 }
153 } label: {
154 HStack {
155 Text(job.status == .failed || job.status == .cancelled || job.status == .timeout ? "Retry Build" : "Rebuild")
156 if viewModel.isRebuilding {
157 Spacer()
158 ProgressView()
159 }
160 }
161 }
162 .disabled(viewModel.isRebuilding)
163
164 Button {
165 showEditResubmitSheet = true
166 } label: {
167 Text("Edit & Resubmit")
168 }
169 .disabled(viewModel.isSubmittingEditedBuild)
170 } footer: {
171 Text("Creates a new build using this job’s saved manifest, tags, note, and visibility.")
172 }
173 }
174 }
175 .refreshable {
176 await viewModel.loadJob()
177 }
178 .srhtErrorBanner(error: Binding(
179 get: { viewModel.error },
180 set: { viewModel.error = $0 }
181 ))
182 }
183 }
184}
185
186private struct EditResubmitBuildSheet: View {
187 let viewModel: BuildDetailViewModel
188 let job: JobDetail
189 let onSubmitted: (Int) -> Void
190
191 @Environment(\.dismiss) private var dismiss
192 @Bindable var viewModelBindable: BuildDetailViewModel
193 @State private var manifest: String
194 @State private var tagsText: String
195 @State private var note: String
196 @State private var secrets = false
197 @State private var execute = true
198 @State private var visibility: Visibility
199
200 init(viewModel: BuildDetailViewModel, job: JobDetail, onSubmitted: @escaping (Int) -> Void) {
201 self.viewModel = viewModel
202 self._viewModelBindable = Bindable(viewModel)
203 self.job = job
204 self.onSubmitted = onSubmitted
205 _manifest = State(initialValue: job.manifest ?? "")
206 _tagsText = State(initialValue: job.tags.joined(separator: ", "))
207 _note = State(initialValue: job.note ?? "")
208 _visibility = State(initialValue: job.visibility ?? .public)
209 }
210
211 var body: some View {
212 NavigationStack {
213 Form {
214 Section("Build Manifest") {
215 TextField("Build manifest", text: $manifest, axis: .vertical)
216 .font(.system(.body, design: .monospaced))
217 .lineLimit(12...24)
218 .textInputAutocapitalization(.never)
219 .autocorrectionDisabled()
220 }
221
222 Section("Build Options") {
223 TextField("Note (optional)", text: $note)
224 TextField("Tags (comma-separated, optional)", text: $tagsText)
225 .textInputAutocapitalization(.never)
226 .autocorrectionDisabled()
227 Picker("Visibility", selection: $visibility) {
228 Text("Public").tag(Visibility.public)
229 Text("Unlisted").tag(Visibility.unlisted)
230 Text("Private").tag(Visibility.private)
231 }
232 Toggle("Start build now", isOn: $execute)
233 Toggle("Allow build secrets", isOn: $secrets)
234 }
235
236 Section {
237 Text("This submits a new build. “Start build now” and “Allow build secrets” use local defaults because the current job does not include those original values.")
238 .font(.footnote)
239 .foregroundStyle(.secondary)
240 }
241
242 if let error = viewModel.error {
243 Section {
244 Label {
245 Text(error)
246 } icon: {
247 Image(systemName: "exclamationmark.triangle.fill")
248 .foregroundStyle(.red)
249 }
250 .foregroundStyle(.red)
251 }
252 }
253 }
254 .navigationTitle("Edit & Resubmit")
255 .navigationBarTitleDisplayMode(.inline)
256 .onDisappear {
257 viewModelBindable.error = nil
258 }
259 .toolbar {
260 ToolbarItem(placement: .cancellationAction) {
261 Button("Cancel") {
262 viewModelBindable.error = nil
263 dismiss()
264 }
265 }
266 ToolbarItem(placement: .confirmationAction) {
267 Button {
268 Task {
269 let tags = tagsText
270 .split(separator: ",")
271 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
272 .filter { !$0.isEmpty }
273 if let jobId = await viewModel.submitBuild(
274 manifest: manifest,
275 tags: tags,
276 note: note,
277 secrets: secrets,
278 execute: execute,
279 visibility: visibility
280 ) {
281 onSubmitted(jobId)
282 }
283 }
284 } label: {
285 if viewModel.isSubmittingEditedBuild {
286 ProgressView()
287 .controlSize(.small)
288 } else {
289 Text("Submit Build")
290 }
291 }
292 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmittingEditedBuild)
293 }
294 }
295 }
296 }
297}
298
299// MARK: - Task Log Section
300
301private struct TaskLogSection: View {
302 let task: BuildTask
303 let viewModel: BuildDetailViewModel
304
305 @State private var isExpanded: Bool
306
307 init(task: BuildTask, viewModel: BuildDetailViewModel) {
308 self.task = task
309 self.viewModel = viewModel
310 self._isExpanded = State(initialValue: task.status == .failed)
311 }
312
313 var body: some View {
314 DisclosureGroup(isExpanded: $isExpanded) {
315 if viewModel.loadingTaskLogs.contains(task.name) {
316 HStack {
317 Spacer()
318 ProgressView("Loading log…")
319 Spacer()
320 }
321 } else if let logText = viewModel.taskLogs[task.name] {
322 ScrollView(.horizontal, showsIndicators: false) {
323 Text(logText)
324 .font(.caption2.monospaced())
325 .foregroundStyle(.primary)
326 .textSelection(.enabled)
327 .frame(maxWidth: .infinity, alignment: .leading)
328 }
329 } else if task.log == nil {
330 Text("No log available.")
331 .foregroundStyle(.secondary)
332 }
333 } label: {
334 HStack {
335 Text(task.status.rawValue)
336 .font(.caption)
337 .foregroundStyle(.secondary)
338 }
339 }
340 .task {
341 if isExpanded {
342 await viewModel.loadTaskLog(task: task)
343 }
344 }
345 .onChange(of: isExpanded) { _, expanded in
346 if expanded {
347 Task { await viewModel.loadTaskLog(task: task) }
348 }
349 }
350 }
351}
352
353// MARK: - Task Status Icon
354
355private struct TaskStatusIcon: View {
356 let status: TaskStatus
357
358 var body: some View {
359 Image(systemName: iconName)
360 .foregroundStyle(color)
361 .frame(width: 20)
362 }
363
364 private var iconName: String {
365 switch status {
366 case .success: "checkmark.circle.fill"
367 case .failed: "xmark.circle.fill"
368 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
369 case .pending: "circle.dashed"
370 case .skipped: "forward.circle.fill"
371 }
372 }
373
374 private var color: Color {
375 switch status {
376 case .success: .green
377 case .failed: .red
378 case .running: .yellow
379 case .pending: .gray
380 case .skipped: .secondary
381 }
382 }
383}
Hutch/Views/Builds/BuildDetailViewModel.swift added +235
@@ -0,0 +1,235 @@
1import Foundation
2
3// MARK: - Response types
4
5private struct JobDetailResponse: Decodable, Sendable {
6 let job: JobDetail
7}
8
9private struct CancelResponse: Decodable, Sendable {
10 let cancel: CancelResult
11}
12
13private struct CancelResult: Decodable, Sendable {
14 let id: Int
15}
16
17private struct SubmitJobResponse: Decodable, Sendable {
18 let submit: SubmittedJob
19}
20
21private struct SubmittedJob: Decodable, Sendable {
22 let id: Int
23}
24
25// MARK: - View Model
26
27@Observable
28@MainActor
29final class BuildDetailViewModel {
30
31 let jobId: Int
32 private let client: SRHTClient
33
34 private(set) var job: JobDetail?
35 private(set) var isLoading = false
36 private(set) var taskLogs: [String: String] = [:]
37 private(set) var loadingTaskLogs: Set<String> = []
38 private(set) var isCancelling = false
39 private(set) var isRebuilding = false
40 private(set) var isSubmittingEditedBuild = false
41 var error: String?
42
43 init(jobId: Int, client: SRHTClient) {
44 self.jobId = jobId
45 self.client = client
46 }
47
48 // MARK: - Queries
49
50 private static let detailQuery = """
51 query job($id: Int!) {
52 job(id: $id) {
53 id
54 created
55 updated
56 status
57 note
58 tags
59 visibility
60 image
61 manifest
62 tasks { name status log { fullURL } }
63 log { fullURL }
64 owner { canonicalName }
65 }
66 }
67 """
68
69 private static let cancelMutation = """
70 mutation cancel($id: Int!) {
71 cancel(jobId: $id) {
72 id
73 }
74 }
75 """
76
77 private static let submitMutation = """
78 mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
79 submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
80 id
81 }
82 }
83 """
84
85 private static let editableSubmitMutation = """
86 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
87 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
88 id
89 }
90 }
91 """
92
93 // MARK: - Public API
94
95 func loadJob() async {
96 guard !isLoading else { return }
97 isLoading = true
98 error = nil
99
100 do {
101 let result = try await client.execute(
102 service: .builds,
103 query: Self.detailQuery,
104 variables: ["id": jobId],
105 responseType: JobDetailResponse.self
106 )
107 job = result.job
108 } catch {
109 self.error = error.localizedDescription
110 }
111
112 isLoading = false
113 }
114
115 func loadTaskLog(task: BuildTask) async {
116 guard let log = task.log,
117 let logURL = URL(string: log.fullURL),
118 !loadingTaskLogs.contains(task.name),
119 taskLogs[task.name] == nil else { return }
120 loadingTaskLogs.insert(task.name)
121
122 do {
123 taskLogs[task.name] = try await client.fetchText(url: logURL)
124 } catch {
125 self.error = error.localizedDescription
126 }
127
128 loadingTaskLogs.remove(task.name)
129 }
130
131 func cancelJob() async {
132 guard let job, job.status.isCancellable, !isCancelling else { return }
133 isCancelling = true
134 error = nil
135
136 do {
137 _ = try await client.execute(
138 service: .builds,
139 query: Self.cancelMutation,
140 variables: ["id": jobId],
141 responseType: CancelResponse.self
142 )
143 // Reload job to get updated status.
144 await loadJob()
145 } catch {
146 self.error = error.localizedDescription
147 }
148
149 isCancelling = false
150 }
151
152 func rebuildJob() async -> Int? {
153 guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
154 return nil
155 }
156
157 isRebuilding = true
158 error = nil
159 defer { isRebuilding = false }
160
161 var variables: [String: any Sendable] = [
162 "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
163 ]
164 if !job.tags.isEmpty {
165 variables["tags"] = job.tags
166 }
167 if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
168 variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
169 }
170 if let visibility = job.visibility {
171 variables["visibility"] = visibility.rawValue
172 }
173
174 do {
175 let result = try await client.execute(
176 service: .builds,
177 query: Self.submitMutation,
178 variables: variables,
179 responseType: SubmitJobResponse.self
180 )
181 return result.submit.id
182 } catch {
183 self.error = error.localizedDescription
184 return nil
185 }
186 }
187
188 func submitBuild(
189 manifest: String,
190 tags: [String],
191 note: String,
192 secrets: Bool,
193 execute: Bool,
194 visibility: Visibility
195 ) async -> Int? {
196 guard !isSubmittingEditedBuild else { return nil }
197
198 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
199 guard !trimmedManifest.isEmpty else {
200 error = "Paste a build manifest."
201 return nil
202 }
203
204 isSubmittingEditedBuild = true
205 error = nil
206 defer { isSubmittingEditedBuild = false }
207
208 var variables: [String: any Sendable] = [
209 "manifest": trimmedManifest,
210 "secrets": secrets,
211 "execute": execute,
212 "visibility": visibility.rawValue
213 ]
214 if !tags.isEmpty {
215 variables["tags"] = tags
216 }
217 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
218 if !trimmedNote.isEmpty {
219 variables["note"] = trimmedNote
220 }
221
222 do {
223 let result = try await client.execute(
224 service: .builds,
225 query: Self.editableSubmitMutation,
226 variables: variables,
227 responseType: SubmitJobResponse.self
228 )
229 return result.submit.id
230 } catch {
231 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
232 return nil
233 }
234 }
235}
Hutch/Views/Builds/BuildListView.swift added +217
@@ -0,0 +1,217 @@
1import SwiftUI
2
3struct BuildListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: BuildListViewModel?
6 @State private var showSubmitSheet = false
7 @State private var submittedJobId: Int?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 listContent(viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading builds…")
15 }
16 }
17 .navigationTitle("Builds")
18 .toolbar {
19 if viewModel != nil {
20 ToolbarItem(placement: .topBarTrailing) {
21 Button {
22 showSubmitSheet = true
23 } label: {
24 Image(systemName: "plus")
25 }
26 }
27 }
28 }
29 .sheet(isPresented: $showSubmitSheet) {
30 if let viewModel {
31 SubmitBuildSheet(viewModel: viewModel) { jobId in
32 showSubmitSheet = false
33 submittedJobId = jobId
34 }
35 }
36 }
37 .navigationDestination(for: JobSummary.self) { job in
38 BuildDetailView(jobId: job.id)
39 }
40 .navigationDestination(isPresented: Binding(
41 get: { submittedJobId != nil },
42 set: { isPresented in
43 if !isPresented {
44 submittedJobId = nil
45 }
46 }
47 )) {
48 if let submittedJobId {
49 BuildDetailView(jobId: submittedJobId)
50 }
51 }
52 .task {
53 if viewModel == nil {
54 let vm = BuildListViewModel(client: appState.client)
55 viewModel = vm
56 await vm.loadJobs()
57 }
58 }
59 }
60
61 @ViewBuilder
62 private func listContent(_ viewModel: BuildListViewModel) -> some View {
63 @Bindable var vm = viewModel
64
65 List {
66 ForEach(viewModel.jobs) { job in
67 NavigationLink(value: job) {
68 BuildRowView(job: job)
69 }
70 .task {
71 await viewModel.loadMoreIfNeeded(currentItem: job)
72 }
73 }
74
75 if viewModel.isLoadingMore {
76 HStack {
77 Spacer()
78 ProgressView()
79 Spacer()
80 }
81 .listRowSeparator(.hidden)
82 }
83 }
84 .listStyle(.plain)
85 .overlay {
86 if viewModel.isLoading, viewModel.jobs.isEmpty {
87 SRHTLoadingStateView(message: "Loading builds…")
88 } else if let error = viewModel.error, viewModel.jobs.isEmpty {
89 SRHTErrorStateView(
90 title: "Couldn't Load Builds",
91 message: error,
92 retryAction: { await viewModel.loadJobs() }
93 )
94 } else if viewModel.jobs.isEmpty, viewModel.error == nil {
95 ContentUnavailableView(
96 "No Builds",
97 systemImage: "hammer",
98 description: Text("Your build jobs will appear here.")
99 )
100 }
101 }
102 .connectivityOverlay(hasContent: !viewModel.jobs.isEmpty) {
103 await viewModel.loadJobs()
104 }
105 .srhtErrorBanner(error: $vm.error)
106 .refreshable {
107 await viewModel.loadJobs()
108 }
109 }
110}
111
112private struct SubmitBuildSheet: View {
113 let viewModel: BuildListViewModel
114 let onSubmitted: (Int) -> Void
115
116 @Environment(\.dismiss) private var dismiss
117 @Bindable var viewModelBindable: BuildListViewModel
118 @State private var manifest = ""
119 @State private var tagsText = ""
120 @State private var note = ""
121 @State private var secrets = false
122 @State private var execute = true
123 @State private var visibility: Visibility = .public
124
125 init(viewModel: BuildListViewModel, onSubmitted: @escaping (Int) -> Void) {
126 self.viewModel = viewModel
127 self._viewModelBindable = Bindable(viewModel)
128 self.onSubmitted = onSubmitted
129 }
130
131 var body: some View {
132 NavigationStack {
133 Form {
134 Section("Build Manifest") {
135 TextField("Paste a build manifest", text: $manifest, axis: .vertical)
136 .font(.system(.body, design: .monospaced))
137 .lineLimit(12...24)
138 .textInputAutocapitalization(.never)
139 .autocorrectionDisabled()
140 }
141
142 Section("Build Options") {
143 TextField("Note (optional)", text: $note)
144 TextField("Tags (comma-separated, optional)", text: $tagsText)
145 .textInputAutocapitalization(.never)
146 .autocorrectionDisabled()
147 Picker("Visibility", selection: $visibility) {
148 Text("Public").tag(Visibility.public)
149 Text("Unlisted").tag(Visibility.unlisted)
150 Text("Private").tag(Visibility.private)
151 }
152 Toggle("Start build now", isOn: $execute)
153 Toggle("Allow build secrets", isOn: $secrets)
154 }
155
156 Section {
157 Text("You need a valid builds.sr.ht manifest and a token with BUILDS:RW.")
158 .font(.footnote)
159 .foregroundStyle(.secondary)
160 }
161
162 if let error = viewModel.error {
163 Section {
164 Label {
165 Text(error)
166 } icon: {
167 Image(systemName: "exclamationmark.triangle.fill")
168 .foregroundStyle(.red)
169 }
170 .foregroundStyle(.red)
171 }
172 }
173 }
174 .navigationTitle("Submit Build")
175 .navigationBarTitleDisplayMode(.inline)
176 .onDisappear {
177 viewModelBindable.error = nil
178 }
179 .toolbar {
180 ToolbarItem(placement: .cancellationAction) {
181 Button("Cancel") {
182 viewModelBindable.error = nil
183 dismiss()
184 }
185 }
186 ToolbarItem(placement: .confirmationAction) {
187 Button {
188 Task {
189 let tags = tagsText
190 .split(separator: ",")
191 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
192 .filter { !$0.isEmpty }
193 if let jobId = await viewModel.submitBuild(
194 manifest: manifest,
195 tags: tags,
196 note: note,
197 secrets: secrets,
198 execute: execute,
199 visibility: visibility
200 ) {
201 onSubmitted(jobId)
202 }
203 }
204 } label: {
205 if viewModel.isSubmitting {
206 ProgressView()
207 .controlSize(.small)
208 } else {
209 Text("Submit Build")
210 }
211 }
212 .disabled(manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
213 }
214 }
215 }
216 }
217}
Hutch/Views/Builds/BuildListViewModel.swift added +220
@@ -0,0 +1,220 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct JobsResponse: Decodable, Sendable {
6 let jobs: JobsPage
7}
8
9private struct JobsPage: Decodable, Sendable {
10 let results: [JobSummary]
11 let cursor: String?
12}
13
14private struct SubmitJobResponse: Decodable, Sendable {
15 let submit: SubmittedJob
16}
17
18private struct SubmittedJob: Decodable, Sendable {
19 let id: Int
20}
21
22// MARK: - View Model
23
24@Observable
25@MainActor
26final class BuildListViewModel {
27
28 private(set) var jobs: [JobSummary] = []
29 private(set) var isLoading = false
30 private(set) var isLoadingMore = false
31 private(set) var isRefreshing = false
32 private(set) var isSubmitting = false
33 var error: String?
34
35 private var cursor: String?
36 private var hasMore = true
37 private let client: SRHTClient
38
39 private static let cacheKey = "builds.jobs"
40
41 init(client: SRHTClient) {
42 self.client = client
43 }
44
45 // MARK: - Query
46
47 private static let query = """
48 query jobs($cursor: Cursor) {
49 jobs(cursor: $cursor) {
50 results {
51 id
52 created
53 updated
54 status
55 note
56 tags
57 visibility
58 image
59 tasks { name status }
60 }
61 cursor
62 }
63 }
64 """
65
66 private static let submitMutation = """
67 mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
68 submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
69 id
70 }
71 }
72 """
73
74 // MARK: - Public API
75
76 /// Fetch the first page of jobs. Shows cached data instantly if available,
77 /// then refreshes from the network in the background.
78 func loadJobs() async {
79 // Show cached data immediately on first load
80 if jobs.isEmpty {
81 loadFromCache()
82 }
83
84 if jobs.isEmpty {
85 isLoading = true
86 } else {
87 isRefreshing = true
88 }
89 error = nil
90 cursor = nil
91 hasMore = true
92
93 do {
94 let page = try await fetchPage(cursor: nil, useCache: true)
95 jobs = page.results
96 cursor = page.cursor
97 hasMore = page.cursor != nil
98 } catch {
99 if jobs.isEmpty {
100 self.error = error.localizedDescription
101 }
102 }
103
104 isLoading = false
105 isRefreshing = false
106 }
107
108 func loadMoreIfNeeded(currentItem: JobSummary) async {
109 guard let last = jobs.last,
110 last.id == currentItem.id,
111 hasMore,
112 !isLoadingMore else {
113 return
114 }
115
116 isLoadingMore = true
117
118 do {
119 let page = try await fetchPage(cursor: cursor, useCache: false)
120 jobs.append(contentsOf: page.results)
121 cursor = page.cursor
122 hasMore = page.cursor != nil
123 } catch {
124 self.error = error.localizedDescription
125 }
126
127 isLoadingMore = false
128 }
129
130 func submitBuild(
131 manifest: String,
132 tags: [String],
133 note: String,
134 secrets: Bool,
135 execute: Bool,
136 visibility: Visibility
137 ) async -> Int? {
138 guard !isSubmitting else { return nil }
139
140 let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
141 guard !trimmedManifest.isEmpty else {
142 error = "Paste a build manifest."
143 return nil
144 }
145
146 isSubmitting = true
147 error = nil
148 defer { isSubmitting = false }
149
150 var variables: [String: any Sendable] = [
151 "manifest": trimmedManifest,
152 "secrets": secrets,
153 "execute": execute,
154 "visibility": visibility.rawValue
155 ]
156 if !tags.isEmpty {
157 variables["tags"] = tags
158 }
159 let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
160 if !trimmedNote.isEmpty {
161 variables["note"] = trimmedNote
162 }
163
164 do {
165 let result = try await client.execute(
166 service: .builds,
167 query: Self.submitMutation,
168 variables: variables,
169 responseType: SubmitJobResponse.self
170 )
171 await loadJobs()
172 return result.submit.id
173 } catch {
174 self.error = "Couldn’t submit the build. \(error.localizedDescription)"
175 return nil
176 }
177 }
178
179 // MARK: - Private
180
181 private func fetchPage(cursor: String?, useCache: Bool) async throws -> JobsPage {
182 var variables: [String: any Sendable] = [:]
183 if let cursor {
184 variables["cursor"] = cursor
185 }
186
187 if useCache && cursor == nil {
188 let result = try await client.executeAndCache(
189 service: .builds,
190 query: Self.query,
191 variables: variables.isEmpty ? nil : variables,
192 responseType: JobsResponse.self,
193 cacheKey: Self.cacheKey
194 )
195 return result.jobs
196 } else {
197 let result = try await client.execute(
198 service: .builds,
199 query: Self.query,
200 variables: variables.isEmpty ? nil : variables,
201 responseType: JobsResponse.self
202 )
203 return result.jobs
204 }
205 }
206
207 private func loadFromCache() {
208 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return }
209 let decoder = JSONDecoder()
210 decoder.dateDecodingStrategy = .srhtFlexible
211 if let response = try? decoder.decode(
212 GraphQLResponse<JobsResponse>.self,
213 from: data
214 ), let page = response.data?.jobs {
215 jobs = page.results
216 cursor = page.cursor
217 hasMore = page.cursor != nil
218 }
219 }
220}
Hutch/Views/Builds/BuildRowView.swift added +106
@@ -0,0 +1,106 @@
1import SwiftUI
2
3struct BuildRowView: View {
4 let job: JobSummary
5
6 var body: some View {
7 HStack(spacing: 12) {
8 JobStatusIcon(status: job.status)
9 .frame(width: 28)
10
11 VStack(alignment: .leading, spacing: 4) {
12 Text(job.displayLabel)
13 .font(.subheadline)
14 .lineLimit(1)
15
16 HStack(spacing: 8) {
17 if let image = job.image {
18 Text(image)
19 .font(.caption)
20 .foregroundStyle(.secondary)
21 }
22
23 Spacer()
24
25 Text(job.created.relativeDescription)
26 .font(.caption)
27 .foregroundStyle(.tertiary)
28 }
29
30 if !job.tasks.isEmpty {
31 TaskProgressView(tasks: job.tasks)
32 }
33 }
34 }
35 .padding(.vertical, 2)
36 }
37}
38
39// MARK: - Job Status Icon
40
41struct JobStatusIcon: View {
42 let status: JobStatus
43
44 var body: some View {
45 Image(systemName: iconName)
46 .foregroundStyle(color)
47 .symbolEffect(.pulse, isActive: status == .running)
48 }
49
50 private var iconName: String {
51 switch status {
52 case .success: "checkmark.circle.fill"
53 case .failed, .timeout: "xmark.circle.fill"
54 case .running: "arrow.trianglehead.2.clockwise.rotate.90"
55 case .queued: "clock.fill"
56 case .pending: "circle.dashed"
57 case .cancelled: "minus.circle.fill"
58 }
59 }
60
61 private var color: Color {
62 switch status {
63 case .success: .green
64 case .failed, .timeout: .red
65 case .running: .yellow
66 case .queued: .orange
67 case .pending, .cancelled: .gray
68 }
69 }
70}
71
72// MARK: - Task Progress
73
74struct TaskProgressView: View {
75 let tasks: [JobTaskSummary]
76
77 var body: some View {
78 HStack(spacing: 6) {
79 ProgressView(value: progress, total: 1.0)
80 .tint(progressColor)
81 .frame(maxWidth: 80)
82
83 Text("\(completedCount)/\(tasks.count) tasks")
84 .font(.caption2)
85 .foregroundStyle(.secondary)
86 }
87 }
88
89 private var completedCount: Int {
90 tasks.filter { $0.status == .success }.count
91 }
92
93 private var progress: Double {
94 tasks.isEmpty ? 0 : Double(completedCount) / Double(tasks.count)
95 }
96
97 private var progressColor: Color {
98 if tasks.contains(where: { $0.status == .failed }) {
99 return .red
100 }
101 if completedCount == tasks.count {
102 return .green
103 }
104 return .blue
105 }
106}
Hutch/Views/Repositories/ArtifactsView.swift added +73
@@ -0,0 +1,73 @@
1import SwiftUI
2
3struct ArtifactsView: View {
4 let viewModel: RepositoryDetailViewModel
5 @Environment(\.openURL) private var openURL
6
7 var body: some View {
8 List {
9 ForEach(viewModel.referenceArtifacts) { refArtifacts in
10 Section(refArtifacts.name) {
11 ForEach(refArtifacts.artifacts) { artifact in
12 ArtifactRow(artifact: artifact) {
13 openURL(artifact.url)
14 }
15 }
16 }
17 }
18 }
19 .listStyle(.insetGrouped)
20 .overlay {
21 if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
22 SRHTLoadingStateView(message: "Loading artifacts…")
23 } else if let error = viewModel.error, viewModel.referenceArtifacts.isEmpty {
24 SRHTErrorStateView(
25 title: "Couldn't Load Artifacts",
26 message: error,
27 retryAction: { await viewModel.loadArtifacts() }
28 )
29 } else if viewModel.referenceArtifacts.isEmpty {
30 ContentUnavailableView(
31 "No Artifacts",
32 systemImage: "archivebox",
33 description: Text("This repository has no release artifacts.")
34 )
35 }
36 }
37 .task {
38 if viewModel.referenceArtifacts.isEmpty {
39 await viewModel.loadArtifacts()
40 }
41 }
42 .refreshable {
43 await viewModel.loadArtifacts()
44 }
45 }
46}
47
48private struct ArtifactRow: View {
49 let artifact: ArtifactInfo
50 let onDownload: () -> Void
51
52 var body: some View {
53 HStack {
54 VStack(alignment: .leading, spacing: 4) {
55 Text(artifact.filename)
56 .font(.subheadline)
57
58 Text(artifact.size.formattedByteCount)
59 .font(.caption)
60 .foregroundStyle(.secondary)
61 }
62
63 Spacer()
64
65 Button {
66 onDownload()
67 } label: {
68 Image(systemName: "arrow.down.circle")
69 .imageScale(.large)
70 }
71 }
72 }
73}
Hutch/Views/Repositories/CommitDetailView.swift added +332
@@ -0,0 +1,332 @@
1import SwiftUI
2
3struct CommitDetailView: View {
4 let commitSummary: CommitSummary
5 let repository: RepositorySummary
6
7 @Environment(AppState.self) private var appState
8 @State private var viewModel: CommitDetailViewModel?
9
10 var body: some View {
11 Group {
12 if let viewModel {
13 commitContent(viewModel)
14 } else {
15 ProgressView()
16 }
17 }
18 .navigationTitle(commitSummary.shortId)
19 .navigationBarTitleDisplayMode(.inline)
20 .toolbar {
21 ToolbarItem(placement: .topBarTrailing) {
22 SRHTShareButton(url: SRHTWebURL.commit(repository: repository, commitId: commitSummary.id), target: .commit) {
23 Image(systemName: "square.and.arrow.up")
24 }
25 }
26 }
27 .task {
28 if viewModel == nil {
29 let vm = CommitDetailViewModel(
30 repositoryRid: repository.rid,
31 service: repository.service,
32 commitId: commitSummary.id,
33 client: appState.client
34 )
35 viewModel = vm
36 await vm.loadCommit()
37 }
38 }
39 }
40
41 @ViewBuilder
42 private func commitContent(_ viewModel: CommitDetailViewModel) -> some View {
43 if viewModel.isLoading {
44 ProgressView()
45 } else if let error = viewModel.error {
46 ContentUnavailableView {
47 Label("Error", systemImage: "exclamationmark.triangle")
48 } description: {
49 Text(error)
50 } actions: {
51 Button("Retry") {
52 Task { await viewModel.loadCommit() }
53 }
54 }
55 } else if let commit = viewModel.commit {
56 ScrollView {
57 LazyVStack(alignment: .leading, spacing: 0) {
58 // Header
59 commitHeader(commit)
60
61 sectionDivider
62
63 // Message
64 commitMessage(commit)
65
66 // Trailers
67 if !commit.trailers.isEmpty {
68 sectionDivider
69 trailersSection(commit.trailers)
70 }
71
72 // Parents
73 if !commit.parents.isEmpty {
74 sectionDivider
75 parentsSection(commit.parents)
76 }
77
78 // Diff
79 if let diff = commit.diff, !diff.isEmpty {
80 sectionDivider
81 diffSection(diff)
82 }
83
84 // Tree
85 if let tree = commit.tree, !tree.entries.results.isEmpty {
86 sectionDivider
87 treeSection(tree.entries.results)
88 }
89 }
90 }
91 .navigationDestination(for: ParentCommit.self) { parent in
92 CommitDetailView(
93 commitSummary: CommitSummary(
94 id: parent.id,
95 shortId: parent.shortId,
96 author: CommitAuthor(name: parent.author.name, email: nil, time: .now),
97 message: ""
98 ),
99 repository: repository
100 )
101 }
102 }
103 }
104
105 // MARK: - Header
106
107 @ViewBuilder
108 private func commitHeader(_ commit: CommitDetail) -> some View {
109 VStack(alignment: .leading, spacing: 8) {
110 // Full hash — tappable to copy
111 Button {
112 UIPasteboard.general.string = commit.id
113 } label: {
114 HStack(spacing: 4) {
115 Text(commit.id)
116 .font(.caption.monospaced())
117 .lineLimit(1)
118 .truncationMode(.middle)
119 Image(systemName: "doc.on.doc")
120 .font(.caption2)
121 }
122 .foregroundStyle(.secondary)
123 }
124
125 // Author
126 HStack {
127 Label(commit.author.name, systemImage: "person")
128 Spacer()
129 Text(commit.author.time.relativeDescription)
130 .foregroundStyle(.secondary)
131 }
132 .font(.subheadline)
133
134 // Committer (if different from author)
135 if commit.committer.name != commit.author.name
136 || commit.committer.email != commit.author.email {
137 HStack {
138 Label(commit.committer.name, systemImage: "person.badge.shield.checkmark")
139 Spacer()
140 Text(commit.committer.time.relativeDescription)
141 .foregroundStyle(.secondary)
142 }
143 .font(.subheadline)
144 .foregroundStyle(.secondary)
145 }
146 }
147 .padding()
148 }
149
150 // MARK: - Message
151
152 @ViewBuilder
153 private func commitMessage(_ commit: CommitDetail) -> some View {
154 VStack(alignment: .leading, spacing: 8) {
155 Text("Message")
156 .font(.caption.weight(.semibold))
157 .foregroundStyle(.secondary)
158 .textCase(.uppercase)
159
160 Text(commit.title)
161 .font(.headline)
162
163 if let body = commit.body {
164 Text(body)
165 .font(.subheadline.monospaced())
166 .foregroundStyle(.secondary)
167 }
168 }
169 .padding()
170 .frame(maxWidth: .infinity, alignment: .leading)
171 }
172
173 // MARK: - Trailers
174
175 @ViewBuilder
176 private func trailersSection(_ trailers: [CommitTrailer]) -> some View {
177 VStack(alignment: .leading, spacing: 8) {
178 Text("Trailers")
179 .font(.caption.weight(.semibold))
180 .foregroundStyle(.secondary)
181 .textCase(.uppercase)
182
183 ForEach(trailers) { trailer in
184 HStack(alignment: .top, spacing: 4) {
185 Text("\(trailer.name):")
186 .font(.subheadline.monospaced().weight(.medium))
187 Text(trailer.value)
188 .font(.subheadline.monospaced())
189 .foregroundStyle(.secondary)
190 }
191 }
192 }
193 .padding()
194 .frame(maxWidth: .infinity, alignment: .leading)
195 }
196
197 // MARK: - Parents
198
199 @ViewBuilder
200 private func parentsSection(_ parents: [ParentCommit]) -> some View {
201 VStack(alignment: .leading, spacing: 8) {
202 Text("Parents")
203 .font(.caption.weight(.semibold))
204 .foregroundStyle(.secondary)
205 .textCase(.uppercase)
206
207 ForEach(parents) { parent in
208 NavigationLink(value: parent) {
209 HStack {
210 Text(parent.shortId)
211 .font(.subheadline.monospaced())
212 Text(parent.author.name)
213 .font(.subheadline)
214 .foregroundStyle(.secondary)
215 Spacer()
216 Image(systemName: "chevron.right")
217 .font(.caption)
218 .foregroundStyle(.tertiary)
219 }
220 }
221 .buttonStyle(.plain)
222 }
223 }
224 .padding()
225 .frame(maxWidth: .infinity, alignment: .leading)
226 }
227
228 // MARK: - Diff
229
230 @ViewBuilder
231 private func diffSection(_ diff: String) -> some View {
232 VStack(alignment: .leading, spacing: 8) {
233 Text("Diff")
234 .font(.caption.weight(.semibold))
235 .foregroundStyle(.secondary)
236 .textCase(.uppercase)
237 .padding(.horizontal)
238 .padding(.top)
239
240 DiffView(diff: invertDiff(diff))
241 .padding(.bottom)
242 }
243 }
244
245 /// The sr.ht API returns diffs comparing current→parent (inverted).
246 /// This swaps +/- prefixes so the diff reads as parent→current.
247 private func invertDiff(_ diff: String) -> String {
248 diff.split(separator: "\n", omittingEmptySubsequences: false)
249 .map { line in
250 let s = String(line)
251 if s.hasPrefix("@@") || s.hasPrefix("diff ") || s.hasPrefix("index ") {
252 return s
253 }
254 if s.hasPrefix("---") {
255 return "+++" + s.dropFirst(3)
256 }
257 if s.hasPrefix("+++") {
258 return "---" + s.dropFirst(3)
259 }
260 if s.hasPrefix("+") {
261 return "-" + s.dropFirst(1)
262 }
263 if s.hasPrefix("-") {
264 return "+" + s.dropFirst(1)
265 }
266 return s
267 }
268 .joined(separator: "\n")
269 }
270
271 // MARK: - Tree
272
273 @ViewBuilder
274 private func treeSection(_ entries: [CommitTreeEntry]) -> some View {
275 VStack(alignment: .leading, spacing: 8) {
276 Text("Tree")
277 .font(.caption.weight(.semibold))
278 .foregroundStyle(.secondary)
279 .textCase(.uppercase)
280
281 ForEach(entries) { entry in
282 HStack(spacing: 8) {
283 Image(systemName: treeEntryIcon(for: entry))
284 .foregroundStyle(treeEntryColor(for: entry))
285 .frame(width: 20)
286 Text(entry.name)
287 .font(.subheadline.monospaced())
288 Spacer()
289 if let obj = entry.object, let shortId = obj.shortId {
290 Text(shortId)
291 .font(.caption.monospaced())
292 .foregroundStyle(.tertiary)
293 }
294 }
295 }
296 }
297 .padding()
298 .frame(maxWidth: .infinity, alignment: .leading)
299 }
300
301 // MARK: - Helpers
302
303 private var sectionDivider: some View {
304 Divider().padding(.horizontal)
305 }
306
307 private func treeEntryIcon(for entry: CommitTreeEntry) -> String {
308 guard let type = entry.object?.type else {
309 return "doc"
310 }
311 switch type {
312 case "tree": return "folder"
313 case "blob": return "doc.text"
314 case "tag": return "tag"
315 case "commit": return "arrow.triangle.branch"
316 default: return "doc"
317 }
318 }
319
320 private func treeEntryColor(for entry: CommitTreeEntry) -> Color {
321 guard let type = entry.object?.type else {
322 return .secondary
323 }
324 switch type {
325 case "tree": return .blue
326 case "blob": return .secondary
327 case "tag": return .orange
328 case "commit": return .purple
329 default: return .secondary
330 }
331 }
332}
Hutch/Views/Repositories/CommitDetailViewModel.swift added +102
@@ -0,0 +1,102 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct CommitResponse: Decodable, Sendable {
6 let repository: CommitRepository?
7}
8
9private struct CommitRepository: Decodable, Sendable {
10 // swiftlint:disable:next identifier_name
11 let revparse_single: CommitDetail
12}
13
14// MARK: - View Model
15
16@Observable
17@MainActor
18final class CommitDetailViewModel {
19
20 let repositoryRid: String
21 let service: SRHTService
22 private let client: SRHTClient
23
24 private(set) var commit: CommitDetail?
25 private(set) var isLoading = false
26 var error: String?
27
28 init(repositoryRid: String, service: SRHTService, commitId: String, client: SRHTClient) {
29 self.repositoryRid = repositoryRid
30 self.service = service
31 self.commitId = commitId
32 self.client = client
33 }
34
35 private let commitId: String
36
37 // MARK: - Query
38
39 private static let query = """
40 query commit($rid: ID!, $id: String!) {
41 repository(rid: $rid) {
42 revparse_single(revspec: $id) {
43 id
44 shortId
45 author { name email time }
46 committer { name email time }
47 message
48 diff
49 trailers { name value }
50 parents { id shortId author { name } }
51 tree {
52 entries {
53 results { id name mode object { type id shortId } }
54 cursor
55 }
56 }
57 }
58 }
59 }
60 """
61
62 func loadCommit() async {
63 guard !isLoading else { return }
64 isLoading = true
65 error = nil
66
67 do {
68 let result = try await executeWithRetry()
69 commit = result.repository?.revparse_single
70 } catch {
71 self.error = error.localizedDescription
72 }
73
74 isLoading = false
75 }
76
77 /// Execute the commit query, retrying once after a 1-second delay on 502/503.
78 private func executeWithRetry() async throws -> CommitResponse {
79 do {
80 return try await client.execute(
81 service: service,
82 query: Self.query,
83 variables: [
84 "rid": repositoryRid,
85 "id": commitId
86 ],
87 responseType: CommitResponse.self
88 )
89 } catch let SRHTError.httpError(code) where code == 502 || code == 503 {
90 try await Task.sleep(for: .seconds(1))
91 return try await client.execute(
92 service: service,
93 query: Self.query,
94 variables: [
95 "rid": repositoryRid,
96 "id": commitId
97 ],
98 responseType: CommitResponse.self
99 )
100 }
101 }
102}
Hutch/Views/Repositories/CommitLogView.swift added +59
@@ -0,0 +1,59 @@
1import SwiftUI
2
3struct CommitLogView: View {
4 let viewModel: RepositoryDetailViewModel
5
6 var body: some View {
7 List {
8 ForEach(viewModel.commits) { commit in
9 NavigationLink(value: commit) {
10 CommitRowView(commit: commit)
11 }
12 .task {
13 await viewModel.loadMoreCommitsIfNeeded(currentItem: commit)
14 }
15 }
16
17 if viewModel.isLoadingMoreCommits {
18 HStack {
19 Spacer()
20 ProgressView()
21 Spacer()
22 }
23 .listRowSeparator(.hidden)
24 }
25 }
26 .listStyle(.plain)
27 .overlay {
28 if viewModel.isLoadingCommits, viewModel.commits.isEmpty {
29 SRHTLoadingStateView(message: "Loading commits…")
30 } else if let error = viewModel.error, viewModel.commits.isEmpty {
31 SRHTErrorStateView(
32 title: "Couldn't Load Commits",
33 message: error,
34 retryAction: { await viewModel.loadCommits() }
35 )
36 } else if viewModel.commits.isEmpty {
37 ContentUnavailableView(
38 "No Commits",
39 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
40 description: Text("This repository has no commit history.")
41 )
42 }
43 }
44 .task {
45 if viewModel.commits.isEmpty {
46 await viewModel.loadCommits()
47 }
48 }
49 .refreshable {
50 await viewModel.loadCommits()
51 }
52 .navigationDestination(for: CommitSummary.self) { commit in
53 CommitDetailView(
54 commitSummary: commit,
55 repository: viewModel.repository
56 )
57 }
58 }
59}
Hutch/Views/Repositories/CommitRowView.swift added +30
@@ -0,0 +1,30 @@
1import SwiftUI
2
3struct CommitRowView: View {
4 let commit: CommitSummary
5
6 var body: some View {
7 VStack(alignment: .leading, spacing: 4) {
8 Text(commit.title)
9 .font(.subheadline)
10 .lineLimit(1)
11
12 HStack(spacing: 8) {
13 Text(commit.shortId)
14 .font(.caption.monospaced())
15 .foregroundStyle(.secondary)
16
17 Text(commit.author.name)
18 .font(.caption)
19 .foregroundStyle(.secondary)
20
21 Spacer()
22
23 Text(commit.author.time.relativeDescription)
24 .font(.caption)
25 .foregroundStyle(.tertiary)
26 }
27 }
28 .padding(.vertical, 2)
29 }
30}
Hutch/Views/Repositories/DiffView.swift added +79
@@ -0,0 +1,79 @@
1import SwiftUI
2
3/// Renders a unified diff string with syntax highlighting:
4/// - Green background for added lines (+)
5/// - Red background for removed lines (-)
6/// - Gray for hunk headers (@@)
7/// - File headers (--- / +++ / diff) in bold
8struct DiffView: View {
9 let diff: String
10
11 var body: some View {
12 let lines = diff.components(separatedBy: "\n")
13
14 LazyVStack(alignment: .leading, spacing: 0) {
15 ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
16 DiffLineView(line: line)
17 }
18 }
19 .font(.caption.monospaced())
20 }
21}
22
23private struct DiffLineView: View {
24 let line: String
25
26 var body: some View {
27 Text(line.isEmpty ? " " : line)
28 .frame(maxWidth: .infinity, alignment: .leading)
29 .padding(.horizontal, 8)
30 .padding(.vertical, 1)
31 .background(backgroundColor)
32 .foregroundStyle(foregroundColor)
33 .fontWeight(isHeader ? .semibold : .regular)
34 }
35
36 private var kind: DiffLineKind {
37 if line.hasPrefix("@@") { return .hunk }
38 if line.hasPrefix("+++") || line.hasPrefix("---") { return .fileHeader }
39 if line.hasPrefix("diff ") { return .fileHeader }
40 if line.hasPrefix("index ") { return .meta }
41 if line.hasPrefix("+") { return .added }
42 if line.hasPrefix("-") { return .removed }
43 return .context
44 }
45
46 private var backgroundColor: Color {
47 switch kind {
48 case .added: .green.opacity(0.15)
49 case .removed: .red.opacity(0.15)
50 case .hunk: .gray.opacity(0.12)
51 case .fileHeader: .gray.opacity(0.08)
52 case .meta: .gray.opacity(0.05)
53 case .context: .clear
54 }
55 }
56
57 private var foregroundColor: Color {
58 switch kind {
59 case .added: .green
60 case .removed: .red
61 case .hunk: .secondary
62 case .meta: .secondary
63 default: .primary
64 }
65 }
66
67 private var isHeader: Bool {
68 kind == .fileHeader
69 }
70}
71
72private enum DiffLineKind {
73 case added
74 case removed
75 case hunk
76 case fileHeader
77 case meta
78 case context
79}
Hutch/Views/Repositories/FileTreeView.swift added +446
@@ -0,0 +1,446 @@
1import SwiftUI
2
3struct FileTreeView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6
7 @State private var viewModel: FileTreeViewModel?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 FileTreeContentView(repository: repository, viewModel: viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading files…")
15 }
16 }
17 .task {
18 if viewModel == nil {
19 let vm = FileTreeViewModel(
20 repositoryRid: repository.rid,
21 service: repository.service,
22 client: client
23 )
24 viewModel = vm
25 async let loadTree: () = vm.loadRootTree()
26 async let loadRefs: () = vm.loadReferences()
27 _ = await (loadTree, loadRefs)
28 }
29 }
30 }
31}
32
33// MARK: - Content View
34
35private struct FileTreeContentView: View {
36 let repository: RepositorySummary
37 let viewModel: FileTreeViewModel
38
39 @State private var showRefPicker = false
40
41 var body: some View {
42 VStack(spacing: 0) {
43 breadcrumbBar
44 Divider()
45 contentArea
46 }
47 .toolbar {
48 ToolbarItem(placement: .topBarTrailing) {
49 Button {
50 showRefPicker = true
51 } label: {
52 Label(
53 revspecLabel,
54 systemImage: "arrow.triangle.branch"
55 )
56 .font(.subheadline)
57 }
58 }
59 }
60 .sheet(isPresented: $showRefPicker) {
61 RefPickerSheet(viewModel: viewModel, isPresented: $showRefPicker)
62 }
63 .srhtErrorBanner(error: Binding(
64 get: { viewModel.error },
65 set: { viewModel.error = $0 }
66 ))
67 .refreshable {
68 await viewModel.loadRootTree()
69 }
70 }
71
72 private var shareURL: URL? {
73 guard let viewingEntry = viewModel.viewingEntry else { return nil }
74 return SRHTWebURL.file(
75 repository: repository,
76 revspec: viewModel.revspec,
77 path: currentFilePath(for: viewingEntry)
78 )
79 }
80
81 private var revspecLabel: String {
82 let revspec = viewModel.revspec
83 if revspec == "HEAD" {
84 return "HEAD"
85 }
86 if revspec.hasPrefix("refs/heads/") {
87 return String(revspec.dropFirst("refs/heads/".count))
88 } else if revspec.hasPrefix("refs/tags/") {
89 return String(revspec.dropFirst("refs/tags/".count))
90 }
91 return revspec
92 }
93
94 // MARK: - Breadcrumb Bar
95
96 private var breadcrumbBar: some View {
97 ScrollView(.horizontal, showsIndicators: false) {
98 HStack(spacing: 12) {
99 HStack(spacing: 4) {
100 ForEach(Array(viewModel.navStack.enumerated()), id: \.offset) { index, navEntry in
101 if index > 0 {
102 Image(systemName: "chevron.right")
103 .font(.caption2)
104 .foregroundStyle(.tertiary)
105 }
106
107 Button {
108 Task {
109 await viewModel.navigateToBreadcrumb(at: index)
110 }
111 } label: {
112 Text(navEntry.name)
113 .font(.subheadline.monospaced())
114 .foregroundStyle(
115 index == viewModel.navStack.count - 1 && viewModel.viewingEntry == nil
116 ? .primary : .secondary
117 )
118 }
119 .buttonStyle(.plain)
120 }
121
122 if let viewing = viewModel.viewingEntry {
123 Image(systemName: "chevron.right")
124 .font(.caption2)
125 .foregroundStyle(.tertiary)
126
127 Text(viewing.name)
128 .font(.subheadline.monospaced())
129 .foregroundStyle(.primary)
130 }
131 }
132
133 Spacer(minLength: 0)
134 }
135 .padding(.horizontal)
136 .padding(.vertical, 8)
137 }
138 .background(.bar)
139 }
140
141 private func currentFilePath(for entry: TreeEntry) -> String {
142 let directoryComponents = viewModel.navStack
143 .dropFirst()
144 .map(\.name)
145 return (directoryComponents + [entry.name]).joined(separator: "/")
146 }
147
148 // MARK: - Content Area
149
150 @ViewBuilder
151 private var contentArea: some View {
152 if viewModel.isLoading, viewModel.entries.isEmpty, viewModel.viewingEntry == nil {
153 SRHTLoadingStateView(message: "Loading files…")
154 } else if let entry = viewModel.viewingEntry, let object = viewModel.viewingObject {
155 // Viewing a file
156 fileContentView(entry: entry, object: object)
157 } else if let error = viewModel.error, viewModel.entries.isEmpty {
158 SRHTErrorStateView(
159 title: "Couldn't Load Files",
160 message: error,
161 retryAction: { await viewModel.loadRootTree() }
162 )
163 } else if !viewModel.entries.isEmpty {
164 // Viewing a directory listing
165 treeListView
166 } else if viewModel.navStack.isEmpty {
167 ContentUnavailableView(
168 "No Files",
169 systemImage: "folder",
170 description: Text("This repository could not be loaded.")
171 )
172 } else {
173 ContentUnavailableView(
174 "Empty Directory",
175 systemImage: "folder",
176 description: Text("This directory has no files.")
177 )
178 }
179 }
180
181 // MARK: - File Content View
182
183 @ViewBuilder
184 private func fileContentView(entry: TreeEntry, object: GitObject) -> some View {
185 switch object {
186 case .textBlob(let blob):
187 textBlobView(entry: entry, blob: blob)
188 case .binaryBlob(let blob):
189 binaryBlobView(entry: entry, blob: blob)
190 default:
191 ContentUnavailableView(
192 "Unknown Object",
193 systemImage: "questionmark.folder",
194 description: Text("Cannot display this object type.")
195 )
196 }
197 }
198
199 // MARK: - Tree List
200
201 private var treeListView: some View {
202 let sorted = viewModel.entries.sorted { a, b in
203 let aIsTree = a.object?.isTree == true
204 let bIsTree = b.object?.isTree == true
205 if aIsTree != bIsTree { return aIsTree }
206 return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
207 }
208
209 return List(sorted) { entry in
210 TreeEntryRow(entry: entry)
211 .contentShape(Rectangle())
212 .onTapGesture {
213 Task {
214 await viewModel.navigateInto(entry: entry)
215 }
216 }
217 }
218 .listStyle(.plain)
219 }
220
221 // MARK: - Text Blob
222
223 @ViewBuilder
224 private func textBlobView(entry: TreeEntry, blob: GitTextBlob) -> some View {
225 VStack(spacing: 0) {
226 HStack {
227 Spacer()
228 SRHTShareButton(url: shareURL, target: .file) {
229 Label("Share File", systemImage: "square.and.arrow.up")
230 }
231 .buttonStyle(.bordered)
232 }
233 .padding(.horizontal)
234 .padding(.top, 12)
235
236 GeometryReader { geometry in
237 ScrollView([.vertical, .horizontal]) {
238 Text(blob.text)
239 .font(.system(.body, design: .monospaced))
240 .multilineTextAlignment(.leading)
241 .fixedSize(horizontal: true, vertical: false)
242 .frame(minWidth: geometry.size.width,
243 minHeight: geometry.size.height,
244 alignment: .topLeading)
245 .padding()
246 }
247 .frame(width: geometry.size.width, height: geometry.size.height)
248 }
249 }
250 }
251
252 // MARK: - Binary Blob
253
254 @ViewBuilder
255 private func binaryBlobView(entry: TreeEntry, blob: GitBinaryBlob) -> some View {
256 VStack(spacing: 16) {
257 Spacer()
258
259 Image(systemName: "doc.zipper")
260 .font(.system(size: 48))
261 .foregroundStyle(.secondary)
262
263 Text(entry.name)
264 .font(.headline)
265
266 if let size = blob.size {
267 Text(formatBytes(size))
268 .font(.subheadline)
269 .foregroundStyle(.secondary)
270 }
271
272 Text("Binary file — cannot be displayed inline.")
273 .font(.subheadline)
274 .foregroundStyle(.tertiary)
275
276 SRHTShareButton(url: shareURL, target: .file) {
277 Label("Share File", systemImage: "square.and.arrow.up")
278 }
279 .buttonStyle(.bordered)
280
281 if let content = blob.content, let url = URL(string: content) {
282 Link(destination: url) {
283 Label("Open in Safari", systemImage: "safari")
284 }
285 .buttonStyle(.borderedProminent)
286 }
287
288 Button {
289 viewModel.dismissFileView()
290 } label: {
291 Text("Back to directory")
292 }
293
294 Spacer()
295 }
296 .frame(maxWidth: .infinity)
297 }
298
299 // MARK: - Helpers
300
301 private func formatBytes(_ bytes: Int) -> String {
302 let formatter = ByteCountFormatter()
303 formatter.countStyle = .file
304 return formatter.string(fromByteCount: Int64(bytes))
305 }
306}
307
308// MARK: - Tree Entry Row
309
310private struct TreeEntryRow: View {
311 let entry: TreeEntry
312
313 var body: some View {
314 Label {
315 Text(entry.name)
316 .font(.body.monospaced())
317 .lineLimit(1)
318 } icon: {
319 Image(systemName: iconName)
320 .foregroundStyle(iconColor)
321 }
322 }
323
324 private var iconName: String {
325 switch entry.object {
326 case .tree: "folder.fill"
327 case .unknown: "questionmark.circle"
328 default: "doc"
329 }
330 }
331
332 private var iconColor: Color {
333 switch entry.object {
334 case .tree: .blue
335 case .unknown: .orange
336 default: .secondary
337 }
338 }
339}
340
341// MARK: - Ref Picker Sheet
342
343private struct RefPickerSheet: View {
344 let viewModel: FileTreeViewModel
345 @Binding var isPresented: Bool
346
347 var body: some View {
348 NavigationStack {
349 List {
350 Section {
351 Button {
352 Task {
353 await viewModel.changeRevspec("HEAD")
354 isPresented = false
355 }
356 } label: {
357 refRow(
358 title: "HEAD",
359 systemImage: "arrow.triangle.branch",
360 color: .blue,
361 isSelected: viewModel.revspec == "HEAD"
362 )
363 }
364 .buttonStyle(.plain)
365 }
366
367 if !viewModel.branches.isEmpty {
368 Section("Branches") {
369 ForEach(viewModel.branches, id: \.name) { ref in
370 Button {
371 Task {
372 await viewModel.changeRevspec(ref.name)
373 isPresented = false
374 }
375 } label: {
376 refRow(
377 title: ref.name.replacingOccurrences(of: "refs/heads/", with: ""),
378 systemImage: "arrow.triangle.branch",
379 color: .blue,
380 isSelected: viewModel.revspec == ref.name
381 )
382 }
383 .buttonStyle(.plain)
384 }
385 }
386 }
387
388 if !viewModel.tags.isEmpty {
389 Section("Tags") {
390 ForEach(viewModel.tags, id: \.name) { ref in
391 Button {
392 Task {
393 await viewModel.changeRevspec(ref.name)
394 isPresented = false
395 }
396 } label: {
397 refRow(
398 title: ref.name.replacingOccurrences(of: "refs/tags/", with: ""),
399 systemImage: "tag",
400 color: .orange,
401 isSelected: viewModel.revspec == ref.name
402 )
403 }
404 .buttonStyle(.plain)
405 }
406 }
407 }
408 }
409 .listStyle(.insetGrouped)
410 .navigationTitle("Select Ref")
411 .navigationBarTitleDisplayMode(.inline)
412 .toolbar {
413 ToolbarItem(placement: .cancellationAction) {
414 Button("Cancel") {
415 isPresented = false
416 }
417 }
418 }
419 .overlay {
420 if viewModel.isLoadingRefs {
421 SRHTLoadingStateView(message: "Loading references…")
422 }
423 }
424 }
425 }
426
427 private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
428 HStack(spacing: 12) {
429 Image(systemName: systemImage)
430 .foregroundStyle(color)
431
432 Text(title)
433 .font(.body.monospaced())
434 .foregroundStyle(.primary)
435
436 Spacer()
437
438 if isSelected {
439 Image(systemName: "checkmark")
440 .font(.caption.weight(.semibold))
441 .foregroundStyle(.tint)
442 }
443 }
444 .contentShape(Rectangle())
445 }
446}
Hutch/Views/Repositories/FileTreeViewModel.swift added +524
@@ -0,0 +1,524 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct RevparseResponse: Decodable, Sendable {
6 let repository: RevparseRepository?
7}
8
9private struct RevparseRepository: Decodable, Sendable {
10 let revparse_single: RevparseCommit?
11}
12
13private struct RevparseCommit: Decodable, Sendable {
14 let tree: GitTree?
15}
16
17private struct SubtreeResponse: Decodable, Sendable {
18 let repository: SubtreeRepository?
19}
20
21private struct SubtreeRepository: Decodable, Sendable {
22 let object: SubtreeObject?
23}
24
25/// Dedicated decoding struct for the subtree query response.
26/// Does not use GitObject enum — decodes entries directly from the Tree inline fragment.
27private struct SubtreeObject: Decodable, Sendable {
28 let entries: GitTreeEntryPage?
29}
30
31private struct BlobResponse: Decodable, Sendable {
32 let repository: BlobRepository?
33}
34
35private struct BlobRepository: Decodable, Sendable {
36 let object: GitObject?
37}
38
39// MARK: - Navigation Stack Entry
40
41struct FileNavEntry: Hashable {
42 let name: String
43 let treeId: String
44}
45
46// MARK: - View Model
47
48@Observable
49@MainActor
50final class FileTreeViewModel {
51
52 let repositoryRid: String
53 let service: SRHTService
54 private let client: SRHTClient
55
56 /// The current revspec. "HEAD" by default, or a full ref name.
57 var revspec: String = "HEAD"
58
59 /// Navigation stack: each entry is a (name, treeId) pair.
60 /// The first entry is always the root.
61 private(set) var navStack: [FileNavEntry] = []
62
63 /// The tree entries at the current directory level.
64 private(set) var entries: [TreeEntry] = []
65
66 /// When viewing a file (text blob or binary blob), this holds the object.
67 private(set) var viewingEntry: TreeEntry?
68 private(set) var viewingObject: GitObject?
69
70 private(set) var isLoading = false
71 var error: String?
72
73 // Available references for the branch/tag picker
74 private(set) var branches: [Reference] = []
75 private(set) var tags: [Reference] = []
76 private(set) var isLoadingRefs = false
77
78 init(repositoryRid: String, service: SRHTService, client: SRHTClient) {
79 self.repositoryRid = repositoryRid
80 self.service = service
81 self.client = client
82 }
83
84 // MARK: - Queries
85
86 private static let rootTreeQuery = """
87 query files($rid: ID!, $revspec: String!) {
88 repository(rid: $rid) {
89 revparse_single(revspec: $revspec) {
90 tree {
91 id
92 entries {
93 results {
94 id
95 name
96 mode
97 object {
98 type
99 id
100 shortId
101 ... on Tree {
102 entries {
103 results {
104 id
105 name
106 mode
107 object { type id shortId }
108 }
109 cursor
110 }
111 }
112 ... on TextBlob {
113 text
114 size
115 }
116 ... on BinaryBlob {
117 size
118 content
119 }
120 }
121 }
122 cursor
123 }
124 }
125 }
126 }
127 }
128 """
129
130 /// Query to fetch additional pages of tree entries by tree ID + cursor.
131 private static let treeEntriesPageQuery = """
132 query treeEntriesPage($rid: ID!, $treeId: String!, $cursor: Cursor) {
133 repository(rid: $rid) {
134 object(id: $treeId) {
135 type
136 id
137 ... on Tree {
138 entries(cursor: $cursor) {
139 results {
140 id
141 name
142 mode
143 object {
144 type
145 id
146 shortId
147 ... on Tree {
148 entries {
149 results {
150 id
151 name
152 mode
153 object { type id shortId }
154 }
155 cursor
156 }
157 }
158 ... on TextBlob {
159 text
160 size
161 }
162 ... on BinaryBlob {
163 size
164 content
165 }
166 }
167 }
168 cursor
169 }
170 }
171 }
172 }
173 }
174 """
175
176 private static let subtreeQuery = """
177 query subtree($rid: ID!, $treeId: String!, $cursor: Cursor) {
178 repository(rid: $rid) {
179 object(id: $treeId) {
180 type
181 id
182 ... on Tree {
183 entries(cursor: $cursor) {
184 results {
185 id
186 name
187 mode
188 object {
189 type
190 id
191 shortId
192 ... on Tree {
193 entries {
194 results {
195 id
196 name
197 mode
198 object { type id shortId }
199 }
200 cursor
201 }
202 }
203 ... on TextBlob {
204 text
205 size
206 }
207 ... on BinaryBlob {
208 size
209 content
210 }
211 }
212 }
213 cursor
214 }
215 }
216 }
217 }
218 }
219 """
220
221 private static let blobQuery = """
222 query blob($rid: ID!, $blobId: String!) {
223 repository(rid: $rid) {
224 object(id: $blobId) {
225 type
226 id
227 ... on TextBlob {
228 text
229 size
230 }
231 ... on BinaryBlob {
232 size
233 content
234 }
235 }
236 }
237 }
238 """
239
240 private static let refsQuery = """
241 query refs($rid: ID!) {
242 repository(rid: $rid) {
243 references {
244 results { name target }
245 cursor
246 }
247 }
248 }
249 """
250
251 // MARK: - Load Root Tree
252
253 func loadRootTree() async {
254 guard !isLoading else { return }
255 isLoading = true
256 error = nil
257 viewingEntry = nil
258 viewingObject = nil
259 defer { isLoading = false }
260
261 let variables: [String: any Sendable] = [
262 "rid": repositoryRid,
263 "revspec": revspec
264 ]
265
266 do {
267 let result: RevparseResponse
268 do {
269 result = try await client.execute(
270 service: service,
271 query: Self.rootTreeQuery,
272 variables: variables,
273 responseType: RevparseResponse.self
274 )
275 } catch {
276 if isMissingGitReferenceError(error) {
277 navStack = [FileNavEntry(name: "root", treeId: "")]
278 entries = []
279 return
280 }
281 throw error
282 }
283 if let tree = result.repository?.revparse_single?.tree,
284 let rootId = tree.id {
285 navStack = [FileNavEntry(name: "root", treeId: rootId)]
286 var allEntries = tree.entries?.results ?? []
287 var cursor = tree.entries?.cursor
288 // Follow cursor pagination for remaining pages
289 while let nextCursor = cursor {
290 let pageEntries = try await fetchTreeEntriesPage(treeId: rootId, cursor: nextCursor)
291 allEntries.append(contentsOf: pageEntries.results)
292 cursor = pageEntries.cursor
293 }
294 entries = allEntries
295 } else {
296 navStack = []
297 entries = []
298 }
299 } catch {
300 self.error = error.localizedDescription
301 }
302 }
303
304 // MARK: - Navigate Into Folder
305
306 func navigateInto(entry: TreeEntry) async {
307 guard let object = entry.object else { return }
308
309 switch object {
310 case .tree(let tree):
311 // Use the git object SHA from entry.object, NOT entry.id
312 guard let objectSHA = tree.id else { return }
313 // If we already have the entries inline and no further pages, use them directly
314 if let inlineEntries = tree.entries?.results, !inlineEntries.isEmpty, tree.entries?.cursor == nil {
315 navStack.append(FileNavEntry(name: entry.name, treeId: objectSHA))
316 entries = inlineEntries
317 viewingEntry = nil
318 viewingObject = nil
319 return
320 }
321 // Otherwise fetch the subtree (handles pagination)
322 await loadSubtree(name: entry.name, treeId: objectSHA)
323
324 case .textBlob:
325 viewingEntry = entry
326 viewingObject = object
327
328 case .binaryBlob(let blob):
329 if blob.content != nil || blob.size != nil {
330 viewingEntry = entry
331 viewingObject = object
332 } else if let blobId = blob.id {
333 await loadBlob(entry: entry, blobId: blobId)
334 }
335
336 case .unknown:
337 break
338 }
339 }
340
341 private func loadSubtree(name: String, treeId: String) async {
342 guard !isLoading else { return }
343 isLoading = true
344 error = nil
345 viewingEntry = nil
346 viewingObject = nil
347 defer { isLoading = false }
348
349 let variables: [String: any Sendable] = [
350 "rid": repositoryRid,
351 "treeId": treeId
352 ]
353
354 do {
355 let result = try await client.execute(
356 service: service,
357 query: Self.subtreeQuery,
358 variables: variables,
359 responseType: SubtreeResponse.self
360 )
361 navStack.append(FileNavEntry(name: name, treeId: treeId))
362 var allEntries = result.repository?.object?.entries?.results ?? []
363 var cursor = result.repository?.object?.entries?.cursor
364 while let nextCursor = cursor {
365 let pageEntries = try await fetchTreeEntriesPage(treeId: treeId, cursor: nextCursor)
366 allEntries.append(contentsOf: pageEntries.results)
367 cursor = pageEntries.cursor
368 }
369 entries = allEntries
370 } catch {
371 self.error = error.localizedDescription
372 }
373 }
374
375 private func loadBlob(entry: TreeEntry, blobId: String) async {
376 guard !isLoading else { return }
377 isLoading = true
378 error = nil
379 defer { isLoading = false }
380
381 let variables: [String: any Sendable] = [
382 "rid": repositoryRid,
383 "blobId": blobId
384 ]
385
386 do {
387 let result = try await client.execute(
388 service: service,
389 query: Self.blobQuery,
390 variables: variables,
391 responseType: BlobResponse.self
392 )
393 viewingEntry = entry
394 viewingObject = result.repository?.object ?? .unknown
395 } catch {
396 self.error = error.localizedDescription
397 }
398 }
399
400 // MARK: - Navigate to Breadcrumb
401
402 func navigateToBreadcrumb(at index: Int) async {
403 guard index >= 0, index < navStack.count else { return }
404
405 // If tapping current level, do nothing
406 if index == navStack.count - 1, viewingEntry == nil {
407 return
408 }
409
410 // Clear file view
411 viewingEntry = nil
412 viewingObject = nil
413
414 // Trim the stack
415 let targetEntry = navStack[index]
416 navStack = Array(navStack.prefix(index + 1))
417
418 if index == 0 {
419 // Go back to root — reload from revparse_single
420 await loadRootTree()
421 } else {
422 // Load the subtree at this level
423 isLoading = true
424 error = nil
425 defer { isLoading = false }
426
427 let variables: [String: any Sendable] = [
428 "rid": repositoryRid,
429 "treeId": targetEntry.treeId
430 ]
431
432 do {
433 let result = try await client.execute(
434 service: service,
435 query: Self.subtreeQuery,
436 variables: variables,
437 responseType: SubtreeResponse.self
438 )
439 var allEntries = result.repository?.object?.entries?.results ?? []
440 var cursor = result.repository?.object?.entries?.cursor
441 while let nextCursor = cursor {
442 let pageEntries = try await fetchTreeEntriesPage(treeId: targetEntry.treeId, cursor: nextCursor)
443 allEntries.append(contentsOf: pageEntries.results)
444 cursor = pageEntries.cursor
445 }
446 entries = allEntries
447 } catch {
448 self.error = error.localizedDescription
449 }
450 }
451 }
452
453 /// Fetch a single page of tree entries by tree ID and cursor.
454 private func fetchTreeEntriesPage(treeId: String, cursor: String) async throws -> GitTreeEntryPage {
455 let variables: [String: any Sendable] = [
456 "rid": repositoryRid,
457 "treeId": treeId,
458 "cursor": cursor
459 ]
460 let result = try await client.execute(
461 service: service,
462 query: Self.treeEntriesPageQuery,
463 variables: variables,
464 responseType: SubtreeResponse.self
465 )
466 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
467 }
468
469 /// Dismiss the file view and go back to the directory listing.
470 func dismissFileView() {
471 viewingEntry = nil
472 viewingObject = nil
473 }
474
475 // MARK: - Change Revspec
476
477 func changeRevspec(_ newRevspec: String) async {
478 revspec = newRevspec
479 await loadRootTree()
480 }
481
482 // MARK: - Load References
483
484 func loadReferences() async {
485 guard !isLoadingRefs else { return }
486 isLoadingRefs = true
487
488 do {
489 let result = try await client.execute(
490 service: service,
491 query: Self.refsQuery,
492 variables: ["rid": repositoryRid],
493 responseType: RefsResponseLocal.self
494 )
495 let allRefs = result.repository?.references.results ?? []
496 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
497 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
498 } catch {
499 // Silently fail for refs — non-critical
500 }
501
502 isLoadingRefs = false
503 }
504
505 private func isMissingGitReferenceError(_ error: Error) -> Bool {
506 guard let srhtError = error as? SRHTError else { return false }
507 guard case .graphQLErrors(let errors) = srhtError else { return false }
508 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
509 }
510}
511
512// File-private refs response to avoid collision with RepositoryDetailViewModel's private type
513private struct RefsResponseLocal: Decodable, Sendable {
514 let repository: RefsRepoLocal?
515}
516
517private struct RefsRepoLocal: Decodable, Sendable {
518 let references: RefsPageLocal
519}
520
521private struct RefsPageLocal: Decodable, Sendable {
522 let results: [Reference]
523 let cursor: String?
524}
Hutch/Views/Repositories/HgRepositoryDetailView.swift +363 −143
@@ -11,19 +11,46 @@ struct HgRepositoryDetailView: View {
1111 @State private var viewModel: HgRepositoryDetailViewModel?
1212 @State private var selectedTab: HgRepositoryDetailViewModel.Tab = .summary
1313 @State private var showSettings = false
14 @State private var isShowingRepositoryDetails = false
15 @State private var showBrowseRefPicker = false
16
17 private var shareURL: URL? {
18 guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil }
19 return SRHTWebURL.file(
20 repository: repository,
21 revspec: viewModel.browseRevspec,
22 path: selectedFilePath
23 )
24 }
1425
1526 var body: some View {
1627 Group {
1728 if let viewModel {
1829 content(viewModel)
1930 } else {
20 ProgressView()
31 SRHTLoadingStateView(message: "Loading repository…")
2132 }
2233 }
2334 .navigationTitle(repository.name)
2435 .navigationBarTitleDisplayMode(.inline)
2536 .toolbar {
26 ToolbarItem(placement: .topBarTrailing) {
37 ToolbarItemGroup(placement: .topBarTrailing) {
38 if selectedTab == .browse, let viewModel {
39 Button {
40 showBrowseRefPicker = true
41 } label: {
42 Label(
43 browseRevspecLabel(viewModel.browseRevspec),
44 systemImage: "arrow.triangle.branch"
45 )
46 .font(.subheadline)
47 }
48 }
49
50 SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
51 Image(systemName: "square.and.arrow.up")
52 }
53
2754 Button {
2855 showSettings = true
2956 } label: {
@@ -41,6 +68,11 @@ struct HgRepositoryDetailView: View {
4168 }
4269 )
4370 }
71 .sheet(isPresented: $showBrowseRefPicker) {
72 if let viewModel {
73 HgBrowseRefPickerSheet(viewModel: viewModel, isPresented: $showBrowseRefPicker)
74 }
75 }
4476 .task {
4577 if viewModel == nil {
4678 let vm = HgRepositoryDetailViewModel(repository: repository, client: appState.client)
@@ -82,82 +114,134 @@ struct HgRepositoryDetailView: View {
82114 revisionsList(viewModel.bookmarks, emptyTitle: "No Bookmarks", emptyDescription: "This repository does not have any bookmarks.")
83115 }
84116 }
85 .alert("Error", isPresented: .constant(viewModel.error != nil)) {
86 Button("OK") { viewModel.error = nil }
87 } message: {
88 if let error = viewModel.error {
89 Text(error)
90 }
91 }
117 .srhtErrorBanner(error: Binding(
118 get: { viewModel.error },
119 set: { viewModel.error = $0 }
120 ))
92121 }
93122
94123 @ViewBuilder
95124 private func summaryTab(_ viewModel: HgRepositoryDetailViewModel) -> some View {
96 if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
97 ProgressView()
98 .frame(maxWidth: .infinity, maxHeight: .infinity)
99 } else {
100 ScrollView {
101 VStack(alignment: .leading, spacing: 16) {
102 summaryCards(viewModel)
103
104 if let readmeView = readmeContentView(viewModel) {
105 readmeView
106 } else {
107 ContentUnavailableView(
108 "No README",
109 systemImage: "doc.text",
110 description: Text("This repository does not have a README file.")
111 )
112 }
113 }
114 .padding()
125 ScrollView {
126 VStack(alignment: .leading, spacing: 16) {
127 headerSection
128 metadataSection(viewModel)
129 repositoryDetailsSection(viewModel)
130 latestChangeSection(viewModel)
131 readmeSection(viewModel)
115132 }
116 .refreshable {
117 await viewModel.loadSummary()
133 .padding()
134 }
135 .overlay {
136 if viewModel.isLoadingSummary, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
137 SRHTLoadingStateView(message: "Loading repository…")
138 } else if let error = viewModel.error, !viewModel.summaryLoaded, viewModel.tip == nil, viewModel.readmeContent == nil {
139 SRHTErrorStateView(
140 title: "Couldn't Load Repository",
141 message: error,
142 retryAction: { await viewModel.loadSummary() }
143 )
118144 }
119145 }
146 .refreshable {
147 await viewModel.loadSummary()
148 }
120149 }
121150
122 private func summaryCards(_ viewModel: HgRepositoryDetailViewModel) -> some View {
123 VStack(alignment: .leading, spacing: 12) {
124 LabeledContent("Visibility", value: visibilityLabel(repository.visibility))
125 LabeledContent("Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
126
151 private var headerSection: some View {
152 VStack(alignment: .leading, spacing: 6) {
153 Text(repository.owner.canonicalName)
154 .font(.subheadline)
155 .foregroundStyle(.secondary)
156 Text(repository.name)
157 .font(.largeTitle.weight(.semibold))
127158 if let description = repository.description, !description.isEmpty {
128 VStack(alignment: .leading, spacing: 4) {
129 Text("Description")
130 .font(.caption.weight(.semibold))
131 .foregroundStyle(.secondary)
132 .textCase(.uppercase)
133 Text(description)
134 }
159 Text(description)
160 .font(.body)
135161 }
162 }
163 }
136164
137 if let tip = viewModel.tip {
138 VStack(alignment: .leading, spacing: 6) {
139 Text("Tip")
140 .font(.caption.weight(.semibold))
141 .foregroundStyle(.secondary)
142 .textCase(.uppercase)
143 Text(tip.title)
144 .font(.headline)
145 HStack {
146 Text(tip.displayShortId)
147 .font(.caption.monospaced())
148 Spacer()
149 Text(tip.author.time.relativeDescription)
150 .font(.caption)
151 .foregroundStyle(.secondary)
152 }
153 Text(tip.author.name)
154 .font(.subheadline)
155 .foregroundStyle(.secondary)
156 }
165 @ViewBuilder
166 private func metadataSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
167 VStack(alignment: .leading, spacing: 10) {
168 SummaryMetadataRow(
169 icon: "arrow.triangle.branch",
170 title: viewModel.tip?.branch ?? repository.head?.name ?? repositoryVisibilityLabel(repository.visibility)
171 )
172
173 if let readmePath = viewModel.readmePath {
174 SummaryMetadataRow(
175 icon: "doc.text",
176 title: readmePath
177 )
157178 }
158179 }
159 .padding()
160 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
180 }
181
182 private func repositoryDetailsSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
183 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
184 VStack(alignment: .leading, spacing: 12) {
185 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(repository.visibility))
186 SummaryDetailRow(label: "Publishing", value: viewModel.nonPublishing ? "Non-publishing" : "Publishing")
187 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: repository).readOnly, monospace: true)
188 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: repository).readWrite, monospace: true)
189 SummaryDetailRow(label: "RID", value: repository.rid, monospace: true)
190 }
191 .padding(.top, 8)
192 } label: {
193 Text("Repository Details")
194 .font(.subheadline.weight(.medium))
195 }
196 }
197
198 @ViewBuilder
199 private func latestChangeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
200 VStack(alignment: .leading, spacing: 8) {
201 if viewModel.isLoadingSummary && viewModel.tip == nil {
202 SRHTLoadingStateView(message: "Loading latest change…")
203 .frame(maxWidth: .infinity)
204 } else if let tip = viewModel.tip {
205 SummaryMetadataRow(
206 icon: "arrow.trianglehead.clockwise",
207 title: tip.title,
208 subtitle: "\(tip.displayShortId) — \(tip.author)"
209 )
210 } else if let error = viewModel.error, !viewModel.summaryLoaded {
211 SRHTErrorStateView(
212 title: "Couldn't Load Latest Change",
213 message: error,
214 retryAction: { await viewModel.loadSummary() }
215 )
216 } else {
217 ContentUnavailableView(
218 "No Recent Revisions",
219 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
220 description: Text("This repository does not have any revision history yet.")
221 )
222 }
223 }
224 }
225
226 @ViewBuilder
227 private func readmeSection(_ viewModel: HgRepositoryDetailViewModel) -> some View {
228 if viewModel.isLoadingSummary && !viewModel.summaryLoaded {
229 SRHTLoadingStateView(message: "Loading README…")
230 } else if let readmeView = readmeContentView(viewModel) {
231 readmeView
232 } else if let error = viewModel.error, !viewModel.summaryLoaded {
233 SRHTErrorStateView(
234 title: "Couldn't Load README",
235 message: error,
236 retryAction: { await viewModel.loadSummary() }
237 )
238 } else {
239 ContentUnavailableView(
240 "No README",
241 systemImage: "doc.text",
242 description: Text("This repository does not have a README file.")
243 )
244 }
161245 }
162246
163247 @ViewBuilder
@@ -166,21 +250,31 @@ struct HgRepositoryDetailView: View {
166250 browseBreadcrumbs(viewModel)
167251 Divider()
168252
169 if viewModel.isLoadingBrowse {
170 Spacer()
171 ProgressView()
172 Spacer()
253 if viewModel.isLoadingBrowse, viewModel.files.isEmpty, viewModel.selectedFilePath == nil {
254 SRHTLoadingStateView(message: "Loading files…")
173255 } else if let selectedFilePath = viewModel.selectedFilePath, let fileContent = viewModel.fileContent {
174 GeometryReader { geometry in
175 ScrollView([.vertical, .horizontal]) {
176 Text(fileContent)
177 .font(.system(.body, design: .monospaced))
178 .frame(
179 minWidth: geometry.size.width,
180 minHeight: geometry.size.height,
181 alignment: .topLeading
182 )
183 .padding()
256 VStack(spacing: 0) {
257 HStack {
258 Spacer()
259 SRHTShareButton(url: shareURL, target: .file) {
260 Label("Share File", systemImage: "square.and.arrow.up")
261 }
262 .buttonStyle(.bordered)
263 }
264 .padding(.horizontal)
265 .padding(.top, 12)
266
267 GeometryReader { geometry in
268 ScrollView([.vertical, .horizontal]) {
269 Text(fileContent)
270 .font(.system(.body, design: .monospaced))
271 .frame(
272 minWidth: geometry.size.width,
273 minHeight: geometry.size.height,
274 alignment: .topLeading
275 )
276 .padding()
277 }
184278 }
185279 }
186280 .safeAreaInset(edge: .bottom) {
@@ -193,6 +287,12 @@ struct HgRepositoryDetailView: View {
193287 .background(.bar)
194288 }
195289 .navigationTitle(selectedFilePath.split(separator: "/").last.map(String.init) ?? repository.name)
290 } else if let error = viewModel.error, viewModel.files.isEmpty {
291 SRHTErrorStateView(
292 title: "Couldn't Load Files",
293 message: error,
294 retryAction: { await viewModel.loadBrowseRoot() }
295 )
196296 } else if viewModel.files.isEmpty {
197297 ContentUnavailableView(
198298 "No Files",
@@ -201,13 +301,7 @@ struct HgRepositoryDetailView: View {
201301 )
202302 } else {
203303 List(viewModel.files) { file in
204 Label {
205 Text(displayFileName(file.name))
206 .font(.body.monospaced())
207 } icon: {
208 Image(systemName: file.isDirectory ? "folder.fill" : "doc")
209 .foregroundStyle(file.isDirectory ? .blue : .secondary)
210 }
304 HgFileRow(file: file)
211305 .contentShape(Rectangle())
212306 .onTapGesture {
213307 Task { await viewModel.openFile(file) }
@@ -282,8 +376,14 @@ struct HgRepositoryDetailView: View {
282376 }
283377 .listStyle(.plain)
284378 .overlay {
285 if viewModel.isLoadingLog {
286 ProgressView()
379 if viewModel.isLoadingLog, viewModel.log.isEmpty {
380 SRHTLoadingStateView(message: "Loading revisions…")
381 } else if let error = viewModel.error, viewModel.log.isEmpty {
382 SRHTErrorStateView(
383 title: "Couldn't Load Revisions",
384 message: error,
385 retryAction: { await viewModel.loadLog() }
386 )
287387 } else if viewModel.log.isEmpty {
288388 ContentUnavailableView(
289389 "No Revisions",
@@ -298,7 +398,7 @@ struct HgRepositoryDetailView: View {
298398 }
299399
300400 @ViewBuilder
301 private func revisionsList(_ revisions: [HgRevision], emptyTitle: String, emptyDescription: String) -> some View {
401 private func revisionsList(_ revisions: [HgNamedRevision], emptyTitle: String, emptyDescription: String) -> some View {
302402 if revisions.isEmpty {
303403 ContentUnavailableView(
304404 emptyTitle,
@@ -307,7 +407,7 @@ struct HgRepositoryDetailView: View {
307407 )
308408 } else {
309409 List(revisions) { revision in
310 revisionRow(revision)
410 namedRevisionRow(revision)
311411 }
312412 .listStyle(.plain)
313413 }
@@ -335,9 +435,7 @@ struct HgRepositoryDetailView: View {
335435 }
336436
337437 HStack {
338 Text(revision.author.name)
339 Spacer()
340 Text(revision.author.time.relativeDescription)
438 Text(revision.author)
341439 }
342440 .font(.caption)
343441 .foregroundStyle(.secondary)
@@ -345,75 +443,197 @@ struct HgRepositoryDetailView: View {
345443 .padding(.vertical, 4)
346444 }
347445
348 @ViewBuilder
349 private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
350 let imageURLResolver = makeImageURLResolver(viewModel)
446 private func namedRevisionRow(_ revision: HgNamedRevision) -> some View {
447 HStack(alignment: .firstTextBaseline) {
448 Text(revision.name)
449 .font(.headline)
450 Spacer()
451 Text(revision.displayShortId)
452 .font(.caption.monospaced())
453 .foregroundStyle(.secondary)
454 }
455 .padding(.vertical, 6)
456 }
351457
458 private func readmeContentView(_ viewModel: HgRepositoryDetailViewModel) -> AnyView? {
352459 guard let content = viewModel.readmeContent else {
353460 return nil
354461 }
355462
463 return AnyView(
464 RenderedMarkupContentView(
465 content: sharedReadmeContent(from: content),
466 readmePath: viewModel.readmePath,
467 colorScheme: colorScheme,
468 ownerCanonicalName: repository.owner.canonicalName,
469 repositoryName: repository.name,
470 repositoryHost: "hg.sr.ht"
471 )
472 )
473 }
474
475 private func browseRevspecLabel(_ revspec: String) -> String {
476 if revspec == "tip" {
477 return "tip"
478 }
479 return revspec
480 }
481
482 private func sharedReadmeContent(from content: HgRepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
356483 switch content {
357484 case .html(let html):
358 return AnyView(
359 HTMLWebView(html: html, colorScheme: colorScheme)
360 .frame(minHeight: 400)
361 )
485 .html(html)
362486 case .markdown(let text):
363 return AnyView(
364 HTMLWebView(
365 html: markdownToHTML(text, imageURLResolver: imageURLResolver),
366 colorScheme: colorScheme
367 )
368 .frame(minHeight: 400)
369 )
487 .markdown(text)
370488 case .org(let text):
371 return AnyView(
372 HTMLWebView(
373 html: orgToHTML(text, imageURLResolver: imageURLResolver),
374 colorScheme: colorScheme
375 )
376 .frame(minHeight: 400)
377 )
489 .org(text)
378490 case .plainText(let text):
379 return AnyView(
380 Text(text)
381 .font(.system(.body, design: .monospaced))
382 .frame(maxWidth: .infinity, alignment: .leading)
383 .padding()
384 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
385 )
491 .plainText(text)
386492 }
387493 }
388494
389 private func makeImageURLResolver(_ viewModel: HgRepositoryDetailViewModel) -> (String) -> String? {
390 let owner = repository.owner.canonicalName
391 let repositoryName = repository.name
392 let readmePath = viewModel.readmePath
393
394 return { source in
395 resolveRepositoryAssetURL(
396 source,
397 owner: owner,
398 repositoryName: repositoryName,
399 readmePath: readmePath
400 )?
401 .replacingOccurrences(of: "git.sr.ht", with: "hg.sr.ht")
495 private func displayFileName(_ name: String) -> String {
496 name.hasSuffix("/") ? String(name.dropLast()) : name
497 }
498
499}
500
501private struct HgBrowseRefPickerSheet: View {
502 let viewModel: HgRepositoryDetailViewModel
503 @Binding var isPresented: Bool
504
505 var body: some View {
506 NavigationStack {
507 List {
508 Section {
509 Button {
510 Task {
511 await viewModel.changeBrowseRevspec("tip")
512 isPresented = false
513 }
514 } label: {
515 refRow(
516 title: "tip",
517 systemImage: "arrow.triangle.branch",
518 color: .blue,
519 isSelected: viewModel.browseRevspec == "tip"
520 )
521 }
522 .buttonStyle(.plain)
523 }
524
525 if !viewModel.branches.isEmpty {
526 Section("Branches") {
527 ForEach(viewModel.branches) { revision in
528 Button {
529 Task {
530 await viewModel.changeBrowseRevspec(revision.name)
531 isPresented = false
532 }
533 } label: {
534 refRow(
535 title: revision.name,
536 systemImage: "arrow.triangle.branch",
537 color: .blue,
538 isSelected: viewModel.browseRevspec == revision.name
539 )
540 }
541 .buttonStyle(.plain)
542 }
543 }
544 }
545
546 if !viewModel.tags.isEmpty {
547 Section("Tags") {
548 ForEach(viewModel.tags) { revision in
549 Button {
550 Task {
551 await viewModel.changeBrowseRevspec(revision.name)
552 isPresented = false
553 }
554 } label: {
555 refRow(
556 title: revision.name,
557 systemImage: "tag",
558 color: .orange,
559 isSelected: viewModel.browseRevspec == revision.name
560 )
561 }
562 .buttonStyle(.plain)
563 }
564 }
565 }
566
567 if !viewModel.bookmarks.isEmpty {
568 Section("Bookmarks") {
569 ForEach(viewModel.bookmarks) { revision in
570 Button {
571 Task {
572 await viewModel.changeBrowseRevspec(revision.name)
573 isPresented = false
574 }
575 } label: {
576 refRow(
577 title: revision.name,
578 systemImage: "bookmark",
579 color: .purple,
580 isSelected: viewModel.browseRevspec == revision.name
581 )
582 }
583 .buttonStyle(.plain)
584 }
585 }
586 }
587 }
588 .listStyle(.insetGrouped)
589 .navigationTitle("Select Ref")
590 .navigationBarTitleDisplayMode(.inline)
591 .toolbar {
592 ToolbarItem(placement: .cancellationAction) {
593 Button("Cancel") {
594 isPresented = false
595 }
596 }
597 }
402598 }
403599 }
404600
405 private func displayFileName(_ name: String) -> String {
406 name.hasSuffix("/") ? String(name.dropLast()) : name
601 private func refRow(title: String, systemImage: String, color: Color, isSelected: Bool) -> some View {
602 HStack(spacing: 12) {
603 Image(systemName: systemImage)
604 .foregroundStyle(color)
605
606 Text(title)
607 .font(.body.monospaced())
608 .foregroundStyle(.primary)
609
610 Spacer()
611
612 if isSelected {
613 Image(systemName: "checkmark")
614 .font(.caption.weight(.semibold))
615 .foregroundStyle(.tint)
616 }
617 }
618 .contentShape(Rectangle())
407619 }
620}
408621
409 private func visibilityLabel(_ visibility: Visibility) -> String {
410 switch visibility {
411 case .public:
412 return "Public"
413 case .unlisted:
414 return "Unlisted"
415 case .private:
416 return "Private"
622private struct HgFileRow: View {
623 let file: HgFile
624
625 var body: some View {
626 Label {
627 Text(displayName)
628 .font(.body.monospaced())
629 .lineLimit(1)
630 } icon: {
631 Image(systemName: file.isDirectory ? "folder.fill" : "doc")
632 .foregroundStyle(file.isDirectory ? .blue : .secondary)
417633 }
418634 }
635
636 private var displayName: String {
637 file.name.hasSuffix("/") ? String(file.name.dropLast()) : file.name
638 }
419639}
Hutch/Views/Repositories/HgRepositoryDetailViewModel.swift added +545
@@ -0,0 +1,545 @@
1import Foundation
2
3private struct HgRepositorySummaryResponse: Decodable, Sendable {
4 let repository: HgRepositorySummaryPayload?
5}
6
7private struct HgRepositorySummaryPayload: Decodable, Sendable {
8 let id: Int
9 let rid: String
10 let name: String
11 let description: String?
12 let visibility: Visibility
13 let readme: String?
14 let nonPublishing: Bool?
15 let tip: HgSummaryTip?
16 let branches: HgNamedRevisionPage?
17 let tags: HgNamedRevisionPage?
18 let bookmarks: HgNamedRevisionPage?
19}
20
21private struct HgSummaryTip: Decodable, Sendable {
22 let id: String?
23 let author: String?
24 let description: String?
25 let branch: String?
26 let tags: [String]?
27
28 var resolvedRevision: HgRevision? {
29 guard
30 let id,
31 let author,
32 let description
33 else {
34 return nil
35 }
36
37 return HgRevision(
38 id: id,
39 author: author,
40 description: description,
41 branch: branch,
42 tags: tags
43 )
44 }
45}
46
47private struct HgRevisionLogResponse: Decodable, Sendable {
48 let repository: HgRevisionLogRepository?
49}
50
51private struct HgRevisionLogRepository: Decodable, Sendable {
52 let log: HgRevisionPage?
53}
54
55private struct HgReadmeFileResponse: Decodable, Sendable {
56 let repository: HgReadmeFileRepository?
57}
58
59private struct HgReadmeFileRepository: Decodable, Sendable {
60 let readme: String?
61}
62
63private struct HgFilesResponse: Decodable, Sendable {
64 let repository: HgFilesRepository?
65}
66
67private struct HgFilesRepository: Decodable, Sendable {
68 let files: HgFilePage?
69}
70
71private struct HgFilePage: Decodable, Sendable {
72 let results: [HgFile]
73 let cursor: String?
74}
75
76private struct HgNamedRevisionPage: Decodable, Sendable {
77 let results: [HgNamedRevision]
78 let cursor: String?
79
80 private enum CodingKeys: String, CodingKey {
81 case results
82 case cursor
83 }
84
85 init(results: [HgNamedRevision], cursor: String?) {
86 self.results = results
87 self.cursor = cursor
88 }
89
90 init(from decoder: any Decoder) throws {
91 let container = try decoder.container(keyedBy: CodingKeys.self)
92 self.results = try container.decodeIfPresent([HgNamedRevision?].self, forKey: .results)?.compactMap { $0 } ?? []
93 self.cursor = try container.decodeIfPresent(String.self, forKey: .cursor)
94 }
95}
96
97private struct HgCatResponse: Decodable, Sendable {
98 let repository: HgCatRepository?
99}
100
101private struct HgCatRepository: Decodable, Sendable {
102 let cat: String?
103}
104
105struct HgRevisionPage: Decodable, Sendable {
106 let results: [HgRevision]
107 let cursor: String?
108}
109
110struct HgRevision: Decodable, Sendable, Identifiable, Hashable {
111 let id: String
112 let author: String
113 let description: String
114 let branch: String?
115 let tags: [String]?
116
117 var displayShortId: String {
118 String(id.prefix(12))
119 }
120
121 var title: String {
122 description.prefix(while: { $0 != "\n" }).trimmingCharacters(in: .whitespacesAndNewlines)
123 }
124
125 var body: String? {
126 let body = description
127 .split(separator: "\n", maxSplits: 1, omittingEmptySubsequences: false)
128 .dropFirst()
129 .first
130 .map(String.init)?
131 .trimmingCharacters(in: .whitespacesAndNewlines)
132 return body?.isEmpty == false ? body : nil
133 }
134
135 var primaryName: String {
136 if let tag = tags?.first, !tag.isEmpty {
137 return tag
138 }
139 if let branch, !branch.isEmpty {
140 return branch
141 }
142 return displayShortId
143 }
144}
145
146struct HgFile: Decodable, Sendable, Hashable, Identifiable {
147 let name: String
148
149 var id: String { name }
150
151 var isDirectory: Bool {
152 name.hasSuffix("/")
153 }
154}
155
156struct HgNamedRevision: Decodable, Sendable, Identifiable, Hashable {
157 let name: String
158 let id: String
159
160 var displayShortId: String {
161 String(id.prefix(12))
162 }
163}
164
165@Observable
166@MainActor
167final class HgRepositoryDetailViewModel {
168 enum Tab: String, CaseIterable {
169 case summary = "Summary"
170 case browse = "Browse"
171 case log = "Log"
172 case tags = "Tags"
173 case branches = "Branches"
174 case bookmarks = "Bookmarks"
175 }
176
177 enum ReadmeContent {
178 case html(String)
179 case markdown(String)
180 case org(String)
181 case plainText(String)
182 }
183
184 let repository: RepositorySummary
185 private let client: SRHTClient
186
187 private(set) var summaryLoaded = false
188 private(set) var isLoadingSummary = false
189 private(set) var readmeContent: ReadmeContent?
190 private(set) var readmePath: String?
191 private(set) var nonPublishing = false
192 private(set) var tip: HgRevision?
193 private(set) var branches: [HgNamedRevision] = []
194 private(set) var tags: [HgNamedRevision] = []
195 private(set) var bookmarks: [HgNamedRevision] = []
196
197 private(set) var log: [HgRevision] = []
198 private(set) var isLoadingLog = false
199 private(set) var isLoadingMoreLog = false
200 private var logCursor: String?
201 private var hasMoreLog = true
202
203 private(set) var currentBrowsePath = ""
204 private(set) var pathStack: [String] = []
205 private(set) var files: [HgFile] = []
206 private(set) var fileContent: String?
207 private(set) var selectedFilePath: String?
208 private(set) var isLoadingBrowse = false
209 private(set) var browseRevspec = "tip"
210
211 var error: String?
212
213 init(repository: RepositorySummary, client: SRHTClient) {
214 self.repository = repository
215 self.client = client
216 }
217
218 private static let summaryQuery = """
219 query hgRepositorySummary($rid: ID!) {
220 repository(rid: $rid) {
221 id
222 rid
223 name
224 description
225 visibility
226 readme
227 nonPublishing
228 tip {
229 id
230 author
231 description
232 branch
233 tags
234 }
235 branches {
236 results {
237 name
238 id
239 }
240 cursor
241 }
242 tags {
243 results {
244 name
245 id
246 }
247 cursor
248 }
249 bookmarks {
250 results {
251 name
252 id
253 }
254 cursor
255 }
256 }
257 }
258 """
259
260 private static let logQuery = """
261 query hgRepositoryLog($rid: ID!, $cursor: Cursor) {
262 repository(rid: $rid) {
263 log(cursor: $cursor) {
264 results {
265 id
266 author
267 description
268 branch
269 tags
270 }
271 cursor
272 }
273 }
274 }
275 """
276
277 private static func readmeFileQuery(filename: String) -> String {
278 """
279 query hgReadmeFile($rid: ID!) {
280 repository(rid: $rid) {
281 readme: cat(path: "\(filename)", revspec: "tip")
282 }
283 }
284 """
285 }
286
287 private static let readmeFilenames = [
288 "README.md", "README.org", "README.txt", "README",
289 "readme.md", "readme.org"
290 ]
291
292 private static let filesQuery = """
293 query hgFiles($rid: ID!, $path: String!, $revspec: String!) {
294 repository(rid: $rid) {
295 files(path: $path, revspec: $revspec) {
296 results {
297 name
298 }
299 cursor
300 }
301 }
302 }
303 """
304
305 private static let catQuery = """
306 query hgCat($rid: ID!, $path: String!, $revspec: String!) {
307 repository(rid: $rid) {
308 cat(path: $path, revspec: $revspec)
309 }
310 }
311 """
312
313 func loadSummary() async {
314 guard !isLoadingSummary, !summaryLoaded else { return }
315 isLoadingSummary = true
316 defer { isLoadingSummary = false }
317 error = nil
318
319 do {
320 let result = try await client.execute(
321 service: .hg,
322 query: Self.summaryQuery,
323 variables: ["rid": repository.rid],
324 responseType: HgRepositorySummaryResponse.self
325 )
326
327 guard let repository = result.repository else {
328 summaryLoaded = true
329 return
330 }
331
332 tip = repository.tip?.resolvedRevision
333 branches = repository.branches?.results ?? []
334 tags = repository.tags?.results ?? []
335 bookmarks = repository.bookmarks?.results ?? []
336 nonPublishing = repository.nonPublishing ?? false
337
338 if let html = repository.readme, !html.isEmpty {
339 readmePath = nil
340 readmeContent = .html(html)
341 } else {
342 await loadReadmeFile()
343 }
344
345 summaryLoaded = true
346 } catch {
347 self.error = error.localizedDescription
348 }
349 }
350
351 func loadLog() async {
352 guard !isLoadingLog else { return }
353 isLoadingLog = true
354 defer { isLoadingLog = false }
355 error = nil
356 logCursor = nil
357 hasMoreLog = true
358
359 do {
360 let page = try await fetchLogPage(cursor: nil)
361 log = page.results
362 logCursor = page.cursor
363 hasMoreLog = page.cursor != nil
364 } catch {
365 if isEmptyRepositoryError(error) {
366 log = []
367 logCursor = nil
368 hasMoreLog = false
369 } else {
370 self.error = error.localizedDescription
371 }
372 }
373 }
374
375 func loadMoreLogIfNeeded(currentItem: HgRevision) async {
376 guard let last = log.last,
377 last.id == currentItem.id,
378 hasMoreLog,
379 !isLoadingMoreLog else {
380 return
381 }
382
383 isLoadingMoreLog = true
384 defer { isLoadingMoreLog = false }
385
386 do {
387 let page = try await fetchLogPage(cursor: logCursor)
388 log.append(contentsOf: page.results)
389 logCursor = page.cursor
390 hasMoreLog = page.cursor != nil
391 } catch {
392 self.error = error.localizedDescription
393 }
394 }
395
396 private func fetchLogPage(cursor: String?) async throws -> HgRevisionPage {
397 var variables: [String: any Sendable] = ["rid": repository.rid]
398 if let cursor {
399 variables["cursor"] = cursor
400 }
401
402 let result = try await client.execute(
403 service: .hg,
404 query: Self.logQuery,
405 variables: variables,
406 responseType: HgRevisionLogResponse.self
407 )
408 return result.repository?.log ?? HgRevisionPage(results: [], cursor: nil)
409 }
410
411 private func loadReadmeFile() async {
412 for filename in Self.readmeFilenames {
413 do {
414 let result = try await client.execute(
415 service: .hg,
416 query: Self.readmeFileQuery(filename: filename),
417 variables: ["rid": repository.rid],
418 responseType: HgReadmeFileResponse.self
419 )
420
421 if let text = result.repository?.readme, !text.isEmpty {
422 readmePath = filename
423 if filename.hasSuffix(".md") {
424 readmeContent = .markdown(text)
425 } else if filename.hasSuffix(".org") {
426 readmeContent = .org(text)
427 } else {
428 readmeContent = .plainText(text)
429 }
430 return
431 }
432 } catch {
433 if isEmptyRepositoryError(error) {
434 readmeContent = nil
435 readmePath = nil
436 return
437 }
438 continue
439 }
440 }
441 }
442
443 func loadBrowseRoot() async {
444 await loadFiles(at: "")
445 }
446
447 func openFile(_ file: HgFile) async {
448 let path = joinedPath(for: file.name)
449 if file.isDirectory {
450 await loadFiles(at: path)
451 return
452 }
453
454 isLoadingBrowse = true
455 defer { isLoadingBrowse = false }
456 error = nil
457
458 do {
459 let result = try await client.execute(
460 service: .hg,
461 query: Self.catQuery,
462 variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
463 responseType: HgCatResponse.self
464 )
465
466 if let text = result.repository?.cat {
467 selectedFilePath = path
468 fileContent = text
469 } else {
470 await loadFiles(at: path)
471 }
472 } catch {
473 self.error = error.localizedDescription
474 }
475 }
476
477 func navigateToPath(index: Int) async {
478 guard index >= 0, index <= pathStack.count else { return }
479 let targetPath = Array(pathStack.prefix(index)).joined(separator: "/")
480 await loadFiles(at: targetPath)
481 }
482
483 func dismissFileView() {
484 selectedFilePath = nil
485 fileContent = nil
486 }
487
488 func changeBrowseRevspec(_ newRevspec: String) async {
489 guard browseRevspec != newRevspec else { return }
490 browseRevspec = newRevspec
491 await loadBrowseRoot()
492 }
493
494 private func loadFiles(at path: String) async {
495 isLoadingBrowse = true
496 defer { isLoadingBrowse = false }
497 error = nil
498 selectedFilePath = nil
499 fileContent = nil
500
501 do {
502 let result = try await client.execute(
503 service: .hg,
504 query: Self.filesQuery,
505 variables: ["rid": repository.rid, "path": path, "revspec": browseRevspec],
506 responseType: HgFilesResponse.self
507 )
508
509 currentBrowsePath = path
510 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
511 files = result.repository?.files?.results ?? []
512 } catch {
513 if isEmptyRepositoryError(error) {
514 currentBrowsePath = path
515 pathStack = path.isEmpty ? [] : path.split(separator: "/").map(String.init)
516 files = []
517 } else {
518 self.error = error.localizedDescription
519 }
520 }
521 }
522
523 private func joinedPath(for name: String) -> String {
524 let cleanedName = name.hasSuffix("/") ? String(name.dropLast()) : name
525 return currentBrowsePath.isEmpty ? cleanedName : "\(currentBrowsePath)/\(cleanedName)"
526 }
527
528 private func isEmptyRepositoryError(_ error: Error) -> Bool {
529 if let srhtError = error as? SRHTError,
530 case .graphQLErrors(let errors) = srhtError {
531 return errors.contains {
532 let message = $0.message.localizedLowercase
533 return message.contains("missing")
534 || message.contains("not found")
535 || message.contains("unknown revision")
536 || message.contains("unknown revision or path not in the working tree")
537 }
538 }
539
540 let message = error.localizedDescription.localizedLowercase
541 return message.contains("missing")
542 || message.contains("not found")
543 || message.contains("unknown revision")
544 }
545}
Hutch/Views/Repositories/HgRepositorySettingsView.swift added +239
@@ -0,0 +1,239 @@
1import SwiftUI
2
3struct HgRepositorySettingsView: View {
4 let repository: RepositorySummary
5 let client: SRHTClient
6 let onDeleted: () -> Void
7
8 @Environment(\.dismiss) private var dismiss
9 @State private var viewModel: HgRepositorySettingsViewModel?
10 @State private var showDeleteConfirmation = false
11 @State private var pendingACLDeletion: HgACLEntry?
12
13 var body: some View {
14 NavigationStack {
15 Group {
16 if let viewModel {
17 settingsForm(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading settings…")
20 }
21 }
22 .navigationTitle("Settings")
23 .navigationBarTitleDisplayMode(.inline)
24 .toolbar {
25 ToolbarItem(placement: .cancellationAction) {
26 Button("Done") { dismiss() }
27 }
28 }
29 }
30 .task {
31 if viewModel == nil {
32 let vm = HgRepositorySettingsViewModel(repository: repository, client: client)
33 viewModel = vm
34 async let info: () = vm.loadRepositoryInfo()
35 async let acls: () = vm.loadACLs()
36 _ = await (info, acls)
37 }
38 }
39 }
40
41 @ViewBuilder
42 private func settingsForm(_ viewModel: HgRepositorySettingsViewModel) -> some View {
43 @Bindable var vm = viewModel
44
45 Form {
46 infoSection(viewModel)
47 accessSection(viewModel)
48 featuresSection(viewModel)
49 histeditSection(viewModel)
50 deleteSection(viewModel)
51 }
52 .srhtErrorBanner(error: $vm.error)
53 .alert(
54 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
55 isPresented: $showDeleteConfirmation
56 ) {
57 Button("Cancel", role: .cancel) {}
58 Button("Delete", role: .destructive) {
59 Task {
60 await viewModel.deleteRepository()
61 if viewModel.didDelete {
62 dismiss()
63 onDeleted()
64 }
65 }
66 }
67 } message: {
68 Text("This cannot be undone.")
69 }
70 .alert("Remove Access?", isPresented: Binding(
71 get: { pendingACLDeletion != nil },
72 set: { isPresented in
73 if !isPresented {
74 pendingACLDeletion = nil
75 }
76 }
77 )) {
78 Button("Cancel", role: .cancel) {}
79 Button("Remove Access", role: .destructive) {
80 guard let entry = pendingACLDeletion else { return }
81 Task {
82 await viewModel.deleteACL(entry)
83 pendingACLDeletion = nil
84 }
85 }
86 } message: {
87 if let entry = pendingACLDeletion {
88 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
89 }
90 }
91 }
92
93 @ViewBuilder
94 private func infoSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
95 Section("Info") {
96 LabeledContent("Name") {
97 Text(repository.name)
98 .font(.body.monospaced())
99 }
100
101 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
102 .lineLimit(3...6)
103
104 Picker("Visibility", selection: Bindable(viewModel).editedVisibility) {
105 Text("Public").tag(Visibility.public)
106 Text("Unlisted").tag(Visibility.unlisted)
107 Text("Private").tag(Visibility.private)
108 }
109
110 Button {
111 Task { await viewModel.saveInfo() }
112 } label: {
113 if viewModel.isSavingInfo {
114 ProgressView()
115 .frame(maxWidth: .infinity)
116 } else {
117 Text("Save Changes")
118 .frame(maxWidth: .infinity)
119 }
120 }
121 .disabled(viewModel.isSavingInfo)
122 }
123 }
124
125 @ViewBuilder
126 private func accessSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
127 Section("Access") {
128 if viewModel.isLoadingACLs {
129 HStack {
130 Spacer()
131 ProgressView()
132 Spacer()
133 }
134 } else if viewModel.acls.isEmpty {
135 Text("No access entries yet.")
136 .foregroundStyle(.secondary)
137 } else {
138 ForEach(viewModel.acls) { entry in
139 HStack {
140 Text(entry.entity.canonicalName)
141 Spacer()
142 Text(entry.mode)
143 .font(.caption.monospaced())
144 .foregroundStyle(.secondary)
145 }
146 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
147 Button(role: .destructive) {
148 pendingACLDeletion = entry
149 } label: {
150 Label("Remove Access", systemImage: "trash")
151 }
152 }
153 }
154 }
155
156 HStack {
157 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
158 .autocorrectionDisabled()
159 .textInputAutocapitalization(.never)
160
161 Picker("", selection: Bindable(viewModel).newACLMode) {
162 Text("RO").tag("RO")
163 Text("RW").tag("RW")
164 }
165 .pickerStyle(.segmented)
166 .frame(width: 100)
167
168 Button {
169 Task { await viewModel.addACL() }
170 } label: {
171 if viewModel.isAddingACL {
172 ProgressView()
173 } else {
174 Text("Add")
175 }
176 }
177 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
178 }
179 Text("Add a SourceHut user and choose read-only or read/write access.")
180 .font(.caption)
181 .foregroundStyle(.secondary)
182 }
183 }
184
185 @ViewBuilder
186 private func featuresSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
187 Section("Features") {
188 Toggle("Hide this repository from public listings", isOn: Bindable(viewModel).editedNonPublishing)
189
190 Button {
191 Task { await viewModel.saveInfo() }
192 } label: {
193 if viewModel.isSavingInfo {
194 ProgressView()
195 .frame(maxWidth: .infinity)
196 } else {
197 Text("Save Changes")
198 .frame(maxWidth: .infinity)
199 }
200 }
201 .disabled(viewModel.isSavingInfo)
202 }
203 }
204
205 @ViewBuilder
206 private func histeditSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
207 Section("Histedit") {
208 TextField("Revision hash", text: Bindable(viewModel).histeditRevision)
209 .autocorrectionDisabled()
210 .textInputAutocapitalization(.never)
211 .disabled(true)
212
213 Text("Removing revisions is not available through the public hg.sr.ht API, so Hutch can’t do this yet.")
214 .font(.caption)
215 .foregroundStyle(.secondary)
216
217 Button("Remove Revision", role: .destructive) {}
218 .disabled(true)
219 }
220 }
221
222 @ViewBuilder
223 private func deleteSection(_ viewModel: HgRepositorySettingsViewModel) -> some View {
224 Section {
225 Button(role: .destructive) {
226 showDeleteConfirmation = true
227 } label: {
228 if viewModel.isDeleting {
229 ProgressView()
230 .frame(maxWidth: .infinity)
231 } else {
232 Text("Delete Repository")
233 .frame(maxWidth: .infinity)
234 }
235 }
236 .disabled(viewModel.isDeleting)
237 }
238 }
239}
Hutch/Views/Repositories/HgRepositorySettingsViewModel.swift added +287
@@ -0,0 +1,287 @@
1import Foundation
2
3private struct HgUpdateRepositoryResponse: Decodable, Sendable {
4 let updateRepository: HgUpdatedRepository
5}
6
7private struct HgUpdatedRepository: Decodable, Sendable {
8 let id: Int
9}
10
11private struct HgRepositoryInfoResponse: Decodable, Sendable {
12 let repository: HgRepositoryInfo?
13}
14
15private struct HgRepositoryInfo: Decodable, Sendable {
16 let description: String?
17 let visibility: Visibility
18 let nonPublishing: Bool?
19}
20
21private struct HgACLResponse: Decodable, Sendable {
22 let repository: HgACLRepository?
23}
24
25private struct HgACLRepository: Decodable, Sendable {
26 let accessControlList: HgACLPage
27}
28
29private struct HgACLPage: Decodable, Sendable {
30 let results: [HgACLEntry]
31 let cursor: String?
32}
33
34private struct HgUpdateACLResponse: Decodable, Sendable {
35 let updateACL: HgACLEntry
36}
37
38private struct HgDeleteACLResponse: Decodable, Sendable {
39 let deleteACL: HgDeletedACL
40}
41
42private struct HgDeletedACL: Decodable, Sendable {
43 let id: Int
44}
45
46private struct HgDeleteRepositoryResponse: Decodable, Sendable {
47 let deleteRepository: HgDeletedRepository
48}
49
50private struct HgDeletedRepository: Decodable, Sendable {
51 let id: Int
52}
53
54struct HgACLEntry: Decodable, Sendable, Identifiable {
55 let id: Int
56 let mode: String
57 let entity: Entity
58}
59
60@Observable
61@MainActor
62final class HgRepositorySettingsViewModel {
63 let repositoryId: Int
64 let repositoryRid: String
65 let repositoryName: String
66 private let client: SRHTClient
67
68 var editedDescription: String
69 var editedVisibility: Visibility
70 var editedNonPublishing: Bool
71 var isSavingInfo = false
72
73 private(set) var acls: [HgACLEntry] = []
74 private(set) var isLoadingACLs = false
75 var newACLEntity = ""
76 var newACLMode = "RO"
77 var isAddingACL = false
78 var isDeletingACL = false
79
80 var histeditRevision = ""
81 var isDeleting = false
82 var didDelete = false
83 var error: String?
84
85 init(repository: RepositorySummary, client: SRHTClient) {
86 self.repositoryId = repository.id
87 self.repositoryRid = repository.rid
88 self.repositoryName = repository.name
89 self.client = client
90 self.editedDescription = repository.description ?? ""
91 self.editedVisibility = repository.visibility
92 self.editedNonPublishing = false
93 }
94
95 private static let updateRepositoryMutation = """
96 mutation updateRepository($id: Int!, $input: RepoInput!) {
97 updateRepository(id: $id, input: $input) {
98 id
99 }
100 }
101 """
102
103 private static let accessControlListQuery = """
104 query hgAccessControlList($rid: ID!) {
105 repository(rid: $rid) {
106 accessControlList {
107 results {
108 id
109 mode
110 entity { canonicalName }
111 }
112 cursor
113 }
114 }
115 }
116 """
117
118 private static let updateACLMutation = """
119 mutation updateACL($repoId: Int!, $mode: AccessMode!, $entity: String!) {
120 updateACL(repoId: $repoId, mode: $mode, entity: $entity) {
121 id
122 mode
123 entity { canonicalName }
124 }
125 }
126 """
127
128 private static let deleteACLMutation = """
129 mutation deleteACL($id: Int!) {
130 deleteACL(id: $id) { id }
131 }
132 """
133
134 private static let deleteRepositoryMutation = """
135 mutation deleteRepository($id: Int!) {
136 deleteRepository(id: $id) { id }
137 }
138 """
139
140 private static let repositoryInfoQuery = """
141 query hgRepositoryInfo($rid: ID!) {
142 repository(rid: $rid) {
143 description
144 visibility
145 nonPublishing
146 }
147 }
148 """
149
150 func loadRepositoryInfo() async {
151 error = nil
152
153 do {
154 let result = try await client.execute(
155 service: .hg,
156 query: Self.repositoryInfoQuery,
157 variables: ["rid": repositoryRid],
158 responseType: HgRepositoryInfoResponse.self
159 )
160
161 if let repository = result.repository {
162 editedDescription = repository.description ?? ""
163 editedVisibility = repository.visibility
164 editedNonPublishing = repository.nonPublishing ?? false
165 }
166 } catch {
167 self.error = error.localizedDescription
168 }
169 }
170
171 func saveInfo() async {
172 isSavingInfo = true
173 defer { isSavingInfo = false }
174 error = nil
175
176 do {
177 let input: [String: any Sendable] = [
178 "description": editedDescription,
179 "visibility": editedVisibility.rawValue,
180 "nonPublishing": editedNonPublishing
181 ]
182 _ = try await client.execute(
183 service: .hg,
184 query: Self.updateRepositoryMutation,
185 variables: ["id": repositoryId, "input": input],
186 responseType: HgUpdateRepositoryResponse.self
187 )
188 } catch {
189 self.error = error.localizedDescription
190 }
191 }
192
193 func loadACLs() async {
194 guard !isLoadingACLs else { return }
195 isLoadingACLs = true
196 defer { isLoadingACLs = false }
197 error = nil
198
199 do {
200 let result = try await client.execute(
201 service: .hg,
202 query: Self.accessControlListQuery,
203 variables: ["rid": repositoryRid],
204 responseType: HgACLResponse.self
205 )
206 acls = result.repository?.accessControlList.results ?? []
207 } catch {
208 self.error = error.localizedDescription
209 }
210 }
211
212 func addACL() async {
213 let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
214 guard !rawEntity.isEmpty else { return }
215 let entity = hgCanonicalEntity(from: rawEntity)
216 isAddingACL = true
217 defer { isAddingACL = false }
218 error = nil
219
220 do {
221 let result = try await client.execute(
222 service: .hg,
223 query: Self.updateACLMutation,
224 variables: [
225 "repoId": repositoryId,
226 "mode": newACLMode,
227 "entity": entity
228 ],
229 responseType: HgUpdateACLResponse.self
230 )
231 if let index = acls.firstIndex(where: { $0.id == result.updateACL.id }) {
232 acls[index] = result.updateACL
233 } else {
234 acls.append(result.updateACL)
235 }
236 newACLEntity = ""
237 } catch {
238 let message = error.localizedDescription
239 if message.localizedCaseInsensitiveContains("No such repository or user found") {
240 self.error = "That user is not available on hg.sr.ht yet. They need to create or activate an hg.sr.ht repository first."
241 } else {
242 self.error = message
243 }
244 }
245 }
246
247 private func hgCanonicalEntity(from input: String) -> String {
248 let username = input.hasPrefix("~") ? String(input.dropFirst()) : input
249 return "~\(username)"
250 }
251
252 func deleteACL(_ entry: HgACLEntry) async {
253 isDeletingACL = true
254 defer { isDeletingACL = false }
255 error = nil
256
257 do {
258 _ = try await client.execute(
259 service: .hg,
260 query: Self.deleteACLMutation,
261 variables: ["id": entry.id],
262 responseType: HgDeleteACLResponse.self
263 )
264 acls.removeAll { $0.id == entry.id }
265 } catch {
266 self.error = error.localizedDescription
267 }
268 }
269
270 func deleteRepository() async {
271 isDeleting = true
272 defer { isDeleting = false }
273 error = nil
274
275 do {
276 _ = try await client.execute(
277 service: .hg,
278 query: Self.deleteRepositoryMutation,
279 variables: ["id": repositoryId],
280 responseType: HgDeleteRepositoryResponse.self
281 )
282 didDelete = true
283 } catch {
284 self.error = error.localizedDescription
285 }
286 }
287}
Hutch/Views/Repositories/ReadmeView.swift added +1125
@@ -0,0 +1,1125 @@
1import SwiftUI
2import WebKit
3
4struct ReadmeView: View {
5 let viewModel: RepositoryDetailViewModel
6
7 @Environment(\.colorScheme) private var colorScheme
8 @State private var isShowingRepositoryDetails = false
9
10 var body: some View {
11 ScrollView {
12 VStack(alignment: .leading, spacing: 16) {
13 headerSection
14 metadataSection
15 repositoryDetailsSection
16 latestChangeSection
17 readmeSection
18 }
19 .padding()
20 }
21 .task {
22 async let readme: () = viewModel.loadReadme()
23 async let commits: () = viewModel.loadCommits()
24 async let refs: () = viewModel.loadReferences()
25 _ = await (readme, commits, refs)
26 }
27 .navigationDestination(for: CommitSummary.self) { commit in
28 CommitDetailView(
29 commitSummary: commit,
30 repository: viewModel.repository
31 )
32 }
33 }
34
35 @ViewBuilder
36 private var headerSection: some View {
37 VStack(alignment: .leading, spacing: 6) {
38 Text(viewModel.repository.owner.canonicalName)
39 .font(.subheadline)
40 .foregroundStyle(.secondary)
41 Text(viewModel.repository.name)
42 .font(.largeTitle.weight(.semibold))
43 if let description = viewModel.repository.description, !description.isEmpty {
44 Text(description)
45 .font(.body)
46 }
47 }
48 }
49
50 @ViewBuilder
51 private var metadataSection: some View {
52 VStack(alignment: .leading, spacing: 10) {
53 SummaryMetadataRow(
54 icon: "arrow.triangle.branch",
55 title: viewModel.repository.head?.name ?? repositoryVisibilityLabel(viewModel.repository.visibility)
56 )
57
58 if let readmePath = viewModel.readmePath {
59 SummaryMetadataRow(
60 icon: "doc.text",
61 title: readmePath
62 )
63 }
64 }
65 }
66
67 private var repositoryDetailsSection: some View {
68 DisclosureGroup(isExpanded: $isShowingRepositoryDetails) {
69 VStack(alignment: .leading, spacing: 12) {
70 SummaryDetailRow(label: "Visibility", value: repositoryVisibilityLabel(viewModel.repository.visibility))
71 SummaryDetailRow(label: "Read-only", value: repositoryCloneURLs(for: viewModel.repository).readOnly, monospace: true)
72 SummaryDetailRow(label: "Read/write", value: repositoryCloneURLs(for: viewModel.repository).readWrite, monospace: true)
73 SummaryDetailRow(label: "RID", value: viewModel.repository.rid, monospace: true)
74 }
75 .padding(.top, 8)
76 } label: {
77 Text("Repository Details")
78 .font(.subheadline.weight(.medium))
79 }
80 }
81
82 @ViewBuilder
83 private var latestChangeSection: some View {
84 VStack(alignment: .leading, spacing: 8) {
85 if viewModel.isLoadingCommits && viewModel.commits.isEmpty {
86 SRHTLoadingStateView(message: "Loading latest change…")
87 .frame(maxWidth: .infinity)
88 } else if let commit = viewModel.commits.first {
89 NavigationLink(value: commit) {
90 SummaryMetadataRow(
91 icon: "arrow.trianglehead.clockwise",
92 title: commit.title,
93 subtitle: "\(commit.shortId) — \(commit.author.name) \(commit.author.time.relativeDescription)"
94 )
95 .contentShape(Rectangle())
96 }
97 .buttonStyle(.plain)
98 } else if let error = viewModel.error, viewModel.commits.isEmpty {
99 SRHTErrorStateView(
100 title: "Couldn't Load Latest Change",
101 message: error,
102 retryAction: { await viewModel.loadCommits() }
103 )
104 } else {
105 ContentUnavailableView(
106 "No Recent Commits",
107 systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90",
108 description: Text("This repository does not have any commit history yet.")
109 )
110 }
111 }
112 }
113
114 @ViewBuilder
115 private var readmeSection: some View {
116 if viewModel.isLoadingReadme {
117 SRHTLoadingStateView(message: "Loading README…")
118 } else if let content = viewModel.readmeContent {
119 RenderedMarkupContentView(
120 content: sharedReadmeContent(from: content),
121 readmePath: viewModel.readmePath,
122 colorScheme: colorScheme,
123 ownerCanonicalName: viewModel.repository.owner.canonicalName,
124 repositoryName: viewModel.repository.name
125 )
126 } else if let error = viewModel.error, !viewModel.readmeLoaded {
127 SRHTErrorStateView(
128 title: "Couldn't Load README",
129 message: error,
130 retryAction: { await viewModel.loadReadme() }
131 )
132 } else {
133 ContentUnavailableView(
134 "No README",
135 systemImage: "doc.text",
136 description: Text("This repository does not have a README file.")
137 )
138 }
139 }
140
141 private func sharedReadmeContent(from content: RepositoryDetailViewModel.ReadmeContent) -> RenderedMarkupContent {
142 switch content {
143 case .html(let html):
144 .html(html)
145 case .markdown(let text):
146 .markdown(text)
147 case .org(let text):
148 .org(text)
149 case .plainText(let text):
150 .plainText(text)
151 }
152 }
153}
154
155enum RenderedMarkupContent: Sendable {
156 case html(String)
157 case markdown(String)
158 case org(String)
159 case plainText(String)
160}
161
162struct RenderedMarkupContentView: View {
163 let content: RenderedMarkupContent
164 let readmePath: String?
165 let colorScheme: ColorScheme
166 let ownerCanonicalName: String
167 let repositoryName: String
168 var repositoryHost = "git.sr.ht"
169
170 @State private var renderedHTML: String?
171
172 private var cacheKey: String {
173 switch content {
174 case .html(let html):
175 "html:\(readmePath ?? "custom"):\(html)"
176 case .markdown(let text):
177 "markdown:\(readmePath ?? ""):\(text)"
178 case .org(let text):
179 "org:\(readmePath ?? ""):\(text)"
180 case .plainText(let text):
181 "plain:\(readmePath ?? ""):\(text)"
182 }
183 }
184
185 var body: some View {
186 Group {
187 switch content {
188 case .html(let html):
189 HTMLWebView(html: html, colorScheme: colorScheme)
190 case .markdown, .org:
191 if let renderedHTML {
192 HTMLWebView(html: renderedHTML, colorScheme: colorScheme)
193 } else {
194 SRHTLoadingStateView(message: "Preparing README…")
195 }
196 case .plainText(let text):
197 Text(text)
198 .font(.system(.body, design: .monospaced))
199 .frame(maxWidth: .infinity, alignment: .leading)
200 }
201 }
202 .task(id: cacheKey) {
203 await prepareHTMLIfNeeded()
204 }
205 }
206
207 private func prepareHTMLIfNeeded() async {
208 switch content {
209 case .html, .plainText:
210 renderedHTML = nil
211 case .markdown(let text):
212 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
213 renderedHTML = cached
214 return
215 }
216 let html = await Task.detached(priority: .userInitiated) {
217 markdownToHTML(text) { source in
218 resolveRepositoryAssetURL(
219 source,
220 owner: ownerCanonicalName,
221 repositoryName: repositoryName,
222 readmePath: readmePath
223 )?
224 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
225 }
226 }.value
227 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
228 guard !Task.isCancelled else { return }
229 renderedHTML = html
230 case .org(let text):
231 if let cached = RenderedReadmeHTMLCache.shared.html(forKey: cacheKey) {
232 renderedHTML = cached
233 return
234 }
235 let html = await Task.detached(priority: .userInitiated) {
236 orgToHTML(text) { source in
237 resolveRepositoryAssetURL(
238 source,
239 owner: ownerCanonicalName,
240 repositoryName: repositoryName,
241 readmePath: readmePath
242 )?
243 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
244 }
245 }.value
246 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
247 guard !Task.isCancelled else { return }
248 renderedHTML = html
249 }
250 }
251}
252
253private final class RenderedReadmeHTMLCache: @unchecked Sendable {
254 static let shared = RenderedReadmeHTMLCache()
255
256 private let storage = NSCache<NSString, NSString>()
257
258 func html(forKey key: String) -> String? {
259 storage.object(forKey: key as NSString) as String?
260 }
261
262 func setHTML(_ html: String, forKey key: String) {
263 storage.setObject(html as NSString, forKey: key as NSString)
264 }
265
266 func removeAll() {
267 storage.removeAllObjects()
268 }
269}
270
271@MainActor
272func clearWebContentRenderCaches() {
273 RenderedReadmeHTMLCache.shared.removeAll()
274 HTMLWebViewCoordinator.heightCache.removeAllObjects()
275}
276
277// MARK: - Markdown to HTML
278
279nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
280 let normalizedText = text
281 .replacingOccurrences(of: "\r\n", with: "\n")
282 .replacingOccurrences(of: "\r", with: "\n")
283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
284 var html = ""
285 var inCodeBlock = false
286 var inList = false
287 var paragraph: [String] = []
288
289 func flushParagraph() {
290 if !paragraph.isEmpty {
291 let normalizedParagraph = paragraph
292 .map { $0.trimmingCharacters(in: .whitespaces) }
293 .joined(separator: " ")
294 html += "<p>" + normalizedParagraph + "</p>\n"
295 paragraph = []
296 }
297 }
298
299 func closeList() {
300 if inList {
301 html += "</ul>\n"
302 inList = false
303 }
304 }
305
306 for line in lines {
307 // Fenced code blocks
308 if line.hasPrefix("```") {
309 if inCodeBlock {
310 html += "</code></pre>\n"
311 inCodeBlock = false
312 } else {
313 flushParagraph()
314 closeList()
315 html += "<pre><code>"
316 inCodeBlock = true
317 }
318 continue
319 }
320
321 if inCodeBlock {
322 html += escapeHTML(line) + "\n"
323 continue
324 }
325
326 // Headings
327 if line.hasPrefix("### ") {
328 flushParagraph()
329 closeList()
330 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
331 continue
332 }
333 if line.hasPrefix("## ") {
334 flushParagraph()
335 closeList()
336 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
337 continue
338 }
339 if line.hasPrefix("# ") {
340 flushParagraph()
341 closeList()
342 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
343 continue
344 }
345
346 // List items
347 let trimmed = line.trimmingCharacters(in: .whitespaces)
348 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
349 flushParagraph()
350 if !inList {
351 html += "<ul>\n"
352 inList = true
353 }
354 html += "<li>" + renderTaskListItem(
355 String(trimmed.dropFirst(2)),
356 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
357 ) + "</li>\n"
358 continue
359 }
360
361 // Blank line
362 if trimmed.isEmpty {
363 flushParagraph()
364 closeList()
365 continue
366 }
367
368 // Regular text — accumulate into paragraph
369 paragraph.append(processInline(line, imageURLResolver: imageURLResolver))
370 }
371
372 // Flush remaining state
373 if inCodeBlock {
374 html += "</code></pre>\n"
375 }
376 flushParagraph()
377 closeList()
378
379 return html
380}
381
382nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
383 var result = escapeHTML(text)
384
385 // Images: ![alt](url)
386 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
387 let alt = nsText.substring(with: match.range(at: 1))
388 let source = nsText.substring(with: match.range(at: 2))
389 let resolvedSource = imageURLResolver?(source) ?? source
390 return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
391 }
392 // Links: [text](url)
393 result = result.replacingOccurrences(
394 of: #"\[([^\]]+)\]\(([^)]+)\)"#,
395 with: #"<a href="$2">$1</a>"#,
396 options: .regularExpression
397 )
398 // Bold: **text**
399 result = result.replacingOccurrences(
400 of: #"\*\*(.+?)\*\*"#,
401 with: "<strong>$1</strong>",
402 options: .regularExpression
403 )
404 // Italic: *text*
405 result = result.replacingOccurrences(
406 of: #"\*(.+?)\*"#,
407 with: "<em>$1</em>",
408 options: .regularExpression
409 )
410 // Inline code: `text`
411 result = result.replacingOccurrences(
412 of: #"`([^`]+)`"#,
413 with: "<code>$1</code>",
414 options: .regularExpression
415 )
416
417 return result
418}
419
420// MARK: - Org-mode to HTML
421
422nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
423 let normalizedText = text
424 .replacingOccurrences(of: "\r\n", with: "\n")
425 .replacingOccurrences(of: "\r", with: "\n")
426 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
427 var html = ""
428 var listType: OrgListType?
429 var inQuoteBlock = false
430 var inPropertyDrawer = false
431 var srcLanguage: String?
432 var paragraph: [String] = []
433 var tableRows: [[String]] = []
434 var propertyRows: [(String, String)] = []
435
436 func flushParagraph() {
437 if !paragraph.isEmpty {
438 let normalizedParagraph = paragraph
439 .map { $0.trimmingCharacters(in: .whitespaces) }
440 .joined(separator: " ")
441 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver) + "</p>\n"
442 paragraph = []
443 }
444 }
445
446 func closeList() {
447 switch listType {
448 case .unordered:
449 html += "</ul>\n"
450 case .ordered:
451 html += "</ol>\n"
452 case nil:
453 break
454 }
455 listType = nil
456 }
457
458 func flushTable() {
459 guard !tableRows.isEmpty else { return }
460 let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
461 let headerRow = tableRows.first ?? []
462 let bodyRows: [[String]]
463
464 html += "<table>\n"
465 if hasHeaderSeparator {
466 html += "<thead><tr>"
467 for cell in headerRow {
468 html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
469 }
470 html += "</tr></thead>\n<tbody>\n"
471 bodyRows = Array(tableRows.dropFirst(2))
472 } else {
473 bodyRows = tableRows
474 }
475
476 for row in bodyRows {
477 html += "<tr>"
478 for cell in row {
479 html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
480 }
481 html += "</tr>\n"
482 }
483
484 if hasHeaderSeparator {
485 html += "</tbody>\n"
486 }
487 html += "</table>\n"
488 tableRows = []
489 }
490
491 func flushPropertyDrawer() {
492 guard !propertyRows.isEmpty else { return }
493 html += "<dl class=\"org-properties\">\n"
494 for (key, value) in propertyRows {
495 html += "<dt>" + escapeHTML(key) + "</dt>"
496 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver) + "</dd>\n"
497 }
498 html += "</dl>\n"
499 propertyRows = []
500 }
501
502 func closeQuoteBlock() {
503 if inQuoteBlock {
504 flushParagraph()
505 html += "</blockquote>\n"
506 inQuoteBlock = false
507 }
508 }
509
510 func closeSourceBlock() {
511 if srcLanguage != nil {
512 html += "</code></pre>\n"
513 srcLanguage = nil
514 }
515 }
516
517 func flushBlockState() {
518 flushParagraph()
519 closeList()
520 flushTable()
521 flushPropertyDrawer()
522 }
523
524 for line in lines {
525 let trimmed = line.trimmingCharacters(in: .whitespaces)
526
527 if srcLanguage != nil {
528 if trimmed.lowercased() == "#+end_src" {
529 closeSourceBlock()
530 } else {
531 html += escapeHTML(line) + "\n"
532 }
533 continue
534 }
535
536 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
537 closeQuoteBlock()
538 continue
539 }
540
541 if trimmed.lowercased().hasPrefix("#+begin_src") {
542 closeQuoteBlock()
543 flushBlockState()
544 let language = trimmed
545 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
546 .dropFirst()
547 .first
548 .map(String.init)?
549 .trimmingCharacters(in: .whitespacesAndNewlines)
550 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
551 html += "<pre><code\(classAttribute)>"
552 srcLanguage = language ?? ""
553 continue
554 }
555
556 if trimmed.lowercased() == "#+begin_quote" {
557 flushBlockState()
558 html += "<blockquote>\n"
559 inQuoteBlock = true
560 continue
561 }
562
563 if trimmed == ":PROPERTIES:" {
564 closeQuoteBlock()
565 flushBlockState()
566 inPropertyDrawer = true
567 continue
568 }
569
570 if trimmed == ":END:", inPropertyDrawer {
571 flushPropertyDrawer()
572 inPropertyDrawer = false
573 continue
574 }
575
576 if inPropertyDrawer,
577 trimmed.hasPrefix(":"),
578 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
579 let keyStart = trimmed.index(after: trimmed.startIndex)
580 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
581 let valueStart = trimmed.index(after: secondColonIndex)
582 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
583 if !key.isEmpty {
584 propertyRows.append((key, value))
585 continue
586 }
587 }
588
589 if isOrgTableLine(trimmed) {
590 closeQuoteBlock()
591 flushParagraph()
592 closeList()
593 tableRows.append(parseOrgTableRow(trimmed))
594 continue
595 } else {
596 flushTable()
597 }
598
599 // Org headings: * heading, ** heading, *** heading
600 if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
601 closeQuoteBlock()
602 flushBlockState()
603 let level = match.1.count
604 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver)
605 html += "<h\(level)>" + content + "</h\(level)>\n"
606 continue
607 }
608
609 // List items: - item
610 if trimmed.hasPrefix("- ") {
611 flushParagraph()
612 flushPropertyDrawer()
613 if listType != .unordered {
614 closeList()
615 html += "<ul>\n"
616 listType = .unordered
617 }
618 html += "<li>" + renderTaskListItem(
619 String(trimmed.dropFirst(2)),
620 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
621 ) + "</li>\n"
622 continue
623 }
624
625 if let orderedItem = orderedListItem(in: trimmed) {
626 flushParagraph()
627 flushPropertyDrawer()
628 if listType != .ordered {
629 closeList()
630 html += "<ol>\n"
631 listType = .ordered
632 }
633 html += "<li>" + renderTaskListItem(
634 orderedItem,
635 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
636 ) + "</li>\n"
637 continue
638 }
639
640 // Blank line
641 if trimmed.isEmpty {
642 if inQuoteBlock {
643 flushParagraph()
644 } else {
645 flushBlockState()
646 }
647 continue
648 }
649
650 // Regular text
651 paragraph.append(line)
652 }
653
654 closeSourceBlock()
655 closeQuoteBlock()
656 flushBlockState()
657
658 return html
659}
660
661nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
662 var result = escapeHTML(text)
663 var protectedFragments: [String: String] = [:]
664
665 result = protectMatches(
666 in: result,
667 pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#,
668 protectedFragments: &protectedFragments
669 ) { match, nsText in
670 let url = nsText.substring(with: match.range(at: 1))
671 let label = nsText.substring(with: match.range(at: 2))
672 if let imageHTML = makeOrgImageHTML(
673 source: url,
674 alt: label,
675 imageURLResolver: imageURLResolver
676 ) {
677 return imageHTML
678 }
679 return #"<a href="\#(url)">\#(label)</a>"#
680 }
681 result = protectMatches(
682 in: result,
683 pattern: #"\[\[([^\]]+)\]\]"#,
684 protectedFragments: &protectedFragments
685 ) { match, nsText in
686 let url = nsText.substring(with: match.range(at: 1))
687 if let imageHTML = makeOrgImageHTML(
688 source: url,
689 alt: nil,
690 imageURLResolver: imageURLResolver
691 ) {
692 return imageHTML
693 }
694 return #"<a href="\#(url)">\#(url)</a>"#
695 }
696 result = protectMatches(
697 in: result,
698 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
699 protectedFragments: &protectedFragments
700 ) { match, nsText in
701 let tildeRange = match.range(at: 1)
702 let equalsRange = match.range(at: 2)
703 let codeText: String
704 if tildeRange.location != NSNotFound {
705 codeText = nsText.substring(with: tildeRange)
706 } else {
707 codeText = nsText.substring(with: equalsRange)
708 }
709 return "<code>\(codeText)</code>"
710 }
711
712 // Bold: *text*
713 result = result.replacingOccurrences(
714 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
715 with: "<strong>$1</strong>",
716 options: .regularExpression
717 )
718 // Italic: /text/
719 result = result.replacingOccurrences(
720 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
721 with: "<em>$1</em>",
722 options: .regularExpression
723 )
724
725 for (token, fragment) in protectedFragments {
726 result = result.replacingOccurrences(of: token, with: fragment)
727 }
728
729 return result
730}
731
732// MARK: - HTML Escaping
733
734nonisolated func escapeHTML(_ text: String) -> String {
735 text.replacingOccurrences(of: "&", with: "&amp;")
736 .replacingOccurrences(of: "<", with: "&lt;")
737 .replacingOccurrences(of: ">", with: "&gt;")
738 .replacingOccurrences(of: "\"", with: "&quot;")
739}
740
741nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
742 escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
743}
744
745nonisolated private func isOrgTableLine(_ line: String) -> Bool {
746 line.hasPrefix("|") && line.hasSuffix("|")
747}
748
749nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
750 line
751 .split(separator: "|", omittingEmptySubsequences: false)
752 .dropFirst()
753 .dropLast()
754 .map { String($0).trimmingCharacters(in: .whitespaces) }
755}
756
757nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
758 let trimmed = cell.trimmingCharacters(in: .whitespaces)
759 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
760}
761
762private enum OrgListType {
763 case unordered
764 case ordered
765}
766
767nonisolated private func orderedListItem(in line: String) -> String? {
768 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
769 return String(match.2)
770}
771
772nonisolated private func protectMatches(
773 in text: String,
774 pattern: String,
775 protectedFragments: inout [String: String],
776 transform: (NSTextCheckingResult, NSString) -> String
777) -> String {
778 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
779 var result = text
780 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
781
782 for match in matches.reversed() {
783 let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
784 let nsText = result as NSString
785 protectedFragments[token] = transform(match, nsText)
786 result = nsText.replacingCharacters(in: match.range, with: token)
787 }
788
789 return result
790}
791
792nonisolated private func replaceMatches(
793 in text: String,
794 pattern: String,
795 transform: (NSTextCheckingResult, NSString) -> String
796) -> String {
797 guard let regex = try? NSRegularExpression(pattern: pattern) else { return text }
798 var result = text
799 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
800
801 for match in matches.reversed() {
802 let nsText = result as NSString
803 let replacement = transform(match, nsText)
804 result = nsText.replacingCharacters(in: match.range, with: replacement)
805 }
806
807 return result
808}
809
810nonisolated private func makeOrgImageHTML(
811 source: String,
812 alt: String?,
813 imageURLResolver: ((String) -> String?)?
814) -> String? {
815 guard isRenderableImageSource(source) else { return nil }
816 let resolvedSource = imageURLResolver?(source) ?? source
817 let altText = escapeHTMLAttribute(alt ?? "")
818 return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
819}
820
821nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
822 let lowercased = source.lowercased()
823 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
824 .contains(where: { lowercased.hasSuffix($0) })
825}
826
827nonisolated func resolveRepositoryAssetURL(
828 _ source: String,
829 owner: String,
830 repositoryName: String,
831 readmePath: String?
832) -> String? {
833 let trimmedSource = source.trimmingCharacters(in: .whitespacesAndNewlines)
834 guard !trimmedSource.isEmpty else { return nil }
835
836 if trimmedSource.hasPrefix("http://") || trimmedSource.hasPrefix("https://") || trimmedSource.hasPrefix("data:") {
837 return trimmedSource
838 }
839
840 let relativePath: String
841 if trimmedSource.hasPrefix("/") {
842 relativePath = String(trimmedSource.dropFirst())
843 } else {
844 let readmeDirectory = (readmePath as NSString?)?.deletingLastPathComponent ?? ""
845 relativePath = normalizeRepositoryPath(
846 (readmeDirectory as NSString).appendingPathComponent(trimmedSource)
847 )
848 }
849
850 guard !relativePath.isEmpty else { return nil }
851 return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
852}
853
854nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
855 var components: [String] = []
856
857 for part in path.split(separator: "/") {
858 switch part {
859 case ".":
860 continue
861 case "..":
862 if !components.isEmpty {
863 components.removeLast()
864 }
865 default:
866 components.append(String(part))
867 }
868 }
869
870 return components.joined(separator: "/")
871}
872
873nonisolated private func renderTaskListItem(
874 _ text: String,
875 inlineRenderer: (String) -> String
876) -> String {
877 let trimmed = text.trimmingCharacters(in: .whitespaces)
878 guard trimmed.count >= 4 else {
879 return inlineRenderer(text)
880 }
881
882 let prefix = String(trimmed.prefix(4))
883 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
884
885 switch prefix {
886 case "[ ] ":
887 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
888 case "[x] ", "[X] ":
889 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
890 default:
891 return inlineRenderer(text)
892 }
893}
894
895// MARK: - WKWebView Wrapper
896
897/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
898struct HTMLWebView: View {
899 let html: String
900 let colorScheme: ColorScheme
901 var style: HTMLWebViewStyle = .readme
902 @State private var contentHeight: CGFloat = 1
903 @State private var loadError: String?
904 @State private var reloadToken = 0
905
906 var body: some View {
907 Group {
908 if let loadError {
909 SRHTErrorStateView(
910 title: "Couldn't Render Content",
911 message: loadError,
912 retryAction: {
913 await MainActor.run {
914 self.loadError = nil
915 reloadToken += 1
916 }
917 }
918 )
919 } else {
920 HTMLWebViewRepresentable(
921 html: html,
922 colorScheme: colorScheme,
923 style: style,
924 dynamicHeight: $contentHeight,
925 loadError: $loadError,
926 reloadToken: reloadToken
927 )
928 .frame(height: max(contentHeight, 1))
929 }
930 }
931 }
932}
933
934struct HTMLWebViewStyle: Sendable {
935 let bodyFontSize: Int
936 let lineHeight: Double
937 let codeFontSize: Int
938 let viewport: String
939
940 static let readme = HTMLWebViewStyle(
941 bodyFontSize: 16,
942 lineHeight: 1.6,
943 codeFontSize: 13,
944 viewport: "width=device-width, initial-scale=1, maximum-scale=1"
945 )
946
947 static let commentPreview = HTMLWebViewStyle(
948 bodyFontSize: 15,
949 lineHeight: 1.5,
950 codeFontSize: 12,
951 viewport: "width=device-width, initial-scale=1, user-scalable=no"
952 )
953}
954
955private struct HTMLWebViewRepresentable: UIViewRepresentable {
956 let html: String
957 let colorScheme: ColorScheme
958 let style: HTMLWebViewStyle
959 @Binding var dynamicHeight: CGFloat
960 @Binding var loadError: String?
961 let reloadToken: Int
962
963 func makeCoordinator() -> HTMLWebViewCoordinator {
964 HTMLWebViewCoordinator(parent: self)
965 }
966
967 func makeUIView(context: Context) -> WKWebView {
968 let config = WKWebViewConfiguration()
969 config.defaultWebpagePreferences.allowsContentJavaScript = true
970 config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
971 let webView = WKWebView(frame: .zero, configuration: config)
972 webView.isOpaque = false
973 webView.backgroundColor = .clear
974 webView.clipsToBounds = false
975 webView.scrollView.isScrollEnabled = false
976 webView.scrollView.contentInsetAdjustmentBehavior = .never
977 webView.scrollView.clipsToBounds = false
978 webView.navigationDelegate = context.coordinator
979 return webView
980 }
981
982 func updateUIView(_ webView: WKWebView, context: Context) {
983 let textColor = colorScheme == .dark ? "#fff" : "#000"
984 let linkColor = colorScheme == .dark ? "#58a6ff" : "#0066cc"
985
986 let wrapped = """
987 <!DOCTYPE html>
988 <html>
989 <head>
990 <meta name="viewport" content="\(style.viewport)">
991 <style>
992 body {
993 font-family: -apple-system, system-ui, sans-serif;
994 font-size: \(style.bodyFontSize)px;
995 line-height: \(style.lineHeight);
996 padding: 0;
997 margin: 0;
998 color: \(textColor);
999 background: transparent;
1000 word-wrap: break-word;
1001 overflow-wrap: break-word;
1002 max-width: 100%;
1003 }
1004 * { box-sizing: border-box; }
1005 h1, h2, h3, h4, h5, h6 { line-height: 1.25; }
1006 p:first-child { margin-top: 0; }
1007 p:last-child { margin-bottom: 0; }
1008 pre, code {
1009 font-family: ui-monospace, Menlo, monospace;
1010 font-size: \(style.codeFontSize)px;
1011 background: rgba(128, 128, 128, 0.15);
1012 padding: 2px 4px;
1013 border-radius: 3px;
1014 }
1015 pre code { padding: 0; background: none; }
1016 pre {
1017 padding: 8px;
1018 overflow-x: auto;
1019 white-space: pre-wrap;
1020 word-wrap: break-word;
1021 }
1022 img { max-width: 100%; height: auto; }
1023 input[type="checkbox"] {
1024 margin-right: 0.45rem;
1025 vertical-align: middle;
1026 }
1027 .task-list-item {
1028 display: inline-flex;
1029 align-items: center;
1030 gap: 0.1rem;
1031 }
1032 a { color: \(linkColor); }
1033 table { border-collapse: collapse; width: 100%; }
1034 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1035 </style>
1036 </head>
1037 <body>\(html)</body>
1038 </html>
1039 """
1040
1041 if let cachedHeight = HTMLWebViewCoordinator.heightCache.object(forKey: wrapped as NSString)?.doubleValue {
1042 let height = CGFloat(cachedHeight)
1043 if abs(dynamicHeight - height) > 0.5 {
1044 dynamicHeight = height
1045 }
1046 }
1047
1048 guard context.coordinator.lastHTML != wrapped || context.coordinator.lastReloadToken != reloadToken else { return }
1049 context.coordinator.lastHTML = wrapped
1050 context.coordinator.lastReloadToken = reloadToken
1051 if loadError != nil {
1052 DispatchQueue.main.async {
1053 self.loadError = nil
1054 }
1055 }
1056 webView.loadHTMLString(wrapped, baseURL: nil)
1057 }
1058}
1059
1060private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unchecked Sendable {
1061 static let websiteDataStore = WKWebsiteDataStore.nonPersistent()
1062 static let heightCache = NSCache<NSString, NSNumber>()
1063
1064 let parent: HTMLWebViewRepresentable
1065 var lastHTML: String?
1066 var lastReloadToken = 0
1067
1068 init(parent: HTMLWebViewRepresentable) {
1069 self.parent = parent
1070 }
1071
1072 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1073 DispatchQueue.main.async {
1074 self.parent.loadError = nil
1075 }
1076 updateHeight(for: webView)
1077 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak webView] in
1078 guard let self, let webView else { return }
1079 self.updateHeight(for: webView)
1080 }
1081 }
1082
1083 func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
1084 handleLoadFailure(error)
1085 }
1086
1087 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1088 handleLoadFailure(error)
1089 }
1090
1091 private func handleLoadFailure(_ error: Error) {
1092 let nsError = error as NSError
1093 guard nsError.code != NSURLErrorCancelled else { return }
1094 DispatchQueue.main.async {
1095 self.parent.loadError = "The content could not be displayed right now."
1096 }
1097 }
1098
1099 private func updateHeight(for webView: WKWebView) {
1100 let script = """
1101 Math.max(
1102 document.body.scrollHeight,
1103 document.body.offsetHeight,
1104 document.documentElement.scrollHeight,
1105 document.documentElement.offsetHeight,
1106 Math.ceil(document.body.getBoundingClientRect().height),
1107 Math.ceil(document.documentElement.getBoundingClientRect().height)
1108 )
1109 """
1110
1111 webView.evaluateJavaScript(script) { [weak self] result, _ in
1112 guard let value = result as? Double, value > 0 else { return }
1113 let height = ceil(value) + 4
1114 DispatchQueue.main.async {
1115 guard let self else { return }
1116 if let html = self.lastHTML {
1117 Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
1118 }
1119 if abs(self.parent.dynamicHeight - height) > 0.5 {
1120 self.parent.dynamicHeight = height
1121 }
1122 }
1123 }
1124 }
1125}
Hutch/Views/Repositories/ReferencesListView.swift added +90
@@ -0,0 +1,90 @@
1import SwiftUI
2
3struct ReferencesListView: View {
4 let viewModel: RepositoryDetailViewModel
5
6 var body: some View {
7 List {
8 if !viewModel.branches.isEmpty {
9 Section("Branches") {
10 ForEach(viewModel.branches, id: \.name) { ref in
11 ReferenceRow(reference: ref, prefix: "refs/heads/")
12 }
13 }
14 }
15
16 if !viewModel.tags.isEmpty {
17 Section("Tags") {
18 ForEach(viewModel.tags, id: \.name) { ref in
19 ReferenceRow(reference: ref, prefix: "refs/tags/")
20 }
21 }
22 }
23 }
24 .listStyle(.insetGrouped)
25 .overlay {
26 if viewModel.isLoadingRefs, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
27 SRHTLoadingStateView(message: "Loading references…")
28 } else if let error = viewModel.error, viewModel.branches.isEmpty, viewModel.tags.isEmpty {
29 SRHTErrorStateView(
30 title: "Couldn't Load References",
31 message: error,
32 retryAction: { await viewModel.loadReferences() }
33 )
34 } else if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
35 ContentUnavailableView(
36 "No References",
37 systemImage: "arrow.triangle.branch",
38 description: Text("This repository has no branches or tags.")
39 )
40 }
41 }
42 .task {
43 if viewModel.branches.isEmpty, viewModel.tags.isEmpty {
44 await viewModel.loadReferences()
45 }
46 }
47 .refreshable {
48 await viewModel.loadReferences()
49 }
50 }
51}
52
53private struct ReferenceRow: View {
54 let reference: Reference
55 let prefix: String
56
57 var body: some View {
58 HStack {
59 Label {
60 Text(shortName)
61 .font(.body.monospaced())
62 } icon: {
63 Image(systemName: icon)
64 .foregroundStyle(iconColor)
65 }
66
67 Spacer()
68
69 Text(String((reference.target ?? "").prefix(8)))
70 .font(.caption.monospaced())
71 .foregroundStyle(.secondary)
72 }
73 }
74
75 private var shortName: String {
76 if reference.name.hasPrefix(prefix) {
77 String(reference.name.dropFirst(prefix.count))
78 } else {
79 reference.name
80 }
81 }
82
83 private var icon: String {
84 prefix.contains("tags") ? "tag" : "arrow.triangle.branch"
85 }
86
87 private var iconColor: Color {
88 prefix.contains("tags") ? .orange : .blue
89 }
90}
Hutch/Views/Repositories/RepositoryDetailView.swift added +107
@@ -0,0 +1,107 @@
1import SwiftUI
2
3struct RepositoryDetailView: View {
4 var repository: RepositorySummary
5 var onDeleted: (() -> Void)?
6
7 @Environment(AppState.self) private var appState
8 @Environment(\.dismiss) private var dismiss
9 @State private var viewModel: RepositoryDetailViewModel?
10 @State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
11 @State private var showSettings = false
12 @State private var displayName: String
13
14 init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) {
15 self.repository = repository
16 self.onDeleted = onDeleted
17 self._displayName = State(initialValue: repository.name)
18 }
19
20 var body: some View {
21 if repository.service == .hg {
22 HgRepositoryDetailView(repository: repository, onDeleted: onDeleted)
23 } else {
24 Group {
25 if let viewModel {
26 detailContent(viewModel)
27 } else {
28 SRHTLoadingStateView(message: "Loading repository…")
29 }
30 }
31 .navigationTitle(displayName)
32 .navigationBarTitleDisplayMode(.inline)
33 .toolbar {
34 ToolbarItemGroup(placement: .topBarTrailing) {
35 SRHTShareButton(url: SRHTWebURL.repository(repository), target: .repository) {
36 Image(systemName: "square.and.arrow.up")
37 }
38
39 Button {
40 showSettings = true
41 } label: {
42 Image(systemName: "gear")
43 }
44 }
45 }
46 .sheet(isPresented: $showSettings) {
47 RepositorySettingsView(
48 repository: repository,
49 branches: viewModel?.branches ?? [],
50 client: appState.client,
51 onRenamed: { newName in
52 displayName = newName
53 },
54 onDeleted: {
55 dismiss()
56 onDeleted?()
57 }
58 )
59 }
60 .task {
61 if viewModel == nil {
62 viewModel = RepositoryDetailViewModel(
63 repository: repository,
64 client: appState.client
65 )
66 }
67 }
68 }
69 }
70
71 @ViewBuilder
72 private func detailContent(_ viewModel: RepositoryDetailViewModel) -> some View {
73 VStack(spacing: 0) {
74 Picker("Tab", selection: $selectedTab) {
75 ForEach(RepositoryDetailViewModel.Tab.allCases, id: \.self) { tab in
76 Text(tab.rawValue).tag(tab)
77 }
78 }
79 .pickerStyle(.segmented)
80 .padding(.horizontal)
81 .padding(.vertical, 8)
82
83 Divider()
84
85 switch selectedTab {
86 case .summary:
87 ReadmeView(viewModel: viewModel)
88 case .tree:
89 FileTreeView(
90 repository: repository,
91 client: appState.client
92 )
93 case .log:
94 CommitLogView(viewModel: viewModel)
95 case .refs:
96 ReferencesListView(viewModel: viewModel)
97 case .artifacts:
98 ArtifactsView(viewModel: viewModel)
99 }
100 }
101 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
102 .srhtErrorBanner(error: Binding(
103 get: { viewModel.error },
104 set: { viewModel.error = $0 }
105 ))
106 }
107}
Hutch/Views/Repositories/RepositoryDetailViewModel.swift added +397
@@ -0,0 +1,397 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct LogResponse: Decodable, Sendable {
6 let repository: LogRepository?
7}
8
9private struct LogRepository: Decodable, Sendable {
10 let log: LogPage
11}
12
13private struct LogPage: Decodable, Sendable {
14 let results: [CommitSummary]
15 let cursor: String?
16}
17
18private struct RefsResponse: Decodable, Sendable {
19 let repository: RefsRepository?
20}
21
22private struct RefsRepository: Decodable, Sendable {
23 let references: RefsPage
24}
25
26private struct RefsPage: Decodable, Sendable {
27 let results: [Reference]
28 let cursor: String?
29}
30
31private struct ReadmeResponse: Decodable, Sendable {
32 let repository: ReadmeRepository?
33}
34
35private struct ReadmeRepository: Decodable, Sendable {
36 let readme: String?
37}
38
39private struct PathResponse: Decodable, Sendable {
40 let repository: PathRepository?
41}
42
43private struct PathRepository: Decodable, Sendable {
44 let readme: PathEntry?
45}
46
47private struct PathEntry: Decodable, Sendable {
48 let object: PathObject?
49}
50
51private struct PathObject: Decodable, Sendable {
52 let text: String?
53}
54
55private struct ArtifactsResponse: Decodable, Sendable {
56 let repository: ArtifactsRepository?
57}
58
59private struct ArtifactsRepository: Decodable, Sendable {
60 let references: ArtifactRefsPage
61}
62
63private struct ArtifactRefsPage: Decodable, Sendable {
64 let results: [ArtifactRef]
65 let cursor: String?
66}
67
68private struct ArtifactRef: Decodable, Sendable {
69 let name: String
70 let artifacts: ArtifactPage
71}
72
73// MARK: - View Model
74
75@Observable
76@MainActor
77final class RepositoryDetailViewModel {
78
79 enum Tab: String, CaseIterable {
80 case summary = "Summary"
81 case tree = "Tree"
82 case log = "Log"
83 case refs = "Refs"
84 case artifacts = "Artifacts"
85 }
86
87 let repository: RepositorySummary
88 private var service: SRHTService { repository.service }
89 private let client: SRHTClient
90
91 // MARK: - Commit log state
92
93 private(set) var commits: [CommitSummary] = []
94 private(set) var isLoadingCommits = false
95 private(set) var isLoadingMoreCommits = false
96 private var commitCursor: String?
97 private var hasMoreCommits = true
98
99 // MARK: - References state
100
101 private(set) var branches: [Reference] = []
102 private(set) var tags: [Reference] = []
103 private(set) var isLoadingRefs = false
104
105 // MARK: - README state
106
107 enum ReadmeContent {
108 case html(String)
109 case markdown(String)
110 case org(String)
111 case plainText(String)
112 }
113
114 private(set) var readmeContent: ReadmeContent?
115 private(set) var readmePath: String?
116 private(set) var isLoadingReadme = false
117 private(set) var readmeLoaded = false
118
119 // MARK: - Artifacts state
120
121 private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
122 private(set) var isLoadingArtifacts = false
123
124 // MARK: - Error
125
126 var error: String?
127
128 init(repository: RepositorySummary, client: SRHTClient) {
129 self.repository = repository
130 self.client = client
131 }
132
133 // MARK: - Commit log
134
135 private static let logQuery = """
136 query repoLog($rid: ID!, $cursor: Cursor) {
137 repository(rid: $rid) {
138 log(cursor: $cursor) {
139 results {
140 id
141 shortId
142 author { name email time }
143 message
144 }
145 cursor
146 }
147 }
148 }
149 """
150
151 func loadCommits() async {
152 guard !isLoadingCommits else { return }
153 isLoadingCommits = true
154 error = nil
155 commitCursor = nil
156 hasMoreCommits = true
157
158 do {
159 let page = try await fetchCommitPage(cursor: nil)
160 commits = page.results
161 commitCursor = page.cursor
162 hasMoreCommits = page.cursor != nil
163 } catch {
164 self.error = error.localizedDescription
165 }
166
167 isLoadingCommits = false
168 }
169
170 func loadMoreCommitsIfNeeded(currentItem: CommitSummary) async {
171 guard let last = commits.last,
172 last.id == currentItem.id,
173 hasMoreCommits,
174 !isLoadingMoreCommits else {
175 return
176 }
177
178 isLoadingMoreCommits = true
179
180 do {
181 let page = try await fetchCommitPage(cursor: commitCursor)
182 commits.append(contentsOf: page.results)
183 commitCursor = page.cursor
184 hasMoreCommits = page.cursor != nil
185 } catch {
186 self.error = error.localizedDescription
187 }
188
189 isLoadingMoreCommits = false
190 }
191
192 private func fetchCommitPage(cursor: String?) async throws -> LogPage {
193 var variables: [String: any Sendable] = ["rid": repository.rid]
194 if let cursor {
195 variables["cursor"] = cursor
196 }
197 let result: LogResponse
198 do {
199 result = try await client.execute(
200 service: service,
201 query: Self.logQuery,
202 variables: variables,
203 responseType: LogResponse.self
204 )
205 } catch {
206 if isMissingGitReferenceError(error) {
207 return LogPage(results: [], cursor: nil)
208 }
209 throw error
210 }
211 guard let repo = result.repository else {
212 return LogPage(results: [], cursor: nil)
213 }
214 return repo.log
215 }
216
217 // MARK: - References
218
219 private static let refsQuery = """
220 query refs($rid: ID!) {
221 repository(rid: $rid) {
222 references {
223 results { name target }
224 cursor
225 }
226 }
227 }
228 """
229
230 func loadReferences() async {
231 guard !isLoadingRefs else { return }
232 isLoadingRefs = true
233 error = nil
234
235 do {
236 let result = try await client.execute(
237 service: service,
238 query: Self.refsQuery,
239 variables: ["rid": repository.rid],
240 responseType: RefsResponse.self
241 )
242 let allRefs = result.repository?.references.results ?? []
243 branches = allRefs.filter { $0.name.hasPrefix("refs/heads/") }
244 tags = allRefs.filter { $0.name.hasPrefix("refs/tags/") }
245 } catch {
246 self.error = error.localizedDescription
247 }
248
249 isLoadingRefs = false
250 }
251
252 // MARK: - README
253
254 private static let readmeQuery = """
255 query readme($rid: ID!) {
256 repository(rid: $rid) {
257 readme
258 }
259 }
260 """
261
262 private static func readmeFileQuery(filename: String) -> String {
263 """
264 query readmeFile($rid: ID!) {
265 repository(rid: $rid) {
266 readme: path(revspec: "HEAD", path: "\(filename)") {
267 object {
268 ... on TextBlob { text }
269 }
270 }
271 }
272 }
273 """
274 }
275
276 private static let readmeFilenames = [
277 "README.md", "README.org", "README.txt", "README",
278 "readme.md", "readme.org"
279 ]
280
281 func loadReadme() async {
282 guard !isLoadingReadme, !readmeLoaded else { return }
283 isLoadingReadme = true
284 defer { isLoadingReadme = false }
285 error = nil
286
287 do {
288 // Step 1: Check the custom HTML readme set via the web UI
289 let result = try await client.execute(
290 service: service,
291 query: Self.readmeQuery,
292 variables: ["rid": repository.rid],
293 responseType: ReadmeResponse.self
294 )
295 if let html = result.repository?.readme, !html.isEmpty {
296 readmePath = nil
297 readmeContent = .html(html)
298 readmeLoaded = true
299 return
300 }
301
302 // Step 2: Try each README filename sequentially
303 for filename in Self.readmeFilenames {
304 let pathResult: PathResponse
305 do {
306 pathResult = try await client.execute(
307 service: service,
308 query: Self.readmeFileQuery(filename: filename),
309 variables: ["rid": repository.rid],
310 responseType: PathResponse.self
311 )
312 } catch {
313 if isMissingGitReferenceError(error) {
314 readmeContent = nil
315 readmePath = nil
316 readmeLoaded = true
317 return
318 }
319 throw error
320 }
321 if let text = pathResult.repository?.readme?.object?.text, !text.isEmpty {
322 readmePath = filename
323 if filename.hasSuffix(".md") {
324 readmeContent = .markdown(text)
325 } else if filename.hasSuffix(".org") {
326 readmeContent = .org(text)
327 } else {
328 readmeContent = .plainText(text)
329 }
330 readmeLoaded = true
331 return
332 }
333 }
334
335 // Step 3: No readme found
336 readmeContent = nil
337 readmePath = nil
338 readmeLoaded = true
339 } catch {
340 self.error = error.localizedDescription
341 }
342 }
343
344 private func isMissingGitReferenceError(_ error: Error) -> Bool {
345 guard let srhtError = error as? SRHTError else { return false }
346 guard case .graphQLErrors(let errors) = srhtError else { return false }
347 return errors.contains { $0.message.localizedCaseInsensitiveContains("reference not found") }
348 }
349
350 // MARK: - Artifacts
351
352 private static let artifactsQuery = """
353 query artifacts($rid: ID!) {
354 repository(rid: $rid) {
355 references {
356 results {
357 name
358 artifacts {
359 results {
360 id
361 filename
362 checksum
363 size
364 url
365 }
366 cursor
367 }
368 }
369 cursor
370 }
371 }
372 }
373 """
374
375 func loadArtifacts() async {
376 guard !isLoadingArtifacts else { return }
377 isLoadingArtifacts = true
378 error = nil
379
380 do {
381 let result = try await client.execute(
382 service: service,
383 query: Self.artifactsQuery,
384 variables: ["rid": repository.rid],
385 responseType: ArtifactsResponse.self
386 )
387 // Only include references that have at least one artifact.
388 referenceArtifacts = (result.repository?.references.results ?? [])
389 .filter { !$0.artifacts.results.isEmpty }
390 .map { ReferenceWithArtifacts(name: $0.name, artifacts: $0.artifacts.results) }
391 } catch {
392 self.error = error.localizedDescription
393 }
394
395 isLoadingArtifacts = false
396 }
397}
Hutch/Views/Repositories/RepositoryListView.swift added +224
@@ -0,0 +1,224 @@
1import SwiftUI
2
3struct RepositoryListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: RepositoryListViewModel?
6 @State private var searchTask: Task<Void, Never>?
7 @State private var showCreateRepositorySheet = false
8 @State private var createdRepository: RepositorySummary?
9
10 var body: some View {
11 Group {
12 if let viewModel {
13 listContent(viewModel)
14 } else {
15 SRHTLoadingStateView(message: "Loading repositories…")
16 }
17 }
18 .navigationTitle("Repositories")
19 .toolbar {
20 if viewModel != nil {
21 ToolbarItem(placement: .topBarTrailing) {
22 Button {
23 showCreateRepositorySheet = true
24 } label: {
25 Image(systemName: "plus")
26 }
27 }
28 }
29 }
30 .sheet(isPresented: $showCreateRepositorySheet) {
31 if let viewModel {
32 CreateRepositorySheet(viewModel: viewModel) { repository in
33 showCreateRepositorySheet = false
34 createdRepository = repository
35 }
36 }
37 }
38 .navigationDestination(isPresented: Binding(
39 get: { createdRepository != nil },
40 set: { isPresented in
41 if !isPresented {
42 createdRepository = nil
43 }
44 }
45 )) {
46 if let createdRepository {
47 RepositoryDetailView(repository: createdRepository) {
48 viewModel?.removeRepository(id: createdRepository.id)
49 }
50 }
51 }
52 .task {
53 if viewModel == nil {
54 viewModel = RepositoryListViewModel(client: appState.client)
55 }
56 }
57 }
58
59 @ViewBuilder
60 private func listContent(_ viewModel: RepositoryListViewModel) -> some View {
61 @Bindable var vm = viewModel
62
63 List {
64 ForEach(viewModel.repositories) { repo in
65 NavigationLink(value: repo) {
66 RepositoryRowView(repository: repo)
67 }
68 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
69 .task {
70 await viewModel.loadMoreIfNeeded(currentItem: repo)
71 }
72 }
73
74 if viewModel.isLoadingMore {
75 HStack {
76 Spacer()
77 ProgressView()
78 Spacer()
79 }
80 .listRowSeparator(.hidden)
81 }
82 }
83 .listStyle(.plain)
84 .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search repositories")
85 .overlay {
86 if viewModel.isLoading, viewModel.repositories.isEmpty {
87 SRHTLoadingStateView(message: "Loading repositories…")
88 } else if let error = viewModel.error, viewModel.repositories.isEmpty {
89 SRHTErrorStateView(
90 title: "Couldn't Load Repositories",
91 message: error,
92 retryAction: { await viewModel.loadRepositories() }
93 )
94 } else if viewModel.repositories.isEmpty, viewModel.error == nil {
95 if viewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
96 ContentUnavailableView(
97 "No Repositories",
98 systemImage: "book.closed",
99 description: Text("You don't have any repositories yet.")
100 )
101 } else {
102 ContentUnavailableView.search
103 }
104 }
105 }
106 .connectivityOverlay(hasContent: !viewModel.repositories.isEmpty) {
107 await viewModel.loadRepositories()
108 }
109 .srhtErrorBanner(error: $vm.error)
110 .refreshable {
111 await viewModel.loadRepositories()
112 }
113 .task {
114 await viewModel.loadRepositories()
115 }
116 .onChange(of: viewModel.searchText) { oldValue, newValue in
117 // Cancel previous search task
118 searchTask?.cancel()
119
120 // Clear results immediately when search text is cleared
121 if newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
122 viewModel.resetSearch()
123 Task {
124 await viewModel.loadRepositories()
125 }
126 return
127 }
128
129 // Debounce search to avoid excessive API calls
130 searchTask = Task {
131 try? await Task.sleep(for: .milliseconds(350))
132 guard !Task.isCancelled else { return }
133 await viewModel.loadRepositories(search: newValue)
134 }
135 }
136 .navigationDestination(for: RepositorySummary.self) { repo in
137 RepositoryDetailView(repository: repo) {
138 viewModel.removeRepository(id: repo.id)
139 }
140 }
141 }
142}
143
144private struct CreateRepositorySheet: View {
145 let viewModel: RepositoryListViewModel
146 let onCreated: (RepositorySummary) -> Void
147
148 @Environment(\.dismiss) private var dismiss
149 @State private var name = ""
150 @State private var description = ""
151 @State private var cloneURL = ""
152 @State private var visibility: Visibility = .public
153 @State private var service: RepositoryCreationService = .git
154
155 var body: some View {
156 NavigationStack {
157 Form {
158 Section("Repository Details") {
159 Picker("Version Control", selection: $service) {
160 ForEach(RepositoryCreationService.allCases) { service in
161 Text(service.displayName).tag(service)
162 }
163 }
164 TextField("Repository name", text: $name)
165 .textInputAutocapitalization(.never)
166 .autocorrectionDisabled()
167 TextField("Short description (optional)", text: $description, axis: .vertical)
168 .lineLimit(2...4)
169 Picker("Visibility", selection: $visibility) {
170 Text("Public").tag(Visibility.public)
171 Text("Unlisted").tag(Visibility.unlisted)
172 Text("Private").tag(Visibility.private)
173 }
174 }
175
176 Section("Import Existing Repository") {
177 if service == .git {
178 TextField("Remote URL (optional)", text: $cloneURL)
179 .textInputAutocapitalization(.never)
180 .autocorrectionDisabled()
181 .keyboardType(.URL)
182 Text("Import an existing Git repository from a remote URL.")
183 .font(.footnote)
184 .foregroundStyle(.secondary)
185 } else {
186 Text("Importing a Mercurial repository from a remote URL is not available through the public API.")
187 .font(.footnote)
188 .foregroundStyle(.secondary)
189 }
190 }
191 }
192 .navigationTitle(service == .git ? "New Git Repository" : "New Mercurial Repository")
193 .navigationBarTitleDisplayMode(.inline)
194 .toolbar {
195 ToolbarItem(placement: .cancellationAction) {
196 Button("Cancel") { dismiss() }
197 }
198 ToolbarItem(placement: .confirmationAction) {
199 Button {
200 Task {
201 if let repository = await viewModel.createRepository(
202 service: service,
203 name: name,
204 description: description,
205 visibility: visibility,
206 cloneURL: cloneURL
207 ) {
208 onCreated(repository)
209 }
210 }
211 } label: {
212 if viewModel.isCreatingRepository {
213 ProgressView()
214 .controlSize(.small)
215 } else {
216 Text("Create Repository")
217 }
218 }
219 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingRepository)
220 }
221 }
222 }
223 }
224}
Hutch/Views/Repositories/RepositoryListViewModel.swift added +548
@@ -0,0 +1,548 @@
1import Foundation
2
3enum RepositoryCreationService: String, CaseIterable, Identifiable, Sendable {
4 case git
5 case hg
6
7 var id: String { rawValue }
8
9 var service: SRHTService {
10 switch self {
11 case .git: .git
12 case .hg: .hg
13 }
14 }
15
16 var displayName: String {
17 switch self {
18 case .git: "Git"
19 case .hg: "Mercurial"
20 }
21 }
22}
23
24/// View model for the repository list screen.
25@Observable
26@MainActor
27final class RepositoryListViewModel {
28
29 private(set) var repositories: [RepositorySummary] = []
30 private(set) var isLoading = false
31 private(set) var isLoadingMore = false
32 private(set) var isRefreshing = false
33 var error: String?
34
35 var searchText = ""
36
37 private(set) var cursor: String?
38 private(set) var hasMore = false
39 private(set) var isSearching = false
40 private(set) var isCreatingRepository = false
41 private let client: SRHTClient
42
43 private static let gitCacheKey = "git.repositories"
44 private static let hgCacheKey = "hg.repositories"
45
46 init(client: SRHTClient) {
47 self.client = client
48 }
49
50 // MARK: - Queries
51
52 private static let gitQuery = """
53 query repositories($cursor: Cursor, $filter: Filter) {
54 repositories(cursor: $cursor, filter: $filter) {
55 results {
56 id
57 rid
58 name
59 description
60 visibility
61 updated
62 owner { canonicalName }
63 HEAD { name }
64 }
65 cursor
66 }
67 }
68 """
69
70 private static let hgQuery = """
71 query repositories($cursor: Cursor) {
72 repositories(cursor: $cursor) {
73 results {
74 id
75 rid
76 name
77 description
78 visibility
79 updated
80 owner { canonicalName }
81 tip { branch }
82 }
83 cursor
84 }
85 }
86 """
87
88 private static let createRepositoryMutation = """
89 mutation createRepository($name: String!, $visibility: Visibility!, $description: String, $cloneUrl: String) {
90 createRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {
91 id
92 rid
93 name
94 description
95 visibility
96 updated
97 owner { canonicalName }
98 }
99 }
100 """
101
102 private static let createHgRepositoryMutation = """
103 mutation createRepository($name: String!, $visibility: Visibility!, $description: String) {
104 createRepository(name: $name, visibility: $visibility, description: $description) {
105 id
106 rid
107 name
108 description
109 visibility
110 updated
111 owner { canonicalName }
112 tip { branch }
113 }
114 }
115 """
116
117 // MARK: - Public API
118
119 /// Fetch the first page of repositories. Shows cached data instantly if available,
120 /// then refreshes from the network in the background.
121 /// - Parameter search: Optional search string. Pass `nil` to use the current `searchText`.
122 func loadRepositories(search: String? = nil) async {
123 let query = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
124 let isSearch = !query.isEmpty
125
126 // Only use cache for non-search, initial loads
127 if !isSearch, repositories.isEmpty {
128 loadFromCache()
129 }
130
131 // During search, never show the full-screen loading overlay (which
132 // would remove the List and dismiss the keyboard). Use "refreshing"
133 // instead so the list stays in the hierarchy.
134 if isSearch {
135 isRefreshing = true
136 isSearching = true
137 } else if repositories.isEmpty {
138 isLoading = true
139 isSearching = false
140 } else {
141 isRefreshing = true
142 isSearching = false
143 }
144 error = nil
145 cursor = nil
146 hasMore = false
147
148 do {
149 var filteredResults: [RepositorySummary]
150
151 if isSearch {
152 // For search queries, fetch all repositories from both services.
153 filteredResults = try await fetchAllRepositories()
154
155 // Perform client-side filtering
156 let lowercasedQuery = query.lowercased()
157 filteredResults = filteredResults.filter { repo in
158 repo.name.lowercased().contains(lowercasedQuery) ||
159 repo.description?.lowercased().contains(lowercasedQuery) ?? false
160 }
161 } else {
162 let repositories = try await fetchAllRepositories(useCache: true)
163 filteredResults = repositories
164 }
165
166 repositories = filteredResults.sorted(by: repositorySortOrder)
167 } catch {
168 // Only show error if we have no cached data to fall back on
169 if repositories.isEmpty {
170 self.error = error.localizedDescription
171 }
172 }
173
174 isLoading = false
175 isRefreshing = false
176 }
177
178 /// Load the next page if available. Called when the user scrolls near the end.
179 /// Note: Pagination is disabled during search (client-side filtering).
180 func loadMoreIfNeeded(currentItem: RepositorySummary) async {
181 _ = currentItem
182 }
183
184 /// Remove a repository from the local list (e.g. after deletion).
185 func removeRepository(id: Int) {
186 repositories.removeAll { $0.id == id }
187 }
188
189 func createRepository(
190 service: RepositoryCreationService,
191 name: String,
192 description: String,
193 visibility: Visibility,
194 cloneURL: String
195 ) async -> RepositorySummary? {
196 guard !isCreatingRepository else { return nil }
197
198 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
199 guard !trimmedName.isEmpty else {
200 error = "Enter a repository name."
201 return nil
202 }
203
204 isCreatingRepository = true
205 error = nil
206 defer { isCreatingRepository = false }
207
208 var variables: [String: any Sendable] = [
209 "name": trimmedName,
210 "visibility": visibility.rawValue
211 ]
212 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
213 if !trimmedDescription.isEmpty {
214 variables["description"] = trimmedDescription
215 }
216 let trimmedCloneURL = cloneURL.trimmingCharacters(in: .whitespacesAndNewlines)
217 if !trimmedCloneURL.isEmpty {
218 variables["cloneUrl"] = trimmedCloneURL
219 }
220
221 do {
222 let repository: RepositorySummary
223 switch service {
224 case .git:
225 let result = try await client.execute(
226 service: .git,
227 query: Self.createRepositoryMutation,
228 variables: variables,
229 responseType: CreateRepositoryResponse.self
230 )
231 repository = result.createRepository
232 case .hg:
233 variables.removeValue(forKey: "cloneUrl")
234 let result = try await client.execute(
235 service: .hg,
236 query: Self.createHgRepositoryMutation,
237 variables: variables,
238 responseType: CreateHGRepositoryResponse.self
239 )
240 repository = result.createRepository.repositorySummary(service: .hg)
241 }
242 repositories.insert(repository, at: 0)
243 return repository
244 } catch {
245 self.error = repositoryCreationErrorMessage(for: error)
246 return nil
247 }
248 }
249
250 private func repositoryCreationErrorMessage(for error: Error) -> String {
251 let message: String
252
253 if let srhtError = error as? SRHTError {
254 switch srhtError {
255 case .graphQLErrors(let errors):
256 message = errors.map(\.message).joined(separator: "\n")
257 default:
258 message = srhtError.localizedDescription
259 }
260 } else {
261 message = error.localizedDescription
262 }
263
264 return "Couldn’t create the repository. \(message)"
265 }
266
267 /// Fetch ALL repositories by paginating through all available pages.
268 /// Used for search functionality to ensure we search through the complete dataset.
269 private func fetchAllRepositories(useCache: Bool = false) async throws -> [RepositorySummary] {
270 async let gitRepositories = fetchRepositories(for: .git, useCache: useCache)
271 async let hgRepositories = fetchRepositories(for: .hg, useCache: useCache)
272 return try await gitRepositories + hgRepositories
273 }
274
275 /// Reset search state and reload all repositories
276 func resetSearch() {
277 repositories = []
278 cursor = nil
279 hasMore = false
280 isSearching = false
281 }
282
283 // MARK: - Private
284
285 /// Page shape matching the GraphQL response without generic constraints that
286 /// conflict with strict concurrency when used from a @MainActor context.
287 private struct Page: Decodable, Sendable {
288 let results: [RepositoryPayload]
289 let cursor: String?
290 }
291
292 private struct RepositoriesResponse: Decodable, Sendable {
293 let repositories: Page?
294 }
295
296 private struct CreateRepositoryResponse: Decodable, Sendable {
297 let createRepository: RepositorySummary
298 }
299
300 private struct CreateHGRepositoryResponse: Decodable, Sendable {
301 let createRepository: HGRepositoryPayload
302 }
303
304 private struct HGPage: Decodable, Sendable {
305 let results: [HGRepositoryPayload]
306 let cursor: String?
307 }
308
309 private struct HGRepositoriesResponse: Decodable, Sendable {
310 let repositories: HGPage?
311 }
312
313 private static let emptyPage = Page(results: [], cursor: nil)
314
315 private struct RepositoryPayload: Decodable, Sendable {
316 let id: Int
317 let rid: String
318 let name: String
319 let description: String?
320 let visibility: Visibility
321 let updated: Date
322 let owner: Entity
323 let head: Reference?
324
325 enum CodingKeys: String, CodingKey {
326 case id, rid, name, description, visibility, updated, owner
327 case head = "HEAD"
328 }
329
330 func repositorySummary(service: SRHTService) -> RepositorySummary {
331 RepositorySummary(
332 id: id,
333 rid: rid,
334 service: service,
335 name: name,
336 description: description,
337 visibility: visibility,
338 updated: updated,
339 owner: owner,
340 head: head
341 )
342 }
343 }
344
345 private struct HGRepositoryPayload: Decodable, Sendable {
346 let id: Int
347 let rid: String
348 let name: String
349 let description: String?
350 let visibility: Visibility
351 let updated: Date
352 let owner: Entity
353 let tip: HGTipReference?
354
355 func repositorySummary(service: SRHTService) -> RepositorySummary {
356 RepositorySummary(
357 id: id,
358 rid: rid,
359 service: service,
360 name: name,
361 description: description,
362 visibility: visibility,
363 updated: updated,
364 owner: owner,
365 head: tip.map { Reference(name: $0.branch, target: nil) }
366 )
367 }
368 }
369
370 private struct HGTipReference: Decodable, Sendable {
371 let branch: String
372 }
373
374 private func fetchPage(
375 service: SRHTService,
376 cursor: String?,
377 search: String? = nil,
378 useCache: Bool
379 ) async throws -> Page {
380 var variables: [String: any Sendable] = [:]
381 if let cursor {
382 variables["cursor"] = cursor
383 }
384 let trimmed = (search ?? searchText).trimmingCharacters(in: .whitespacesAndNewlines)
385 if !trimmed.isEmpty {
386 variables["filter"] = ["search": trimmed] as [String: any Sendable]
387 }
388
389 if useCache && cursor == nil {
390 switch service {
391 case .git:
392 let result = try await client.executeAndCache(
393 service: service,
394 query: Self.gitQuery,
395 variables: variables.isEmpty ? nil : variables,
396 responseType: RepositoriesResponse.self,
397 cacheKey: cacheKey(for: service)
398 )
399 return result.repositories ?? Self.emptyPage
400 case .hg:
401 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
402 let result = try await client.executeAndCache(
403 service: service,
404 query: Self.hgQuery,
405 variables: hgVariables,
406 responseType: HGRepositoriesResponse.self,
407 cacheKey: cacheKey(for: service)
408 )
409 return Page(
410 results: result.repositories?.results.map {
411 RepositoryPayload(
412 id: $0.id,
413 rid: $0.rid,
414 name: $0.name,
415 description: $0.description,
416 visibility: $0.visibility,
417 updated: $0.updated,
418 owner: $0.owner,
419 head: $0.tip.map { Reference(name: $0.branch, target: nil) }
420 )
421 } ?? [],
422 cursor: result.repositories?.cursor
423 )
424 default:
425 let result = try await client.executeAndCache(
426 service: service,
427 query: Self.gitQuery,
428 variables: variables.isEmpty ? nil : variables,
429 responseType: RepositoriesResponse.self,
430 cacheKey: cacheKey(for: service)
431 )
432 return result.repositories ?? Self.emptyPage
433 }
434 } else {
435 switch service {
436 case .git:
437 let result = try await client.execute(
438 service: service,
439 query: Self.gitQuery,
440 variables: variables.isEmpty ? nil : variables,
441 responseType: RepositoriesResponse.self
442 )
443 return result.repositories ?? Self.emptyPage
444 case .hg:
445 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
446 let result = try await client.execute(
447 service: service,
448 query: Self.hgQuery,
449 variables: hgVariables,
450 responseType: HGRepositoriesResponse.self
451 )
452 return Page(
453 results: result.repositories?.results.map {
454 RepositoryPayload(
455 id: $0.id,
456 rid: $0.rid,
457 name: $0.name,
458 description: $0.description,
459 visibility: $0.visibility,
460 updated: $0.updated,
461 owner: $0.owner,
462 head: $0.tip.map { Reference(name: $0.branch, target: nil) }
463 )
464 } ?? [],
465 cursor: result.repositories?.cursor
466 )
467 default:
468 let result = try await client.execute(
469 service: service,
470 query: Self.gitQuery,
471 variables: variables.isEmpty ? nil : variables,
472 responseType: RepositoriesResponse.self
473 )
474 return result.repositories ?? Self.emptyPage
475 }
476 }
477 }
478
479 private func loadFromCache() {
480 let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
481 guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
482 let decoder = JSONDecoder()
483 decoder.dateDecodingStrategy = .srhtFlexible
484 switch service {
485 case .git:
486 if let response = try? decoder.decode(
487 GraphQLResponse<RepositoriesResponse>.self,
488 from: data
489 ), let repos = response.data?.repositories {
490 return repos.results.map { $0.repositorySummary(service: service) }
491 }
492 case .hg:
493 if let response = try? decoder.decode(
494 GraphQLResponse<HGRepositoriesResponse>.self,
495 from: data
496 ), let repos = response.data?.repositories {
497 return repos.results.map { $0.repositorySummary(service: service) }
498 }
499 default:
500 break
501 }
502 return []
503 }
504 if !cachedRepositories.isEmpty {
505 repositories = cachedRepositories.sorted(by: repositorySortOrder)
506 }
507 }
508
509 private func fetchRepositories(for service: SRHTService, useCache: Bool) async throws -> [RepositorySummary] {
510 var allRepositories: [RepositorySummary] = []
511 var currentCursor: String? = nil
512
513 while true {
514 let page = try await fetchPage(
515 service: service,
516 cursor: currentCursor,
517 search: nil,
518 useCache: useCache && currentCursor == nil
519 )
520 allRepositories.append(contentsOf: page.results.map { $0.repositorySummary(service: service) })
521 guard let nextCursor = page.cursor else { break }
522 currentCursor = nextCursor
523 }
524
525 return allRepositories
526 }
527
528 private func cacheKey(for service: SRHTService) -> String {
529 switch service {
530 case .git:
531 Self.gitCacheKey
532 case .hg:
533 Self.hgCacheKey
534 default:
535 "\(service.rawValue).repositories"
536 }
537 }
538
539 private func repositorySortOrder(lhs: RepositorySummary, rhs: RepositorySummary) -> Bool {
540 if lhs.updated == rhs.updated {
541 if lhs.service == rhs.service {
542 return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
543 }
544 return lhs.service.rawValue < rhs.service.rawValue
545 }
546 return lhs.updated > rhs.updated
547 }
548}
Hutch/Views/Repositories/RepositoryRowView.swift added +85
@@ -0,0 +1,85 @@
1import SwiftUI
2
3struct RepositoryRowView: View {
4 let repository: RepositorySummary
5
6 var body: some View {
7 VStack(alignment: .leading, spacing: 4) {
8 HStack(alignment: .firstTextBaseline) {
9 Text(repository.name)
10 .font(.headline)
11
12 Spacer()
13
14 if repository.service == .hg {
15 Text("HG")
16 .font(.caption2.weight(.medium))
17 .padding(.horizontal, 6)
18 .padding(.vertical, 2)
19 .background(Color.cyan.opacity(0.15), in: Capsule())
20 .foregroundStyle(.cyan)
21 }
22
23 VisibilityBadge(visibility: repository.visibility)
24 }
25
26 Text(repository.owner.canonicalName)
27 .font(.subheadline)
28 .foregroundStyle(.secondary)
29
30 if let description = repository.description,
31 !description.isEmpty {
32 Text(description)
33 .font(.subheadline)
34 .foregroundStyle(.secondary)
35 .lineLimit(2)
36 }
37
38 HStack(spacing: 12) {
39 if let head = repository.head {
40 Label(head.name, systemImage: "arrow.triangle.branch")
41 .font(.caption)
42 .foregroundStyle(.secondary)
43 }
44
45 Spacer()
46
47 Text(repository.updated.relativeDescription)
48 .font(.caption)
49 .foregroundStyle(.tertiary)
50 }
51 }
52 .padding(.vertical, 2)
53 }
54}
55
56// MARK: - VisibilityBadge
57
58struct VisibilityBadge: View {
59 let visibility: Visibility
60
61 var body: some View {
62 Text(label)
63 .font(.caption2.weight(.medium))
64 .padding(.horizontal, 6)
65 .padding(.vertical, 2)
66 .background(color.opacity(0.15), in: Capsule())
67 .foregroundStyle(color)
68 }
69
70 private var label: String {
71 switch visibility {
72 case .public: "PUBLIC"
73 case .unlisted: "UNLISTED"
74 case .private: "PRIVATE"
75 }
76 }
77
78 private var color: Color {
79 switch visibility {
80 case .public: .green
81 case .unlisted: .orange
82 case .private: .red
83 }
84 }
85}
Hutch/Views/Repositories/RepositorySettingsView.swift +41 −17
@@ -10,6 +10,7 @@ struct RepositorySettingsView: View {
1010 @Environment(\.dismiss) private var dismiss
1111 @State private var viewModel: RepositorySettingsViewModel?
1212 @State private var showDeleteConfirmation = false
13 @State private var pendingACLDeletion: ACLEntry?
1314
1415 var body: some View {
1516 NavigationStack {
@@ -17,7 +18,7 @@ struct RepositorySettingsView: View {
1718 if let viewModel {
1819 settingsForm(viewModel)
1920 } else {
20 ProgressView()
21 SRHTLoadingStateView(message: "Loading settings…")
2122 }
2223 }
2324 .navigationTitle("Settings")
@@ -51,13 +52,7 @@ struct RepositorySettingsView: View {
5152 accessSection(viewModel)
5253 deleteSection(viewModel)
5354 }
54 .alert("Error", isPresented: .constant(viewModel.error != nil)) {
55 Button("OK") { viewModel.error = nil }
56 } message: {
57 if let error = viewModel.error {
58 Text(error)
59 }
60 }
55 .srhtErrorBanner(error: $vm.error)
6156 .alert(
6257 "Permanently delete \(repository.owner.canonicalName)/\(repository.name)?",
6358 isPresented: $showDeleteConfirmation
@@ -75,6 +70,27 @@ struct RepositorySettingsView: View {
7570 } message: {
7671 Text("This cannot be undone.")
7772 }
73 .alert("Remove Access?", isPresented: Binding(
74 get: { pendingACLDeletion != nil },
75 set: { isPresented in
76 if !isPresented {
77 pendingACLDeletion = nil
78 }
79 }
80 )) {
81 Button("Cancel", role: .cancel) {}
82 Button("Remove Access", role: .destructive) {
83 guard let entry = pendingACLDeletion else { return }
84 Task {
85 await viewModel.deleteACL(entry)
86 pendingACLDeletion = nil
87 }
88 }
89 } message: {
90 if let entry = pendingACLDeletion {
91 Text("\(entry.entity.canonicalName) will lose \(entry.mode) access to this repository.")
92 }
93 }
7894 }
7995
8096 // MARK: - Info Section
@@ -82,6 +98,11 @@ struct RepositorySettingsView: View {
8298 @ViewBuilder
8399 private func infoSection(_ viewModel: RepositorySettingsViewModel) -> some View {
84100 Section("Info") {
101 LabeledContent("Name") {
102 Text(repository.name)
103 .font(.body.monospaced())
104 }
105
85106 TextField("Description", text: Bindable(viewModel).editedDescription, axis: .vertical)
86107 .lineLimit(3...6)
87108
@@ -107,7 +128,7 @@ struct RepositorySettingsView: View {
107128 ProgressView()
108129 .frame(maxWidth: .infinity)
109130 } else {
110 Text("Save")
131 Text("Save Changes")
111132 .frame(maxWidth: .infinity)
112133 }
113134 }
@@ -120,7 +141,7 @@ struct RepositorySettingsView: View {
120141 @ViewBuilder
121142 private func renameSection(_ viewModel: RepositorySettingsViewModel) -> some View {
122143 Section {
123 TextField("Repository Name", text: Bindable(viewModel).editedName)
144 TextField("New repository name", text: Bindable(viewModel).editedName)
124145 .autocorrectionDisabled()
125146 .textInputAutocapitalization(.never)
126147
@@ -141,7 +162,7 @@ struct RepositorySettingsView: View {
141162 ProgressView()
142163 .frame(maxWidth: .infinity)
143164 } else {
144 Text("Rename")
165 Text("Rename Repository")
145166 .frame(maxWidth: .infinity)
146167 }
147168 }
@@ -163,7 +184,7 @@ struct RepositorySettingsView: View {
163184 Spacer()
164185 }
165186 } else if viewModel.acls.isEmpty {
166 Text("No access control entries.")
187 Text("No access entries yet.")
167188 .foregroundStyle(.secondary)
168189 } else {
169190 ForEach(viewModel.acls) { entry in
@@ -174,11 +195,11 @@ struct RepositorySettingsView: View {
174195 .font(.caption.monospaced())
175196 .foregroundStyle(.secondary)
176197 }
177 .swipeActions(edge: .trailing, allowsFullSwipe: true) {
198 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
178199 Button(role: .destructive) {
179 Task { await viewModel.deleteACL(entry) }
200 pendingACLDeletion = entry
180201 } label: {
181 Label("Delete", systemImage: "trash")
202 Label("Remove Access", systemImage: "trash")
182203 }
183204 }
184205 }
@@ -186,7 +207,7 @@ struct RepositorySettingsView: View {
186207
187208 // Add ACL form
188209 HStack {
189 TextField("Username", text: Bindable(viewModel).newACLEntity)
210 TextField("Username or ~username", text: Bindable(viewModel).newACLEntity)
190211 .autocorrectionDisabled()
191212 .textInputAutocapitalization(.never)
192213
@@ -203,11 +224,14 @@ struct RepositorySettingsView: View {
203224 if viewModel.isAddingACL {
204225 ProgressView()
205226 } else {
206 Image(systemName: "plus.circle.fill")
227 Text("Add")
207228 }
208229 }
209230 .disabled(viewModel.isAddingACL || viewModel.newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
210231 }
232 Text("Add a SourceHut user and choose read-only or read/write access.")
233 .font(.caption)
234 .foregroundStyle(.secondary)
211235 } header: {
212236 Text("Access")
213237 }
Hutch/Views/Repositories/RepositorySettingsViewModel.swift +18 −6
@@ -63,6 +63,7 @@ final class RepositorySettingsViewModel {
6363
6464 let repositoryId: Int
6565 let repositoryRid: String
66 let service: SRHTService
6667 private let client: SRHTClient
6768
6869 // MARK: - Info fields
@@ -107,6 +108,7 @@ final class RepositorySettingsViewModel {
107108 ) {
108109 self.repositoryId = repository.id
109110 self.repositoryRid = repository.rid
111 self.service = repository.service
110112 self.client = client
111113 self.editedDescription = repository.description ?? ""
112114 self.editedVisibility = repository.visibility
@@ -143,7 +145,7 @@ final class RepositorySettingsViewModel {
143145 "HEAD": editedHead
144146 ]
145147 _ = try await client.execute(
146 service: .git,
148 service: service,
147149 query: Self.updateRepoMutation,
148150 variables: ["id": repositoryId, "input": input],
149151 responseType: UpdateRepoResponse.self
@@ -165,7 +167,7 @@ final class RepositorySettingsViewModel {
165167 "name": editedName
166168 ]
167169 let result = try await client.execute(
168 service: .git,
170 service: service,
169171 query: Self.updateRepoMutation,
170172 variables: ["id": repositoryId, "input": input],
171173 responseType: UpdateRepoResponse.self
@@ -207,6 +209,16 @@ final class RepositorySettingsViewModel {
207209 }
208210 """
209211
212 private static let userLookupQuery = """
213 query userLookup($username: String!) {
214 user(username: $username) {
215 id
216 username
217 canonicalName
218 }
219 }
220 """
221
210222 func loadACLs() async {
211223 guard !isLoadingACLs else { return }
212224 isLoadingACLs = true
@@ -214,7 +226,7 @@ final class RepositorySettingsViewModel {
214226
215227 do {
216228 let result = try await client.execute(
217 service: .git,
229 service: service,
218230 query: Self.aclsQuery,
219231 variables: ["rid": repositoryRid],
220232 responseType: ACLResponse.self
@@ -234,7 +246,7 @@ final class RepositorySettingsViewModel {
234246
235247 do {
236248 let result = try await client.execute(
237 service: .git,
249 service: service,
238250 query: Self.updateACLMutation,
239251 variables: [
240252 "repoId": repositoryId,
@@ -262,7 +274,7 @@ final class RepositorySettingsViewModel {
262274
263275 do {
264276 _ = try await client.execute(
265 service: .git,
277 service: service,
266278 query: Self.deleteACLMutation,
267279 variables: ["id": entry.id],
268280 responseType: DeleteACLResponse.self
@@ -288,7 +300,7 @@ final class RepositorySettingsViewModel {
288300
289301 do {
290302 _ = try await client.execute(
291 service: .git,
303 service: service,
292304 query: Self.deleteRepoMutation,
293305 variables: ["id": repositoryId],
294306 responseType: DeleteRepoResponse.self
Hutch/Views/Repositories/RepositorySummarySupport.swift +15 −36
@@ -39,31 +39,30 @@ func repositoryVisibilityLabel(_ visibility: Visibility) -> String {
3939 }
4040}
4141
42struct RepositorySummaryCard<Content: View>: View {
42struct SummaryMetadataRow: View {
43 let icon: String
4344 let title: String
44 @ViewBuilder let content: Content
45
46 init(_ title: String, @ViewBuilder content: () -> Content) {
47 self.title = title
48 self.content = content()
49 }
45 var subtitle: String? = nil
5046
5147 var body: some View {
52 VStack(alignment: .leading, spacing: 12) {
53 Text(title)
54 .font(.caption.weight(.semibold))
48 HStack(alignment: .top, spacing: 10) {
49 Image(systemName: icon)
5550 .foregroundStyle(.secondary)
56 .textCase(.uppercase)
51 .frame(width: 18)
5752
58 content
53 VStack(alignment: .leading, spacing: 2) {
54 Text(title)
55 if let subtitle, !subtitle.isEmpty {
56 Text(subtitle)
57 .font(.subheadline)
58 .foregroundStyle(.secondary)
59 }
60 }
5961 }
60 .frame(maxWidth: .infinity, alignment: .leading)
61 .padding()
62 .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
6362 }
6463}
6564
66struct RepositorySummaryField: View {
65struct SummaryDetailRow: View {
6766 let label: String
6867 let value: String
6968 var monospace: Bool = false
@@ -79,23 +78,3 @@ struct RepositorySummaryField: View {
7978 }
8079 }
8180}
82
83struct RepositorySummaryListRow: View {
84 let label: String
85 let values: [String]
86
87 var body: some View {
88 VStack(alignment: .leading, spacing: 4) {
89 Text(label)
90 .font(.caption)
91 .foregroundStyle(.secondary)
92
93 if values.isEmpty {
94 Text("None")
95 .foregroundStyle(.tertiary)
96 } else {
97 Text(values.joined(separator: ", "))
98 }
99 }
100 }
101}
Hutch/Views/Settings/SettingsView.swift added +724
@@ -0,0 +1,724 @@
1import PhotosUI
2import SwiftUI
3
4struct SettingsView: View {
5 @Environment(AppState.self) private var appState
6 @Environment(\.colorScheme) private var colorScheme
7 @State private var viewModel: SettingsViewModel?
8 @State private var pendingDestructiveAction: SettingsDestructiveAction?
9
10 var body: some View {
11 NavigationStack {
12 Group {
13 if let viewModel {
14 settingsContent(viewModel)
15 } else {
16 SRHTLoadingStateView(message: "Loading profile…")
17 }
18 }
19 .navigationTitle("Settings")
20 .task {
21 if viewModel == nil {
22 let vm = SettingsViewModel(client: appState.client)
23 viewModel = vm
24 await vm.loadProfile()
25 }
26 }
27 }
28 }
29
30 @ViewBuilder
31 private func settingsContent(_ viewModel: SettingsViewModel) -> some View {
32 @Bindable var vm = viewModel
33
34 Form {
35 if let profile = viewModel.profile {
36 // Profile section
37 profileSection(profile, viewModel: viewModel)
38
39 // SSH Keys
40 sshKeysSection(viewModel)
41
42 // PGP Keys
43 pgpKeysSection(viewModel)
44
45 // Personal Access Tokens
46 patSection(viewModel)
47 }
48
49 // Token / Sign Out
50 tokenSection()
51
52 aboutSection()
53 }
54 .overlay {
55 if viewModel.isLoading, viewModel.profile == nil {
56 SRHTLoadingStateView(message: "Loading profile…")
57 } else if let error = viewModel.error, viewModel.profile == nil {
58 SRHTErrorStateView(
59 title: "Couldn't Load Profile",
60 message: error,
61 retryAction: { await viewModel.loadProfile() }
62 )
63 }
64 }
65 .sheet(isPresented: $vm.isEditingProfile) {
66 if let profile = viewModel.profile {
67 EditProfileSheet(
68 profile: profile,
69 viewModel: viewModel
70 )
71 }
72 }
73 .alert("Error", isPresented: Binding(
74 get: { viewModel.error != nil && viewModel.profile != nil },
75 set: { isPresented in
76 if !isPresented {
77 viewModel.error = nil
78 }
79 }
80 )) {
81 Button("OK") { viewModel.error = nil }
82 } message: {
83 if let error = viewModel.error {
84 Text(error)
85 }
86 }
87 .alert(
88 pendingDestructiveAction?.title ?? "",
89 isPresented: Binding(
90 get: { pendingDestructiveAction != nil },
91 set: { isPresented in
92 if !isPresented {
93 pendingDestructiveAction = nil
94 }
95 }
96 )
97 ) {
98 Button("Cancel", role: .cancel) {}
99 Button(pendingDestructiveAction?.confirmationLabel ?? "Confirm", role: .destructive) {
100 guard let action = pendingDestructiveAction else { return }
101 pendingDestructiveAction = nil
102 Task {
103 switch action {
104 case .resetAppData:
105 await appState.resetAppData()
106 case .signOut:
107 await appState.signOut()
108 case .deleteSSHKey(let key):
109 await viewModel.deleteSSHKey(key)
110 case .deletePGPKey(let key):
111 await viewModel.deletePGPKey(key)
112 }
113 }
114 }
115 } message: {
116 if let pendingDestructiveAction {
117 Text(pendingDestructiveAction.message)
118 }
119 }
120 .refreshable {
121 await viewModel.loadProfile()
122 }
123 }
124
125 // MARK: - Profile Section
126
127 @ViewBuilder
128 private func profileSection(_ profile: UserProfile, viewModel: SettingsViewModel) -> some View {
129 Section("Profile") {
130 HStack(spacing: 12) {
131 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
132 switch phase {
133 case .success(let image):
134 image
135 .resizable()
136 .scaledToFill()
137 default:
138 Image(systemName: "person.crop.circle.fill")
139 .resizable()
140 .foregroundStyle(.secondary)
141 }
142 }
143 .frame(width: 56, height: 56)
144 .clipShape(Circle())
145
146 VStack(alignment: .leading, spacing: 2) {
147 Text(profile.canonicalName)
148 .font(.headline)
149 Text(profile.email)
150 .font(.subheadline)
151 .foregroundStyle(.secondary)
152 if let userType = profile.userType {
153 Text(userType.capitalized)
154 .font(.caption)
155 .foregroundStyle(.tertiary)
156 }
157 }
158 }
159 .padding(.vertical, 4)
160
161 if let bio = profile.bio, !bio.isEmpty {
162 VStack(alignment: .leading, spacing: 2) {
163 Text("Bio")
164 .font(.caption)
165 .foregroundStyle(.secondary)
166 RenderedMarkupContentView(
167 content: .markdown(bio),
168 readmePath: nil,
169 colorScheme: colorScheme,
170 ownerCanonicalName: "",
171 repositoryName: ""
172 )
173 }
174 }
175
176 if let location = profile.location, !location.isEmpty {
177 LabeledContent("Location", value: location)
178 }
179
180 if let url = profile.url, !url.isEmpty {
181 LabeledContent("URL", value: url)
182 }
183
184 if let status = profile.paymentStatus {
185 LabeledContent("Payment", value: status.capitalized)
186 }
187
188 if let sub = profile.subscription {
189 if let status = sub.status {
190 LabeledContent("Subscription", value: status.capitalized)
191 }
192 if let interval = sub.interval {
193 LabeledContent("Interval", value: interval.capitalized)
194 }
195 }
196
197 Button("Edit Profile") {
198 viewModel.isEditingProfile = true
199 }
200
201 SRHTShareButton(url: SRHTWebURL.profile(canonicalName: profile.canonicalName), target: .profile) {
202 SwiftUI.Label("Share Profile", systemImage: "square.and.arrow.up")
203 }
204 }
205 }
206
207 // MARK: - SSH Keys Section
208
209 @ViewBuilder
210 private func sshKeysSection(_ viewModel: SettingsViewModel) -> some View {
211 @Bindable var vm = viewModel
212
213 Section {
214 ForEach(viewModel.sshKeys) { key in
215 VStack(alignment: .leading, spacing: 2) {
216 Text(key.fingerprint)
217 .font(.caption.monospaced())
218 .lineLimit(1)
219 .truncationMode(.middle)
220
221 HStack {
222 if let comment = key.comment, !comment.isEmpty {
223 Text(comment)
224 .font(.caption2)
225 .foregroundStyle(.secondary)
226 }
227 Spacer()
228 Text(key.created.relativeDescription)
229 .font(.caption2)
230 .foregroundStyle(.tertiary)
231 }
232
233 if let lastUsed = key.lastUsed {
234 Text("Last used \(lastUsed.relativeDescription)")
235 .font(.caption2)
236 .foregroundStyle(.tertiary)
237 }
238 }
239 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
240 Button("Delete", role: .destructive) {
241 pendingDestructiveAction = .deleteSSHKey(key)
242 }
243 }
244 }
245
246 if viewModel.isAddingSSHKey {
247 TextField("Paste SSH public key", text: $vm.newSSHKey, axis: .vertical)
248 .font(.caption.monospaced())
249 .lineLimit(3...6)
250
251 HStack {
252 Button("Cancel") {
253 viewModel.isAddingSSHKey = false
254 viewModel.newSSHKey = ""
255 }
256 Spacer()
257 Button("Add") {
258 Task { await viewModel.addSSHKey() }
259 }
260 .buttonStyle(.borderedProminent)
261 .disabled(viewModel.newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
262 }
263 } else {
264 Button {
265 viewModel.isAddingSSHKey = true
266 } label: {
267 SwiftUI.Label("Add SSH Key", systemImage: "key")
268 }
269 }
270 } header: {
271 Text("SSH Keys")
272 } footer: {
273 Text("\(viewModel.sshKeys.count) key\(viewModel.sshKeys.count == 1 ? "" : "s")")
274 }
275 }
276
277 // MARK: - PGP Keys Section
278
279 @ViewBuilder
280 private func pgpKeysSection(_ viewModel: SettingsViewModel) -> some View {
281 @Bindable var vm = viewModel
282
283 Section {
284 ForEach(viewModel.pgpKeys) { key in
285 VStack(alignment: .leading, spacing: 2) {
286 Text(key.fingerprint)
287 .font(.caption.monospaced())
288 .lineLimit(1)
289 .truncationMode(.middle)
290
291 Text(key.created.relativeDescription)
292 .font(.caption2)
293 .foregroundStyle(.tertiary)
294 }
295 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
296 Button("Delete", role: .destructive) {
297 pendingDestructiveAction = .deletePGPKey(key)
298 }
299 }
300 }
301
302 if viewModel.isAddingPGPKey {
303 TextField("Paste PGP public key", text: $vm.newPGPKey, axis: .vertical)
304 .font(.caption.monospaced())
305 .lineLimit(3...6)
306
307 HStack {
308 Button("Cancel") {
309 viewModel.isAddingPGPKey = false
310 viewModel.newPGPKey = ""
311 }
312 Spacer()
313 Button("Add") {
314 Task { await viewModel.addPGPKey() }
315 }
316 .buttonStyle(.borderedProminent)
317 .disabled(viewModel.newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
318 }
319 } else {
320 Button {
321 viewModel.isAddingPGPKey = true
322 } label: {
323 SwiftUI.Label("Add PGP Key", systemImage: "key.fill")
324 }
325 }
326 } header: {
327 Text("PGP Keys")
328 } footer: {
329 Text("\(viewModel.pgpKeys.count) key\(viewModel.pgpKeys.count == 1 ? "" : "s")")
330 }
331 }
332
333 // MARK: - Personal Access Tokens Section
334
335 @ViewBuilder
336 private func patSection(_ viewModel: SettingsViewModel) -> some View {
337 Section {
338 if viewModel.isLoadingPATs {
339 HStack {
340 Spacer()
341 ProgressView()
342 Spacer()
343 }
344 } else if viewModel.personalAccessTokens.isEmpty {
345 Button("Load Tokens") {
346 Task { await viewModel.loadPersonalAccessTokens() }
347 }
348 } else {
349 ForEach(viewModel.personalAccessTokens) { token in
350 VStack(alignment: .leading, spacing: 4) {
351 HStack {
352 Text(token.comment ?? "Token #\(token.id)")
353 .font(.subheadline)
354 Spacer()
355 }
356
357 HStack(spacing: 12) {
358 Text("Issued \(token.issued.relativeDescription)")
359 .font(.caption2)
360 .foregroundStyle(.secondary)
361
362 if let expires = token.expires {
363 Text("Expires \(expires.relativeDescription)")
364 .font(.caption2)
365 .foregroundStyle(expires < Date.now ? .red : .secondary)
366 }
367 }
368
369 if let grants = token.grants, !grants.isEmpty {
370 Text(grants)
371 .font(.caption2.monospaced())
372 .foregroundStyle(.tertiary)
373 .lineLimit(2)
374 }
375 }
376 }
377 }
378 } header: {
379 Text("Personal Access Tokens")
380 } footer: {
381 if !viewModel.personalAccessTokens.isEmpty {
382 Text("\(viewModel.personalAccessTokens.count) token\(viewModel.personalAccessTokens.count == 1 ? "" : "s")")
383 }
384 }
385 }
386
387 // MARK: - Token / Sign Out Section
388
389 @ViewBuilder
390 private func tokenSection() -> some View {
391 Section {
392 HStack {
393 Image(systemName: "key.fill")
394 .foregroundStyle(.secondary)
395 Text("Personal access token in use")
396 .font(.subheadline)
397 .foregroundStyle(.secondary)
398 }
399 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
400
401 Button("Reset App Data", role: .destructive) {
402 pendingDestructiveAction = .resetAppData
403 }
404
405 Button("Sign Out", role: .destructive) {
406 pendingDestructiveAction = .signOut
407 }
408 } header: {
409 Text("Authentication")
410 } footer: {
411 Text("Hutch stores your SourceHut token in the iOS keychain. Reset App Data removes saved token data, local settings, cached responses, cookies, and embedded web data on this device.")
412 }
413 }
414
415 @ViewBuilder
416 private func aboutSection() -> some View {
417 Section("App") {
418 NavigationLink {
419 AboutView()
420 } label: {
421 SwiftUI.Label("About Hutch", systemImage: "info.circle")
422 }
423 }
424 }
425}
426
427// MARK: - Edit Profile Sheet
428
429private struct EditProfileSheet: View {
430 let profile: UserProfile
431 let viewModel: SettingsViewModel
432
433 @State private var email: String
434 @State private var url: String
435 @State private var location: String
436 @State private var bio: String
437 @State private var selectedPhoto: PhotosPickerItem?
438 @State private var avatarPreview: UIImage?
439 @State private var isShowingRemoveAvatarConfirmation = false
440
441 @Environment(\.dismiss) private var dismiss
442
443 init(profile: UserProfile, viewModel: SettingsViewModel) {
444 self.profile = profile
445 self.viewModel = viewModel
446 _email = State(initialValue: profile.email)
447 _url = State(initialValue: profile.url ?? "")
448 _location = State(initialValue: profile.location ?? "")
449 _bio = State(initialValue: profile.bio ?? "")
450 }
451
452 var body: some View {
453 NavigationStack {
454 Form {
455 Section {
456 HStack {
457 Spacer()
458 VStack(spacing: 8) {
459 PhotosPicker(selection: $selectedPhoto, matching: .images) {
460 Group {
461 if let avatarPreview {
462 Image(uiImage: avatarPreview)
463 .resizable()
464 .scaledToFill()
465 } else {
466 AsyncImage(url: profile.avatar.flatMap { URL(string: $0) }) { phase in
467 switch phase {
468 case .success(let image):
469 image
470 .resizable()
471 .scaledToFill()
472 default:
473 Image(systemName: "person.crop.circle.fill")
474 .resizable()
475 .foregroundStyle(.secondary)
476 }
477 }
478 }
479 }
480 .frame(width: 80, height: 80)
481 .clipShape(Circle())
482 .overlay(
483 Circle()
484 .stroke(.secondary.opacity(0.3), lineWidth: 1)
485 )
486 }
487
488 Text("Tap to change avatar")
489 .font(.caption)
490 .foregroundStyle(.secondary)
491
492 if viewModel.isUploadingAvatar {
493 ProgressView()
494 .controlSize(.small)
495 }
496 }
497 Spacer()
498 }
499 .listRowBackground(Color.clear)
500
501 if profile.avatar != nil || avatarPreview != nil {
502 HStack {
503 Spacer()
504 Button(role: .destructive) {
505 isShowingRemoveAvatarConfirmation = true
506 } label: {
507 Text("Remove Avatar")
508 }
509 .buttonStyle(.borderedProminent)
510 .disabled(viewModel.isUploadingAvatar)
511 Spacer()
512 }
513 .listRowBackground(Color.clear)
514 .listRowSeparator(.hidden)
515 }
516 }
517
518 Section("Edit Profile") {
519 VStack(alignment: .leading, spacing: 4) {
520 Text("Email")
521 .font(.caption)
522 .foregroundStyle(.secondary)
523 TextField("Enter email", text: $email)
524 .textContentType(.emailAddress)
525 .keyboardType(.emailAddress)
526 .autocorrectionDisabled()
527 .textInputAutocapitalization(.never)
528 }
529
530 VStack(alignment: .leading, spacing: 4) {
531 Text("URL")
532 .font(.caption)
533 .foregroundStyle(.secondary)
534 TextField("Enter URL", text: $url)
535 .textContentType(.URL)
536 .keyboardType(.URL)
537 .autocorrectionDisabled()
538 .textInputAutocapitalization(.never)
539 }
540
541 VStack(alignment: .leading, spacing: 4) {
542 Text("Location")
543 .font(.caption)
544 .foregroundStyle(.secondary)
545 TextField("Enter location", text: $location)
546 }
547
548 VStack(alignment: .leading, spacing: 4) {
549 Text("Bio")
550 .font(.caption)
551 .foregroundStyle(.secondary)
552 TextField("Enter bio", text: $bio, axis: .vertical)
553 .lineLimit(3...6)
554 }
555 }
556 }
557 .navigationTitle("Edit Profile")
558 .navigationBarTitleDisplayMode(.inline)
559 .onChange(of: selectedPhoto) { _, newItem in
560 guard let newItem else { return }
561 Task {
562 if let data = try? await newItem.loadTransferable(type: Data.self),
563 let image = UIImage(data: data) {
564 avatarPreview = image
565 // Encode as JPEG and upload
566 if let jpegData = image.jpegData(compressionQuality: 0.85) {
567 await viewModel.uploadAvatar(jpegData: jpegData)
568 }
569 }
570 }
571 }
572 .alert("Remove Avatar?", isPresented: $isShowingRemoveAvatarConfirmation) {
573 Button("Cancel", role: .cancel) {}
574 Button("Remove Avatar", role: .destructive) {
575 Task {
576 await viewModel.removeAvatar()
577 if viewModel.error == nil {
578 avatarPreview = nil
579 selectedPhoto = nil
580 }
581 }
582 }
583 } message: {
584 Text("Your profile avatar will be removed from SourceHut.")
585 }
586 .toolbar {
587 ToolbarItem(placement: .cancellationAction) {
588 Button("Cancel") {
589 dismiss()
590 }
591 }
592 ToolbarItem(placement: .confirmationAction) {
593 Button {
594 Task {
595 await viewModel.saveProfile(
596 email: email,
597 url: url,
598 location: location,
599 bio: bio
600 )
601 if viewModel.error == nil {
602 dismiss()
603 }
604 }
605 } label: {
606 if viewModel.isSavingProfile {
607 ProgressView()
608 .controlSize(.small)
609 } else {
610 Text("Save")
611 }
612 }
613 .disabled(viewModel.isSavingProfile)
614 }
615 }
616 }
617 }
618}
619
620private enum SettingsDestructiveAction {
621 case resetAppData
622 case signOut
623 case deleteSSHKey(SSHKey)
624 case deletePGPKey(PGPKey)
625
626 var title: String {
627 switch self {
628 case .resetAppData:
629 "Reset App Data?"
630 case .signOut:
631 "Sign Out?"
632 case .deleteSSHKey:
633 "Remove SSH Key?"
634 case .deletePGPKey:
635 "Remove PGP Key?"
636 }
637 }
638
639 var confirmationLabel: String {
640 switch self {
641 case .resetAppData:
642 "Reset App Data"
643 case .signOut:
644 "Sign Out"
645 case .deleteSSHKey:
646 "Remove SSH Key"
647 case .deletePGPKey:
648 "Remove PGP Key"
649 }
650 }
651
652 var message: String {
653 switch self {
654 case .resetAppData:
655 "This signs you out and removes saved token data, local settings, cached responses, cookies, and embedded web content on this device."
656 case .signOut:
657 "This signs you out of Hutch and clears saved authentication state on this device."
658 case .deleteSSHKey(let key):
659 "Remove SSH key \(key.fingerprint) from your account?"
660 case .deletePGPKey(let key):
661 "Remove PGP key \(key.fingerprint) from your account?"
662 }
663 }
664}
665
666private struct AboutView: View {
667 private let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
668 ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
669 ?? "Hutch"
670 private let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
671 ?? "Unknown"
672 private let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
673 ?? "Unknown"
674
675 var body: some View {
676 Form {
677 Section {
678 VStack(alignment: .leading, spacing: 6) {
679 Text(appName)
680 .font(.title2.weight(.semibold))
681 Text("A native SourceHut client for iPhone.")
682 .font(.subheadline)
683 .foregroundStyle(.secondary)
684 }
685 .padding(.vertical, 4)
686
687 LabeledContent("Version", value: version)
688 LabeledContent("Build", value: build)
689 }
690
691 Section("Links") {
692 Link(destination: URL(string: "https://sr.ht")!) {
693 SwiftUI.Label("SourceHut", systemImage: "link")
694 }
695 Link(destination: URL(string: "https://man.sr.ht")!) {
696 SwiftUI.Label("SourceHut Manuals", systemImage: "book")
697 }
698 Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) {
699 SwiftUI.Label("Project Repository", systemImage: "folder")
700 }
701 }
702
703 Section("Support") {
704 Link(destination: URL(string: "mailto:hello@cleberg.net")!) {
705 SwiftUI.Label("Email Support", systemImage: "envelope")
706 }
707 }
708
709 Section("Privacy") {
710 Text("Hutch uses your SourceHut personal access token to make requests on your behalf. The token is stored locally in the iOS keychain.")
711 .font(.subheadline)
712 .foregroundStyle(.secondary)
713 }
714
715 Section("Acknowledgements") {
716 Text("Built for SourceHut users who want quick access to repositories, builds, and tickets on iPhone.")
717 .font(.subheadline)
718 .foregroundStyle(.secondary)
719 }
720 }
721 .navigationTitle("About")
722 .navigationBarTitleDisplayMode(.inline)
723 }
724}
Hutch/Views/Settings/SettingsViewModel.swift added +394
@@ -0,0 +1,394 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct MeProfileResponse: Decodable, Sendable {
6 let me: UserProfile
7}
8
9private struct UpdateUserResponse: Decodable, Sendable {
10 let updateUser: UpdatedUser
11}
12
13private struct UpdatedUser: Decodable, Sendable {
14 let username: String
15 let email: String
16 let url: String?
17 let location: String?
18 let bio: String?
19 let avatar: String?
20}
21
22private struct CreateSSHKeyResponse: Decodable, Sendable {
23 let createSSHKey: SSHKey
24}
25
26private struct DeleteSSHKeyResponse: Decodable, Sendable {
27 let deleteSSHKey: DeleteResult
28}
29
30private struct CreatePGPKeyResponse: Decodable, Sendable {
31 let createPGPKey: PGPKey
32}
33
34private struct DeletePGPKeyResponse: Decodable, Sendable {
35 let deletePGPKey: DeleteResult
36}
37
38private struct DeleteResult: Decodable, Sendable {
39 let id: Int?
40}
41
42private struct PATListResponse: Decodable, Sendable {
43 let personalAccessTokens: [PersonalAccessToken]
44}
45
46// MARK: - View Model
47
48@Observable
49@MainActor
50final class SettingsViewModel {
51
52 private(set) var profile: UserProfile?
53 private(set) var sshKeys: [SSHKey] = []
54 private(set) var pgpKeys: [PGPKey] = []
55 private(set) var personalAccessTokens: [PersonalAccessToken] = []
56
57 private(set) var isLoading = false
58 private(set) var isLoadingPATs = false
59 private(set) var isSavingProfile = false
60 private(set) var isUploadingAvatar = false
61 var error: String?
62
63 var isEditingProfile = false
64
65 // Add SSH key fields
66 var newSSHKey = ""
67 var isAddingSSHKey = false
68
69 // Add PGP key fields
70 var newPGPKey = ""
71 var isAddingPGPKey = false
72
73 private let client: SRHTClient
74
75 init(client: SRHTClient) {
76 self.client = client
77 }
78
79 // MARK: - Queries
80
81 private static let profileQuery = """
82 query me {
83 me {
84 username
85 canonicalName
86 email
87 url
88 location
89 bio
90 avatar
91 userType
92 sshKeys {
93 results { id fingerprint comment created lastUsed }
94 cursor
95 }
96 pgpKeys {
97 results { id fingerprint created }
98 cursor
99 }
100 paymentStatus
101 subscription { status autorenew interval }
102 }
103 }
104 """
105
106 private static let updateUserMutation = """
107 mutation updateUser($input: UserInput!) {
108 updateUser(input: $input) {
109 username email url location bio avatar
110 }
111 }
112 """
113
114 private static let createSSHKeyMutation = """
115 mutation createSSHKey($key: String!) {
116 createSSHKey(key: $key) {
117 id fingerprint comment created lastUsed
118 }
119 }
120 """
121
122 private static let deleteSSHKeyMutation = """
123 mutation deleteSSHKey($id: Int!) {
124 deleteSSHKey(id: $id) { id }
125 }
126 """
127
128 private static let createPGPKeyMutation = """
129 mutation createPGPKey($key: String!) {
130 createPGPKey(key: $key) {
131 id fingerprint created
132 }
133 }
134 """
135
136 private static let deletePGPKeyMutation = """
137 mutation deletePGPKey($id: Int!) {
138 deletePGPKey(id: $id) { id }
139 }
140 """
141
142 private static let personalAccessTokensQuery = """
143 query personalAccessTokens {
144 personalAccessTokens { id issued expires comment grants }
145 }
146 """
147
148 // MARK: - Load Profile
149
150 func loadProfile() async {
151 guard !isLoading else { return }
152 isLoading = true
153 error = nil
154
155 do {
156 let result = try await client.execute(
157 service: .meta,
158 query: Self.profileQuery,
159 responseType: MeProfileResponse.self
160 )
161 profile = result.me
162 sshKeys = result.me.sshKeys.results
163 pgpKeys = result.me.pgpKeys.results
164 } catch {
165 self.error = error.localizedDescription
166 }
167
168 isLoading = false
169 }
170
171 // MARK: - Update Profile
172
173 func saveProfile(email: String, url: String, location: String, bio: String) async {
174 guard !isSavingProfile else { return }
175 isSavingProfile = true
176 error = nil
177
178 do {
179 let input: [String: any Sendable] = [
180 "email": email,
181 "url": url.isEmpty ? nil as String? as Any : url,
182 "location": location.isEmpty ? nil as String? as Any : location,
183 "bio": bio.isEmpty ? nil as String? as Any : bio
184 ]
185 let result = try await client.execute(
186 service: .meta,
187 query: Self.updateUserMutation,
188 variables: ["input": input],
189 responseType: UpdateUserResponse.self
190 )
191 let updated = result.updateUser
192 if let p = profile {
193 profile = UserProfile(
194 username: p.username,
195 canonicalName: p.canonicalName,
196 email: updated.email,
197 url: updated.url,
198 location: updated.location,
199 bio: updated.bio,
200 avatar: updated.avatar ?? p.avatar,
201 userType: p.userType,
202 sshKeys: p.sshKeys,
203 pgpKeys: p.pgpKeys,
204 paymentStatus: p.paymentStatus,
205 subscription: p.subscription
206 )
207 }
208 isEditingProfile = false
209 } catch {
210 self.error = error.localizedDescription
211 }
212
213 isSavingProfile = false
214 }
215
216 // MARK: - Avatar
217
218 func uploadAvatar(jpegData: Data) async {
219 guard !isUploadingAvatar else { return }
220 isUploadingAvatar = true
221 error = nil
222
223 do {
224 // The input variable has avatar set to null; the actual file
225 // is sent as a separate multipart part per graphql-multipart-request-spec.
226 let input: [String: any Sendable] = ["avatar": nil as String? as Any]
227 let result = try await client.executeMultipart(
228 service: .meta,
229 query: Self.updateUserMutation,
230 variables: ["input": input],
231 fileVariablePath: "input.avatar",
232 fileData: jpegData,
233 fileName: "avatar.jpg",
234 mimeType: "image/jpeg",
235 responseType: UpdateUserResponse.self
236 )
237 let updated = result.updateUser
238 if let p = profile {
239 profile = UserProfile(
240 username: p.username,
241 canonicalName: p.canonicalName,
242 email: updated.email,
243 url: updated.url,
244 location: updated.location,
245 bio: updated.bio,
246 avatar: updated.avatar ?? p.avatar,
247 userType: p.userType,
248 sshKeys: p.sshKeys,
249 pgpKeys: p.pgpKeys,
250 paymentStatus: p.paymentStatus,
251 subscription: p.subscription
252 )
253 }
254 } catch {
255 self.error = error.localizedDescription
256 }
257
258 isUploadingAvatar = false
259 }
260
261 func removeAvatar() async {
262 guard !isUploadingAvatar else { return }
263 isUploadingAvatar = true
264 error = nil
265
266 do {
267 let input: [String: any Sendable] = ["avatar": nil as String? as Any]
268 let result = try await client.execute(
269 service: .meta,
270 query: Self.updateUserMutation,
271 variables: ["input": input],
272 responseType: UpdateUserResponse.self
273 )
274 let updated = result.updateUser
275 if let p = profile {
276 profile = UserProfile(
277 username: p.username,
278 canonicalName: p.canonicalName,
279 email: updated.email,
280 url: updated.url,
281 location: updated.location,
282 bio: updated.bio,
283 avatar: nil,
284 userType: p.userType,
285 sshKeys: p.sshKeys,
286 pgpKeys: p.pgpKeys,
287 paymentStatus: p.paymentStatus,
288 subscription: p.subscription
289 )
290 }
291 } catch {
292 self.error = error.localizedDescription
293 }
294
295 isUploadingAvatar = false
296 }
297
298 // MARK: - SSH Keys
299
300 func addSSHKey() async {
301 let key = newSSHKey.trimmingCharacters(in: .whitespacesAndNewlines)
302 guard !key.isEmpty else { return }
303 error = nil
304
305 do {
306 let result = try await client.execute(
307 service: .meta,
308 query: Self.createSSHKeyMutation,
309 variables: ["key": key],
310 responseType: CreateSSHKeyResponse.self
311 )
312 sshKeys.append(result.createSSHKey)
313 newSSHKey = ""
314 isAddingSSHKey = false
315 } catch {
316 self.error = error.localizedDescription
317 }
318 }
319
320 func deleteSSHKey(_ key: SSHKey) async {
321 error = nil
322
323 do {
324 _ = try await client.execute(
325 service: .meta,
326 query: Self.deleteSSHKeyMutation,
327 variables: ["id": key.id],
328 responseType: DeleteSSHKeyResponse.self
329 )
330 sshKeys.removeAll { $0.id == key.id }
331 } catch {
332 self.error = error.localizedDescription
333 }
334 }
335
336 // MARK: - PGP Keys
337
338 func addPGPKey() async {
339 let key = newPGPKey.trimmingCharacters(in: .whitespacesAndNewlines)
340 guard !key.isEmpty else { return }
341 error = nil
342
343 do {
344 let result = try await client.execute(
345 service: .meta,
346 query: Self.createPGPKeyMutation,
347 variables: ["key": key],
348 responseType: CreatePGPKeyResponse.self
349 )
350 pgpKeys.append(result.createPGPKey)
351 newPGPKey = ""
352 isAddingPGPKey = false
353 } catch {
354 self.error = error.localizedDescription
355 }
356 }
357
358 func deletePGPKey(_ key: PGPKey) async {
359 error = nil
360
361 do {
362 _ = try await client.execute(
363 service: .meta,
364 query: Self.deletePGPKeyMutation,
365 variables: ["id": key.id],
366 responseType: DeletePGPKeyResponse.self
367 )
368 pgpKeys.removeAll { $0.id == key.id }
369 } catch {
370 self.error = error.localizedDescription
371 }
372 }
373
374 // MARK: - Personal Access Tokens
375
376 func loadPersonalAccessTokens() async {
377 guard !isLoadingPATs else { return }
378 isLoadingPATs = true
379
380 do {
381 let result = try await client.execute(
382 service: .meta,
383 query: Self.personalAccessTokensQuery,
384 responseType: PATListResponse.self
385 )
386 personalAccessTokens = result.personalAccessTokens
387 } catch {
388 self.error = error.localizedDescription
389 }
390
391 isLoadingPATs = false
392 }
393
394}
Hutch/Views/Tickets/TicketDetailView.swift added +828
@@ -0,0 +1,828 @@
1import SwiftUI
2import WebKit
3
4struct TicketDetailView: View {
5 let ownerUsername: String
6 let trackerName: String
7 let trackerId: Int
8 let trackerRid: String
9 let ticketId: Int
10
11 @Environment(AppState.self) private var appState
12 @Environment(\.colorScheme) private var colorScheme
13 @State private var viewModel: TicketDetailViewModel?
14
15 // Sheet state
16 @State private var showResolveSheet = false
17 @State private var showAssignSheet = false
18 @State private var showLabelsSheet = false
19
20 // Comment composer mode
21 @State private var commentMode: CommentMode = .write
22
23 private enum CommentMode: String, CaseIterable {
24 case write = "Write"
25 case preview = "Preview"
26 }
27
28 var body: some View {
29 Group {
30 if let viewModel {
31 detailContent(viewModel)
32 } else {
33 SRHTLoadingStateView(message: "Loading ticket…")
34 }
35 }
36 .navigationTitle("#\(ticketId)")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItemGroup(placement: .topBarTrailing) {
40 SRHTShareButton(url: SRHTWebURL.ticket(ownerUsername: ownerUsername, trackerName: trackerName, ticketId: ticketId), target: .ticket) {
41 Image(systemName: "square.and.arrow.up")
42 }
43
44 if let viewModel, viewModel.ticket != nil {
45 actionsMenu(viewModel)
46 }
47 }
48 }
49 .task {
50 if viewModel == nil {
51 let vm = TicketDetailViewModel(
52 ownerUsername: ownerUsername,
53 trackerName: trackerName,
54 trackerId: trackerId,
55 trackerRid: trackerRid,
56 ticketId: ticketId,
57 client: appState.client
58 )
59 viewModel = vm
60 await vm.loadTicket()
61 }
62 }
63 }
64
65 // MARK: - Actions Menu
66
67 @ViewBuilder
68 private func actionsMenu(_ viewModel: TicketDetailViewModel) -> some View {
69 Menu {
70 if let ticket = viewModel.ticket {
71 if ticket.status == .resolved {
72 Button {
73 Task {
74 await viewModel.updateStatus(
75 status: .reported,
76 resolution: .unresolved
77 )
78 }
79 } label: {
80 SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
81 }
82 } else {
83 Button {
84 showResolveSheet = true
85 } label: {
86 SwiftUI.Label("Resolve", systemImage: "checkmark.circle")
87 }
88 }
89 }
90
91 Button {
92 showAssignSheet = true
93 } label: {
94 SwiftUI.Label("Manage Assignees", systemImage: "person.badge.plus")
95 }
96
97 Button {
98 showLabelsSheet = true
99 Task { await viewModel.loadTrackerLabels() }
100 } label: {
101 SwiftUI.Label("Manage Labels", systemImage: "tag")
102 }
103 } label: {
104 Image(systemName: "ellipsis.circle")
105 }
106 .sheet(isPresented: $showResolveSheet) {
107 ResolveSheet(viewModel: viewModel, isPresented: $showResolveSheet)
108 .presentationDetents([.medium])
109 }
110 .sheet(isPresented: $showAssignSheet) {
111 AssignSheet(viewModel: viewModel, isPresented: $showAssignSheet)
112 .presentationDetents([.medium])
113 }
114 .sheet(isPresented: $showLabelsSheet) {
115 LabelsSheet(viewModel: viewModel, isPresented: $showLabelsSheet)
116 .presentationDetents([.medium])
117 }
118 }
119
120 // MARK: - Detail Content
121
122 @ViewBuilder
123 private func detailContent(_ viewModel: TicketDetailViewModel) -> some View {
124 @Bindable var vm = viewModel
125
126 if viewModel.isLoading, viewModel.ticket == nil {
127 SRHTLoadingStateView(message: "Loading ticket…")
128 } else if let error = viewModel.error, viewModel.ticket == nil {
129 SRHTErrorStateView(
130 title: "Couldn't Load Ticket",
131 message: error,
132 retryAction: { await viewModel.loadTicket() }
133 )
134 } else if let ticket = viewModel.ticket {
135 ScrollView {
136 VStack(alignment: .leading, spacing: 0) {
137 // Header
138 ticketHeader(ticket)
139
140 Divider()
141 .padding(.vertical, 12)
142
143 // Description
144 if let description = ticket.description, !description.isEmpty {
145 MarkdownContentView(markdown: description)
146 .padding(.horizontal)
147 .padding(.bottom, 16)
148
149 Divider()
150 .padding(.bottom, 12)
151 }
152
153 // Event timeline
154 if !viewModel.events.isEmpty {
155 Text("Activity")
156 .font(.headline)
157 .padding(.horizontal)
158 .padding(.bottom, 8)
159
160 LazyVStack(alignment: .leading, spacing: 0) {
161 ForEach(viewModel.events) { event in
162 EventRow(
163 event: event,
164 ticketSubmitter: viewModel.ticket?.submitter.canonicalName,
165 ticketAssignees: viewModel.ticket?.assignees.map { $0.canonicalName }
166 )
167 if event.id != viewModel.events.last?.id {
168 Divider()
169 .padding(.leading, 40)
170 }
171 }
172 }
173
174 Divider()
175 .padding(.vertical, 12)
176 }
177
178 // Comment input
179 commentInput(viewModel)
180 }
181 }
182 .srhtErrorBanner(error: $vm.error)
183 .refreshable {
184 await viewModel.loadTicket()
185 }
186 }
187 }
188
189 // MARK: - Header
190
191 @ViewBuilder
192 private func ticketHeader(_ ticket: TicketDetail) -> some View {
193 VStack(alignment: .leading, spacing: 8) {
194 Text(ticket.title)
195 .font(.title3.weight(.semibold))
196
197 HStack(spacing: 8) {
198 TicketStatusIcon(status: ticket.status)
199 Text(ticket.status.displayName)
200 .font(.subheadline.weight(.medium))
201
202 if ticket.status == .resolved, let resolution = ticket.resolution {
203 Text("(\(resolution.displayName))")
204 .font(.subheadline)
205 .foregroundStyle(.secondary)
206 }
207 }
208
209 HStack(spacing: 4) {
210 Text("Opened by")
211 .foregroundStyle(.secondary)
212 Text(ticket.submitter.canonicalName)
213 .fontWeight(.medium)
214 Text(ticket.created.relativeDescription)
215 .foregroundStyle(.tertiary)
216 }
217 .font(.caption)
218
219 if !ticket.assignees.isEmpty {
220 HStack(spacing: 4) {
221 Image(systemName: "person.fill")
222 .font(.caption2)
223 .foregroundStyle(.secondary)
224 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
225 .font(.caption)
226 .foregroundStyle(.secondary)
227 }
228 }
229
230 if !ticket.labels.isEmpty {
231 FlowLayout(spacing: 4) {
232 ForEach(ticket.labels) { label in
233 LabelPill(label: label)
234 }
235 }
236 }
237 }
238 .padding()
239 }
240
241 // MARK: - Comment Input
242
243 @ViewBuilder
244 private func commentInput(_ viewModel: TicketDetailViewModel) -> some View {
245 @Bindable var vm = viewModel
246
247 VStack(alignment: .leading, spacing: 8) {
248 Text("New Comment")
249 .font(.headline)
250
251 Picker("Mode", selection: $commentMode) {
252 ForEach(CommentMode.allCases, id: \.self) { mode in
253 Text(mode.rawValue).tag(mode)
254 }
255 }
256 .pickerStyle(.segmented)
257
258 if commentMode == .write {
259 TextField("Write your comment…", text: $vm.commentText, axis: .vertical)
260 .textFieldStyle(.roundedBorder)
261 .lineLimit(3...8)
262 } else {
263 // Markdown preview
264 if viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
265 Text("Nothing to preview")
266 .font(.subheadline)
267 .foregroundStyle(.secondary)
268 .frame(maxWidth: .infinity, minHeight: 80, alignment: .center)
269 .background(Color(.secondarySystemBackground))
270 .clipShape(RoundedRectangle(cornerRadius: 8))
271 } else {
272 MarkdownContentView(markdown: viewModel.commentText)
273 .frame(minHeight: 80, maxHeight: 200)
274 .clipShape(RoundedRectangle(cornerRadius: 8))
275 }
276 }
277
278 HStack {
279 Spacer()
280 Button {
281 Task { await viewModel.submitComment() }
282 } label: {
283 if viewModel.isSubmitting {
284 ProgressView()
285 .controlSize(.small)
286 } else {
287 Text("Post Comment")
288 }
289 }
290 .buttonStyle(.borderedProminent)
291 .disabled(viewModel.commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSubmitting)
292 }
293 }
294 .padding()
295 }
296}
297
298// MARK: - Self-Sizing Markdown Web View
299
300/// Renders markdown as HTML in a WKWebView that auto-sizes its height to
301/// fit the rendered content. Reuses the same `markdownToHTML` converter and
302/// styling as the README renderer.
303private struct MarkdownContentView: View {
304 let markdown: String
305 @Environment(\.colorScheme) private var colorScheme
306 @State private var renderedHTML: String?
307
308 var body: some View {
309 Group {
310 if let renderedHTML {
311 HTMLWebView(
312 html: renderedHTML,
313 colorScheme: colorScheme,
314 style: .commentPreview
315 )
316 } else {
317 SRHTLoadingStateView(message: "Preparing content…")
318 .frame(minHeight: 80)
319 }
320 }
321 .task(id: markdown) {
322 if renderedHTML != nil {
323 try? await Task.sleep(for: .milliseconds(150))
324 guard !Task.isCancelled else { return }
325 }
326
327 let html = await Task.detached(priority: .userInitiated) {
328 markdownToHTML(markdown)
329 }.value
330 guard !Task.isCancelled else { return }
331 renderedHTML = html
332 }
333 }
334}
335
336// MARK: - Event Row
337
338private struct EventRow: View {
339 let event: TicketEvent
340 let ticketSubmitter: String?
341 let ticketAssignees: [String]?
342 @State private var isShowingSystemStatusInfo = false
343
344 var body: some View {
345 ForEach(event.changes) { change in
346 HStack(alignment: .top, spacing: 12) {
347 Image(systemName: icon(for: change))
348 .foregroundStyle(color(for: change))
349 .frame(width: 24)
350 .padding(.top, 2)
351
352 VStack(alignment: .leading, spacing: 4) {
353 HStack {
354 if kind(for: change) == .comment {
355 Text(change.author?.canonicalName ?? "")
356 .font(.subheadline.weight(.medium))
357 } else {
358 let descriptionText = description(for: change, in: event, ticketSubmitter: ticketSubmitter, ticketAssignees: ticketAssignees)
359 if descriptionText.hasPrefix("System") {
360 HStack(spacing: 4) {
361 Text(descriptionText)
362 .font(.subheadline)
363 .foregroundStyle(.secondary)
364 Button {
365 isShowingSystemStatusInfo = true
366 } label: {
367 Image(systemName: "info.circle")
368 .foregroundStyle(.gray)
369 }
370 .buttonStyle(.plain)
371 }
372 } else {
373 Text(descriptionText)
374 .font(.subheadline)
375 .foregroundStyle(.secondary)
376 }
377 }
378 Spacer()
379 Text(event.created.relativeDescription)
380 .font(.caption)
381 .foregroundStyle(.tertiary)
382 }
383 if kind(for: change) == .comment, let text = change.text {
384 MarkdownContentView(markdown: text)
385 }
386 }
387 }
388 .padding(.horizontal)
389 .padding(.vertical, 8)
390 .alert("System Status Change", isPresented: $isShowingSystemStatusInfo) {
391 Button("OK", role: .cancel) {}
392 } message: {
393 Text("This status change was recorded automatically or without a named user attached to the event.")
394 }
395 }
396 }
397
398 private func description(for change: EventChange, in event: TicketEvent, ticketSubmitter: String? = nil, ticketAssignees: [String]? = nil) -> String {
399 let eventKind = kind(for: change)
400 let authorName: String
401
402 if let commentAuthor = event.changes.first(where: {
403 kind(for: $0) == .comment && $0.author != nil
404 })?.author?.canonicalName {
405 authorName = commentAuthor
406 } else {
407 switch eventKind {
408 case .created:
409 authorName = change.author?.canonicalName ?? ticketSubmitter ?? "Someone"
410 case .statusChange:
411 authorName = "System"
412 case .labelAdded, .labelRemoved, .labelUpdated:
413 authorName = change.labeler?.canonicalName ?? "Someone"
414 case .assigned, .unassigned:
415 authorName = change.assigner?.canonicalName ?? "Someone"
416 case .comment:
417 authorName = change.author?.canonicalName ?? "Someone"
418 case .ticketMention, .userMention:
419 authorName = change.author?.canonicalName
420 ?? change.assigner?.canonicalName
421 ?? change.labeler?.canonicalName
422 ?? "Someone"
423 case .unknown:
424 authorName = change.author?.canonicalName
425 ?? change.assigner?.canonicalName
426 ?? change.labeler?.canonicalName
427 ?? change.assignee?.canonicalName
428 ?? change.mentioned?.canonicalName
429 ?? ticketAssignees?.first
430 ?? "Someone"
431 }
432 }
433
434 switch eventKind {
435 case .statusChange:
436 let oldStatus = change.oldStatus?.displayName ?? "unknown"
437 let newStatus = change.newStatus?.displayName ?? "unknown"
438 return "\(authorName) changed status from \(oldStatus) to \(newStatus)"
439 case .labelUpdated, .labelAdded:
440 let labelName = change.label?.name ?? "a label"
441 let verb = eventKind == .labelAdded ? "added" : "updated"
442 return "\(authorName) \(verb) label \"\(labelName)\""
443 case .labelRemoved:
444 let labelName = change.label?.name ?? "a label"
445 return "\(authorName) removed label \"\(labelName)\""
446 case .assigned:
447 let assigneeName = change.assignee?.canonicalName ?? "someone"
448 return "\(authorName) assigned \(assigneeName)"
449 case .unassigned:
450 let assigneeName = change.assignee?.canonicalName ?? "someone"
451 return "\(authorName) unassigned \(assigneeName)"
452 case .ticketMention:
453 if let ticketId = change.mentioned?.id {
454 return "\(authorName) mentioned ticket #\(ticketId)"
455 }
456 return "\(authorName) mentioned another ticket"
457 case .userMention:
458 let user = change.mentioned?.canonicalName ?? "someone"
459 return "\(authorName) mentioned \(user)"
460 case .created:
461 return "\(authorName) opened this ticket"
462 case .comment:
463 return "\(authorName) commented"
464 case .unknown:
465 return "\(authorName) updated this ticket"
466 }
467 }
468
469 private func icon(for change: EventChange) -> String {
470 switch kind(for: change) {
471 case .comment:
472 "text.bubble"
473 case .statusChange:
474 "arrow.triangle.2.circlepath"
475 case .labelAdded, .labelRemoved, .labelUpdated:
476 "tag"
477 case .assigned:
478 "person.badge.plus"
479 case .unassigned:
480 "person.badge.minus"
481 case .ticketMention, .userMention:
482 "at"
483 case .created:
484 "plus.circle"
485 case .unknown:
486 "circle.fill"
487 }
488 }
489
490 private func color(for change: EventChange) -> Color {
491 switch kind(for: change) {
492 case .comment:
493 .blue
494 case .statusChange:
495 change.newStatus == .resolved ? .green : .orange
496 case .labelAdded, .labelRemoved, .labelUpdated:
497 .purple
498 case .assigned, .unassigned:
499 .cyan
500 case .ticketMention, .userMention:
501 .indigo
502 case .created:
503 .green
504 case .unknown:
505 .gray
506 }
507 }
508
509 private func kind(for change: EventChange) -> EventKind {
510 switch change.eventType {
511 case "COMMENT", "Comment":
512 .comment
513 case "STATUS_CHANGE", "StatusChange":
514 .statusChange
515 case "LABEL_UPDATE", "LabelUpdate":
516 .labelUpdated
517 case "LABEL_ADDED", "LabelAdded":
518 .labelAdded
519 case "LABEL_REMOVED", "LabelRemoved":
520 .labelRemoved
521 case "ASSIGNMENT", "Assignment", "ASSIGNED_USER", "AssignedUser":
522 .assigned
523 case "UNASSIGNED_USER", "UnassignedUser":
524 .unassigned
525 case "TICKET_MENTION", "TicketMention":
526 .ticketMention
527 case "USER_MENTION", "UserMention":
528 .userMention
529 case "CREATED", "Created":
530 .created
531 default:
532 .unknown
533 }
534 }
535
536 private enum EventKind: Equatable {
537 case comment
538 case statusChange
539 case labelUpdated
540 case labelAdded
541 case labelRemoved
542 case assigned
543 case unassigned
544 case ticketMention
545 case userMention
546 case created
547 case unknown
548 }
549}
550
551// MARK: - Resolve Sheet
552
553private struct ResolveSheet: View {
554 let viewModel: TicketDetailViewModel
555 @Binding var isPresented: Bool
556 @State private var selectedResolution: TicketResolution = .fixed
557
558 private static let resolutionOptions: [TicketResolution] = [
559 .closed, .fixed, .implemented, .wontFix,
560 .byDesign, .invalid, .duplicate, .notOurBug
561 ]
562
563 var body: some View {
564 NavigationStack {
565 Form {
566 Section("Resolution") {
567 Picker("Resolution", selection: $selectedResolution) {
568 ForEach(Self.resolutionOptions, id: \.self) { resolution in
569 Text(resolution.displayName).tag(resolution)
570 }
571 }
572 .pickerStyle(.inline)
573 .labelsHidden()
574 }
575 }
576 .navigationTitle("Resolve Ticket")
577 .navigationBarTitleDisplayMode(.inline)
578 .toolbar {
579 ToolbarItem(placement: .cancellationAction) {
580 Button("Cancel") { isPresented = false }
581 }
582 ToolbarItem(placement: .confirmationAction) {
583 Button("Mark Resolved") {
584 Task {
585 await viewModel.updateStatus(
586 status: .resolved,
587 resolution: selectedResolution
588 )
589 if viewModel.error == nil {
590 isPresented = false
591 }
592 }
593 }
594 .disabled(viewModel.isPerformingAction)
595 }
596 }
597 .overlay {
598 if viewModel.isPerformingAction {
599 ProgressView()
600 }
601 }
602 }
603 }
604}
605
606// MARK: - Assign Sheet
607
608private struct AssignSheet: View {
609 let viewModel: TicketDetailViewModel
610 @Binding var isPresented: Bool
611 @State private var username = ""
612
613 var body: some View {
614 NavigationStack {
615 Form {
616 // Current assignees with remove buttons
617 if let ticket = viewModel.ticket, !ticket.assignees.isEmpty {
618 Section("Current Assignees") {
619 ForEach(ticket.assignees, id: \.canonicalName) { assignee in
620 HStack {
621 Text(assignee.canonicalName)
622 Spacer()
623 Button(role: .destructive) {
624 Task {
625 await viewModel.unassignUser(
626 username: assignee.canonicalName
627 )
628 }
629 } label: {
630 Image(systemName: "minus.circle.fill")
631 .foregroundStyle(.red)
632 }
633 .buttonStyle(.plain)
634 }
635 }
636 }
637 }
638
639 Section("Add Assignee") {
640 TextField("Username or ~username", text: $username)
641 .textContentType(.username)
642 .autocorrectionDisabled()
643 .textInputAutocapitalization(.never)
644
645 Button("Add Assignee") {
646 let name = username.trimmingCharacters(in: .whitespacesAndNewlines)
647 guard !name.isEmpty else { return }
648 Task {
649 await viewModel.assignUser(username: name)
650 username = ""
651 }
652 }
653 .disabled(
654 username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
655 || viewModel.isPerformingAction
656 )
657 }
658 }
659 .navigationTitle("Assignees")
660 .navigationBarTitleDisplayMode(.inline)
661 .toolbar {
662 ToolbarItem(placement: .confirmationAction) {
663 Button("Done") { isPresented = false }
664 }
665 }
666 .overlay {
667 if viewModel.isPerformingAction {
668 ProgressView()
669 }
670 }
671 }
672 }
673}
674
675// MARK: - Labels Sheet
676
677private struct LabelsSheet: View {
678 let viewModel: TicketDetailViewModel
679 @Binding var isPresented: Bool
680 @State private var showCreateLabel = false
681
682 var body: some View {
683 NavigationStack {
684 Group {
685 if viewModel.trackerLabels.isEmpty {
686 if viewModel.isPerformingAction {
687 ProgressView()
688 } else {
689 ContentUnavailableView(
690 "No Labels",
691 systemImage: "tag",
692 description: Text("This tracker has no labels defined.")
693 )
694 }
695 } else {
696 List {
697 ForEach(viewModel.trackerLabels) { label in
698 LabelToggleRow(
699 label: label,
700 isApplied: viewModel.ticket?.labels.contains(where: { $0.id == label.id }) ?? false,
701 isLoading: viewModel.isPerformingAction
702 ) { shouldApply in
703 Task {
704 if shouldApply {
705 await viewModel.labelTicket(labelId: label.id)
706 } else {
707 await viewModel.unlabelTicket(labelId: label.id)
708 }
709 }
710 }
711 }
712 }
713 }
714 }
715 .navigationTitle("Labels")
716 .navigationBarTitleDisplayMode(.inline)
717 .toolbar {
718 ToolbarItem(placement: .cancellationAction) {
719 Button("Done") { isPresented = false }
720 }
721 ToolbarItem(placement: .primaryAction) {
722 Button {
723 showCreateLabel = true
724 } label: {
725 SwiftUI.Label("New Label", systemImage: "plus")
726 }
727 }
728 }
729 .sheet(isPresented: $showCreateLabel) {
730 CreateLabelSheet(viewModel: viewModel, isPresented: $showCreateLabel)
731 .presentationDetents([.medium])
732 }
733 }
734 }
735}
736
737// MARK: - Create Label Sheet
738
739private struct CreateLabelSheet: View {
740 let viewModel: TicketDetailViewModel
741 @Binding var isPresented: Bool
742 @State private var labelName = ""
743 @State private var backgroundColor = Color.blue
744 @State private var foregroundColor = Color.white
745
746 var body: some View {
747 NavigationStack {
748 Form {
749 Section("Label Details") {
750 TextField("Label name", text: $labelName)
751 .autocorrectionDisabled()
752 }
753
754 Section("Colors") {
755 ColorPicker("Background color", selection: $backgroundColor, supportsOpacity: false)
756 ColorPicker("Text color", selection: $foregroundColor, supportsOpacity: false)
757 }
758
759 Section("Preview") {
760 HStack {
761 Spacer()
762 Text(labelName.isEmpty ? "Label" : labelName)
763 .font(.caption2.weight(.medium))
764 .padding(.horizontal, 6)
765 .padding(.vertical, 2)
766 .background(backgroundColor)
767 .foregroundStyle(foregroundColor)
768 .clipShape(Capsule())
769 Spacer()
770 }
771 }
772 }
773 .navigationTitle("New Label")
774 .navigationBarTitleDisplayMode(.inline)
775 .toolbar {
776 ToolbarItem(placement: .cancellationAction) {
777 Button("Cancel") { isPresented = false }
778 }
779 ToolbarItem(placement: .confirmationAction) {
780 Button("Create Label") {
781 Task {
782 await viewModel.createLabel(
783 name: labelName.trimmingCharacters(in: .whitespacesAndNewlines),
784 backgroundColor: backgroundColor.hexString,
785 foregroundColor: foregroundColor.hexString
786 )
787 if viewModel.error == nil {
788 isPresented = false
789 }
790 }
791 }
792 .disabled(
793 labelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
794 || viewModel.isPerformingAction
795 )
796 }
797 }
798 .overlay {
799 if viewModel.isPerformingAction {
800 ProgressView()
801 }
802 }
803 }
804 }
805}
806
807private struct LabelToggleRow: View {
808 let label: TicketLabel
809 let isApplied: Bool
810 let isLoading: Bool
811 let onToggle: (Bool) -> Void
812
813 var body: some View {
814 Button {
815 onToggle(!isApplied)
816 } label: {
817 HStack {
818 LabelPill(label: label)
819 Spacer()
820 if isApplied {
821 Image(systemName: "checkmark")
822 .foregroundStyle(.blue)
823 }
824 }
825 }
826 .disabled(isLoading)
827 }
828}
Hutch/Views/Tickets/TicketDetailViewModel.swift added +551
@@ -0,0 +1,551 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TicketDetailResponse: Decodable, Sendable {
6 let user: UserTrackerTicketWrapper
7}
8
9private struct UserTrackerTicketWrapper: Decodable, Sendable {
10 let tracker: TrackerTicketWrapper
11}
12
13private struct TrackerTicketWrapper: Decodable, Sendable {
14 let ticket: TicketDetailPayload
15}
16
17private struct TicketDetailPayload: Decodable, Sendable {
18 let id: Int
19 let created: Date
20 let updated: Date
21 let title: String
22 let description: String?
23 let status: TicketStatus
24 let resolution: TicketResolution?
25 let authenticity: Authenticity
26 let submitter: Entity
27 let assignees: [Entity]
28 let labels: [TicketLabel]
29 let events: EventsPage
30}
31
32private struct EventsPage: Decodable, Sendable {
33 let results: [TicketEvent]
34 let cursor: String?
35}
36
37private struct SubmitCommentResponse: Decodable, Sendable {
38 let submitComment: SubmittedEvent
39}
40
41private struct SubmittedEvent: Decodable, Sendable {
42 let id: Int
43 let created: Date
44 let changes: [EventChange]
45}
46
47private struct MutationEventResponse: Decodable, Sendable {
48 let id: Int
49}
50
51private struct UpdateStatusResponse: Decodable, Sendable {
52 let updateTicketStatus: UpdatedStatusEvent
53}
54
55private struct UpdatedStatusEvent: Decodable, Sendable {
56 let eventType: String
57}
58
59private struct AssignUserResponse: Decodable, Sendable {
60 let assignUser: MutationEventResponse
61}
62
63private struct UnassignUserResponse: Decodable, Sendable {
64 let unassignUser: MutationEventResponse
65}
66
67private struct LabelTicketResponse: Decodable, Sendable {
68 let labelTicket: MutationEventResponse
69}
70
71private struct UnlabelTicketResponse: Decodable, Sendable {
72 let unlabelTicket: MutationEventResponse
73}
74
75private struct UserLookupResponse: Decodable, Sendable {
76 let user: UserIdPayload
77}
78
79private struct UserIdPayload: Decodable, Sendable {
80 let id: Int
81}
82
83private struct CreateLabelResponse: Decodable, Sendable {
84 let createLabel: TicketLabel
85}
86
87private struct TrackerLabelsResponse: Decodable, Sendable {
88 let user: UserTrackerLabelsWrapper
89}
90
91private struct UserTrackerLabelsWrapper: Decodable, Sendable {
92 let tracker: TrackerLabelsWrapper
93}
94
95private struct TrackerLabelsWrapper: Decodable, Sendable {
96 let labels: LabelsPage
97}
98
99private struct LabelsPage: Decodable, Sendable {
100 let results: [TicketLabel]
101}
102
103// MARK: - View Model
104
105@Observable
106@MainActor
107final class TicketDetailViewModel {
108
109 let ownerUsername: String
110 let trackerName: String
111 let trackerId: Int
112 let trackerRid: String
113 let ticketId: Int
114
115 private(set) var ticket: TicketDetail?
116 private(set) var events: [TicketEvent] = []
117 private(set) var isLoading = false
118 private(set) var isSubmitting = false
119 private(set) var isPerformingAction = false
120 private(set) var trackerLabels: [TicketLabel] = []
121 var commentText = ""
122 var error: String?
123
124 private let client: SRHTClient
125
126 private static func timelineOrder(lhs: TicketEvent, rhs: TicketEvent) -> Bool {
127 if lhs.created == rhs.created {
128 return lhs.id < rhs.id
129 }
130 return lhs.created < rhs.created
131 }
132
133 init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
134 self.ownerUsername = ownerUsername
135 self.trackerName = trackerName
136 self.trackerId = trackerId
137 self.trackerRid = trackerRid
138 self.ticketId = ticketId
139 self.client = client
140 }
141
142 // MARK: - Queries
143
144 private static let detailQuery = """
145 query ticket($owner: String!, $tracker: String!, $ticketId: Int!) {
146 user(username: $owner) {
147 tracker(name: $tracker) {
148 ticket(id: $ticketId) {
149 id
150 created
151 updated
152 title: subject
153 description: body
154 status
155 resolution
156 authenticity
157 submitter { canonicalName }
158 assignees { canonicalName }
159 labels { id name backgroundColor foregroundColor }
160 events {
161 results {
162 id
163 created
164 changes {
165 eventType
166 ... on Comment {
167 author { canonicalName }
168 text
169 authenticity
170 }
171 ... on StatusChange {
172 oldStatus
173 newStatus
174 }
175 ... on LabelUpdate {
176 labeler { canonicalName }
177 label { name }
178 }
179 ... on Assignment {
180 assigner { canonicalName }
181 assignee { canonicalName }
182 }
183 ... on TicketMention {
184 mentioned { id }
185 }
186 ... on UserMention {
187 mentioned { canonicalName }
188 }
189 ... on Created {
190 author { canonicalName }
191 }
192 }
193 }
194 cursor
195 }
196 }
197 }
198 }
199 }
200 """
201
202 private static let submitCommentMutation = """
203 mutation submitComment($trackerId: Int!, $ticketId: Int!, $input: SubmitCommentInput!) {
204 submitComment(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
205 id
206 created
207 changes {
208 eventType
209 ... on Comment {
210 author { canonicalName }
211 text
212 authenticity
213 }
214 }
215 }
216 }
217 """
218
219 private static let updateStatusMutation = """
220 mutation updateTicketStatus($trackerId: Int!, $ticketId: Int!, $input: UpdateStatusInput!) {
221 updateTicketStatus(trackerId: $trackerId, ticketId: $ticketId, input: $input) {
222 eventType: __typename
223 }
224 }
225 """
226
227 private static let assignUserMutation = """
228 mutation assignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
229 assignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
230 }
231 """
232
233 private static let unassignUserMutation = """
234 mutation unassignUser($trackerId: Int!, $ticketId: Int!, $userId: Int!) {
235 unassignUser(trackerId: $trackerId, ticketId: $ticketId, userId: $userId) { id }
236 }
237 """
238
239 private static let labelTicketMutation = """
240 mutation labelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
241 labelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
242 }
243 """
244
245 private static let unlabelTicketMutation = """
246 mutation unlabelTicket($trackerId: Int!, $ticketId: Int!, $labelId: Int!) {
247 unlabelTicket(trackerId: $trackerId, ticketId: $ticketId, labelId: $labelId) { id }
248 }
249 """
250
251 private static let userLookupQuery = """
252 query userLookup($username: String!) {
253 user(username: $username) { id }
254 }
255 """
256
257 private static let trackerLabelsQuery = """
258 query trackerLabels($owner: String!, $tracker: String!) {
259 user(username: $owner) {
260 tracker(name: $tracker) {
261 labels {
262 results { id name backgroundColor foregroundColor }
263 }
264 }
265 }
266 }
267 """
268
269 private static let createLabelMutation = """
270 mutation createLabel($trackerId: Int!, $name: String!, $backgroundColor: String!, $foregroundColor: String!) {
271 createLabel(trackerId: $trackerId, name: $name, backgroundColor: $backgroundColor, foregroundColor: $foregroundColor) {
272 id
273 name
274 backgroundColor
275 foregroundColor
276 }
277 }
278 """
279
280 // MARK: - Public API
281
282 func loadTicket() async {
283 guard !isLoading else { return }
284 isLoading = true
285 error = nil
286
287 do {
288 let result = try await client.execute(
289 service: .todo,
290 query: Self.detailQuery,
291 variables: [
292 "owner": ownerUsername,
293 "tracker": trackerName,
294 "ticketId": ticketId
295 ],
296 responseType: TicketDetailResponse.self
297 )
298 let payload = result.user.tracker.ticket
299 ticket = TicketDetail(
300 id: payload.id,
301 created: payload.created,
302 updated: payload.updated,
303 title: payload.title,
304 description: payload.description,
305 status: payload.status,
306 resolution: payload.resolution,
307 authenticity: payload.authenticity,
308 submitter: payload.submitter,
309 assignees: payload.assignees,
310 labels: payload.labels
311 )
312 events = payload.events.results.sorted(by: Self.timelineOrder)
313 } catch {
314 self.error = error.localizedDescription
315 }
316
317 isLoading = false
318 }
319
320 func submitComment() async {
321 let text = commentText.trimmingCharacters(in: .whitespacesAndNewlines)
322 guard !text.isEmpty, !isSubmitting else { return }
323 isSubmitting = true
324 error = nil
325
326 do {
327 let input: [String: any Sendable] = ["text": text]
328 let result = try await client.execute(
329 service: .todo,
330 query: Self.submitCommentMutation,
331 variables: [
332 "trackerId": trackerId,
333 "ticketId": ticketId,
334 "input": input
335 ],
336 responseType: SubmitCommentResponse.self
337 )
338 // Append the returned event so the comment shows immediately.
339 let submitted = result.submitComment
340 let event = TicketEvent(
341 id: submitted.id,
342 created: submitted.created,
343 changes: submitted.changes
344 )
345 events.append(event)
346 events.sort(by: Self.timelineOrder)
347 commentText = ""
348 } catch {
349 self.error = error.localizedDescription
350 }
351
352 isSubmitting = false
353 }
354
355 func updateComment(commentId: Int, text: String) async {
356 _ = commentId
357 _ = text
358 error = "Comment editing is not available in todo.sr.ht's public GraphQL API."
359 }
360
361 // MARK: - Ticket Actions
362
363 func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
364 guard !isPerformingAction else { return }
365 isPerformingAction = true
366 error = nil
367
368 do {
369 let input: [String: any Sendable] = [
370 "status": status.rawValue,
371 "resolution": resolution.rawValue
372 ]
373 _ = try await client.execute(
374 service: .todo,
375 query: Self.updateStatusMutation,
376 variables: [
377 "trackerId": trackerId,
378 "ticketId": ticketId,
379 "input": input
380 ],
381 responseType: UpdateStatusResponse.self
382 )
383 // Re-fetch the ticket to get updated status/resolution
384 await loadTicket()
385 } catch {
386 self.error = error.localizedDescription
387 }
388
389 isPerformingAction = false
390 }
391
392 func assignUser(username: String) async {
393 guard !isPerformingAction else { return }
394 isPerformingAction = true
395 error = nil
396
397 do {
398 // Resolve username to user ID
399 let userResult = try await client.execute(
400 service: .todo,
401 query: Self.userLookupQuery,
402 variables: ["username": username],
403 responseType: UserLookupResponse.self
404 )
405 let userId = userResult.user.id
406
407 _ = try await client.execute(
408 service: .todo,
409 query: Self.assignUserMutation,
410 variables: [
411 "trackerId": trackerId,
412 "ticketId": ticketId,
413 "userId": userId
414 ],
415 responseType: AssignUserResponse.self
416 )
417 // Reload to reflect the change
418 await loadTicket()
419 } catch {
420 self.error = error.localizedDescription
421 }
422
423 isPerformingAction = false
424 }
425
426 func unassignUser(username: String) async {
427 guard !isPerformingAction else { return }
428 isPerformingAction = true
429 error = nil
430
431 do {
432 // Resolve username to user ID
433 let stripped = username.hasPrefix("~") ? String(username.dropFirst()) : username
434 let userResult = try await client.execute(
435 service: .todo,
436 query: Self.userLookupQuery,
437 variables: ["username": stripped],
438 responseType: UserLookupResponse.self
439 )
440 let userId = userResult.user.id
441
442 _ = try await client.execute(
443 service: .todo,
444 query: Self.unassignUserMutation,
445 variables: [
446 "trackerId": trackerId,
447 "ticketId": ticketId,
448 "userId": userId
449 ],
450 responseType: UnassignUserResponse.self
451 )
452 // Reload to reflect the change
453 await loadTicket()
454 } catch {
455 self.error = error.localizedDescription
456 }
457
458 isPerformingAction = false
459 }
460
461 func labelTicket(labelId: Int) async {
462 guard !isPerformingAction else { return }
463 isPerformingAction = true
464 error = nil
465
466 do {
467 _ = try await client.execute(
468 service: .todo,
469 query: Self.labelTicketMutation,
470 variables: [
471 "trackerId": trackerId,
472 "ticketId": ticketId,
473 "labelId": labelId
474 ],
475 responseType: LabelTicketResponse.self
476 )
477 await loadTicket()
478 } catch {
479 self.error = error.localizedDescription
480 }
481
482 isPerformingAction = false
483 }
484
485 func unlabelTicket(labelId: Int) async {
486 guard !isPerformingAction else { return }
487 isPerformingAction = true
488 error = nil
489
490 do {
491 _ = try await client.execute(
492 service: .todo,
493 query: Self.unlabelTicketMutation,
494 variables: [
495 "trackerId": trackerId,
496 "ticketId": ticketId,
497 "labelId": labelId
498 ],
499 responseType: UnlabelTicketResponse.self
500 )
501 await loadTicket()
502 } catch {
503 self.error = error.localizedDescription
504 }
505
506 isPerformingAction = false
507 }
508
509 func loadTrackerLabels() async {
510 do {
511 let result = try await client.execute(
512 service: .todo,
513 query: Self.trackerLabelsQuery,
514 variables: [
515 "owner": ownerUsername,
516 "tracker": trackerName
517 ],
518 responseType: TrackerLabelsResponse.self
519 )
520 trackerLabels = result.user.tracker.labels.results
521 } catch {
522 self.error = error.localizedDescription
523 }
524 }
525
526 func createLabel(name: String, backgroundColor: String, foregroundColor: String) async {
527 guard !isPerformingAction else { return }
528 isPerformingAction = true
529 error = nil
530
531 do {
532 let result = try await client.execute(
533 service: .todo,
534 query: Self.createLabelMutation,
535 variables: [
536 "trackerId": trackerId,
537 "name": name,
538 "backgroundColor": backgroundColor,
539 "foregroundColor": foregroundColor
540 ],
541 responseType: CreateLabelResponse.self
542 )
543 trackerLabels.append(result.createLabel)
544 } catch {
545 self.error = error.localizedDescription
546 }
547
548 isPerformingAction = false
549 }
550
551}
Hutch/Views/Tickets/TicketListView.swift added +389
@@ -0,0 +1,389 @@
1import SwiftUI
2
3struct TicketListView: View {
4 let ownerUsername: String
5 let trackerName: String
6 let trackerId: Int
7 let trackerRid: String
8
9 @Environment(AppState.self) private var appState
10 @State private var viewModel: TicketListViewModel?
11 @State private var showCreateTicketSheet = false
12 @State private var createdTicket: TicketSummary?
13
14 var body: some View {
15 Group {
16 if let viewModel {
17 listContent(viewModel)
18 } else {
19 SRHTLoadingStateView(message: "Loading tickets…")
20 }
21 }
22 .navigationTitle(trackerName)
23 .navigationBarTitleDisplayMode(.inline)
24 .toolbar {
25 ToolbarItemGroup(placement: .topBarTrailing) {
26 SRHTShareButton(url: SRHTWebURL.tracker(ownerUsername: ownerUsername, trackerName: trackerName), target: .tracker) {
27 Image(systemName: "square.and.arrow.up")
28 }
29
30 if viewModel != nil {
31 Button {
32 showCreateTicketSheet = true
33 } label: {
34 Image(systemName: "plus")
35 }
36 }
37 }
38 }
39 .sheet(isPresented: $showCreateTicketSheet) {
40 if let viewModel {
41 CreateTicketSheet(viewModel: viewModel) { ticket in
42 showCreateTicketSheet = false
43 createdTicket = ticket
44 }
45 }
46 }
47 .navigationDestination(isPresented: Binding(
48 get: { createdTicket != nil },
49 set: { isPresented in
50 if !isPresented {
51 createdTicket = nil
52 }
53 }
54 )) {
55 if let createdTicket {
56 TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: createdTicket.id)
57 }
58 }
59 .task {
60 if viewModel == nil {
61 let vm = TicketListViewModel(
62 ownerUsername: ownerUsername,
63 trackerName: trackerName,
64 trackerId: trackerId,
65 client: appState.client
66 )
67 viewModel = vm
68 await vm.loadTickets()
69 }
70 }
71 }
72
73 @ViewBuilder
74 private func listContent(_ viewModel: TicketListViewModel) -> some View {
75 @Bindable var vm = viewModel
76
77 List {
78 // Filter picker
79 Section {
80 Picker("Filter", selection: $vm.filter) {
81 ForEach(TicketFilter.allCases, id: \.self) { filter in
82 Text(filter.rawValue).tag(filter)
83 }
84 }
85 .pickerStyle(.segmented)
86 .listRowBackground(Color.clear)
87 .listRowInsets(EdgeInsets())
88 }
89
90 // Tickets
91 ForEach(viewModel.filteredTickets) { ticket in
92 NavigationLink(value: ticket) {
93 TicketRowView(ticket: ticket)
94 }
95 .task {
96 await viewModel.loadMoreIfNeeded(currentItem: ticket)
97 }
98 }
99
100 if viewModel.isLoadingMore {
101 HStack {
102 Spacer()
103 ProgressView()
104 Spacer()
105 }
106 .listRowSeparator(.hidden)
107 }
108 }
109 .listStyle(.plain)
110 .overlay {
111 if viewModel.isLoading, viewModel.tickets.isEmpty {
112 SRHTLoadingStateView(message: "Loading tickets…")
113 } else if let error = viewModel.error, viewModel.tickets.isEmpty {
114 SRHTErrorStateView(
115 title: "Couldn't Load Tickets",
116 message: error,
117 retryAction: { await viewModel.loadTickets() }
118 )
119 } else if viewModel.filteredTickets.isEmpty, viewModel.error == nil {
120 ContentUnavailableView(
121 "No Tickets",
122 systemImage: "ticket",
123 description: Text("No \(viewModel.filter.rawValue.lowercased()) tickets found.")
124 )
125 }
126 }
127 .connectivityOverlay(hasContent: !viewModel.filteredTickets.isEmpty) {
128 await viewModel.loadTickets()
129 }
130 .srhtErrorBanner(error: $vm.error)
131 .refreshable {
132 await viewModel.loadTickets()
133 }
134 .navigationDestination(for: TicketSummary.self) { ticket in
135 TicketDetailView(ownerUsername: ownerUsername, trackerName: trackerName, trackerId: trackerId, trackerRid: trackerRid, ticketId: ticket.id)
136 }
137 }
138}
139
140private struct CreateTicketSheet: View {
141 let viewModel: TicketListViewModel
142 let onCreated: (TicketSummary) -> Void
143
144 @Environment(\.dismiss) private var dismiss
145 @State private var subject = ""
146 @State private var descriptionText = ""
147
148 var body: some View {
149 NavigationStack {
150 Form {
151 Section("Ticket Details") {
152 TextField("Title", text: $subject)
153 TextField("Description (optional)", text: $descriptionText, axis: .vertical)
154 .lineLimit(6...12)
155 }
156 }
157 .navigationTitle("New Ticket")
158 .navigationBarTitleDisplayMode(.inline)
159 .toolbar {
160 ToolbarItem(placement: .cancellationAction) {
161 Button("Cancel") { dismiss() }
162 }
163 ToolbarItem(placement: .confirmationAction) {
164 Button {
165 Task {
166 if let ticket = await viewModel.createTicket(subject: subject, body: descriptionText) {
167 onCreated(ticket)
168 }
169 }
170 } label: {
171 if viewModel.isCreatingTicket {
172 ProgressView()
173 .controlSize(.small)
174 } else {
175 Text("Create Ticket")
176 }
177 }
178 .disabled(subject.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTicket)
179 }
180 }
181 }
182 }
183}
184
185// MARK: - Ticket Row
186
187private struct TicketRowView: View {
188 let ticket: TicketSummary
189
190 var body: some View {
191 VStack(alignment: .leading, spacing: 6) {
192 HStack(alignment: .top) {
193 TicketStatusIcon(status: ticket.status)
194 .frame(width: 20)
195
196 VStack(alignment: .leading, spacing: 2) {
197 Text("#\(ticket.id)")
198 .font(.caption.monospaced())
199 .foregroundStyle(.secondary)
200 + Text(" ")
201 + Text(ticket.title)
202 .font(.subheadline)
203 }
204
205 Spacer()
206 }
207
208 HStack(spacing: 8) {
209 Text(ticket.submitter.canonicalName)
210 .font(.caption)
211 .foregroundStyle(.secondary)
212
213 Spacer()
214
215 Text(ticket.created.relativeDescription)
216 .font(.caption)
217 .foregroundStyle(.tertiary)
218 }
219
220 if !ticket.labels.isEmpty {
221 FlowLayout(spacing: 4) {
222 ForEach(ticket.labels) { label in
223 LabelPill(label: label)
224 }
225 }
226 }
227
228 if !ticket.assignees.isEmpty {
229 HStack(spacing: 4) {
230 Image(systemName: "person.fill")
231 .font(.caption2)
232 .foregroundStyle(.secondary)
233 Text(ticket.assignees.map(\.canonicalName).joined(separator: ", "))
234 .font(.caption)
235 .foregroundStyle(.secondary)
236 .lineLimit(1)
237 }
238 }
239 }
240 .padding(.vertical, 2)
241 }
242}
243
244// MARK: - Ticket Status Icon
245
246struct TicketStatusIcon: View {
247 let status: TicketStatus
248
249 var body: some View {
250 Image(systemName: iconName)
251 .foregroundStyle(color)
252 }
253
254 private var iconName: String {
255 switch status {
256 case .reported: "circle"
257 case .confirmed: "circle.inset.filled"
258 case .inProgress: "arrow.trianglehead.2.clockwise.rotate.90"
259 case .pending: "clock.fill"
260 case .resolved: "checkmark.circle.fill"
261 }
262 }
263
264 private var color: Color {
265 switch status {
266 case .reported: .gray
267 case .confirmed: .blue
268 case .inProgress: .yellow
269 case .pending: .orange
270 case .resolved: .green
271 }
272 }
273}
274
275// MARK: - Label Pill
276
277struct LabelPill: View {
278 let label: TicketLabel
279
280 var body: some View {
281 Text(label.name)
282 .font(.caption2.weight(.medium))
283 .padding(.horizontal, 6)
284 .padding(.vertical, 2)
285 .background(backgroundColor)
286 .foregroundStyle(foregroundColor)
287 .clipShape(Capsule())
288 }
289
290 private var backgroundColor: Color {
291 Color(hex: label.backgroundColor) ?? .gray.opacity(0.2)
292 }
293
294 private var foregroundColor: Color {
295 Color(hex: label.foregroundColor) ?? .primary
296 }
297}
298
299// MARK: - Color from hex string
300
301extension Color {
302 init?(hex: String) {
303 var hexString = hex.trimmingCharacters(in: .whitespacesAndNewlines)
304 if hexString.hasPrefix("#") {
305 hexString.removeFirst()
306 }
307
308 guard hexString.count == 6,
309 let hexNumber = UInt64(hexString, radix: 16) else {
310 return nil
311 }
312
313 let r = Double((hexNumber & 0xFF0000) >> 16) / 255
314 let g = Double((hexNumber & 0x00FF00) >> 8) / 255
315 let b = Double(hexNumber & 0x0000FF) / 255
316
317 self.init(red: r, green: g, blue: b)
318 }
319
320 /// Returns a `#rrggbb` hex string for this color.
321 var hexString: String {
322 let resolved = resolve(in: .init())
323 let r = Int(max(0, min(1, resolved.red)) * 255)
324 let g = Int(max(0, min(1, resolved.green)) * 255)
325 let b = Int(max(0, min(1, resolved.blue)) * 255)
326 return String(format: "#%02x%02x%02x", r, g, b)
327 }
328}
329
330// MARK: - Flow Layout (for label pills)
331
332struct FlowLayout: Layout {
333 var spacing: CGFloat = 4
334
335 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
336 let result = layoutSubviews(proposal: proposal, subviews: subviews)
337 return result.size
338 }
339
340 func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
341 let result = layoutSubviews(proposal: proposal, subviews: subviews)
342 for (index, position) in result.positions.enumerated() {
343 subviews[index].place(
344 at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
345 proposal: ProposedViewSize(result.sizes[index])
346 )
347 }
348 }
349
350 private struct LayoutResult {
351 var positions: [CGPoint]
352 var sizes: [CGSize]
353 var size: CGSize
354 }
355
356 private func layoutSubviews(proposal: ProposedViewSize, subviews: Subviews) -> LayoutResult {
357 let maxWidth = proposal.width ?? .infinity
358 var positions: [CGPoint] = []
359 var sizes: [CGSize] = []
360 var currentX: CGFloat = 0
361 var currentY: CGFloat = 0
362 var lineHeight: CGFloat = 0
363 var totalHeight: CGFloat = 0
364 var totalWidth: CGFloat = 0
365
366 for subview in subviews {
367 let size = subview.sizeThatFits(.unspecified)
368 sizes.append(size)
369
370 if currentX + size.width > maxWidth, currentX > 0 {
371 currentX = 0
372 currentY += lineHeight + spacing
373 lineHeight = 0
374 }
375
376 positions.append(CGPoint(x: currentX, y: currentY))
377 lineHeight = max(lineHeight, size.height)
378 currentX += size.width + spacing
379 totalWidth = max(totalWidth, currentX - spacing)
380 totalHeight = currentY + lineHeight
381 }
382
383 return LayoutResult(
384 positions: positions,
385 sizes: sizes,
386 size: CGSize(width: totalWidth, height: totalHeight)
387 )
388 }
389}
Hutch/Views/Tickets/TicketListViewModel.swift added +215
@@ -0,0 +1,215 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackerTicketsResponse: Decodable, Sendable {
6 let user: UserTrackerWrapper
7}
8
9private struct UserTrackerWrapper: Decodable, Sendable {
10 let tracker: TrackerTicketsWrapper
11}
12
13private struct TrackerTicketsWrapper: Decodable, Sendable {
14 let tickets: TicketsPage
15}
16
17private struct TicketsPage: Decodable, Sendable {
18 let results: [TicketSummary]
19 let cursor: String?
20}
21
22// MARK: - Filter
23
24enum TicketFilter: String, CaseIterable, Sendable {
25 case open = "Open"
26 case resolved = "Resolved"
27 case all = "All"
28}
29
30// MARK: - View Model
31
32@Observable
33@MainActor
34final class TicketListViewModel {
35 let ownerUsername: String
36 let trackerName: String
37 let trackerId: Int
38
39 private(set) var tickets: [TicketSummary] = []
40 private(set) var isLoading = false
41 private(set) var isLoadingMore = false
42 private(set) var isCreatingTicket = false
43 var error: String?
44 var filter: TicketFilter = .open
45
46 private var cursor: String?
47 private var hasMore = true
48 private let client: SRHTClient
49
50 init(ownerUsername: String, trackerName: String, trackerId: Int, client: SRHTClient) {
51 self.ownerUsername = ownerUsername
52 self.trackerName = trackerName
53 self.trackerId = trackerId
54 self.client = client
55 }
56
57 // MARK: - Query
58
59 private static let query = """
60 query tickets($owner: String!, $tracker: String!, $cursor: Cursor) {
61 user(username: $owner) {
62 tracker(name: $tracker) {
63 tickets(cursor: $cursor) {
64 results {
65 id
66 title: subject
67 status
68 resolution
69 created
70 submitter { canonicalName }
71 labels { id name backgroundColor foregroundColor }
72 assignees { canonicalName }
73 }
74 cursor
75 }
76 }
77 }
78 }
79 """
80
81 private static let submitTicketMutation = """
82 mutation submitTicket($trackerId: Int!, $input: SubmitTicketInput!) {
83 submitTicket(trackerId: $trackerId, input: $input) {
84 id
85 title: subject
86 status
87 resolution
88 created
89 submitter { canonicalName }
90 labels { id name backgroundColor foregroundColor }
91 assignees { canonicalName }
92 }
93 }
94 """
95
96 // MARK: - Computed
97
98 /// Tickets filtered by the selected status filter.
99 var filteredTickets: [TicketSummary] {
100 switch filter {
101 case .open:
102 tickets.filter { $0.status.isOpen }
103 case .resolved:
104 tickets.filter { !$0.status.isOpen }
105 case .all:
106 tickets
107 }
108 }
109
110 // MARK: - Public API
111
112 func loadTickets() async {
113 isLoading = true
114 error = nil
115 cursor = nil
116 hasMore = true
117
118 do {
119 let page = try await fetchPage(cursor: nil)
120 tickets = page.results
121 cursor = page.cursor
122 hasMore = page.cursor != nil
123 } catch {
124 self.error = error.localizedDescription
125 }
126
127 isLoading = false
128 }
129
130 func loadMoreIfNeeded(currentItem: TicketSummary) async {
131 guard let last = tickets.last,
132 last.id == currentItem.id,
133 hasMore,
134 !isLoadingMore else {
135 return
136 }
137
138 isLoadingMore = true
139
140 do {
141 let page = try await fetchPage(cursor: cursor)
142 tickets.append(contentsOf: page.results)
143 cursor = page.cursor
144 hasMore = page.cursor != nil
145 } catch {
146 self.error = error.localizedDescription
147 }
148
149 isLoadingMore = false
150 }
151
152 func createTicket(subject: String, body: String) async -> TicketSummary? {
153 guard !isCreatingTicket else { return nil }
154
155 let trimmedSubject = subject.trimmingCharacters(in: .whitespacesAndNewlines)
156 guard !trimmedSubject.isEmpty else {
157 error = "Enter a ticket title."
158 return nil
159 }
160
161 isCreatingTicket = true
162 error = nil
163 defer { isCreatingTicket = false }
164
165 var input: [String: any Sendable] = [
166 "subject": trimmedSubject
167 ]
168 let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines)
169 if !trimmedBody.isEmpty {
170 input["body"] = trimmedBody
171 }
172 let variables: [String: any Sendable] = [
173 "trackerId": trackerId,
174 "input": input
175 ]
176
177 do {
178 let result = try await client.execute(
179 service: .todo,
180 query: Self.submitTicketMutation,
181 variables: variables,
182 responseType: SubmitTicketResponse.self
183 )
184 let ticket = result.submitTicket
185 tickets.insert(ticket, at: 0)
186 return ticket
187 } catch {
188 self.error = "Couldn’t create the ticket. \(error.localizedDescription)"
189 return nil
190 }
191 }
192
193 // MARK: - Private
194
195 private func fetchPage(cursor: String?) async throws -> TicketsPage {
196 var variables: [String: any Sendable] = [
197 "owner": ownerUsername,
198 "tracker": trackerName
199 ]
200 if let cursor {
201 variables["cursor"] = cursor
202 }
203 let result = try await client.execute(
204 service: .todo,
205 query: Self.query,
206 variables: variables,
207 responseType: TrackerTicketsResponse.self
208 )
209 return result.user.tracker.tickets
210 }
211
212 private struct SubmitTicketResponse: Decodable, Sendable {
213 let submitTicket: TicketSummary
214 }
215}
Hutch/Views/Tickets/TrackerListView.swift added +214
@@ -0,0 +1,214 @@
1import SwiftUI
2
3struct TrackerListView: View {
4 @Environment(AppState.self) private var appState
5 @State private var viewModel: TrackerListViewModel?
6 @State private var showCreateTrackerSheet = false
7 @State private var createdTracker: TrackerSummary?
8
9 var body: some View {
10 Group {
11 if let viewModel {
12 listContent(viewModel)
13 } else {
14 SRHTLoadingStateView(message: "Loading trackers…")
15 }
16 }
17 .navigationTitle("Trackers")
18 .toolbar {
19 if viewModel != nil {
20 ToolbarItem(placement: .topBarTrailing) {
21 Button {
22 showCreateTrackerSheet = true
23 } label: {
24 Image(systemName: "plus")
25 }
26 }
27 }
28 }
29 .sheet(isPresented: $showCreateTrackerSheet) {
30 if let viewModel {
31 CreateTrackerSheet(viewModel: viewModel) { tracker in
32 showCreateTrackerSheet = false
33 createdTracker = tracker
34 }
35 }
36 }
37 .navigationDestination(isPresented: Binding(
38 get: { createdTracker != nil },
39 set: { isPresented in
40 if !isPresented {
41 createdTracker = nil
42 }
43 }
44 )) {
45 if let createdTracker {
46 TicketListView(
47 ownerUsername: String(createdTracker.owner.canonicalName.dropFirst()),
48 trackerName: createdTracker.name,
49 trackerId: createdTracker.id,
50 trackerRid: createdTracker.rid
51 )
52 }
53 }
54 .task {
55 if viewModel == nil {
56 let vm = TrackerListViewModel(client: appState.client)
57 viewModel = vm
58 await vm.loadTrackers()
59 }
60 }
61 }
62
63 @ViewBuilder
64 private func listContent(_ viewModel: TrackerListViewModel) -> some View {
65 @Bindable var vm = viewModel
66
67 List {
68 ForEach(viewModel.trackers) { tracker in
69 NavigationLink(value: tracker) {
70 TrackerRowView(tracker: tracker)
71 }
72 .task {
73 await viewModel.loadMoreIfNeeded(currentItem: tracker)
74 }
75 }
76
77 if viewModel.isLoadingMore {
78 HStack {
79 Spacer()
80 ProgressView()
81 Spacer()
82 }
83 .listRowSeparator(.hidden)
84 }
85 }
86 .listStyle(.plain)
87 .overlay {
88 if viewModel.isLoading, viewModel.trackers.isEmpty {
89 SRHTLoadingStateView(message: "Loading trackers…")
90 } else if let error = viewModel.error, viewModel.trackers.isEmpty {
91 SRHTErrorStateView(
92 title: "Couldn't Load Trackers",
93 message: error,
94 retryAction: { await viewModel.loadTrackers() }
95 )
96 } else if viewModel.trackers.isEmpty, viewModel.error == nil {
97 ContentUnavailableView(
98 "No Trackers",
99 systemImage: "checklist",
100 description: Text("Your bug trackers will appear here.")
101 )
102 }
103 }
104 .connectivityOverlay(hasContent: !viewModel.trackers.isEmpty) {
105 await viewModel.loadTrackers()
106 }
107 .srhtErrorBanner(error: $vm.error)
108 .refreshable {
109 await viewModel.loadTrackers()
110 }
111 .navigationDestination(for: TrackerSummary.self) { tracker in
112 TicketListView(
113 ownerUsername: String(tracker.owner.canonicalName.dropFirst()),
114 trackerName: tracker.name,
115 trackerId: tracker.id,
116 trackerRid: tracker.rid
117 )
118 }
119 }
120}
121
122private struct CreateTrackerSheet: View {
123 let viewModel: TrackerListViewModel
124 let onCreated: (TrackerSummary) -> Void
125
126 @Environment(\.dismiss) private var dismiss
127 @State private var name = ""
128 @State private var description = ""
129 @State private var visibility: Visibility = .public
130
131 var body: some View {
132 NavigationStack {
133 Form {
134 Section("Tracker Details") {
135 TextField("Tracker name", text: $name)
136 .textInputAutocapitalization(.never)
137 .autocorrectionDisabled()
138 TextField("Short description (optional)", text: $description, axis: .vertical)
139 .lineLimit(2...4)
140 Picker("Visibility", selection: $visibility) {
141 Text("Public").tag(Visibility.public)
142 Text("Unlisted").tag(Visibility.unlisted)
143 Text("Private").tag(Visibility.private)
144 }
145 }
146 }
147 .navigationTitle("New Tracker")
148 .navigationBarTitleDisplayMode(.inline)
149 .toolbar {
150 ToolbarItem(placement: .cancellationAction) {
151 Button("Cancel") { dismiss() }
152 }
153 ToolbarItem(placement: .confirmationAction) {
154 Button {
155 Task {
156 if let tracker = await viewModel.createTracker(
157 name: name,
158 description: description,
159 visibility: visibility
160 ) {
161 onCreated(tracker)
162 }
163 }
164 } label: {
165 if viewModel.isCreatingTracker {
166 ProgressView()
167 .controlSize(.small)
168 } else {
169 Text("Create Tracker")
170 }
171 }
172 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isCreatingTracker)
173 }
174 }
175 }
176 }
177}
178
179// MARK: - Tracker Row
180
181private struct TrackerRowView: View {
182 let tracker: TrackerSummary
183
184 var body: some View {
185 VStack(alignment: .leading, spacing: 4) {
186 HStack {
187 Text(tracker.name)
188 .font(.subheadline.weight(.medium))
189
190 Spacer()
191
192 VisibilityBadge(visibility: tracker.visibility)
193 }
194
195 if let owner = tracker.owner.canonicalName.split(separator: "~").last {
196 Text("~\(owner)")
197 .font(.caption)
198 .foregroundStyle(.secondary)
199 }
200
201 if let description = tracker.description, !description.isEmpty {
202 Text(description)
203 .font(.caption)
204 .foregroundStyle(.secondary)
205 .lineLimit(2)
206 }
207
208 Text(tracker.updated.relativeDescription)
209 .font(.caption2)
210 .foregroundStyle(.tertiary)
211 }
212 .padding(.vertical, 2)
213 }
214}
Hutch/Views/Tickets/TrackerListViewModel.swift added +166
@@ -0,0 +1,166 @@
1import Foundation
2
3// MARK: - Response types (file-private to avoid @MainActor Decodable issues)
4
5private struct TrackersResponse: Decodable, Sendable {
6 let trackers: TrackersPage
7}
8
9private struct TrackersPage: Decodable, Sendable {
10 let results: [TrackerSummary]
11 let cursor: String?
12}
13
14// MARK: - View Model
15
16@Observable
17@MainActor
18final class TrackerListViewModel {
19
20 private(set) var trackers: [TrackerSummary] = []
21 private(set) var isLoading = false
22 private(set) var isLoadingMore = false
23 private(set) var isCreatingTracker = false
24 var error: String?
25
26 private var cursor: String?
27 private var hasMore = true
28 private let client: SRHTClient
29
30 init(client: SRHTClient) {
31 self.client = client
32 }
33
34 // MARK: - Query
35
36 private static let query = """
37 query trackers($cursor: Cursor) {
38 trackers(cursor: $cursor) {
39 results {
40 id
41 rid
42 name
43 description
44 visibility
45 updated
46 owner { canonicalName }
47 }
48 cursor
49 }
50 }
51 """
52
53 private static let createTrackerMutation = """
54 mutation createTracker($name: String!, $visibility: Visibility!, $description: String) {
55 createTracker(name: $name, visibility: $visibility, description: $description) {
56 id
57 rid
58 name
59 description
60 visibility
61 updated
62 owner { canonicalName }
63 }
64 }
65 """
66
67 // MARK: - Public API
68
69 func loadTrackers() async {
70 isLoading = true
71 error = nil
72 cursor = nil
73 hasMore = true
74
75 do {
76 let page = try await fetchPage(cursor: nil)
77 trackers = page.results
78 cursor = page.cursor
79 hasMore = page.cursor != nil
80 } catch {
81 self.error = error.localizedDescription
82 }
83
84 isLoading = false
85 }
86
87 func loadMoreIfNeeded(currentItem: TrackerSummary) async {
88 guard let last = trackers.last,
89 last.id == currentItem.id,
90 hasMore,
91 !isLoadingMore else {
92 return
93 }
94
95 isLoadingMore = true
96
97 do {
98 let page = try await fetchPage(cursor: cursor)
99 trackers.append(contentsOf: page.results)
100 cursor = page.cursor
101 hasMore = page.cursor != nil
102 } catch {
103 self.error = error.localizedDescription
104 }
105
106 isLoadingMore = false
107 }
108
109 func createTracker(name: String, description: String, visibility: Visibility) async -> TrackerSummary? {
110 guard !isCreatingTracker else { return nil }
111
112 let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
113 guard !trimmedName.isEmpty else {
114 error = "Enter a tracker name."
115 return nil
116 }
117
118 isCreatingTracker = true
119 error = nil
120 defer { isCreatingTracker = false }
121
122 var variables: [String: any Sendable] = [
123 "name": trimmedName,
124 "visibility": visibility.rawValue
125 ]
126 let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
127 if !trimmedDescription.isEmpty {
128 variables["description"] = trimmedDescription
129 }
130
131 do {
132 let result = try await client.execute(
133 service: .todo,
134 query: Self.createTrackerMutation,
135 variables: variables,
136 responseType: CreateTrackerResponse.self
137 )
138 let tracker = result.createTracker
139 trackers.insert(tracker, at: 0)
140 return tracker
141 } catch {
142 self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
143 return nil
144 }
145 }
146
147 // MARK: - Private
148
149 private func fetchPage(cursor: String?) async throws -> TrackersPage {
150 var variables: [String: any Sendable] = [:]
151 if let cursor {
152 variables["cursor"] = cursor
153 }
154 let result = try await client.execute(
155 service: .todo,
156 query: Self.query,
157 variables: variables.isEmpty ? nil : variables,
158 responseType: TrackersResponse.self
159 )
160 return result.trackers
161 }
162
163 private struct CreateTrackerResponse: Decodable, Sendable {
164 let createTracker: TrackerSummary
165 }
166}
README.md added +102
@@ -0,0 +1,102 @@
1# Hutch
2
3<div style="display: flex; align-items: center; border: 1px solid #ccc; border-radius: 10px; padding: 10px; width: 300px; background-color: #f9f9f9;">
4 <img src="https://img.cleberg.net/apps/hutch/logo/hutch_light.png" alt="Hutch App Logo" style="border-radius: 50%; width: 60px; height: 60px; margin-right: 10px;">
5 <div>
6 <h2 style="margin: 0; font-size: 20px; color: #333;">Hutch</h2>
7 <h3 style="margin: 0; font-size: 14px; color: #777;">An iOS client for Sourcehut</h3>
8 </div>
9</div>
10
11
12![Hutch App Logo]()
13
14## Table of Contents
15
16- [Introduction](#introduction)
17<!-- - [Features](#features)
18- [Screenshots](#screenshots) -->
19- [Installation](#installation)
20- [Usage](#usage)
21- [Contributing](#contributing)
22- [License](#license)
23- [Contact](#contact)
24
25## Introduction
26
27**Hutch** is a native Swift client on iOS for [Sourcehut](https://sr.ht).
28
29<!-- TODO
30
31## Features
32
33- **Seamless Integration**: Effortlessly connect to your sr.ht account.
34- **Repository Access**: View and manage your repositories in one convenient
35 location.
36- **Ticket Management**: Create and track tickets associated with your projects.
37- **Profile Management**: Update your account description, keys, and
38 preferences.
39
40For a complete list of features, visit [here](http://example.com/features).
41
42## Roadmap
43
44- **Build Notifications**: Get real-time updates on your builds and status
45 changes.
46- **Dashboard Access**: View and manage your projects in one convenient
47 location.
48
49To see future planned features and updates, check out the project roadmap
50[here](http://example.com/roadmap).
51
52## Screenshots
53
54Here are some screenshots of the app in action:
55
56![Screenshot 1](https://img.cleberg.net/apps/hutch/screenshots/repositories.png)
57![Screenshot 2](https://img.cleberg.net/apps/hutch/screenshots/builds.png)
58![Screenshot 3](https://img.cleberg.net/apps/hutch/screenshots/tickets.png)
59![Screenshot 4](https://img.cleberg.net/apps/hutch/screenshots/settings.png)
60
61-->
62
63## Installation
64
65To get started with Hutch, follow these steps:
66
671. Clone the repository: `git clone
68 https://github.com/ccleberg/Hutch.git`
692. Open the project in Xcode.
703. Run the app on your device or simulator.
71
72## Usage
73
74Provide instructions on how to use the app. You might include:
75
76- **Step 1**: To login, use a PAT in Sourcehut's portal:
77 https://meta.sr.ht/oauth2/personal-token
78- **Step 2**: Navigate the app the same way you'd navigate Sourcehut.
79- **Step 3**: If something doesn't work as expected, send us an email!
80
81## Contributing
82
83Contributions are welcome! Please follow these steps:
84
851. Fork the repository.
862. Create a new branch for your feature (`git checkout -b feature-xyz`).
873. Make your changes.
884. Commit your changes and push to the branch.
895. Open a pull request.
90
91## License
92
93This project is licensed under the GPL 3.0 or later License. See the
94[LICENSE](LICENSE) file for more information.
95
96## Contact
97
98For questions or feedback, please reach out to [hello@cleberg.net].
99
100---
101
102Thank you for checking out **Hutch**! We hope you find it useful and enjoyable.
TODO.md added +68
@@ -0,0 +1,68 @@
1# Hutch TODO
2
3## Claude Code, Codex, & Mistral Prompting Notes
4
5- **Always start a new session with the project context block** (see
6 hutch-ios-prompts.md prompt 0) so the agent knows the API conventions.
7- **Keep prompts short and specific.** Long prompts waste context. One bug or
8 feature per prompt. Paste console output and error messages directly instead
9 of describing them.
10- **When a fix fails twice, ask the agent to print raw state first** (raw API
11 response, current file contents, exact variable values) before attempting
12 another fix. Blind retries consume context without progress.
13- **Never paste full file contents into the prompt.** Ask the agent to read
14 the file itself using its file tools.
15- **Batch only tightly related changes.** Unrelated changes in one prompt
16 increase the chance of partial failure and wasted context on rollback.
17
18## Features
19
20- **Exact repository lookup**: Since public repository discovery/search is not
21 exposed in the public sr.ht API, add a manual `~owner/repo` lookup flow that
22 can open git.sr.ht or hg.sr.ht repositories directly.
23
24- **paste.sr.ht tab**: Paste support. Endpoint:
25 https://paste.sr.ht/graphql. Show the authenticated user's pastes, allow
26 viewing individual pastes, and support creating new pastes. Required scope:
27 PASTES:RO for browsing, PASTES:RW for creation/editing.
28
29- **lists.sr.ht tab**: Mailing list support. Endpoint:
30 https://lists.sr.ht/graphql. Show the authenticated user's mailing lists,
31 allow browsing email threads, and display individual emails as plain text.
32 Required scope: LISTS:RO.
33
34- **pages.sr.ht tab**: Site management support. Endpoint:
35 https://pages.sr.ht/graphql. Show the authenticated user's published sites,
36 site metadata, publishing status, and access control where supported. This is
37 for managing Pages sites, not browsing public project discovery.
38
39- **Donation page / IAP**: Add a donation/support page once the App Store /
40 StoreKit setup is ready.
41
42## Out of Scope
43
44- **Universal links** (e.g. tapping a git.sr.ht URL in Safari opens Hutch):
45 Requires Sourcehut to host an apple-app-site-association file on their
46 servers. This is outside our control. The hutch:// custom URL scheme works as
47 a fallback for links we generate ourselves.
48
49- **Push notifications for builds and tickets**: The sr.ht API only supports
50 server-side webhooks (HTTP POST to a URL you control). Delivering push
51 notifications to iOS would require a backend relay server to receive webhooks
52 and forward them via APNs. Out of scope for a personal app with no backend.
53
54- **Contribution activity / GitHub-style heatmap**: No aggregate contributions
55 endpoint exists in the sr.ht GraphQL API. Computing this would require
56 paginating every repository's full commit log and filtering by author --
57 extremely slow and not practical.
58
59- **Explore / search (hub.sr.ht)**: The public hub.sr.ht GraphQL API does not
60 expose repository discovery/search or a projects feed comparable to
61 https://sr.ht/projects.
62
63- **Pronouns on profile**: Not exposed in the meta.sr.ht GraphQL schema. The
64 field exists in the web UI but is not available to external API clients.
65
66- **Revoke personal access tokens**: The revokePersonalAccessToken mutation is
67 marked @internal in the meta.sr.ht schema and is not accessible to external
68 clients.