From 65412ee9818bf251fd5caac231746b04c7eca23f Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 10:13:46 -0500 Subject: fix: honor forceRefresh for projects and system status on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loadDashboard(forceRefresh:) fanned the flag out to five loaders, but loadProjects and loadSystemStatusSnapshot dropped it: they called fetchProjects() and snapshotResult() with no policy, so pull-to-refresh returned cached projects and status while the other three sections refreshed. SonarCloud flagged both params as unused (swift:S1172). Thread forceRefresh through ProjectService.fetchProjects into the page policy (refreshIgnoringCache when forced), and pass it to snapshotResult, which already accepted it. ProjectsListView carried the same latent bug via its own .refreshable — fixed there too now that fetchProjects can force. --- Hutch/Networking/ProjectService.swift | 8 ++++---- Hutch/Views/Home/HomeViewModel.swift | 4 ++-- Hutch/Views/Projects/ProjectsListView.swift | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) (limited to 'Hutch') diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift index 44320ca..91dc339 100644 --- a/Hutch/Networking/ProjectService.swift +++ b/Hutch/Networking/ProjectService.swift @@ -269,15 +269,15 @@ struct ProjectService: Sendable { self.client = client } - func fetchProjects() async throws -> [Project] { - try await fetchProjectSummaries().map(Self.makeSummaryProject) + func fetchProjects(forceRefresh: Bool = false) async throws -> [Project] { + try await fetchProjectSummaries(forceRefresh: forceRefresh).map(Self.makeSummaryProject) } func fetchProjectDetail(rid: String) async throws -> Project { try await fetchProjectDetailPayload(rid: rid) } - private func fetchProjectSummaries() async throws -> [ProjectSummaryPayload] { + private func fetchProjectSummaries(forceRefresh: Bool) async throws -> [ProjectSummaryPayload] { var results: [ProjectSummaryPayload] = [] var cursor: String? @@ -295,7 +295,7 @@ struct ProjectService: Sendable { cacheKey: APICacheKeys.projects(cursor: cursor), resourceType: .userProfile, ttl: APICacheTTLs.projectList, - policy: .cacheFirstThenRefresh + policy: forceRefresh ? .refreshIgnoringCache : .cacheFirstThenRefresh ) let response = cached.value diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index a3aed30..3789c11 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -683,7 +683,7 @@ final class HomeViewModel { private func loadProjects(forceRefresh: Bool) async -> Result<[Project], Error> { do { - return .success(try await projectService.fetchProjects()) + return .success(try await projectService.fetchProjects(forceRefresh: forceRefresh)) } catch { return .failure(error) } @@ -716,7 +716,7 @@ final class HomeViewModel { private func loadSystemStatusSnapshot(forceRefresh: Bool) async -> Result, Error> { do { - return .success(try await systemStatusRepository.snapshotResult()) + return .success(try await systemStatusRepository.snapshotResult(forceRefresh: forceRefresh)) } catch { return .failure(error) } diff --git a/Hutch/Views/Projects/ProjectsListView.swift b/Hutch/Views/Projects/ProjectsListView.swift index f6d5766..783417c 100644 --- a/Hutch/Views/Projects/ProjectsListView.swift +++ b/Hutch/Views/Projects/ProjectsListView.swift @@ -25,14 +25,14 @@ final class ProjectsListViewModel { } } - func loadProjects() async { + func loadProjects(forceRefresh: Bool = false) async { guard !isLoading else { return } isLoading = true error = nil defer { isLoading = false } do { - projects = try await service.fetchProjects() + projects = try await service.fetchProjects(forceRefresh: forceRefresh) } catch { if projects.isEmpty { self.error = error.userFacingMessage @@ -115,7 +115,7 @@ struct ProjectsListView: View { ) ) .refreshable { - await viewModel.loadProjects() + await viewModel.loadProjects(forceRefresh: true) } .connectivityOverlay(hasContent: !viewModel.projects.isEmpty) { await viewModel.loadProjects() -- cgit v1.2.3 From 9834b780b24a1dd617fa761155712920159f3d8d Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 10:18:27 -0500 Subject: chore: move AccountSession.swift out of the stray nested Hutch/Hutch dir It sat at Hutch/Hutch/App/AccountSession.swift, one level too deep. The target is a PBXFileSystemSynchronizedRootGroup rooted at Hutch/, so the file compiled by path with no pbxproj reference; moving it beside the rest of App/ needs no project-file change. Also removed the emptied Hutch/Hutch tree and the stray empty Hutch/HutchTests dir. --- Hutch/App/AccountSession.swift | 32 ++++++++++++++++++++++++++++++++ Hutch/Hutch/App/AccountSession.swift | 32 -------------------------------- 2 files changed, 32 insertions(+), 32 deletions(-) create mode 100644 Hutch/App/AccountSession.swift delete mode 100644 Hutch/Hutch/App/AccountSession.swift (limited to 'Hutch') diff --git a/Hutch/App/AccountSession.swift b/Hutch/App/AccountSession.swift new file mode 100644 index 0000000..1918b0a --- /dev/null +++ b/Hutch/App/AccountSession.swift @@ -0,0 +1,32 @@ +import Foundation + +struct AccountSession: Sendable { + let account: AccountEntry + let user: User + let client: SRHTClient + let defaults: UserDefaults + let systemStatusRepository: SystemStatusRepository + + var id: String { + account.id + } +} + +enum AccountDefaultsStore { + private static let suitePrefix = "net.cleberg.Hutch.account" + + static func userDefaults(for accountID: String) -> UserDefaults { + let suiteName = suiteName(for: accountID) + return UserDefaults(suiteName: suiteName) ?? .standard + } + + static func clear(accountID: String) { + let suiteName = suiteName(for: accountID) + guard let defaults = UserDefaults(suiteName: suiteName) else { return } + defaults.removePersistentDomain(forName: suiteName) + } + + private static func suiteName(for accountID: String) -> String { + "\(suitePrefix).\(accountID)" + } +} diff --git a/Hutch/Hutch/App/AccountSession.swift b/Hutch/Hutch/App/AccountSession.swift deleted file mode 100644 index 1918b0a..0000000 --- a/Hutch/Hutch/App/AccountSession.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Foundation - -struct AccountSession: Sendable { - let account: AccountEntry - let user: User - let client: SRHTClient - let defaults: UserDefaults - let systemStatusRepository: SystemStatusRepository - - var id: String { - account.id - } -} - -enum AccountDefaultsStore { - private static let suitePrefix = "net.cleberg.Hutch.account" - - static func userDefaults(for accountID: String) -> UserDefaults { - let suiteName = suiteName(for: accountID) - return UserDefaults(suiteName: suiteName) ?? .standard - } - - static func clear(accountID: String) { - let suiteName = suiteName(for: accountID) - guard let defaults = UserDefaults(suiteName: suiteName) else { return } - defaults.removePersistentDomain(forName: suiteName) - } - - private static func suiteName(for accountID: String) -> String { - "\(suitePrefix).\(accountID)" - } -} -- cgit v1.2.3 From e93972f39150e5e590e49aaf46a369c463277c30 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 10:18:40 -0500 Subject: chore: clear the actionable SonarCloud code smells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S1871: merge the identical .home / .recentActivity deep-link cases in RootView — recent activity is a section of Home, not its own screen. - S1186: comment the two intentionally-empty Cancel buttons (PatchsetDetailView, TicketDetailView) and the empty URLProtocol stopLoading override in APICacheTests. - S108: comment the expected-miss catch block in APICacheTests. - S1172: rename the unused url parameter in mimeType(for:) to _. - S4624: extract the nested template literal in the deep-link builders (background.js, content.js) to a pathSegment variable. Left as Won't Fix, with reasons: the 35 hardcoded-URI warnings (a one-forge client and its literal-URL tests), executeCached's 8 params (38 call sites, no benefit), the forceRefresh S1172 pair (fixed as a real bug instead), S1481 on ArtifactsView (false positive — $vm.error is used), and S7785 (top-level await would break a classic content script). --- Hutch/App/RootView.swift | 8 +++----- Hutch/Views/Patchsets/PatchsetDetailView.swift | 2 +- Hutch/Views/Repositories/RepositoryDetailViewModel.swift | 2 +- Hutch/Views/Tickets/TicketDetailView.swift | 2 +- HutchSafariExtension/Resources/background.js | 3 ++- HutchSafariExtension/Resources/content.js | 3 ++- HutchTests/APICacheTests.swift | 3 ++- 7 files changed, 12 insertions(+), 11 deletions(-) (limited to 'Hutch') diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index 10720ad..2e16718 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -198,11 +198,9 @@ struct RootView: View { } switch link { - case .home: - homePath = NavigationPath() - appState.selectedTab = .home - - case .recentActivity: + // Recent activity is a section of the Home tab, not a screen of its + // own, so its intent/widget deep link lands on Home like .home does. + case .home, .recentActivity: homePath = NavigationPath() appState.selectedTab = .home diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift index f560326..91aa417 100644 --- a/Hutch/Views/Patchsets/PatchsetDetailView.swift +++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift @@ -71,7 +71,7 @@ struct PatchsetDetailView: View { Task { await viewModel.updateStatus(to: status) } } } - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) {} // dismisses the dialog; no action needed } .alert( "Couldn't Update Patchset", diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 9b6b942..82e1592 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -627,7 +627,7 @@ final class RepositoryDetailViewModel { /// Artifacts are release tarballs and signatures rather than media, so a /// generic binary type is honest more often than guessing from the extension. - private nonisolated static func mimeType(for url: URL) -> String { + private nonisolated static func mimeType(for _: URL) -> String { "application/octet-stream" } diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index b114fa6..1705a73 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -204,7 +204,7 @@ struct TicketDetailView: View { } } } - Button("Cancel", role: .cancel) {} + Button("Cancel", role: .cancel) {} // dismisses the dialog; no action needed } message: { Text("This permanently deletes the ticket and its comments. This cannot be undone.") } diff --git a/HutchSafariExtension/Resources/background.js b/HutchSafariExtension/Resources/background.js index 81bf8b6..0147de0 100644 --- a/HutchSafariExtension/Resources/background.js +++ b/HutchSafariExtension/Resources/background.js @@ -68,7 +68,8 @@ function hutchDeepLinkFor(rawURL) { const path = deepLinkPath(url.hostname, normalizedPath(url.pathname)); const service = deepLinkService(url.hostname, path); - return `hutch://${service}${path ? `/${path}` : ""}${url.search}${url.hash}`; + const pathSegment = path ? `/${path}` : ""; + return `hutch://${service}${pathSegment}${url.search}${url.hash}`; } function showUnsupportedMessage(tabId) { diff --git a/HutchSafariExtension/Resources/content.js b/HutchSafariExtension/Resources/content.js index dba7c5d..e5cb087 100644 --- a/HutchSafariExtension/Resources/content.js +++ b/HutchSafariExtension/Resources/content.js @@ -63,7 +63,8 @@ function hutchDeepLinkForLocation() { const path = hutchDeepLinkPath(location.hostname, hutchNormalizedPath(location.pathname)); const service = hutchDeepLinkService(location.hostname, path); - return `hutch://${service}${path ? `/${path}` : ""}${location.search}${location.hash}`; + const pathSegment = path ? `/${path}` : ""; + return `hutch://${service}${pathSegment}${location.search}${location.hash}`; } function storageGet(defaults) { diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift index 89c6c53..a03e6f9 100644 --- a/HutchTests/APICacheTests.swift +++ b/HutchTests/APICacheTests.swift @@ -256,6 +256,7 @@ struct APICacheTests { _ = try await cache.read(cacheKey: key) Issue.record("Expected cache miss for \(key).") } catch APICacheError.miss { + // expected: a miss is the success path here } catch { Issue.record("Unexpected error for \(key): \(error).") } @@ -293,7 +294,7 @@ private final class CachedURLProtocol: URLProtocol, @unchecked Sendable { } } - override func stopLoading() {} + override func stopLoading() {} // required override; nothing to tear down static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) { Self.responses = responses -- cgit v1.2.3 From d73c6ac381baac0ae8b0d5dfc551165591115905 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 11:13:13 -0500 Subject: fix: move the S1186 empty-block comments inside the braces Trailing // comments after {} left the block lexically empty, so SonarCloud kept flagging stopLoading (and would have re-flagged the two Cancel buttons). S1186 wants a *nested* comment; use /* ... */ inside. --- Hutch/Views/Patchsets/PatchsetDetailView.swift | 2 +- Hutch/Views/Tickets/TicketDetailView.swift | 2 +- HutchTests/APICacheTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) (limited to 'Hutch') diff --git a/Hutch/Views/Patchsets/PatchsetDetailView.swift b/Hutch/Views/Patchsets/PatchsetDetailView.swift index 91aa417..3ef5ff4 100644 --- a/Hutch/Views/Patchsets/PatchsetDetailView.swift +++ b/Hutch/Views/Patchsets/PatchsetDetailView.swift @@ -71,7 +71,7 @@ struct PatchsetDetailView: View { Task { await viewModel.updateStatus(to: status) } } } - Button("Cancel", role: .cancel) {} // dismisses the dialog; no action needed + Button("Cancel", role: .cancel) { /* dismisses the dialog; no action needed */ } } .alert( "Couldn't Update Patchset", diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 1705a73..6339776 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -204,7 +204,7 @@ struct TicketDetailView: View { } } } - Button("Cancel", role: .cancel) {} // dismisses the dialog; no action needed + Button("Cancel", role: .cancel) { /* dismisses the dialog; no action needed */ } } message: { Text("This permanently deletes the ticket and its comments. This cannot be undone.") } diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift index a03e6f9..24db870 100644 --- a/HutchTests/APICacheTests.swift +++ b/HutchTests/APICacheTests.swift @@ -294,7 +294,7 @@ private final class CachedURLProtocol: URLProtocol, @unchecked Sendable { } } - override func stopLoading() {} // required override; nothing to tear down + override func stopLoading() { /* required override; nothing to tear down */ } static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) { Self.responses = responses -- cgit v1.2.3 From f3c70e04148c57dcafead89b4e02b83e5f5635f8 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Thu, 16 Jul 2026 11:17:45 -0500 Subject: chore: track the two HutchIntents TODOs in the roadmap (S1135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SearchHutchIntent's route-to-Lookup stopgap and the absent OpenSavedSearchIntent are both gated on a global search/persistence layer Hutch lacks. Promote both to ROADMAP § "App Intent gaps" and replace the inline TODOs with plain references, clearing those two S1135 issues without losing the design intent. --- Hutch/App/HutchIntents.swift | 6 ++++-- ROADMAP.md | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) (limited to 'Hutch') diff --git a/Hutch/App/HutchIntents.swift b/Hutch/App/HutchIntents.swift index c2f144a..947ec04 100644 --- a/Hutch/App/HutchIntents.swift +++ b/Hutch/App/HutchIntents.swift @@ -134,7 +134,8 @@ struct SearchHutchIntent: AppIntent { var route: HutchRoute { let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines) - // TODO: Route to global local search once Hutch has one. + // Routes to Lookup for now; repoint at a global content search when Hutch + // gains one — tracked in ROADMAP.md § "App Intent gaps". return normalized.isEmpty ? .lookup : .search(query: normalized) } @@ -145,7 +146,8 @@ struct SearchHutchIntent: AppIntent { } } -// TODO: Add OpenSavedSearchIntent when Hutch has global saved-search persistence. +// An OpenSavedSearchIntent belongs here once Hutch has global saved-search +// persistence — tracked in ROADMAP.md § "App Intent gaps". // MARK: - App Entities diff --git a/ROADMAP.md b/ROADMAP.md index 8e32355..43c3e7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -244,8 +244,10 @@ Won't Fix, with reasons (resolve in SonarCloud's web UI, not in code): - **`javascript:S7785`** — prefers top-level `await` for `injectBannerIfEnabled()`, but `content.js` is a classic content script, not a module. Top-level `await` would be a syntax error. Not applicable. -- **5× `swift:S1135`** — TODO comments (INFO). Two in `HutchIntents` name real - gaps; leave them until those features land. +- **5× `swift:S1135`** — TODO comments (INFO). The two in `HutchIntents` named + real gaps and are now promoted to "App Intent gaps" below, with the inline + `TODO`s replaced by plain references — so those two clear. The remaining three + (`DeepLink`, `NotificationPreferencesViewModel` ×2) stay until addressed. The 3 hotspots are the part actually worth thought: @@ -315,6 +317,24 @@ sequenced after the ingest rather than planned now. Read `api/graph/schema.graphqls` in `hub.sr.ht` before committing the version number. The bucket may be empty. +### App Intent gaps — unscheduled + +Two App Intents in `HutchIntents.swift` are placeholders for features Hutch does +not have yet. Both are gated on the same missing capability — a global +search/persistence layer — so neither is schedulable until that lands. (These +were the two `swift:S1135` TODOs; promoted here so the code carries a reference +rather than a bare `TODO`.) + +- **Global content search.** `SearchHutchIntent` accepts a query but routes to + the Lookup screen — sourcehut entity resolution — because Hutch has no + full-text search across tickets, repos, and lists. Its own description says + "Opens Hutch lookup with a search query." When a real search exists, repoint + the `.search` route in `SearchHutchIntent.route`. +- **`OpenSavedSearchIntent`.** Saved searches are per-tracker only + (`TicketSavedFilterStore`, `ScopedSearchHistoryStore`); there is no global + 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 -- cgit v1.2.3