diff --git a/Forji/Forji/App/ContentView.swift b/Forji/Forji/App/ContentView.swift index 991a2e5..03f6835 100644 --- a/Forji/Forji/App/ContentView.swift +++ b/Forji/Forji/App/ContentView.swift @@ -19,11 +19,12 @@ enum AppAppearance: Int, CaseIterable { struct ContentView: View { @AppStorage("appearance") private var appearance: AppAppearance = .system @AppStorage("defaultAllInstances") private var defaultAllInstances = false - @State private var authService = AuthenticationService() + @State var authService: AuthenticationService @State private var multiInstanceManager: MultiInstanceManager? @Query(filter: #Predicate { $0.isDefault }) private var defaultInstances: [ForgejoInstance] @Query private var allInstances: [ForgejoInstance] @State private var hasAttemptedAutoLogin = false + @State private var sessionFullyRestored = false @Environment(\.modelContext) private var modelContext @Environment(NavigationState.self) private var navigationState @@ -45,6 +46,7 @@ struct ContentView: View { } } else if authService.isAuthenticated, authService.client != nil { HomeView(authService: authService) + .task { await completeSessionRestore() } } else if !hasAttemptedAutoLogin { ProgressView("Connecting...") .task { await attemptAutoLogin() } @@ -67,6 +69,18 @@ struct ContentView: View { } } + private func completeSessionRestore() async { + guard !sessionFullyRestored, let instance = authService.currentInstance else { return } + do { + try await authService.restoreSession(instance: instance) + instance.lastUsed = Date() + try? modelContext.save() + } catch { + await authService.logout(modelContext: modelContext) + } + sessionFullyRestored = true + } + private func attemptAutoLogin() async { #if DEBUG if devResetData { @@ -76,6 +90,7 @@ struct ContentView: View { } catch { assertionFailure("dev_resetData failed: \(error)") } + DiskCache.removeAll() devResetData = false } if devSkipAutoLogin { @@ -90,6 +105,16 @@ struct ContentView: View { if defaultAllInstances, allInstances.count > 1 { let manager = MultiInstanceManager() + manager.bootstrap(instances: allInstances) + if manager.isConnected { + multiInstanceManager = manager + hasAttemptedAutoLogin = true + Task { + await manager.connect(instances: allInstances) + } + return + } + await manager.connect(instances: allInstances) if manager.isConnected { multiInstanceManager = manager @@ -99,6 +124,19 @@ struct ContentView: View { } if let defaultInstance = defaultInstances.first { + if authService.bootstrapSession(instance: defaultInstance) { + hasAttemptedAutoLogin = true + do { + try await authService.restoreSession(instance: defaultInstance) + defaultInstance.lastUsed = Date() + try? modelContext.save() + sessionFullyRestored = true + } catch { + await authService.logout(modelContext: modelContext) + } + return + } + do { try await authService.restoreSession(instance: defaultInstance) defaultInstance.lastUsed = Date() @@ -153,7 +191,7 @@ struct ContentView: View { #if DEBUG #Preview { - ContentView() + ContentView(authService: AuthenticationService()) .modelContainer(for: ForgejoInstance.self, inMemory: true) .environment(NavigationState.shared) } diff --git a/Forji/Forji/App/ForjiApp.swift b/Forji/Forji/App/ForjiApp.swift index c526913..ed49852 100644 --- a/Forji/Forji/App/ForjiApp.swift +++ b/Forji/Forji/App/ForjiApp.swift @@ -1,3 +1,4 @@ +import ForgejoKit import SwiftData import SwiftUI @@ -7,22 +8,51 @@ struct ForjiApp: App { @Environment(\.scenePhase) private var scenePhase @AppStorage("notificationsEnabled") private var notificationsEnabled = false - var sharedModelContainer: ModelContainer = { - let schema = Schema([ - ForgejoInstance.self, - ]) - let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let sharedModelContainer: ModelContainer + @State private var bootstrappedAuthService: AuthenticationService + init() { + let schema = Schema([ForgejoInstance.self]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + let container: ModelContainer do { - return try ModelContainer(for: schema, configurations: [modelConfiguration]) + container = try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { fatalError("Could not create ModelContainer: \(error)") } - }() + sharedModelContainer = container + + let auth = AuthenticationService() + + #if DEBUG + let devServerURL = UserDefaults.standard.string(forKey: "dev_serverURL") ?? "" + let skipBootstrap = UserDefaults.standard.bool(forKey: "dev_resetData") + || UserDefaults.standard.bool(forKey: "dev_skipAutoLogin") + || !devServerURL.isEmpty + #else + let skipBootstrap = false + #endif + + if !skipBootstrap { + let context = ModelContext(container) + let defaultDescriptor = FetchDescriptor( + predicate: #Predicate { $0.isDefault }, + ) + let allDescriptor = FetchDescriptor() + let allInstances = (try? context.fetch(allDescriptor)) ?? [] + let instance = (try? context.fetch(defaultDescriptor).first) + ?? (allInstances.count == 1 ? allInstances.first : nil) + if let instance { + auth.bootstrapSession(instance: instance) + } + } + + _bootstrappedAuthService = State(initialValue: auth) + } var body: some Scene { WindowGroup { - ContentView() + ContentView(authService: bootstrappedAuthService) .environment(NavigationState.shared) } .modelContainer(sharedModelContainer) diff --git a/Forji/Forji/Helpers/DiskCache.swift b/Forji/Forji/Helpers/DiskCache.swift new file mode 100644 index 0000000..66b3f49 --- /dev/null +++ b/Forji/Forji/Helpers/DiskCache.swift @@ -0,0 +1,37 @@ +import Foundation + +enum DiskCache { + private static var cacheDirectory: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("ListCache", isDirectory: true) + } + + private static func fileURL(for name: String) -> URL { + cacheDirectory.appendingPathComponent(name).appendingPathExtension("json") + } + + static func save(_ items: [T], as name: String) { + do { + let dir = cacheDirectory + if !FileManager.default.fileExists(atPath: dir.path) { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + let data = try JSONEncoder().encode(items) + try data.write(to: fileURL(for: name), options: .atomic) + } catch { + // Non-fatal + } + } + + static func load(as name: String) -> [T]? { + guard let data = try? Data(contentsOf: fileURL(for: name)), + let items = try? JSONDecoder().decode([T].self, from: data), + !items.isEmpty + else { return nil } + return items + } + + static func removeAll() { + try? FileManager.default.removeItem(at: cacheDirectory) + } +} diff --git a/Forji/Forji/Helpers/PaginationState.swift b/Forji/Forji/Helpers/PaginationState.swift index 0435226..340a8bf 100644 --- a/Forji/Forji/Helpers/PaginationState.swift +++ b/Forji/Forji/Helpers/PaginationState.swift @@ -13,6 +13,7 @@ final class PaginationState { private var currentPage = 1 private var loadTask: Task? let pageSize: Int + var cacheName: String? init(pageSize: Int = 20) { self.pageSize = pageSize @@ -24,8 +25,6 @@ final class PaginationState { self.pageSize = pageSize } - /// Cancels any in-flight load, resets pagination, and starts a new fetch. - /// Returns the internal Task so callers can await completion (e.g. refreshable). @discardableResult func reload( clearItems: Bool = false, @@ -35,6 +34,7 @@ final class PaginationState { if clearItems { items = [] } currentPage = 1 hasMore = true + hasLoaded = false isLoading = true showError = false let pageSize = pageSize @@ -96,3 +96,37 @@ final class PaginationState { hasLoaded = false } } + +// MARK: - Disk cache + +extension PaginationState where Item: Codable { + func loadFromCache() { + guard let cacheName, !hasLoaded else { return } + guard let cached: [Item] = DiskCache.load(as: cacheName) else { return } + items = cached + } + + func saveToCache() { + guard let cacheName, hasLoaded, !items.isEmpty else { return } + DiskCache.save(items, as: cacheName) + } + + @discardableResult + func reloadAndCache( + clearItems: Bool = false, + using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item], + ) -> Task { + let task = reload(clearItems: clearItems, using: fetch) + return Task { + await task.value + saveToCache() + } + } + + func loadMoreAndCache( + using fetch: @escaping (_ page: Int, _ limit: Int) async throws -> [Item], + ) async { + await loadMore(using: fetch) + saveToCache() + } +} diff --git a/Forji/Forji/Services/AuthenticationService.swift b/Forji/Forji/Services/AuthenticationService.swift index fc0b86f..81c0648 100644 --- a/Forji/Forji/Services/AuthenticationService.swift +++ b/Forji/Forji/Services/AuthenticationService.swift @@ -75,6 +75,26 @@ class AuthenticationService { client = nil } + /// Synchronous session restore from keychain so the home screen + /// with cached data can appear on the very first frame. + @discardableResult + func bootstrapSession(instance: ForgejoInstance) -> Bool { + let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) + guard let token = KeychainManager.getTokenSync( + for: normalizedURL, username: instance.username, + ) else { return false } + + client = ForgejoClient( + serverURL: normalizedURL, + username: instance.username, + token: token, + allowSelfSignedCertificates: instance.allowSelfSignedCertificates, + ) + currentInstance = instance + isAuthenticated = true + return true + } + func logout(modelContext: ModelContext? = nil) async { if let instance = currentInstance { let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL) @@ -100,6 +120,8 @@ class AuthenticationService { try? modelContext.save() } } + // Clears cache for all instances since keys are hashed and cannot be scoped per-instance + DiskCache.removeAll() isAuthenticated = false currentUser = nil currentInstance = nil diff --git a/Forji/Forji/Services/KeychainManager.swift b/Forji/Forji/Services/KeychainManager.swift index bfd8bc9..7775751 100644 --- a/Forji/Forji/Services/KeychainManager.swift +++ b/Forji/Forji/Services/KeychainManager.swift @@ -40,6 +40,26 @@ actor KeychainManager { try deleteItem(forKey: key(for: server, username: username, suffix: "_token")) } + // MARK: - Synchronous token read (no actor hop) + + nonisolated static func getTokenSync(for server: String, username: String) -> String? { + let account = "\(server)_\(username)_token" + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { return nil } + return value + } + // MARK: - Migration func migrateAccessibility(for server: String, username: String) { diff --git a/Forji/Forji/Services/MultiInstanceManager.swift b/Forji/Forji/Services/MultiInstanceManager.swift index 74c75bd..77e97aa 100644 --- a/Forji/Forji/Services/MultiInstanceManager.swift +++ b/Forji/Forji/Services/MultiInstanceManager.swift @@ -13,28 +13,31 @@ final class MultiInstanceManager { !connections.isEmpty } - func connect(instances: [ForgejoInstance]) async { - isConnecting = true + /// Fast path: restore clients from keychain so UI can render immediately. + /// Full validation should run afterwards via `connect(instances:)`. + func bootstrap(instances: [ForgejoInstance]) { connections = [] failedInstances = [] + for instance in instances { + let authService = AuthenticationService() + if authService.bootstrapSession(instance: instance) { + connections.append((instance: instance, authService: authService)) + } + } + } + + func connect(instances: [ForgejoInstance]) async { + isConnecting = true let snapshots = instances.map { instance in InstanceSnapshot( - serverURL: instance.serverURL, - username: instance.username, - allowSelfSigned: instance.allowSelfSignedCertificates, - useTokenAuth: instance.useTokenAuth, + serverURL: instance.serverURL, username: instance.username, + allowSelfSigned: instance.allowSelfSignedCertificates, useTokenAuth: instance.useTokenAuth, ) } + let authServices = snapshots.map { _ in AuthenticationService() } - var authServices = [AuthenticationService]() - for _ in snapshots { - authServices.append(AuthenticationService()) - } - - let results = await withTaskGroup( - of: (Int, Bool, String?).self, - ) { group in + let results = await withTaskGroup(of: (Int, Bool, String?).self) { group in for (index, snapshot) in snapshots.enumerated() { let auth = authServices[index] group.addTask { @@ -46,22 +49,26 @@ final class MultiInstanceManager { } } } - var collected = [(Int, Bool, String?)]() - for await result in group { - collected.append(result) - } - return collected + return await group.reduce(into: [(Int, Bool, String?)]()) { $0.append($1) } } - for (index, success, error) in results { + let bootstrapped = Dictionary(uniqueKeysWithValues: connections.map { ($0.instance.serverURL, $0) }) + var newConnections: [(instance: ForgejoInstance, authService: AuthenticationService)] = [] + var newFailed: [(instance: ForgejoInstance, error: String)] = [] + + for (index, success, error) in results.sorted(by: { $0.0 < $1.0 }) { let instance = instances[index] if success { - connections.append((instance: instance, authService: authServices[index])) + newConnections.append((instance: instance, authService: authServices[index])) + } else if let existing = bootstrapped[instance.serverURL] { + newConnections.append(existing) } else { - failedInstances.append((instance: instance, error: error ?? "Unknown error")) + newFailed.append((instance: instance, error: error ?? "Unknown error")) } } + connections = newConnections + failedInstances = newFailed isConnecting = false } diff --git a/Forji/Forji/Views/IssueListView.swift b/Forji/Forji/Views/IssueListView.swift index fe3763c..14b984a 100644 --- a/Forji/Forji/Views/IssueListView.swift +++ b/Forji/Forji/Views/IssueListView.swift @@ -14,6 +14,7 @@ struct IssueListView: View { self.repository = repository self.authService = authService issueService = authService.client.map { IssueService(client: $0) } + } private var owner: String { diff --git a/Forji/Forji/Views/NotificationsOverviewView.swift b/Forji/Forji/Views/NotificationsOverviewView.swift index ed97d52..6c475ee 100644 --- a/Forji/Forji/Views/NotificationsOverviewView.swift +++ b/Forji/Forji/Views/NotificationsOverviewView.swift @@ -12,6 +12,11 @@ struct NotificationsOverviewView: View { init(authService: AuthenticationService) { self.authService = authService notificationService = authService.client.map { NotificationService(client: $0) } + + let state = PaginationState() + state.cacheName = "notifications" + state.loadFromCache() + _pagination = State(initialValue: state) } private var statusTypes: [String] { @@ -92,7 +97,9 @@ struct NotificationsOverviewView: View { await reloadNotifications().value } .task { - reloadNotifications() + if !pagination.hasLoaded { + reloadNotifications() + } } .onChange(of: statusFilter) { reloadNotifications(clearItems: true) @@ -147,7 +154,7 @@ struct NotificationsOverviewView: View { @discardableResult private func reloadNotifications(clearItems: Bool = false) -> Task { guard let notificationService else { return Task {} } - return pagination.reload(clearItems: clearItems) { [self] page, limit in + return pagination.reloadAndCache(clearItems: clearItems) { [self] page, limit in try await notificationService.fetchNotifications( statusTypes: statusTypes, page: page, @@ -158,7 +165,7 @@ struct NotificationsOverviewView: View { private func loadMoreNotifications() async { guard let notificationService else { return } - await pagination.loadMore { page, limit in + await pagination.loadMoreAndCache { page, limit in try await notificationService.fetchNotifications( statusTypes: statusTypes, page: page, diff --git a/Forji/Forji/Views/PullRequestListView.swift b/Forji/Forji/Views/PullRequestListView.swift index af679f7..4f97247 100644 --- a/Forji/Forji/Views/PullRequestListView.swift +++ b/Forji/Forji/Views/PullRequestListView.swift @@ -14,6 +14,7 @@ struct PullRequestListView: View { self.repository = repository self.authService = authService prService = authService.client.map { PullRequestService(client: $0) } + } private var owner: String { diff --git a/Forji/Forji/Views/RepositoryListView.swift b/Forji/Forji/Views/RepositoryListView.swift index f50f49c..22938f7 100644 --- a/Forji/Forji/Views/RepositoryListView.swift +++ b/Forji/Forji/Views/RepositoryListView.swift @@ -3,25 +3,23 @@ import SwiftUI struct RepositoryListView: View { @State private var authService: AuthenticationService - @State private var repositories: [Repository] = [] + @State private var pagination = PaginationState() @State private var starredRepoIds: Set = [] - @State private var isLoading = false - @State private var errorMessage: String? - @State private var showError = false @State private var selectedFilter: RepositoryFilter = .all @State private var searchText = "" @State private var searchTask: Task? - @State private var hasMore = true - @State private var currentPage = 1 @State private var starringInFlight: Set = [] - @State private var hasLoaded = false private let repositoryService: RepositoryService? - private let pageSize = 20 init(authService: AuthenticationService) { self.authService = authService repositoryService = authService.client.map { RepositoryService(client: $0) } + + let state = PaginationState() + state.cacheName = "repos" + state.loadFromCache() + _pagination = State(initialValue: state) } enum RepositoryFilter: String, CaseIterable { @@ -37,6 +35,7 @@ struct RepositoryListView: View { } var body: some View { + @Bindable var pagination = pagination VStack(spacing: 0) { SegmentedPickerSection( title: "Filter", @@ -46,7 +45,7 @@ struct RepositoryListView: View { ) List { - if isLoading, repositories.isEmpty { + if pagination.isLoading, pagination.items.isEmpty { Section { HStack { Spacer() @@ -55,7 +54,7 @@ struct RepositoryListView: View { } .listRowBackground(Color.clear) } - } else if repositories.isEmpty { + } else if pagination.items.isEmpty { ContentUnavailableView { Label("No Repositories", systemImage: "folder.badge.questionmark") .foregroundStyle(.secondary) @@ -68,7 +67,7 @@ struct RepositoryListView: View { } } else { Section { - ForEach(repositories) { repo in + ForEach(pagination.items) { repo in NavigationLink { RepositoryDetailView(repository: repo, authService: authService) } label: { @@ -82,13 +81,13 @@ struct RepositoryListView: View { } } - if hasMore { + if pagination.hasMore { ProgressView() .frame(maxWidth: .infinity) .listRowBackground(Color.clear) .accessibilityIdentifier("load-more-indicator") .task { - await loadMoreRepositories() + await loadMore() } } } @@ -97,123 +96,64 @@ struct RepositoryListView: View { .listStyle(.insetGrouped) .accessibilityIdentifier("repo-list") .refreshable { - currentPage = 1 - hasMore = true - await loadRepositories() + await reloadRepositories().value } } .navigationTitle("Repositories") .searchable(text: $searchText, prompt: "Search repositories") .task { - if !hasLoaded { - await loadRepositories() + if !pagination.hasLoaded { + reloadRepositories() } } .onChange(of: selectedFilter) { _, _ in - Task { - currentPage = 1 - hasMore = true - repositories = [] - await loadRepositories() - } + reloadRepositories(clearItems: true) } .debouncedSearch(text: $searchText, task: $searchTask) { - currentPage = 1 - hasMore = true - await loadRepositories() + await reloadRepositories().value } - .errorAlert(message: $errorMessage, isPresented: $showError) + .errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError) } - private func loadRepositories() async { + @discardableResult + private func reloadRepositories(clearItems: Bool = false) -> Task { + guard let repositoryService else { return Task {} } + return pagination.reloadAndCache(clearItems: clearItems) { [self] page, limit in + async let repos = fetchRepositories(service: repositoryService, page: page, limit: limit) + async let _ = loadStarredIds() + return try await repos + } + } + + private func loadMore() async { guard let repositoryService else { return } - isLoading = true - errorMessage = nil - - do { - async let allStarredIds = repositoryService.fetchAllStarredRepoIds() - - if searchText.isEmpty { - switch selectedFilter { - case .all: - let fetched = try await repositoryService.fetchUserRepositories(page: 1, limit: pageSize) - repositories = fetched - hasMore = fetched.count >= pageSize - case .starred: - let fetched = try await repositoryService.fetchStarredRepositories(page: 1, limit: pageSize) - repositories = fetched - hasMore = fetched.count >= pageSize - } - } else { - let results = try await repositoryService.searchRepositories( - query: searchText, page: 1, limit: pageSize, - ) - switch selectedFilter { - case .all: - repositories = results - hasMore = results.count >= pageSize - case .starred: - let ids = try await allStarredIds - let filtered = results.filter { ids.contains($0.id) } - repositories = filtered - hasMore = results.count >= pageSize - } - } - - starredRepoIds = try await allStarredIds - currentPage = 2 - hasLoaded = true - } catch is CancellationError { - // Ignore cancellation - } catch { - errorMessage = error.localizedDescription - showError = true + await pagination.loadMoreAndCache { [self] page, limit in + try await fetchRepositories(service: repositoryService, page: page, limit: limit) } - - isLoading = false } - private func loadMoreRepositories() async { - guard let repositoryService, hasMore, !isLoading else { return } - isLoading = true - - do { - if searchText.isEmpty { - switch selectedFilter { - case .all: - let fetched = try await repositoryService.fetchUserRepositories(page: currentPage, limit: pageSize) - repositories.append(contentsOf: fetched) - hasMore = fetched.count >= pageSize - case .starred: - let fetched = try await repositoryService.fetchStarredRepositories( - page: currentPage, limit: pageSize, - ) - repositories.append(contentsOf: fetched) - hasMore = fetched.count >= pageSize - } - } else { - let results = try await repositoryService.searchRepositories( - query: searchText, page: currentPage, limit: pageSize, - ) - switch selectedFilter { - case .all: - repositories.append(contentsOf: results) - hasMore = results.count >= pageSize - case .starred: - let filtered = results.filter { starredRepoIds.contains($0.id) } - repositories.append(contentsOf: filtered) - hasMore = results.count >= pageSize - } + private func fetchRepositories(service: RepositoryService, page: Int, limit: Int) async throws -> [Repository] { + if searchText.isEmpty { + switch selectedFilter { + case .all: + return try await service.fetchUserRepositories(page: page, limit: limit) + case .starred: + return try await service.fetchStarredRepositories(page: page, limit: limit) } - currentPage += 1 - } catch is CancellationError { - // Ignore cancellation - } catch { - errorMessage = error.localizedDescription - showError = true + } else { + let results = try await service.searchRepositories(query: searchText, page: page, limit: limit) + if selectedFilter == .starred { + return results.filter { starredRepoIds.contains($0.id) } + } + return results } + } - isLoading = false + private func loadStarredIds() async { + guard let repositoryService else { return } + if let ids = try? await repositoryService.fetchAllStarredRepoIds() { + starredRepoIds = ids + } } private func toggleStar(for repo: Repository) async { @@ -241,9 +181,7 @@ struct RepositoryListView: View { try await repositoryService.starRepository(owner: owner, repo: repoName) } if selectedFilter == .starred { - currentPage = 1 - hasMore = true - await loadRepositories() + reloadRepositories(clearItems: true) } } catch { // Revert optimistic update @@ -252,8 +190,8 @@ struct RepositoryListView: View { } else { starredRepoIds.remove(repo.id) } - errorMessage = error.localizedDescription - showError = true + pagination.errorMessage = error.localizedDescription + pagination.showError = true } starringInFlight.remove(repo.id) } diff --git a/Forji/Forji/Views/SearchableOverviewView.swift b/Forji/Forji/Views/SearchableOverviewView.swift index 59bd942..7f23960 100644 --- a/Forji/Forji/Views/SearchableOverviewView.swift +++ b/Forji/Forji/Views/SearchableOverviewView.swift @@ -56,6 +56,11 @@ struct SearchableOverviewView: View { detailContent = detail createContent = createView issueService = authService.client.map { IssueService(client: $0) } + + let state = PaginationState() + state.cacheName = issueType + state.loadFromCache() + _pagination = State(initialValue: state) } private var emptyDescription: String { @@ -288,10 +293,8 @@ struct SearchableOverviewView: View { } } - /// Fires parallel requests for each involvement type and merges results. - /// The API uses AND logic when multiple flags are set, so we need separate - /// calls to get OR-union semantics for "All" involvement. - /// Individual requests that time out return empty so others still appear. + /// The API uses AND logic when multiple flags are set, so we fire separate + /// requests per flag to get OR-union semantics. Timeouts return empty. private func searchInvolved(service: IssueService, page: Int, limit: Int) async throws -> [Issue] { let type = issueType let state = stateFilter.rawValue @@ -320,8 +323,6 @@ struct SearchableOverviewView: View { return flags } - /// Runs a fetch; returns `[]` on timeout so one slow request does not - /// block the rest. All other errors (auth, decoding, etc.) propagate. private func resilientFetch(_ fetch: () async throws -> [Issue]) async throws -> [Issue] { do { return try await fetch() @@ -330,8 +331,6 @@ struct SearchableOverviewView: View { } } - /// Merges multiple batches of issues, removing duplicates by ID and - /// sorting by most recently updated first. static func deduplicateAndSort(_ batches: [[Issue]]) -> [Issue] { var seen = Set() var merged = [Issue]() @@ -344,14 +343,14 @@ struct SearchableOverviewView: View { @discardableResult private func reloadItems(clearItems: Bool = false) -> Task { guard let issueService else { return Task {} } - return pagination.reload(clearItems: clearItems) { [self] page, limit in + return pagination.reloadAndCache(clearItems: clearItems) { [self] page, limit in try await searchIssues(service: issueService, page: page, limit: limit) } } private func loadMore() async { guard let issueService else { return } - await pagination.loadMore { [self] page, limit in + await pagination.loadMoreAndCache { [self] page, limit in try await searchIssues(service: issueService, page: page, limit: limit) } } diff --git a/Forji/Forji/Views/SettingsTabView.swift b/Forji/Forji/Views/SettingsTabView.swift index 82fdcaa..579b89c 100644 --- a/Forji/Forji/Views/SettingsTabView.swift +++ b/Forji/Forji/Views/SettingsTabView.swift @@ -10,6 +10,8 @@ struct SettingsTabView: View { @AppStorage("appearance") private var appearance: AppAppearance = .system @AppStorage("notificationsEnabled") private var notificationsEnabled = false @State private var authService: AuthenticationService + @State private var showClearCacheConfirmation = false + @State private var showClearCacheSuccess = false @Environment(\.modelContext) private var modelContext init(authService: AuthenticationService) { @@ -67,6 +69,27 @@ struct SettingsTabView: View { } } + Section("Data") { + Button { + showClearCacheConfirmation = true + } label: { + Label("Clear Cache", systemImage: "trash") + } + .accessibilityIdentifier("clear-cache-button") + .confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) { + Button("Clear Cache", role: .destructive) { + clearCache() + } + } message: { + Text("This removes all cached data. Items will be fetched from the server on next load.") + } + .alert("Cache Cleared", isPresented: $showClearCacheSuccess) { + Button("OK") {} + } message: { + Text("All cached data has been removed.") + } + } + Section { Button { authService.disconnect() @@ -88,6 +111,11 @@ struct SettingsTabView: View { .navigationTitle("Settings") } + private func clearCache() { + DiskCache.removeAll() + showClearCacheSuccess = true + } + private func enableNotifications() async { let center = UNUserNotificationCenter.current() do { @@ -119,6 +147,8 @@ struct MergedSettingsTabView: View { @AppStorage("appearance") private var appearance: AppAppearance = .system @AppStorage("notificationsEnabled") private var notificationsEnabled = false @AppStorage("defaultAllInstances") private var defaultAllInstances = false + @State private var showClearCacheConfirmation = false + @State private var showClearCacheSuccess = false @Environment(\.modelContext) private var modelContext @Query private var allInstances: [ForgejoInstance] let manager: MultiInstanceManager @@ -193,6 +223,27 @@ struct MergedSettingsTabView: View { .font(.caption) } + Section("Data") { + Button { + showClearCacheConfirmation = true + } label: { + Label("Clear Cache", systemImage: "trash") + } + .accessibilityIdentifier("clear-cache-button") + .confirmationDialog("Clear Cache", isPresented: $showClearCacheConfirmation) { + Button("Clear Cache", role: .destructive) { + clearCache() + } + } message: { + Text("This removes all cached data. Items will be fetched from the server on next load.") + } + .alert("Cache Cleared", isPresented: $showClearCacheSuccess) { + Button("OK") {} + } message: { + Text("All cached data has been removed.") + } + } + Section { Button { onDisconnect?() @@ -207,6 +258,11 @@ struct MergedSettingsTabView: View { .navigationTitle("Settings") } + private func clearCache() { + DiskCache.removeAll() + showClearCacheSuccess = true + } + private func enableNotifications() async { let center = UNUserNotificationCenter.current() do { diff --git a/Forji/ForjiTests/DiskCacheTests.swift b/Forji/ForjiTests/DiskCacheTests.swift new file mode 100644 index 0000000..74b1d69 --- /dev/null +++ b/Forji/ForjiTests/DiskCacheTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import Forji + +struct DiskCacheTests { + + @Test func saveAndLoadRoundTrip() { + DiskCache.save(["a", "b", "c"], as: "test-roundtrip") + let items: [String]? = DiskCache.load(as: "test-roundtrip") + #expect(items == ["a", "b", "c"]) + } + + @Test func loadMissingReturnsNil() { + let items: [String]? = DiskCache.load(as: "nonexistent-\(UUID().uuidString)") + #expect(items == nil) + } + + @Test func overwriteExistingFile() { + DiskCache.save(["old"], as: "test-overwrite") + DiskCache.save(["new"], as: "test-overwrite") + let items: [String]? = DiskCache.load(as: "test-overwrite") + #expect(items == ["new"]) + } + + @Test func differentNamesAreIsolated() { + DiskCache.save([1, 2], as: "test-alpha") + DiskCache.save([3, 4], as: "test-beta") + let alpha: [Int]? = DiskCache.load(as: "test-alpha") + let beta: [Int]? = DiskCache.load(as: "test-beta") + #expect(alpha == [1, 2]) + #expect(beta == [3, 4]) + } + + @Test func removeAllClearsEverything() { + DiskCache.save(["x"], as: "test-remove") + DiskCache.removeAll() + let items: [String]? = DiskCache.load(as: "test-remove") + #expect(items == nil) + } + + @Test func emptyArrayReturnsNil() { + DiskCache.save([String](), as: "test-empty") + let items: [String]? = DiskCache.load(as: "test-empty") + #expect(items == nil) + } + + @Test func typeMismatchReturnsNil() { + DiskCache.save(["text"], as: "test-mismatch") + let wrong: [Int]? = DiskCache.load(as: "test-mismatch") + #expect(wrong == nil) + } +} diff --git a/Forji/ForjiTests/PaginationStateCacheTests.swift b/Forji/ForjiTests/PaginationStateCacheTests.swift new file mode 100644 index 0000000..1e97a30 --- /dev/null +++ b/Forji/ForjiTests/PaginationStateCacheTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +@testable import Forji + +@Suite(.serialized) +struct PaginationStateCacheTests { + + init() { + DiskCache.removeAll() + } + + @Test @MainActor func reloadAndCachePersistsItems() async { + let pagination = PaginationState(pageSize: 20) + pagination.cacheName = "test-persist" + + await pagination.reloadAndCache { _, _ in ["a", "b", "c"] }.value + + let cached: [String]? = DiskCache.load(as: "test-persist") + #expect(cached == ["a", "b", "c"]) + } + + @Test @MainActor func loadFromCacheRestoresItems() { + DiskCache.save(["x", "y"], as: "test-restore") + + let pagination = PaginationState(pageSize: 20) + pagination.cacheName = "test-restore" + pagination.loadFromCache() + + #expect(pagination.items == ["x", "y"]) + #expect(!pagination.hasLoaded) + } + + @Test @MainActor func loadFromCacheSkipsWhenAlreadyLoaded() async { + DiskCache.save(["cached"], as: "test-skip") + + let pagination = PaginationState(pageSize: 20) + pagination.cacheName = "test-skip" + + await pagination.reloadAndCache { _, _ in ["fresh"] }.value + #expect(pagination.hasLoaded) + + pagination.loadFromCache() + #expect(pagination.items == ["fresh"]) + } + + @Test @MainActor func noCacheNameSkipsPersistence() async { + let pagination = PaginationState(pageSize: 20) + + await pagination.reloadAndCache { _, _ in ["no-name"] }.value + + pagination.loadFromCache() + #expect(pagination.items == ["no-name"]) + } + + @Test @MainActor func loadMoreAndCacheSaves() async { + let pagination = PaginationState(pageSize: 2) + pagination.cacheName = "test-loadmore" + + await pagination.reloadAndCache { _, _ in ["a", "b"] }.value + #expect(pagination.hasMore) + + await pagination.loadMoreAndCache { _, _ in ["c"] } + + let cached: [String]? = DiskCache.load(as: "test-loadmore") + #expect(cached == ["a", "b", "c"]) + } + + @Test @MainActor func failedReloadDoesNotOverwriteCache() async { + let pagination = PaginationState(pageSize: 20) + pagination.cacheName = "test-fail" + + await pagination.reloadAndCache { _, _ in ["good"] }.value + + await pagination.reloadAndCache(clearItems: true) { _, _ in + throw URLError(.badServerResponse) + }.value + + let cached: [String]? = DiskCache.load(as: "test-fail") + #expect(cached == ["good"]) + } +}