mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
feat: persistent cache #17
This commit is contained in:
parent
c42ed9552e
commit
5aa65525cf
15 changed files with 485 additions and 162 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
37
Forji/Forji/Helpers/DiskCache.swift
Normal file
37
Forji/Forji/Helpers/DiskCache.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ struct IssueListView: View {
|
|||
self.repository = repository
|
||||
self.authService = authService
|
||||
issueService = authService.client.map { IssueService(client: $0) }
|
||||
|
||||
}
|
||||
|
||||
private var owner: String {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ struct PullRequestListView: View {
|
|||
self.repository = repository
|
||||
self.authService = authService
|
||||
prService = authService.client.map { PullRequestService(client: $0) }
|
||||
|
||||
}
|
||||
|
||||
private var owner: String {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
52
Forji/ForjiTests/DiskCacheTests.swift
Normal file
52
Forji/ForjiTests/DiskCacheTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
81
Forji/ForjiTests/PaginationStateCacheTests.swift
Normal file
81
Forji/ForjiTests/PaginationStateCacheTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue