summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-18 20:23:58 -0500
committerChristian Cleberg <[email protected]>2026-03-18 20:23:58 -0500
commitc41cd437acde4e4f4c0781cebd4d5f4bf9517bb9 (patch)
tree28bf13b89e36774720d015a26a8979b567ad0be9
parent1745311950fd68ad81957b3ba64f8566cd86ce19 (diff)
parent4dd920558a1c7174c0e9a5b872325df36e363516 (diff)
downloadhutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.tar.gz
hutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.tar.bz2
hutch-c41cd437acde4e4f4c0781cebd4d5f4bf9517bb9.zip
v1.2: Merge branch 'dev'
-rw-r--r--Hutch.xcodeproj/project.pbxproj138
-rw-r--r--Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme96
-rw-r--r--Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme79
-rw-r--r--Hutch/Models/Builds.swift27
-rw-r--r--Hutch/Models/Git.swift19
-rw-r--r--Hutch/Networking/SRHTClient.swift14
-rw-r--r--Hutch/Networking/SRHTError.swift4
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift4
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift17
-rw-r--r--Hutch/Views/Repositories/FileTreeView.swift2
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift24
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift141
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift66
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift74
-rw-r--r--Hutch/Views/Settings/SettingsView.swift35
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift4
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift5
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift20
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift27
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift19
-rw-r--r--HutchTests.xctestplan30
-rw-r--r--HutchTests/BuildTaskTests.swift27
-rw-r--r--HutchTests/GitObjectTests.swift40
-rw-r--r--HutchTests/HutchTests.swift16
-rw-r--r--HutchTests/ReadmeViewTests.swift29
-rw-r--r--HutchTests/RepositoryListViewModelTests.swift50
-rw-r--r--HutchTests/RepositorySettingsViewModelTests.swift104
-rw-r--r--HutchTests/SRHTClientTests.swift27
-rw-r--r--HutchTests/SettingsViewModelTests.swift39
-rw-r--r--HutchTests/SettingsViewTests.swift22
-rw-r--r--HutchTests/TicketDetailViewModelTests.swift30
-rw-r--r--HutchTests/TrackerListViewModelTests.swift16
-rw-r--r--TESTING.md28
33 files changed, 1165 insertions, 108 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8aab8bb..6368778 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -13,12 +13,25 @@
8B2F89682F69DEB900FC0253 /* SECURITY.md in Resources */ = {isa = PBXBuildFile; fileRef = 8B2F89652F69DEB900FC0253 /* SECURITY.md */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 8B4B28C92F6704280045FA19 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 8B4B28D02F6704280045FA19;
+ remoteInfo = Hutch;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXFileReference section */
8B2F89612F69DEA900FC0253 /* TODO.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = TODO.md; sourceTree = "<group>"; };
8B2F89632F69DEB900FC0253 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
8B2F89642F69DEB900FC0253 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
8B2F89652F69DEB900FC0253 /* SECURITY.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = SECURITY.md; sourceTree = "<group>"; };
8B4B28D12F6704280045FA19 /* Hutch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Hutch.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HutchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8B8182C22F6B742B000AE049 /* HutchTests */ = {isa = PBXFileReference; lastKnownFileType = folder; path = HutchTests; sourceTree = "<group>"; };
+ 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = HutchTests.xctestplan; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -40,6 +53,11 @@
path = Hutch;
sourceTree = "<group>";
};
+ 8B8182B82F6B73F3000AE049 /* HutchTests */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = HutchTests;
+ sourceTree = "<group>";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -50,17 +68,27 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8B8182B42F6B73F3000AE049 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
8B4B28C82F6704280045FA19 = {
isa = PBXGroup;
children = (
+ 8BDCA5272F6B76B20066AA29 /* HutchTests.xctestplan */,
+ 8B8182C22F6B742B000AE049 /* HutchTests */,
8B2F89632F69DEB900FC0253 /* LICENSE */,
8B2F89642F69DEB900FC0253 /* README.md */,
8B2F89652F69DEB900FC0253 /* SECURITY.md */,
8B2F89612F69DEA900FC0253 /* TODO.md */,
8B4B28D32F6704280045FA19 /* Hutch */,
+ 8B8182B82F6B73F3000AE049 /* HutchTests */,
8B4B28D22F6704280045FA19 /* Products */,
);
sourceTree = "<group>";
@@ -69,6 +97,7 @@
isa = PBXGroup;
children = (
8B4B28D12F6704280045FA19 /* Hutch.app */,
+ 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -98,6 +127,29 @@
productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
productType = "com.apple.product-type.application";
};
+ 8B8182B62F6B73F3000AE049 /* HutchTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */;
+ buildPhases = (
+ 8B8182B32F6B73F3000AE049 /* Sources */,
+ 8B8182B42F6B73F3000AE049 /* Frameworks */,
+ 8B8182B52F6B73F3000AE049 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 8B8182B82F6B73F3000AE049 /* HutchTests */,
+ );
+ name = HutchTests;
+ packageProductDependencies = (
+ );
+ productName = HutchTests;
+ productReference = 8B8182B72F6B73F3000AE049 /* HutchTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -111,6 +163,10 @@
8B4B28D02F6704280045FA19 = {
CreatedOnToolsVersion = 26.3;
};
+ 8B8182B62F6B73F3000AE049 = {
+ CreatedOnToolsVersion = 26.3;
+ TestTargetID = 8B4B28D02F6704280045FA19;
+ };
};
};
buildConfigurationList = 8B4B28CC2F6704280045FA19 /* Build configuration list for PBXProject "Hutch" */;
@@ -128,6 +184,7 @@
projectRoot = "";
targets = (
8B4B28D02F6704280045FA19 /* Hutch */,
+ 8B8182B62F6B73F3000AE049 /* HutchTests */,
);
};
/* End PBXProject section */
@@ -144,6 +201,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8B8182B52F6B73F3000AE049 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -154,8 +218,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8B8182B32F6B73F3000AE049 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ 8B8182BC2F6B73F3000AE049 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 8B4B28D02F6704280045FA19 /* Hutch */;
+ targetProxy = 8B8182BB2F6B73F3000AE049 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
8B4B28DA2F6704290045FA19 /* Debug */ = {
isa = XCBuildConfiguration;
@@ -284,7 +363,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -301,7 +380,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -320,7 +399,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -337,7 +416,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -350,6 +429,48 @@
};
name = Release;
};
+ 8B8182BD2F6B73F3000AE049 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch";
+ };
+ name = Debug;
+ };
+ 8B8182BE2F6B73F3000AE049 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.HutchTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = NO;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Hutch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Hutch";
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -371,6 +492,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 8B8182BF2F6B73F3000AE049 /* Build configuration list for PBXNativeTarget "HutchTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8B8182BD2F6B73F3000AE049 /* Debug */,
+ 8B8182BE2F6B73F3000AE049 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
new file mode 100644
index 0000000..4bb83be
--- /dev/null
+++ b/Hutch.xcodeproj/xcshareddata/xcschemes/Hutch.xcscheme
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+ LastUpgradeVersion = "2630"
+ version = "1.7">
+ <BuildAction
+ parallelizeBuildables = "YES"
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
+ <BuildActionEntries>
+ <BuildActionEntry
+ buildForTesting = "YES"
+ buildForRunning = "YES"
+ buildForProfiling = "YES"
+ buildForArchiving = "YES"
+ buildForAnalyzing = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B4B28D02F6704280045FA19"
+ BuildableName = "Hutch.app"
+ BlueprintName = "Hutch"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </BuildActionEntry>
+ </BuildActionEntries>
+ </BuildAction>
+ <TestAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ shouldUseLaunchSchemeArgsEnv = "YES">
+ <TestPlans>
+ <TestPlanReference
+ reference = "container:HutchTests"
+ default = "YES">
+ </TestPlanReference>
+ </TestPlans>
+ <Testables>
+ <TestableReference
+ skipped = "NO"
+ parallelizable = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B8182B62F6B73F3000AE049"
+ BuildableName = "HutchTests.xctest"
+ BlueprintName = "HutchTests"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ </Testables>
+ </TestAction>
+ <LaunchAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ launchStyle = "0"
+ useCustomWorkingDirectory = "NO"
+ ignoresPersistentStateOnLaunch = "NO"
+ debugDocumentVersioning = "YES"
+ debugServiceExtension = "internal"
+ allowLocationSimulation = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B4B28D02F6704280045FA19"
+ BuildableName = "Hutch.app"
+ BlueprintName = "Hutch"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ </LaunchAction>
+ <ProfileAction
+ buildConfiguration = "Release"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ savedToolIdentifier = ""
+ useCustomWorkingDirectory = "NO"
+ debugDocumentVersioning = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B4B28D02F6704280045FA19"
+ BuildableName = "Hutch.app"
+ BlueprintName = "Hutch"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ </ProfileAction>
+ <AnalyzeAction
+ buildConfiguration = "Debug">
+ </AnalyzeAction>
+ <ArchiveAction
+ buildConfiguration = "Release"
+ revealArchiveInOrganizer = "YES">
+ </ArchiveAction>
+</Scheme>
diff --git a/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme
new file mode 100644
index 0000000..2cbf63c
--- /dev/null
+++ b/Hutch.xcodeproj/xcshareddata/xcschemes/HutchTests.xcscheme
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+ LastUpgradeVersion = "2630"
+ version = "1.7">
+ <BuildAction
+ parallelizeBuildables = "YES"
+ buildImplicitDependencies = "YES"
+ buildArchitectures = "Automatic">
+ </BuildAction>
+ <TestAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ shouldUseLaunchSchemeArgsEnv = "YES">
+ <TestPlans>
+ <TestPlanReference
+ reference = "container:HutchTests.xctestplan"
+ default = "YES">
+ </TestPlanReference>
+ </TestPlans>
+ <Testables>
+ <TestableReference
+ skipped = "NO"
+ parallelizable = "YES">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B8182B62F6B73F3000AE049"
+ BuildableName = "HutchTests.xctest"
+ BlueprintName = "HutchTests"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </TestableReference>
+ </Testables>
+ </TestAction>
+ <LaunchAction
+ buildConfiguration = "Debug"
+ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+ launchStyle = "0"
+ useCustomWorkingDirectory = "NO"
+ ignoresPersistentStateOnLaunch = "NO"
+ debugDocumentVersioning = "YES"
+ debugServiceExtension = "internal"
+ allowLocationSimulation = "YES">
+ <BuildableProductRunnable
+ runnableDebuggingMode = "0">
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B4B28D02F6704280045FA19"
+ BuildableName = "Hutch.app"
+ BlueprintName = "Hutch"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </BuildableProductRunnable>
+ </LaunchAction>
+ <ProfileAction
+ buildConfiguration = "Release"
+ shouldUseLaunchSchemeArgsEnv = "YES"
+ savedToolIdentifier = ""
+ useCustomWorkingDirectory = "NO"
+ debugDocumentVersioning = "YES">
+ <MacroExpansion>
+ <BuildableReference
+ BuildableIdentifier = "primary"
+ BlueprintIdentifier = "8B4B28D02F6704280045FA19"
+ BuildableName = "Hutch.app"
+ BlueprintName = "Hutch"
+ ReferencedContainer = "container:Hutch.xcodeproj">
+ </BuildableReference>
+ </MacroExpansion>
+ </ProfileAction>
+ <AnalyzeAction
+ buildConfiguration = "Debug">
+ </AnalyzeAction>
+ <ArchiveAction
+ buildConfiguration = "Release"
+ revealArchiveInOrganizer = "YES">
+ </ArchiveAction>
+</Scheme>
diff --git a/Hutch/Models/Builds.swift b/Hutch/Models/Builds.swift
index 6694c35..b0c0e8d 100644
--- a/Hutch/Models/Builds.swift
+++ b/Hutch/Models/Builds.swift
@@ -34,10 +34,33 @@ enum TaskStatus: String, Codable, Sendable {
/// A single task within a build job.
struct BuildTask: Codable, Sendable, Identifiable {
- var id: String { name }
+ private(set) var ordinal: Int?
let name: String
let status: TaskStatus
let log: BuildLog?
+
+ var id: String {
+ if let ordinal {
+ return "\(ordinal):\(name)"
+ }
+ return [name, log?.fullURL, status.rawValue]
+ .compactMap { $0 }
+ .joined(separator: "::")
+ }
+
+ var logCacheKey: String {
+ id
+ }
+
+ func withOrdinal(_ ordinal: Int) -> BuildTask {
+ var task = self
+ task.ordinal = ordinal
+ return task
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case name, status, log
+ }
}
// MARK: - Job Summary (for list view)
@@ -90,7 +113,7 @@ struct JobDetail: Codable, Sendable {
let visibility: Visibility?
let image: String?
let manifest: String?
- let tasks: [BuildTask]
+ var tasks: [BuildTask]
let log: BuildLog?
let owner: Entity
}
diff --git a/Hutch/Models/Git.swift b/Hutch/Models/Git.swift
index 19ec31b..905822e 100644
--- a/Hutch/Models/Git.swift
+++ b/Hutch/Models/Git.swift
@@ -113,7 +113,7 @@ struct GitTree: Codable, Sendable {
struct GitTextBlob: Codable, Sendable {
let id: String?
let shortId: String?
- let text: String
+ let text: String?
let size: Int?
}
@@ -134,14 +134,16 @@ struct GitTreeEntryPage: Codable, Sendable {
extension GitObject: Codable {
private enum CodingKeys: String, CodingKey {
case type, id, shortId, entries, text, size, content
+ case typename = "__typename"
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decodeIfPresent(String.self, forKey: .type)
+ let typename = try container.decodeIfPresent(String.self, forKey: .typename)
- switch type {
- case "TREE":
+ switch type ?? typename {
+ case "TREE", "Tree":
let tree = GitTree(
id: try container.decodeIfPresent(String.self, forKey: .id),
shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
@@ -149,13 +151,12 @@ extension GitObject: Codable {
)
self = .tree(tree)
- case "BLOB":
- // TextBlob has a "text" key; BinaryBlob does not
- if container.contains(.text) {
+ case "BLOB", "TextBlob", "BinaryBlob":
+ if typename == "TextBlob" || container.contains(.text) {
let blob = GitTextBlob(
id: try container.decodeIfPresent(String.self, forKey: .id),
shortId: try container.decodeIfPresent(String.self, forKey: .shortId),
- text: try container.decode(String.self, forKey: .text),
+ text: try container.decodeIfPresent(String.self, forKey: .text),
size: try container.decodeIfPresent(Int.self, forKey: .size)
)
self = .textBlob(blob)
@@ -184,12 +185,14 @@ extension GitObject: Codable {
try container.encodeIfPresent(tree.entries, forKey: .entries)
case .textBlob(let blob):
try container.encode("BLOB", forKey: .type)
+ try container.encode("TextBlob", forKey: .typename)
try container.encodeIfPresent(blob.id, forKey: .id)
try container.encodeIfPresent(blob.shortId, forKey: .shortId)
- try container.encode(blob.text, forKey: .text)
+ try container.encodeIfPresent(blob.text, forKey: .text)
try container.encodeIfPresent(blob.size, forKey: .size)
case .binaryBlob(let blob):
try container.encode("BLOB", forKey: .type)
+ try container.encode("BinaryBlob", forKey: .typename)
try container.encodeIfPresent(blob.id, forKey: .id)
try container.encodeIfPresent(blob.shortId, forKey: .shortId)
try container.encodeIfPresent(blob.size, forKey: .size)
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index a24fe24..6032cab 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -435,6 +435,9 @@ final class SRHTClient: Sendable {
guard let token = _token.withLock({ $0 }), !token.isEmpty else {
throw SRHTError.unauthorized
}
+ guard Self.isTrustedAuthenticatedTextURL(url) else {
+ throw SRHTError.invalidAuthenticatedURL(url)
+ }
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
@@ -512,6 +515,17 @@ final class SRHTClient: Sendable {
// MARK: - Data Helper
+private extension SRHTClient {
+ static func isTrustedAuthenticatedTextURL(_ url: URL) -> Bool {
+ guard url.scheme?.localizedCaseInsensitiveCompare("https") == .orderedSame,
+ let host = url.host?.lowercased() else {
+ return false
+ }
+
+ return host.hasSuffix(".sr.ht")
+ }
+}
+
private extension Data {
mutating func append(_ string: String) {
if let data = string.data(using: .utf8) {
diff --git a/Hutch/Networking/SRHTError.swift b/Hutch/Networking/SRHTError.swift
index f148c00..f2da408 100644
--- a/Hutch/Networking/SRHTError.swift
+++ b/Hutch/Networking/SRHTError.swift
@@ -6,6 +6,8 @@ enum SRHTError: LocalizedError, Sendable {
case graphQLErrors([GraphQLError])
/// The HTTP response had a non-2xx status code.
case httpError(Int)
+ /// The client refused to send credentials to an unexpected URL.
+ case invalidAuthenticatedURL(URL)
/// The response data could not be decoded.
case decodingError(any Error)
/// A networking error from URLSession (timeout, DNS, connectivity, etc.).
@@ -20,6 +22,8 @@ enum SRHTError: LocalizedError, Sendable {
return "GraphQL error: \(messages)"
case .httpError(let code):
return "Server returned HTTP \(code)."
+ case .invalidAuthenticatedURL(let url):
+ return "Refused to authenticate request to unexpected URL: \(url.absoluteString)"
case .decodingError(let error):
return "Failed to decode response: \(error.localizedDescription)"
case .networkError(let error):
diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift
index 977cd87..0479f76 100644
--- a/Hutch/Views/Builds/BuildDetailView.swift
+++ b/Hutch/Views/Builds/BuildDetailView.swift
@@ -312,13 +312,13 @@ private struct TaskLogSection: View {
var body: some View {
DisclosureGroup(isExpanded: $isExpanded) {
- if viewModel.loadingTaskLogs.contains(task.name) {
+ if viewModel.loadingTaskLogs.contains(task.logCacheKey) {
HStack {
Spacer()
ProgressView("Loading log…")
Spacer()
}
- } else if let logText = viewModel.taskLogs[task.name] {
+ } else if let logText = viewModel.taskLogs[task.logCacheKey] {
ScrollView(.horizontal, showsIndicators: false) {
Text(logText)
.font(.caption2.monospaced())
diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift
index c14e47b..1768e47 100644
--- a/Hutch/Views/Builds/BuildDetailViewModel.swift
+++ b/Hutch/Views/Builds/BuildDetailViewModel.swift
@@ -104,7 +104,11 @@ final class BuildDetailViewModel {
variables: ["id": jobId],
responseType: JobDetailResponse.self
)
- job = result.job
+ var loadedJob = result.job
+ loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
+ task.withOrdinal(index)
+ }
+ job = loadedJob
} catch {
self.error = error.localizedDescription
}
@@ -113,19 +117,20 @@ final class BuildDetailViewModel {
}
func loadTaskLog(task: BuildTask) async {
+ let cacheKey = task.logCacheKey
guard let log = task.log,
let logURL = URL(string: log.fullURL),
- !loadingTaskLogs.contains(task.name),
- taskLogs[task.name] == nil else { return }
- loadingTaskLogs.insert(task.name)
+ !loadingTaskLogs.contains(cacheKey),
+ taskLogs[cacheKey] == nil else { return }
+ loadingTaskLogs.insert(cacheKey)
do {
- taskLogs[task.name] = try await client.fetchText(url: logURL)
+ taskLogs[cacheKey] = try await client.fetchText(url: logURL)
} catch {
self.error = error.localizedDescription
}
- loadingTaskLogs.remove(task.name)
+ loadingTaskLogs.remove(cacheKey)
}
func cancelJob() async {
diff --git a/Hutch/Views/Repositories/FileTreeView.swift b/Hutch/Views/Repositories/FileTreeView.swift
index 3750adb..45fe5b6 100644
--- a/Hutch/Views/Repositories/FileTreeView.swift
+++ b/Hutch/Views/Repositories/FileTreeView.swift
@@ -235,7 +235,7 @@ private struct FileTreeContentView: View {
GeometryReader { geometry in
ScrollView([.vertical, .horizontal]) {
- Text(blob.text)
+ Text(blob.text ?? "")
.font(.system(.body, design: .monospaced))
.multilineTextAlignment(.leading)
.fixedSize(horizontal: true, vertical: false)
diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift
index f270779..a9c86b4 100644
--- a/Hutch/Views/Repositories/FileTreeViewModel.swift
+++ b/Hutch/Views/Repositories/FileTreeViewModel.swift
@@ -96,6 +96,7 @@ final class FileTreeViewModel {
mode
object {
type
+ __typename
id
shortId
... on Tree {
@@ -110,12 +111,10 @@ final class FileTreeViewModel {
}
}
... on TextBlob {
- text
size
}
... on BinaryBlob {
size
- content
}
}
}
@@ -142,6 +141,7 @@ final class FileTreeViewModel {
mode
object {
type
+ __typename
id
shortId
... on Tree {
@@ -156,12 +156,10 @@ final class FileTreeViewModel {
}
}
... on TextBlob {
- text
size
}
... on BinaryBlob {
size
- content
}
}
}
@@ -187,6 +185,7 @@ final class FileTreeViewModel {
mode
object {
type
+ __typename
id
shortId
... on Tree {
@@ -201,12 +200,10 @@ final class FileTreeViewModel {
}
}
... on TextBlob {
- text
size
}
... on BinaryBlob {
size
- content
}
}
}
@@ -321,16 +318,23 @@ final class FileTreeViewModel {
// Otherwise fetch the subtree (handles pagination)
await loadSubtree(name: entry.name, treeId: objectSHA)
- case .textBlob:
- viewingEntry = entry
- viewingObject = object
+ case .textBlob(let blob):
+ if blob.text != nil {
+ viewingEntry = entry
+ viewingObject = object
+ } else if let blobId = blob.id {
+ await loadBlob(entry: entry, blobId: blobId)
+ }
case .binaryBlob(let blob):
- if blob.content != nil || blob.size != nil {
+ if blob.content != nil {
viewingEntry = entry
viewingObject = object
} else if let blobId = blob.id {
await loadBlob(entry: entry, blobId: blobId)
+ } else {
+ viewingEntry = entry
+ viewingObject = object
}
case .unknown:
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index b53885e..576adfc 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
let alt = nsText.substring(with: match.range(at: 1))
let source = nsText.substring(with: match.range(at: 2))
let resolvedSource = imageURLResolver?(source) ?? source
- return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
+ guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
+ return escapeHTML(alt)
+ }
+ return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"#
}
// Links: [text](url)
- result = result.replacingOccurrences(
- of: #"\[([^\]]+)\]\(([^)]+)\)"#,
- with: #"<a href="$2">$1</a>"#,
- options: .regularExpression
- )
+ result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
+ let label = nsText.substring(with: match.range(at: 1))
+ let rawURL = nsText.substring(with: match.range(at: 2))
+ guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
+ return label
+ }
+ return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
+ }
// Bold: **text**
result = result.replacingOccurrences(
of: #"\*\*(.+?)\*\*"#,
@@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
) {
return imageHTML
}
- return #"<a href="\#(url)">\#(label)</a>"#
+ guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
+ return label
+ }
+ return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
}
result = protectMatches(
in: result,
@@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
) {
return imageHTML
}
- return #"<a href="\#(url)">\#(url)</a>"#
+ guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
+ return url
+ }
+ return #"<a href="\#(sanitizedURL)">\#(url)</a>"#
}
result = protectMatches(
in: result,
@@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String {
escapeHTML(text).replacingOccurrences(of: "'", with: "&#39;")
}
+nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? {
+ sanitizeReadmeURLString(
+ rawURL,
+ allowedSchemes: ["http", "https", "mailto"],
+ allowsFragmentOnly: true
+ )
+}
+
+nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? {
+ sanitizeReadmeURLString(
+ rawURL,
+ allowedSchemes: ["http", "https"],
+ allowsFragmentOnly: false
+ )
+}
+
+nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool {
+ guard let scheme = url.scheme?.lowercased() else {
+ return false
+ }
+ if scheme == "about" || scheme == "data" {
+ return true
+ }
+ guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else {
+ return false
+ }
+ return sanitizedURL == escapeHTMLAttribute(url.absoluteString)
+}
+
+nonisolated private func sanitizeReadmeURLString(
+ _ rawURL: String,
+ allowedSchemes: Set<String>,
+ allowsFragmentOnly: Bool
+) -> String? {
+ let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedURL.isEmpty else { return nil }
+
+ if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 {
+ return escapeHTMLAttribute(trimmedURL)
+ }
+
+ guard let components = URLComponents(string: trimmedURL),
+ let scheme = components.scheme?.lowercased(),
+ allowedSchemes.contains(scheme),
+ let sanitizedURL = components.url?.absoluteString else {
+ return nil
+ }
+
+ return escapeHTMLAttribute(sanitizedURL)
+}
+
nonisolated private func isOrgTableLine(_ line: String) -> Bool {
line.hasPrefix("|") && line.hasSuffix("|")
}
@@ -899,6 +962,7 @@ struct HTMLWebView: View {
let html: String
let colorScheme: ColorScheme
var style: HTMLWebViewStyle = .readme
+ @Environment(\.openURL) private var openURL
@State private var contentHeight: CGFloat = 1
@State private var loadError: String?
@State private var reloadToken = 0
@@ -921,6 +985,7 @@ struct HTMLWebView: View {
html: html,
colorScheme: colorScheme,
style: style,
+ openURL: openURL,
dynamicHeight: $contentHeight,
loadError: $loadError,
reloadToken: reloadToken
@@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
let html: String
let colorScheme: ColorScheme
let style: HTMLWebViewStyle
+ let openURL: OpenURLAction
@Binding var dynamicHeight: CGFloat
@Binding var loadError: String?
let reloadToken: Int
@@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
- config.defaultWebpagePreferences.allowsContentJavaScript = true
+ config.defaultWebpagePreferences.allowsContentJavaScript = false
config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore
let webView = WKWebView(frame: .zero, configuration: config)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.clipsToBounds = false
+ webView.allowsLinkPreview = false
webView.scrollView.isScrollEnabled = false
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.scrollView.clipsToBounds = false
@@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
handleLoadFailure(error)
}
+ func webView(
+ _ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
+ ) {
+ guard let requestURL = navigationAction.request.url else {
+ decisionHandler(.allow)
+ return
+ }
+
+ if navigationAction.navigationType == .linkActivated {
+ if isAllowedReadmeNavigationURL(requestURL) {
+ parent.openURL(requestURL)
+ }
+ decisionHandler(.cancel)
+ return
+ }
+
+ if isAllowedReadmeNavigationURL(requestURL) {
+ decisionHandler(.allow)
+ } else {
+ decisionHandler(.cancel)
+ }
+ }
+
private func handleLoadFailure(_ error: Error) {
let nsError = error as NSError
guard nsError.code != NSURLErrorCancelled else { return }
@@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc
}
private func updateHeight(for webView: WKWebView) {
- let script = """
- Math.max(
- document.body.scrollHeight,
- document.body.offsetHeight,
- document.documentElement.scrollHeight,
- document.documentElement.offsetHeight,
- Math.ceil(document.body.getBoundingClientRect().height),
- Math.ceil(document.documentElement.getBoundingClientRect().height)
- )
- """
-
- webView.evaluateJavaScript(script) { [weak self] result, _ in
- guard let value = result as? Double, value > 0 else { return }
- let height = ceil(value) + 4
- DispatchQueue.main.async {
- guard let self else { return }
- if let html = self.lastHTML {
- Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
- }
- if abs(self.parent.dynamicHeight - height) > 0.5 {
- self.parent.dynamicHeight = height
- }
+ webView.layoutIfNeeded()
+ let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4
+ guard height > 0 else { return }
+ DispatchQueue.main.async {
+ if let html = self.lastHTML {
+ Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString)
+ }
+ if abs(self.parent.dynamicHeight - height) > 0.5 {
+ self.parent.dynamicHeight = height
}
}
}
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index bba0108..6b63203 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -38,10 +38,13 @@ final class RepositoryListViewModel {
private(set) var hasMore = false
private(set) var isSearching = false
private(set) var isCreatingRepository = false
+ private(set) var hasLoadedSearchIndex = false
+ private var searchIndex: [RepositorySummary] = []
private let client: SRHTClient
private static let gitCacheKey = "git.repositories"
private static let hgCacheKey = "hg.repositories"
+ private static let minimumRemoteSearchLength = 3
init(client: SRHTClient) {
self.client = client
@@ -147,22 +150,23 @@ final class RepositoryListViewModel {
do {
var filteredResults: [RepositorySummary]
-
+
if isSearch {
- // For search queries, fetch all repositories from both services.
- filteredResults = try await fetchAllRepositories()
-
- // Perform client-side filtering
- let lowercasedQuery = query.lowercased()
- filteredResults = filteredResults.filter { repo in
- repo.name.lowercased().contains(lowercasedQuery) ||
- repo.description?.lowercased().contains(lowercasedQuery) ?? false
+ if hasLoadedSearchIndex || repositories.isEmpty == false {
+ filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
+ } else if Self.shouldRefreshSearchIndex(for: query) {
+ let repositories = try await fetchAllRepositories(useCache: true)
+ updateSearchIndex(with: repositories)
+ filteredResults = Self.filterRepositories(repositoriesForSearchIndex, matching: query)
+ } else {
+ filteredResults = []
}
} else {
let repositories = try await fetchAllRepositories(useCache: true)
+ updateSearchIndex(with: repositories)
filteredResults = repositories
}
-
+
repositories = filteredResults.sorted(by: repositorySortOrder)
} catch {
// Only show error if we have no cached data to fall back on
@@ -240,6 +244,7 @@ final class RepositoryListViewModel {
repository = result.createRepository.repositorySummary(service: .hg)
}
repositories.insert(repository, at: 0)
+ insertIntoSearchIndex(repository)
return repository
} catch {
self.error = repositoryCreationErrorMessage(for: error)
@@ -371,6 +376,10 @@ final class RepositoryListViewModel {
let branch: String
}
+ private var repositoriesForSearchIndex: [RepositorySummary] {
+ searchIndex
+ }
+
private func fetchPage(
service: SRHTService,
cursor: String?,
@@ -502,7 +511,9 @@ final class RepositoryListViewModel {
return []
}
if !cachedRepositories.isEmpty {
- repositories = cachedRepositories.sorted(by: repositorySortOrder)
+ let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
+ repositories = sortedRepositories
+ updateSearchIndex(with: sortedRepositories)
}
}
@@ -545,4 +556,37 @@ final class RepositoryListViewModel {
}
return lhs.updated > rhs.updated
}
+
+ private func updateSearchIndex(with repositories: [RepositorySummary]) {
+ searchIndex = repositories.sorted(by: repositorySortOrder)
+ hasLoadedSearchIndex = !searchIndex.isEmpty
+ }
+
+ private func insertIntoSearchIndex(_ repository: RepositorySummary) {
+ let updatedRepositories = (repositoriesForSearchIndex + [repository])
+ .uniqued(on: \.id)
+ .sorted(by: repositorySortOrder)
+ updateSearchIndex(with: updatedRepositories)
+ }
+
+ static func shouldRefreshSearchIndex(for query: String) -> Bool {
+ query.trimmingCharacters(in: .whitespacesAndNewlines).count >= Self.minimumRemoteSearchLength
+ }
+
+ static func filterRepositories(_ repositories: [RepositorySummary], matching query: String) -> [RepositorySummary] {
+ let lowercasedQuery = query.lowercased()
+ return repositories.filter { repo in
+ repo.name.lowercased().contains(lowercasedQuery) ||
+ repo.description?.lowercased().contains(lowercasedQuery) ?? false
+ }
+ }
+}
+
+private extension Array {
+ func uniqued<ID: Hashable>(on keyPath: KeyPath<Element, ID>) -> [Element] {
+ var seenIDs: Set<ID> = []
+ return filter { element in
+ seenIDs.insert(element[keyPath: keyPath]).inserted
+ }
+ }
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index d6f3300..71d6ab6 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -6,12 +6,20 @@ private struct UpdateRepoResponse: Decodable, Sendable {
let updateRepository: UpdatedRepo
}
+private struct UpdateRepoInfoResponse: Decodable, Sendable {
+ let updateRepository: UpdatedRepoInfo
+}
+
private struct UpdatedRepo: Decodable, Sendable {
- let id: Int
- let rid: String
+ let id: Int?
+ let rid: String?
let name: String
let description: String?
- let visibility: Visibility
+ let visibility: Visibility?
+}
+
+private struct UpdatedRepoInfo: Decodable, Sendable {
+ let id: Int
}
private struct ACLResponse: Decodable, Sendable {
@@ -71,6 +79,7 @@ final class RepositorySettingsViewModel {
var editedDescription: String
var editedVisibility: Visibility
var editedHead: String
+ private let originalEditedHead: String
var isSavingInfo = false
// MARK: - Rename fields
@@ -116,11 +125,14 @@ final class RepositorySettingsViewModel {
self.branches = branches
// Extract branch name from HEAD reference
+ let initialEditedHead: String
if let head = repository.head?.name {
- self.editedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
+ initialEditedHead = head.replacingOccurrences(of: "refs/heads/", with: "")
} else {
- self.editedHead = "main"
+ initialEditedHead = "main"
}
+ self.editedHead = initialEditedHead
+ self.originalEditedHead = initialEditedHead
}
// MARK: - Update Repository Info
@@ -133,22 +145,32 @@ final class RepositorySettingsViewModel {
}
"""
+ private static let updateRepoInfoMutation = """
+ mutation updateRepository($id: Int!, $input: RepoInput!) {
+ updateRepository(id: $id, input: $input) {
+ id
+ }
+ }
+ """
+
func saveInfo() async {
isSavingInfo = true
defer { isSavingInfo = false }
error = nil
do {
- let input: [String: any Sendable] = [
+ var input: [String: any Sendable] = [
"description": editedDescription,
- "visibility": editedVisibility.rawValue,
- "HEAD": editedHead
+ "visibility": editedVisibility.rawValue
]
+ if let headReference = selectedHeadReferenceForSave() {
+ input["HEAD"] = headReference
+ }
_ = try await client.execute(
service: service,
- query: Self.updateRepoMutation,
+ query: Self.updateRepoInfoMutation,
variables: ["id": repositoryId, "input": input],
- responseType: UpdateRepoResponse.self
+ responseType: UpdateRepoInfoResponse.self
)
} catch {
self.error = error.localizedDescription
@@ -238,8 +260,9 @@ final class RepositorySettingsViewModel {
}
func addACL() async {
- let entity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !entity.isEmpty else { return }
+ let rawEntity = newACLEntity.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !rawEntity.isEmpty else { return }
+ let entity = Self.gitCanonicalEntity(from: rawEntity)
isAddingACL = true
defer { isAddingACL = false }
error = nil
@@ -267,6 +290,33 @@ final class RepositorySettingsViewModel {
}
}
+ static func gitCanonicalEntity(from input: String) -> String {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+ let username = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
+ return "~\(username)"
+ }
+
+ static func gitHeadReference(from input: String) -> String {
+ let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return trimmed }
+ if trimmed.hasPrefix("refs/") {
+ return trimmed
+ }
+ return "refs/heads/\(trimmed)"
+ }
+
+ func selectedHeadReferenceForSave() -> String? {
+ let normalizedEditedHead = editedHead.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard normalizedEditedHead != originalEditedHead else {
+ return nil
+ }
+
+ return branches.first {
+ $0.name.replacingOccurrences(of: "refs/heads/", with: "") == normalizedEditedHead
+ }?.name
+ }
+
func deleteACL(_ entry: ACLEntry) async {
isDeletingACL = true
defer { isDeletingACL = false }
diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift
index 5bbded8..9b7d41f 100644
--- a/Hutch/Views/Settings/SettingsView.swift
+++ b/Hutch/Views/Settings/SettingsView.swift
@@ -1,6 +1,10 @@
import PhotosUI
import SwiftUI
+private let settingsBioMarkdownOptions = AttributedString.MarkdownParsingOptions(
+ interpretedSyntax: .inlineOnlyPreservingWhitespace
+)
+
struct SettingsView: View {
@Environment(AppState.self) private var appState
@Environment(\.colorScheme) private var colorScheme
@@ -163,13 +167,7 @@ struct SettingsView: View {
Text("Bio")
.font(.caption)
.foregroundStyle(.secondary)
- RenderedMarkupContentView(
- content: .markdown(bio),
- readmePath: nil,
- colorScheme: colorScheme,
- ownerCanonicalName: "",
- repositoryName: ""
- )
+ SettingsBioView(markdown: bio)
}
}
@@ -424,6 +422,27 @@ struct SettingsView: View {
}
}
+private struct SettingsBioView: View {
+ let markdown: String
+
+ var body: some View {
+ Text(settingsBioAttributedString(markdown))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .tint(.accentColor)
+ .textSelection(.enabled)
+ }
+}
+
+func settingsBioAttributedString(_ markdown: String) -> AttributedString {
+ guard let attributed = try? AttributedString(
+ markdown: markdown,
+ options: settingsBioMarkdownOptions
+ ) else {
+ return AttributedString(markdown)
+ }
+ return attributed
+}
+
// MARK: - Edit Profile Sheet
private struct EditProfileSheet: View {
@@ -695,7 +714,7 @@ private struct AboutView: View {
Link(destination: URL(string: "https://man.sr.ht")!) {
SwiftUI.Label("SourceHut Manuals", systemImage: "book")
}
- Link(destination: URL(string: "https://git.sr.ht/~ccleberg/Hutch")!) {
+ Link(destination: URL(string: "https://sr.ht/~ccleberg/Hutch")!) {
SwiftUI.Label("Project Repository", systemImage: "folder")
}
}
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index 204afbf..1cc5426 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -24,7 +24,7 @@ private struct CreateSSHKeyResponse: Decodable, Sendable {
}
private struct DeleteSSHKeyResponse: Decodable, Sendable {
- let deleteSSHKey: DeleteResult
+ let deleteSSHKey: DeleteResult?
}
private struct CreatePGPKeyResponse: Decodable, Sendable {
@@ -32,7 +32,7 @@ private struct CreatePGPKeyResponse: Decodable, Sendable {
}
private struct DeletePGPKeyResponse: Decodable, Sendable {
- let deletePGPKey: DeleteResult
+ let deletePGPKey: DeleteResult?
}
private struct DeleteResult: Decodable, Sendable {
diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift
index 6b2be4f..f553bd8 100644
--- a/Hutch/Views/Tickets/TicketDetailView.swift
+++ b/Hutch/Views/Tickets/TicketDetailView.swift
@@ -71,10 +71,7 @@ struct TicketDetailView: View {
if ticket.status == .resolved {
Button {
Task {
- await viewModel.updateStatus(
- status: .reported,
- resolution: .unresolved
- )
+ await viewModel.updateStatus(status: .reported)
}
} label: {
SwiftUI.Label("Reopen", systemImage: "arrow.uturn.backward")
diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift
index 70eaf0a..90e33aa 100644
--- a/Hutch/Views/Tickets/TicketDetailViewModel.swift
+++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift
@@ -130,6 +130,19 @@ final class TicketDetailViewModel {
return lhs.created < rhs.created
}
+ static func statusUpdateInput(
+ status: TicketStatus,
+ resolution: TicketResolution?
+ ) -> [String: any Sendable] {
+ var input: [String: any Sendable] = [
+ "status": status.rawValue
+ ]
+ if status == .resolved, let resolution {
+ input["resolution"] = resolution.rawValue
+ }
+ return input
+ }
+
init(ownerUsername: String, trackerName: String, trackerId: Int, trackerRid: String, ticketId: Int, client: SRHTClient) {
self.ownerUsername = ownerUsername
self.trackerName = trackerName
@@ -360,16 +373,13 @@ final class TicketDetailViewModel {
// MARK: - Ticket Actions
- func updateStatus(status: TicketStatus, resolution: TicketResolution) async {
+ func updateStatus(status: TicketStatus, resolution: TicketResolution? = nil) async {
guard !isPerformingAction else { return }
isPerformingAction = true
error = nil
do {
- let input: [String: any Sendable] = [
- "status": status.rawValue,
- "resolution": resolution.rawValue
- ]
+ let input = Self.statusUpdateInput(status: status, resolution: resolution)
_ = try await client.execute(
service: .todo,
query: Self.updateStatusMutation,
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index d16246b..ee253ec 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -124,10 +124,17 @@ private struct CreateTrackerSheet: View {
let onCreated: (TrackerSummary) -> Void
@Environment(\.dismiss) private var dismiss
+ @Bindable var viewModelBindable: TrackerListViewModel
@State private var name = ""
@State private var description = ""
@State private var visibility: Visibility = .public
+ init(viewModel: TrackerListViewModel, onCreated: @escaping (TrackerSummary) -> Void) {
+ self.viewModel = viewModel
+ self._viewModelBindable = Bindable(viewModel)
+ self.onCreated = onCreated
+ }
+
var body: some View {
NavigationStack {
Form {
@@ -143,12 +150,30 @@ private struct CreateTrackerSheet: View {
Text("Private").tag(Visibility.private)
}
}
+
+ if let error = viewModel.error {
+ Section {
+ Label {
+ Text(error)
+ } icon: {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ }
+ .foregroundStyle(.red)
+ }
+ }
}
.navigationTitle("New Tracker")
.navigationBarTitleDisplayMode(.inline)
+ .onDisappear {
+ viewModelBindable.error = nil
+ }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
- Button("Cancel") { dismiss() }
+ Button("Cancel") {
+ viewModelBindable.error = nil
+ dismiss()
+ }
}
ToolbarItem(placement: .confirmationAction) {
Button {
diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift
index 9704071..978a610 100644
--- a/Hutch/Views/Tickets/TrackerListViewModel.swift
+++ b/Hutch/Views/Tickets/TrackerListViewModel.swift
@@ -139,7 +139,7 @@ final class TrackerListViewModel {
trackers.insert(tracker, at: 0)
return tracker
} catch {
- self.error = "Couldn’t create the tracker. \(error.localizedDescription)"
+ self.error = trackerCreationErrorMessage(for: error)
return nil
}
}
@@ -163,4 +163,21 @@ final class TrackerListViewModel {
private struct CreateTrackerResponse: Decodable, Sendable {
let createTracker: TrackerSummary
}
+
+ private func trackerCreationErrorMessage(for error: Error) -> String {
+ let message: String
+
+ if let srhtError = error as? SRHTError {
+ switch srhtError {
+ case .graphQLErrors(let errors):
+ message = errors.map(\.message).joined(separator: "\n")
+ default:
+ message = srhtError.localizedDescription
+ }
+ } else {
+ message = error.localizedDescription
+ }
+
+ return "Couldn’t create the tracker. \(message)"
+ }
}
diff --git a/HutchTests.xctestplan b/HutchTests.xctestplan
new file mode 100644
index 0000000..8af3d1b
--- /dev/null
+++ b/HutchTests.xctestplan
@@ -0,0 +1,30 @@
+{
+ "configurations" : [
+ {
+ "id" : "06A589A5-80C7-41FE-911F-03518252CBDD",
+ "name" : "Test Scheme Action",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "performanceAntipatternCheckerEnabled" : true,
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:Hutch.xcodeproj",
+ "identifier" : "8B4B28D02F6704280045FA19",
+ "name" : "Hutch"
+ }
+ },
+ "testTargets" : [
+ {
+ "parallelizable" : true,
+ "target" : {
+ "containerPath" : "container:Hutch.xcodeproj",
+ "identifier" : "8B8182B62F6B73F3000AE049",
+ "name" : "HutchTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/HutchTests/BuildTaskTests.swift b/HutchTests/BuildTaskTests.swift
new file mode 100644
index 0000000..2e2a1e0
--- /dev/null
+++ b/HutchTests/BuildTaskTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct BuildTaskTests {
+
+ @Test
+ @MainActor
+ func duplicateTaskNamesProduceDistinctIDsAndLogCacheKeys() {
+ let firstTask = BuildTask(
+ name: "test",
+ status: .failed,
+ log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/1")
+ ).withOrdinal(0)
+
+ let secondTask = BuildTask(
+ name: "test",
+ status: .failed,
+ log: BuildLog(fullURL: "https://builds.sr.ht/job/1/task/2")
+ ).withOrdinal(1)
+
+ #expect(firstTask.id == "0:test")
+ #expect(secondTask.id == "1:test")
+ #expect(firstTask.id != secondTask.id)
+ #expect(firstTask.logCacheKey != secondTask.logCacheKey)
+ }
+}
diff --git a/HutchTests/GitObjectTests.swift b/HutchTests/GitObjectTests.swift
new file mode 100644
index 0000000..547c6f3
--- /dev/null
+++ b/HutchTests/GitObjectTests.swift
@@ -0,0 +1,40 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct GitObjectTests {
+
+ @Test
+ @MainActor
+ func decodesMetadataOnlyTextBlobUsingTypename() throws {
+ let data = Data(#"{"type":"BLOB","__typename":"TextBlob","id":"blob123","shortId":"blob123","size":42}"#.utf8)
+
+ let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+ guard case .textBlob(let textBlob) = blob else {
+ Issue.record("Expected metadata-only blob to decode as text blob.")
+ return
+ }
+
+ #expect(textBlob.id == "blob123")
+ #expect(textBlob.size == 42)
+ #expect(textBlob.text == nil)
+ }
+
+ @Test
+ @MainActor
+ func decodesMetadataOnlyBinaryBlobUsingTypename() throws {
+ let data = Data(#"{"type":"BLOB","__typename":"BinaryBlob","id":"blob456","shortId":"blob456","size":64}"#.utf8)
+
+ let blob = try JSONDecoder().decode(GitObject.self, from: data)
+
+ guard case .binaryBlob(let binaryBlob) = blob else {
+ Issue.record("Expected metadata-only blob to decode as binary blob.")
+ return
+ }
+
+ #expect(binaryBlob.id == "blob456")
+ #expect(binaryBlob.size == 64)
+ #expect(binaryBlob.content == nil)
+ }
+}
diff --git a/HutchTests/HutchTests.swift b/HutchTests/HutchTests.swift
new file mode 100644
index 0000000..c681efa
--- /dev/null
+++ b/HutchTests/HutchTests.swift
@@ -0,0 +1,16 @@
+//
+// HutchTests.swift
+// HutchTests
+//
+// Created by cmc on 2026-03-18.
+//
+
+import Testing
+
+struct HutchTests {
+
+ @Test func example() async throws {
+ // Write your test here and use APIs like `#expect(...)` to check expected conditions.
+ }
+
+}
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
new file mode 100644
index 0000000..9c3c019
--- /dev/null
+++ b/HutchTests/ReadmeViewTests.swift
@@ -0,0 +1,29 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct ReadmeViewTests {
+
+ @Test
+ func sanitizedReadmeLinkURLStringRejectsUnexpectedSchemes() {
+ #expect(sanitizedReadmeLinkURLString("javascript:alert(1)") == nil)
+ #expect(sanitizedReadmeLinkURLString("file:///tmp/readme") == nil)
+ #expect(sanitizedReadmeLinkURLString("data:text/html;base64,SGVsbG8=") == nil)
+ }
+
+ @Test
+ func processInlineDropsUnsafeMarkdownLinks() {
+ let rendered = processInline("[click me](javascript:alert)")
+
+ #expect(rendered == "click me")
+ #expect(!rendered.contains("href="))
+ #expect(!rendered.contains("javascript:"))
+ }
+
+ @Test
+ func sanitizedReadmeLinkURLStringAllowsExpectedDestinations() {
+ #expect(sanitizedReadmeLinkURLString("https://example.com/docs?q=1") == "https://example.com/docs?q=1")
+ #expect(sanitizedReadmeLinkURLString("mailto:[email protected]") == "mailto:[email protected]")
+ #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
+ }
+}
diff --git a/HutchTests/RepositoryListViewModelTests.swift b/HutchTests/RepositoryListViewModelTests.swift
new file mode 100644
index 0000000..d6e76bf
--- /dev/null
+++ b/HutchTests/RepositoryListViewModelTests.swift
@@ -0,0 +1,50 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct RepositoryListViewModelTests {
+
+ @Test
+ @MainActor
+ func searchIndexRefreshRequiresMinimumQueryLength() {
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "ab") == false)
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: "abc") == true)
+ #expect(RepositoryListViewModel.shouldRefreshSearchIndex(for: " abc ") == true)
+ }
+
+ @Test
+ @MainActor
+ func filterRepositoriesMatchesNameAndDescriptionLocally() {
+ let repositories = [
+ makeRepository(id: 1, service: .git, name: "Hutch", description: "SourceHut client"),
+ makeRepository(id: 2, service: .hg, name: "Mail", description: "patch queue"),
+ makeRepository(id: 3, service: .git, name: "Tree", description: nil)
+ ]
+
+ let nameMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "hut")
+ let descriptionMatches = RepositoryListViewModel.filterRepositories(repositories, matching: "patch")
+
+ #expect(nameMatches.map(\.id) == [1])
+ #expect(descriptionMatches.map(\.id) == [2])
+ }
+
+ @MainActor
+ private func makeRepository(
+ id: Int,
+ service: SRHTService,
+ name: String,
+ description: String?
+ ) -> RepositorySummary {
+ RepositorySummary(
+ id: id,
+ rid: "rid-\(id)",
+ service: service,
+ name: name,
+ description: description,
+ visibility: .public,
+ updated: Date(timeIntervalSince1970: TimeInterval(id)),
+ owner: Entity(canonicalName: "~owner"),
+ head: Reference(name: "main", target: nil)
+ )
+ }
+}
diff --git a/HutchTests/RepositorySettingsViewModelTests.swift b/HutchTests/RepositorySettingsViewModelTests.swift
new file mode 100644
index 0000000..a2c88ce
--- /dev/null
+++ b/HutchTests/RepositorySettingsViewModelTests.swift
@@ -0,0 +1,104 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct UpdateRepositoryInfoEnvelope: Decodable {
+ let updateRepository: UpdateRepositoryInfoPayload
+}
+
+private struct UpdateRepositoryInfoPayload: Decodable {
+ let id: Int
+}
+
+struct RepositorySettingsViewModelTests {
+
+ @Test
+ func saveInfoResponseDecodesMinimalRepositoryPayload() throws {
+ let json = """
+ {
+ "data": {
+ "updateRepository": {
+ "id": 42
+ }
+ }
+ }
+ """
+
+ let decoded = try JSONDecoder().decode(
+ GraphQLResponse<UpdateRepositoryInfoEnvelope>.self,
+ from: Data(json.utf8)
+ )
+
+ #expect(decoded.data?.updateRepository.id == 42)
+ }
+
+ @Test
+ func gitCanonicalEntityAddsMissingTilde() {
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "alice") == "~alice")
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: "~alice") == "~alice")
+ #expect(RepositorySettingsViewModel.gitCanonicalEntity(from: " alice ") == "~alice")
+ }
+
+ @Test
+ func gitHeadReferenceUsesFullBranchRef() {
+ #expect(RepositorySettingsViewModel.gitHeadReference(from: "main") == "refs/heads/main")
+ #expect(RepositorySettingsViewModel.gitHeadReference(from: " refs/heads/dev ") == "refs/heads/dev")
+ }
+
+ @Test
+ @MainActor
+ func unchangedHeadIsOmittedFromSaveInput() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: "refs/heads/main"),
+ branches: [Reference(name: "refs/heads/main", target: nil)],
+ client: SRHTClient(token: "test-token")
+ )
+
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ }
+
+ @Test
+ @MainActor
+ func changedHeadUsesSelectedBranchReference() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: "refs/heads/main"),
+ branches: [
+ Reference(name: "refs/heads/main", target: nil),
+ Reference(name: "refs/heads/dev", target: nil)
+ ],
+ client: SRHTClient(token: "test-token")
+ )
+ viewModel.editedHead = "dev"
+
+ #expect(viewModel.selectedHeadReferenceForSave() == "refs/heads/dev")
+ }
+
+ @Test
+ @MainActor
+ func bareRepositoryOmitsHeadWhenNoBranchExists() {
+ let viewModel = RepositorySettingsViewModel(
+ repository: makeRepository(headName: nil),
+ branches: [],
+ client: SRHTClient(token: "test-token")
+ )
+
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ viewModel.editedHead = "main"
+ #expect(viewModel.selectedHeadReferenceForSave() == nil)
+ }
+
+ @MainActor
+ private func makeRepository(headName: String?) -> RepositorySummary {
+ RepositorySummary(
+ id: 1,
+ rid: "rid-1",
+ service: .git,
+ name: "repo",
+ description: "desc",
+ visibility: .public,
+ updated: .now,
+ owner: Entity(canonicalName: "~owner"),
+ head: headName.map { Reference(name: $0, target: nil) }
+ )
+ }
+}
diff --git a/HutchTests/SRHTClientTests.swift b/HutchTests/SRHTClientTests.swift
new file mode 100644
index 0000000..2ce2529
--- /dev/null
+++ b/HutchTests/SRHTClientTests.swift
@@ -0,0 +1,27 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SRHTClientTests {
+
+ @Test
+ @MainActor
+ func fetchTextRejectsUnexpectedAuthenticatedURL() async throws {
+ let client = SRHTClient(token: "test-token")
+ let url = try #require(URL(string: "https://example.com/build-log"))
+
+ do {
+ _ = try await client.fetchText(url: url)
+ Issue.record("Expected fetchText(url:) to reject non-sr.ht URLs.")
+ } catch let error as SRHTError {
+ guard case .invalidAuthenticatedURL(let rejectedURL) = error else {
+ Issue.record("Expected invalidAuthenticatedURL error, got \(error).")
+ return
+ }
+
+ #expect(rejectedURL == url)
+ } catch {
+ Issue.record("Expected SRHTError.invalidAuthenticatedURL, got \(error).")
+ }
+ }
+}
diff --git a/HutchTests/SettingsViewModelTests.swift b/HutchTests/SettingsViewModelTests.swift
new file mode 100644
index 0000000..fc18162
--- /dev/null
+++ b/HutchTests/SettingsViewModelTests.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+private struct DeletePGPKeyEnvelope: Decodable {
+ let deletePGPKey: DeleteResultPayload?
+}
+
+private struct DeleteResultPayload: Decodable {
+ let id: Int?
+}
+
+struct SettingsViewModelTests {
+
+ @Test
+ @MainActor
+ func deletePGPKeyResponseDecodesNullPayloadWithGraphQLErrors() throws {
+ let json = """
+ {
+ "errors": [
+ {
+ "message": "PGP key ID 13629 is set as the user's preferred PGP key - it must be unset before removing the key"
+ }
+ ],
+ "data": {
+ "deletePGPKey": null
+ }
+ }
+ """
+
+ let decoded = try JSONDecoder().decode(
+ GraphQLResponse<DeletePGPKeyEnvelope>.self,
+ from: Data(json.utf8)
+ )
+
+ #expect(decoded.data?.deletePGPKey == nil)
+ #expect(decoded.errors?.first?.message.contains("preferred PGP key") == true)
+ }
+}
diff --git a/HutchTests/SettingsViewTests.swift b/HutchTests/SettingsViewTests.swift
new file mode 100644
index 0000000..950c3dc
--- /dev/null
+++ b/HutchTests/SettingsViewTests.swift
@@ -0,0 +1,22 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct SettingsViewTests {
+
+ @Test
+ @MainActor
+ func settingsBioAttributedStringPreservesInlineMarkdown() {
+ let attributed = settingsBioAttributedString("Hello **world** and [link](https://example.com)")
+
+ #expect(String(attributed.characters).contains("Hello world and link"))
+ }
+
+ @Test
+ @MainActor
+ func settingsBioAttributedStringFallsBackForInvalidMarkdown() {
+ let attributed = settingsBioAttributedString("[broken")
+
+ #expect(String(attributed.characters) == "[broken")
+ }
+}
diff --git a/HutchTests/TicketDetailViewModelTests.swift b/HutchTests/TicketDetailViewModelTests.swift
new file mode 100644
index 0000000..bf0bc60
--- /dev/null
+++ b/HutchTests/TicketDetailViewModelTests.swift
@@ -0,0 +1,30 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TicketDetailViewModelTests {
+
+ @Test
+ @MainActor
+ func reopenStatusInputOmitsResolution() {
+ let input = TicketDetailViewModel.statusUpdateInput(
+ status: .reported,
+ resolution: .unresolved
+ )
+
+ #expect(input["status"] as? String == TicketStatus.reported.rawValue)
+ #expect(input["resolution"] == nil)
+ }
+
+ @Test
+ @MainActor
+ func resolveStatusInputIncludesResolution() {
+ let input = TicketDetailViewModel.statusUpdateInput(
+ status: .resolved,
+ resolution: .fixed
+ )
+
+ #expect(input["status"] as? String == TicketStatus.resolved.rawValue)
+ #expect(input["resolution"] as? String == TicketResolution.fixed.rawValue)
+ }
+}
diff --git a/HutchTests/TrackerListViewModelTests.swift b/HutchTests/TrackerListViewModelTests.swift
new file mode 100644
index 0000000..1caedea
--- /dev/null
+++ b/HutchTests/TrackerListViewModelTests.swift
@@ -0,0 +1,16 @@
+import Foundation
+import Testing
+@testable import Hutch
+
+struct TrackerListViewModelTests {
+
+ @Test
+ @MainActor
+ func graphQLErrorDescriptionIsPreservedForTrackerCreationFailures() {
+ let error = SRHTError.graphQLErrors([
+ GraphQLError(message: "A tracker named bugs already exists", locations: nil)
+ ])
+
+ #expect(error.localizedDescription == "GraphQL error: A tracker named bugs already exists")
+ }
+}
diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..29d6aeb
--- /dev/null
+++ b/TESTING.md
@@ -0,0 +1,28 @@
+# Testing Plan for Hutch
+
+---
+
+## **Test Coverage Goals**
+
+Ensure stability and reliability for the following core workflows:
+
+
+| Area | Description |
+| ------------------------ | ----------------------------------------------- |
+| Authentication Flow | PAT login, token refresh, sign-out |
+| Repository Management | Clone, push, browse (Git/Hg), creation |
+| Ticket/Tracker Workflows | Create, view, filter, update |
+| Builds | Submission, retry, editing, status updates |
+| Sharing & Navigation | Deep links, sharing sheets, external navigation |
+| Offline Support | Caching, sync on reconnect |
+| UI/UX Consistency | Dark mode, accessibility, error states |
+
+
+---
+
+## **Test Environments**
+
+
+| Platform | Versions | Devices/Testers |
+| -------- | -------- | --------------- |
+| iOS | v1.0+ | iOS 17+ |