summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/test.yml12
-rw-r--r--Hutch.xcodeproj/project.pbxproj40
-rw-r--r--Hutch/App/HutchIntents.swift78
-rw-r--r--Hutch/Extensions/Date+Relative.swift2
-rw-r--r--Hutch/Extensions/DateFormatter+SRHT.swift2
-rw-r--r--Hutch/Extensions/Foundation+Sendable.swift7
-rw-r--r--Hutch/Extensions/SRHTWebURL.swift2
-rw-r--r--Hutch/Models/AccountEntry.swift2
-rw-r--r--Hutch/Models/ArtifactInfo.swift6
-rw-r--r--Hutch/Models/Builds.swift18
-rw-r--r--Hutch/Models/CommitDetail.swift16
-rw-r--r--Hutch/Models/CommitSummary.swift4
-rw-r--r--Hutch/Models/ContributionCalendar.swift28
-rw-r--r--Hutch/Models/Git.swift40
-rw-r--r--Hutch/Models/Inbox.swift20
-rw-r--r--Hutch/Models/Meta.swift16
-rw-r--r--Hutch/Models/Paste.swift6
-rw-r--r--Hutch/Models/Patchset.swift12
-rw-r--r--Hutch/Models/Project.swift24
-rw-r--r--Hutch/Models/RepositoryACL.swift4
-rw-r--r--Hutch/Models/RepositorySummary.swift8
-rw-r--r--Hutch/Models/SystemStatusModels.swift10
-rw-r--r--Hutch/Models/TicketBulkAction.swift6
-rw-r--r--Hutch/Models/Todo.swift30
-rw-r--r--Hutch/Models/User.swift2
-rw-r--r--Hutch/Networking/RepositoryDeployKeyService.swift100
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift2
-rw-r--r--Hutch/Views/More/TipStoreViewModel.swift4
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift143
-rw-r--r--Hutch/Views/Repositories/RepositoryDeployKeysView.swift272
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift16
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift2
-rw-r--r--Hutch/Views/Repositories/SyntaxHighlighter.swift6
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift10
-rw-r--r--HutchTests/APICacheTests.swift1
-rw-r--r--HutchTests/AppStateTests.swift1
-rw-r--r--HutchTests/BuildListViewModelTests.swift1
-rw-r--r--HutchTests/BuildTaskLogSearchTests.swift1
-rw-r--r--HutchTests/BuildTaskTests.swift1
-rw-r--r--HutchTests/BundleUserAgentTests.swift1
-rw-r--r--HutchTests/ContributionCalendarTests.swift1
-rw-r--r--HutchTests/DeepLinkTests.swift1
-rw-r--r--HutchTests/GitObjectTests.swift1
-rw-r--r--HutchTests/HomeViewModelTests.swift1
-rw-r--r--HutchTests/HutchTests.swift1
-rw-r--r--HutchTests/InboxViewModelTests.swift1
-rw-r--r--HutchTests/LookupHistoryStoreTests.swift1
-rw-r--r--HutchTests/MailingListActivityTests.swift1
-rw-r--r--HutchTests/ManPageCatalogTests.swift1
-rw-r--r--HutchTests/PatchsetTests.swift2
-rw-r--r--HutchTests/ProjectFormTests.swift1
-rw-r--r--HutchTests/ProjectPinStoreTests.swift1
-rw-r--r--HutchTests/ProjectTests.swift1
-rw-r--r--HutchTests/ReadmeViewTests.swift5
-rw-r--r--HutchTests/RecentActivityStoreTests.swift1
-rw-r--r--HutchTests/RepositoryACLViewModelTests.swift1
-rw-r--r--HutchTests/RepositoryDeployKeyTests.swift28
-rw-r--r--HutchTests/RepositoryListViewModelTests.swift1
-rw-r--r--HutchTests/RepositorySettingsViewModelTests.swift1
-rw-r--r--HutchTests/SRHTClientTests.swift1
-rw-r--r--HutchTests/SRHTWebURLTests.swift1
-rw-r--r--HutchTests/ScopedSearchHistoryStoreTests.swift1
-rw-r--r--HutchTests/SettingsViewModelTests.swift1
-rw-r--r--HutchTests/SettingsViewTests.swift1
-rw-r--r--HutchTests/SourceHutWebDeepLinkMapperTests.swift1
-rw-r--r--HutchTests/SyntaxHighlighterTests.swift9
-rw-r--r--HutchTests/SystemStatusRepositoryTests.swift9
-rw-r--r--HutchTests/SystemStatusServiceTests.swift1
-rw-r--r--HutchTests/TicketBulkActionTests.swift1
-rw-r--r--HutchTests/TicketDetailViewModelTests.swift1
-rw-r--r--HutchTests/TicketSavedFilterStoreTests.swift1
-rw-r--r--HutchTests/TipStoreViewModelTests.swift1
-rw-r--r--HutchTests/TrackerListViewModelTests.swift1
-rw-r--r--HutchTests/TrackerManagementViewModelTests.swift1
-rw-r--r--HutchWidgetExtension/ContributionGraphWidget.swift4
-rw-r--r--ROADMAP.txt121
-rw-r--r--SCOPE.txt14
77 files changed, 920 insertions, 257 deletions
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c002514..62592d8 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -3,10 +3,12 @@ name: Tests
# builds.sr.ht is the canonical CI for this project, but it has no macOS images
# and will not add them, so xcodebuild cannot run there. This job exists to run
# the test plan on the GitHub mirror; see builds/swift-ci.yml for the rest.
-# Prose only: the root docs and Docs/. Both patterns are single-star, so they
-# match those two levels and nothing deeper — a .md that ever lands inside a
-# source directory still runs the suite. A skip rule that is too broad is how a
-# real change slips through ungated.
+# Prose only: the root docs (*.md, *.txt, README.nfo, LICENSE) and Docs/*.md.
+# The globs are single-star, so they match the repo root and Docs/ and nothing
+# deeper — a .md or .txt that ever lands inside a source directory still runs
+# the suite. A skip rule that is too broad is how a real change slips through
+# ungated. paths-ignore skips the run only when *all* changed files match, so a
+# doc edit alongside code still tests.
#
# pull_request only, by design. GitHub runs the suite against the merge result
# (PR merged into main), so a green PR already validates exactly what lands on
@@ -23,7 +25,7 @@ name: Tests
# docs-only PR waiting forever.
on:
pull_request:
- paths-ignore: ['*.md', 'Docs/*.md']
+ paths-ignore: ['*.md', '*.txt', 'README.nfo', 'LICENSE', 'Docs/*.md']
workflow_dispatch:
# Without this, GITHUB_TOKEN inherits the repository default, which is
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index efff26f..355157b 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -622,7 +622,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -634,7 +634,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -659,7 +659,7 @@
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -680,7 +680,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch";
};
@@ -702,7 +702,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch";
};
@@ -714,7 +714,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -732,7 +732,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -743,7 +743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -761,7 +761,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
@@ -772,7 +772,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -790,7 +790,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
@@ -801,7 +801,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 94;
+ CURRENT_PROJECT_VERSION = 96;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.9.0;
+ MARKETING_VERSION = 3.11.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -819,7 +819,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift
index fe4ebdc..3f4cd83 100644
--- a/Hutch/App/HutchIntents.swift
+++ b/Hutch/App/HutchIntents.swift
@@ -4,9 +4,9 @@ import Foundation
// MARK: - Navigation Intents
struct OpenWorkQueueIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Work Queue"
- static var description = IntentDescription("Opens Hutch to your Work Queue.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Work Queue"
+ static let description = IntentDescription("Opens Hutch to your Work Queue.")
+ static let openAppWhenRun = true
var route: HutchRoute { .workQueue(scope: .all) }
@@ -18,9 +18,9 @@ struct OpenWorkQueueIntent: AppIntent {
}
struct OpenRecentActivityIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Recent Activity"
- static var description = IntentDescription("Opens Hutch to recent activity.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Recent Activity"
+ static let description = IntentDescription("Opens Hutch to recent activity.")
+ static let openAppWhenRun = true
var route: HutchRoute { .recentActivity }
@@ -32,9 +32,9 @@ struct OpenRecentActivityIntent: AppIntent {
}
struct OpenSystemStatusIntent: AppIntent {
- static var title: LocalizedStringResource = "Open System Status"
- static var description = IntentDescription("Opens Hutch to SourceHut system status.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open System Status"
+ static let description = IntentDescription("Opens Hutch to SourceHut system status.")
+ static let openAppWhenRun = true
var route: HutchRoute { .systemStatus }
@@ -46,9 +46,9 @@ struct OpenSystemStatusIntent: AppIntent {
}
struct OpenPinnedResourceIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Pinned Resource"
- static var description = IntentDescription("Opens a pinned Hutch resource.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Pinned Resource"
+ static let description = IntentDescription("Opens a pinned Hutch resource.")
+ static let openAppWhenRun = true
@Parameter(title: "Pinned Resource")
var pinnedResource: PinnedResourceEntity
@@ -63,9 +63,9 @@ struct OpenPinnedResourceIntent: AppIntent {
}
struct OpenProjectDashboardIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Project Dashboard"
- static var description = IntentDescription("Opens a pinned project dashboard in Hutch.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Project Dashboard"
+ static let description = IntentDescription("Opens a pinned project dashboard in Hutch.")
+ static let openAppWhenRun = true
@Parameter(title: "Project")
var project: ProjectEntity
@@ -84,16 +84,16 @@ struct OpenProjectDashboardIntent: AppIntent {
enum HutchShortcutScope: String, AppEnum {
case all
- static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope")
- static var caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Scope")
+ static let caseDisplayRepresentations: [HutchShortcutScope: DisplayRepresentation] = [
.all: "All"
]
}
struct OpenFailedBuildsIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Failed Builds"
- static var description = IntentDescription("Opens Hutch to failed builds.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Failed Builds"
+ static let description = IntentDescription("Opens Hutch to failed builds.")
+ static let openAppWhenRun = true
@Parameter(title: "Scope", default: .all)
var scope: HutchShortcutScope
@@ -108,9 +108,9 @@ struct OpenFailedBuildsIntent: AppIntent {
}
struct OpenAssignedTicketsIntent: AppIntent {
- static var title: LocalizedStringResource = "Open Assigned Tickets"
- static var description = IntentDescription("Opens Hutch to tickets assigned to you.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Open Assigned Tickets"
+ static let description = IntentDescription("Opens Hutch to tickets assigned to you.")
+ static let openAppWhenRun = true
@Parameter(title: "Scope", default: .all)
var scope: HutchShortcutScope
@@ -125,9 +125,9 @@ struct OpenAssignedTicketsIntent: AppIntent {
}
struct SearchHutchIntent: AppIntent {
- static var title: LocalizedStringResource = "Search Hutch"
- static var description = IntentDescription("Opens Hutch lookup with a search query.")
- static var openAppWhenRun = true
+ static let title: LocalizedStringResource = "Search Hutch"
+ static let description = IntentDescription("Opens Hutch lookup with a search query.")
+ static let openAppWhenRun = true
@Parameter(title: "Query")
var query: String
@@ -155,8 +155,8 @@ struct SearchHutchIntent: AppIntent {
// MARK: - App Entities
struct PinnedResourceEntity: AppEntity, Identifiable {
- static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource")
- static var defaultQuery = PinnedResourceQuery()
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Pinned Resource")
+ static let defaultQuery = PinnedResourceQuery()
let id: String
let name: String
@@ -181,8 +181,8 @@ struct PinnedResourceQuery: EntityQuery {
}
struct ProjectEntity: AppEntity, Identifiable {
- static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
- static var defaultQuery = ProjectEntityQuery()
+ static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Project")
+ static let defaultQuery = ProjectEntityQuery()
let id: String
let name: String
@@ -278,8 +278,8 @@ enum HutchIntentEntityStore {
// MARK: - Existing Read-Only Summary Intents
struct CheckSystemStatusIntent: AppIntent {
- static var title: LocalizedStringResource = "Check SourceHut Status"
- static var description = IntentDescription("Returns the current SourceHut system status.")
+ static let title: LocalizedStringResource = "Check SourceHut Status"
+ static let description = IntentDescription("Returns the current SourceHut system status.")
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
@@ -302,8 +302,8 @@ struct CheckSystemStatusIntent: AppIntent {
}
struct CheckBuildsIntent: AppIntent {
- static var title: LocalizedStringResource = "Check Hutch Builds"
- static var description = IntentDescription("Returns a summary of your recent build status.")
+ static let title: LocalizedStringResource = "Check Hutch Builds"
+ static let description = IntentDescription("Returns a summary of your recent build status.")
@MainActor
func perform() async throws -> some IntentResult & ReturnsValue<String> & ProvidesDialog {
@@ -337,7 +337,7 @@ struct CheckBuildsIntent: AppIntent {
// MARK: - Search Type
-extension LookupType: @retroactive AppEnum {
+extension LookupType: AppEnum {
public nonisolated static var typeDisplayRepresentation: TypeDisplayRepresentation {
TypeDisplayRepresentation(name: "Search Type")
}
@@ -357,8 +357,8 @@ extension LookupType: @retroactive AppEnum {
// MARK: - Mutating Intents
struct ClearRecentActivityIntent: AppIntent {
- static var title: LocalizedStringResource = "Clear Recent Activity"
- static var description = IntentDescription("Clears the Recent list on the Hutch Home tab.")
+ static let title: LocalizedStringResource = "Clear Recent Activity"
+ static let description = IntentDescription("Clears the Recent list on the Hutch Home tab.")
@MainActor
func perform() async throws -> some IntentResult & ProvidesDialog {
@@ -368,8 +368,8 @@ struct ClearRecentActivityIntent: AppIntent {
}
struct UnpinResourceIntent: AppIntent {
- static var title: LocalizedStringResource = "Unpin Resource"
- static var description = IntentDescription("Removes a pinned resource from the Hutch Home tab.")
+ static let title: LocalizedStringResource = "Unpin Resource"
+ static let description = IntentDescription("Removes a pinned resource from the Hutch Home tab.")
@Parameter(title: "Pinned Resource")
var pinnedResource: PinnedResourceEntity
diff --git a/Hutch/Extensions/Date+Relative.swift b/Hutch/Extensions/Date+Relative.swift
index f06ba59..b6c28ed 100644
--- a/Hutch/Extensions/Date+Relative.swift
+++ b/Hutch/Extensions/Date+Relative.swift
@@ -1,6 +1,6 @@
import Foundation
-extension Date {
+nonisolated extension Date {
/// A short relative description like "2h ago", "3d ago", or "Jan 5, 2025".
var relativeDescription: String {
let formatter = RelativeDateTimeFormatter()
diff --git a/Hutch/Extensions/DateFormatter+SRHT.swift b/Hutch/Extensions/DateFormatter+SRHT.swift
index c017b11..a06b58a 100644
--- a/Hutch/Extensions/DateFormatter+SRHT.swift
+++ b/Hutch/Extensions/DateFormatter+SRHT.swift
@@ -1,6 +1,6 @@
import Foundation
-extension DateFormatter {
+nonisolated extension DateFormatter {
/// Formatter for the sr.ht `Time` scalar: `%Y-%m-%dT%H:%M:%SZ` (UTC).
static let srht: DateFormatter = {
let formatter = DateFormatter()
diff --git a/Hutch/Extensions/Foundation+Sendable.swift b/Hutch/Extensions/Foundation+Sendable.swift
new file mode 100644
index 0000000..9a12aa6
--- /dev/null
+++ b/Hutch/Extensions/Foundation+Sendable.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+// `UserDefaults` is documented as thread-safe but is not marked `Sendable` by
+// Foundation. Hutch passes it into account sessions and the various per-account
+// stores, all of which need to cross concurrency boundaries. The retroactive
+// unchecked conformance reflects the documented thread-safety.
+extension UserDefaults: @retroactive @unchecked Sendable {}
diff --git a/Hutch/Extensions/SRHTWebURL.swift b/Hutch/Extensions/SRHTWebURL.swift
index fc5a1d5..5715ce4 100644
--- a/Hutch/Extensions/SRHTWebURL.swift
+++ b/Hutch/Extensions/SRHTWebURL.swift
@@ -1,6 +1,6 @@
import Foundation
-enum SRHTWebURL {
+nonisolated enum SRHTWebURL {
static let chat = URL(string: "https://chat.sr.ht")!
static let status = URL(string: "https://status.sr.ht")!
/// RSS/Atom feed for status incidents (HTML snapshot uses `status`).
diff --git a/Hutch/Models/AccountEntry.swift b/Hutch/Models/AccountEntry.swift
index 551cd19..cfc2f62 100644
--- a/Hutch/Models/AccountEntry.swift
+++ b/Hutch/Models/AccountEntry.swift
@@ -1,7 +1,7 @@
import Foundation
/// A stored sr.ht account (token + resolved username).
-struct AccountEntry: Codable, Identifiable, Equatable, Sendable {
+nonisolated struct AccountEntry: Codable, Identifiable, Equatable, Sendable {
let id: String
var username: String
var token: String
diff --git a/Hutch/Models/ArtifactInfo.swift b/Hutch/Models/ArtifactInfo.swift
index dbe9d96..6418ba8 100644
--- a/Hutch/Models/ArtifactInfo.swift
+++ b/Hutch/Models/ArtifactInfo.swift
@@ -1,7 +1,7 @@
import Foundation
/// An artifact with its parent reference name, used in the artifacts tab.
-struct ArtifactInfo: Codable, Sendable, Identifiable {
+nonisolated struct ArtifactInfo: Codable, Sendable, Identifiable {
let id: Int
let filename: String
let checksum: String
@@ -9,13 +9,13 @@ struct ArtifactInfo: Codable, Sendable, Identifiable {
let url: URL
}
-struct ArtifactPage: Codable, Sendable {
+nonisolated struct ArtifactPage: Codable, Sendable {
let results: [ArtifactInfo]
let cursor: String?
}
/// A reference (tag) that has associated artifacts.
-struct ReferenceWithArtifacts: Codable, Sendable, Identifiable {
+nonisolated struct ReferenceWithArtifacts: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let artifacts: [ArtifactInfo]
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index 3926888..48a9c91 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -3,7 +3,7 @@ import Foundation
// MARK: - Enums
/// Status of a build job.
-enum JobStatus: String, Codable, Sendable {
+nonisolated enum JobStatus: String, Codable, Sendable {
case pending = "PENDING"
case queued = "QUEUED"
case running = "RUNNING"
@@ -30,7 +30,7 @@ enum JobStatus: String, Codable, Sendable {
}
/// Status of a single build task within a job.
-enum TaskStatus: String, Codable, Sendable {
+nonisolated enum TaskStatus: String, Codable, Sendable {
case pending = "PENDING"
case running = "RUNNING"
case success = "SUCCESS"
@@ -41,7 +41,7 @@ enum TaskStatus: String, Codable, Sendable {
// MARK: - Build Task
/// A single task within a build job.
-struct BuildTask: Codable, Sendable, Identifiable, Equatable {
+nonisolated struct BuildTask: Codable, Sendable, Identifiable, Equatable {
private(set) var ordinal: Int?
let name: String
let status: TaskStatus
@@ -74,7 +74,7 @@ struct BuildTask: Codable, Sendable, Identifiable, Equatable {
// MARK: - Job Summary (for list view)
/// Lightweight job model matching the fields returned by the jobs list query.
-struct JobSummary: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct JobSummary: Codable, Sendable, Identifiable, Hashable {
let id: Int
let created: Date
let updated: Date
@@ -103,7 +103,7 @@ struct JobSummary: Codable, Sendable, Identifiable, Hashable {
}
/// Minimal task info for the list query.
-struct JobTaskSummary: Codable, Sendable, Hashable {
+nonisolated struct JobTaskSummary: Codable, Sendable, Hashable {
let name: String
let status: TaskStatus
}
@@ -111,7 +111,7 @@ struct JobTaskSummary: Codable, Sendable, Hashable {
// MARK: - Job Detail (for detail view)
/// Full job model with all fields for the detail view.
-struct JobDetail: Codable, Sendable, Equatable {
+nonisolated struct JobDetail: Codable, Sendable, Equatable {
let id: Int
let created: Date
let updated: Date
@@ -128,7 +128,7 @@ struct JobDetail: Codable, Sendable, Equatable {
}
/// A downloadable artifact emitted by a build job.
-struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
+nonisolated struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
let id: Int
let created: Date
let path: String
@@ -145,14 +145,14 @@ struct BuildArtifact: Codable, Sendable, Identifiable, Equatable {
}
/// The log associated with a build job.
-struct BuildLog: Codable, Sendable, Equatable {
+nonisolated struct BuildLog: Codable, Sendable, Equatable {
let fullURL: String
}
// MARK: - Job Group
/// A group of related build jobs.
-struct JobGroup: Codable, Sendable, Identifiable {
+nonisolated struct JobGroup: Codable, Sendable, Identifiable {
let id: Int
let created: Date
let note: String?
diff --git a/Hutch/Models/CommitDetail.swift b/Hutch/Models/CommitDetail.swift
index c95199d..962d2a4 100644
--- a/Hutch/Models/CommitDetail.swift
+++ b/Hutch/Models/CommitDetail.swift
@@ -1,7 +1,7 @@
import Foundation
/// Full commit detail returned by the revparse_single query.
-struct CommitDetail: Codable, Sendable, Identifiable {
+nonisolated struct CommitDetail: Codable, Sendable, Identifiable {
let id: String
let shortId: String
let author: CommitAuthor
@@ -26,39 +26,39 @@ struct CommitDetail: Codable, Sendable, Identifiable {
}
}
-struct CommitTrailer: Codable, Sendable, Identifiable {
+nonisolated struct CommitTrailer: Codable, Sendable, Identifiable {
var id: String { "\(name):\(value)" }
let name: String
let value: String
}
-struct ParentCommit: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct ParentCommit: Codable, Sendable, Identifiable, Hashable {
let id: String
let shortId: String
let author: ParentAuthor
}
-struct ParentAuthor: Codable, Sendable, Hashable {
+nonisolated struct ParentAuthor: Codable, Sendable, Hashable {
let name: String
}
-struct CommitTree: Codable, Sendable {
+nonisolated struct CommitTree: Codable, Sendable {
let entries: CommitTreeEntries
}
-struct CommitTreeEntries: Codable, Sendable {
+nonisolated struct CommitTreeEntries: Codable, Sendable {
let results: [CommitTreeEntry]
let cursor: String?
}
-struct CommitTreeEntry: Codable, Sendable, Identifiable {
+nonisolated struct CommitTreeEntry: Codable, Sendable, Identifiable {
let id: String
let name: String
let mode: Int
let object: CommitTreeObject?
}
-struct CommitTreeObject: Codable, Sendable {
+nonisolated struct CommitTreeObject: Codable, Sendable {
let type: String?
let id: String?
let shortId: String?
diff --git a/Hutch/Models/CommitSummary.swift b/Hutch/Models/CommitSummary.swift
index 88c3e7d..99ed233 100644
--- a/Hutch/Models/CommitSummary.swift
+++ b/Hutch/Models/CommitSummary.swift
@@ -2,7 +2,7 @@ import Foundation
/// Lightweight commit model for list views. Matches the subset of fields
/// returned by the repository log query.
-struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
let id: String
let shortId: String
let author: CommitAuthor
@@ -15,7 +15,7 @@ struct CommitSummary: Codable, Sendable, Identifiable, Hashable {
}
/// A compact author representation used in commit list responses.
-struct CommitAuthor: Codable, Sendable, Hashable {
+nonisolated struct CommitAuthor: Codable, Sendable, Hashable {
let name: String
let email: String?
let time: Date
diff --git a/Hutch/Models/ContributionCalendar.swift b/Hutch/Models/ContributionCalendar.swift
index a429fa2..cf87ca7 100644
--- a/Hutch/Models/ContributionCalendar.swift
+++ b/Hutch/Models/ContributionCalendar.swift
@@ -1,6 +1,6 @@
import Foundation
-struct ContributionCalendarResponse: Decodable, Sendable, Hashable {
+nonisolated struct ContributionCalendarResponse: Decodable, Sendable, Hashable {
let actor: String
let from: Date
let to: Date
@@ -9,7 +9,7 @@ struct ContributionCalendarResponse: Decodable, Sendable, Hashable {
let indexingState: ContributionIndexingState
let days: [ContributionDay]
- enum CodingKeys: String, CodingKey {
+ nonisolated enum CodingKeys: String, CodingKey {
case actor
case from
case to
@@ -59,14 +59,14 @@ struct ContributionCalendarResponse: Decodable, Sendable, Hashable {
}
}
-struct ContributionDay: Decodable, Sendable, Hashable, Identifiable {
+nonisolated struct ContributionDay: Decodable, Sendable, Hashable, Identifiable {
var id: Date { date }
let date: Date
let count: Int
let score: Double
- enum CodingKeys: String, CodingKey {
+ nonisolated enum CodingKeys: String, CodingKey {
case date
case count
case score
@@ -90,7 +90,7 @@ struct ContributionDay: Decodable, Sendable, Hashable, Identifiable {
}
}
-struct ContributionStatsResponse: Decodable, Sendable, Hashable {
+nonisolated struct ContributionStatsResponse: Decodable, Sendable, Hashable {
let actor: String
let from: Date
let to: Date
@@ -103,7 +103,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
let longestStreak: Int
let currentStreak: Int
- enum CodingKeys: String, CodingKey {
+ nonisolated enum CodingKeys: String, CodingKey {
case actor
case from
case to
@@ -117,7 +117,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
case currentStreak = "current_streak"
}
- struct StatsWindow: Sendable, Hashable {
+ nonisolated struct StatsWindow: Sendable, Hashable {
let actor: String
let from: Date
let to: Date
@@ -126,7 +126,7 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
let indexingState: ContributionIndexingState
}
- struct StatsTotals: Sendable, Hashable {
+ nonisolated struct StatsTotals: Sendable, Hashable {
let totalEvents: Int
let totalScore: Double
let activeDays: Int
@@ -181,13 +181,13 @@ struct ContributionStatsResponse: Decodable, Sendable, Hashable {
}
}
-enum ContributionIndexingState: String, Codable, Sendable, Hashable {
+nonisolated enum ContributionIndexingState: String, Codable, Sendable, Hashable {
case pending
case indexed
case error
}
-enum ContributionIntensity: Int, Sendable, CaseIterable {
+nonisolated enum ContributionIntensity: Int, Sendable, CaseIterable {
case empty = 0
case level1 = 1
case level2 = 2
@@ -210,12 +210,12 @@ enum ContributionIntensity: Int, Sendable, CaseIterable {
}
}
-struct ContributionWeek: Sendable, Hashable {
+nonisolated struct ContributionWeek: Sendable, Hashable {
let startDate: Date
let days: [ContributionDay]
}
-enum ContributionCalendarLayout {
+nonisolated enum ContributionCalendarLayout {
static func weekColumns(
from days: [ContributionDay],
calendar: Calendar = .contributionCalendar
@@ -241,7 +241,7 @@ enum ContributionCalendarLayout {
}
}
-enum ContributionDateParser {
+nonisolated enum ContributionDateParser {
static func parse(_ rawValue: String) -> Date? {
let parts = rawValue.split(separator: "-", omittingEmptySubsequences: false)
guard
@@ -348,7 +348,7 @@ enum ContributionDateParser {
}
}
-extension Calendar {
+nonisolated extension Calendar {
static var contributionCalendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.firstWeekday = 1
diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift
index 26a5e2d..ae74f19 100644
--- a/Hutch/Models/Git.swift
+++ b/Hutch/Models/Git.swift
@@ -3,14 +3,14 @@ import Foundation
// MARK: - Enums
/// Repository visibility level.
-enum Visibility: String, Codable, Sendable {
+nonisolated enum Visibility: String, Codable, Sendable {
case publicVisibility = "PUBLIC"
case unlisted = "UNLISTED"
case privateVisibility = "PRIVATE"
}
/// Repository access mode.
-enum AccessMode: String, Codable, Sendable, CaseIterable {
+nonisolated enum AccessMode: String, Codable, Sendable, CaseIterable {
case ro = "RO"
case rw = "RW"
}
@@ -19,14 +19,14 @@ enum AccessMode: String, Codable, Sendable, CaseIterable {
/// The `Entity` GraphQL interface from git.sr.ht. Represents the owner of a
/// resource (typically a user).
-struct Entity: Codable, Sendable, Hashable {
+nonisolated struct Entity: Codable, Sendable, Hashable {
let canonicalName: String
}
// MARK: - Repository
/// A git repository from git.sr.ht.
-struct Repository: Codable, Sendable, Identifiable {
+nonisolated struct Repository: Codable, Sendable, Identifiable {
let id: Int
let created: Date
let updated: Date
@@ -37,7 +37,7 @@ struct Repository: Codable, Sendable, Identifiable {
let accessMode: AccessMode
let owner: Entity
- enum CodingKeys: String, CodingKey {
+ nonisolated enum CodingKeys: String, CodingKey {
case id, created, updated, name, description, visibility, readme
case accessMode = "access"
case owner
@@ -47,7 +47,7 @@ struct Repository: Codable, Sendable, Identifiable {
// MARK: - Signature
/// A Git commit/tag signature (author or committer).
-struct Signature: Codable, Sendable {
+nonisolated struct Signature: Codable, Sendable {
let name: String
let email: String
let time: Date
@@ -56,7 +56,7 @@ struct Signature: Codable, Sendable {
// MARK: - Trailer
/// A Git commit trailer (e.g. "Signed-off-by", "Co-authored-by").
-struct Trailer: Codable, Sendable {
+nonisolated struct Trailer: Codable, Sendable {
let name: String
let value: String
}
@@ -64,7 +64,7 @@ struct Trailer: Codable, Sendable {
// MARK: - Commit
/// A Git commit from git.sr.ht.
-struct Commit: Codable, Sendable, Identifiable {
+nonisolated struct Commit: Codable, Sendable, Identifiable {
let id: String
let shortId: String
let author: Signature
@@ -77,13 +77,13 @@ struct Commit: Codable, Sendable, Identifiable {
// MARK: - Reference
/// A Git reference (branch or tag name).
-struct Reference: Codable, Sendable, Hashable {
+nonisolated struct Reference: Codable, Sendable, Hashable {
let name: String
let target: String?
}
/// A Git reference enriched with the date of its tip commit or tag, for display in the Refs tab.
-struct ReferenceDetail: Sendable, Hashable {
+nonisolated struct ReferenceDetail: Sendable, Hashable {
let name: String
let target: String?
let date: Date?
@@ -92,7 +92,7 @@ struct ReferenceDetail: Sendable, Hashable {
// MARK: - TreeEntry
/// An entry in a Git tree (file or directory).
-struct TreeEntry: Codable, Sendable, Identifiable {
+nonisolated struct TreeEntry: Codable, Sendable, Identifiable {
let id: String
let name: String
let mode: Int?
@@ -104,41 +104,41 @@ struct TreeEntry: Codable, Sendable, Identifiable {
/// Both TextBlob and BinaryBlob have type == "BLOB"; they are
/// distinguished by the presence of the "text" key (TextBlob) vs
/// the "content" key (BinaryBlob).
-enum GitObject: Sendable {
+nonisolated enum GitObject: Sendable {
case tree(GitTree)
case textBlob(GitTextBlob)
case binaryBlob(GitBinaryBlob)
case unknown
}
-struct GitTree: Codable, Sendable {
+nonisolated struct GitTree: Codable, Sendable {
let id: String?
let shortId: String?
let entries: GitTreeEntryPage?
}
-struct GitTextBlob: Codable, Sendable {
+nonisolated struct GitTextBlob: Codable, Sendable {
let id: String?
let shortId: String?
let text: String?
let size: Int?
}
-struct GitBinaryBlob: Codable, Sendable {
+nonisolated struct GitBinaryBlob: Codable, Sendable {
let id: String?
let shortId: String?
let size: Int?
let content: String?
}
-struct GitTreeEntryPage: Codable, Sendable {
+nonisolated struct GitTreeEntryPage: Codable, Sendable {
let results: [TreeEntry]
let cursor: String?
}
// MARK: - GitObject Codable
-extension GitObject: Codable {
+nonisolated extension GitObject: Codable {
private enum CodingKeys: String, CodingKey {
case type, id, shortId, entries, text, size, content
case typename = "__typename"
@@ -211,7 +211,7 @@ extension GitObject: Codable {
}
/// Convenience helpers for checking object type.
-extension GitObject {
+nonisolated extension GitObject {
var isTree: Bool {
if case .tree = self { return true }
return false
@@ -230,7 +230,7 @@ extension GitObject {
// MARK: - Tag
/// An annotated Git tag from git.sr.ht.
-struct Tag: Codable, Sendable, Identifiable {
+nonisolated struct Tag: Codable, Sendable, Identifiable {
let id: String
let shortId: String
let name: String
@@ -241,7 +241,7 @@ struct Tag: Codable, Sendable, Identifiable {
// MARK: - Artifact
/// A release artifact attached to a Git tag.
-struct Artifact: Codable, Sendable, Identifiable {
+nonisolated struct Artifact: Codable, Sendable, Identifiable {
let id: Int
let created: Date
let filename: String
diff --git a/Hutch/Models/Inbox.swift b/Hutch/Models/Inbox.swift
index c8e41bb..dfed719 100644
--- a/Hutch/Models/Inbox.swift
+++ b/Hutch/Models/Inbox.swift
@@ -1,6 +1,6 @@
import Foundation
-struct InboxThreadSummary: Identifiable, Hashable, Sendable {
+nonisolated struct InboxThreadSummary: Identifiable, Hashable, Sendable {
let rootEmailID: Int
let rootMessageID: String
let threadRootEmailIDs: [Int]
@@ -76,7 +76,7 @@ struct InboxThreadSummary: Identifiable, Hashable, Sendable {
}
}
-struct InboxMessage: Identifiable, Hashable, Sendable {
+nonisolated struct InboxMessage: Identifiable, Hashable, Sendable {
let id: Int
let author: Entity
let date: Date
@@ -89,12 +89,12 @@ struct InboxMessage: Identifiable, Hashable, Sendable {
let rawMessageURL: URL?
}
-enum InboxMessageContentBlock: Hashable, Sendable {
+nonisolated enum InboxMessageContentBlock: Hashable, Sendable {
case plainText(String)
case diff(String)
}
-struct InboxThreadDetail: Sendable {
+nonisolated struct InboxThreadDetail: Sendable {
let id: String
let rootEmailID: Int
let rootMessageID: String
@@ -114,7 +114,7 @@ struct InboxThreadDetail: Sendable {
}
}
-extension InboxThreadDetail {
+nonisolated extension InboxThreadDetail {
var replyRecipient: String {
"\(listOwner.canonicalName)/\(listName)@lists.sr.ht"
}
@@ -144,7 +144,7 @@ extension InboxThreadDetail {
}
}
-struct MailComposeDraft: Sendable {
+nonisolated struct MailComposeDraft: Sendable {
let recipients: [String]
let ccRecipients: [String]
let subject: String
@@ -155,20 +155,20 @@ struct MailComposeDraft: Sendable {
}
}
-extension MailComposeDraft: Identifiable {}
+nonisolated extension MailComposeDraft: Identifiable {}
-struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
+nonisolated struct InboxMailingListReference: Decodable, Sendable, Hashable, Identifiable {
let id: Int
let rid: String
let name: String
let owner: Entity
}
-struct InboxPatchPreview: Decodable, Sendable, Hashable {
+nonisolated struct InboxPatchPreview: Decodable, Sendable, Hashable {
let subject: String?
}
-enum InboxReadStateStore {
+nonisolated enum InboxReadStateStore {
private static let key = "InboxThreadLastViewed"
private static let baselineKey = "InboxUnreadBaseline"
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
index f52fc76..e0a09de 100644
--- a/Hutch/Models/Meta.swift
+++ b/Hutch/Models/Meta.swift
@@ -3,7 +3,7 @@ import Foundation
// MARK: - User Profile (full)
/// Extended user profile from meta.sr.ht with all fields from the `me` query.
-struct UserProfile: Codable, Sendable {
+nonisolated struct UserProfile: Codable, Sendable {
let username: String
let canonicalName: String
let email: String
@@ -20,7 +20,7 @@ struct UserProfile: Codable, Sendable {
// MARK: - SSH Key
-struct SSHKey: Codable, Sendable, Identifiable {
+nonisolated struct SSHKey: Codable, Sendable, Identifiable {
let id: Int
let comment: String?
let created: Date
@@ -34,27 +34,27 @@ struct SSHKey: Codable, Sendable, Identifiable {
}
}
-struct SSHKeyPage: Codable, Sendable {
+nonisolated struct SSHKeyPage: Codable, Sendable {
let results: [SSHKey]
let cursor: String?
}
// MARK: - PGP Key
-struct PGPKey: Codable, Sendable, Identifiable {
+nonisolated struct PGPKey: Codable, Sendable, Identifiable {
let id: Int
let fingerprint: String
let created: Date
}
-struct PGPKeyPage: Codable, Sendable {
+nonisolated struct PGPKeyPage: Codable, Sendable {
let results: [PGPKey]
let cursor: String?
}
// MARK: - Subscription
-struct Subscription: Codable, Sendable {
+nonisolated struct Subscription: Codable, Sendable {
let status: String?
let autorenew: Bool?
let interval: String?
@@ -62,7 +62,7 @@ struct Subscription: Codable, Sendable {
// MARK: - Personal Access Token
-struct PersonalAccessToken: Codable, Sendable, Identifiable {
+nonisolated struct PersonalAccessToken: Codable, Sendable, Identifiable {
let id: Int
let issued: Date
let expires: Date?
@@ -72,7 +72,7 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable {
/// One entry in meta.sr.ht's audit log: a security-relevant action on the
/// account, with the address it came from.
-struct AuditLogEntry: Codable, Sendable, Identifiable {
+nonisolated struct AuditLogEntry: Codable, Sendable, Identifiable {
let id: Int
let created: Date
let ipAddress: String
diff --git a/Hutch/Models/Paste.swift b/Hutch/Models/Paste.swift
index b4c8f18..a8bf926 100644
--- a/Hutch/Models/Paste.swift
+++ b/Hutch/Models/Paste.swift
@@ -1,6 +1,6 @@
import Foundation
-struct PasteFile: Codable, Sendable, Hashable, Identifiable {
+nonisolated struct PasteFile: Codable, Sendable, Hashable, Identifiable {
let filename: String?
let hash: String
let contents: URL?
@@ -8,7 +8,7 @@ struct PasteFile: Codable, Sendable, Hashable, Identifiable {
var id: String { hash }
}
-struct Paste: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct Paste: Codable, Sendable, Identifiable, Hashable {
let id: String
let created: Date
let visibility: Visibility
@@ -16,7 +16,7 @@ struct Paste: Codable, Sendable, Identifiable, Hashable {
let user: Entity
}
-struct PasteUploadDraft: Identifiable, Equatable, Sendable {
+nonisolated struct PasteUploadDraft: Identifiable, Equatable, Sendable {
let id: UUID
var filename: String
var contents: String
diff --git a/Hutch/Models/Patchset.swift b/Hutch/Models/Patchset.swift
index 330bbbc..cc10b35 100644
--- a/Hutch/Models/Patchset.swift
+++ b/Hutch/Models/Patchset.swift
@@ -1,7 +1,7 @@
import Foundation
/// Review state of a patchset on lists.sr.ht.
-enum PatchsetStatus: String, Codable, Sendable, CaseIterable {
+nonisolated enum PatchsetStatus: String, Codable, Sendable, CaseIterable {
case unknown = "UNKNOWN"
case proposed = "PROPOSED"
case needsRevision = "NEEDS_REVISION"
@@ -55,7 +55,7 @@ enum PatchsetStatus: String, Codable, Sendable, CaseIterable {
/// A patchset as it appears in a mailing list listing, derived from the thread's
/// root email rather than a dedicated patchsets query — `MailingList` exposes no
/// such field.
-struct PatchsetSummary: Identifiable, Hashable, Sendable {
+nonisolated struct PatchsetSummary: Identifiable, Hashable, Sendable {
let id: Int
let subject: String
let version: Int
@@ -70,7 +70,7 @@ struct PatchsetSummary: Identifiable, Hashable, Sendable {
}
/// One email within a patchset: either the cover letter or a single patch.
-struct PatchsetEmail: Identifiable, Hashable, Sendable {
+nonisolated struct PatchsetEmail: Identifiable, Hashable, Sendable {
let id: Int
let subject: String
let date: Date?
@@ -88,13 +88,13 @@ struct PatchsetEmail: Identifiable, Hashable, Sendable {
}
/// A build or check reported against a patchset.
-struct PatchsetToolResult: Identifiable, Hashable, Sendable {
+nonisolated struct PatchsetToolResult: Identifiable, Hashable, Sendable {
let id: Int
let icon: PatchsetToolIcon
let details: String
}
-enum PatchsetToolIcon: String, Codable, Sendable {
+nonisolated enum PatchsetToolIcon: String, Codable, Sendable {
case pending = "PENDING"
case waiting = "WAITING"
case success = "SUCCESS"
@@ -112,7 +112,7 @@ enum PatchsetToolIcon: String, Codable, Sendable {
}
/// A patchset with its cover letter, patches, and review context.
-struct PatchsetDetail: Sendable {
+nonisolated struct PatchsetDetail: Sendable {
let id: Int
let created: Date
let updated: Date
diff --git a/Hutch/Models/Project.swift b/Hutch/Models/Project.swift
index 8f5aa68..bd1a688 100644
--- a/Hutch/Models/Project.swift
+++ b/Hutch/Models/Project.swift
@@ -1,7 +1,7 @@
import Foundation
-struct Project: Identifiable, Hashable, Sendable {
- struct MailingList: Identifiable, Hashable, Sendable {
+nonisolated struct Project: Identifiable, Hashable, Sendable {
+ nonisolated struct MailingList: Identifiable, Hashable, Sendable {
let id: String
let name: String
let description: String?
@@ -22,8 +22,8 @@ struct Project: Identifiable, Hashable, Sendable {
}
}
- struct SourceRepo: Identifiable, Hashable, Sendable {
- enum RepoType: String, Decodable, Sendable {
+ nonisolated struct SourceRepo: Identifiable, Hashable, Sendable {
+ nonisolated enum RepoType: String, Decodable, Sendable {
case git = "GIT"
case hg = "HG"
@@ -47,7 +47,7 @@ struct Project: Identifiable, Hashable, Sendable {
}
}
- struct Tracker: Identifiable, Hashable, Sendable {
+ nonisolated struct Tracker: Identifiable, Hashable, Sendable {
let id: String
let name: String
let description: String?
@@ -72,7 +72,7 @@ struct Project: Identifiable, Hashable, Sendable {
let isFullyLoaded: Bool
/// Identity and display fields for a project.
- struct Metadata: Sendable, Hashable {
+ nonisolated struct Metadata: Sendable, Hashable {
let id: String
let name: String
let description: String?
@@ -82,7 +82,7 @@ struct Project: Identifiable, Hashable, Sendable {
let updated: Date
}
- struct Resources: Sendable, Hashable {
+ nonisolated struct Resources: Sendable, Hashable {
let mailingLists: [MailingList]
let sources: [SourceRepo]
let trackers: [Tracker]
@@ -171,7 +171,7 @@ struct Project: Identifiable, Hashable, Sendable {
}
}
-extension Project.MailingList {
+nonisolated extension Project.MailingList {
var displayName: String {
Project.normalizedText(name) ?? "Untitled Mailing List"
}
@@ -185,7 +185,7 @@ extension Project.MailingList {
}
}
-extension Project.SourceRepo {
+nonisolated extension Project.SourceRepo {
var displayName: String {
Project.normalizedText(name) ?? "Untitled Repository"
}
@@ -203,7 +203,7 @@ extension Project.SourceRepo {
}
}
-extension Project.Tracker {
+nonisolated extension Project.Tracker {
var displayName: String {
Project.normalizedText(name) ?? "Untitled Tracker"
}
@@ -221,13 +221,13 @@ extension Project.Tracker {
}
}
-extension String {
+nonisolated extension String {
var srhtUsername: String {
hasPrefix("~") ? String(dropFirst()) : self
}
}
-extension Visibility {
+nonisolated extension Visibility {
var displayName: String {
switch self {
case .publicVisibility:
diff --git a/Hutch/Models/RepositoryACL.swift b/Hutch/Models/RepositoryACL.swift
index e5f5eb1..e5d93f6 100644
--- a/Hutch/Models/RepositoryACL.swift
+++ b/Hutch/Models/RepositoryACL.swift
@@ -1,12 +1,12 @@
import Foundation
-struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct RepositoryACLEntry: Codable, Sendable, Identifiable, Hashable {
let id: Int
let mode: AccessMode
let entity: Entity
}
-extension AccessMode {
+nonisolated extension AccessMode {
var shortLabel: String { rawValue }
var displayName: String {
diff --git a/Hutch/Models/RepositorySummary.swift b/Hutch/Models/RepositorySummary.swift
index b41589b..40ec7a4 100644
--- a/Hutch/Models/RepositorySummary.swift
+++ b/Hutch/Models/RepositorySummary.swift
@@ -3,7 +3,7 @@ import Foundation
/// Lightweight repository model matching the fields returned by the
/// repositories list query. Avoids optionalizing all fields on the full
/// `Repository` model.
-struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
let id: Int
/// GraphQL resource identifier used by `repository(rid:)` queries.
let rid: String
@@ -15,13 +15,13 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
let owner: Entity
let head: Reference?
- enum CodingKeys: String, CodingKey {
+ nonisolated enum CodingKeys: String, CodingKey {
case id, rid, service, name, description, visibility, updated, owner
case head = "HEAD"
}
/// Grouped initializer fields (single parameter keeps APIs explicit without exceeding parameter-count limits).
- struct Fields: Sendable, Hashable {
+ nonisolated struct Fields: Sendable, Hashable {
let id: Int
let rid: String
let service: SRHTService
@@ -59,7 +59,7 @@ struct RepositorySummary: Codable, Sendable, Identifiable, Hashable {
}
}
-extension RepositorySummary {
+nonisolated extension RepositorySummary {
var defaultBranchName: String? {
head?.name.replacingOccurrences(of: "refs/heads/", with: "")
}
diff --git a/Hutch/Models/SystemStatusModels.swift b/Hutch/Models/SystemStatusModels.swift
index 9a664b0..ddd659c 100644
--- a/Hutch/Models/SystemStatusModels.swift
+++ b/Hutch/Models/SystemStatusModels.swift
@@ -1,6 +1,6 @@
import Foundation
-enum StatusLevel: String, Codable, Sendable {
+nonisolated enum StatusLevel: String, Codable, Sendable {
case operational
case degraded
case majorOutage
@@ -32,7 +32,7 @@ enum StatusLevel: String, Codable, Sendable {
}
}
-struct StatusServiceState: Identifiable, Hashable, Codable, Sendable {
+nonisolated struct StatusServiceState: Identifiable, Hashable, Codable, Sendable {
let id: String
let name: String
let slug: String?
@@ -40,7 +40,7 @@ struct StatusServiceState: Identifiable, Hashable, Codable, Sendable {
let description: String?
}
-struct StatusIncident: Identifiable, Hashable, Codable, Sendable {
+nonisolated struct StatusIncident: Identifiable, Hashable, Codable, Sendable {
let id: String
let title: String
let summary: String?
@@ -50,7 +50,7 @@ struct StatusIncident: Identifiable, Hashable, Codable, Sendable {
let isActive: Bool?
}
-struct SystemStatusSnapshot: Hashable, Codable, Sendable {
+nonisolated struct SystemStatusSnapshot: Hashable, Codable, Sendable {
let services: [StatusServiceState]
let activeIncidents: [StatusIncident]
let lastUpdated: Date
@@ -78,7 +78,7 @@ struct SystemStatusSnapshot: Hashable, Codable, Sendable {
}
}
-struct SystemStatusPageData: Sendable {
+nonisolated struct SystemStatusPageData: Sendable {
let snapshot: SystemStatusSnapshot
let recentIncidents: [StatusIncident]
}
diff --git a/Hutch/Models/TicketBulkAction.swift b/Hutch/Models/TicketBulkAction.swift
index dcf7676..ea9cc01 100644
--- a/Hutch/Models/TicketBulkAction.swift
+++ b/Hutch/Models/TicketBulkAction.swift
@@ -1,6 +1,6 @@
import Foundation
-enum TicketBulkActionKind: String, Sendable {
+nonisolated enum TicketBulkActionKind: String, Sendable {
case close
case assign
@@ -23,7 +23,7 @@ enum TicketBulkActionKind: String, Sendable {
}
}
-struct TicketBulkActionResult: Identifiable, Sendable {
+nonisolated struct TicketBulkActionResult: Identifiable, Sendable {
let id = UUID()
let action: TicketBulkActionKind
let totalCount: Int
@@ -82,7 +82,7 @@ struct TicketBulkActionResult: Identifiable, Sendable {
}
}
-struct TicketBulkActionFailure: Sendable {
+nonisolated struct TicketBulkActionFailure: Sendable {
let ticketID: Int
let message: String
}
diff --git a/Hutch/Models/Todo.swift b/Hutch/Models/Todo.swift
index 129d4bf..d2ae889 100644
--- a/Hutch/Models/Todo.swift
+++ b/Hutch/Models/Todo.swift
@@ -3,7 +3,7 @@ import Foundation
// MARK: - Enums
/// Status of a ticket.
-enum TicketStatus: String, Codable, Sendable, CaseIterable {
+nonisolated enum TicketStatus: String, Codable, Sendable, CaseIterable {
case reported = "REPORTED"
case confirmed = "CONFIRMED"
case inProgress = "IN_PROGRESS"
@@ -30,7 +30,7 @@ enum TicketStatus: String, Codable, Sendable, CaseIterable {
}
/// Resolution of a ticket.
-enum TicketResolution: String, Codable, Sendable {
+nonisolated enum TicketResolution: String, Codable, Sendable {
case unresolved = "UNRESOLVED"
case fixed = "FIXED"
case implemented = "IMPLEMENTED"
@@ -64,7 +64,7 @@ enum TicketResolution: String, Codable, Sendable {
}
/// Authenticity of a ticket or comment.
-enum Authenticity: String, Codable, Sendable {
+nonisolated enum Authenticity: String, Codable, Sendable {
case authentic = "AUTHENTIC"
case tampered = "TAMPERED"
case unauthenticated = "UNAUTHENTICATED"
@@ -74,7 +74,7 @@ enum Authenticity: String, Codable, Sendable {
/// A label that can be applied to tickets.
/// Named `TicketLabel` to avoid collision with `SwiftUI.Label`.
-struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let backgroundColor: String
@@ -83,7 +83,7 @@ struct TicketLabel: Codable, Sendable, Identifiable, Hashable {
// MARK: - Tracker ACL
-struct TrackerACLPermissions: Codable, Sendable, Hashable {
+nonisolated struct TrackerACLPermissions: Codable, Sendable, Hashable {
let browse: Bool
let submit: Bool
let comment: Bool
@@ -91,7 +91,7 @@ struct TrackerACLPermissions: Codable, Sendable, Hashable {
let triage: Bool
}
-struct TrackerACL: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct TrackerACL: Codable, Sendable, Identifiable, Hashable {
let id: Int
let created: Date
let entity: Entity
@@ -112,7 +112,7 @@ struct TrackerACL: Codable, Sendable, Identifiable, Hashable {
}
}
-struct DefaultTrackerACL: Codable, Sendable, Hashable {
+nonisolated struct DefaultTrackerACL: Codable, Sendable, Hashable {
let browse: Bool
let submit: Bool
let comment: Bool
@@ -133,7 +133,7 @@ struct DefaultTrackerACL: Codable, Sendable, Hashable {
// MARK: - Tracker
/// A bug tracker from todo.sr.ht.
-struct Tracker: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct Tracker: Codable, Sendable, Identifiable, Hashable {
let id: Int
let created: Date
let updated: Date
@@ -154,7 +154,7 @@ struct Tracker: Codable, Sendable, Identifiable, Hashable {
// MARK: - Tracker Summary (for list view)
/// Lightweight tracker model matching the fields returned by the trackers list query.
-struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
let id: Int
/// GraphQL resource identifier used by `tracker(id:)` queries.
let rid: String
@@ -168,7 +168,7 @@ struct TrackerSummary: Codable, Sendable, Identifiable, Hashable {
// MARK: - Ticket Summary (for list view)
/// Lightweight ticket model for the list query.
-struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
+nonisolated struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
let id: Int
let title: String
let status: TicketStatus
@@ -190,7 +190,7 @@ struct TicketSummary: Codable, Sendable, Identifiable, Hashable {
// MARK: - Ticket Detail
/// Full ticket model for the detail view.
-struct TicketDetail: Codable, Sendable {
+nonisolated struct TicketDetail: Codable, Sendable {
let id: Int
let created: Date
let updated: Date
@@ -207,7 +207,7 @@ struct TicketDetail: Codable, Sendable {
// MARK: - Event
/// A timeline event on a ticket. Each event contains one or more changes.
-struct TicketEvent: Codable, Sendable, Identifiable {
+nonisolated struct TicketEvent: Codable, Sendable, Identifiable {
let id: Int
let created: Date
var changes: [EventChange]
@@ -215,7 +215,7 @@ struct TicketEvent: Codable, Sendable, Identifiable {
/// A single change within an event, decoded from the polymorphic EventDetail
/// interface using inline fragments.
-struct EventChange: Codable, Sendable, Identifiable {
+nonisolated struct EventChange: Codable, Sendable, Identifiable {
let id: UUID
let eventType: String
@@ -272,12 +272,12 @@ struct EventChange: Codable, Sendable, Identifiable {
/// Decoded from the `mentioned` field which may be a Ticket (with `id`) or
/// an Entity (with `canonicalName`) depending on the event type.
-struct MentionTarget: Codable, Sendable {
+nonisolated struct MentionTarget: Codable, Sendable {
let id: Int?
let canonicalName: String?
}
/// Label info as returned within a LabelUpdate event change.
-struct EventLabel: Codable, Sendable {
+nonisolated struct EventLabel: Codable, Sendable {
let name: String
}
diff --git a/Hutch/Models/User.swift b/Hutch/Models/User.swift
index 4ab7315..10576ad 100644
--- a/Hutch/Models/User.swift
+++ b/Hutch/Models/User.swift
@@ -1,7 +1,7 @@
import Foundation
/// A Sourcehut user from meta.sr.ht.
-struct User: Decodable, Sendable, Hashable {
+nonisolated struct User: Decodable, Sendable, Hashable {
let id: Int
let created: String?
let updated: String?
diff --git a/Hutch/Networking/RepositoryDeployKeyService.swift b/Hutch/Networking/RepositoryDeployKeyService.swift
new file mode 100644
index 0000000..39e2da1
--- /dev/null
+++ b/Hutch/Networking/RepositoryDeployKeyService.swift
@@ -0,0 +1,100 @@
+import Foundation
+
+/// A per-repository deploy key (git.sr.ht `SSHKey` under `Repository.deployKeys`).
+struct RepositoryDeployKey: Decodable, Sendable, Identifiable, Hashable {
+ let rid: String
+ let keyType: String
+ let fingerprintSHA256: String
+ let comment: String?
+ let access: AccessMode
+
+ var id: String { rid }
+}
+
+private struct DeployKeysQueryResponse: Decodable, Sendable {
+ let repository: DeployKeysRepository?
+}
+
+private struct DeployKeysRepository: Decodable, Sendable {
+ let deployKeys: DeployKeysPage
+}
+
+private struct DeployKeysPage: Decodable, Sendable {
+ let results: [RepositoryDeployKey]
+}
+
+/// `createDeployKey`'s response returns an empty `access` (the stored value is
+/// correct — the list query reports it), so we select only `rid` here and let
+/// callers reload rather than decode the partial key.
+private struct CreateDeployKeyResponse: Decodable, Sendable {}
+
+/// Delete returns the removed key; only success matters here.
+private struct DeleteDeployKeyResponse: Decodable, Sendable {}
+
+/// Deploy keys are a git.sr.ht capability (`createDeployKey` / `deleteDeployKey`),
+/// owner-only, alongside repository ACLs.
+struct RepositoryDeployKeyService: Sendable {
+ private let client: SRHTClient
+
+ init(client: SRHTClient) {
+ self.client = client
+ }
+
+ func fetchDeployKeys(repositoryRid: String) async throws -> [RepositoryDeployKey] {
+ let response = try await client.execute(
+ service: .git,
+ query: Self.deployKeysQuery,
+ variables: ["rid": repositoryRid],
+ responseType: DeployKeysQueryResponse.self
+ )
+ return response.repository?.deployKeys.results ?? []
+ }
+
+ func createDeployKey(repositoryRid: String, mode: AccessMode, key: String) async throws {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.createDeployKeyMutation,
+ variables: ["repo": repositoryRid, "mode": mode.rawValue, "key": key],
+ responseType: CreateDeployKeyResponse.self
+ )
+ }
+
+ func deleteDeployKey(rid: String) async throws {
+ _ = try await client.execute(
+ service: .git,
+ query: Self.deleteDeployKeyMutation,
+ variables: ["rid": rid],
+ responseType: DeleteDeployKeyResponse.self
+ )
+ }
+}
+
+private extension RepositoryDeployKeyService {
+ static let deployKeysQuery = """
+ query repositoryDeployKeys($rid: ID!) {
+ repository(rid: $rid) {
+ deployKeys {
+ results {
+ rid
+ keyType
+ fingerprintSHA256
+ comment
+ access
+ }
+ }
+ }
+ }
+ """
+
+ static let createDeployKeyMutation = """
+ mutation createDeployKey($repo: ID!, $mode: AccessMode!, $key: String!) {
+ createDeployKey(repo: $repo, mode: $mode, key: $key) { rid }
+ }
+ """
+
+ static let deleteDeployKeyMutation = """
+ mutation deleteDeployKey($rid: ID!) {
+ deleteDeployKey(rid: $rid) { rid }
+ }
+ """
+}
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 1b159bf..b6ed2ff 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -100,7 +100,7 @@ final class MailingListListViewModel {
query: Self.createMailingListMutation,
variables: [
"name": trimmedName,
- "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription,
+ "description": trimmedDescription.isEmpty ? nil as String? as any Sendable : trimmedDescription,
"visibility": visibility.rawValue
],
responseType: Response.self
diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift
index bc909c1..043b218 100644
--- a/Hutch/Views/More/TipStoreViewModel.swift
+++ b/Hutch/Views/More/TipStoreViewModel.swift
@@ -32,7 +32,9 @@ final class TipStoreViewModel {
var errorMessage: String?
var statusMessage: String?
- private var transactionUpdatesTask: Task<Void, Never>?
+ // Internal task handle, not observable state; assigned only on the main
+ // actor and read once from the nonisolated deinit.
+ @ObservationIgnored nonisolated(unsafe) private var transactionUpdatesTask: Task<Void, Never>?
init() {
transactionUpdatesTask = Task.detached(priority: .background) {
diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift
index 10bb19e..a4decbb 100644
--- a/Hutch/Views/Projects/ProjectMailingListView.swift
+++ b/Hutch/Views/Projects/ProjectMailingListView.swift
@@ -37,6 +37,36 @@ private struct PatchsetSummaryPayload: Decodable, Sendable {
let status: PatchsetStatus
}
+private struct ListMetaResponse: Decodable, Sendable {
+ let list: ListMetaPayload?
+}
+
+private struct ListMetaPayload: Decodable, Sendable {
+ let id: Int
+ let owner: Entity
+}
+
+private struct SubscriptionRidsResponse: Decodable, Sendable {
+ let subscriptions: SubscriptionRidsPage
+}
+
+private struct SubscriptionRidsPage: Decodable, Sendable {
+ let results: [SubscriptionRidEntry]
+ let cursor: String?
+}
+
+private struct SubscriptionRidEntry: Decodable, Sendable {
+ let list: SubscriptionRidList?
+}
+
+private struct SubscriptionRidList: Decodable, Sendable {
+ let rid: String
+}
+
+/// Toggle mutations return the subscription (nullable on unsubscribe); only
+/// success matters.
+private struct SubscriptionToggleResponse: Decodable, Sendable {}
+
@Observable
@MainActor
final class MailingListDetailViewModel {
@@ -47,6 +77,13 @@ final class MailingListDetailViewModel {
var error: String?
var searchText = ""
+ /// Subscription state. `isSubscribed` is `nil` while unknown or unavailable
+ /// (the toggle stays hidden); `isOwnList` hides it for lists you own.
+ private(set) var listNumericID: Int?
+ private(set) var isSubscribed: Bool?
+ private(set) var isOwnList = false
+ private(set) var isTogglingSubscription = false
+
private let mailingList: InboxMailingListReference
private let client: SRHTClient
private let defaults: UserDefaults
@@ -86,6 +123,96 @@ final class MailingListDetailViewModel {
self.accountID = accountID
}
+ // MARK: - Subscription
+
+ private static let listMetaQuery = """
+ query listMeta($rid: ID!) {
+ list(rid: $rid) { id owner { canonicalName } }
+ }
+ """
+
+ // `MailingList.subscription` is unreliable (see the API-traps note), so
+ // subscribe state comes from the authoritative `subscriptions` query.
+ private static let subscriptionRidsQuery = """
+ query subscriptionRids($cursor: Cursor) {
+ subscriptions(cursor: $cursor) {
+ results {
+ ... on MailingListSubscription { list { rid } }
+ }
+ cursor
+ }
+ }
+ """
+
+ private static let subscribeMutation = """
+ mutation mailingListSubscribe($id: Int!) {
+ mailingListSubscribe(listID: $id) { id }
+ }
+ """
+
+ private static let unsubscribeMutation = """
+ mutation mailingListUnsubscribe($id: Int!) {
+ mailingListUnsubscribe(listID: $id) { id }
+ }
+ """
+
+ /// Resolves the list's numeric id, whether the viewer owns it, and — for
+ /// lists they don't own — whether they're subscribed.
+ func loadSubscriptionState(currentUserCanonicalName: String?) async {
+ do {
+ let meta = try await client.execute(
+ service: .lists,
+ query: Self.listMetaQuery,
+ variables: ["rid": mailingList.rid],
+ responseType: ListMetaResponse.self
+ )
+ guard let list = meta.list else { return }
+ listNumericID = list.id
+
+ if let currentUserCanonicalName, list.owner.canonicalName == currentUserCanonicalName {
+ isOwnList = true
+ return
+ }
+ isSubscribed = try await isSubscribed(toRid: mailingList.rid)
+ } catch {
+ // Leave state unknown; the toggle stays hidden rather than lying.
+ }
+ }
+
+ private func isSubscribed(toRid rid: String) async throws -> Bool {
+ var cursor: String?
+ repeat {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.subscriptionRidsQuery,
+ variables: cursor.map { ["cursor": $0] },
+ responseType: SubscriptionRidsResponse.self
+ )
+ if response.subscriptions.results.contains(where: { $0.list?.rid == rid }) {
+ return true
+ }
+ cursor = response.subscriptions.cursor
+ } while cursor != nil
+ return false
+ }
+
+ func toggleSubscription() async {
+ guard let id = listNumericID, let subscribed = isSubscribed, !isTogglingSubscription else { return }
+ isTogglingSubscription = true
+ defer { isTogglingSubscription = false }
+ do {
+ _ = try await client.execute(
+ service: .lists,
+ query: subscribed ? Self.unsubscribeMutation : Self.subscribeMutation,
+ variables: ["id": id],
+ responseType: SubscriptionToggleResponse.self
+ )
+ isSubscribed = !subscribed
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+
var filteredThreads: [InboxThreadSummary] {
Self.filterThreads(threads, matching: searchText)
}
@@ -386,6 +513,21 @@ struct MailingListDetailView: View {
.accessibilityLabel(isPinnedToHome ? "Unpin from Home" : "Pin to Home")
}
}
+ if let viewModel, !viewModel.isOwnList, let subscribed = viewModel.isSubscribed {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ Task { await viewModel.toggleSubscription() }
+ } label: {
+ if viewModel.isTogglingSubscription {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: subscribed ? "bell.fill" : "bell")
+ }
+ }
+ .disabled(viewModel.isTogglingSubscription)
+ .accessibilityLabel(subscribed ? "Unsubscribe from list" : "Subscribe to list")
+ }
+ }
}
.task {
if viewModel == nil {
@@ -397,6 +539,7 @@ struct MailingListDetailView: View {
)
self.viewModel = viewModel
await viewModel.loadThreads()
+ await viewModel.loadSubscriptionState(currentUserCanonicalName: currentUserKey)
}
}
.onAppear {
diff --git a/Hutch/Views/Repositories/RepositoryDeployKeysView.swift b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
new file mode 100644
index 0000000..71e252b
--- /dev/null
+++ b/Hutch/Views/Repositories/RepositoryDeployKeysView.swift
@@ -0,0 +1,272 @@
+import SwiftUI
+
+@Observable
+@MainActor
+final class RepositoryDeployKeysViewModel {
+ private(set) var keys: [RepositoryDeployKey] = []
+ private(set) var isLoading = false
+ private(set) var isSaving = false
+ private(set) var deletingRID: String?
+ var loadError: String?
+ var error: String?
+ var saveError: String?
+
+ let repositoryRid: String
+ private let service: RepositoryDeployKeyService
+
+ init(repositoryRid: String, service: RepositoryDeployKeyService) {
+ self.repositoryRid = repositoryRid
+ self.service = service
+ }
+
+ func load() async {
+ guard !isLoading else { return }
+ isLoading = true
+ loadError = nil
+ defer { isLoading = false }
+ do {
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ } catch {
+ if keys.isEmpty {
+ loadError = error.userFacingMessage
+ } else {
+ self.error = error.userFacingMessage
+ }
+ }
+ }
+
+ func addKey(publicKey: String, mode: AccessMode) async -> Bool {
+ let trimmed = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty, !isSaving else { return false }
+ isSaving = true
+ saveError = nil
+ defer { isSaving = false }
+ do {
+ try await service.createDeployKey(repositoryRid: repositoryRid, mode: mode, key: trimmed)
+ // The create response omits the key's fields, so reload the list.
+ keys = try await service.fetchDeployKeys(repositoryRid: repositoryRid)
+ return true
+ } catch {
+ saveError = error.userFacingMessage
+ return false
+ }
+ }
+
+ func deleteKey(_ key: RepositoryDeployKey) async {
+ guard deletingRID == nil else { return }
+ deletingRID = key.rid
+ error = nil
+ defer { deletingRID = nil }
+ do {
+ try await service.deleteDeployKey(rid: key.rid)
+ keys.removeAll { $0.rid == key.rid }
+ } catch {
+ self.error = error.userFacingMessage
+ }
+ }
+}
+
+struct RepositoryDeployKeysView: View {
+ let repository: RepositorySummary
+ let client: SRHTClient
+ var showsDoneButton = false
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var viewModel: RepositoryDeployKeysViewModel?
+ @State private var showAddSheet = false
+ @State private var pendingDeletion: RepositoryDeployKey?
+
+ var body: some View {
+ Group {
+ if let viewModel {
+ content(viewModel)
+ } else {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ }
+ }
+ .navigationTitle("Deploy Keys")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ if showsDoneButton {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Done") { dismiss() }
+ }
+ }
+ if viewModel != nil {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showAddSheet = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ .accessibilityLabel("Add deploy key")
+ }
+ }
+ }
+ .task {
+ if viewModel == nil {
+ let vm = RepositoryDeployKeysViewModel(
+ repositoryRid: repository.rid,
+ service: RepositoryDeployKeyService(client: client)
+ )
+ viewModel = vm
+ await vm.load()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func content(_ viewModel: RepositoryDeployKeysViewModel) -> some View {
+ Group {
+ if viewModel.isLoading, viewModel.keys.isEmpty, viewModel.loadError == nil {
+ SRHTLoadingStateView(message: "Loading deploy keys…")
+ } else if let loadError = viewModel.loadError, viewModel.keys.isEmpty {
+ SRHTErrorStateView(
+ title: "Couldn't Load Deploy Keys",
+ message: loadError,
+ retryAction: { await viewModel.load() }
+ )
+ } else {
+ List {
+ if viewModel.keys.isEmpty {
+ Section {
+ ContentUnavailableView(
+ "No Deploy Keys",
+ systemImage: "key",
+ description: Text("Add an SSH public key to grant this repository read or read/write access for automation.")
+ )
+ .themedRow()
+ }
+ } else {
+ Section {
+ ForEach(viewModel.keys) { key in
+ DeployKeyRow(key: key, isDeleting: viewModel.deletingRID == key.rid)
+ .themedRow()
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button(role: .destructive) {
+ pendingDeletion = key
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ } footer: {
+ Text("Deploy keys are SSH keys scoped to this repository only.")
+ }
+ }
+ }
+ .themedList()
+ .refreshable { await viewModel.load() }
+ }
+ }
+ .srhtErrorBanner(error: Binding(get: { viewModel.error }, set: { viewModel.error = $0 }))
+ .confirmationDialog(
+ "Delete this deploy key?",
+ isPresented: Binding(get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }),
+ titleVisibility: .visible
+ ) {
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ Button("Delete", role: .destructive) {
+ if let key = pendingDeletion {
+ pendingDeletion = nil
+ Task { await viewModel.deleteKey(key) }
+ }
+ }
+ } message: {
+ Text("This revokes the key's access to \(repository.name). This cannot be undone.")
+ }
+ .sheet(isPresented: $showAddSheet) {
+ AddDeployKeyView(viewModel: viewModel)
+ }
+ }
+}
+
+private struct DeployKeyRow: View {
+ let key: RepositoryDeployKey
+ let isDeleting: Bool
+
+ var body: some View {
+ HStack(spacing: 12) {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(key.comment?.isEmpty == false ? key.comment! : key.keyType)
+ .font(.body)
+ .lineLimit(1)
+ Text(key.fingerprintSHA256)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+ Spacer()
+ if isDeleting {
+ ProgressView().controlSize(.small)
+ } else {
+ Text(key.access.displayName)
+ .font(.caption.weight(.medium))
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 2)
+ }
+}
+
+private struct AddDeployKeyView: View {
+ let viewModel: RepositoryDeployKeysViewModel
+
+ @Environment(\.dismiss) private var dismiss
+ @State private var publicKey = ""
+ @State private var mode: AccessMode = .ro
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("SSH Public Key") {
+ TextField("ssh-ed25519 AAAA… comment", text: $publicKey, axis: .vertical)
+ .lineLimit(3...8)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .font(.body.monospaced())
+ .themedRow()
+ }
+ Section {
+ Picker("Access", selection: $mode) {
+ Text("Read Only").tag(AccessMode.ro)
+ Text("Read/Write").tag(AccessMode.rw)
+ }
+ .themedRow()
+ } footer: {
+ Text("Read/Write lets the key push to this repository.")
+ }
+ if let saveError = viewModel.saveError, !saveError.isEmpty {
+ Section {
+ Text(saveError).foregroundStyle(.red).themedRow()
+ }
+ }
+ }
+ .themedList()
+ .navigationTitle("Add Deploy Key")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { dismiss() }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button {
+ Task {
+ if await viewModel.addKey(publicKey: publicKey, mode: mode) {
+ dismiss()
+ }
+ }
+ } label: {
+ if viewModel.isSaving {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Add")
+ }
+ }
+ .disabled(publicKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 8f466ba..a4ac25e 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -12,6 +12,7 @@ struct RepositoryDetailView: View {
@State private var selectedTab: RepositoryDetailViewModel.Tab = .summary
@State private var showSettings = false
@State private var showACLs = false
+ @State private var showDeployKeys = false
@State private var currentRepository: RepositorySummary
@State private var pinChangeCount = 0
@@ -82,6 +83,15 @@ struct RepositoryDetailView: View {
)
}
}
+ .sheet(isPresented: $showDeployKeys) {
+ NavigationStack {
+ RepositoryDeployKeysView(
+ repository: currentRepository,
+ client: appState.client,
+ showsDoneButton: true
+ )
+ }
+ }
.task {
if viewModel == nil {
viewModel = RepositoryDetailViewModel(
@@ -201,6 +211,12 @@ struct RepositoryDetailView: View {
}
Button {
+ showDeployKeys = true
+ } label: {
+ Label("Deploy Keys", systemImage: "key")
+ }
+
+ Button {
showSettings = true
} label: {
Label("Repository Settings", systemImage: "gear")
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 82e1592..62a8b12 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -557,7 +557,7 @@ final class RepositoryDetailViewModel {
variables: [
"repoId": repository.id,
"revspec": revspec,
- "file": nil as String? as Any
+ "file": nil as String? as any Sendable
],
file: MultipartUploadFile(
variablePath: "file",
diff --git a/Hutch/Views/Repositories/SyntaxHighlighter.swift b/Hutch/Views/Repositories/SyntaxHighlighter.swift
index 5ae8f58..dfab443 100644
--- a/Hutch/Views/Repositories/SyntaxHighlighter.swift
+++ b/Hutch/Views/Repositories/SyntaxHighlighter.swift
@@ -3,7 +3,7 @@ import Highlightr
import SwiftUI
import UIKit
-enum SyntaxHighlightTheme {
+nonisolated enum SyntaxHighlightTheme {
case light
case dark
@@ -31,7 +31,7 @@ enum SyntaxHighlightTheme {
/// instance must stay on the thread/task that created it. Callers that fail to
/// resolve a language — or hit an unavailable engine — get `nil` and should
/// fall back to plain, escaped text.
-final class SyntaxHighlighter {
+nonisolated final class SyntaxHighlighter {
private let highlightr: Highlightr?
private let supportedLanguages: Set<String>
@@ -208,7 +208,7 @@ final class SyntaxHighlighter {
]
}
-private extension UIColor {
+nonisolated private extension UIColor {
/// `#rrggbb` for HTML inline styles, or `nil` if the color isn't RGB-convertible.
var hexRGBString: String? {
var red: CGFloat = 0
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index 8536e60..2d57ba5 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -198,9 +198,9 @@ final class SettingsViewModel {
do {
let input: [String: any Sendable] = [
"email": email,
- "url": url.isEmpty ? nil as String? as Any : url,
- "location": location.isEmpty ? nil as String? as Any : location,
- "bio": bio.isEmpty ? nil as String? as Any : bio
+ "url": url.isEmpty ? nil as String? as any Sendable : url,
+ "location": location.isEmpty ? nil as String? as any Sendable : location,
+ "bio": bio.isEmpty ? nil as String? as any Sendable : bio
]
let result = try await client.execute(
service: .meta,
@@ -243,7 +243,7 @@ final class SettingsViewModel {
do {
// The input variable has avatar set to null; the actual file
// is sent as a separate multipart part per graphql-multipart-request-spec.
- let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
let result = try await client.executeMultipart(
service: .meta,
query: Self.updateUserMutation,
@@ -286,7 +286,7 @@ final class SettingsViewModel {
error = nil
do {
- let input: [String: any Sendable] = ["avatar": nil as String? as Any]
+ let input: [String: any Sendable] = ["avatar": nil as String? as any Sendable]
let result = try await client.execute(
service: .meta,
query: Self.updateUserMutation,
diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift
index 24db870..9a93783 100644
--- a/HutchTests/APICacheTests.swift
+++ b/HutchTests/APICacheTests.swift
@@ -3,6 +3,7 @@ import Testing
@testable import Hutch
@Suite(.serialized)
+@MainActor
struct APICacheTests {
private struct Payload: Codable, Sendable, Equatable {
let value: String
diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift
index b7e1755..49baa17 100644
--- a/HutchTests/AppStateTests.swift
+++ b/HutchTests/AppStateTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct AppStateTests {
@Test
diff --git a/HutchTests/BuildListViewModelTests.swift b/HutchTests/BuildListViewModelTests.swift
index 8ee5b81..9b64621 100644
--- a/HutchTests/BuildListViewModelTests.swift
+++ b/HutchTests/BuildListViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct BuildListViewModelTests {
@Test
diff --git a/HutchTests/BuildTaskLogSearchTests.swift b/HutchTests/BuildTaskLogSearchTests.swift
index 4cb9984..8c7ddc2 100644
--- a/HutchTests/BuildTaskLogSearchTests.swift
+++ b/HutchTests/BuildTaskLogSearchTests.swift
@@ -1,6 +1,7 @@
import Testing
@testable import Hutch
+@MainActor
struct BuildTaskLogSearchTests {
@Test
diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift
index 2e2a1e0..c7baae7 100644
--- a/HutchTests/BuildTaskTests.swift
+++ b/HutchTests/BuildTaskTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct BuildTaskTests {
@Test
diff --git a/HutchTests/BundleUserAgentTests.swift b/HutchTests/BundleUserAgentTests.swift
index c1a20b8..a4bda55 100644
--- a/HutchTests/BundleUserAgentTests.swift
+++ b/HutchTests/BundleUserAgentTests.swift
@@ -40,6 +40,7 @@ private final class CapturingURLProtocol: URLProtocol, @unchecked Sendable {
/// Tests are serialized because CapturingURLProtocol uses shared static state.
@Suite(.serialized)
+@MainActor
struct BundleUserAgentTests {
// MARK: Bundle extension
diff --git a/HutchTests/ContributionCalendarTests.swift b/HutchTests/ContributionCalendarTests.swift
index 0328ad5..e5d625a 100644
--- a/HutchTests/ContributionCalendarTests.swift
+++ b/HutchTests/ContributionCalendarTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ContributionCalendarTests {
@Test
diff --git a/HutchTests/DeepLinkTests.swift b/HutchTests/DeepLinkTests.swift
index cdc5bab..dc4f605 100644
--- a/HutchTests/DeepLinkTests.swift
+++ b/HutchTests/DeepLinkTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct DeepLinkTests {
@Test
diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift
index 547c6f3..3546d11 100644
--- a/HutchTests/GitObjectTests.swift
+++ b/HutchTests/GitObjectTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct GitObjectTests {
@Test
diff --git a/HutchTests/HomeViewModelTests.swift b/HutchTests/HomeViewModelTests.swift
index 851b520..a050931 100644
--- a/HutchTests/HomeViewModelTests.swift
+++ b/HutchTests/HomeViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct HomeViewModelTests {
@Test
diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift
index c681efa..98c68d3 100644
--- a/HutchTests/HutchTests.swift
+++ b/HutchTests/HutchTests.swift
@@ -7,6 +7,7 @@
import Testing
+@MainActor
struct HutchTests {
@Test func example() async throws {
diff --git a/HutchTests/InboxViewModelTests.swift b/HutchTests/InboxViewModelTests.swift
index 5780b6a..9c35001 100644
--- a/HutchTests/InboxViewModelTests.swift
+++ b/HutchTests/InboxViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct InboxViewModelTests {
@Test
diff --git a/HutchTests/LookupHistoryStoreTests.swift b/HutchTests/LookupHistoryStoreTests.swift
index 414d016..af7c69b 100644
--- a/HutchTests/LookupHistoryStoreTests.swift
+++ b/HutchTests/LookupHistoryStoreTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct LookupHistoryStoreTests {
@Test
diff --git a/HutchTests/MailingListActivityTests.swift b/HutchTests/MailingListActivityTests.swift
index 46208cb..61a7e77 100644
--- a/HutchTests/MailingListActivityTests.swift
+++ b/HutchTests/MailingListActivityTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct MailingListActivityTests {
@Test
diff --git a/HutchTests/ManPageCatalogTests.swift b/HutchTests/ManPageCatalogTests.swift
index df995f8..e6bd2fc 100644
--- a/HutchTests/ManPageCatalogTests.swift
+++ b/HutchTests/ManPageCatalogTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ManPageCatalogTests {
@Test
func loadsBundledCatalog() {
diff --git a/HutchTests/PatchsetTests.swift b/HutchTests/PatchsetTests.swift
index 945131f..e181f14 100644
--- a/HutchTests/PatchsetTests.swift
+++ b/HutchTests/PatchsetTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct PatchsetStatusTests {
@Test
@@ -46,6 +47,7 @@ struct PatchsetStatusTests {
}
}
+@MainActor
struct PatchsetSummaryTests {
@Test
diff --git a/HutchTests/ProjectFormTests.swift b/HutchTests/ProjectFormTests.swift
index a4b4dfa..3e1fffc 100644
--- a/HutchTests/ProjectFormTests.swift
+++ b/HutchTests/ProjectFormTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ProjectFormTests {
@Test
func parsesTagsSplittingAndTrimming() {
diff --git a/HutchTests/ProjectPinStoreTests.swift b/HutchTests/ProjectPinStoreTests.swift
index 13df539..c040868 100644
--- a/HutchTests/ProjectPinStoreTests.swift
+++ b/HutchTests/ProjectPinStoreTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ProjectPinStoreTests {
@Test
func storesPinnedProjectsPerUser() {
diff --git a/HutchTests/ProjectTests.swift b/HutchTests/ProjectTests.swift
index 23faa0d..785c98f 100644
--- a/HutchTests/ProjectTests.swift
+++ b/HutchTests/ProjectTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ProjectTests {
private enum Fixture {
static let exampleWebsite = "https://example.com"
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
index 9e053b8..96eb8c0 100644
--- a/HutchTests/ReadmeViewTests.swift
+++ b/HutchTests/ReadmeViewTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ReadmeViewTests {
@Test
@@ -28,6 +29,7 @@ struct ReadmeViewTests {
}
}
+@MainActor
struct MarkdownRenderingTests {
@Test
@@ -219,6 +221,7 @@ struct MarkdownRenderingTests {
}
}
+@MainActor
struct OrgRenderingTests {
@Test
@@ -376,6 +379,7 @@ struct OrgRenderingTests {
}
}
+@MainActor
struct RepositoryAssetURLTests {
@Test
@@ -391,6 +395,7 @@ struct RepositoryAssetURLTests {
}
}
+@MainActor
struct RepositoryLinkURLTests {
@Test
diff --git a/HutchTests/RecentActivityStoreTests.swift b/HutchTests/RecentActivityStoreTests.swift
index e6fcc04..7713e6d 100644
--- a/HutchTests/RecentActivityStoreTests.swift
+++ b/HutchTests/RecentActivityStoreTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct RecentActivityStoreTests {
@Test
func removeDropsMatchingEntryKeepingOthers() {
diff --git a/HutchTests/RepositoryACLViewModelTests.swift b/HutchTests/RepositoryACLViewModelTests.swift
index 9640b93..501074c 100644
--- a/HutchTests/RepositoryACLViewModelTests.swift
+++ b/HutchTests/RepositoryACLViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct RepositoryACLViewModelTests {
@Test
diff --git a/HutchTests/RepositoryDeployKeyTests.swift b/HutchTests/RepositoryDeployKeyTests.swift
new file mode 100644
index 0000000..d7a0dad
--- /dev/null
+++ b/HutchTests/RepositoryDeployKeyTests.swift
@@ -0,0 +1,28 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+@MainActor
+struct RepositoryDeployKeyTests {
+ @Test
+ func decodesDeployKey() throws {
+ let json = """
+ { "rid": "k1", "keyType": "ssh-ed25519", "fingerprintSHA256": "SHA256:abc", "comment": "laptop", "access": "RW" }
+ """
+ let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
+ #expect(key.rid == "k1")
+ #expect(key.id == "k1")
+ #expect(key.access == .rw)
+ #expect(key.comment == "laptop")
+ }
+
+ @Test
+ func decodesDeployKeyWithNullComment() throws {
+ let json = """
+ { "rid": "k2", "keyType": "ssh-rsa", "fingerprintSHA256": "SHA256:def", "comment": null, "access": "RO" }
+ """
+ let key = try JSONDecoder().decode(RepositoryDeployKey.self, from: Data(json.utf8))
+ #expect(key.comment == nil)
+ #expect(key.access == .ro)
+ }
+}
diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift
index 5fe3974..7787fca 100644
--- a/HutchTests/RepositoryListViewModelTests.swift
+++ b/HutchTests/RepositoryListViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct RepositoryListViewModelTests {
@Test
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
index 13d0b21..25e46c9 100644
--- a/HutchTests/RepositorySettingsViewModelTests.swift
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -10,6 +10,7 @@ private struct UpdateRepositoryInfoPayload: Decodable {
let id: Int
}
+@MainActor
struct RepositorySettingsViewModelTests {
@Test
diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift
index 12d6d0f..51b6a68 100644
--- a/HutchTests/SRHTClientTests.swift
+++ b/HutchTests/SRHTClientTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SRHTClientTests {
@Test
diff --git a/HutchTests/SRHTWebURLTests.swift b/HutchTests/SRHTWebURLTests.swift
index d1053e6..f20f964 100644
--- a/HutchTests/SRHTWebURLTests.swift
+++ b/HutchTests/SRHTWebURLTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SRHTWebURLTests {
private enum Expected {
static let chatOrigin = "https://chat.sr.ht"
diff --git a/HutchTests/ScopedSearchHistoryStoreTests.swift b/HutchTests/ScopedSearchHistoryStoreTests.swift
index 856d69f..8effa06 100644
--- a/HutchTests/ScopedSearchHistoryStoreTests.swift
+++ b/HutchTests/ScopedSearchHistoryStoreTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct ScopedSearchHistoryStoreTests {
@Test
diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift
index 906f33f..e88d064 100644
--- a/HutchTests/SettingsViewModelTests.swift
+++ b/HutchTests/SettingsViewModelTests.swift
@@ -64,6 +64,7 @@ private struct DeleteResultPayload: Decodable {
}
@Suite(.serialized)
+@MainActor
struct SettingsViewModelTests {
@Test
diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift
index 950c3dc..f0f7fc1 100644
--- a/HutchTests/SettingsViewTests.swift
+++ b/HutchTests/SettingsViewTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SettingsViewTests {
@Test
diff --git a/HutchTests/SourceHutWebDeepLinkMapperTests.swift b/HutchTests/SourceHutWebDeepLinkMapperTests.swift
index 0fa3aaa..2c638c2 100644
--- a/HutchTests/SourceHutWebDeepLinkMapperTests.swift
+++ b/HutchTests/SourceHutWebDeepLinkMapperTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SourceHutWebDeepLinkMapperTests {
@Test
func mapsRepositoryURLs() {
diff --git a/HutchTests/SyntaxHighlighterTests.swift b/HutchTests/SyntaxHighlighterTests.swift
index 258e81e..3150814 100644
--- a/HutchTests/SyntaxHighlighterTests.swift
+++ b/HutchTests/SyntaxHighlighterTests.swift
@@ -3,6 +3,7 @@ import Testing
import UIKit
@testable import Hutch
+@MainActor
struct SyntaxHighlighterTests {
@Test
func mapsFileNamesToLanguages() {
@@ -24,9 +25,8 @@ struct SyntaxHighlighterTests {
let highlighter = SyntaxHighlighter(theme: .light)
let html = highlighter.highlightedHTML(for: "let answer = 42", language: "swift")
- let unwrapped = try? #require(html)
- #expect(unwrapped?.contains("<span style=\"color:") == true)
- #expect(unwrapped?.contains("answer") == true)
+ #expect(html?.contains("<span style=\"color:") == true)
+ #expect(html?.contains("answer") == true)
}
@Test
@@ -42,7 +42,6 @@ struct SyntaxHighlighterTests {
let font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
let attributed = highlighter.attributedText(for: "print(\"hi\")", language: "python", font: font)
- let unwrapped = try? #require(attributed)
- #expect(unwrapped?.string.contains("print") == true)
+ #expect(attributed?.string.contains("print") == true)
}
}
diff --git a/HutchTests/SystemStatusRepositoryTests.swift b/HutchTests/SystemStatusRepositoryTests.swift
index 56fa7c0..45d32b9 100644
--- a/HutchTests/SystemStatusRepositoryTests.swift
+++ b/HutchTests/SystemStatusRepositoryTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SystemStatusRepositoryTests {
@Test
@@ -111,21 +112,21 @@ private struct TestSystemStatusService: SystemStatusServing {
}
private extension SystemStatusRepositoryTests {
- static let cachedSnapshotHTML = #"""
+ nonisolated static let cachedSnapshotHTML = #"""
<div class="component" data-status="disrupted">
<a href="/affected/git.sr.ht/">git.sr.ht</a>
<span class="component-status">Disrupted</span>
</div>
"""#
- static let cachedOperationalHTML = #"""
+ nonisolated static let cachedOperationalHTML = #"""
<div class="component" data-status="ok">
<a href="/affected/meta.sr.ht/">meta.sr.ht</a>
<span class="component-status">Operational</span>
</div>
"""#
- static let cachedIncidentRSS = #"""
+ nonisolated static let cachedIncidentRSS = #"""
<rss version="2.0">
<channel>
<item>
@@ -139,7 +140,7 @@ private extension SystemStatusRepositoryTests {
</rss>
"""#
- static let emptyRSS = #"""
+ nonisolated static let emptyRSS = #"""
<rss version="2.0">
<channel></channel>
</rss>
diff --git a/HutchTests/SystemStatusServiceTests.swift b/HutchTests/SystemStatusServiceTests.swift
index d963fa4..9a7af1f 100644
--- a/HutchTests/SystemStatusServiceTests.swift
+++ b/HutchTests/SystemStatusServiceTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct SystemStatusServiceTests {
private enum Fixture {
static let ddosIssueURL = "https://status.sr.ht/issues/2026-04-06-ddos-attack/"
diff --git a/HutchTests/TicketBulkActionTests.swift b/HutchTests/TicketBulkActionTests.swift
index 2f4d1fd..2c1d738 100644
--- a/HutchTests/TicketBulkActionTests.swift
+++ b/HutchTests/TicketBulkActionTests.swift
@@ -1,6 +1,7 @@
import Testing
@testable import Hutch
+@MainActor
struct TicketBulkActionTests {
@Test
diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift
index ba4a476..d2c0c8e 100644
--- a/HutchTests/TicketDetailViewModelTests.swift
+++ b/HutchTests/TicketDetailViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct TicketDetailViewModelTests {
@Test
diff --git a/HutchTests/TicketSavedFilterStoreTests.swift b/HutchTests/TicketSavedFilterStoreTests.swift
index 2ff64e6..cfdcba6 100644
--- a/HutchTests/TicketSavedFilterStoreTests.swift
+++ b/HutchTests/TicketSavedFilterStoreTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct TicketSavedFilterStoreTests {
@Test
diff --git a/HutchTests/TipStoreViewModelTests.swift b/HutchTests/TipStoreViewModelTests.swift
index ed58d1e..734a7cc 100644
--- a/HutchTests/TipStoreViewModelTests.swift
+++ b/HutchTests/TipStoreViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct TipStoreViewModelTests {
@Test
diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift
index 5c57e04..85b051f 100644
--- a/HutchTests/TrackerListViewModelTests.swift
+++ b/HutchTests/TrackerListViewModelTests.swift
@@ -2,6 +2,7 @@ import Foundation
import Testing
@testable import Hutch
+@MainActor
struct TrackerListViewModelTests {
@Test
diff --git a/HutchTests/TrackerManagementViewModelTests.swift b/HutchTests/TrackerManagementViewModelTests.swift
index 531608c..211c838 100644
--- a/HutchTests/TrackerManagementViewModelTests.swift
+++ b/HutchTests/TrackerManagementViewModelTests.swift
@@ -1,6 +1,7 @@
import Testing
@testable import Hutch
+@MainActor
struct TrackerManagementViewModelTests {
@Test
diff --git a/HutchWidgetExtension/ContributionGraphWidget.swift b/HutchWidgetExtension/ContributionGraphWidget.swift
index 2fc07f2..bc56a03 100644
--- a/HutchWidgetExtension/ContributionGraphWidget.swift
+++ b/HutchWidgetExtension/ContributionGraphWidget.swift
@@ -28,12 +28,16 @@ struct ContributionGraphTimelineProvider: TimelineProvider {
}
func getSnapshot(in _: Context, completion: @escaping (ContributionGraphEntry) -> Void) {
+ // WidgetKit's completion is safe to call from the task; rebind as
+ // nonisolated(unsafe) so it can cross into the `sending` Task closure.
+ nonisolated(unsafe) let completion = completion
Task {
completion(await loadEntry())
}
}
func getTimeline(in _: Context, completion: @escaping (Timeline<ContributionGraphEntry>) -> Void) {
+ nonisolated(unsafe) let completion = completion
Task {
let entry = await loadEntry()
let refreshDate = Calendar.contributionCalendar.date(byAdding: .hour, value: 1, to: Date()) ?? Date().addingTimeInterval(3600)
diff --git a/ROADMAP.txt b/ROADMAP.txt
index 6c4765e..4352b9e 100644
--- a/ROADMAP.txt
+++ b/ROADMAP.txt
@@ -27,6 +27,15 @@ Things the schema does not tell you, each of which has already cost real time.
`git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at
`api/graph/schema.graphqls`. Regenerating the dumps with a full introspection
query would remove the trap.
+- **`MailingList.subscription` does not report your subscription.** The field
+ exists and is typed `MailingListSubscription`, but it returns null even
+ immediately after a `mailingListSubscribe` that hands you back a real
+ subscription id — verified live against `~hutch`, for both owned and
+ non-owned lists. Do not gate subscribe-state on it. The authoritative source
+ is membership in the `subscriptions` query (correct: true after subscribe,
+ false after unsubscribe); the mutations take `listID: Int!`, read from
+ `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the
+ "looks right, isn't" variety.
## Phase 0: Unblock CI — done (v3.5.0)
@@ -79,13 +88,14 @@ were never called. Each removes a "why can't I do this here?" moment.
- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
`copySelf`, surfaced as an Email section in Settings.
-`mailingListSubscribe` is deliberately not wired up. `MailingList` has no
-`subscription` field, unlike `Ticket` and `Tracker`, so per-list state is only
-knowable from the `subscriptions` query — which by definition lists what the
-user is already subscribed to. Subscribing needs a list the user is *not*
-subscribed to, and sr.ht exposes no discovery API to find one (see
-[SCOPE.txt](SCOPE.txt) on hub.sr.ht). Revisit if hub.sr.ht ever gains an API, or
-alongside Phase 2, which surfaces lists through patchsets.
+`mailingListSubscribe` was left unwired here on the view that per-list state was
+only knowable from the `subscriptions` query, and subscribing needs a list you
+are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
+things: a specific list is reachable without a discovery API (Lookup, a project's
+lists, patchsets), and the `subscriptions` query *is* the reliable state source —
+membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
+looked like a shortcut but is a trap (see API traps); it is not used. See
+"mailing list subscribe" below.
### Refactors folded in
@@ -144,10 +154,11 @@ so "breaking change" does not apply. These buckets track *user-visible scale*.
| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
-| v3.10.0 | "What's cooking" ingest; doc truth-up; deploy keys; revisit `mailingListSubscribe` | Ships one feature, corrects the map |
-| v3.11.0 | Accessibility | Independent, device-verified |
+| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
+| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
+| v3.12.0 | Accessibility | Independent, device-verified |
| v4.0.0 | Localization *with* translations | The only true re-presentation |
-| — | Swift 6 language mode; cache reads | Internal; ride along, no tag |
+| — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag |
3.9.0 was cut this session, bundling the hub.sr.ht writes with the other
features listed. That reorders the original plan: the "What's cooking" ingest
@@ -276,7 +287,7 @@ exception earns the release its own line: the `forceRefresh` fix changes what
pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
not just a green suite.
-### Ingest "What's cooking on SourceHut?" — v3.9.0
+### Ingest "What's cooking on SourceHut?" — v3.10.0
sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
@@ -294,19 +305,42 @@ flags two openings:
- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
resources. ~~Rechecked and shipped~~: project create/edit, resource
link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
- below. `SCOPE.txt` still needs its "hub has no public API / no discovery"
- claim corrected. `mailingListSubscribe` was *not* unblocked — that needs a
- per-list subscription field lists.sr.ht still lacks.
-- **git.sr.ht deploy keys are complete** (`createDeployKey` / `deleteDeployKey`
- are in the SDL). Hutch never calls them.
+ below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
+ been corrected.
+- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
+ `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
+ into the repository actions menu, owner-only, alongside ACLs.
Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
were captured.
-Research does not ship, so v3.9.0 pairs the ingest with **deploy keys** — the one
-self-contained feature it has already surfaced and that the SDL confirms exists.
-That gives the release something a user can see. Everything else the ingest turns
-up gets filed, not built, and hub.sr.ht gets its own bucket below.
+Deploy keys — the one self-contained feature the ingest had already surfaced and
+that the SDL confirmed — shipped in v3.10.0.
+
+**Ingest run (2026-08, Q1–Q2 posts + live schema introspection with a test
+token):** everything else the posts flagged is already in Hutch — RIDs (used
+throughout), pronouns and avatars (queried and displayed, avatar upload/delete
+in Settings), hub project writes and discovery (shipped), deploy keys (shipped).
+Planned-but-not-yet-shipped upstream, so nothing to build: anonymous API access
+and "standardized / connections-spec" GraphQL (Q2 named both as future work).
+The one *new* opening the introspection turned up is below.
+
+### Mailing list subscribe — done (v3.11.0)
+
+A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar
+(`MailingListDetailView`, which backs both Lookup results and
+`ProjectMailingListView`). It is hidden for lists you own and while state is
+unknown.
+
+Live testing rewrote the plan. `MailingList.subscription` looked like the state
+source but is a trap — it returns null even right after a successful
+`mailingListSubscribe` that hands back a subscription id (see API traps). So
+state comes from membership in the `subscriptions` query, which *is* reliable,
+and the numeric `listID` the mutations require comes from `list(rid:){ id }`.
+The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`,
+`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot:
+a specific list is reachable via Lookup, a project's lists, or patchsets, and
+that is all subscribing needs.
### hub.sr.ht writes — projects and discovery done
@@ -325,11 +359,10 @@ confirmed without a token (introspection there is auth-gated), so the mutations
degrade to a visible error rather than a crash if a field is not yet deployed.
Verify on a signed-in device.
-`mailingListSubscribe` stays out. `MailingList` still has no `subscription`
-field, and public *project* discovery does not help find a mailing list the user
-is *not* subscribed to — the reason Phase 1 declined it. Revisit only if
-lists.sr.ht gains per-list subscription state. `SCOPE.txt` still needs its
-"hub has no public API / no discovery" claim corrected.
+`mailingListSubscribe` is now buildable: the ingest found `MailingList` gained a
+`subscription` field, so per-list state is readable and the subscribe/unsubscribe
+toggle can reflect it — see the "mailing list subscribe" bucket below. `SCOPE.txt`
+has since had its "hub has no public API / no discovery" claim corrected.
### App Intent gaps — unscheduled
@@ -351,21 +384,29 @@ rather than a bare `TODO`.)
saved-search store for an intent to open. Add the intent once global
saved-search persistence exists.
-### Swift 6 language mode — no release of its own
-
-The project builds in Swift 5 language mode with
-`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. Moving to Swift 6 is blocked on
-concurrency diagnostics that are warnings today and errors there:
-
-- `APICacheTests` and `BundleUserAgentTests` call main-actor-isolated
- initialisers and properties from nonisolated contexts, and `await` a few
- expressions without marking them. Roughly 20 warnings, all in tests.
-- Response types are implicitly `@MainActor` under the default isolation, so
- their `Decodable` conformances are too. Decoding one from a nonisolated
- context — an `async let` over a raw `client.execute`, say — warns now and
- fails then. The pattern that avoids it is `async let` over `@MainActor`
- methods, as in `HomeViewModel.loadDashboard` and
- `NotificationPreferencesViewModel.load`.
+### Swift 6 language mode — done
+
+`SWIFT_VERSION` is now `6.0` (keeping `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`).
+The migration was mostly mechanical, in a few buckets:
+
+- **Models are `nonisolated`.** Under MainActor-default, every value-type model
+ was implicitly `@MainActor`; the pure data types in `Models/` (and utility
+ extensions like `Date.relativeDescription`, `DateFormatter+SRHT`, `SRHTWebURL`)
+ are now `nonisolated`, so the nonisolated networking layer can use them.
+- **App Intents statics.** `AppIntent`/`AppEntity`/`AppEnum` `static var`s were
+ "global shared mutable state"; the stored ones are now `static let`.
+- **Sendable dictionaries.** `nil as String? as Any` in `[String: any Sendable]`
+ GraphQL variable dicts became `nil as String? as any Sendable`.
+- **`UserDefaults`** gets a retroactive `@unchecked Sendable` (documented
+ thread-safe) since it threads through account sessions and stores.
+- **WidgetKit** completion handlers are rebound `nonisolated(unsafe)` to cross
+ into their `Task {}`; the `@Observable` `TipStoreViewModel` task handle is
+ `@ObservationIgnored nonisolated(unsafe)` for its nonisolated `deinit`.
+- **Tests** run on `@MainActor` (they exercise MainActor app code), with a few
+ constant fixtures marked `nonisolated` for use inside `@Sendable` stub
+ closures.
+
+Builds and the full suite are clean in Swift 6 mode with no behaviour change.
### Cache reads that bypass the client — no release of its own
diff --git a/SCOPE.txt b/SCOPE.txt
index 6a4f180..490ad45 100644
--- a/SCOPE.txt
+++ b/SCOPE.txt
@@ -4,7 +4,11 @@
file)
- Push notifications for builds and tickets (requires a backend relay server)
- ref: https://git.sr.ht/~ccleberg/hutch-notify
-- Explore / search (hub.sr.ht) (no public discovery API)
+- ~~Explore / search (hub.sr.ht) (no public discovery API)~~ — **stale**.
+ hub.sr.ht added a public `projects` query ("List all public projects"), and
+ Hutch ships it as project discovery (#12, `DiscoverProjectsView`). Left struck
+ through as more evidence for the ingestion task in ROADMAP.txt: this entry ruled
+ out a feature the API had come to support.
- ~~Pronouns on profile (not in GraphQL schema)~~ — **stale**. sr.ht added
pronouns (see the Q1 2026 "What's cooking"), and Hutch already queries them in
`AppState` and shows them in `UserProfileView`. Left here struck through as
@@ -27,9 +31,11 @@
unaffected because it filters on `ev.ticket_id`, which is why ticket timelines
work. Nothing a client can do fixes this; revisit only if sr.ht changes the
resolver.
-- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList`
- has no `subscription` field and sr.ht has no discovery API, so there is no way
- to find a list you are not already subscribed to — see hub.sr.ht above)
+- ~~Subscribe to a mailing list~~ — **shipped** (v3.11.0). A subscribe/unsubscribe
+ toggle on the mailing-list detail view. State comes from the `subscriptions`
+ query (`MailingList.subscription` is a trap — it never reports your state; see
+ ROADMAP.txt API traps), and a list is reachable to subscribe to via Lookup or a
+ project's lists — no discovery API needed.
- Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch
reviews patchsets but cannot send them)