Merge pull request 'feat/persistent-cache' (#25) from feat/persistent-cache into main

Reviewed-on: https://codeberg.org/secana/Forji/pulls/25
This commit is contained in:
secana 2026-03-28 19:32:18 +01:00
commit 095adfb22a
16 changed files with 498 additions and 164 deletions

View file

@ -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<ForgejoInstance> { $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)
}

View file

@ -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<ForgejoInstance>(
predicate: #Predicate { $0.isDefault },
)
let allDescriptor = FetchDescriptor<ForgejoInstance>()
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)

View file

@ -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<T: Codable>(_ 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<T: Codable>(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)
}
}

View file

@ -13,6 +13,7 @@ final class PaginationState<Item> {
private var currentPage = 1
private var loadTask: Task<Void, Never>?
let pageSize: Int
var cacheName: String?
init(pageSize: Int = 20) {
self.pageSize = pageSize
@ -24,8 +25,6 @@ final class PaginationState<Item> {
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<Item> {
if clearItems { items = [] }
currentPage = 1
hasMore = true
hasLoaded = false
isLoading = true
showError = false
let pageSize = pageSize
@ -96,3 +96,37 @@ final class PaginationState<Item> {
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<Void, Never> {
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()
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -14,6 +14,7 @@ struct IssueListView: View {
self.repository = repository
self.authService = authService
issueService = authService.client.map { IssueService(client: $0) }
}
private var owner: String {

View file

@ -12,6 +12,11 @@ struct NotificationsOverviewView: View {
init(authService: AuthenticationService) {
self.authService = authService
notificationService = authService.client.map { NotificationService(client: $0) }
let state = PaginationState<NotificationThread>()
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<Void, Never> {
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,

View file

@ -14,6 +14,7 @@ struct PullRequestListView: View {
self.repository = repository
self.authService = authService
prService = authService.client.map { PullRequestService(client: $0) }
}
private var owner: String {

View file

@ -27,7 +27,7 @@ struct RepositoryDetailView: View {
_selectedBranch = State(initialValue: repository.defaultBranch ?? "main")
}
enum DetailTab: String, CaseIterable {
enum DetailTab: String {
case code = "Code"
case issues = "Issues"
case pulls = "Pull Requests"
@ -41,6 +41,17 @@ struct RepositoryDetailView: View {
}
}
private var availableTabs: [DetailTab] {
var tabs: [DetailTab] = [.code]
if repository.hasIssues != false {
tabs.append(.issues)
}
if repository.hasPullRequests != false {
tabs.append(.pulls)
}
return tabs
}
var body: some View {
VStack(spacing: 0) {
// Repository header
@ -99,7 +110,7 @@ struct RepositoryDetailView: View {
// Tab selector
Picker("View", selection: $selectedTab) {
ForEach(DetailTab.allCases, id: \.self) { tab in
ForEach(availableTabs, id: \.self) { tab in
Label(tab.rawValue, systemImage: tab.icon)
.tag(tab)
}

View file

@ -3,25 +3,23 @@ import SwiftUI
struct RepositoryListView: View {
@State private var authService: AuthenticationService
@State private var repositories: [Repository] = []
@State private var pagination = PaginationState<Repository>()
@State private var starredRepoIds: Set<Int> = []
@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<Void, Never>?
@State private var hasMore = true
@State private var currentPage = 1
@State private var starringInFlight: Set<Int> = []
@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<Repository>()
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<Void, Never> {
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)
}

View file

@ -56,6 +56,11 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
detailContent = detail
createContent = createView
issueService = authService.client.map { IssueService(client: $0) }
let state = PaginationState<Issue>()
state.cacheName = issueType
state.loadFromCache()
_pagination = State(initialValue: state)
}
private var emptyDescription: String {
@ -288,10 +293,8 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: 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<Row: View, Detail: View, CreateView: View>: 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<Row: View, Detail: View, CreateView: View>: 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<Int>()
var merged = [Issue]()
@ -344,14 +343,14 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
@discardableResult
private func reloadItems(clearItems: Bool = false) -> Task<Void, Never> {
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)
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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<String>(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<String>(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<String>(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<String>(pageSize: 20)
await pagination.reloadAndCache { _, _ in ["no-name"] }.value
pagination.loadFromCache()
#expect(pagination.items == ["no-name"])
}
@Test @MainActor func loadMoreAndCacheSaves() async {
let pagination = PaginationState<String>(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<String>(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"])
}
}