From eda9e535b36186541d6f38a5970ab83f00786a22 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 18:59:32 +0200 Subject: [PATCH 1/9] feat: add dedicated drive management UI --- doc/APP_AND_DOMAINS.md | 80 +- potassiumProvider/ContentView.swift | 353 +------- .../PotassiumProviderAppModel.swift | 102 ++- potassiumProvider/ProviderSetupView.swift | 837 ++++++++++++++++++ potassiumProvider/ProviderStatusView.swift | 50 +- potassiumProvider/ProviderUITestFixture.swift | 84 ++ potassiumProvider/potassiumProviderApp.swift | 10 +- .../ProviderSetupViewTests.swift | 256 ++++++ .../potassiumProviderTests.swift | 5 + .../potassiumProviderUITests.swift | 68 ++ 10 files changed, 1408 insertions(+), 437 deletions(-) create mode 100644 potassiumProvider/ProviderSetupView.swift create mode 100644 potassiumProvider/ProviderUITestFixture.swift create mode 100644 potassiumProviderTests/ProviderSetupViewTests.swift diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 3bb3696..aa8f536 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -22,7 +22,8 @@ The app handles: and sanitized provider activity - revealing each configured drive through its File Provider user-visible URL - requesting an immediate working-set refresh for one configured drive -- showing Setup for account and drive configuration +- showing Setup for account and drive configuration through dedicated account + and drive-management destinations On macOS, the app runs as an accessory menu bar app: it hides its Dock icon and keeps an atom status item visible while the process is running. Clicking the @@ -30,9 +31,21 @@ status item reveals the setup window, and right-clicking it opens a menu with a close option. Closing the setup window does not quit the app. The main window has three tabs: Status, Setup, and Activities. Status is the -first tab and becomes the default once at least one File Provider domain is -configured. If no drives are configured, the app opens on Setup by default while -leaving the empty Status dashboard available. +default tab and is a read-only dashboard. Account and drive actions live under +Setup rather than on the dashboard. + +Setup uses a three-level navigation hierarchy: + +1. Setup lists connected accounts and summarizes discovered and configured + drive counts. +2. An account screen owns rename, drive refresh, logout, and the account's drive + list. +3. Each drive opens a dedicated management screen for its File Provider and, + on macOS, known-folder actions. + +Account creation has its own destination. Infomaniak OAuth is the primary path; +manual access-token entry remains available in an Advanced section for +development. The Status dashboard only uses local/provider-safe state: local account records, configured domain records, currently loaded kDrive summaries, SQLite listing @@ -40,12 +53,11 @@ snapshot aggregates, and sanitized activity/conflict counts. It does not fetch remote account profile data, quotas, OAuth token details, private links, or file contents. -Each drive card has a Show in Finder button on macOS or Show in Files on iOS and -visionOS. The app asks that domain's `NSFileProviderManager` for the root -container's user-visible URL and opens it through the system. Sync Now signals -the domain's working-set enumerator, then refreshes the local Status dashboard -after File Provider accepts the request. These controls do not enumerate files -or bypass the extension. +Each configured drive's management screen has a Show in Finder button on macOS +or Show in Files on iOS and visionOS. The app asks that domain's +`NSFileProviderManager` for the root container's user-visible URL and opens it +through the system. Sync Now signals the domain's working-set enumerator. These +controls do not enumerate files or bypass the extension. The app does not enumerate files itself. File listing is handled by the File Provider extension after the system asks for an enumerator. Each extension @@ -70,6 +82,12 @@ tokens and expired non-refreshable tokens are skipped silently so the account ca be refreshed manually or reconnected without creating repeated setup-page errors. +An account's drive list is the union of current remote discovery and stored +domain configurations. A configured drive therefore remains manageable when +remote discovery is unavailable or no longer returns that drive. Its detail +screen uses the saved drive name and explicitly marks remote details as +unavailable. + ## Domain Configuration `ProviderDomainConfiguration` is the local record that connects an Apple File @@ -102,7 +120,8 @@ The add flow is: 1. The user adds an account through OAuth or by saving a manual access token. 2. The app creates a local account record and saves the token under that account. 3. The app loads kDrives for that account through `PotassiumKDriveService.listDrives()`. -4. The user chooses a discovered drive row for that account. +4. The user opens a discovered drive and chooses **Add to Files** on its + management screen. 5. `PotassiumProviderAppModel.addDomain()` creates a `ProviderDomainConfiguration` whose display name is derived from the drive name and, when needed, the account display name. @@ -123,9 +142,9 @@ system's registered display name. ## Desktop & Documents On macOS -On macOS 15 or later, a configured drive row can opt in to Apple's known-folder -feature. This is not arbitrary-folder sync: Apple currently permits Desktop and -Documents to be claimed only together. +On macOS 15 or later, a configured drive's management screen can opt in to +Apple's known-folder feature. This is not arbitrary-folder sync: Apple currently +permits Desktop and Documents to be claimed only together. The app resolves the existing root-level kDrive directory named `Private` and uses its item identifier as the common parent for `Private/Desktop` and @@ -142,6 +161,12 @@ matching control to stop syncing both folders through `releaseKnownFolders`. ## Removing A Domain +Removal is initiated from the drive-management screen and requires explicit +confirmation. The confirmation identifies the provider-local state being +cleared and states that remote kDrive files are not deleted. After successful +removal, a remotely discovered drive stays on screen in its unconfigured state +and can be added again. + The remove flow is: 1. On macOS, the app refreshes live known-folder state and releases Desktop and @@ -156,14 +181,29 @@ The remove flow is: Removing a domain only removes provider state from this app. It does not delete remote kDrive files. +## Configured Drive Actions + +A configured drive's management screen owns all direct File Provider actions: + +- show the domain root in Finder on macOS or Files on other platforms +- request a fresh working-set sync +- remove the drive from Files +- enable, repair, or stop Desktop & Documents sync on supported macOS versions + +Only one mutating or provider-management action can run for a drive at a time. +The screen disables conflicting controls and shows operation progress. Drive +discovery refresh remains account-scoped even when requested from a drive +screen. + ## Logging Out One Account -Independent logout first removes every File Provider domain tied to that -account, including domain JSON, snapshots, activity/conflict rows, and thumbnail -cache entries. On macOS this includes releasing any known folders owned by those -domains; a release failure stops logout. Only after domain cleanup succeeds does -the app delete that account's keychain token and account JSON. Domains and tokens -for other accounts are left untouched. +Independent logout is confirmed from the account screen. It first removes every +File Provider domain tied to that account, including domain JSON, snapshots, +activity/conflict rows, and thumbnail cache entries. On macOS this includes +releasing any known folders owned by those domains; a release failure stops +logout. Only after domain cleanup succeeds does the app delete that account's +keychain token and account JSON. Domains and tokens for other accounts are left +untouched, and remote kDrive files are never deleted by logout. For development, `scripts/uninstall-file-provider.sh` can perform the same domain-detach path outside the UI. It runs the signed macOS app with a hidden diff --git a/potassiumProvider/ContentView.swift b/potassiumProvider/ContentView.swift index d6981f6..817d452 100644 --- a/potassiumProvider/ContentView.swift +++ b/potassiumProvider/ContentView.swift @@ -3,7 +3,6 @@ import SwiftUI struct ContentView: View { @ObservedObject var model: PotassiumProviderAppModel - @State private var accountPendingLogout: ProviderAccount? @State private var selectedTab: ProviderAppTab init(model: PotassiumProviderAppModel) { @@ -23,7 +22,7 @@ struct ContentView: View { } .tag(ProviderAppTab.status) - setupView + ProviderSetupView(model: model) .tabItem { Label("Setup", systemImage: "externaldrive.connected.to.line.below") } @@ -41,218 +40,6 @@ struct ContentView: View { ) } } - - private var setupView: some View { - NavigationStack { - List { - addAccountSection - - if model.accounts.isEmpty { - Section { - Label("No accounts connected", systemImage: "person.crop.circle.badge.questionmark") - .foregroundStyle(.secondary) - } - } else { - ForEach(model.accounts) { account in - accountSection(account) - } - } - - if let statusMessage = model.statusMessage { - Section { - Label(statusMessage, systemImage: "checkmark.circle") - .foregroundStyle(.secondary) - } - } - } - .navigationTitle("potassiumProvider") - .task(id: setupAutoLoadTaskID) { - await model.loadDrivesForAccountsIfPossible() - } - .toolbar { - ToolbarItem(placement: refreshToolbarPlacement) { - Button { - Task { - for account in model.accounts { - await model.loadDrives(accountIdentifier: account.accountIdentifier) - } - } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(model.accounts.isEmpty) - } - } - .alert("kDrive", isPresented: errorBinding) { - Button("OK", role: .cancel) {} - } message: { - Text(model.errorMessage ?? "") - } - .alert(item: $accountPendingLogout) { account in - Alert( - title: Text("Log Out \(account.displayName)?"), - message: Text("This removes this account's drives from Files and clears its local provider state."), - primaryButton: .destructive(Text("Log Out")) { - Task { await model.logoutAccount(account) } - }, - secondaryButton: .cancel() - ) - } - } - } - - private var addAccountSection: some View { - Section("Accounts") { - Button { - Task { await model.connectWithOAuth() } - } label: { - Label(model.isConnecting ? "Connecting" : "Add Infomaniak Account", systemImage: "person.crop.circle.badge.plus") - } - .disabled(model.isConnecting) - - HStack { - SecureField("Access token", text: $model.manualAccessToken) - .platformPasswordEntry() - Button { - Task { await model.saveManualAccessToken() } - } label: { - Label("Save Token", systemImage: "key.fill") - } - .disabled(model.manualAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - private func accountSection(_ account: ProviderAccount) -> some View { - Section { - HStack(spacing: 12) { - Label { - TextField( - "Account name", - text: Binding { - model.account(accountIdentifier: account.accountIdentifier)?.displayName ?? account.displayName - } set: { newValue in - Task { - await model.renameAccount( - accountIdentifier: account.accountIdentifier, - displayName: newValue - ) - } - } - ) - } icon: { - Image(systemName: account.authenticationKind == .oauth ? "person.crop.circle" : "key") - } - - Spacer() - - Button { - Task { await model.loadDrives(accountIdentifier: account.accountIdentifier) } - } label: { - Label("Refresh Drives", systemImage: "arrow.clockwise") - } - .labelStyle(.iconOnly) - .disabled(model.canLoadDrives(for: account.accountIdentifier) == false) - - Button(role: .destructive) { - accountPendingLogout = account - } label: { - Label("Log Out", systemImage: "rectangle.portrait.and.arrow.right") - } - .labelStyle(.iconOnly) - } - - drivesView(account) - } header: { - Text(account.displayName) - } - } - - @ViewBuilder - private func drivesView(_ account: ProviderAccount) -> some View { - let drives = model.drives(for: account.accountIdentifier) - let configuredDomains = model.domains(for: account.accountIdentifier) - let configuredDomainsByDriveID = Dictionary(uniqueKeysWithValues: configuredDomains.map { ($0.driveID, $0) }) - let loadedDriveIDs = Set(drives.map(\.id)) - let configuredDomainsWithoutLoadedDrive = configuredDomains.filter { loadedDriveIDs.contains($0.driveID) == false } - - if drives.isEmpty && configuredDomainsWithoutLoadedDrive.isEmpty { - Button { - Task { await model.loadDrives(accountIdentifier: account.accountIdentifier) } - } label: { - Label( - model.isLoadingDrives(for: account.accountIdentifier) ? "Loading Drives" : "Load Drives", - systemImage: "externaldrive.connected.to.line.below" - ) - } - .disabled(model.canLoadDrives(for: account.accountIdentifier) == false) - } else { - ForEach(drives) { drive in - DriveConfigurationRow( - driveID: drive.id, - driveName: drive.name, - detail: "Drive \(drive.id) · \(drive.role)", - configuration: configuredDomainsByDriveID[drive.id], - knownFolderSyncState: configuredDomainsByDriveID[drive.id].map(model.knownFolderSyncState(for:)) ?? .unavailable, - isChangingKnownFolderSync: configuredDomainsByDriveID[drive.id].map(model.isChangingKnownFolderSync(for:)) ?? false - ) { - Task { - await model.addDomain( - accountIdentifier: account.accountIdentifier, - drive: drive - ) - } - } remove: { configuration in - Task { await model.removeDomain(configuration) } - } enableKnownFolderSync: { configuration in - Task { await model.enableKnownFolderSync(for: configuration) } - } disableKnownFolderSync: { configuration in - Task { await model.disableKnownFolderSync(for: configuration) } - } - } - - ForEach(configuredDomainsWithoutLoadedDrive) { domain in - DriveConfigurationRow( - driveID: domain.driveID, - driveName: domain.driveName, - detail: "Drive \(domain.driveID)", - configuration: domain, - knownFolderSyncState: model.knownFolderSyncState(for: domain), - isChangingKnownFolderSync: model.isChangingKnownFolderSync(for: domain) - ) { - Task { await model.addDomain(accountIdentifier: account.accountIdentifier) } - } remove: { configuration in - Task { await model.removeDomain(configuration) } - } enableKnownFolderSync: { configuration in - Task { await model.enableKnownFolderSync(for: configuration) } - } disableKnownFolderSync: { configuration in - Task { await model.disableKnownFolderSync(for: configuration) } - } - } - } - } - - private var errorBinding: Binding { - Binding { - model.errorMessage != nil - } set: { isPresented in - if isPresented == false { - model.errorMessage = nil - } - } - } - - private var refreshToolbarPlacement: ToolbarItemPlacement { - #if os(macOS) - .automatic - #else - .topBarTrailing - #endif - } - - private var setupAutoLoadTaskID: String { - model.accounts.map(\.accountIdentifier).joined(separator: "|") - } } enum ProviderAppTab: Hashable { @@ -267,121 +54,6 @@ enum ProviderAppTabSelectionPolicy { } } -private struct DriveConfigurationRow: View { - let driveID: Int - let driveName: String - let detail: String - let configuration: ProviderDomainConfiguration? - let knownFolderSyncState: ProviderKnownFolderSyncState - let isChangingKnownFolderSync: Bool - let add: () -> Void - let remove: (ProviderDomainConfiguration) -> Void - let enableKnownFolderSync: (ProviderDomainConfiguration) -> Void - let disableKnownFolderSync: (ProviderDomainConfiguration) -> Void - @State private var isStopSyncConfirmationPresented = false - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - Image(systemName: "externaldrive.fill") - .foregroundStyle(.tint) - VStack(alignment: .leading, spacing: 4) { - Text(driveName) - .font(.headline) - Text(configuration == nil ? detail : "\(detail) · In Files") - .font(.subheadline) - .foregroundStyle(.secondary) - } - Spacer() - if let configuration { - Button(role: .destructive) { - remove(configuration) - } label: { - Label("Remove from Files", systemImage: "trash") - } - .labelStyle(.iconOnly) - } else { - Button(action: add) { - Label("Use in Files", systemImage: "folder.badge.plus") - } - .labelStyle(.iconOnly) - } - } - - #if os(macOS) - if let configuration { - Divider() - HStack(spacing: 12) { - Image(systemName: "desktopcomputer") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 3) { - Text("Desktop & Documents") - .font(.subheadline.weight(.medium)) - Text(knownFolderDetail) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - if isChangingKnownFolderSync { - ProgressView() - .controlSize(.small) - } else { - knownFolderAction(for: configuration) - } - } - .confirmationDialog( - "Stop syncing Desktop and Documents?", - isPresented: $isStopSyncConfirmationPresented, - titleVisibility: .visible - ) { - Button("Stop Syncing", role: .destructive) { - disableKnownFolderSync(configuration) - } - Button("Cancel", role: .cancel) {} - } message: { - Text("macOS will stop replicating both folders with kDrive. Remote files in /private are not deleted.") - } - } - #endif - } - } - - #if os(macOS) - private var knownFolderDetail: String { - switch knownFolderSyncState { - case .active: - return "Syncing with /private/Desktop and /private/Documents" - case .partial: - return "Partially enabled · stop and enable again to repair" - case .inactive: - return "Sync both folders with kDrive /private" - case .unavailable: - return "File Provider status unavailable" - } - } - - @ViewBuilder - private func knownFolderAction(for configuration: ProviderDomainConfiguration) -> some View { - switch knownFolderSyncState { - case .active, .partial: - Button("Stop Syncing") { - isStopSyncConfirmationPresented = true - } - .buttonStyle(.bordered) - case .inactive: - Button("Sync") { - enableKnownFolderSync(configuration) - } - .buttonStyle(.borderedProminent) - case .unavailable: - Text("Unavailable") - .font(.caption) - .foregroundStyle(.secondary) - } - } - #endif -} - #Preview { ContentView(model: PotassiumProviderAppModel( accountStore: ProviderAccountFileStore( @@ -395,26 +67,3 @@ private struct DriveConfigurationRow: View { tokenStore: InMemoryOAuthTokenStore() )) } - -private extension View { - @ViewBuilder - func platformPasswordEntry() -> some View { - #if canImport(UIKit) - self - .textContentType(.password) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - #else - self - #endif - } - - @ViewBuilder - func platformNumberEntry() -> some View { - #if canImport(UIKit) - self.keyboardType(.numberPad) - #else - self - #endif - } -} diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index ba568fd..a325961 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -6,6 +6,20 @@ import Foundation import OSLog import PotassiumProviderCore +struct ProviderDriveKey: Hashable, Sendable { + let accountIdentifier: String + let driveID: Int +} + +enum ProviderDriveAction: Equatable, Sendable { + case addingToFiles + case removingFromFiles + case enablingKnownFolders + case disablingKnownFolders + case showingInFiles + case syncingNow +} + @MainActor final class PotassiumProviderAppModel: ObservableObject { private static let log = ProviderLog.app @@ -17,7 +31,7 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var loadingDriveAccountIdentifiers: Set = [] @Published private(set) var knownFolderSyncStatesByDomainIdentifier: [String: ProviderKnownFolderSyncState] = [:] @Published private(set) var knownFolderTransitionDomainIdentifiers: Set = [] - @Published private(set) var activeDomainActionIdentifiers: Set = [] + @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -45,6 +59,9 @@ final class PotassiumProviderAppModel: ObservableObject { snapshotStore: (any KDriveSnapshotStoring)? = nil, eventStore: (any KDriveProviderEventStoring)? = nil, automaticallyReloadStoredState: Bool = true, + initialAccounts: [ProviderAccount] = [], + initialDrivesByAccountIdentifier: [String: [KDriveDriveSummary]] = [:], + initialDomains: [ProviderDomainConfiguration] = [], fileProviderFactory: @escaping (String) -> any KDriveFileProviding = { PotassiumKDriveService(bearerToken: $0) } ) { self.accountStore = accountStore ?? Self.makeDefaultAccountStore() @@ -55,7 +72,12 @@ final class PotassiumProviderAppModel: ObservableObject { self.snapshotStore = snapshotStore ?? Self.makeDefaultSnapshotStore() self.eventStore = eventStore ?? Self.makeDefaultEventStore() self.fileProviderFactory = fileProviderFactory - statusMessage = "No accounts connected." + accounts = initialAccounts + drivesByAccountIdentifier = initialDrivesByAccountIdentifier + domains = initialDomains + statusMessage = initialAccounts.isEmpty + ? "No accounts connected." + : "Loaded \(initialAccounts.count) account\(initialAccounts.count == 1 ? "" : "s")." observeFileProviderDomainChanges() if automaticallyReloadStoredState { Task { await reloadStoredState() } @@ -120,8 +142,19 @@ final class PotassiumProviderAppModel: ObservableObject { knownFolderTransitionDomainIdentifiers.contains(configuration.domainIdentifier) } + func activeDriveAction(for key: ProviderDriveKey) -> ProviderDriveAction? { + activeDriveActions[key] + } + + func isPerformingDriveAction(for accountIdentifier: String) -> Bool { + activeDriveActions.keys.contains { $0.accountIdentifier == accountIdentifier } + } + func isPerformingDomainAction(_ domainIdentifier: String) -> Bool { - activeDomainActionIdentifiers.contains(domainIdentifier) + guard let configuration = domains.first(where: { $0.domainIdentifier == domainIdentifier }) else { + return false + } + return activeDriveActions[driveKey(for: configuration)] != nil } func selectedDriveID(for accountIdentifier: String) -> Int? { @@ -293,6 +326,9 @@ final class PotassiumProviderAppModel: ObservableObject { statusMessage = nil return } + let key = ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: draft.id) + guard beginDriveAction(.addingToFiles, for: key) else { return } + defer { endDriveAction(for: key) } var savedConfiguration: ProviderDomainConfiguration? do { @@ -339,6 +375,10 @@ final class PotassiumProviderAppModel: ObservableObject { } func removeDomain(_ configuration: ProviderDomainConfiguration) async { + let key = driveKey(for: configuration) + guard beginDriveAction(.removingFromFiles, for: key) else { return } + defer { endDriveAction(for: key) } + do { try await removeDomainAndLocalState(configuration) let synchronizedState = try await synchronizedDomainConfigurations() @@ -360,8 +400,8 @@ final class PotassiumProviderAppModel: ObservableObject { func enableKnownFolderSync(for configuration: ProviderDomainConfiguration) async { #if os(macOS) - guard beginKnownFolderTransition(for: configuration) else { return } - defer { knownFolderTransitionDomainIdentifiers.remove(configuration.domainIdentifier) } + guard beginKnownFolderTransition(.enablingKnownFolders, for: configuration) else { return } + defer { endKnownFolderTransition(for: configuration) } do { let token = try await usableToken(accountIdentifier: configuration.accountIdentifier) @@ -392,8 +432,8 @@ final class PotassiumProviderAppModel: ObservableObject { func disableKnownFolderSync(for configuration: ProviderDomainConfiguration) async { #if os(macOS) - guard beginKnownFolderTransition(for: configuration) else { return } - defer { knownFolderTransitionDomainIdentifiers.remove(configuration.domainIdentifier) } + guard beginKnownFolderTransition(.disablingKnownFolders, for: configuration) else { return } + defer { endKnownFolderTransition(for: configuration) } do { try await domainRegistrar.releaseKnownFolders(for: configuration) @@ -415,8 +455,9 @@ final class PotassiumProviderAppModel: ObservableObject { } func userVisibleRootURL(for configuration: ProviderDomainConfiguration) async -> URL? { - guard beginDomainAction(for: configuration) else { return nil } - defer { activeDomainActionIdentifiers.remove(configuration.domainIdentifier) } + let key = driveKey(for: configuration) + guard beginDriveAction(.showingInFiles, for: key) else { return nil } + defer { endDriveAction(for: key) } do { let url = try await domainRegistrar.userVisibleRootURL(for: configuration) @@ -440,8 +481,9 @@ final class PotassiumProviderAppModel: ObservableObject { } func syncNow(_ configuration: ProviderDomainConfiguration) async { - guard beginDomainAction(for: configuration) else { return } - defer { activeDomainActionIdentifiers.remove(configuration.domainIdentifier) } + let key = driveKey(for: configuration) + guard beginDriveAction(.syncingNow, for: key) else { return } + defer { endDriveAction(for: key) } do { try await domainRegistrar.signalWorkingSet(for: configuration) @@ -460,6 +502,12 @@ final class PotassiumProviderAppModel: ObservableObject { } func logoutAccount(_ account: ProviderAccount) async { + guard isPerformingDriveAction(for: account.accountIdentifier) == false else { + errorMessage = "Wait for the current drive action to finish before logging out \(account.displayName)." + statusMessage = nil + return + } + do { let accountDomains = domains(for: account.accountIdentifier) for domain in accountDomains { @@ -667,12 +715,36 @@ final class PotassiumProviderAppModel: ObservableObject { return token } - private func beginKnownFolderTransition(for configuration: ProviderDomainConfiguration) -> Bool { - knownFolderTransitionDomainIdentifiers.insert(configuration.domainIdentifier).inserted + private func driveKey(for configuration: ProviderDomainConfiguration) -> ProviderDriveKey { + ProviderDriveKey( + accountIdentifier: configuration.accountIdentifier, + driveID: configuration.driveID + ) + } + + private func beginDriveAction(_ action: ProviderDriveAction, for key: ProviderDriveKey) -> Bool { + guard activeDriveActions[key] == nil else { return false } + activeDriveActions[key] = action + return true + } + + private func endDriveAction(for key: ProviderDriveKey) { + activeDriveActions[key] = nil + } + + private func beginKnownFolderTransition( + _ action: ProviderDriveAction, + for configuration: ProviderDomainConfiguration + ) -> Bool { + let key = driveKey(for: configuration) + guard beginDriveAction(action, for: key) else { return false } + knownFolderTransitionDomainIdentifiers.insert(configuration.domainIdentifier) + return true } - private func beginDomainAction(for configuration: ProviderDomainConfiguration) -> Bool { - activeDomainActionIdentifiers.insert(configuration.domainIdentifier).inserted + private func endKnownFolderTransition(for configuration: ProviderDomainConfiguration) { + knownFolderTransitionDomainIdentifiers.remove(configuration.domainIdentifier) + endDriveAction(for: driveKey(for: configuration)) } private func refreshKnownFolderSyncStates() async throws { diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift new file mode 100644 index 0000000..d567ae4 --- /dev/null +++ b/potassiumProvider/ProviderSetupView.swift @@ -0,0 +1,837 @@ +import PotassiumProviderCore +import SwiftUI + +enum ProviderSetupRoute: Hashable { + case addAccount + case account(String) + case drive(ProviderDriveKey) +} + +struct ProviderDriveDescriptor: Identifiable, Equatable { + var id: ProviderDriveKey { + ProviderDriveKey(accountIdentifier: accountIdentifier, driveID: driveID) + } + + let accountIdentifier: String + let driveID: Int + let remote: KDriveDriveSummary? + let configuration: ProviderDomainConfiguration? + + var name: String { + remote?.name ?? configuration?.driveName ?? "kDrive \(driveID)" + } + + var role: String? { + guard let role = remote?.role, role.isEmpty == false else { return nil } + return role + } + + var remoteStatus: String? { + guard let status = remote?.status, status.isEmpty == false else { return nil } + return status + } + + var isInMaintenance: Bool { + remote?.isInMaintenance ?? false + } + + var isConfigured: Bool { + configuration != nil + } + + var remoteDetailsAreAvailable: Bool { + remote != nil + } + + static func merge( + accountIdentifier: String, + drives: [KDriveDriveSummary], + configurations: [ProviderDomainConfiguration] + ) -> [ProviderDriveDescriptor] { + var configurationsByDriveID: [Int: ProviderDomainConfiguration] = [:] + for configuration in configurations where configurationsByDriveID[configuration.driveID] == nil { + configurationsByDriveID[configuration.driveID] = configuration + } + + var seenDriveIDs: Set = [] + var descriptors = drives.compactMap { drive -> ProviderDriveDescriptor? in + guard seenDriveIDs.insert(drive.id).inserted else { return nil } + return ProviderDriveDescriptor( + accountIdentifier: accountIdentifier, + driveID: drive.id, + remote: drive, + configuration: configurationsByDriveID[drive.id] + ) + } + + descriptors.append(contentsOf: configurations + .filter { seenDriveIDs.insert($0.driveID).inserted } + .map { + ProviderDriveDescriptor( + accountIdentifier: accountIdentifier, + driveID: $0.driveID, + remote: nil, + configuration: $0 + ) + }) + return descriptors + } +} + +struct ProviderSetupView: View { + @ObservedObject var model: PotassiumProviderAppModel + @State private var path: [ProviderSetupRoute] = [] + + var body: some View { + NavigationStack(path: $path) { + accountList + .navigationDestination(for: ProviderSetupRoute.self) { route in + destination(for: route) + } + } + .alert("kDrive", isPresented: errorBinding) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } + } + + private var accountList: some View { + List { + Section { + NavigationLink(value: ProviderSetupRoute.addAccount) { + Label("Add Infomaniak Account", systemImage: "person.crop.circle.badge.plus") + .font(.headline) + } + .accessibilityIdentifier("setup.addAccount") + } + + Section("Accounts") { + if model.accounts.isEmpty { + ContentUnavailableView( + "No Accounts Connected", + systemImage: "person.crop.circle.badge.questionmark", + description: Text("Add an Infomaniak account to discover its kDrives.") + ) + } else { + ForEach(model.accounts) { account in + NavigationLink(value: ProviderSetupRoute.account(account.accountIdentifier)) { + ProviderAccountRow( + account: account, + loadedDriveCount: model.drives(for: account.accountIdentifier).count, + configuredDriveCount: model.domains(for: account.accountIdentifier).count, + isLoading: model.isLoadingDrives(for: account.accountIdentifier) + ) + } + .accessibilityIdentifier("setup.account.\(account.accountIdentifier)") + } + } + } + + if let statusMessage = model.statusMessage { + Section { + ProviderFeedbackLabel(message: statusMessage) + } + } + } + .navigationTitle("Setup") + .task(id: setupAutoLoadTaskID) { + await model.loadDrivesForAccountsIfPossible() + } + .toolbar { + ToolbarItem(placement: refreshToolbarPlacement) { + Button { + Task { + for account in model.accounts { + await model.loadDrives(accountIdentifier: account.accountIdentifier) + } + } + } label: { + Label("Refresh All Accounts", systemImage: "arrow.clockwise") + } + .disabled(model.accounts.isEmpty || model.loadingDriveAccountIdentifiers.isEmpty == false) + .accessibilityIdentifier("setup.refreshAll") + } + } + } + + @ViewBuilder + private func destination(for route: ProviderSetupRoute) -> some View { + switch route { + case .addAccount: + ProviderAddAccountView(model: model) + case .account(let accountIdentifier): + ProviderAccountManagementView( + model: model, + accountIdentifier: accountIdentifier + ) { + path.removeAll() + } + case .drive(let key): + ProviderDriveManagementView(model: model, key: key) + } + } + + private var errorBinding: Binding { + Binding { + model.errorMessage != nil + } set: { isPresented in + if isPresented == false { + model.errorMessage = nil + } + } + } + + private var setupAutoLoadTaskID: String { + model.accounts.map(\.accountIdentifier).joined(separator: "|") + } + + private var refreshToolbarPlacement: ToolbarItemPlacement { + #if os(macOS) + .automatic + #else + .topBarTrailing + #endif + } +} + +private struct ProviderAccountRow: View { + let account: ProviderAccount + let loadedDriveCount: Int + let configuredDriveCount: Int + let isLoading: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: account.authenticationKind.systemImage) + .font(.title3) + .foregroundStyle(.tint) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(account.displayName) + .font(.headline) + Text(account.authenticationKind.title) + .font(.caption) + .foregroundStyle(.secondary) + Text("\(loadedDriveCount) discovered · \(configuredDriveCount) in Files") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + if isLoading { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Loading drives") + } + } + .padding(.vertical, 4) + } +} + +private struct ProviderAddAccountView: View { + @ObservedObject var model: PotassiumProviderAppModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + Button { + Task { + let existingAccountIDs = Set(model.accounts.map(\.accountIdentifier)) + await model.connectWithOAuth() + if model.accounts.contains(where: { existingAccountIDs.contains($0.accountIdentifier) == false }) { + dismiss() + } + } + } label: { + HStack { + Label( + model.isConnecting ? "Connecting…" : "Continue with Infomaniak", + systemImage: "person.crop.circle.badge.plus" + ) + Spacer() + if model.isConnecting { + ProgressView() + .controlSize(.small) + } + } + } + .disabled(model.isConnecting) + .accessibilityIdentifier("addAccount.oauth") + } header: { + Text("Infomaniak Account") + } footer: { + Text("Sign in through Infomaniak to connect and automatically refresh your account.") + } + + Section { + SecureField("Access token", text: $model.manualAccessToken) + .platformPasswordEntry() + .accessibilityIdentifier("addAccount.manualToken") + + Button { + Task { + let existingAccountIDs = Set(model.accounts.map(\.accountIdentifier)) + await model.saveManualAccessToken() + if model.accounts.contains(where: { existingAccountIDs.contains($0.accountIdentifier) == false }) { + dismiss() + } + } + } label: { + Label("Save Access Token", systemImage: "key.fill") + } + .disabled(model.manualAccessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("addAccount.saveManualToken") + } header: { + Text("Advanced") + } footer: { + Text("Manual tokens are intended for development and may stop working when they expire.") + } + } + .navigationTitle("Add Account") + } +} + +private struct ProviderAccountManagementView: View { + @ObservedObject var model: PotassiumProviderAppModel + let accountIdentifier: String + let didRemoveAccount: () -> Void + + @State private var isRenamePresented = false + @State private var renameDraft = "" + @State private var isLogoutConfirmationPresented = false + + var body: some View { + Group { + if let account { + accountForm(account) + } else { + ContentUnavailableView { + Label("Account Unavailable", systemImage: "person.crop.circle.badge.exclamationmark") + } description: { + Text("This account is no longer connected.") + } actions: { + Button("Back to Setup", action: didRemoveAccount) + } + } + } + .navigationTitle(account?.displayName ?? "Account") + .toolbar { + ToolbarItem(placement: refreshToolbarPlacement) { + Button { + Task { await model.loadDrives(accountIdentifier: accountIdentifier) } + } label: { + Label("Refresh Drives", systemImage: "arrow.clockwise") + } + .disabled( + model.canLoadDrives(for: accountIdentifier) == false || + model.isPerformingDriveAction(for: accountIdentifier) + ) + .accessibilityIdentifier("account.refreshDrives") + } + } + .alert("Rename Account", isPresented: $isRenamePresented) { + TextField("Account name", text: $renameDraft) + Button("Save") { + let submittedName = renameDraft.trimmingCharacters(in: .whitespacesAndNewlines) + Task { + await model.renameAccount( + accountIdentifier: accountIdentifier, + displayName: submittedName + ) + } + } + .disabled(renameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Cancel", role: .cancel) {} + } message: { + Text("Choose the local name shown for this account.") + } + .confirmationDialog( + "Log out of \(account?.displayName ?? "this account")?", + isPresented: $isLogoutConfirmationPresented, + titleVisibility: .visible + ) { + Button("Log Out", role: .destructive) { + guard let account else { return } + Task { + await model.logoutAccount(account) + if model.account(accountIdentifier: accountIdentifier) == nil { + didRemoveAccount() + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the account’s drives from Files and clears their provider-local state and saved credentials. Remote kDrive files are not deleted.") + } + } + + private var account: ProviderAccount? { + model.account(accountIdentifier: accountIdentifier) + } + + private var driveDescriptors: [ProviderDriveDescriptor] { + ProviderDriveDescriptor.merge( + accountIdentifier: accountIdentifier, + drives: model.drives(for: accountIdentifier), + configurations: model.domains(for: accountIdentifier) + ) + } + + private func accountForm(_ account: ProviderAccount) -> some View { + List { + Section("Account") { + LabeledContent("Name", value: account.displayName) + LabeledContent("Authentication", value: account.authenticationKind.title) + + Button { + renameDraft = account.displayName + isRenamePresented = true + } label: { + Label("Rename Account", systemImage: "pencil") + } + .accessibilityIdentifier("account.rename") + } + + Section("Drives") { + if model.isLoadingDrives(for: accountIdentifier) && driveDescriptors.isEmpty { + HStack { + ProgressView() + Text("Loading kDrives…") + } + .accessibilityElement(children: .combine) + } else if driveDescriptors.isEmpty { + ContentUnavailableView { + Label("No kDrives Available", systemImage: "externaldrive.badge.questionmark") + } description: { + Text("Refresh this account to discover its kDrives.") + } actions: { + Button("Load Drives") { + Task { await model.loadDrives(accountIdentifier: accountIdentifier) } + } + .disabled(model.canLoadDrives(for: accountIdentifier) == false) + } + } else { + ForEach(driveDescriptors) { descriptor in + NavigationLink(value: ProviderSetupRoute.drive(descriptor.id)) { + ProviderDriveRow(descriptor: descriptor) + } + .accessibilityIdentifier("account.drive.\(descriptor.driveID)") + } + } + } + + if let statusMessage = model.statusMessage { + Section { + ProviderFeedbackLabel(message: statusMessage) + } + } + + Section { + Button("Log Out", role: .destructive) { + isLogoutConfirmationPresented = true + } + .disabled(model.isPerformingDriveAction(for: accountIdentifier)) + .accessibilityIdentifier("account.logout") + } footer: { + Text("Logging out removes this account’s File Provider domains and local state. It does not delete remote files.") + } + } + } + + private var refreshToolbarPlacement: ToolbarItemPlacement { + #if os(macOS) + .automatic + #else + .topBarTrailing + #endif + } +} + +private struct ProviderDriveRow: View { + let descriptor: ProviderDriveDescriptor + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "externaldrive.fill") + .foregroundStyle(.tint) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 4) { + Text(descriptor.name) + .font(.headline) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + Spacer() + + if descriptor.isConfigured { + Text("In Files") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + } + + if descriptor.isInMaintenance { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityLabel("In maintenance") + } + } + .padding(.vertical, 3) + } + + private var detail: String { + var parts = ["Drive \(descriptor.driveID)"] + if let role = descriptor.role { + parts.append(role) + } + if descriptor.remoteDetailsAreAvailable == false { + parts.append("Remote details unavailable") + } + return parts.joined(separator: " · ") + } +} + +private struct ProviderDriveManagementView: View { + @ObservedObject var model: PotassiumProviderAppModel + let key: ProviderDriveKey + @Environment(\.openURL) private var openURL + + @State private var isRemovalConfirmationPresented = false + @State private var isStopSyncConfirmationPresented = false + + var body: some View { + Group { + if model.account(accountIdentifier: key.accountIdentifier) == nil { + ContentUnavailableView( + "Account Unavailable", + systemImage: "person.crop.circle.badge.exclamationmark", + description: Text("The account for this drive is no longer connected.") + ) + } else if let descriptor { + driveForm(descriptor) + } else { + ContentUnavailableView( + "Drive Unavailable", + systemImage: "externaldrive.badge.exclamationmark", + description: Text("This drive is no longer available or configured.") + ) + } + } + .navigationTitle(descriptor?.name ?? "Drive") + .toolbar { + ToolbarItem(placement: refreshToolbarPlacement) { + Button { + Task { await model.loadDrives(accountIdentifier: key.accountIdentifier) } + } label: { + Label("Refresh Drive Details", systemImage: "arrow.clockwise") + } + .disabled(model.canLoadDrives(for: key.accountIdentifier) == false || isBusy) + .accessibilityIdentifier("drive.refresh") + } + } + .confirmationDialog( + "Remove \(descriptor?.name ?? "this drive") from Files?", + isPresented: $isRemovalConfirmationPresented, + titleVisibility: .visible + ) { + Button("Remove from Files", role: .destructive) { + guard let configuration = descriptor?.configuration else { return } + Task { await model.removeDomain(configuration) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the File Provider domain, cached snapshots, activities, conflicts, and other provider-local state. Remote kDrive files are not deleted.") + } + #if os(macOS) + .confirmationDialog( + "Stop syncing Desktop and Documents?", + isPresented: $isStopSyncConfirmationPresented, + titleVisibility: .visible + ) { + Button("Stop Syncing", role: .destructive) { + guard let configuration = descriptor?.configuration else { return } + Task { await model.disableKnownFolderSync(for: configuration) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("macOS will stop replicating both folders with kDrive. Remote files in /private are not deleted.") + } + #endif + } + + private var descriptor: ProviderDriveDescriptor? { + ProviderDriveDescriptor.merge( + accountIdentifier: key.accountIdentifier, + drives: model.drives(for: key.accountIdentifier), + configurations: model.domains(for: key.accountIdentifier) + ).first { $0.driveID == key.driveID } + } + + private var activeAction: ProviderDriveAction? { + model.activeDriveAction(for: key) + } + + private var isBusy: Bool { + activeAction != nil || model.isLoadingDrives(for: key.accountIdentifier) + } + + private func driveForm(_ descriptor: ProviderDriveDescriptor) -> some View { + List { + Section("Drive") { + LabeledContent("Name", value: descriptor.name) + LabeledContent("Drive ID", value: String(descriptor.driveID)) + if let account = model.account(accountIdentifier: key.accountIdentifier) { + LabeledContent("Account", value: account.displayName) + } + if let role = descriptor.role { + LabeledContent("Role", value: role) + } + if let remoteStatus = descriptor.remoteStatus { + LabeledContent("Remote Status", value: remoteStatus) + } + if descriptor.remoteDetailsAreAvailable == false { + Label( + "Saved configuration · remote details are currently unavailable", + systemImage: "icloud.slash" + ) + .foregroundStyle(.secondary) + } + if descriptor.isInMaintenance { + Label("This drive is currently in maintenance.", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + + Section("Files") { + LabeledContent("Availability") { + Text(descriptor.isConfigured ? "In Files" : "Not in Files") + .foregroundStyle(descriptor.isConfigured ? .green : .secondary) + } + + if descriptor.configuration == nil, let remote = descriptor.remote { + Button { + Task { + await model.addDomain( + accountIdentifier: key.accountIdentifier, + drive: remote + ) + } + } label: { + actionLabel( + title: "Add to Files", + systemImage: "folder.badge.plus", + action: .addingToFiles + ) + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + .accessibilityIdentifier("drive.addToFiles") + } + + if let configuration = descriptor.configuration { + Button { + Task { + if let url = await model.userVisibleRootURL(for: configuration) { + openURL(url) + } + } + } label: { + #if os(macOS) + actionLabel( + title: "Show in Finder", + systemImage: "folder", + action: .showingInFiles + ) + #else + actionLabel( + title: "Show in Files", + systemImage: "folder", + action: .showingInFiles + ) + #endif + } + .disabled(isBusy) + .accessibilityIdentifier("drive.showInFiles") + + Button { + Task { await model.syncNow(configuration) } + } label: { + actionLabel( + title: "Sync Now", + systemImage: "arrow.triangle.2.circlepath", + action: .syncingNow + ) + } + .disabled(isBusy) + .accessibilityIdentifier("drive.syncNow") + } + } + + #if os(macOS) + if let configuration = descriptor.configuration { + knownFolderSection(configuration) + } + #endif + + if let statusMessage = model.statusMessage { + Section { + ProviderFeedbackLabel(message: statusMessage) + } + } + + if descriptor.configuration != nil { + Section { + Button("Remove from Files", role: .destructive) { + isRemovalConfirmationPresented = true + } + .disabled(isBusy) + .accessibilityIdentifier("drive.removeFromFiles") + } footer: { + Text("Removing this drive clears its provider-local state but does not delete remote kDrive files.") + } + } + } + } + + @ViewBuilder + private func actionLabel( + title: String, + systemImage: String, + action: ProviderDriveAction + ) -> some View { + HStack { + Label(title, systemImage: systemImage) + Spacer() + if activeAction == action { + ProgressView() + .controlSize(.small) + .accessibilityLabel("\(title) in progress") + } + } + } + + #if os(macOS) + private func knownFolderSection(_ configuration: ProviderDomainConfiguration) -> some View { + let state = model.knownFolderSyncState(for: configuration) + return Section { + LabeledContent("Status", value: knownFolderStatusTitle(state)) + Text(knownFolderDetail(state)) + .font(.subheadline) + .foregroundStyle(.secondary) + + switch state { + case .active, .partial: + Button("Stop Syncing", role: .destructive) { + isStopSyncConfirmationPresented = true + } + .disabled(isBusy) + .accessibilityIdentifier("drive.stopKnownFolders") + case .inactive: + Button { + Task { await model.enableKnownFolderSync(for: configuration) } + } label: { + actionLabel( + title: "Sync Desktop & Documents", + systemImage: "desktopcomputer", + action: .enablingKnownFolders + ) + } + .buttonStyle(.borderedProminent) + .disabled(isBusy) + .accessibilityIdentifier("drive.enableKnownFolders") + case .unavailable: + Label("File Provider status unavailable", systemImage: "exclamationmark.circle") + .foregroundStyle(.secondary) + } + } header: { + Text("Desktop & Documents") + } footer: { + Text("macOS manages Desktop and Documents together under kDrive /private.") + } + } + + private func knownFolderStatusTitle(_ state: ProviderKnownFolderSyncState) -> String { + switch state { + case .active: + "Syncing" + case .partial: + "Needs Repair" + case .inactive: + "Not Syncing" + case .unavailable: + "Unavailable" + } + } + + private func knownFolderDetail(_ state: ProviderKnownFolderSyncState) -> String { + switch state { + case .active: + "Desktop and Documents sync with /private/Desktop and /private/Documents." + case .partial: + "Only part of the known-folder configuration is active. Stop syncing, then enable it again to repair the setup." + case .inactive: + "Sync both folders with this drive’s existing /private directory." + case .unavailable: + "The live File Provider known-folder state could not be read." + } + } + #endif + + private var refreshToolbarPlacement: ToolbarItemPlacement { + #if os(macOS) + .automatic + #else + .topBarTrailing + #endif + } +} + +private struct ProviderFeedbackLabel: View { + let message: String + + var body: some View { + Label(message, systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + .accessibilityElement(children: .combine) + } +} + +private extension ProviderAccountAuthenticationKind { + var title: String { + switch self { + case .oauth: + "OAuth" + case .manualAccessToken: + "Manual Token" + } + } + + var systemImage: String { + switch self { + case .oauth: + "person.crop.circle" + case .manualAccessToken: + "key" + } + } +} + +private extension View { + @ViewBuilder + func platformPasswordEntry() -> some View { + #if canImport(UIKit) + self + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + #else + self + #endif + } +} diff --git a/potassiumProvider/ProviderStatusView.swift b/potassiumProvider/ProviderStatusView.swift index d5fc06d..8468a50 100644 --- a/potassiumProvider/ProviderStatusView.swift +++ b/potassiumProvider/ProviderStatusView.swift @@ -6,7 +6,6 @@ struct ProviderStatusView: View { @ObservedObject var appModel: PotassiumProviderAppModel @StateObject private var statusModel: ProviderStatusViewModel @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.openURL) private var openURL let showSetup: () -> Void @@ -49,32 +48,8 @@ struct ProviderStatusView: View { ProviderStatusSectionHeader("Drives") VStack(spacing: 10) { ForEach(statusModel.dashboard.drives) { drive in - if let configuration = appModel.domains.first(where: { - $0.domainIdentifier == drive.domainIdentifier - }) { - ProviderStatusDriveCard( - drive: drive, - isPerformingAction: appModel.isPerformingDomainAction( - drive.domainIdentifier - ), - showInFiles: { - Task { - if let url = await appModel.userVisibleRootURL( - for: configuration - ) { - openURL(url) - } - } - }, - syncNow: { - Task { - await appModel.syncNow(configuration) - await statusModel.load(input: statusInput) - } - } - ) + ProviderStatusDriveCard(drive: drive) .transition(cardTransition) - } } } } @@ -642,9 +617,6 @@ private struct ProviderStatusAccountRow: View { private struct ProviderStatusDriveCard: View { let drive: ProviderStatusDrive - let isPerformingAction: Bool - let showInFiles: () -> Void - let syncNow: () -> Void var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -702,26 +674,6 @@ private struct ProviderStatusDriveCard: View { } .font(.caption) .foregroundStyle(.secondary) - - HStack { - Button(action: showInFiles) { - #if os(macOS) - Label("Show in Finder", systemImage: "folder") - #else - Label("Show in Files", systemImage: "folder") - #endif - } - .disabled(isPerformingAction) - Button(action: syncNow) { - Label("Sync Now", systemImage: "arrow.triangle.2.circlepath") - } - .disabled(isPerformingAction) - if isPerformingAction { - ProgressView() - .controlSize(.small) - } - } - .buttonStyle(.bordered) } .providerStatusCard() } diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift new file mode 100644 index 0000000..3263b37 --- /dev/null +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -0,0 +1,84 @@ +#if DEBUG +import Foundation +import PotassiumProviderCore + +@MainActor +enum ProviderUITestFixture { + private static let environmentKey = "POTASSIUM_UI_TEST_FIXTURE" + + static func makeModel() -> PotassiumProviderAppModel? { + guard ProcessInfo.processInfo.environment[environmentKey] == "setup-navigation" else { + return nil + } + + let account = ProviderAccount( + accountIdentifier: "ui-account", + displayName: "Design Team", + authenticationKind: .oauth + ) + let configuredDrive = KDriveDriveSummary( + id: 10, + name: "Shared Projects", + accountID: 1, + role: "admin", + status: "active", + isInMaintenance: false + ) + let availableDrive = KDriveDriveSummary( + id: 20, + name: "Archive", + accountID: 1, + role: "user", + status: "maintenance", + isInMaintenance: true + ) + let configuration = ProviderDomainConfiguration( + domainIdentifier: "ui-domain", + accountIdentifier: account.accountIdentifier, + displayName: configuredDrive.name, + driveID: configuredDrive.id, + driveName: configuredDrive.name + ) + let fixtureDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("potassiumProviderUITestFixture", isDirectory: true) + + return PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: fixtureDirectory.appendingPathComponent("Accounts", isDirectory: true) + ), + domainStore: DomainConfigurationFileStore( + directoryURL: fixtureDirectory.appendingPathComponent("Domains", isDirectory: true) + ), + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: ProviderUITestOAuthAuthenticator(), + domainRegistrar: ProviderUITestDomainRegistrar(), + automaticallyReloadStoredState: false, + initialAccounts: [account], + initialDrivesByAccountIdentifier: [ + account.accountIdentifier: [configuredDrive, availableDrive], + ], + initialDomains: [configuration] + ) + } +} + +@MainActor +private final class ProviderUITestOAuthAuthenticator: KDriveOAuthAuthenticating { + func authenticate() async throws -> KDriveOAuthToken { + KDriveOAuthToken( + accessToken: "ui-test-token", + tokenType: "Bearer", + refreshToken: nil, + scope: nil, + idToken: nil, + expiresAt: nil + ) + } +} + +@MainActor +private struct ProviderUITestDomainRegistrar: ProviderDomainRegistering { + func addDomain(for configuration: ProviderDomainConfiguration) async throws {} + func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} +} +#endif diff --git a/potassiumProvider/potassiumProviderApp.swift b/potassiumProvider/potassiumProviderApp.swift index 9df4ac1..b786f8d 100644 --- a/potassiumProvider/potassiumProviderApp.swift +++ b/potassiumProvider/potassiumProviderApp.swift @@ -2,11 +2,19 @@ import Darwin import SwiftUI struct potassiumProviderApp: App { - @StateObject private var model = PotassiumProviderAppModel() + @StateObject private var model: PotassiumProviderAppModel #if os(macOS) @NSApplicationDelegateAdaptor(MacAppDelegate.self) private var appDelegate #endif + init() { + #if DEBUG + _model = StateObject(wrappedValue: ProviderUITestFixture.makeModel() ?? PotassiumProviderAppModel()) + #else + _model = StateObject(wrappedValue: PotassiumProviderAppModel()) + #endif + } + var body: some Scene { #if os(macOS) Window("potassiumProvider", id: MacAppPresenceConfiguration.mainWindowSceneID) { diff --git a/potassiumProviderTests/ProviderSetupViewTests.swift b/potassiumProviderTests/ProviderSetupViewTests.swift new file mode 100644 index 0000000..e503b4e --- /dev/null +++ b/potassiumProviderTests/ProviderSetupViewTests.swift @@ -0,0 +1,256 @@ +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@MainActor +struct ProviderSetupViewTests { + @Test func driveDescriptorsMergeRemoteAndConfiguredState() throws { + let configured = makeDrive(id: 10, name: "Projects", role: "admin") + let available = makeDrive(id: 20, name: "Archive", role: "user", isInMaintenance: true) + let configuration = makeConfiguration( + accountIdentifier: "account-a", + driveID: configured.id, + driveName: configured.name + ) + + let descriptors = ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [configured, available], + configurations: [configuration] + ) + + #expect(descriptors.map(\.driveID) == [10, 20]) + #expect(descriptors[0].configuration == configuration) + #expect(descriptors[0].role == "admin") + #expect(descriptors[1].configuration == nil) + #expect(descriptors[1].isInMaintenance) + } + + @Test func configuredDriveRemainsVisibleWithoutRemoteDetails() throws { + let configuration = makeConfiguration( + accountIdentifier: "account-a", + driveID: 30, + driveName: "Saved Drive" + ) + + let descriptor = try #require(ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [], + configurations: [configuration] + ).first) + + #expect(descriptor.name == "Saved Drive") + #expect(descriptor.isConfigured) + #expect(descriptor.remoteDetailsAreAvailable == false) + } + + @Test func driveIdentityIncludesItsAccount() { + let accountA = ProviderDriveKey(accountIdentifier: "account-a", driveID: 10) + let accountB = ProviderDriveKey(accountIdentifier: "account-b", driveID: 10) + + #expect(accountA != accountB) + #expect(ProviderSetupRoute.drive(accountA) != ProviderSetupRoute.drive(accountB)) + } + + @Test func refreshedRemoteMetadataReplacesPresentationWithoutChangingIdentity() throws { + let initial = try #require(ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [makeDrive(id: 10, name: "Old Name", role: "user")], + configurations: [] + ).first) + let refreshed = try #require(ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [makeDrive(id: 10, name: "New Name", role: "admin")], + configurations: [] + ).first) + + #expect(initial.id == refreshed.id) + #expect(refreshed.name == "New Name") + #expect(refreshed.role == "admin") + } + + @Test func removedConfigurationLeavesDiscoveredDriveUnconfigured() throws { + let drive = makeDrive(id: 10, name: "Projects", role: "admin") + let configuration = makeConfiguration( + accountIdentifier: "account-a", + driveID: drive.id, + driveName: drive.name + ) + let configured = try #require(ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [drive], + configurations: [configuration] + ).first) + let removed = try #require(ProviderDriveDescriptor.merge( + accountIdentifier: "account-a", + drives: [drive], + configurations: [] + ).first) + + #expect(configured.id == removed.id) + #expect(configured.isConfigured) + #expect(removed.isConfigured == false) + } + + @Test func duplicateAddIsGuardedAndActionStateClears() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let account = ProviderAccount( + accountIdentifier: "account-a", + displayName: "Account", + authenticationKind: .oauth + ) + let drive = makeDrive(id: 10, name: "Projects", role: "admin") + let registrar = BlockingAddDomainRegistrar() + let model = PotassiumProviderAppModel( + accountStore: ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts", isDirectory: true) + ), + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains", isDirectory: true) + ), + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: SetupTestOAuthAuthenticator(), + domainRegistrar: registrar, + automaticallyReloadStoredState: false, + initialAccounts: [account], + initialDrivesByAccountIdentifier: [account.accountIdentifier: [drive]] + ) + let key = ProviderDriveKey(accountIdentifier: account.accountIdentifier, driveID: drive.id) + + let firstAdd = Task { + await model.addDomain(accountIdentifier: account.accountIdentifier, drive: drive) + } + await registrar.waitUntilAddStarts() + + #expect(model.activeDriveAction(for: key) == .addingToFiles) + await model.addDomain(accountIdentifier: account.accountIdentifier, drive: drive) + #expect(registrar.addCallCount == 1) + await model.logoutAccount(account) + #expect(model.account(accountIdentifier: account.accountIdentifier) != nil) + #expect(model.errorMessage?.contains("Wait for the current drive action") == true) + + registrar.resumeAdd() + await firstAdd.value + + #expect(model.activeDriveAction(for: key) == nil) + #expect(model.isConfigured(accountIdentifier: account.accountIdentifier, driveID: drive.id)) + } + + @Test func explicitRenamePersistsOnlyTheSubmittedNormalizedName() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let account = ProviderAccount( + accountIdentifier: "account-a", + displayName: "Old Name", + authenticationKind: .oauth + ) + let accountStore = ProviderAccountFileStore( + directoryURL: directory.appendingPathComponent("Accounts", isDirectory: true) + ) + let model = PotassiumProviderAppModel( + accountStore: accountStore, + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains", isDirectory: true) + ), + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: SetupTestOAuthAuthenticator(), + domainRegistrar: SetupTestDomainRegistrar(), + automaticallyReloadStoredState: false, + initialAccounts: [account] + ) + + await model.renameAccount( + accountIdentifier: account.accountIdentifier, + displayName: " Design Team " + ) + + #expect(model.account(accountIdentifier: account.accountIdentifier)?.displayName == "Design Team") + #expect(try await accountStore.allAccounts().map(\.displayName) == ["Design Team"]) + } + + private func makeDrive( + id: Int, + name: String, + role: String, + isInMaintenance: Bool = false + ) -> KDriveDriveSummary { + KDriveDriveSummary( + id: id, + name: name, + accountID: 1, + role: role, + status: isInMaintenance ? "maintenance" : "active", + isInMaintenance: isInMaintenance + ) + } + + private func makeConfiguration( + accountIdentifier: String, + driveID: Int, + driveName: String + ) -> ProviderDomainConfiguration { + ProviderDomainConfiguration( + accountIdentifier: accountIdentifier, + displayName: driveName, + driveID: driveID, + driveName: driveName + ) + } +} + +@MainActor +private final class BlockingAddDomainRegistrar: ProviderDomainRegistering { + private var addStartedContinuations: [CheckedContinuation] = [] + private var addContinuation: CheckedContinuation? + private(set) var addCallCount = 0 + + func addDomain(for configuration: ProviderDomainConfiguration) async throws { + addCallCount += 1 + let waiting = addStartedContinuations + addStartedContinuations.removeAll() + waiting.forEach { $0.resume() } + await withCheckedContinuation { continuation in + addContinuation = continuation + } + } + + func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} + + func waitUntilAddStarts() async { + guard addCallCount == 0 else { return } + await withCheckedContinuation { continuation in + addStartedContinuations.append(continuation) + } + } + + func resumeAdd() { + addContinuation?.resume() + addContinuation = nil + } +} + +@MainActor +private final class SetupTestOAuthAuthenticator: KDriveOAuthAuthenticating { + func authenticate() async throws -> KDriveOAuthToken { + KDriveOAuthToken( + accessToken: "test-token", + tokenType: "Bearer", + refreshToken: nil, + scope: nil, + idToken: nil, + expiresAt: nil + ) + } +} + +@MainActor +private struct SetupTestDomainRegistrar: ProviderDomainRegistering { + func addDomain(for configuration: ProviderDomainConfiguration) async throws {} + func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} +} diff --git a/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index e22edd0..0e56a55 100644 --- a/potassiumProviderTests/potassiumProviderTests.swift +++ b/potassiumProviderTests/potassiumProviderTests.swift @@ -1047,6 +1047,7 @@ struct PotassiumProviderCoreTests { #expect(driveRow.health == .attention) } + @MainActor @Test func tabSelectionPolicyDefaultsToStatus() { #expect(ProviderAppTabSelectionPolicy.defaultSelection(configuredDomainCount: 0) == .status) #expect(ProviderAppTabSelectionPolicy.defaultSelection(configuredDomainCount: 1) == .status) @@ -1937,6 +1938,10 @@ struct PotassiumProviderCoreTests { #expect(model.domains.isEmpty) #expect(try await domainStore.allConfigurations().isEmpty) #expect(model.errorMessage?.contains("The application cannot be used right now") == true) + #expect(model.activeDriveAction(for: ProviderDriveKey( + accountIdentifier: account.accountIdentifier, + driveID: 42 + )) == nil) let failure = try #require(await eventStore.activities().first) #expect(failure.domainIdentifier == ProviderConstants.appActivityDomainIdentifier) diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index c894a2e..db717d0 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -33,6 +33,54 @@ final class potassiumProviderUITests: XCTestCase { // https://developer.apple.com/documentation/xcuiautomation } + @MainActor + func testSetupNavigatesFromAccountToAvailableDriveManagement() throws { + let app = launchSetupFixture() + openSetup(in: app) + + let account = app.buttons["setup.account.ui-account"] + XCTAssertTrue(account.waitForExistence(timeout: 5)) + account.tap() + + let availableDrive = app.buttons["account.drive.20"] + XCTAssertTrue(availableDrive.waitForExistence(timeout: 5)) + availableDrive.tap() + + XCTAssertTrue(app.buttons["drive.addToFiles"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["This drive is currently in maintenance."].exists) + } + + @MainActor + func testConfiguredDrivePresentsRemovalConfirmation() throws { + let app = launchSetupFixture() + openSetup(in: app) + app.buttons["setup.account.ui-account"].tap() + app.buttons["account.drive.10"].tap() + + let remove = app.buttons["drive.removeFromFiles"] + XCTAssertTrue(remove.waitForExistence(timeout: 5)) + remove.tap() + + XCTAssertTrue(app.buttons["Remove from Files"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "Remote kDrive files are not deleted") + ).firstMatch.exists) + } + + @MainActor + func testAddAccountUsesDedicatedScreenWithAdvancedTokenEntry() throws { + let app = launchSetupFixture() + openSetup(in: app) + + let addAccount = app.buttons["setup.addAccount"] + XCTAssertTrue(addAccount.waitForExistence(timeout: 5)) + addAccount.tap() + + XCTAssertTrue(app.buttons["addAccount.oauth"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.secureTextFields["addAccount.manualToken"].exists) + XCTAssertTrue(app.staticTexts["Advanced"].exists) + } + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. @@ -40,4 +88,24 @@ final class potassiumProviderUITests: XCTestCase { XCUIApplication().launch() } } + + @MainActor + private func launchSetupFixture() -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["POTASSIUM_UI_TEST_FIXTURE"] = "setup-navigation" + app.launch() + return app + } + + @MainActor + private func openSetup(in app: XCUIApplication) { + let tabBarButton = app.tabBars.buttons["Setup"] + if tabBarButton.waitForExistence(timeout: 2) { + tabBarButton.tap() + } else { + let setupButton = app.buttons["Setup"] + XCTAssertTrue(setupButton.waitForExistence(timeout: 5)) + setupButton.tap() + } + } } From 242377c04373fac4bd3708a0031e80ab06ea9068 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 19:06:15 +0200 Subject: [PATCH 2/9] feat: animate setup navigation --- potassiumProvider/ProviderSetupView.swift | 58 +++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index d567ae4..c0dc717 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -135,6 +135,7 @@ struct ProviderSetupView: View { } } .navigationTitle("Setup") + .providerNavigationAnimation(animatesInitialAppearance: false) .task(id: setupAutoLoadTaskID) { await model.loadDrivesForAccountsIfPossible() } @@ -160,6 +161,7 @@ struct ProviderSetupView: View { switch route { case .addAccount: ProviderAddAccountView(model: model) + .providerNavigationAnimation() case .account(let accountIdentifier): ProviderAccountManagementView( model: model, @@ -167,8 +169,10 @@ struct ProviderSetupView: View { ) { path.removeAll() } + .providerNavigationAnimation() case .drive(let key): ProviderDriveManagementView(model: model, key: key) + .providerNavigationAnimation() } } @@ -823,6 +827,12 @@ private extension ProviderAccountAuthenticationKind { } private extension View { + func providerNavigationAnimation(animatesInitialAppearance: Bool = true) -> some View { + modifier(ProviderNavigationAnimationModifier( + animatesInitialAppearance: animatesInitialAppearance + )) + } + @ViewBuilder func platformPasswordEntry() -> some View { #if canImport(UIKit) @@ -835,3 +845,51 @@ private extension View { #endif } } + +private struct ProviderNavigationAnimationModifier: ViewModifier { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + let animatesInitialAppearance: Bool + + @State private var hasAppeared = false + @State private var isVisible = false + @State private var horizontalDirection: CGFloat = 1 + + func body(content: Content) -> some View { + content + .opacity(isVisible ? 1 : 0) + .offset(x: reduceMotion ? 0 : horizontalDirection * 28) + .onAppear { + let shouldAnimate = hasAppeared || animatesInitialAppearance + hasAppeared = true + + if shouldAnimate { + withAnimation(navigationAnimation) { + isVisible = true + horizontalDirection = 0 + } + } else { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isVisible = true + horizontalDirection = 0 + } + } + } + .onDisappear { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isVisible = false + horizontalDirection = -1 + } + } + } + + private var navigationAnimation: Animation { + reduceMotion + ? .easeOut(duration: 0.16) + : .snappy(duration: 0.3, extraBounce: 0) + } +} From 965e8db3952441a3bf93bace5410e6380435f5bd Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 20:20:31 +0200 Subject: [PATCH 3/9] fix: polish setup loading feedback --- doc/APP_AND_DOMAINS.md | 5 +- .../PotassiumProviderAppModel.swift | 5 ++ potassiumProvider/ProviderSetupView.swift | 86 +++++++++++++++---- potassiumProvider/ProviderUITestFixture.swift | 10 ++- .../ProviderSetupViewTests.swift | 82 ++++++++++++++++++ .../potassiumProviderUITests.swift | 17 +++- 6 files changed, 183 insertions(+), 22 deletions(-) diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index aa8f536..a3fffc3 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -45,7 +45,10 @@ Setup uses a three-level navigation hierarchy: Account creation has its own destination. Infomaniak OAuth is the primary path; manual access-token entry remains available in an Advanced section for -development. +development. While stored state is being restored, Setup shows an explicit +loading row instead of briefly presenting the empty-account state. Setup errors +appear in a nonmodal, dismissible banner so background refreshes do not interrupt +navigation with a transient alert. The Status dashboard only uses local/provider-safe state: local account records, configured domain records, currently loaded kDrive summaries, SQLite listing diff --git a/potassiumProvider/PotassiumProviderAppModel.swift b/potassiumProvider/PotassiumProviderAppModel.swift index a325961..876657c 100644 --- a/potassiumProvider/PotassiumProviderAppModel.swift +++ b/potassiumProvider/PotassiumProviderAppModel.swift @@ -32,6 +32,7 @@ final class PotassiumProviderAppModel: ObservableObject { @Published private(set) var knownFolderSyncStatesByDomainIdentifier: [String: ProviderKnownFolderSyncState] = [:] @Published private(set) var knownFolderTransitionDomainIdentifiers: Set = [] @Published private(set) var activeDriveActions: [ProviderDriveKey: ProviderDriveAction] = [:] + @Published private(set) var isReloadingStoredState = false @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -75,6 +76,7 @@ final class PotassiumProviderAppModel: ObservableObject { accounts = initialAccounts drivesByAccountIdentifier = initialDrivesByAccountIdentifier domains = initialDomains + isReloadingStoredState = automaticallyReloadStoredState statusMessage = initialAccounts.isEmpty ? "No accounts connected." : "Loaded \(initialAccounts.count) account\(initialAccounts.count == 1 ? "" : "s")." @@ -183,6 +185,9 @@ final class PotassiumProviderAppModel: ObservableObject { } func reloadStoredState() async { + isReloadingStoredState = true + defer { isReloadingStoredState = false } + do { try await migrateLegacyStateIfNeeded() accounts = try await accountStore.allAccounts() diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index c0dc717..10ac7f2 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -80,6 +80,7 @@ struct ProviderDriveDescriptor: Identifiable, Equatable { struct ProviderSetupView: View { @ObservedObject var model: PotassiumProviderAppModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var path: [ProviderSetupRoute] = [] var body: some View { @@ -89,11 +90,18 @@ struct ProviderSetupView: View { destination(for: route) } } - .alert("kDrive", isPresented: errorBinding) { - Button("OK", role: .cancel) {} - } message: { - Text(model.errorMessage ?? "") + .safeAreaInset(edge: .bottom, spacing: 0) { + if let errorMessage = model.errorMessage { + ProviderSetupErrorBanner(message: errorMessage) { + model.errorMessage = nil + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } + .animation( + reduceMotion ? .easeOut(duration: 0.16) : .snappy(duration: 0.28, extraBounce: 0), + value: model.errorMessage + ) } private var accountList: some View { @@ -105,9 +113,19 @@ struct ProviderSetupView: View { } .accessibilityIdentifier("setup.addAccount") } + .disabled(model.isReloadingStoredState) Section("Accounts") { - if model.accounts.isEmpty { + if model.isReloadingStoredState && model.accounts.isEmpty { + HStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text("Loading saved accounts…") + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Loading saved accounts") + } else if model.accounts.isEmpty { ContentUnavailableView( "No Accounts Connected", systemImage: "person.crop.circle.badge.questionmark", @@ -128,7 +146,7 @@ struct ProviderSetupView: View { } } - if let statusMessage = model.statusMessage { + if model.isReloadingStoredState == false, let statusMessage = model.statusMessage { Section { ProviderFeedbackLabel(message: statusMessage) } @@ -150,7 +168,11 @@ struct ProviderSetupView: View { } label: { Label("Refresh All Accounts", systemImage: "arrow.clockwise") } - .disabled(model.accounts.isEmpty || model.loadingDriveAccountIdentifiers.isEmpty == false) + .disabled( + model.isReloadingStoredState || + model.accounts.isEmpty || + model.loadingDriveAccountIdentifiers.isEmpty == false + ) .accessibilityIdentifier("setup.refreshAll") } } @@ -176,16 +198,6 @@ struct ProviderSetupView: View { } } - private var errorBinding: Binding { - Binding { - model.errorMessage != nil - } set: { isPresented in - if isPresented == false { - model.errorMessage = nil - } - } - } - private var setupAutoLoadTaskID: String { model.accounts.map(\.accountIdentifier).joined(separator: "|") } @@ -806,6 +818,46 @@ private struct ProviderFeedbackLabel: View { } } +private struct ProviderSetupErrorBanner: View { + let message: String + let dismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text("kDrive") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Button(action: dismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss kDrive message") + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke(.quaternary, lineWidth: 1) + } + .shadow(color: .black.opacity(0.08), radius: 8, y: 3) + .padding() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("setup.errorBanner") + } +} + private extension ProviderAccountAuthenticationKind { var title: String { switch self { diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index 3263b37..f9f7954 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -7,7 +7,9 @@ enum ProviderUITestFixture { private static let environmentKey = "POTASSIUM_UI_TEST_FIXTURE" static func makeModel() -> PotassiumProviderAppModel? { - guard ProcessInfo.processInfo.environment[environmentKey] == "setup-navigation" else { + guard let fixtureName = ProcessInfo.processInfo.environment[environmentKey], + ["setup-navigation", "setup-error-banner"].contains(fixtureName) + else { return nil } @@ -42,7 +44,7 @@ enum ProviderUITestFixture { let fixtureDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("potassiumProviderUITestFixture", isDirectory: true) - return PotassiumProviderAppModel( + let model = PotassiumProviderAppModel( accountStore: ProviderAccountFileStore( directoryURL: fixtureDirectory.appendingPathComponent("Accounts", isDirectory: true) ), @@ -59,6 +61,10 @@ enum ProviderUITestFixture { ], initialDomains: [configuration] ) + if fixtureName == "setup-error-banner" { + model.errorMessage = "Could not refresh kDrive details." + } + return model } } diff --git a/potassiumProviderTests/ProviderSetupViewTests.swift b/potassiumProviderTests/ProviderSetupViewTests.swift index e503b4e..552a305 100644 --- a/potassiumProviderTests/ProviderSetupViewTests.swift +++ b/potassiumProviderTests/ProviderSetupViewTests.swift @@ -93,6 +93,41 @@ struct ProviderSetupViewTests { #expect(removed.isConfigured == false) } + @Test func storedStateReloadPublishesLoadingUntilAccountsAreAvailable() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + + let account = ProviderAccount( + accountIdentifier: "account-a", + displayName: "Account", + authenticationKind: .oauth + ) + let accountStore = BlockingSetupAccountStore(accounts: [account]) + let model = PotassiumProviderAppModel( + accountStore: accountStore, + domainStore: DomainConfigurationFileStore( + directoryURL: directory.appendingPathComponent("Domains", isDirectory: true) + ), + tokenStore: InMemoryOAuthTokenStore(), + oauthAuthenticator: SetupTestOAuthAuthenticator(), + domainRegistrar: SetupTestDomainRegistrar(), + automaticallyReloadStoredState: false + ) + + #expect(model.isReloadingStoredState == false) + let reload = Task { await model.reloadStoredState() } + await accountStore.waitUntilAllAccountsStarts() + + #expect(model.isReloadingStoredState) + + await accountStore.resumeAllAccounts() + await reload.value + + #expect(model.isReloadingStoredState == false) + #expect(model.accounts == [account]) + } + @Test func duplicateAddIsGuardedAndActionStateClears() async throws { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -254,3 +289,50 @@ private struct SetupTestDomainRegistrar: ProviderDomainRegistering { func addDomain(for configuration: ProviderDomainConfiguration) async throws {} func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} } + +private actor BlockingSetupAccountStore: ProviderAccountStoring { + private var accounts: [ProviderAccount] + private var hasStartedAllAccounts = false + private var startContinuations: [CheckedContinuation] = [] + private var allAccountsContinuation: CheckedContinuation? + + init(accounts: [ProviderAccount]) { + self.accounts = accounts + } + + func allAccounts() async -> [ProviderAccount] { + hasStartedAllAccounts = true + let waiting = startContinuations + startContinuations.removeAll() + waiting.forEach { $0.resume() } + await withCheckedContinuation { continuation in + allAccountsContinuation = continuation + } + return accounts + } + + func account(accountIdentifier: String) -> ProviderAccount? { + accounts.first { $0.accountIdentifier == accountIdentifier } + } + + func save(_ account: ProviderAccount) { + accounts.removeAll { $0.accountIdentifier == account.accountIdentifier } + accounts.append(account) + } + + func remove(accountIdentifier: String) { + accounts.removeAll { $0.accountIdentifier == accountIdentifier } + } + + func waitUntilAllAccountsStarts() async { + guard hasStartedAllAccounts == false else { return } + await withCheckedContinuation { continuation in + startContinuations.append(continuation) + } + } + + func resumeAllAccounts() { + allAccountsContinuation?.resume() + allAccountsContinuation = nil + } +} diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index db717d0..39572c0 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -81,6 +81,19 @@ final class potassiumProviderUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Advanced"].exists) } + @MainActor + func testSetupPresentsErrorsAsDismissibleBannerInsteadOfAlert() throws { + let app = launchSetupFixture(named: "setup-error-banner") + openSetup(in: app) + + let banner = app.descendants(matching: .any)["setup.errorBanner"] + XCTAssertTrue(banner.waitForExistence(timeout: 5)) + XCTAssertEqual(app.alerts.count, 0) + + app.buttons["Dismiss kDrive message"].tap() + XCTAssertTrue(banner.waitForNonExistence(timeout: 5)) + } + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. @@ -90,9 +103,9 @@ final class potassiumProviderUITests: XCTestCase { } @MainActor - private func launchSetupFixture() -> XCUIApplication { + private func launchSetupFixture(named fixtureName: String = "setup-navigation") -> XCUIApplication { let app = XCUIApplication() - app.launchEnvironment["POTASSIUM_UI_TEST_FIXTURE"] = "setup-navigation" + app.launchEnvironment["POTASSIUM_UI_TEST_FIXTURE"] = fixtureName app.launch() return app } From ee142304622418236620eb964e2f3eb02f6f9728 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 21:06:27 +0200 Subject: [PATCH 4/9] feat: revamp activities timeline paging --- .../ProviderEventStore.swift | 188 +++- doc/CONFLICTS.md | 26 +- doc/LOGGING.md | 5 + doc/PERSISTENCE.md | 15 +- .../xcschemes/potassiumProvider.xcscheme | 24 + potassiumProvider/ConflictLogView.swift | 956 +++++------------- potassiumProvider/ConflictLogViewModel.swift | 361 +++++++ .../ProviderActivityTimelineRow.swift | 578 +++++++++++ potassiumProvider/ProviderUITestFixture.swift | 106 +- .../ActivityTimelinePagingTests.swift | 437 ++++++++ .../potassiumProviderTests.swift | 79 +- .../potassiumProviderUITests.swift | 44 + 12 files changed, 2071 insertions(+), 748 deletions(-) create mode 100644 potassiumProvider/ConflictLogViewModel.swift create mode 100644 potassiumProvider/ProviderActivityTimelineRow.swift create mode 100644 potassiumProviderTests/ActivityTimelinePagingTests.swift diff --git a/PotassiumProviderCore/ProviderEventStore.swift b/PotassiumProviderCore/ProviderEventStore.swift index 6557062..0b97c4b 100644 --- a/PotassiumProviderCore/ProviderEventStore.swift +++ b/PotassiumProviderCore/ProviderEventStore.swift @@ -223,6 +223,76 @@ public struct KDriveProviderActivityEvent: Identifiable, Codable, Equatable, Sen } } +public enum KDriveProviderTimelineFilter: String, CaseIterable, Codable, Equatable, Sendable { + case errorsAndConflicts + case allActivity +} + +public enum KDriveProviderTimelineEntry: Identifiable, Equatable, Sendable { + case conflict(KDriveConflictEvent) + case activity(KDriveProviderActivityEvent) + + public var id: String { + switch self { + case .conflict(let event): + return "conflict-\(event.id.uuidString)" + case .activity(let event): + return "activity-\(event.id.uuidString)" + } + } + + public var date: Date { + switch self { + case .conflict(let event): + return event.resolvedAt ?? event.detectedAt + case .activity(let event): + return event.occurredAt + } + } + + public var cursor: KDriveProviderTimelineCursor { + switch self { + case .conflict(let event): + return KDriveProviderTimelineCursor(date: date, kind: .conflict, eventID: event.id) + case .activity(let event): + return KDriveProviderTimelineCursor(date: date, kind: .activity, eventID: event.id) + } + } +} + +public struct KDriveProviderTimelineCursor: Codable, Equatable, Sendable { + public enum Kind: Int, Codable, Equatable, Sendable { + case activity = 0 + case conflict = 1 + } + + public let date: Date + public let kind: Kind + public let eventID: UUID + + public init(date: Date, kind: Kind, eventID: UUID) { + self.date = date + self.kind = kind + self.eventID = eventID + } +} + +public struct KDriveProviderTimelinePage: Equatable, Sendable { + public let entries: [KDriveProviderTimelineEntry] + public let nextCursor: KDriveProviderTimelineCursor? + public let hasMore: Bool + + public init( + entries: [KDriveProviderTimelineEntry], + nextCursor: KDriveProviderTimelineCursor?, + hasMore: Bool + ) { + self.entries = entries + self.nextCursor = nextCursor + self.hasMore = hasMore + } +} + public protocol KDriveProviderEventStoring: Sendable { func saveConflict(_ event: KDriveConflictEvent) async throws func recordActivity(_ event: KDriveProviderActivityEvent) async throws @@ -233,6 +303,14 @@ public protocol KDriveProviderEventStoring: Sendable { func removeEvents(domainIdentifier: String) async throws } +public protocol KDriveProviderEventTimelinePaging: Sendable { + func timelinePage( + filter: KDriveProviderTimelineFilter, + before cursor: KDriveProviderTimelineCursor?, + limit: Int + ) async throws -> KDriveProviderTimelinePage +} + public struct KDriveProviderEventDomainStatistics: Equatable, Sendable { public let domainIdentifier: String public let unresolvedConflictCount: Int @@ -283,7 +361,7 @@ public protocol KDriveProviderEventPruning: Sendable { func pruneActivityEvents(maximumCount: Int) async throws } -public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveProviderEventStatisticsProviding, KDriveProviderEventObserving, KDriveProviderEventPruning, KDriveProviderEventExporting { +public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging, KDriveProviderEventStatisticsProviding, KDriveProviderEventObserving, KDriveProviderEventPruning, KDriveProviderEventExporting { private let database: Connection private let maximumActivityEventCount: Int private var eventChangeContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -361,6 +439,73 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP .map(Self.activityEvent(from:)) } + public func timelinePage( + filter: KDriveProviderTimelineFilter, + before cursor: KDriveProviderTimelineCursor?, + limit: Int + ) throws -> KDriveProviderTimelinePage { + let pageSize = max(0, limit) + guard pageSize > 0 else { + return KDriveProviderTimelinePage(entries: [], nextCursor: nil, hasMore: false) + } + + let queryLimit = pageSize + 1 + var conflictQuery = ProviderEventSchema.conflictEvents + var activityQuery = ProviderEventSchema.activityEvents + .filter(ProviderEventSchema.relatedConflictID == nil) + + if filter == .errorsAndConflicts { + activityQuery = activityQuery.filter( + ProviderEventSchema.outcome == KDriveProviderActivityOutcome.failure.rawValue + ) + } + + if let cursor { + let cursorDate = cursor.date.timeIntervalSince1970 + let cursorID = cursor.eventID.uuidString + conflictQuery = conflictQuery.filter(Self.timelineCursorPredicate( + date: ProviderEventSchema.effectiveConflictDate, + id: ProviderEventSchema.id, + kind: .conflict, + beforeDate: cursorDate, + beforeKind: cursor.kind, + beforeID: cursorID + )) + activityQuery = activityQuery.filter(Self.timelineCursorPredicate( + date: ProviderEventSchema.occurredAt, + id: ProviderEventSchema.id, + kind: .activity, + beforeDate: cursorDate, + beforeKind: cursor.kind, + beforeID: cursorID + )) + } + + let conflicts = try database.prepare( + conflictQuery + .order(ProviderEventSchema.effectiveConflictDate.desc, ProviderEventSchema.id.desc) + .limit(queryLimit) + ).map(Self.conflictEvent(from:)) + let activity = try database.prepare( + activityQuery + .order(ProviderEventSchema.occurredAt.desc, ProviderEventSchema.id.desc) + .limit(queryLimit) + ).map(Self.activityEvent(from:)) + + let merged = ( + conflicts.map(KDriveProviderTimelineEntry.conflict) + + activity.map(KDriveProviderTimelineEntry.activity) + ).sorted(by: Self.isTimelineEntryNewer) + let entries = Array(merged.prefix(pageSize)) + let hasMore = merged.count > pageSize + + return KDriveProviderTimelinePage( + entries: entries, + nextCursor: hasMore ? entries.last?.cursor : nil, + hasMore: hasMore + ) + } + public func eventStatistics(domainIdentifiers: Set) throws -> [KDriveProviderEventDomainStatistics] { let requestedDomainIdentifiers = Set(domainIdentifiers.filter { $0.isEmpty == false }) guard requestedDomainIdentifiers.isEmpty == false else { return [] } @@ -531,11 +676,14 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_domainIdentifier_idx ON conflict_events(domainIdentifier)") try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_detectedAt_idx ON conflict_events(detectedAt)") + try database.execute("CREATE INDEX IF NOT EXISTS conflict_events_timeline_idx ON conflict_events(COALESCE(resolvedAt, detectedAt) DESC, id DESC)") try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_domainIdentifier_idx ON provider_activity_events(domainIdentifier)") try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_occurredAt_idx ON provider_activity_events(occurredAt)") try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_relatedConflictID_idx ON provider_activity_events(relatedConflictID)") try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_outcome_idx ON provider_activity_events(outcome)") try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_correlationID_idx ON provider_activity_events(correlationID)") + try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_timeline_idx ON provider_activity_events(occurredAt DESC, id DESC) WHERE relatedConflictID IS NULL") + try database.execute("CREATE INDEX IF NOT EXISTS provider_activity_events_failure_timeline_idx ON provider_activity_events(outcome, occurredAt DESC, id DESC) WHERE relatedConflictID IS NULL") } private static func migrateActivityEvents(on database: Connection) throws { @@ -731,6 +879,41 @@ public actor KDriveProviderEventSQLiteStore: KDriveProviderEventStoring, KDriveP return rhs } + private static func timelineCursorPredicate( + date: SQLite.Expression, + id: SQLite.Expression, + kind: KDriveProviderTimelineCursor.Kind, + beforeDate: Double, + beforeKind: KDriveProviderTimelineCursor.Kind, + beforeID: String + ) -> SQLite.Expression { + let earlierDate = date < beforeDate + let sameDate = date == beforeDate + + if kind.rawValue < beforeKind.rawValue { + return earlierDate || sameDate + } + if kind.rawValue > beforeKind.rawValue { + return earlierDate + } + return earlierDate || (sameDate && id < beforeID) + } + + private static func isTimelineEntryNewer( + _ lhs: KDriveProviderTimelineEntry, + than rhs: KDriveProviderTimelineEntry + ) -> Bool { + let lhsCursor = lhs.cursor + let rhsCursor = rhs.cursor + if lhsCursor.date != rhsCursor.date { + return lhsCursor.date > rhsCursor.date + } + if lhsCursor.kind != rhsCursor.kind { + return lhsCursor.kind.rawValue > rhsCursor.kind.rawValue + } + return lhsCursor.eventID.uuidString > rhsCursor.eventID.uuidString + } + private static func setters(for event: KDriveConflictEvent) -> [Setter] { [ ProviderEventSchema.id <- event.id.uuidString, @@ -875,6 +1058,9 @@ private enum ProviderEventSchema { static let id = Expression("id") static let detectedAt = Expression("detectedAt") static let resolvedAt = Expression("resolvedAt") + static let effectiveConflictDate = SQLite.Expression( + literal: "COALESCE(\"resolvedAt\", \"detectedAt\")" + ) static let occurredAt = Expression("occurredAt") static let domainIdentifier = Expression("domainIdentifier") static let driveID = Expression("driveID") diff --git a/doc/CONFLICTS.md b/doc/CONFLICTS.md index a22a956..1981b10 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -198,14 +198,21 @@ Stale rename, move, trash, and permanent delete attempts are recorded as The app has an Activities tab backed by `Snapshots.sqlite3`. -- Conflict rows show the detection date, operation, resolution state, automatic - resolution marker, summary, and file link when the File Provider item can be - resolved. -- File links are resolved at display time from stored domain and item - identifiers through `NSFileProviderManager.getUserVisibleURL(for:)`. -- The default view shows conflicts and recent non-conflict failure activity. -- The Last Activity toggle adds recent successful provider activity from the - database, including enumeration/change sync and major item operations. +- The timeline loads 50 mixed conflict/activity entries initially and + automatically prefetches older keyset-paged entries near the end. Appending + history and merging live database changes preserve the visible event anchor. +- Entries are grouped by day and use compact summaries. Expanding an entry + reveals conflict state, diagnostics, recovery guidance, identifiers, copy, + and item actions without making every row expensive to render. +- File links are resolved from stored domain and item identifiers through + `NSFileProviderManager.getUserVisibleURL(for:)` only after the user selects + **Open in Files**. Scrolling the timeline does not perform File Provider URL + lookups. +- The default Errors filter shows conflicts and non-conflict failure activity. + All Activity also includes successful enumeration, change sync, and item + operations. +- New live entries are merged silently without moving a user who is reading + older history. **Back to Latest** returns to the newest entry. - The Clear button removes activity event rows and automatically resolved conflict rows while preserving unresolved, blocked, and failed conflict rows. - The Export button creates a redacted JSON support log. It pseudonymizes @@ -218,7 +225,8 @@ The app has an Activities tab backed by `Snapshots.sqlite3`. as app activity and do not attempt File Provider item-link resolution. - The tab observes database changes with SQLite.swift's `updateHook` for its own connection and SQLite `PRAGMA data_version` polling for writes committed - by the File Provider extension's separate connection. + by the File Provider extension's separate connection. Bursts are coalesced + before the newest page is refreshed. - This is an audit/read model only. It does not replay failed operations or automatically retry retained staged uploads. diff --git a/doc/LOGGING.md b/doc/LOGGING.md index af5dc6a..c01c57c 100644 --- a/doc/LOGGING.md +++ b/doc/LOGGING.md @@ -45,6 +45,11 @@ conflict rows remain until a user action or domain cleanup removes them. The existing Clear action removes all activity rows and automatically resolved conflicts, preserving unresolved conflict state. +The Activities screen pages over this retained history in batches of 50. This +only limits UI decoding and rendering; it does not reduce retention or support +export coverage. Live notifications refresh and merge the newest page, and +ordinary scrolling does not resolve File Provider item URLs. + ## Support Export The Activities toolbar exports a JSON document via the system file picker. Each diff --git a/doc/PERSISTENCE.md b/doc/PERSISTENCE.md index d4a068b..786c108 100644 --- a/doc/PERSISTENCE.md +++ b/doc/PERSISTENCE.md @@ -181,9 +181,18 @@ versions are rebuilt from kDrive instead of reused for later mutations. - `remoteRequestID` Conflict and activity tables are indexed by domain and event date. Activity rows -are also indexed by related conflict ID, outcome, and correlation ID. Activity -rows are bounded to the newest 5,000 records by default; conflict rows are not -removed by this retention rule. +are also indexed by related conflict ID, outcome, correlation ID, and partial +timeline indexes for non-conflict activity. The conflict timeline index uses +`COALESCE(resolvedAt, detectedAt)` so a resolved conflict appears at its +effective event date. Activity rows are bounded to the newest 5,000 records by +default; conflict rows are not removed by this retention rule. + +The Activities UI reads through `KDriveProviderEventTimelinePaging`. +`KDriveProviderTimelineCursor` is a keyset cursor composed of effective event +date, event kind, and UUID. This produces deterministic mixed pages when +timestamps match and avoids the duplication and scroll movement associated with +offset pagination while new rows are being written. Related-conflict activity +is excluded in SQL because the conflict row already represents that event. Activity rows support both domain-scoped provider events and app-scoped setup events. App-scoped rows use `ProviderConstants.appActivityDomainIdentifier` and diff --git a/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme index f54e915..b253dfb 100644 --- a/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme +++ b/potassiumProvider.xcodeproj/xcshareddata/xcschemes/potassiumProvider.xcscheme @@ -35,6 +35,20 @@ ReferencedContainer = "container:potassiumProvider.xcodeproj"> + + + + + + + + { + private var filterBinding: Binding { Binding { - model.showsActivity ? .fullActivity : .errorsOnly - } set: { option in - model.showsActivity = option.showsActivity + model.filter + } set: { filter in + Task { + await model.setFilter(filter) + moveToLatest(animated: true) + } } } - private var emptyActivityMessage: String { - model.showsActivity ? "No activities yet" : "No errors or conflicts yet" - } - - private var activityToolbarPlacement: ToolbarItemPlacement { - #if os(macOS) - .automatic - #else - .topBarTrailing - #endif - } -} - -private enum ActivityFilterOption: CaseIterable, Identifiable { - case errorsOnly - case fullActivity - - var id: Self { self } - - var title: String { - switch self { - case .errorsOnly: - return "Only Errors" - case .fullActivity: - return "Full Activity" + @ViewBuilder + private var timelineContent: some View { + if model.isDatabaseUnavailable { + ContentUnavailableView( + "Activities Unavailable", + systemImage: "externaldrive.badge.exclamationmark", + description: Text("The activity database could not be opened.") + ) + } else if model.isInitialLoading && model.entries.isEmpty { + ProgressView("Loading activities…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("activity.initialLoading") + } else if let errorMessage = model.initialErrorMessage, model.entries.isEmpty { + ContentUnavailableView { + Label("Could Not Load Activities", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Try Again") { + Task { await model.load() } + } + .accessibilityIdentifier("activity.retryInitial") + } + } else if model.entries.isEmpty { + ContentUnavailableView( + emptyActivityTitle, + systemImage: "checkmark.seal", + description: Text(emptyActivityDescription) + ) + } else { + timelineScrollView } } - var systemImage: String { - switch self { - case .errorsOnly: - return "exclamationmark.triangle" - case .fullActivity: - return "clock.arrow.circlepath" - } - } + private var timelineScrollView: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + if let actionErrorMessage = model.actionErrorMessage { + ActivityInlineError( + message: actionErrorMessage, + dismiss: model.dismissActionError + ) + .padding(.horizontal) + .padding(.top, 12) + } - var showsActivity: Bool { - self == .fullActivity - } -} + ForEach(model.sections) { section in + Section { + ForEach(section.entries) { entry in + ProviderActivityTimelineRow(entry: entry) + .id(entry.id) + .padding(.horizontal) + .padding(.vertical, 5) + } + } header: { + ActivityDateHeader(date: section.id) + } + } -private extension View { - @ViewBuilder - func providerActivityControlGlass() -> some View { - #if os(visionOS) - self - #else - glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 8)) - #endif - } + paginationFooter + } + .scrollTargetLayout() + .padding(.bottom, 12) + } + .scrollPosition(id: $scrollPositionID) + .onScrollTargetVisibilityChange(idType: String.self, threshold: 0.35) { visibleIDs in + visibleEntryIDs = visibleIDs + Task { await model.loadMoreIfNeeded(visibleEntryIDs: visibleIDs) } + } + .refreshable { + await model.refresh() + } + .accessibilityIdentifier("activity.timeline") - @ViewBuilder - func providerActivityCopyableText(_ text: String) -> some View { - #if os(macOS) - textSelection(.enabled) - .contextMenu { + if isAwayFromLatest { Button { - ProviderActivityClipboard.copy(text) + moveToLatest(animated: true) } label: { - Label("Copy", systemImage: "doc.on.doc") + Label("Back to Latest", systemImage: "arrow.up.to.line") } + .buttonStyle(.borderedProminent) + .labelStyle(.titleAndIcon) + .padding() + .accessibilityIdentifier("activity.backToLatest") } - #else - self - #endif - } -} - -#if os(macOS) -private enum ProviderActivityClipboard { - static func copy(_ text: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + } } -} -#endif - -@MainActor -final class ConflictLogViewModel: ObservableObject { - @Published private(set) var conflicts: [KDriveConflictEvent] = [] - @Published private(set) var activity: [KDriveProviderActivityEvent] = [] - @Published private(set) var isLoading = false - @Published private(set) var isClearing = false - @Published private(set) var isExporting = false - @Published var showsActivity = false - @Published var errorMessage: String? - private let eventStore: (any KDriveProviderEventStoring)? - private var eventObservationTask: Task? - - init(eventStore: (any KDriveProviderEventStoring)?) { - self.eventStore = eventStore - if let eventStore = eventStore as? any KDriveProviderEventObserving { - eventObservationTask = Task { [weak self] in - let changes = await eventStore.eventChanges(pollInterval: 1) - for await _ in changes { - await self?.load() + @ViewBuilder + private var paginationFooter: some View { + if model.isLoadingMore { + HStack { + Spacer() + ProgressView("Loading older activity…") + Spacer() + } + .padding() + .accessibilityIdentifier("activity.loadingMore") + } else if let paginationErrorMessage = model.paginationErrorMessage { + VStack(spacing: 8) { + Label(paginationErrorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + Button("Try Again") { + Task { await model.loadMore() } } + .accessibilityIdentifier("activity.retryPage") } + .frame(maxWidth: .infinity) + .padding() + } else if model.hasMore == false { + Label("End of activity", systemImage: "checkmark") + .font(.footnote) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity) + .padding() + .accessibilityIdentifier("activity.end") } } - deinit { - eventObservationTask?.cancel() - } - - var timelineItems: [ConflictTimelineItem] { - let conflictItems = conflicts.map(ConflictTimelineItem.conflict) - let activityItems = activity - .filter { $0.relatedConflictID == nil } - .filter { showsActivity || $0.outcome == .failure } - .map(ConflictTimelineItem.activity) - return (conflictItems + activityItems).sorted { $0.date > $1.date } - } - - var canClearActivity: Bool { - eventStore != nil && isLoading == false && isClearing == false - } - - var canExportSupportLog: Bool { - eventStore is any KDriveProviderEventExporting && isExporting == false - } - - func load() async { - guard let eventStore else { - conflicts = [] - activity = [] - errorMessage = "Activity database is unavailable." - return + @ToolbarContentBuilder + private var activityToolbar: some ToolbarContent { + #if os(macOS) + ToolbarItemGroup(placement: .automatic) { + clearButton + refreshButton + exportButton } - - isLoading = true - defer { isLoading = false } - - do { - conflicts = try await eventStore.recentConflicts(domainIdentifier: nil, limit: 100) - activity = try await eventStore.recentActivity( - domainIdentifier: nil, - outcome: showsActivity ? nil : .failure, - limit: 100 - ) - errorMessage = nil - } catch { - errorMessage = "Could not load activity events: \(error.localizedDescription)" + #else + ToolbarItem(placement: .topBarTrailing) { + refreshButton + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + exportButton + clearButton + } label: { + Label("More Activity Actions", systemImage: "ellipsis.circle") + } } + #endif } - func clearActivity() async { - guard let eventStore else { - conflicts = [] - activity = [] - errorMessage = "Activity database is unavailable." - return - } - - isClearing = true - defer { isClearing = false } - - do { - try await eventStore.removeActivityAndResolvedConflicts(domainIdentifier: nil) - await load() - } catch { - errorMessage = "Could not clear activity events: \(error.localizedDescription)" + private var clearButton: some View { + Button(role: .destructive) { + isClearConfirmationPresented = true + } label: { + Label(model.isClearing ? "Clearing" : "Clear", systemImage: "trash") } + .disabled(model.canClearActivity == false) } - func supportLogData() async -> Data? { - guard let eventStore = eventStore as? any KDriveProviderEventExporting else { - errorMessage = "Support-log export is unavailable." - return nil + private var refreshButton: some View { + Button { + Task { await model.refresh() } + } label: { + Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") } - - isExporting = true - defer { isExporting = false } - - do { - let data = try await eventStore.supportLogData(domainIdentifier: nil) - errorMessage = nil - return data - } catch { - errorMessage = "Could not create support log: \(error.localizedDescription)" - return nil - } - } - - func recordExportFailure(_ error: Error) { - errorMessage = "Could not export support log: \(error.localizedDescription)" + .disabled(model.isLoading) + .accessibilityIdentifier("activity.refresh") } -} - -private struct ProviderSupportLogDocument: FileDocument { - static var readableContentTypes: [UTType] { [.json] } - let data: Data - - init(data: Data) { - self.data = data - } - - init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents else { - throw CocoaError(.fileReadCorruptFile) + private var exportButton: some View { + Button { + Task { + guard let data = await model.supportLogData() else { return } + supportLogDocument = ProviderSupportLogDocument(data: data) + isSupportLogExporterPresented = true + } + } label: { + Label(model.isExporting ? "Exporting" : "Export", systemImage: "square.and.arrow.up") } - self.data = data - } - - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) + .disabled(model.canExportSupportLog == false) } -} -enum ConflictTimelineItem: Identifiable { - case conflict(KDriveConflictEvent) - case activity(KDriveProviderActivityEvent) - - var id: String { - switch self { - case .conflict(let event): - return "conflict-\(event.id.uuidString)" - case .activity(let event): - return "activity-\(event.id.uuidString)" - } + private var isAwayFromLatest: Bool { + guard let latestID = model.entries.first?.id else { return false } + guard visibleEntryIDs.isEmpty == false else { return false } + return visibleEntryIDs.contains(latestID) == false } - var date: Date { - switch self { - case .conflict(let event): - return event.resolvedAt ?? event.detectedAt - case .activity(let event): - return event.occurredAt - } - } -} - -private struct ConflictEventRow: View { - let event: KDriveConflictEvent - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline) { - Label(eventTitle, systemImage: event.resolutionState.systemImage) - .font(.headline) - Spacer() - Text(event.detectedAt, format: .dateTime.month().day().hour().minute()) - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - Label(event.operation.displayName, systemImage: event.operation.systemImage) - Label(event.resolutionState.displayName, systemImage: event.resolutionState.systemImage) - if event.automaticallyResolved { - Label("Automatic", systemImage: "bolt.fill") - } + private func moveToLatest(animated: Bool) { + guard let latestID = model.entries.first?.id else { return } + if animated && reduceMotion == false { + withAnimation(.snappy) { + scrollPositionID = latestID } - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(event.resolutionSummary) - .font(.subheadline) - - if let stagedUploadRelativePath = event.stagedUploadRelativePath { - Text(stagedUploadRelativePath) - .font(.caption) - .foregroundStyle(.secondary) - } - - ProviderItemLink( - domainIdentifier: event.domainIdentifier, - itemIdentifier: event.conflictItemIdentifier ?? event.originalItemIdentifier, - title: linkTitle, - fallbackDetail: event.conflictItemPath ?? event.originalItemPath - ) + } else { + scrollPositionID = latestID } - .padding(.vertical, 4) - .providerActivityCopyableText(copyText) } - private var eventTitle: String { - event.conflictItemName - ?? event.originalItemName - ?? event.originalItemIdentifier - ?? "Unknown item" + private var emptyActivityTitle: String { + model.showsActivity ? "No Activities Yet" : "No Errors or Conflicts" } - private var linkTitle: String { - event.conflictItemName - ?? event.originalItemName - ?? "Open item" - } - - private var copyText: String { - var lines = [ - "Conflict: \(eventTitle)", - "Detected: \(event.detectedAt.providerActivityCopyFormatted)", - "Operation: \(event.operation.displayName)", - "State: \(event.resolutionState.displayName)", - "Summary: \(event.resolutionSummary)" - ] - - if let resolvedAt = event.resolvedAt { - lines.insert("Resolved: \(resolvedAt.providerActivityCopyFormatted)", at: 2) - } - if event.automaticallyResolved { - lines.append("Automatic: Yes") - } - if let stagedUploadRelativePath = event.stagedUploadRelativePath, stagedUploadRelativePath.isEmpty == false { - lines.append("Staged upload: \(stagedUploadRelativePath)") - } - if let itemPath = event.conflictItemPath ?? event.originalItemPath, itemPath.isEmpty == false { - lines.append("Item path: \(itemPath)") - } - if let itemIdentifier = event.conflictItemIdentifier ?? event.originalItemIdentifier, itemIdentifier.isEmpty == false { - lines.append("Item identifier: \(itemIdentifier)") - } - - return lines.joined(separator: "\n") + private var emptyActivityDescription: String { + model.showsActivity + ? "Provider activity will appear here as files synchronize." + : "Everything looks clear. Switch to All Activity to see successful operations." } } -private struct ActivityEventRow: View { - let event: KDriveProviderActivityEvent +private struct ActivityDateHeader: View { + let date: Date var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline) { - Label(title, systemImage: event.outcome.systemImage(for: event.kind)) - .font(.headline) - Spacer() - Text(event.occurredAt, format: .dateTime.month().day().hour().minute()) - .font(.caption) - .foregroundStyle(.secondary) - } - - Text(event.summary) - .font(.subheadline) - - if event.outcome == .failure { - HStack(spacing: 12) { - Label(event.kind.displayName, systemImage: event.kind.systemImage) - if let errorCategory = event.errorCategory { - Label(errorCategory.displayName, systemImage: "tag") - } - if let diagnosticCode { - Label(diagnosticCode, systemImage: "number") - } - } - .font(.caption) - .foregroundStyle(.secondary) - - if let recoverySuggestion = event.recoverySuggestion, recoverySuggestion.isEmpty == false { - Text(recoverySuggestion) - .font(.caption) - .foregroundStyle(.secondary) - } - - if let diagnosticSummary = event.diagnosticSummary, diagnosticSummary.isEmpty == false { - Text(diagnosticSummary) - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if event.scope == .domain { - ProviderItemLink( - domainIdentifier: event.domainIdentifier, - itemIdentifier: event.itemIdentifier, - title: event.itemName ?? event.itemIdentifier ?? "Open item", - fallbackDetail: event.itemPath - ) - } else { - Label("App", systemImage: "app") - .font(.caption) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - .providerActivityCopyableText(copyText) + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + .padding(.vertical, 7) + .background(.bar) + .accessibilityAddTraits(.isHeader) } private var title: String { - switch event.outcome { - case .success: - return event.kind.displayName - case .failure: - return "Failed \(event.kind.displayName.lowercased())" + let calendar = Calendar.autoupdatingCurrent + if calendar.isDateInToday(date) { + return "Today" } - } - - private var diagnosticCode: String? { - if let providerErrorCode = event.providerErrorCode { - return "Provider \(providerErrorCode)" + if calendar.isDateInYesterday(date) { + return "Yesterday" } - if let underlyingErrorDomain = event.underlyingErrorDomain, - let underlyingErrorCode = event.underlyingErrorCode { - return "\(underlyingErrorDomain) \(underlyingErrorCode)" - } - return nil - } - - private var copyText: String { - var lines = [ - "Activity: \(title)", - "Occurred: \(event.occurredAt.providerActivityCopyFormatted)", - "Outcome: \(event.outcome.copyDisplayName)", - "Outcome key: \(event.outcome.rawValue)", - "Kind: \(event.kind.displayName)", - "Kind key: \(event.kind.rawValue)", - "Summary: \(event.summary)" - ] - - if let correlationID = event.correlationID, correlationID.isEmpty == false { - lines.append("Correlation ID: \(correlationID)") - } - if let durationMilliseconds = event.durationMilliseconds { - lines.append("Duration: \(durationMilliseconds) ms") - } - if let networkOperation = event.networkOperation, networkOperation.isEmpty == false { - lines.append("Network operation: \(networkOperation)") - } - if let httpStatusCode = event.httpStatusCode { - lines.append("HTTP status: \(httpStatusCode)") - } - - if event.outcome == .failure { - lines.append("Severity: \(event.severity.copyDisplayName)") - lines.append("Severity key: \(event.severity.rawValue)") - if let errorCategory = event.errorCategory { - lines.append("Error category: \(errorCategory.displayName)") - lines.append("Error category key: \(errorCategory.rawValue)") - } - if let providerErrorCode = event.providerErrorCode { - lines.append("Provider error code: \(providerErrorCode)") - } - if let underlyingErrorDomain = event.underlyingErrorDomain, underlyingErrorDomain.isEmpty == false { - lines.append("Underlying error domain: \(underlyingErrorDomain)") - } - if let underlyingErrorCode = event.underlyingErrorCode { - lines.append("Underlying error code: \(underlyingErrorCode)") - } - if let recoverySuggestion = event.recoverySuggestion, recoverySuggestion.isEmpty == false { - lines.append("Recovery suggestion: \(recoverySuggestion)") - } - if let diagnosticSummary = event.diagnosticSummary, diagnosticSummary.isEmpty == false { - lines.append("Diagnostic summary: \(diagnosticSummary)") - } - if let relatedConflictID = event.relatedConflictID { - lines.append("Related conflict ID: \(relatedConflictID.uuidString)") - } - lines.append("Event ID: \(event.id.uuidString)") - lines.append("Domain identifier: \(event.domainIdentifier)") - lines.append("Drive ID: \(event.driveID)") - } - if event.scope == .domain { - lines.append("Scope: Domain") - if let itemName = event.itemName, itemName.isEmpty == false { - lines.append("Item: \(itemName)") - } - if let itemPath = event.itemPath, itemPath.isEmpty == false { - lines.append("Item path: \(itemPath)") - } - if let itemIdentifier = event.itemIdentifier, itemIdentifier.isEmpty == false { - lines.append("Item identifier: \(itemIdentifier)") - } - } else { - lines.append("Scope: App") - } - - return lines.joined(separator: "\n") + return date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()) } } -private struct ProviderItemLink: View { - let domainIdentifier: String - let itemIdentifier: String? - let title: String - let fallbackDetail: String? - - @State private var resolvedURL: URL? - @State private var didResolve = false +private struct ActivityInlineError: View { + let message: String + let dismiss: () -> Void var body: some View { - Group { - if let resolvedURL { - Link(destination: resolvedURL) { - Label(title, systemImage: "arrow.up.forward.app") - } - } else if let itemIdentifier { - Label(fallbackDetail ?? itemIdentifier, systemImage: didResolve ? "link.badge.plus" : "link") - .foregroundStyle(.secondary) - } - } - .font(.caption) - .task(id: "\(domainIdentifier)-\(itemIdentifier ?? "")") { - await resolveURL() - } - } - - private func resolveURL() async { - guard let itemIdentifier else { - didResolve = true - resolvedURL = nil - return - } - - let domain = NSFileProviderDomain( - identifier: NSFileProviderDomainIdentifier(rawValue: domainIdentifier), - displayName: domainIdentifier - ) - guard let manager = NSFileProviderManager(for: domain) else { - didResolve = true - resolvedURL = nil - return - } - - resolvedURL = await withCheckedContinuation { continuation in - manager.getUserVisibleURL(for: NSFileProviderItemIdentifier(itemIdentifier)) { url, _ in - continuation.resume(returning: url) - } - } - didResolve = true - } -} - -private extension Date { - var providerActivityCopyFormatted: String { - formatted(.dateTime.year().month().day().hour().minute().second()) - } -} - -private extension KDriveProviderActivityKind { - var displayName: String { - switch self { - case .enumeration: - return "Enumeration" - case .changeSync: - return "Change Sync" - case .syncAnchor: - return "Sync Anchor" - case .fetchContents: - return "Fetch" - case .metadataLookup: - return "Metadata Lookup" - case .create: - return "Create" - case .modify: - return "Modify" - case .trash: - return "Trash" - case .delete: - return "Delete" - case .conflict: - return "Conflict" - case .thumbnail: - return "Thumbnail" - case .runtimeLoading: - return "Runtime Loading" - case .authentication: - return "Authentication" - case .driveDiscovery: - return "Drive Discovery" - case .domainManagement: - return "Domain Management" - case .favorite: - return "Favorite" - case .duplicate: - return "Duplicate" - case .restore: - return "Restore" - case .shareLink: - return "Share Link" - case .versionRestore: - return "Version Restore" - } - } - - var systemImage: String { - switch self { - case .enumeration: - return "list.bullet.rectangle" - case .changeSync: - return "arrow.triangle.2.circlepath" - case .syncAnchor: - return "link" - case .fetchContents: - return "arrow.down.doc" - case .metadataLookup: - return "doc.text.magnifyingglass" - case .create: - return "plus" - case .modify: - return "pencil" - case .trash: - return "trash" - case .delete: - return "xmark.bin" - case .conflict: - return "exclamationmark.triangle" - case .thumbnail: - return "photo" - case .runtimeLoading: - return "gearshape" - case .authentication: - return "person.crop.circle.badge.exclamationmark" - case .driveDiscovery: - return "externaldrive.badge.questionmark" - case .domainManagement: - return "folder.badge.gearshape" - case .favorite: - return "star" - case .duplicate: - return "plus.square.on.square" - case .restore: - return "arrow.uturn.backward" - case .shareLink: - return "link" - case .versionRestore: - return "clock.arrow.trianglehead.counterclockwise.rotate.90" + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Dismiss", systemImage: "xmark", action: dismiss) + .labelStyle(.iconOnly) + .accessibilityLabel("Dismiss activity message") } + .padding(12) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .accessibilityIdentifier("activity.error") } } -private extension KDriveProviderActivityOutcome { - func systemImage(for kind: KDriveProviderActivityKind) -> String { - switch self { - case .success: - return kind.systemImage - case .failure: - return "exclamationmark.triangle" - } - } - - var copyDisplayName: String { - switch self { - case .success: - return "Success" - case .failure: - return "Failure" - } - } -} +private struct ProviderSupportLogDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json] } -private extension KDriveProviderActivitySeverity { - var copyDisplayName: String { - switch self { - case .info: - return "Info" - case .warning: - return "Warning" - case .error: - return "Error" - } - } -} + let data: Data -private extension KDriveProviderActivityErrorCategory { - var displayName: String { - switch self { - case .authentication: - return "Authentication" - case .network: - return "Network" - case .api: - return "API" - case .fileProvider: - return "File Provider" - case .listing: - return "Listing" - case .snapshot: - return "Snapshot" - case .storage: - return "Storage" - case .validation: - return "Validation" - case .mutationConflict: - return "Conflict" - case .unknown: - return "Unknown" - } + init(data: Data) { + self.data = data } -} -private extension KDriveConflictResolutionState { - var displayName: String { - switch self { - case .unresolved: - return "Unresolved" - case .automaticallyResolved: - return "Resolved" - case .blockedRetryable: - return "Blocked" - case .failed: - return "Failed" + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { + throw CocoaError(.fileReadCorruptFile) } + self.data = data } - var systemImage: String { - switch self { - case .unresolved: - return "questionmark.circle" - case .automaticallyResolved: - return "checkmark.circle" - case .blockedRetryable: - return "pause.circle" - case .failed: - return "xmark.circle" - } + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) } } diff --git a/potassiumProvider/ConflictLogViewModel.swift b/potassiumProvider/ConflictLogViewModel.swift new file mode 100644 index 0000000..5c70c9c --- /dev/null +++ b/potassiumProvider/ConflictLogViewModel.swift @@ -0,0 +1,361 @@ +import Combine +import Foundation +import PotassiumProviderCore + +struct ActivityTimelineSection: Identifiable, Equatable { + let id: Date + let entries: [KDriveProviderTimelineEntry] +} + +@MainActor +final class ConflictLogViewModel: ObservableObject { + static let pageSize = 50 + static let prefetchDistance = 10 + + @Published private(set) var entries: [KDriveProviderTimelineEntry] = [] + @Published private(set) var sections: [ActivityTimelineSection] = [] + @Published private(set) var filter: KDriveProviderTimelineFilter = .errorsAndConflicts + @Published private(set) var isInitialLoading = false + @Published private(set) var isLoadingMore = false + @Published private(set) var isRefreshing = false + @Published private(set) var isClearing = false + @Published private(set) var isExporting = false + @Published private(set) var hasMore = false + @Published private(set) var initialErrorMessage: String? + @Published private(set) var paginationErrorMessage: String? + @Published var actionErrorMessage: String? + + private let eventStore: (any KDriveProviderEventStoring)? + private let timelineStore: (any KDriveProviderEventTimelinePaging)? + private var nextCursor: KDriveProviderTimelineCursor? + private var loadGeneration = 0 + private var hasLoadedAdditionalPages = false + private var eventObservationTask: Task? + private var coalescedRefreshTask: Task? + + init(eventStore: (any KDriveProviderEventStoring)?) { + self.eventStore = eventStore + timelineStore = eventStore as? any KDriveProviderEventTimelinePaging + } + + deinit { + eventObservationTask?.cancel() + coalescedRefreshTask?.cancel() + } + + var showsActivity: Bool { + filter == .allActivity + } + + var isLoading: Bool { + isInitialLoading || isRefreshing + } + + var isDatabaseUnavailable: Bool { + eventStore == nil || timelineStore == nil + } + + var canClearActivity: Bool { + eventStore != nil + && isInitialLoading == false + && isLoadingMore == false + && isRefreshing == false + && isClearing == false + } + + var canExportSupportLog: Bool { + eventStore is any KDriveProviderEventExporting && isExporting == false + } + + func start() async { + if entries.isEmpty && isInitialLoading == false { + await load() + } + startObservingChanges() + } + + func stop() { + eventObservationTask?.cancel() + eventObservationTask = nil + coalescedRefreshTask?.cancel() + coalescedRefreshTask = nil + } + + func setFilter(_ newFilter: KDriveProviderTimelineFilter) async { + guard filter != newFilter else { return } + filter = newFilter + await load() + } + + func load() async { + guard let timelineStore else { + entries = [] + rebuildSections() + hasMore = false + initialErrorMessage = "Activity database is unavailable." + return + } + + loadGeneration += 1 + let generation = loadGeneration + isLoadingMore = false + isRefreshing = false + isInitialLoading = true + paginationErrorMessage = nil + initialErrorMessage = nil + defer { + if generation == loadGeneration { + isInitialLoading = false + } + } + + do { + let page = try await timelineStore.timelinePage( + filter: filter, + before: nil, + limit: Self.pageSize + ) + guard generation == loadGeneration else { return } + entries = page.entries + nextCursor = page.nextCursor + hasMore = page.hasMore + hasLoadedAdditionalPages = false + rebuildSections() + } catch { + guard generation == loadGeneration else { return } + entries = [] + nextCursor = nil + hasMore = false + rebuildSections() + initialErrorMessage = "Could not load activity events: \(error.localizedDescription)" + } + } + + func loadMoreIfNeeded(visibleEntryIDs: [String]) async { + guard shouldPrefetch(visibleEntryIDs: visibleEntryIDs) else { return } + await loadMore() + } + + func loadMore() async { + guard + let timelineStore, + hasMore, + isLoadingMore == false, + isInitialLoading == false, + isRefreshing == false, + let nextCursor + else { + return + } + + let generation = loadGeneration + isLoadingMore = true + paginationErrorMessage = nil + defer { + if generation == loadGeneration { + isLoadingMore = false + } + } + + do { + let page = try await timelineStore.timelinePage( + filter: filter, + before: nextCursor, + limit: Self.pageSize + ) + guard generation == loadGeneration else { return } + merge(page.entries) + self.nextCursor = page.nextCursor + hasMore = page.hasMore + hasLoadedAdditionalPages = true + } catch { + guard generation == loadGeneration else { return } + paginationErrorMessage = "Could not load older activity: \(error.localizedDescription)" + } + } + + func refresh() async { + guard let timelineStore else { + initialErrorMessage = "Activity database is unavailable." + return + } + guard + isRefreshing == false, + isInitialLoading == false, + isLoadingMore == false, + isClearing == false + else { + return + } + + let generation = loadGeneration + isRefreshing = true + defer { + if generation == loadGeneration { + isRefreshing = false + } + } + + do { + let page = try await timelineStore.timelinePage( + filter: filter, + before: nil, + limit: Self.pageSize + ) + guard generation == loadGeneration else { return } + + if hasLoadedAdditionalPages { + merge(page.entries) + } else { + entries = page.entries + nextCursor = page.nextCursor + hasMore = page.hasMore + rebuildSections() + } + initialErrorMessage = nil + actionErrorMessage = nil + } catch { + guard generation == loadGeneration else { return } + if entries.isEmpty { + initialErrorMessage = "Could not load activity events: \(error.localizedDescription)" + } else { + actionErrorMessage = "Could not refresh activity events: \(error.localizedDescription)" + } + } + } + + func clearActivity() async { + guard let eventStore else { + actionErrorMessage = "Activity database is unavailable." + return + } + + isClearing = true + defer { isClearing = false } + + do { + try await eventStore.removeActivityAndResolvedConflicts(domainIdentifier: nil) + actionErrorMessage = nil + await load() + } catch { + actionErrorMessage = "Could not clear activity events: \(error.localizedDescription)" + } + } + + func supportLogData() async -> Data? { + guard let eventStore = eventStore as? any KDriveProviderEventExporting else { + actionErrorMessage = "Support-log export is unavailable." + return nil + } + + isExporting = true + defer { isExporting = false } + + do { + let data = try await eventStore.supportLogData(domainIdentifier: nil) + actionErrorMessage = nil + return data + } catch { + actionErrorMessage = "Could not create support log: \(error.localizedDescription)" + return nil + } + } + + func recordExportFailure(_ error: Error) { + actionErrorMessage = "Could not export support log: \(error.localizedDescription)" + } + + func dismissActionError() { + actionErrorMessage = nil + } + + private func shouldPrefetch(visibleEntryIDs: [String]) -> Bool { + guard hasMore, isLoadingMore == false, visibleEntryIDs.isEmpty == false else { + return false + } + let thresholdIndex = max(0, entries.count - Self.prefetchDistance) + return visibleEntryIDs.contains { id in + guard let index = entries.firstIndex(where: { $0.id == id }) else { return false } + return index >= thresholdIndex + } + } + + private func merge(_ updatedEntries: [KDriveProviderTimelineEntry]) { + var entriesByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) + for entry in updatedEntries { + entriesByID[entry.id] = entry + } + entries = entriesByID.values.sorted(by: Self.isNewer) + rebuildSections() + } + + private func rebuildSections() { + let calendar = Calendar.autoupdatingCurrent + var grouped: [(date: Date, entries: [KDriveProviderTimelineEntry])] = [] + + for entry in entries { + let date = calendar.startOfDay(for: entry.date) + if grouped.last?.date == date { + grouped[grouped.count - 1].entries.append(entry) + } else { + grouped.append((date, [entry])) + } + } + sections = grouped.map { ActivityTimelineSection(id: $0.date, entries: $0.entries) } + } + + private static func isNewer( + _ lhs: KDriveProviderTimelineEntry, + than rhs: KDriveProviderTimelineEntry + ) -> Bool { + let lhsCursor = lhs.cursor + let rhsCursor = rhs.cursor + if lhsCursor.date != rhsCursor.date { + return lhsCursor.date > rhsCursor.date + } + if lhsCursor.kind != rhsCursor.kind { + return lhsCursor.kind.rawValue > rhsCursor.kind.rawValue + } + return lhsCursor.eventID.uuidString > rhsCursor.eventID.uuidString + } + + private func startObservingChanges() { + guard + eventObservationTask == nil, + let observingStore = eventStore as? any KDriveProviderEventObserving + else { + return + } + + eventObservationTask = Task { [weak self] in + let changes = await observingStore.eventChanges(pollInterval: 1) + for await _ in changes { + guard Task.isCancelled == false else { return } + self?.scheduleCoalescedRefresh() + } + } + } + + private func scheduleCoalescedRefresh() { + coalescedRefreshTask?.cancel() + coalescedRefreshTask = Task { [weak self] in + do { + try await Task.sleep(for: .milliseconds(350)) + } catch { + return + } + await self?.refreshAfterActivitySettles() + } + } + + private func refreshAfterActivitySettles() async { + while isInitialLoading || isLoadingMore || isClearing { + do { + try await Task.sleep(for: .milliseconds(200)) + } catch { + return + } + } + await refresh() + } +} diff --git a/potassiumProvider/ProviderActivityTimelineRow.swift b/potassiumProvider/ProviderActivityTimelineRow.swift new file mode 100644 index 0000000..0d7717f --- /dev/null +++ b/potassiumProvider/ProviderActivityTimelineRow.swift @@ -0,0 +1,578 @@ +import FileProvider +import PotassiumProviderCore +import SwiftUI +#if os(macOS) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +struct ProviderActivityTimelineRow: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + let entry: KDriveProviderTimelineEntry + @State private var isExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Button { + if reduceMotion { + isExpanded.toggle() + } else { + withAnimation(.snappy) { + isExpanded.toggle() + } + } + } label: { + summary + } + .buttonStyle(.plain) + .accessibilityLabel("\(title), \(statusText)") + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + .accessibilityHint(isExpanded ? "Collapses activity details" : "Shows activity details") + + if isExpanded { + Divider() + details + .transition(reduceMotion ? .identity : .opacity.combined(with: .move(edge: .top))) + } + } + .padding(14) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 14)) + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke(.separator.opacity(0.4), lineWidth: 0.5) + } + .accessibilityIdentifier("activity.entry.\(entry.id)") + } + + private var summary: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(iconColor) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(.headline) + .lineLimit(1) + Spacer(minLength: 8) + Text(entry.date, format: .dateTime.hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + Text(statusText) + if let itemName { + Text("•") + Text(itemName) + .lineLimit(1) + } + } + .font(.caption) + .foregroundStyle(.secondary) + + Text(summaryText) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(isExpanded ? nil : 1) + } + + Image(systemName: "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundStyle(.tertiary) + .rotationEffect(.degrees(isExpanded ? 180 : 0)) + .accessibilityHidden(true) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var details: some View { + switch entry { + case .conflict(let event): + ConflictActivityDetails(event: event) + case .activity(let event): + ProviderActivityDetails(event: event) + } + } + + private var title: String { + switch entry { + case .conflict(let event): + return event.conflictItemName + ?? event.originalItemName + ?? event.originalItemIdentifier + ?? "Unknown item" + case .activity(let event): + return event.outcome == .success + ? event.kind.displayName + : "Failed \(event.kind.displayName.lowercased())" + } + } + + private var statusText: String { + switch entry { + case .conflict(let event): + return event.resolutionState.displayName + case .activity(let event): + return event.kind.displayName + } + } + + private var itemName: String? { + switch entry { + case .conflict(let event): + return event.conflictItemName ?? event.originalItemName + case .activity(let event): + return event.itemName + } + } + + private var summaryText: String { + switch entry { + case .conflict(let event): + return event.resolutionSummary + case .activity(let event): + return event.summary + } + } + + private var systemImage: String { + switch entry { + case .conflict(let event): + return event.resolutionState.systemImage + case .activity(let event): + return event.outcome.systemImage(for: event.kind) + } + } + + private var iconColor: Color { + switch entry { + case .conflict(let event): + return event.resolutionState == .automaticallyResolved ? .green : .orange + case .activity(let event): + return event.outcome == .failure ? .orange : .accentColor + } + } +} + +private struct ConflictActivityDetails: View { + let event: KDriveConflictEvent + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ActivityMetadataFlow { + Label(event.operation.displayName, systemImage: event.operation.systemImage) + Label(event.resolutionState.displayName, systemImage: event.resolutionState.systemImage) + if event.automaticallyResolved { + Label("Automatic", systemImage: "bolt.fill") + } + } + + Text(event.resolutionSummary) + .font(.subheadline) + + if let resolvedAt = event.resolvedAt { + LabeledContent("Resolved", value: resolvedAt.providerActivityCopyFormatted) + } + LabeledContent("Detected", value: event.detectedAt.providerActivityCopyFormatted) + + if let stagedUploadRelativePath = event.stagedUploadRelativePath { + LabeledContent("Staged upload", value: stagedUploadRelativePath) + } + + ProviderItemAction( + domainIdentifier: event.domainIdentifier, + itemIdentifier: event.conflictItemIdentifier ?? event.originalItemIdentifier, + title: event.conflictItemName ?? event.originalItemName ?? "Open item", + fallbackDetail: event.conflictItemPath ?? event.originalItemPath + ) + + CopyActivityDetailsButton(text: copyText) + } + .font(.caption) + } + + private var copyText: String { + var lines = [ + "Conflict: \(event.conflictItemName ?? event.originalItemName ?? event.originalItemIdentifier ?? "Unknown item")", + "Detected: \(event.detectedAt.providerActivityCopyFormatted)", + "Operation: \(event.operation.displayName)", + "State: \(event.resolutionState.displayName)", + "Summary: \(event.resolutionSummary)", + ] + if let resolvedAt = event.resolvedAt { + lines.insert("Resolved: \(resolvedAt.providerActivityCopyFormatted)", at: 2) + } + if event.automaticallyResolved { + lines.append("Automatic: Yes") + } + if let path = event.stagedUploadRelativePath, path.isEmpty == false { + lines.append("Staged upload: \(path)") + } + if let path = event.conflictItemPath ?? event.originalItemPath, path.isEmpty == false { + lines.append("Item path: \(path)") + } + if let identifier = event.conflictItemIdentifier ?? event.originalItemIdentifier, + identifier.isEmpty == false { + lines.append("Item identifier: \(identifier)") + } + return lines.joined(separator: "\n") + } +} + +private struct ProviderActivityDetails: View { + let event: KDriveProviderActivityEvent + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ActivityMetadataFlow { + Label(event.kind.displayName, systemImage: event.kind.systemImage) + if let errorCategory = event.errorCategory { + Label(errorCategory.displayName, systemImage: "tag") + } + if let diagnosticCode { + Label(diagnosticCode, systemImage: "number") + } + } + + Text(event.summary) + .font(.subheadline) + + if let recoverySuggestion = event.recoverySuggestion, recoverySuggestion.isEmpty == false { + Label(recoverySuggestion, systemImage: "wrench.and.screwdriver") + .foregroundStyle(.secondary) + } + if let diagnosticSummary = event.diagnosticSummary, diagnosticSummary.isEmpty == false { + Text(diagnosticSummary) + .foregroundStyle(.secondary) + } + if let durationMilliseconds = event.durationMilliseconds { + LabeledContent("Duration", value: "\(durationMilliseconds) ms") + } + if let httpStatusCode = event.httpStatusCode { + LabeledContent("HTTP status", value: "\(httpStatusCode)") + } + + if event.scope == .domain { + ProviderItemAction( + domainIdentifier: event.domainIdentifier, + itemIdentifier: event.itemIdentifier, + title: event.itemName ?? event.itemIdentifier ?? "Open item", + fallbackDetail: event.itemPath + ) + } else { + Label("App activity", systemImage: "app") + .foregroundStyle(.secondary) + } + + CopyActivityDetailsButton(text: copyText) + } + .font(.caption) + } + + private var diagnosticCode: String? { + if let providerErrorCode = event.providerErrorCode { + return "Provider \(providerErrorCode)" + } + if let domain = event.underlyingErrorDomain, let code = event.underlyingErrorCode { + return "\(domain) \(code)" + } + return nil + } + + private var copyText: String { + var lines = [ + "Activity: \(event.outcome == .success ? event.kind.displayName : "Failed \(event.kind.displayName.lowercased())")", + "Occurred: \(event.occurredAt.providerActivityCopyFormatted)", + "Outcome: \(event.outcome.copyDisplayName)", + "Outcome key: \(event.outcome.rawValue)", + "Kind: \(event.kind.displayName)", + "Kind key: \(event.kind.rawValue)", + "Summary: \(event.summary)", + ] + + if let correlationID = event.correlationID, correlationID.isEmpty == false { + lines.append("Correlation ID: \(correlationID)") + } + if let durationMilliseconds = event.durationMilliseconds { + lines.append("Duration: \(durationMilliseconds) ms") + } + if let networkOperation = event.networkOperation, networkOperation.isEmpty == false { + lines.append("Network operation: \(networkOperation)") + } + if let httpStatusCode = event.httpStatusCode { + lines.append("HTTP status: \(httpStatusCode)") + } + if event.outcome == .failure { + lines.append("Severity: \(event.severity.copyDisplayName)") + lines.append("Severity key: \(event.severity.rawValue)") + if let errorCategory = event.errorCategory { + lines.append("Error category: \(errorCategory.displayName)") + lines.append("Error category key: \(errorCategory.rawValue)") + } + if let providerErrorCode = event.providerErrorCode { + lines.append("Provider error code: \(providerErrorCode)") + } + if let domain = event.underlyingErrorDomain, domain.isEmpty == false { + lines.append("Underlying error domain: \(domain)") + } + if let code = event.underlyingErrorCode { + lines.append("Underlying error code: \(code)") + } + if let suggestion = event.recoverySuggestion, suggestion.isEmpty == false { + lines.append("Recovery suggestion: \(suggestion)") + } + if let summary = event.diagnosticSummary, summary.isEmpty == false { + lines.append("Diagnostic summary: \(summary)") + } + if let relatedConflictID = event.relatedConflictID { + lines.append("Related conflict ID: \(relatedConflictID.uuidString)") + } + lines.append("Event ID: \(event.id.uuidString)") + lines.append("Domain identifier: \(event.domainIdentifier)") + lines.append("Drive ID: \(event.driveID)") + } + if event.scope == .domain { + lines.append("Scope: Domain") + if let itemName = event.itemName, itemName.isEmpty == false { + lines.append("Item: \(itemName)") + } + if let itemPath = event.itemPath, itemPath.isEmpty == false { + lines.append("Item path: \(itemPath)") + } + if let itemIdentifier = event.itemIdentifier, itemIdentifier.isEmpty == false { + lines.append("Item identifier: \(itemIdentifier)") + } + } else { + lines.append("Scope: App") + } + return lines.joined(separator: "\n") + } +} + +private struct ProviderItemAction: View { + @Environment(\.openURL) private var openURL + let domainIdentifier: String + let itemIdentifier: String? + let title: String + let fallbackDetail: String? + @State private var isResolving = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + if let itemIdentifier { + Button { + Task { await resolveAndOpen(itemIdentifier: itemIdentifier) } + } label: { + if isResolving { + Label("Opening in Files…", systemImage: "arrow.up.forward.app") + } else { + Label("Open in Files", systemImage: "arrow.up.forward.app") + } + } + .disabled(isResolving) + .accessibilityLabel("Open \(title) in Files") + .accessibilityIdentifier("activity.openInFiles") + + if let fallbackDetail, fallbackDetail.isEmpty == false { + Text(fallbackDetail) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + } + } + + private func resolveAndOpen(itemIdentifier: String) async { + guard isResolving == false else { return } + isResolving = true + errorMessage = nil + defer { isResolving = false } + + let domain = NSFileProviderDomain( + identifier: NSFileProviderDomainIdentifier(rawValue: domainIdentifier), + displayName: domainIdentifier + ) + guard let manager = NSFileProviderManager(for: domain) else { + errorMessage = "This File Provider domain is unavailable." + return + } + + let resolvedURL: URL? = await withCheckedContinuation { continuation in + manager.getUserVisibleURL(for: NSFileProviderItemIdentifier(itemIdentifier)) { url, _ in + continuation.resume(returning: url) + } + } + guard let resolvedURL else { + errorMessage = "This item is not currently available in Files." + return + } + openURL(resolvedURL) + } +} + +private struct CopyActivityDetailsButton: View { + let text: String + @State private var didCopy = false + + var body: some View { + Button { + ProviderActivityClipboard.copy(text) + didCopy = true + } label: { + Label(didCopy ? "Copied" : "Copy Details", systemImage: didCopy ? "checkmark" : "doc.on.doc") + } + .accessibilityIdentifier("activity.copyDetails") + } +} + +private struct ActivityMetadataFlow: View { + @ViewBuilder let content: Content + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + content + } + VStack(alignment: .leading, spacing: 6) { + content + } + } + .foregroundStyle(.secondary) + } +} + +private enum ProviderActivityClipboard { + static func copy(_ text: String) { + #if os(macOS) + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + #elseif canImport(UIKit) + UIPasteboard.general.string = text + #endif + } +} + +extension Date { + var providerActivityCopyFormatted: String { + formatted(.dateTime.year().month().day().hour().minute().second()) + } +} + +extension KDriveProviderActivityKind { + var displayName: String { + switch self { + case .enumeration: "Enumeration" + case .changeSync: "Change Sync" + case .syncAnchor: "Sync Anchor" + case .fetchContents: "Fetch" + case .metadataLookup: "Metadata Lookup" + case .create: "Create" + case .modify: "Modify" + case .trash: "Trash" + case .delete: "Delete" + case .conflict: "Conflict" + case .thumbnail: "Thumbnail" + case .runtimeLoading: "Runtime Loading" + case .authentication: "Authentication" + case .driveDiscovery: "Drive Discovery" + case .domainManagement: "Domain Management" + case .favorite: "Favorite" + case .duplicate: "Duplicate" + case .restore: "Restore" + case .shareLink: "Share Link" + case .versionRestore: "Version Restore" + } + } + + var systemImage: String { + switch self { + case .enumeration: "list.bullet.rectangle" + case .changeSync: "arrow.triangle.2.circlepath" + case .syncAnchor: "link" + case .fetchContents: "arrow.down.doc" + case .metadataLookup: "doc.text.magnifyingglass" + case .create: "plus" + case .modify: "pencil" + case .trash: "trash" + case .delete: "xmark.bin" + case .conflict: "exclamationmark.triangle" + case .thumbnail: "photo" + case .runtimeLoading: "gearshape" + case .authentication: "person.crop.circle.badge.exclamationmark" + case .driveDiscovery: "externaldrive.badge.questionmark" + case .domainManagement: "folder.badge.gearshape" + case .favorite: "star" + case .duplicate: "plus.square.on.square" + case .restore: "arrow.uturn.backward" + case .shareLink: "link" + case .versionRestore: "clock.arrow.trianglehead.counterclockwise.rotate.90" + } + } +} + +extension KDriveProviderActivityOutcome { + func systemImage(for kind: KDriveProviderActivityKind) -> String { + self == .success ? kind.systemImage : "exclamationmark.triangle" + } + + var copyDisplayName: String { + self == .success ? "Success" : "Failure" + } +} + +extension KDriveProviderActivitySeverity { + var copyDisplayName: String { + switch self { + case .info: "Info" + case .warning: "Warning" + case .error: "Error" + } + } +} + +extension KDriveProviderActivityErrorCategory { + var displayName: String { + switch self { + case .authentication: "Authentication" + case .network: "Network" + case .api: "API" + case .fileProvider: "File Provider" + case .listing: "Listing" + case .snapshot: "Snapshot" + case .storage: "Storage" + case .validation: "Validation" + case .mutationConflict: "Conflict" + case .unknown: "Unknown" + } + } +} + +extension KDriveConflictResolutionState { + var displayName: String { + switch self { + case .unresolved: "Unresolved" + case .automaticallyResolved: "Resolved" + case .blockedRetryable: "Blocked" + case .failed: "Failed" + } + } + + var systemImage: String { + switch self { + case .unresolved: "questionmark.circle" + case .automaticallyResolved: "checkmark.circle" + case .blockedRetryable: "pause.circle" + case .failed: "xmark.circle" + } + } +} diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index f9f7954..d782616 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -8,11 +8,15 @@ enum ProviderUITestFixture { static func makeModel() -> PotassiumProviderAppModel? { guard let fixtureName = ProcessInfo.processInfo.environment[environmentKey], - ["setup-navigation", "setup-error-banner"].contains(fixtureName) + ["setup-navigation", "setup-error-banner", "activities-pagination"].contains(fixtureName) else { return nil } + if fixtureName == "activities-pagination" { + return makeActivitiesModel() + } + let account = ProviderAccount( accountIdentifier: "ui-account", displayName: "Design Team", @@ -66,6 +70,36 @@ enum ProviderUITestFixture { } return model } + + private static func makeActivitiesModel() -> PotassiumProviderAppModel { + let activity = (0..<1_000).map { index in + KDriveProviderActivityEvent( + id: deterministicUUID(index), + occurredAt: Date(timeIntervalSince1970: Double(2_000_000 - index)), + domainIdentifier: "ui-domain", + driveID: 10, + kind: .enumeration, + outcome: .failure, + severity: .error, + itemIdentifier: "\(index)", + itemName: String(format: "Item %04d", index), + itemPath: String(format: "/Item %04d", index), + summary: String(format: "Deterministic activity %04d.", index), + diagnostic: KDriveProviderActivityErrorDiagnostic( + errorCategory: .fileProvider, + recoverySuggestion: "Retry after the provider becomes available." + ) + ) + } + return PotassiumProviderAppModel( + eventStore: ProviderUITestActivityStore(activity: activity), + automaticallyReloadStoredState: false + ) + } + + private static func deterministicUUID(_ index: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", index + 1))! + } } @MainActor @@ -87,4 +121,74 @@ private struct ProviderUITestDomainRegistrar: ProviderDomainRegistering { func addDomain(for configuration: ProviderDomainConfiguration) async throws {} func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} } + +private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging { + private var activity: [KDriveProviderActivityEvent] + + init(activity: [KDriveProviderActivityEvent]) { + self.activity = activity + } + + func saveConflict(_: KDriveConflictEvent) {} + + func recordActivity(_ event: KDriveProviderActivityEvent) { + activity.removeAll { $0.id == event.id } + activity.append(event) + } + + func recentConflicts(domainIdentifier _: String?, limit _: Int) -> [KDriveConflictEvent] { + [] + } + + func recentActivity(domainIdentifier _: String?, limit: Int) -> [KDriveProviderActivityEvent] { + Array(activity.prefix(limit)) + } + + func recentActivity( + domainIdentifier _: String?, + outcome: KDriveProviderActivityOutcome?, + limit: Int + ) -> [KDriveProviderActivityEvent] { + Array(activity.filter { outcome == nil || $0.outcome == outcome }.prefix(limit)) + } + + func removeActivityAndResolvedConflicts(domainIdentifier _: String?) { + activity.removeAll() + } + + func removeEvents(domainIdentifier: String) { + activity.removeAll { $0.domainIdentifier == domainIdentifier } + } + + func timelinePage( + filter: KDriveProviderTimelineFilter, + before cursor: KDriveProviderTimelineCursor?, + limit: Int + ) -> KDriveProviderTimelinePage { + let entries = activity + .filter { filter == .allActivity || $0.outcome == .failure } + .map(KDriveProviderTimelineEntry.activity) + .filter { entry in + guard let cursor else { return true } + return entry.cursor.date < cursor.date + || ( + entry.cursor.date == cursor.date + && entry.cursor.eventID.uuidString < cursor.eventID.uuidString + ) + } + .sorted { + if $0.cursor.date != $1.cursor.date { + return $0.cursor.date > $1.cursor.date + } + return $0.cursor.eventID.uuidString > $1.cursor.eventID.uuidString + } + let pageEntries = Array(entries.prefix(limit)) + let hasMore = entries.count > pageEntries.count + return KDriveProviderTimelinePage( + entries: pageEntries, + nextCursor: hasMore ? pageEntries.last?.cursor : nil, + hasMore: hasMore + ) + } +} #endif diff --git a/potassiumProviderTests/ActivityTimelinePagingTests.swift b/potassiumProviderTests/ActivityTimelinePagingTests.swift new file mode 100644 index 0000000..1cc1561 --- /dev/null +++ b/potassiumProviderTests/ActivityTimelinePagingTests.swift @@ -0,0 +1,437 @@ +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@Suite(.serialized) +struct ActivityTimelinePagingTests { + @Test func sqliteTimelinePagesMixedEventsWithoutDuplicatesOrGaps() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try KDriveProviderEventSQLiteStore( + databaseURL: directory.appendingPathComponent("Snapshots.sqlite3") + ) + + var expectedEntries: [KDriveProviderTimelineEntry] = [] + for index in 0..<7 { + let event = makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: Double(700 - index * 100)), + outcome: index.isMultiple(of: 2) ? .failure : .success + ) + try await store.recordActivity(event) + expectedEntries.append(.activity(event)) + } + for index in 0..<5 { + let event = makeConflict( + id: UUID(), + detectedAt: Date(timeIntervalSince1970: Double(650 - index * 100)) + ) + try await store.saveConflict(event) + expectedEntries.append(.conflict(event)) + } + let relatedActivity = makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: 1_000), + outcome: .failure, + relatedConflictID: UUID() + ) + try await store.recordActivity(relatedActivity) + + expectedEntries.sort(by: isNewer) + var cursor: KDriveProviderTimelineCursor? + var actualEntries: [KDriveProviderTimelineEntry] = [] + repeat { + let page = try await store.timelinePage( + filter: .allActivity, + before: cursor, + limit: 3 + ) + actualEntries.append(contentsOf: page.entries) + cursor = page.nextCursor + if page.hasMore == false { + break + } + } while true + + #expect(actualEntries.map(\.id) == expectedEntries.map(\.id)) + #expect(Set(actualEntries.map(\.id)).count == actualEntries.count) + #expect(actualEntries.contains { $0.id == relatedActivity.timelineID } == false) + } + + @Test func sqliteTimelineUsesEffectiveConflictDateAndStableEqualTimestampOrdering() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try KDriveProviderEventSQLiteStore( + databaseURL: directory.appendingPathComponent("Snapshots.sqlite3") + ) + let sharedDate = Date(timeIntervalSince1970: 500) + var resolvedConflict = makeConflict( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + detectedAt: Date(timeIntervalSince1970: 100) + ) + resolvedConflict.resolvedAt = sharedDate + let sameDateActivity = makeActivity( + id: UUID(uuidString: "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")!, + date: sharedDate, + outcome: .failure + ) + let olderConflict = makeConflict( + id: UUID(), + detectedAt: Date(timeIntervalSince1970: 400) + ) + + try await store.saveConflict(resolvedConflict) + try await store.recordActivity(sameDateActivity) + try await store.saveConflict(olderConflict) + + let firstPage = try await store.timelinePage( + filter: .errorsAndConflicts, + before: nil, + limit: 2 + ) + let secondPage = try await store.timelinePage( + filter: .errorsAndConflicts, + before: firstPage.nextCursor, + limit: 2 + ) + + #expect(firstPage.entries.map(\.id) == [ + resolvedConflict.timelineID, + sameDateActivity.timelineID, + ]) + #expect(firstPage.hasMore) + #expect(secondPage.entries.map(\.id) == [olderConflict.timelineID]) + #expect(secondPage.hasMore == false) + } + + @Test func sqliteTimelineFiltersSuccessesAndRelatedConflictActivityAtQueryTime() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try KDriveProviderEventSQLiteStore( + databaseURL: directory.appendingPathComponent("Snapshots.sqlite3") + ) + let success = makeActivity(id: UUID(), date: Date(timeIntervalSince1970: 300), outcome: .success) + let failure = makeActivity(id: UUID(), date: Date(timeIntervalSince1970: 200), outcome: .failure) + let relatedFailure = makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: 400), + outcome: .failure, + relatedConflictID: UUID() + ) + try await store.recordActivity(success) + try await store.recordActivity(failure) + try await store.recordActivity(relatedFailure) + + let errors = try await store.timelinePage( + filter: .errorsAndConflicts, + before: nil, + limit: 50 + ) + let all = try await store.timelinePage(filter: .allActivity, before: nil, limit: 50) + + #expect(errors.entries.map(\.id) == [failure.timelineID]) + #expect(all.entries.map(\.id) == [success.timelineID, failure.timelineID]) + } + + @Test func keysetCursorKeepsOlderPageStableWhenNewEventArrives() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let store = try KDriveProviderEventSQLiteStore( + databaseURL: directory.appendingPathComponent("Snapshots.sqlite3") + ) + for timestamp in [500.0, 400, 300, 200] { + try await store.recordActivity(makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: timestamp), + outcome: .success + )) + } + + let firstPage = try await store.timelinePage(filter: .allActivity, before: nil, limit: 2) + let inserted = makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: 450), + outcome: .success + ) + try await store.recordActivity(inserted) + let olderPage = try await store.timelinePage( + filter: .allActivity, + before: firstPage.nextCursor, + limit: 2 + ) + let refreshedPage = try await store.timelinePage(filter: .allActivity, before: nil, limit: 3) + + #expect(Set(firstPage.entries.map(\.id)).isDisjoint(with: olderPage.entries.map(\.id))) + #expect(olderPage.entries.contains { $0.id == inserted.timelineID } == false) + #expect(refreshedPage.entries.contains { $0.id == inserted.timelineID }) + } + + @MainActor + @Test func viewModelPrefetchesOnceNearTheEndAndAppendsInPlace() async throws { + let activity = (0..<120).map { index in + makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: Double(1_000 - index)), + outcome: .failure + ) + } + let store = PagingEventStore(activity: activity) + let model = ConflictLogViewModel(eventStore: store) + + await model.load() + #expect(model.entries.count == 50) + #expect(model.hasMore) + + let firstTriggerID = model.entries[40].id + await model.loadMoreIfNeeded(visibleEntryIDs: [firstTriggerID]) + await model.loadMoreIfNeeded(visibleEntryIDs: [firstTriggerID]) + + #expect(model.entries.count == 100) + #expect(await store.pageRequestCount() == 2) + + await model.loadMoreIfNeeded(visibleEntryIDs: [model.entries[90].id]) + #expect(model.entries.count == 120) + #expect(model.hasMore == false) + #expect(await store.pageRequestCount() == 3) + } + + @MainActor + @Test func viewModelKeepsLoadedEntriesAndCleansProgressAfterPagingFailure() async throws { + let activity = (0..<80).map { index in + makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: Double(1_000 - index)), + outcome: .failure + ) + } + let store = PagingEventStore(activity: activity) + let model = ConflictLogViewModel(eventStore: store) + await model.load() + await store.failNextOlderPage() + + await model.loadMoreIfNeeded(visibleEntryIDs: [model.entries[40].id]) + + #expect(model.entries.count == 50) + #expect(model.isLoadingMore == false) + #expect(model.paginationErrorMessage != nil) + + await model.loadMore() + #expect(model.entries.count == 80) + #expect(model.paginationErrorMessage == nil) + #expect(model.isLoadingMore == false) + } + + @MainActor + @Test func viewModelRefreshMergesNewEntriesWithoutDiscardingLoadedHistory() async throws { + let activity = (0..<70).map { index in + makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: Double(1_000 - index)), + outcome: .failure + ) + } + let store = PagingEventStore(activity: activity) + let model = ConflictLogViewModel(eventStore: store) + await model.load() + await model.loadMore() + let loadedIDs = Set(model.entries.map(\.id)) + let newEvent = makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: 2_000), + outcome: .failure + ) + await store.recordActivity(newEvent) + + await model.refresh() + + #expect(model.entries.first?.id == newEvent.timelineID) + #expect(loadedIDs.isSubset(of: Set(model.entries.map(\.id)))) + #expect(Set(model.entries.map(\.id)).count == model.entries.count) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("activity-timeline-tests-\(UUID().uuidString)", isDirectory: true) + } + + private func makeActivity( + id: UUID, + date: Date, + outcome: KDriveProviderActivityOutcome, + relatedConflictID: UUID? = nil + ) -> KDriveProviderActivityEvent { + KDriveProviderActivityEvent( + id: id, + occurredAt: date, + domainIdentifier: "domain-1", + driveID: 1, + kind: .enumeration, + outcome: outcome, + severity: outcome == .failure ? .error : .info, + itemIdentifier: nil, + itemName: nil, + itemPath: nil, + summary: "Activity \(id.uuidString)", + relatedConflictID: relatedConflictID + ) + } + + private func makeConflict(id: UUID, detectedAt: Date) -> KDriveConflictEvent { + KDriveConflictEvent( + id: id, + detectedAt: detectedAt, + domainIdentifier: "domain-1", + driveID: 1, + operation: .modify, + originalItemIdentifier: "item-\(id.uuidString)", + originalItemName: "Report.txt", + originalItemPath: "/Report.txt", + resolutionState: .blockedRetryable, + automaticallyResolved: false, + resolutionKind: .blockedBeforeServerMutation, + resolutionSummary: "The operation is blocked." + ) + } + + private func isNewer( + _ lhs: KDriveProviderTimelineEntry, + than rhs: KDriveProviderTimelineEntry + ) -> Bool { + if lhs.cursor.date != rhs.cursor.date { + return lhs.cursor.date > rhs.cursor.date + } + if lhs.cursor.kind != rhs.cursor.kind { + return lhs.cursor.kind.rawValue > rhs.cursor.kind.rawValue + } + return lhs.cursor.eventID.uuidString > rhs.cursor.eventID.uuidString + } +} + +private actor PagingEventStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging { + private var conflicts: [KDriveConflictEvent] = [] + private var activity: [KDriveProviderActivityEvent] + private var requestCount = 0 + private var shouldFailNextOlderPage = false + + init(activity: [KDriveProviderActivityEvent]) { + self.activity = activity + } + + func saveConflict(_ event: KDriveConflictEvent) { + conflicts.removeAll { $0.id == event.id } + conflicts.append(event) + } + + func recordActivity(_ event: KDriveProviderActivityEvent) { + activity.removeAll { $0.id == event.id } + activity.append(event) + } + + func recentConflicts(domainIdentifier: String?, limit: Int) -> [KDriveConflictEvent] { + Array(conflicts.prefix(limit)) + } + + func recentActivity(domainIdentifier: String?, limit: Int) -> [KDriveProviderActivityEvent] { + Array(activity.prefix(limit)) + } + + func recentActivity( + domainIdentifier: String?, + outcome: KDriveProviderActivityOutcome?, + limit: Int + ) -> [KDriveProviderActivityEvent] { + Array(activity.filter { outcome == nil || $0.outcome == outcome }.prefix(limit)) + } + + func removeActivityAndResolvedConflicts(domainIdentifier: String?) { + activity.removeAll() + conflicts.removeAll { $0.resolutionState == .automaticallyResolved } + } + + func removeEvents(domainIdentifier: String) { + activity.removeAll { $0.domainIdentifier == domainIdentifier } + conflicts.removeAll { $0.domainIdentifier == domainIdentifier } + } + + func timelinePage( + filter: KDriveProviderTimelineFilter, + before cursor: KDriveProviderTimelineCursor?, + limit: Int + ) throws -> KDriveProviderTimelinePage { + requestCount += 1 + if cursor != nil, shouldFailNextOlderPage { + shouldFailNextOlderPage = false + throw TestPagingError.failed + } + + let sorted = ( + conflicts.map(KDriveProviderTimelineEntry.conflict) + + activity + .filter { $0.relatedConflictID == nil } + .filter { filter == .allActivity || $0.outcome == .failure } + .map(KDriveProviderTimelineEntry.activity) + ).sorted(by: Self.isNewer) + let eligible = sorted.filter { entry in + guard let cursor else { return true } + return Self.isOlder(entry.cursor, than: cursor) + } + let entries = Array(eligible.prefix(limit)) + let hasMore = eligible.count > entries.count + return KDriveProviderTimelinePage( + entries: entries, + nextCursor: hasMore ? entries.last?.cursor : nil, + hasMore: hasMore + ) + } + + func pageRequestCount() -> Int { + requestCount + } + + func failNextOlderPage() { + shouldFailNextOlderPage = true + } + + private static func isNewer( + _ lhs: KDriveProviderTimelineEntry, + than rhs: KDriveProviderTimelineEntry + ) -> Bool { + if lhs.cursor.date != rhs.cursor.date { + return lhs.cursor.date > rhs.cursor.date + } + if lhs.cursor.kind != rhs.cursor.kind { + return lhs.cursor.kind.rawValue > rhs.cursor.kind.rawValue + } + return lhs.cursor.eventID.uuidString > rhs.cursor.eventID.uuidString + } + + private static func isOlder( + _ lhs: KDriveProviderTimelineCursor, + than rhs: KDriveProviderTimelineCursor + ) -> Bool { + if lhs.date != rhs.date { + return lhs.date < rhs.date + } + if lhs.kind != rhs.kind { + return lhs.kind.rawValue < rhs.kind.rawValue + } + return lhs.eventID.uuidString < rhs.eventID.uuidString + } +} + +private enum TestPagingError: LocalizedError { + case failed + + var errorDescription: String? { + "The test page failed." + } +} + +private extension KDriveProviderActivityEvent { + var timelineID: String { "activity-\(id.uuidString)" } +} + +private extension KDriveConflictEvent { + var timelineID: String { "conflict-\(id.uuidString)" } +} diff --git a/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index 0e56a55..ff1e0c4 100644 --- a/potassiumProviderTests/potassiumProviderTests.swift +++ b/potassiumProviderTests/potassiumProviderTests.swift @@ -897,24 +897,20 @@ struct PotassiumProviderCoreTests { await model.load() - #expect(model.conflicts == [conflict]) - #expect(model.activity == [failureActivity]) - #expect(model.timelineItems.map(\.id) == [ + #expect(model.entries.map(\.id) == [ "activity-\(failureActivity.id.uuidString)", "conflict-\(conflict.id.uuidString)" ]) - #expect(await store.activityQueryCount() == 1) + #expect(await store.timelineQueryCount() == 1) - model.showsActivity = true - await model.load() + await model.setFilter(.allActivity) - #expect(model.activity == [successActivity, failureActivity]) - #expect(model.timelineItems.map(\.id) == [ + #expect(model.entries.map(\.id) == [ "activity-\(successActivity.id.uuidString)", "activity-\(failureActivity.id.uuidString)", "conflict-\(conflict.id.uuidString)" ]) - #expect(await store.activityQueryCount() == 2) + #expect(await store.timelineQueryCount() == 2) } @MainActor @@ -955,9 +951,7 @@ struct PotassiumProviderCoreTests { await model.clearActivity() - #expect(model.conflicts == [unresolvedConflict]) - #expect(model.activity.isEmpty) - #expect(model.timelineItems.map(\.id) == ["conflict-\(unresolvedConflict.id.uuidString)"]) + #expect(model.entries.map(\.id) == ["conflict-\(unresolvedConflict.id.uuidString)"]) #expect(await store.storedConflicts().map(\.id) == [unresolvedConflict.id]) #expect(await store.activities().isEmpty) } @@ -2714,10 +2708,11 @@ private struct FakeProviderEventStatisticsProvider: KDriveProviderEventStatistic } } -private actor FakeProviderEventStore: KDriveProviderEventStoring { +private actor FakeProviderEventStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging { private var conflicts: [KDriveConflictEvent] private var activity: [KDriveProviderActivityEvent] private var activityQueries = 0 + private var timelineQueries = 0 init(conflicts: [KDriveConflictEvent], activity: [KDriveProviderActivityEvent]) { self.conflicts = conflicts @@ -2770,10 +2765,40 @@ private actor FakeProviderEventStore: KDriveProviderEventStoring { } } + func timelinePage( + filter: KDriveProviderTimelineFilter, + before cursor: KDriveProviderTimelineCursor?, + limit: Int + ) throws -> KDriveProviderTimelinePage { + timelineQueries += 1 + let allEntries = ( + conflicts.map(KDriveProviderTimelineEntry.conflict) + + activity + .filter { $0.relatedConflictID == nil } + .filter { filter == .allActivity || $0.outcome == .failure } + .map(KDriveProviderTimelineEntry.activity) + ).sorted(by: isTimelineEntryNewer) + let eligibleEntries = allEntries.filter { entry in + guard let cursor else { return true } + return isTimelineCursor(entry.cursor, olderThan: cursor) + } + let pageEntries = Array(eligibleEntries.prefix(max(0, limit))) + let hasMore = eligibleEntries.count > pageEntries.count + return KDriveProviderTimelinePage( + entries: pageEntries, + nextCursor: hasMore ? pageEntries.last?.cursor : nil, + hasMore: hasMore + ) + } + func activityQueryCount() -> Int { activityQueries } + func timelineQueryCount() -> Int { + timelineQueries + } + func storedConflicts() -> [KDriveConflictEvent] { conflicts } @@ -2781,6 +2806,34 @@ private actor FakeProviderEventStore: KDriveProviderEventStoring { func activities() -> [KDriveProviderActivityEvent] { activity } + + private func isTimelineEntryNewer( + _ lhs: KDriveProviderTimelineEntry, + than rhs: KDriveProviderTimelineEntry + ) -> Bool { + let lhsCursor = lhs.cursor + let rhsCursor = rhs.cursor + if lhsCursor.date != rhsCursor.date { + return lhsCursor.date > rhsCursor.date + } + if lhsCursor.kind != rhsCursor.kind { + return lhsCursor.kind.rawValue > rhsCursor.kind.rawValue + } + return lhsCursor.eventID.uuidString > rhsCursor.eventID.uuidString + } + + private func isTimelineCursor( + _ candidate: KDriveProviderTimelineCursor, + olderThan cursor: KDriveProviderTimelineCursor + ) -> Bool { + if candidate.date != cursor.date { + return candidate.date < cursor.date + } + if candidate.kind != cursor.kind { + return candidate.kind.rawValue < cursor.kind.rawValue + } + return candidate.eventID.uuidString < cursor.eventID.uuidString + } } @MainActor diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index 39572c0..b48b9ae 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -94,6 +94,38 @@ final class potassiumProviderUITests: XCTestCase { XCTAssertTrue(banner.waitForNonExistence(timeout: 5)) } + @MainActor + func testActivitiesPrefetchesOlderRowsAndReturnsToLatest() throws { + let app = launchSetupFixture(named: "activities-pagination") + openActivities(in: app) + + XCTAssertTrue(app.staticTexts["Item 0000"].waitForExistence(timeout: 5)) + let timeline = app.scrollViews["activity.timeline"] + XCTAssertTrue(timeline.waitForExistence(timeout: 5)) + + for _ in 0..<14 where app.staticTexts["Item 0075"].exists == false { + timeline.swipeUp() + } + + XCTAssertTrue(app.staticTexts["Item 0075"].waitForExistence(timeout: 5)) + let backToLatest = app.buttons["activity.backToLatest"] + XCTAssertTrue(backToLatest.waitForExistence(timeout: 5)) + backToLatest.tap() + XCTAssertTrue(app.staticTexts["Item 0000"].waitForExistence(timeout: 5)) + } + + @MainActor + func testActivitiesScrollPerformance() throws { + let app = launchSetupFixture(named: "activities-pagination") + openActivities(in: app) + let timeline = app.scrollViews["activity.timeline"] + XCTAssertTrue(timeline.waitForExistence(timeout: 5)) + + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric]) { + timeline.swipeUp(velocity: .fast) + } + } + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. @@ -121,4 +153,16 @@ final class potassiumProviderUITests: XCTestCase { setupButton.tap() } } + + @MainActor + private func openActivities(in app: XCUIApplication) { + let tabBarButton = app.tabBars.buttons["Activities"] + if tabBarButton.waitForExistence(timeout: 2) { + tabBarButton.tap() + } else { + let activitiesButton = app.buttons["Activities"] + XCTAssertTrue(activitiesButton.waitForExistence(timeout: 5)) + activitiesButton.tap() + } + } } From 03afd22620c9028a868f410fb06cc7f979d5704e Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 21:17:13 +0200 Subject: [PATCH 5/9] fix: allow activities support log export --- doc/LOGGING.md | 3 +++ potassiumProvider.xcodeproj/project.pbxproj | 4 ++-- potassiumProvider/ProviderUITestFixture.swift | 6 +++++- .../MacAppPresenceTests.swift | 15 +++++++++++++++ .../potassiumProviderUITests.swift | 19 +++++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/doc/LOGGING.md b/doc/LOGGING.md index c01c57c..2e8ba9f 100644 --- a/doc/LOGGING.md +++ b/doc/LOGGING.md @@ -58,6 +58,9 @@ and request identifiers. It omits names, paths, staged-upload paths, and raw conflict identifiers. Summaries and diagnostic text are scrubbed of known item values, URLs, and paths before export. +On macOS, the containing app enables sandboxed user-selected file read/write +access so the save panel can create the support log at the chosen destination. + The document includes event timestamps, operation kinds, outcomes, severities, sanitized summaries, numeric error codes, duration, network operation, HTTP status, and conflict-resolution state. It is not an export of the Apple unified diff --git a/potassiumProvider.xcodeproj/project.pbxproj b/potassiumProvider.xcodeproj/project.pbxproj index 6dd2f6f..1bf7d17 100644 --- a/potassiumProvider.xcodeproj/project.pbxproj +++ b/potassiumProvider.xcodeproj/project.pbxproj @@ -723,7 +723,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Config/potassiumProviderInfo.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -771,7 +771,7 @@ ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; - ENABLE_USER_SELECTED_FILES = readonly; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Config/potassiumProviderInfo.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index d782616..ce96083 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -122,7 +122,7 @@ private struct ProviderUITestDomainRegistrar: ProviderDomainRegistering { func removeDomain(for configuration: ProviderDomainConfiguration) async throws {} } -private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging { +private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging, KDriveProviderEventExporting { private var activity: [KDriveProviderActivityEvent] init(activity: [KDriveProviderActivityEvent]) { @@ -160,6 +160,10 @@ private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDrivePro activity.removeAll { $0.domainIdentifier == domainIdentifier } } + func supportLogData(domainIdentifier _: String?) -> Data { + Data(#"{"formatVersion":1,"events":[]}"#.utf8) + } + func timelinePage( filter: KDriveProviderTimelineFilter, before cursor: KDriveProviderTimelineCursor?, diff --git a/potassiumProviderTests/MacAppPresenceTests.swift b/potassiumProviderTests/MacAppPresenceTests.swift index 12d9d50..d7372b5 100644 --- a/potassiumProviderTests/MacAppPresenceTests.swift +++ b/potassiumProviderTests/MacAppPresenceTests.swift @@ -81,5 +81,20 @@ struct MacAppPresenceTests { #expect(plist["LSUIElement"] as? Bool == true) } + + @Test func macAppAllowsWritingUserSelectedSupportLogDestinations() throws { + let sourceFile = URL(fileURLWithPath: #filePath) + let projectDirectory = sourceFile + .deletingLastPathComponent() + .deletingLastPathComponent() + let projectFileURL = projectDirectory + .appendingPathComponent("potassiumProvider.xcodeproj", isDirectory: true) + .appendingPathComponent("project.pbxproj") + let projectFile = try String(contentsOf: projectFileURL, encoding: .utf8) + let readWriteSetting = "ENABLE_USER_SELECTED_FILES = readwrite;" + + #expect(projectFile.components(separatedBy: readWriteSetting).count - 1 == 2) + #expect(projectFile.contains("ENABLE_USER_SELECTED_FILES = readonly;") == false) + } } #endif diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index b48b9ae..ed76fcf 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -126,6 +126,25 @@ final class potassiumProviderUITests: XCTestCase { } } + #if os(macOS) + @MainActor + func testActivitiesExportPresentsSavePanel() throws { + let app = launchSetupFixture(named: "activities-pagination") + openActivities(in: app) + + let export = app.buttons["Export"] + XCTAssertTrue(export.waitForExistence(timeout: 5)) + XCTAssertTrue(export.isEnabled) + export.tap() + + let savePanel = app.sheets.firstMatch + XCTAssertTrue(savePanel.waitForExistence(timeout: 5)) + let cancel = savePanel.buttons["Cancel"] + XCTAssertTrue(cancel.waitForExistence(timeout: 2)) + cancel.tap() + } + #endif + @MainActor func testLaunchPerformance() throws { // This measures how long it takes to launch your application. From 446c8ede4bd14332612ffc3ac890b6ae4be00e1e Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 21:25:42 +0200 Subject: [PATCH 6/9] feat: animate tab transitions --- doc/APP_AND_DOMAINS.md | 4 +++- potassiumProvider/ContentView.swift | 3 +++ potassiumProvider/ProviderSetupView.swift | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/doc/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index a3fffc3..2642a77 100644 --- a/doc/APP_AND_DOMAINS.md +++ b/doc/APP_AND_DOMAINS.md @@ -32,7 +32,9 @@ close option. Closing the setup window does not quit the app. The main window has three tabs: Status, Setup, and Activities. Status is the default tab and is a read-only dashboard. Account and drive actions live under -Setup rather than on the dashboard. +Setup rather than on the dashboard. Switching tabs uses the same short +slide-and-fade motion as Setup navigation; Reduce Motion removes the horizontal +movement and keeps a brief fade. Setup uses a three-level navigation hierarchy: diff --git a/potassiumProvider/ContentView.swift b/potassiumProvider/ContentView.swift index 817d452..703832c 100644 --- a/potassiumProvider/ContentView.swift +++ b/potassiumProvider/ContentView.swift @@ -17,18 +17,21 @@ struct ContentView: View { ProviderStatusView(appModel: model) { selectedTab = .setup } + .providerNavigationAnimation(animatesInitialAppearance: false) .tabItem { Label("Status", systemImage: "gauge.medium") } .tag(ProviderAppTab.status) ProviderSetupView(model: model) + .providerNavigationAnimation() .tabItem { Label("Setup", systemImage: "externaldrive.connected.to.line.below") } .tag(ProviderAppTab.setup) ConflictLogView(eventStore: model.providerEventStore) + .providerNavigationAnimation() .tabItem { Label("Activities", systemImage: "clock.arrow.circlepath") } diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index 10ac7f2..dad43d0 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -878,13 +878,15 @@ private extension ProviderAccountAuthenticationKind { } } -private extension View { +extension View { func providerNavigationAnimation(animatesInitialAppearance: Bool = true) -> some View { modifier(ProviderNavigationAnimationModifier( animatesInitialAppearance: animatesInitialAppearance )) } +} +private extension View { @ViewBuilder func platformPasswordEntry() -> some View { #if canImport(UIKit) From 06a637db62c7cf4aba93d9ad6180068271c8dd0b Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 21:44:32 +0200 Subject: [PATCH 7/9] fix: reveal activity items in Finder --- doc/CONFLICTS.md | 6 +- .../ProviderActivityTimelineRow.swift | 65 +++++++++++++++++-- .../ActivityTimelinePagingTests.swift | 38 +++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/doc/CONFLICTS.md b/doc/CONFLICTS.md index 1981b10..41c1638 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -206,7 +206,11 @@ The app has an Activities tab backed by `Snapshots.sqlite3`. and item actions without making every row expensive to render. - File links are resolved from stored domain and item identifiers through `NSFileProviderManager.getUserVisibleURL(for:)` only after the user selects - **Open in Files**. Scrolling the timeline does not perform File Provider URL + **Open in Finder** on macOS or **Open in Files** on iOS and visionOS. + macOS then asks `NSWorkspace` to reveal and select the item in Finder rather + than opening the document in its default app. Resolution or Finder-selection + failures show an inline message explaining that the item may have moved or + been deleted. Scrolling the timeline does not perform File Provider URL lookups. - The default Errors filter shows conflicts and non-conflict failure activity. All Activity also includes successful enumeration, change sync, and item diff --git a/potassiumProvider/ProviderActivityTimelineRow.swift b/potassiumProvider/ProviderActivityTimelineRow.swift index 0d7717f..dc2be64 100644 --- a/potassiumProvider/ProviderActivityTimelineRow.swift +++ b/potassiumProvider/ProviderActivityTimelineRow.swift @@ -355,7 +355,9 @@ private struct ProviderActivityDetails: View { } private struct ProviderItemAction: View { + #if !os(macOS) @Environment(\.openURL) private var openURL + #endif let domainIdentifier: String let itemIdentifier: String? let title: String @@ -370,13 +372,21 @@ private struct ProviderItemAction: View { Task { await resolveAndOpen(itemIdentifier: itemIdentifier) } } label: { if isResolving { - Label("Opening in Files…", systemImage: "arrow.up.forward.app") + Label( + ProviderFileBrowserPresentation.openingActionTitle, + systemImage: "arrow.up.forward.app" + ) } else { - Label("Open in Files", systemImage: "arrow.up.forward.app") + Label( + ProviderFileBrowserPresentation.openActionTitle, + systemImage: "arrow.up.forward.app" + ) } } .disabled(isResolving) - .accessibilityLabel("Open \(title) in Files") + .accessibilityLabel( + ProviderFileBrowserPresentation.accessibilityLabel(for: title) + ) .accessibilityIdentifier("activity.openInFiles") if let fallbackDetail, fallbackDetail.isEmpty == false { @@ -413,11 +423,58 @@ private struct ProviderItemAction: View { } } guard let resolvedURL else { - errorMessage = "This item is not currently available in Files." + errorMessage = ProviderFileBrowserPresentation.unavailableMessage + return + } + + #if os(macOS) + guard ProviderFileBrowserPresentation.revealInFinder(resolvedURL) else { + errorMessage = ProviderFileBrowserPresentation.unavailableMessage return } + #else openURL(resolvedURL) + #endif + } +} + +enum ProviderFileBrowserPresentation { + #if os(macOS) + static let applicationName = "Finder" + #else + static let applicationName = "Files" + #endif + + static var openActionTitle: String { + "Open in \(applicationName)" + } + + static var openingActionTitle: String { + "Opening in \(applicationName)…" + } + + static func accessibilityLabel(for itemTitle: String) -> String { + "Open \(itemTitle) in \(applicationName)" + } + + static var unavailableMessage: String { + #if os(macOS) + "This item could not be found in Finder. It may have been moved or deleted." + #else + "This item is not currently available in Files." + #endif + } + + #if os(macOS) + @MainActor + static func revealInFinder(_ url: URL) -> Bool { + guard url.isFileURL else { return false } + return NSWorkspace.shared.selectFile( + url.path, + inFileViewerRootedAtPath: "" + ) } + #endif } private struct CopyActivityDetailsButton: View { diff --git a/potassiumProviderTests/ActivityTimelinePagingTests.swift b/potassiumProviderTests/ActivityTimelinePagingTests.swift index 1cc1561..8d9e42c 100644 --- a/potassiumProviderTests/ActivityTimelinePagingTests.swift +++ b/potassiumProviderTests/ActivityTimelinePagingTests.swift @@ -5,6 +5,44 @@ import Testing @Suite(.serialized) struct ActivityTimelinePagingTests { + @Test func itemOpenActionUsesThePlatformFileBrowserName() { + #if os(macOS) + #expect(ProviderFileBrowserPresentation.applicationName == "Finder") + #expect(ProviderFileBrowserPresentation.openActionTitle == "Open in Finder") + #expect(ProviderFileBrowserPresentation.openingActionTitle == "Opening in Finder…") + #expect( + ProviderFileBrowserPresentation.accessibilityLabel(for: "Report") + == "Open Report in Finder" + ) + #expect( + ProviderFileBrowserPresentation.unavailableMessage + == "This item could not be found in Finder. It may have been moved or deleted." + ) + #else + #expect(ProviderFileBrowserPresentation.applicationName == "Files") + #expect(ProviderFileBrowserPresentation.openActionTitle == "Open in Files") + #expect(ProviderFileBrowserPresentation.openingActionTitle == "Opening in Files…") + #expect( + ProviderFileBrowserPresentation.accessibilityLabel(for: "Report") + == "Open Report in Files" + ) + #expect( + ProviderFileBrowserPresentation.unavailableMessage + == "This item is not currently available in Files." + ) + #endif + } + + #if os(macOS) + @MainActor + @Test func finderRevealReportsAMissingItem() { + let missingURL = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-provider-item-\(UUID().uuidString)") + + #expect(ProviderFileBrowserPresentation.revealInFinder(missingURL) == false) + } + #endif + @Test func sqliteTimelinePagesMixedEventsWithoutDuplicatesOrGaps() async throws { let directory = temporaryDirectory() defer { try? FileManager.default.removeItem(at: directory) } From ef42e3870c1a2898ec39743fe292ecdd0ee51e10 Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sat, 25 Jul 2026 23:12:36 +0200 Subject: [PATCH 8/9] fix: harden activities actions --- doc/CONFLICTS.md | 14 +- doc/LOGGING.md | 7 + potassiumProvider/ConflictLogView.swift | 47 ++- potassiumProvider/ConflictLogViewModel.swift | 44 ++- .../ProviderActivityTimelineRow.swift | 211 +++++++++++-- potassiumProvider/ProviderUITestFixture.swift | 83 ++++- .../ActivityTimelinePagingTests.swift | 292 ++++++++++++++++++ .../potassiumProviderUITests.swift | 66 ++++ 8 files changed, 698 insertions(+), 66 deletions(-) diff --git a/doc/CONFLICTS.md b/doc/CONFLICTS.md index 41c1638..272e565 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -210,8 +210,9 @@ The app has an Activities tab backed by `Snapshots.sqlite3`. macOS then asks `NSWorkspace` to reveal and select the item in Finder rather than opening the document in its default app. Resolution or Finder-selection failures show an inline message explaining that the item may have moved or - been deleted. Scrolling the timeline does not perform File Provider URL - lookups. + been deleted. A rejected Files presentation on iOS or visionOS also returns + the row to a retryable state and displays an inline unavailable-item message. + Scrolling the timeline does not perform File Provider URL lookups. - The default Errors filter shows conflicts and non-conflict failure activity. All Activity also includes successful enumeration, change sync, and item operations. @@ -219,9 +220,16 @@ The app has an Activities tab backed by `Snapshots.sqlite3`. older history. **Back to Latest** returns to the newest entry. - The Clear button removes activity event rows and automatically resolved conflict rows while preserving unresolved, blocked, and failed conflict rows. + Clear is exclusive with loading, refresh, and export; the reader returns to + the newest position only after the store confirms that clearing succeeded. - The Export button creates a redacted JSON support log. It pseudonymizes identifiers and omits item names, paths, staged-upload paths, and raw conflict - identifiers. + identifiers. Export and Refresh may run together because both are read-only. +- Action failures use a dismissible banner above every timeline state, + including empty, initial-error, and unavailable-database views. Paging errors + remain attached to the paging footer. Copy feedback changes to **Copied** + only after the platform clipboard accepts the write; a rejected write stays + retryable and displays an inline error. - Failure rows store sanitized diagnostics such as category, severity, mapped provider error code, underlying error domain/code, recovery suggestion, and a short diagnostic summary. They do not store tokens or raw response bodies. diff --git a/doc/LOGGING.md b/doc/LOGGING.md index 2e8ba9f..ea40386 100644 --- a/doc/LOGGING.md +++ b/doc/LOGGING.md @@ -50,6 +50,13 @@ only limits UI decoding and rendering; it does not reduce retention or support export coverage. Live notifications refresh and merge the newest page, and ordinary scrolling does not resolve File Provider item URLs. +Timeline actions expose model-backed availability and repeat those guards inside +the action methods. Destructive Clear cannot overlap loading, Refresh, or +Export, while the read-only Refresh and Export operations may overlap. Action +errors are displayed independently from initial-load and paging errors, and all +operation progress state is cleared after success, failure, cancellation, or a +superseded filter generation. + ## Support Export The Activities toolbar exports a JSON document via the system file picker. Each diff --git a/potassiumProvider/ConflictLogView.swift b/potassiumProvider/ConflictLogView.swift index a38c0ef..dbeb0f4 100644 --- a/potassiumProvider/ConflictLogView.swift +++ b/potassiumProvider/ConflictLogView.swift @@ -10,9 +10,22 @@ struct ConflictLogView: View { @State private var supportLogDocument: ProviderSupportLogDocument? @State private var scrollPositionID: String? @State private var visibleEntryIDs: [String] = [] + private let actionDependencies: ProviderActivityActionDependencies - init(eventStore: (any KDriveProviderEventStoring)?) { - _model = StateObject(wrappedValue: ConflictLogViewModel(eventStore: eventStore)) + init( + eventStore: (any KDriveProviderEventStoring)?, + actionDependencies: ProviderActivityActionDependencies? = nil + ) { + let viewModel = ConflictLogViewModel(eventStore: eventStore) + #if DEBUG + viewModel.actionErrorMessage = ProviderUITestFixture.initialActivityActionError() + self.actionDependencies = actionDependencies + ?? ProviderUITestFixture.activityActionDependencies() + ?? .live + #else + self.actionDependencies = actionDependencies ?? .live + #endif + _model = StateObject(wrappedValue: viewModel) } var body: some View { @@ -20,6 +33,14 @@ struct ConflictLogView: View { VStack(spacing: 0) { filterControl Divider() + if let actionErrorMessage = model.actionErrorMessage { + ActivityInlineError( + message: actionErrorMessage, + dismiss: model.dismissActionError + ) + .padding(.horizontal) + .padding(.vertical, 10) + } timelineContent } .navigationTitle("Activities") @@ -33,8 +54,9 @@ struct ConflictLogView: View { .confirmationDialog("Clear Activities?", isPresented: $isClearConfirmationPresented) { Button("Clear Events and Resolved Conflicts", role: .destructive) { Task { - await model.clearActivity() - moveToLatest(animated: false) + if await model.clearActivity() { + moveToLatest(animated: false) + } } } Button("Cancel", role: .cancel) {} @@ -69,6 +91,7 @@ struct ConflictLogView: View { .padding(.vertical, 10) .accessibilityLabel("Activity visibility") .accessibilityIdentifier("activity.filter") + .disabled(model.canChangeFilter == false) } private var filterBinding: Binding { @@ -120,19 +143,13 @@ struct ConflictLogView: View { ZStack(alignment: .bottomTrailing) { ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - if let actionErrorMessage = model.actionErrorMessage { - ActivityInlineError( - message: actionErrorMessage, - dismiss: model.dismissActionError - ) - .padding(.horizontal) - .padding(.top, 12) - } - ForEach(model.sections) { section in Section { ForEach(section.entries) { entry in - ProviderActivityTimelineRow(entry: entry) + ProviderActivityTimelineRow( + entry: entry, + actionDependencies: actionDependencies + ) .id(entry.id) .padding(.horizontal) .padding(.vertical, 5) @@ -240,7 +257,7 @@ struct ConflictLogView: View { } label: { Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") } - .disabled(model.isLoading) + .disabled(model.canRefresh == false) .accessibilityIdentifier("activity.refresh") } diff --git a/potassiumProvider/ConflictLogViewModel.swift b/potassiumProvider/ConflictLogViewModel.swift index 5c70c9c..c7e68a2 100644 --- a/potassiumProvider/ConflictLogViewModel.swift +++ b/potassiumProvider/ConflictLogViewModel.swift @@ -61,10 +61,25 @@ final class ConflictLogViewModel: ObservableObject { && isLoadingMore == false && isRefreshing == false && isClearing == false + && isExporting == false } var canExportSupportLog: Bool { - eventStore is any KDriveProviderEventExporting && isExporting == false + eventStore is any KDriveProviderEventExporting + && isExporting == false + && isClearing == false + } + + var canRefresh: Bool { + timelineStore != nil + && isInitialLoading == false + && isLoadingMore == false + && isRefreshing == false + && isClearing == false + } + + var canChangeFilter: Bool { + timelineStore != nil && isClearing == false } func start() async { @@ -82,7 +97,7 @@ final class ConflictLogViewModel: ObservableObject { } func setFilter(_ newFilter: KDriveProviderTimelineFilter) async { - guard filter != newFilter else { return } + guard canChangeFilter, filter != newFilter else { return } filter = newFilter await load() } @@ -143,6 +158,7 @@ final class ConflictLogViewModel: ObservableObject { isLoadingMore == false, isInitialLoading == false, isRefreshing == false, + isClearing == false, let nextCursor else { return @@ -179,14 +195,7 @@ final class ConflictLogViewModel: ObservableObject { initialErrorMessage = "Activity database is unavailable." return } - guard - isRefreshing == false, - isInitialLoading == false, - isLoadingMore == false, - isClearing == false - else { - return - } + guard canRefresh else { return } let generation = loadGeneration isRefreshing = true @@ -224,11 +233,13 @@ final class ConflictLogViewModel: ObservableObject { } } - func clearActivity() async { + @discardableResult + func clearActivity() async -> Bool { guard let eventStore else { actionErrorMessage = "Activity database is unavailable." - return + return false } + guard canClearActivity else { return false } isClearing = true defer { isClearing = false } @@ -237,8 +248,10 @@ final class ConflictLogViewModel: ObservableObject { try await eventStore.removeActivityAndResolvedConflicts(domainIdentifier: nil) actionErrorMessage = nil await load() + return true } catch { actionErrorMessage = "Could not clear activity events: \(error.localizedDescription)" + return false } } @@ -247,6 +260,7 @@ final class ConflictLogViewModel: ObservableObject { actionErrorMessage = "Support-log export is unavailable." return nil } + guard canExportSupportLog else { return nil } isExporting = true defer { isExporting = false } @@ -262,6 +276,12 @@ final class ConflictLogViewModel: ObservableObject { } func recordExportFailure(_ error: Error) { + let cocoaError = error as NSError + if error is CancellationError + || (cocoaError.domain == NSCocoaErrorDomain + && cocoaError.code == CocoaError.Code.userCancelled.rawValue) { + return + } actionErrorMessage = "Could not export support log: \(error.localizedDescription)" } diff --git a/potassiumProvider/ProviderActivityTimelineRow.swift b/potassiumProvider/ProviderActivityTimelineRow.swift index dc2be64..acf07c3 100644 --- a/potassiumProvider/ProviderActivityTimelineRow.swift +++ b/potassiumProvider/ProviderActivityTimelineRow.swift @@ -10,8 +10,17 @@ import UIKit struct ProviderActivityTimelineRow: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion let entry: KDriveProviderTimelineEntry + let actionDependencies: ProviderActivityActionDependencies @State private var isExpanded = false + init( + entry: KDriveProviderTimelineEntry, + actionDependencies: ProviderActivityActionDependencies = .live + ) { + self.entry = entry + self.actionDependencies = actionDependencies + } + var body: some View { VStack(alignment: .leading, spacing: 10) { Button { @@ -29,6 +38,7 @@ struct ProviderActivityTimelineRow: View { .accessibilityLabel("\(title), \(statusText)") .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") .accessibilityHint(isExpanded ? "Collapses activity details" : "Shows activity details") + .accessibilityIdentifier("activity.entry.\(entry.id)") if isExpanded { Divider() @@ -42,7 +52,6 @@ struct ProviderActivityTimelineRow: View { RoundedRectangle(cornerRadius: 14) .stroke(.separator.opacity(0.4), lineWidth: 0.5) } - .accessibilityIdentifier("activity.entry.\(entry.id)") } private var summary: some View { @@ -93,9 +102,9 @@ struct ProviderActivityTimelineRow: View { private var details: some View { switch entry { case .conflict(let event): - ConflictActivityDetails(event: event) + ConflictActivityDetails(event: event, actionDependencies: actionDependencies) case .activity(let event): - ProviderActivityDetails(event: event) + ProviderActivityDetails(event: event, actionDependencies: actionDependencies) } } @@ -161,6 +170,7 @@ struct ProviderActivityTimelineRow: View { private struct ConflictActivityDetails: View { let event: KDriveConflictEvent + let actionDependencies: ProviderActivityActionDependencies var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -188,10 +198,14 @@ private struct ConflictActivityDetails: View { domainIdentifier: event.domainIdentifier, itemIdentifier: event.conflictItemIdentifier ?? event.originalItemIdentifier, title: event.conflictItemName ?? event.originalItemName ?? "Open item", - fallbackDetail: event.conflictItemPath ?? event.originalItemPath + fallbackDetail: event.conflictItemPath ?? event.originalItemPath, + itemOpener: actionDependencies.itemOpener ) - CopyActivityDetailsButton(text: copyText) + CopyActivityDetailsButton( + text: copyText, + copyAction: actionDependencies.copyAction + ) } .font(.caption) } @@ -226,6 +240,7 @@ private struct ConflictActivityDetails: View { private struct ProviderActivityDetails: View { let event: KDriveProviderActivityEvent + let actionDependencies: ProviderActivityActionDependencies var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -262,14 +277,18 @@ private struct ProviderActivityDetails: View { domainIdentifier: event.domainIdentifier, itemIdentifier: event.itemIdentifier, title: event.itemName ?? event.itemIdentifier ?? "Open item", - fallbackDetail: event.itemPath + fallbackDetail: event.itemPath, + itemOpener: actionDependencies.itemOpener ) } else { Label("App activity", systemImage: "app") .foregroundStyle(.secondary) } - CopyActivityDetailsButton(text: copyText) + CopyActivityDetailsButton( + text: copyText, + copyAction: actionDependencies.copyAction + ) } .font(.caption) } @@ -362,9 +381,24 @@ private struct ProviderItemAction: View { let itemIdentifier: String? let title: String let fallbackDetail: String? + let itemOpener: ProviderItemOpening? @State private var isResolving = false @State private var errorMessage: String? + init( + domainIdentifier: String, + itemIdentifier: String?, + title: String, + fallbackDetail: String?, + itemOpener: ProviderItemOpening? = nil + ) { + self.domainIdentifier = domainIdentifier + self.itemIdentifier = itemIdentifier + self.title = title + self.fallbackDetail = fallbackDetail + self.itemOpener = itemOpener + } + var body: some View { VStack(alignment: .leading, spacing: 5) { if let itemIdentifier { @@ -408,33 +442,89 @@ private struct ProviderItemAction: View { errorMessage = nil defer { isResolving = false } + let opener = itemOpener ?? ProviderItemOpening.live { url in + #if os(macOS) + ProviderFileBrowserPresentation.revealInFinder(url) + #else + await withCheckedContinuation { continuation in + openURL(url) { accepted in + continuation.resume(returning: accepted) + } + } + #endif + } + switch await opener.open( + domainIdentifier: domainIdentifier, + itemIdentifier: itemIdentifier + ) { + case .opened: + break + case .domainUnavailable: + errorMessage = "This File Provider domain is unavailable." + case .itemUnavailable: + errorMessage = ProviderFileBrowserPresentation.unavailableMessage + } + } +} + +enum ProviderItemOpenResult: Equatable { + case opened + case domainUnavailable + case itemUnavailable +} + +enum ProviderItemURLResolution: Equatable { + case resolved(URL) + case domainUnavailable + case itemUnavailable +} + +@MainActor +struct ProviderItemOpening { + typealias Resolver = ( + _ domainIdentifier: String, + _ itemIdentifier: String + ) async -> ProviderItemURLResolution + typealias Presenter = (_ url: URL) async -> Bool + + let resolve: Resolver + let present: Presenter + + func open( + domainIdentifier: String, + itemIdentifier: String + ) async -> ProviderItemOpenResult { + switch await resolve(domainIdentifier, itemIdentifier) { + case .domainUnavailable: + return .domainUnavailable + case .itemUnavailable: + return .itemUnavailable + case .resolved(let url): + return await present(url) ? .opened : .itemUnavailable + } + } + + static func live(present: @escaping Presenter) -> ProviderItemOpening { + ProviderItemOpening(resolve: resolveUserVisibleURL, present: present) + } + + private static func resolveUserVisibleURL( + domainIdentifier: String, + itemIdentifier: String + ) async -> ProviderItemURLResolution { let domain = NSFileProviderDomain( identifier: NSFileProviderDomainIdentifier(rawValue: domainIdentifier), displayName: domainIdentifier ) guard let manager = NSFileProviderManager(for: domain) else { - errorMessage = "This File Provider domain is unavailable." - return + return .domainUnavailable } - let resolvedURL: URL? = await withCheckedContinuation { continuation in manager.getUserVisibleURL(for: NSFileProviderItemIdentifier(itemIdentifier)) { url, _ in continuation.resume(returning: url) } } - guard let resolvedURL else { - errorMessage = ProviderFileBrowserPresentation.unavailableMessage - return - } - - #if os(macOS) - guard ProviderFileBrowserPresentation.revealInFinder(resolvedURL) else { - errorMessage = ProviderFileBrowserPresentation.unavailableMessage - return - } - #else - openURL(resolvedURL) - #endif + return resolvedURL.map(ProviderItemURLResolution.resolved) ?? .itemUnavailable } } @@ -479,16 +569,70 @@ enum ProviderFileBrowserPresentation { private struct CopyActivityDetailsButton: View { let text: String - @State private var didCopy = false + let copyAction: ProviderActivityCopyAction + @State private var feedback: ProviderActivityCopyFeedback = .idle + + init( + text: String, + copyAction: ProviderActivityCopyAction = .live + ) { + self.text = text + self.copyAction = copyAction + } var body: some View { - Button { + VStack(alignment: .leading, spacing: 5) { + Button { + feedback = copyAction.copy(text) + } label: { + Label( + feedback == .copied ? "Copied" : "Copy Details", + systemImage: feedback == .copied ? "checkmark" : "doc.on.doc" + ) + } + .accessibilityIdentifier("activity.copyDetails") + + if feedback == .failed { + Label( + "Could not copy activity details.", + systemImage: "exclamationmark.triangle" + ) + .foregroundStyle(.orange) + .accessibilityIdentifier("activity.copyError") + } + } + } +} + +enum ProviderActivityCopyFeedback: Equatable { + case idle + case copied + case failed +} + +struct ProviderActivityCopyAction { + let write: (String) -> Bool + + func copy(_ text: String) -> ProviderActivityCopyFeedback { + write(text) ? .copied : .failed + } + + static var live: ProviderActivityCopyAction { + ProviderActivityCopyAction { text in ProviderActivityClipboard.copy(text) - didCopy = true - } label: { - Label(didCopy ? "Copied" : "Copy Details", systemImage: didCopy ? "checkmark" : "doc.on.doc") } - .accessibilityIdentifier("activity.copyDetails") + } +} + +struct ProviderActivityActionDependencies { + let itemOpener: ProviderItemOpening? + let copyAction: ProviderActivityCopyAction + + static var live: ProviderActivityActionDependencies { + ProviderActivityActionDependencies( + itemOpener: nil, + copyAction: .live + ) } } @@ -508,13 +652,16 @@ private struct ActivityMetadataFlow: View { } } -private enum ProviderActivityClipboard { - static func copy(_ text: String) { +enum ProviderActivityClipboard { + static func copy(_ text: String) -> Bool { #if os(macOS) NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) + return NSPasteboard.general.setString(text, forType: .string) #elseif canImport(UIKit) UIPasteboard.general.string = text + return true + #else + return false #endif } } diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index ce96083..d1e5eaa 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -8,7 +8,14 @@ enum ProviderUITestFixture { static func makeModel() -> PotassiumProviderAppModel? { guard let fixtureName = ProcessInfo.processInfo.environment[environmentKey], - ["setup-navigation", "setup-error-banner", "activities-pagination"].contains(fixtureName) + [ + "setup-navigation", + "setup-error-banner", + "activities-pagination", + "activities-action-errors", + "activities-unavailable", + "activities-row-action-errors", + ].contains(fixtureName) else { return nil } @@ -16,6 +23,37 @@ enum ProviderUITestFixture { if fixtureName == "activities-pagination" { return makeActivitiesModel() } + if fixtureName == "activities-action-errors" { + return PotassiumProviderAppModel( + eventStore: ProviderUITestActivityStore(activity: [], failsExport: true), + automaticallyReloadStoredState: false + ) + } + if fixtureName == "activities-unavailable" { + return PotassiumProviderAppModel( + eventStore: nil, + automaticallyReloadStoredState: false + ) + } + if fixtureName == "activities-row-action-errors" { + let event = KDriveProviderActivityEvent( + id: deterministicUUID(10_001), + occurredAt: Date(timeIntervalSince1970: 2_000_000), + domainIdentifier: "ui-domain", + driveID: 10, + kind: .enumeration, + outcome: .failure, + severity: .error, + itemIdentifier: "missing-item", + itemName: "Missing Item", + itemPath: "/Missing Item", + summary: "The item could not be enumerated." + ) + return PotassiumProviderAppModel( + eventStore: ProviderUITestActivityStore(activity: [event]), + automaticallyReloadStoredState: false + ) + } let account = ProviderAccount( accountIdentifier: "ui-account", @@ -100,6 +138,30 @@ enum ProviderUITestFixture { private static func deterministicUUID(_ index: Int) -> UUID { UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", index + 1))! } + + static func activityActionDependencies() -> ProviderActivityActionDependencies? { + guard ProcessInfo.processInfo.environment[environmentKey] == "activities-row-action-errors" + else { + return nil + } + return ProviderActivityActionDependencies( + itemOpener: ProviderItemOpening( + resolve: { _, _ in + .resolved(URL(fileURLWithPath: "/Missing Item")) + }, + present: { _ in false } + ), + copyAction: ProviderActivityCopyAction(write: { _ in false }) + ) + } + + static func initialActivityActionError() -> String? { + guard ProcessInfo.processInfo.environment[environmentKey] == "activities-unavailable" + else { + return nil + } + return "Activity actions are unavailable while the database is closed." + } } @MainActor @@ -124,9 +186,11 @@ private struct ProviderUITestDomainRegistrar: ProviderDomainRegistering { private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging, KDriveProviderEventExporting { private var activity: [KDriveProviderActivityEvent] + private let failsExport: Bool - init(activity: [KDriveProviderActivityEvent]) { + init(activity: [KDriveProviderActivityEvent], failsExport: Bool = false) { self.activity = activity + self.failsExport = failsExport } func saveConflict(_: KDriveConflictEvent) {} @@ -160,8 +224,11 @@ private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDrivePro activity.removeAll { $0.domainIdentifier == domainIdentifier } } - func supportLogData(domainIdentifier _: String?) -> Data { - Data(#"{"formatVersion":1,"events":[]}"#.utf8) + func supportLogData(domainIdentifier _: String?) throws -> Data { + if failsExport { + throw ProviderUITestActivityError.exportFailed + } + return Data(#"{"formatVersion":1,"events":[]}"#.utf8) } func timelinePage( @@ -195,4 +262,12 @@ private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDrivePro ) } } + +private enum ProviderUITestActivityError: LocalizedError { + case exportFailed + + var errorDescription: String? { + "The fixture could not create a support log." + } +} #endif diff --git a/potassiumProviderTests/ActivityTimelinePagingTests.swift b/potassiumProviderTests/ActivityTimelinePagingTests.swift index 8d9e42c..6a86f37 100644 --- a/potassiumProviderTests/ActivityTimelinePagingTests.swift +++ b/potassiumProviderTests/ActivityTimelinePagingTests.swift @@ -5,6 +5,50 @@ import Testing @Suite(.serialized) struct ActivityTimelinePagingTests { + @MainActor + @Test func itemOpeningReportsResolutionAndPresentationResults() async { + let url = URL(fileURLWithPath: "/tmp/report.txt") + let opened = ProviderItemOpening( + resolve: { _, _ in .resolved(url) }, + present: { $0 == url } + ) + let rejected = ProviderItemOpening( + resolve: { _, _ in .resolved(url) }, + present: { _ in false } + ) + let missingDomain = ProviderItemOpening( + resolve: { _, _ in .domainUnavailable }, + present: { _ in true } + ) + let missingItem = ProviderItemOpening( + resolve: { _, _ in .itemUnavailable }, + present: { _ in true } + ) + + #expect(await opened.open(domainIdentifier: "domain", itemIdentifier: "item") == .opened) + #expect( + await rejected.open(domainIdentifier: "domain", itemIdentifier: "item") + == .itemUnavailable + ) + #expect( + await missingDomain.open(domainIdentifier: "domain", itemIdentifier: "item") + == .domainUnavailable + ) + #expect( + await missingItem.open(domainIdentifier: "domain", itemIdentifier: "item") + == .itemUnavailable + ) + } + + @MainActor + @Test func copyActionOnlyReportsCopiedAfterASuccessfulWrite() { + let successful = ProviderActivityCopyAction(write: { _ in true }) + let failed = ProviderActivityCopyAction(write: { _ in false }) + + #expect(successful.copy("details") == .copied) + #expect(failed.copy("details") == .failed) + } + @Test func itemOpenActionUsesThePlatformFileBrowserName() { #if os(macOS) #expect(ProviderFileBrowserPresentation.applicationName == "Finder") @@ -288,6 +332,119 @@ struct ActivityTimelinePagingTests { #expect(Set(model.entries.map(\.id)).count == model.entries.count) } + @MainActor + @Test func viewModelGuardsClearAndExportFromOverlapAndDuplicateCalls() async { + let store = BlockingActivityActionStore() + let model = ConflictLogViewModel(eventStore: store) + await model.load() + + let clearTask = Task { await model.clearActivity() } + while await store.clearRequestCount() == 0 { + await Task.yield() + } + #expect(model.isClearing) + #expect(model.canExportSupportLog == false) + #expect(await model.clearActivity() == false) + #expect(await model.supportLogData() == nil) + #expect(await store.clearRequestCount() == 1) + #expect(await store.exportRequestCount() == 0) + + await store.releaseClear() + #expect(await clearTask.value) + #expect(model.isClearing == false) + + let exportTask = Task { await model.supportLogData() } + while await store.exportRequestCount() == 0 { + await Task.yield() + } + #expect(model.isExporting) + #expect(model.canClearActivity == false) + #expect(await model.supportLogData() == nil) + #expect(await model.clearActivity() == false) + #expect(await store.exportRequestCount() == 1) + #expect(await store.clearRequestCount() == 1) + + await store.releaseExport() + #expect(await exportTask.value != nil) + #expect(model.isExporting == false) + } + + @MainActor + @Test func viewModelAvailabilityMatchesUnavailableAndBusyStates() async { + let unavailable = ConflictLogViewModel(eventStore: nil) + #expect(unavailable.canRefresh == false) + #expect(unavailable.canChangeFilter == false) + #expect(unavailable.canClearActivity == false) + #expect(unavailable.canExportSupportLog == false) + + let store = BlockingActivityActionStore() + let model = ConflictLogViewModel(eventStore: store) + await model.load() + #expect(model.canRefresh) + #expect(model.canChangeFilter) + + let clearTask = Task { await model.clearActivity() } + while await store.clearRequestCount() == 0 { + await Task.yield() + } + #expect(model.canRefresh == false) + #expect(model.canChangeFilter == false) + await store.releaseClear() + #expect(await clearTask.value) + #expect(model.canRefresh) + #expect(model.canChangeFilter) + } + + @MainActor + @Test func viewModelCleansActionProgressAfterFailures() async { + let store = FailingActivityActionStore() + let model = ConflictLogViewModel(eventStore: store) + await model.load() + + #expect(await model.clearActivity() == false) + #expect(model.isClearing == false) + #expect(model.actionErrorMessage?.contains("Could not clear activity events") == true) + + #expect(await model.supportLogData() == nil) + #expect(model.isExporting == false) + #expect(model.actionErrorMessage?.contains("Could not create support log") == true) + } + + @MainActor + @Test func refreshAvailabilityTracksInitialAndPagingLoads() async { + let entry = KDriveProviderTimelineEntry.activity(makeActivity( + id: UUID(), + date: Date(timeIntervalSince1970: 1_000), + outcome: .failure + )) + let initialStore = BlockingTimelineStore(entry: entry, blocksInitialLoad: true) + let initialModel = ConflictLogViewModel(eventStore: initialStore) + let initialTask = Task { await initialModel.load() } + while await initialStore.pageRequestCount() == 0 { + await Task.yield() + } + #expect(initialModel.isInitialLoading) + #expect(initialModel.canRefresh == false) + #expect(initialModel.canChangeFilter) + await initialStore.releasePage() + await initialTask.value + #expect(initialModel.canRefresh) + + let pagingStore = BlockingTimelineStore(entry: entry, blocksInitialLoad: false) + let pagingModel = ConflictLogViewModel(eventStore: pagingStore) + await pagingModel.load() + let pagingTask = Task { await pagingModel.loadMore() } + while await pagingStore.pageRequestCount() < 2 { + await Task.yield() + } + #expect(pagingModel.isLoadingMore) + #expect(pagingModel.canRefresh == false) + #expect(pagingModel.canChangeFilter) + await pagingStore.releasePage() + await pagingTask.value + #expect(pagingModel.canRefresh) + } + private func temporaryDirectory() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent("activity-timeline-tests-\(UUID().uuidString)", isDirectory: true) @@ -458,6 +615,141 @@ private actor PagingEventStore: KDriveProviderEventStoring, KDriveProviderEventT } } +private actor BlockingActivityActionStore: + KDriveProviderEventStoring, + KDriveProviderEventTimelinePaging, + KDriveProviderEventExporting +{ + private var clearCount = 0 + private var exportCount = 0 + private var clearContinuation: CheckedContinuation? + private var exportContinuation: CheckedContinuation? + + func saveConflict(_: KDriveConflictEvent) {} + func recordActivity(_: KDriveProviderActivityEvent) {} + func recentConflicts(domainIdentifier _: String?, limit _: Int) -> [KDriveConflictEvent] { [] } + func recentActivity(domainIdentifier _: String?, limit _: Int) -> [KDriveProviderActivityEvent] { [] } + func recentActivity( + domainIdentifier _: String?, + outcome _: KDriveProviderActivityOutcome?, + limit _: Int + ) -> [KDriveProviderActivityEvent] { [] } + + func removeActivityAndResolvedConflicts(domainIdentifier _: String?) async { + clearCount += 1 + await withCheckedContinuation { clearContinuation = $0 } + } + + func removeEvents(domainIdentifier _: String) {} + + func timelinePage( + filter _: KDriveProviderTimelineFilter, + before _: KDriveProviderTimelineCursor?, + limit _: Int + ) -> KDriveProviderTimelinePage { + KDriveProviderTimelinePage(entries: [], nextCursor: nil, hasMore: false) + } + + func supportLogData(domainIdentifier _: String?) async -> Data { + exportCount += 1 + await withCheckedContinuation { exportContinuation = $0 } + return Data("{}".utf8) + } + + func clearRequestCount() -> Int { clearCount } + func exportRequestCount() -> Int { exportCount } + + func releaseClear() { + clearContinuation?.resume() + clearContinuation = nil + } + + func releaseExport() { + exportContinuation?.resume() + exportContinuation = nil + } +} + +private actor FailingActivityActionStore: + KDriveProviderEventStoring, + KDriveProviderEventTimelinePaging, + KDriveProviderEventExporting +{ + func saveConflict(_: KDriveConflictEvent) {} + func recordActivity(_: KDriveProviderActivityEvent) {} + func recentConflicts(domainIdentifier _: String?, limit _: Int) -> [KDriveConflictEvent] { [] } + func recentActivity(domainIdentifier _: String?, limit _: Int) -> [KDriveProviderActivityEvent] { [] } + func recentActivity( + domainIdentifier _: String?, + outcome _: KDriveProviderActivityOutcome?, + limit _: Int + ) -> [KDriveProviderActivityEvent] { [] } + func removeActivityAndResolvedConflicts(domainIdentifier _: String?) throws { + throw TestPagingError.failed + } + func removeEvents(domainIdentifier _: String) {} + func timelinePage( + filter _: KDriveProviderTimelineFilter, + before _: KDriveProviderTimelineCursor?, + limit _: Int + ) -> KDriveProviderTimelinePage { + KDriveProviderTimelinePage(entries: [], nextCursor: nil, hasMore: false) + } + func supportLogData(domainIdentifier _: String?) throws -> Data { + throw TestPagingError.failed + } +} + +private actor BlockingTimelineStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging { + private let entry: KDriveProviderTimelineEntry + private let blocksInitialLoad: Bool + private var requestCount = 0 + private var pageContinuation: CheckedContinuation? + + init(entry: KDriveProviderTimelineEntry, blocksInitialLoad: Bool) { + self.entry = entry + self.blocksInitialLoad = blocksInitialLoad + } + + func saveConflict(_: KDriveConflictEvent) {} + func recordActivity(_: KDriveProviderActivityEvent) {} + func recentConflicts(domainIdentifier _: String?, limit _: Int) -> [KDriveConflictEvent] { [] } + func recentActivity(domainIdentifier _: String?, limit _: Int) -> [KDriveProviderActivityEvent] { [] } + func recentActivity( + domainIdentifier _: String?, + outcome _: KDriveProviderActivityOutcome?, + limit _: Int + ) -> [KDriveProviderActivityEvent] { [] } + func removeActivityAndResolvedConflicts(domainIdentifier _: String?) {} + func removeEvents(domainIdentifier _: String) {} + + func timelinePage( + filter _: KDriveProviderTimelineFilter, + before: KDriveProviderTimelineCursor?, + limit _: Int + ) async -> KDriveProviderTimelinePage { + requestCount += 1 + if (before == nil && blocksInitialLoad) || before != nil { + await withCheckedContinuation { pageContinuation = $0 } + } + if before == nil { + return KDriveProviderTimelinePage( + entries: [entry], + nextCursor: entry.cursor, + hasMore: true + ) + } + return KDriveProviderTimelinePage(entries: [], nextCursor: nil, hasMore: false) + } + + func pageRequestCount() -> Int { requestCount } + + func releasePage() { + pageContinuation?.resume() + pageContinuation = nil + } +} + private enum TestPagingError: LocalizedError { case failed diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index ed76fcf..1d70af0 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -126,6 +126,72 @@ final class potassiumProviderUITests: XCTestCase { } } + @MainActor + func testActivitiesShowsExportFailureWhileTimelineIsEmpty() throws { + let app = launchSetupFixture(named: "activities-action-errors") + openActivities(in: app) + + #if os(macOS) + let export = app.buttons["Export"] + #else + app.buttons["More Activity Actions"].tap() + let export = app.buttons["Export"] + #endif + XCTAssertTrue(export.waitForExistence(timeout: 5)) + export.tap() + + let error = app.descendants(matching: .any)["activity.error"] + XCTAssertTrue(error.waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", "Could not create support log") + ).firstMatch.exists) + } + + @MainActor + func testActivitiesDisablesRefreshWhenDatabaseIsUnavailable() throws { + let app = launchSetupFixture(named: "activities-unavailable") + openActivities(in: app) + + XCTAssertTrue(app.staticTexts["Activities Unavailable"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)["activity.error"].exists) + let refresh = app.buttons["activity.refresh"] + XCTAssertTrue(refresh.exists) + XCTAssertFalse(refresh.isEnabled) + } + + @MainActor + func testActivitiesReportsRejectedItemOpenAndClipboardWrite() throws { + let app = launchSetupFixture(named: "activities-row-action-errors") + openActivities(in: app) + + let entry = app.buttons.matching( + NSPredicate(format: "label CONTAINS %@", "Failed enumeration") + ).firstMatch + XCTAssertTrue(entry.waitForExistence(timeout: 5)) + entry.tap() + + let openItem = app.buttons["activity.openInFiles"] + XCTAssertTrue(openItem.waitForExistence(timeout: 5)) + openItem.tap() + #if os(macOS) + let unavailableText = "This item could not be found in Finder" + #else + let unavailableText = "This item is not currently available in Files" + #endif + XCTAssertTrue(app.staticTexts.matching( + NSPredicate(format: "label CONTAINS %@", unavailableText) + ).firstMatch.waitForExistence(timeout: 5)) + + let copy = app.buttons["activity.copyDetails"] + XCTAssertTrue(copy.exists) + copy.tap() + XCTAssertTrue( + app.descendants(matching: .any)["activity.copyError"] + .waitForExistence(timeout: 5) + ) + XCTAssertFalse(app.buttons["Copied"].exists) + } + #if os(macOS) @MainActor func testActivitiesExportPresentsSavePanel() throws { From c68e8d3684f8f5eae09600abdd4e4751d6f65e4b Mon Sep 17 00:00:00 2001 From: OpenCow Date: Sun, 26 Jul 2026 00:19:26 +0200 Subject: [PATCH 9/9] test: fix macOS UI test automation --- potassiumProvider/ProviderSetupView.swift | 6 +- potassiumProvider/ProviderUITestFixture.swift | 34 ++++++- .../potassiumProviderUITests.swift | 91 +++++++++++++------ 3 files changed, 101 insertions(+), 30 deletions(-) diff --git a/potassiumProvider/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift index dad43d0..d819652 100644 --- a/potassiumProvider/ProviderSetupView.swift +++ b/potassiumProvider/ProviderSetupView.swift @@ -840,10 +840,12 @@ private struct ProviderSetupErrorBanner: View { Spacer(minLength: 8) Button(action: dismiss) { - Image(systemName: "xmark") + Label("Dismiss", systemImage: "xmark") } - .buttonStyle(.plain) + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) .accessibilityLabel("Dismiss kDrive message") + .accessibilityIdentifier("setup.dismissError") } .padding(12) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) diff --git a/potassiumProvider/ProviderUITestFixture.swift b/potassiumProvider/ProviderUITestFixture.swift index d1e5eaa..071688f 100644 --- a/potassiumProvider/ProviderUITestFixture.swift +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -31,7 +31,7 @@ enum ProviderUITestFixture { } if fixtureName == "activities-unavailable" { return PotassiumProviderAppModel( - eventStore: nil, + eventStore: ProviderUITestUnavailableEventStore(), automaticallyReloadStoredState: false ) } @@ -263,6 +263,38 @@ private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDrivePro } } +private actor ProviderUITestUnavailableEventStore: KDriveProviderEventStoring { + func saveConflict(_: KDriveConflictEvent) {} + + func recordActivity(_: KDriveProviderActivityEvent) {} + + func recentConflicts( + domainIdentifier _: String?, + limit _: Int + ) -> [KDriveConflictEvent] { + [] + } + + func recentActivity( + domainIdentifier _: String?, + limit _: Int + ) -> [KDriveProviderActivityEvent] { + [] + } + + func recentActivity( + domainIdentifier _: String?, + outcome _: KDriveProviderActivityOutcome?, + limit _: Int + ) -> [KDriveProviderActivityEvent] { + [] + } + + func removeActivityAndResolvedConflicts(domainIdentifier _: String?) {} + + func removeEvents(domainIdentifier _: String) {} +} + private enum ProviderUITestActivityError: LocalizedError { case exportFailed diff --git a/potassiumProviderUITests/potassiumProviderUITests.swift b/potassiumProviderUITests/potassiumProviderUITests.swift index 1d70af0..ddbf0d4 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -62,9 +62,9 @@ final class potassiumProviderUITests: XCTestCase { remove.tap() XCTAssertTrue(app.buttons["Remove from Files"].waitForExistence(timeout: 5)) - XCTAssertTrue(app.staticTexts.matching( - NSPredicate(format: "label CONTAINS %@", "Remote kDrive files are not deleted") - ).firstMatch.exists) + XCTAssertTrue( + text(containing: "Remote kDrive files are not deleted", in: app).exists + ) } @MainActor @@ -90,7 +90,13 @@ final class potassiumProviderUITests: XCTestCase { XCTAssertTrue(banner.waitForExistence(timeout: 5)) XCTAssertEqual(app.alerts.count, 0) - app.buttons["Dismiss kDrive message"].tap() + let dismiss = app.buttons["setup.dismissError"] + XCTAssertTrue(dismiss.waitForExistence(timeout: 5)) + #if os(macOS) + app.typeKey(.escape, modifierFlags: []) + #else + dismiss.tap() + #endif XCTAssertTrue(banner.waitForNonExistence(timeout: 5)) } @@ -99,19 +105,25 @@ final class potassiumProviderUITests: XCTestCase { let app = launchSetupFixture(named: "activities-pagination") openActivities(in: app) - XCTAssertTrue(app.staticTexts["Item 0000"].waitForExistence(timeout: 5)) + let latestEntry = app.buttons[ + "activity.entry.activity-00000000-0000-0000-0000-000000000001" + ] + XCTAssertTrue(latestEntry.waitForExistence(timeout: 5)) let timeline = app.scrollViews["activity.timeline"] XCTAssertTrue(timeline.waitForExistence(timeout: 5)) - for _ in 0..<14 where app.staticTexts["Item 0075"].exists == false { + let olderEntry = app.buttons[ + "activity.entry.activity-00000000-0000-0000-0000-000000000076" + ] + for _ in 0..<14 where olderEntry.exists == false { timeline.swipeUp() } - XCTAssertTrue(app.staticTexts["Item 0075"].waitForExistence(timeout: 5)) + XCTAssertTrue(olderEntry.waitForExistence(timeout: 5)) let backToLatest = app.buttons["activity.backToLatest"] XCTAssertTrue(backToLatest.waitForExistence(timeout: 5)) backToLatest.tap() - XCTAssertTrue(app.staticTexts["Item 0000"].waitForExistence(timeout: 5)) + XCTAssertTrue(latestEntry.waitForExistence(timeout: 5)) } @MainActor @@ -142,9 +154,9 @@ final class potassiumProviderUITests: XCTestCase { let error = app.descendants(matching: .any)["activity.error"] XCTAssertTrue(error.waitForExistence(timeout: 5)) - XCTAssertTrue(app.staticTexts.matching( - NSPredicate(format: "label CONTAINS %@", "Could not create support log") - ).firstMatch.exists) + XCTAssertTrue( + text(containing: "Could not create support log", in: app).exists + ) } @MainActor @@ -152,7 +164,10 @@ final class potassiumProviderUITests: XCTestCase { let app = launchSetupFixture(named: "activities-unavailable") openActivities(in: app) - XCTAssertTrue(app.staticTexts["Activities Unavailable"].waitForExistence(timeout: 5)) + XCTAssertTrue( + text(containing: "Activities Unavailable", in: app) + .waitForExistence(timeout: 5) + ) XCTAssertTrue(app.descendants(matching: .any)["activity.error"].exists) let refresh = app.buttons["activity.refresh"] XCTAssertTrue(refresh.exists) @@ -178,9 +193,10 @@ final class potassiumProviderUITests: XCTestCase { #else let unavailableText = "This item is not currently available in Files" #endif - XCTAssertTrue(app.staticTexts.matching( - NSPredicate(format: "label CONTAINS %@", unavailableText) - ).firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue( + text(containing: unavailableText, in: app) + .waitForExistence(timeout: 5) + ) let copy = app.buttons["activity.copyDetails"] XCTAssertTrue(copy.exists) @@ -229,25 +245,46 @@ final class potassiumProviderUITests: XCTestCase { @MainActor private func openSetup(in app: XCUIApplication) { - let tabBarButton = app.tabBars.buttons["Setup"] - if tabBarButton.waitForExistence(timeout: 2) { - tabBarButton.tap() - } else { - let setupButton = app.buttons["Setup"] - XCTAssertTrue(setupButton.waitForExistence(timeout: 5)) - setupButton.tap() - } + openTab(named: "Setup", in: app) } @MainActor private func openActivities(in app: XCUIApplication) { - let tabBarButton = app.tabBars.buttons["Activities"] + openTab(named: "Activities", in: app) + } + + @MainActor + private func text(containing text: String, in app: XCUIApplication) -> XCUIElement { + app.staticTexts.matching( + NSPredicate( + format: "label CONTAINS %@ OR value CONTAINS %@", + text, + text + ) + ).firstMatch + } + + @MainActor + private func openTab(named name: String, in app: XCUIApplication) { + #if os(macOS) + let tab = app.radioButtons[name] + XCTAssertTrue( + tab.waitForExistence(timeout: 5), + "The \(name) tab was not available." + ) + tab.tap() + #else + let tabBarButton = app.tabBars.buttons[name] if tabBarButton.waitForExistence(timeout: 2) { tabBarButton.tap() } else { - let activitiesButton = app.buttons["Activities"] - XCTAssertTrue(activitiesButton.waitForExistence(timeout: 5)) - activitiesButton.tap() + let tabButton = app.buttons[name] + XCTAssertTrue( + tabButton.waitForExistence(timeout: 5), + "The \(name) tab was not available." + ) + tabButton.tap() } + #endif } }