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/APP_AND_DOMAINS.md b/doc/APP_AND_DOMAINS.md index 3bb3696..2642a77 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,26 @@ 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. 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: + +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. 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 @@ -40,12 +58,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 +87,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 +125,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 +147,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 +166,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 +186,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/doc/CONFLICTS.md b/doc/CONFLICTS.md index a22a956..272e565 100644 --- a/doc/CONFLICTS.md +++ b/doc/CONFLICTS.md @@ -198,19 +198,38 @@ 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 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. 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. +- 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. + 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. @@ -218,7 +237,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..ea40386 100644 --- a/doc/LOGGING.md +++ b/doc/LOGGING.md @@ -45,6 +45,18 @@ 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. + +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 @@ -53,6 +65,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/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/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.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 - } - } - - 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" - } - } - - var systemImage: String { - switch self { - case .errorsOnly: - return "exclamationmark.triangle" - case .fullActivity: - return "clock.arrow.circlepath" + model.filter + } set: { filter in + Task { + await model.setFilter(filter) + moveToLatest(animated: true) + } } } - var showsActivity: Bool { - self == .fullActivity - } -} - -private extension View { - @ViewBuilder - func providerActivityControlGlass() -> some View { - #if os(visionOS) - self - #else - glassEffect(.regular.interactive(), in: RoundedRectangle(cornerRadius: 8)) - #endif - } - @ViewBuilder - func providerActivityCopyableText(_ text: String) -> some View { - #if os(macOS) - textSelection(.enabled) - .contextMenu { - Button { - ProviderActivityClipboard.copy(text) - } label: { - Label("Copy", systemImage: "doc.on.doc") + 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 - 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? + } else if model.entries.isEmpty { + ContentUnavailableView( + emptyActivityTitle, + systemImage: "checkmark.seal", + description: Text(emptyActivityDescription) + ) + } else { + timelineScrollView + } + } + + private var timelineScrollView: some View { + ZStack(alignment: .bottomTrailing) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(model.sections) { section in + Section { + ForEach(section.entries) { entry in + ProviderActivityTimelineRow( + entry: entry, + actionDependencies: actionDependencies + ) + .id(entry.id) + .padding(.horizontal) + .padding(.vertical, 5) + } + } header: { + ActivityDateHeader(date: section.id) + } + } - private let eventStore: (any KDriveProviderEventStoring)? - private var eventObservationTask: Task? + 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") - 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() + if isAwayFromLatest { + Button { + moveToLatest(animated: true) + } label: { + Label("Back to Latest", systemImage: "arrow.up.to.line") } + .buttonStyle(.borderedProminent) + .labelStyle(.titleAndIcon) + .padding() + .accessibilityIdentifier("activity.backToLatest") } } } - 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 - } - - 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)" + @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") } } - func clearActivity() 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 } - - isClearing = true - defer { isClearing = false } - - do { - try await eventStore.removeActivityAndResolvedConflicts(domainIdentifier: nil) - await load() - } catch { - errorMessage = "Could not clear 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 supportLogData() async -> Data? { - guard let eventStore = eventStore as? any KDriveProviderEventExporting else { - errorMessage = "Support-log export is unavailable." - return nil + private var clearButton: some View { + Button(role: .destructive) { + isClearConfirmationPresented = true + } label: { + Label(model.isClearing ? "Clearing" : "Clear", systemImage: "trash") } - - 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)" - } -} - -private struct ProviderSupportLogDocument: FileDocument { - static var readableContentTypes: [UTType] { [.json] } - - let data: Data - - init(data: Data) { - self.data = data + .disabled(model.canClearActivity == false) } - init(configuration: ReadConfiguration) throws { - guard let data = configuration.file.regularFileContents else { - throw CocoaError(.fileReadCorruptFile) + private var refreshButton: some View { + Button { + Task { await model.refresh() } + } label: { + Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") } - self.data = data + .disabled(model.canRefresh == false) + .accessibilityIdentifier("activity.refresh") } - func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { - FileWrapper(regularFileWithContents: data) - } -} - -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 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") } + .disabled(model.canExportSupportLog == false) } - var date: Date { - switch self { - case .conflict(let event): - return event.resolvedAt ?? event.detectedAt - case .activity(let event): - return event.occurredAt - } + 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 } -} - -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") - } - } - .font(.subheadline) - .foregroundStyle(.secondary) - - Text(event.resolutionSummary) - .font(.subheadline) - if let stagedUploadRelativePath = event.stagedUploadRelativePath { - Text(stagedUploadRelativePath) - .font(.caption) - .foregroundStyle(.secondary) + private func moveToLatest(animated: Bool) { + guard let latestID = model.entries.first?.id else { return } + if animated && reduceMotion == false { + withAnimation(.snappy) { + scrollPositionID = latestID } - - 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())" - } - } - - private var diagnosticCode: String? { - if let providerErrorCode = event.providerErrorCode { - return "Provider \(providerErrorCode)" - } - 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)") + let calendar = Calendar.autoupdatingCurrent + if calendar.isDateInToday(date) { + return "Today" } - 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") + if calendar.isDateInYesterday(date) { + return "Yesterday" } - - 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..c7e68a2 --- /dev/null +++ b/potassiumProvider/ConflictLogViewModel.swift @@ -0,0 +1,381 @@ +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 + && isExporting == false + } + + var canExportSupportLog: Bool { + 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 { + if entries.isEmpty && isInitialLoading == false { + await load() + } + startObservingChanges() + } + + func stop() { + eventObservationTask?.cancel() + eventObservationTask = nil + coalescedRefreshTask?.cancel() + coalescedRefreshTask = nil + } + + func setFilter(_ newFilter: KDriveProviderTimelineFilter) async { + guard canChangeFilter, 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, + isClearing == 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 canRefresh 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)" + } + } + } + + @discardableResult + func clearActivity() async -> Bool { + guard let eventStore else { + actionErrorMessage = "Activity database is unavailable." + return false + } + guard canClearActivity else { return false } + + isClearing = true + defer { isClearing = false } + + do { + try await eventStore.removeActivityAndResolvedConflicts(domainIdentifier: nil) + actionErrorMessage = nil + await load() + return true + } catch { + actionErrorMessage = "Could not clear activity events: \(error.localizedDescription)" + return false + } + } + + func supportLogData() async -> Data? { + guard let eventStore = eventStore as? any KDriveProviderEventExporting else { + actionErrorMessage = "Support-log export is unavailable." + return nil + } + guard canExportSupportLog else { 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) { + 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)" + } + + 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/ContentView.swift b/potassiumProvider/ContentView.swift index d6981f6..703832c 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) { @@ -18,18 +17,21 @@ struct ContentView: View { ProviderStatusView(appModel: model) { selectedTab = .setup } + .providerNavigationAnimation(animatesInitialAppearance: false) .tabItem { Label("Status", systemImage: "gauge.medium") } .tag(ProviderAppTab.status) - setupView + 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") } @@ -41,218 +43,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 +57,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 +70,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..876657c 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,8 @@ 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 isReloadingStoredState = false @Published private(set) var statusMessage: String? @Published var errorMessage: String? @Published var manualAccessToken = "" @@ -45,6 +60,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 +73,13 @@ 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 + isReloadingStoredState = automaticallyReloadStoredState + statusMessage = initialAccounts.isEmpty + ? "No accounts connected." + : "Loaded \(initialAccounts.count) account\(initialAccounts.count == 1 ? "" : "s")." observeFileProviderDomainChanges() if automaticallyReloadStoredState { Task { await reloadStoredState() } @@ -120,8 +144,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? { @@ -150,6 +185,9 @@ final class PotassiumProviderAppModel: ObservableObject { } func reloadStoredState() async { + isReloadingStoredState = true + defer { isReloadingStoredState = false } + do { try await migrateLegacyStateIfNeeded() accounts = try await accountStore.allAccounts() @@ -293,6 +331,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 +380,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 +405,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 +437,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 +460,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 +486,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 +507,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 +720,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/ProviderActivityTimelineRow.swift b/potassiumProvider/ProviderActivityTimelineRow.swift new file mode 100644 index 0000000..acf07c3 --- /dev/null +++ b/potassiumProvider/ProviderActivityTimelineRow.swift @@ -0,0 +1,782 @@ +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 + 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 { + 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") + .accessibilityIdentifier("activity.entry.\(entry.id)") + + 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) + } + } + + 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, actionDependencies: actionDependencies) + case .activity(let event): + ProviderActivityDetails(event: event, actionDependencies: actionDependencies) + } + } + + 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 + let actionDependencies: ProviderActivityActionDependencies + + 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, + itemOpener: actionDependencies.itemOpener + ) + + CopyActivityDetailsButton( + text: copyText, + copyAction: actionDependencies.copyAction + ) + } + .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 + let actionDependencies: ProviderActivityActionDependencies + + 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, + itemOpener: actionDependencies.itemOpener + ) + } else { + Label("App activity", systemImage: "app") + .foregroundStyle(.secondary) + } + + CopyActivityDetailsButton( + text: copyText, + copyAction: actionDependencies.copyAction + ) + } + .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 { + #if !os(macOS) + @Environment(\.openURL) private var openURL + #endif + let domainIdentifier: String + 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 { + Button { + Task { await resolveAndOpen(itemIdentifier: itemIdentifier) } + } label: { + if isResolving { + Label( + ProviderFileBrowserPresentation.openingActionTitle, + systemImage: "arrow.up.forward.app" + ) + } else { + Label( + ProviderFileBrowserPresentation.openActionTitle, + systemImage: "arrow.up.forward.app" + ) + } + } + .disabled(isResolving) + .accessibilityLabel( + ProviderFileBrowserPresentation.accessibilityLabel(for: title) + ) + .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 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 { + return .domainUnavailable + } + let resolvedURL: URL? = await withCheckedContinuation { continuation in + manager.getUserVisibleURL(for: NSFileProviderItemIdentifier(itemIdentifier)) { url, _ in + continuation.resume(returning: url) + } + } + return resolvedURL.map(ProviderItemURLResolution.resolved) ?? .itemUnavailable + } +} + +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 { + let text: String + 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 { + 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) + } + } +} + +struct ProviderActivityActionDependencies { + let itemOpener: ProviderItemOpening? + let copyAction: ProviderActivityCopyAction + + static var live: ProviderActivityActionDependencies { + ProviderActivityActionDependencies( + itemOpener: nil, + copyAction: .live + ) + } +} + +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) + } +} + +enum ProviderActivityClipboard { + static func copy(_ text: String) -> Bool { + #if os(macOS) + NSPasteboard.general.clearContents() + return NSPasteboard.general.setString(text, forType: .string) + #elseif canImport(UIKit) + UIPasteboard.general.string = text + return true + #else + return false + #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/ProviderSetupView.swift b/potassiumProvider/ProviderSetupView.swift new file mode 100644 index 0000000..d819652 --- /dev/null +++ b/potassiumProvider/ProviderSetupView.swift @@ -0,0 +1,951 @@ +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 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var path: [ProviderSetupRoute] = [] + + var body: some View { + NavigationStack(path: $path) { + accountList + .navigationDestination(for: ProviderSetupRoute.self) { route in + destination(for: route) + } + } + .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 { + List { + Section { + NavigationLink(value: ProviderSetupRoute.addAccount) { + Label("Add Infomaniak Account", systemImage: "person.crop.circle.badge.plus") + .font(.headline) + } + .accessibilityIdentifier("setup.addAccount") + } + .disabled(model.isReloadingStoredState) + + Section("Accounts") { + 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", + 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 model.isReloadingStoredState == false, let statusMessage = model.statusMessage { + Section { + ProviderFeedbackLabel(message: statusMessage) + } + } + } + .navigationTitle("Setup") + .providerNavigationAnimation(animatesInitialAppearance: false) + .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.isReloadingStoredState || + 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) + .providerNavigationAnimation() + case .account(let accountIdentifier): + ProviderAccountManagementView( + model: model, + accountIdentifier: accountIdentifier + ) { + path.removeAll() + } + .providerNavigationAnimation() + case .drive(let key): + ProviderDriveManagementView(model: model, key: key) + .providerNavigationAnimation() + } + } + + 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 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) { + Label("Dismiss", systemImage: "xmark") + } + .buttonStyle(.borderless) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Dismiss kDrive message") + .accessibilityIdentifier("setup.dismissError") + } + .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 { + case .oauth: + "OAuth" + case .manualAccessToken: + "Manual Token" + } + } + + var systemImage: String { + switch self { + case .oauth: + "person.crop.circle" + case .manualAccessToken: + "key" + } + } +} + +extension View { + func providerNavigationAnimation(animatesInitialAppearance: Bool = true) -> some View { + modifier(ProviderNavigationAnimationModifier( + animatesInitialAppearance: animatesInitialAppearance + )) + } +} + +private extension View { + @ViewBuilder + func platformPasswordEntry() -> some View { + #if canImport(UIKit) + self + .textContentType(.password) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + #else + self + #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) + } +} 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..071688f --- /dev/null +++ b/potassiumProvider/ProviderUITestFixture.swift @@ -0,0 +1,305 @@ +#if DEBUG +import Foundation +import PotassiumProviderCore + +@MainActor +enum ProviderUITestFixture { + private static let environmentKey = "POTASSIUM_UI_TEST_FIXTURE" + + static func makeModel() -> PotassiumProviderAppModel? { + guard let fixtureName = ProcessInfo.processInfo.environment[environmentKey], + [ + "setup-navigation", + "setup-error-banner", + "activities-pagination", + "activities-action-errors", + "activities-unavailable", + "activities-row-action-errors", + ].contains(fixtureName) + else { + return nil + } + + 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: ProviderUITestUnavailableEventStore(), + 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", + 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) + + let model = 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] + ) + if fixtureName == "setup-error-banner" { + model.errorMessage = "Could not refresh kDrive details." + } + 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))! + } + + 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 +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 {} +} + +private actor ProviderUITestActivityStore: KDriveProviderEventStoring, KDriveProviderEventTimelinePaging, KDriveProviderEventExporting { + private var activity: [KDriveProviderActivityEvent] + private let failsExport: Bool + + init(activity: [KDriveProviderActivityEvent], failsExport: Bool = false) { + self.activity = activity + self.failsExport = failsExport + } + + 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 supportLogData(domainIdentifier _: String?) throws -> Data { + if failsExport { + throw ProviderUITestActivityError.exportFailed + } + return Data(#"{"formatVersion":1,"events":[]}"#.utf8) + } + + 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 + ) + } +} + +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 + + var errorDescription: String? { + "The fixture could not create a support log." + } +} +#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/ActivityTimelinePagingTests.swift b/potassiumProviderTests/ActivityTimelinePagingTests.swift new file mode 100644 index 0000000..6a86f37 --- /dev/null +++ b/potassiumProviderTests/ActivityTimelinePagingTests.swift @@ -0,0 +1,767 @@ +import Foundation +import PotassiumProviderCore +import Testing +@testable import potassiumProvider + +@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") + #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) } + 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) + } + + @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) + } + + 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 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 + + 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/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/potassiumProviderTests/ProviderSetupViewTests.swift b/potassiumProviderTests/ProviderSetupViewTests.swift new file mode 100644 index 0000000..552a305 --- /dev/null +++ b/potassiumProviderTests/ProviderSetupViewTests.swift @@ -0,0 +1,338 @@ +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 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) + 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 {} +} + +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/potassiumProviderTests/potassiumProviderTests.swift b/potassiumProviderTests/potassiumProviderTests.swift index e22edd0..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) } @@ -1047,6 +1041,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 +1932,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) @@ -2709,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 @@ -2765,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 } @@ -2776,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 c894a2e..ddbf0d4 100644 --- a/potassiumProviderUITests/potassiumProviderUITests.swift +++ b/potassiumProviderUITests/potassiumProviderUITests.swift @@ -33,6 +33,200 @@ 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( + text(containing: "Remote kDrive files are not deleted", in: app).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 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) + + 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)) + } + + @MainActor + func testActivitiesPrefetchesOlderRowsAndReturnsToLatest() throws { + let app = launchSetupFixture(named: "activities-pagination") + openActivities(in: app) + + 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)) + + let olderEntry = app.buttons[ + "activity.entry.activity-00000000-0000-0000-0000-000000000076" + ] + for _ in 0..<14 where olderEntry.exists == false { + timeline.swipeUp() + } + + XCTAssertTrue(olderEntry.waitForExistence(timeout: 5)) + let backToLatest = app.buttons["activity.backToLatest"] + XCTAssertTrue(backToLatest.waitForExistence(timeout: 5)) + backToLatest.tap() + XCTAssertTrue(latestEntry.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 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( + text(containing: "Could not create support log", in: app).exists + ) + } + + @MainActor + func testActivitiesDisablesRefreshWhenDatabaseIsUnavailable() throws { + let app = launchSetupFixture(named: "activities-unavailable") + openActivities(in: app) + + 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) + 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( + text(containing: unavailableText, in: app) + .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 { + 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. @@ -40,4 +234,57 @@ final class potassiumProviderUITests: XCTestCase { XCUIApplication().launch() } } + + @MainActor + private func launchSetupFixture(named fixtureName: String = "setup-navigation") -> XCUIApplication { + let app = XCUIApplication() + app.launchEnvironment["POTASSIUM_UI_TEST_FIXTURE"] = fixtureName + app.launch() + return app + } + + @MainActor + private func openSetup(in app: XCUIApplication) { + openTab(named: "Setup", in: app) + } + + @MainActor + private func openActivities(in app: XCUIApplication) { + 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 tabButton = app.buttons[name] + XCTAssertTrue( + tabButton.waitForExistence(timeout: 5), + "The \(name) tab was not available." + ) + tabButton.tap() + } + #endif + } }