diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6a9df19..07fac73 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -14,7 +14,9 @@ struct SettingsView: View { @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @State private var isShowingLogoutConfirmation = false + @State private var isShowingAdvancedWarning = false @State private var storageSummary: CacheStorageSummary? @State private var pendingOperations: [PendingOperationSummary] = [] @State private var isSyncing = false @@ -22,6 +24,16 @@ struct SettingsView: View { @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? + /// Full Local Sync and cache-clearing both need a real connection to be + /// safe — clearing while offline (or letting Full Local Sync think it + /// should be running) can leave the app with nothing local to show and + /// no way to refetch it. "Offline" here means either a real dropped + /// connection or the user's own manual toggle — both leave the app with + /// no server to talk to. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { ScrollView { sectionDetail @@ -40,6 +52,7 @@ struct SettingsView: View { case .appearance: appearanceDetail case .account: accountDetail case .offlineSync: offlineSyncDetail + case .advanced: advancedDetail case .about: aboutDetail } } @@ -103,14 +116,18 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) - .onChange(of: isFullLocalSyncEnabled) { _, enabled in - if enabled { Task { await runFullSync() } } - } - Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") + .disabled(!isEffectivelyOnline) + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.") .font(.caption) .foregroundStyle(.secondary) + if !isEffectivelyOnline { + Text("Requires an internet connection to turn on or off.") + .font(.caption2) + .foregroundStyle(.orange) + } } .frame(maxWidth: 480, alignment: .leading) + .help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.") if isFullLocalSyncEnabled { VStack(alignment: .leading, spacing: 6) { @@ -123,6 +140,7 @@ struct SettingsView: View { } else { Button("Sync Now") { Task { await runFullSync() } } .controlSize(.small) + .disabled(!isEffectivelyOnline) if let lastFullSyncSummary { Text(fullSyncSummaryText(lastFullSyncSummary)) .font(.caption) @@ -156,24 +174,8 @@ struct SettingsView: View { private var storageRow: some View { VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Cache Storage") - .font(.subheadline.weight(.medium)) - Spacer() - Button(role: .destructive) { - Task { await clearCache() } - } label: { - if isClearingCache { - ProgressView().controlSize(.small) - } else { - Text("Clear Cache") - } - } - .buttonStyle(.plain) - .foregroundStyle(.red) - .font(.caption) - .disabled(isClearingCache || (storageSummary?.itemCount ?? 0) == 0) - } + Text("Cache Storage") + .font(.subheadline.weight(.medium)) if let storageSummary { Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") .font(.caption) @@ -183,6 +185,9 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Text("Clearing the cache is in Advanced Options.") + .font(.caption2) + .foregroundStyle(.secondary) } } @@ -248,6 +253,102 @@ struct SettingsView: View { } } + // MARK: - Advanced + + /// Everything here either does something destructive (clearing the + /// cache Full Local Sync just spent minutes building) or doesn't exist + /// yet — gating all of it behind an off-by-default master toggle plus a + /// confirmation to turn that toggle on is the safeguard: nothing here + /// can be reached by accident, and nothing outside this section can + /// touch the cache at all, so there's no path to "messed up Full Local + /// Sync" that doesn't go through this screen on purpose. + private var advancedDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + VStack(alignment: .leading, spacing: 6) { + Toggle("Enable Advanced Options", isOn: advancedOptionsBinding) + Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 12) { + comingSoonRow("Export All Data") + comingSoonRow("Developer Diagnostics") + comingSoonRow("Reset Local Database") + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Clear All Cache") + Spacer() + Button(role: .destructive) { + Task { await clearCache() } + } label: { + if isClearingCache { + ProgressView().controlSize(.small) + } else { + Text("Clear") + } + } + } + Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .disabled(!isAdvancedOptionsEnabled || isClearingCache) + .opacity(isAdvancedOptionsEnabled ? 1 : 0.4) + .frame(maxWidth: 480, alignment: .leading) + } + .confirmationDialog( + "Enable Advanced Options?", + isPresented: $isShowingAdvancedWarning, + titleVisibility: .visible + ) { + Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true } + Button("Cancel", role: .cancel) {} + } message: { + Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.") + } + } + + /// Never writes `true` directly — turning the toggle on only opens the + /// warning dialog; only that dialog's own "Enable" button actually sets + /// it. Turning off doesn't need confirmation. + private var advancedOptionsBinding: Binding { + Binding( + get: { isAdvancedOptionsEnabled }, + set: { newValue in + if newValue { + isShowingAdvancedWarning = true + } else { + isAdvancedOptionsEnabled = false + } + } + ) + } + + private func comingSoonRow(_ title: String) -> some View { + HStack { + Text(title) + Spacer() + Text("Coming Soon") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(true) + .opacity(0.5) + } + // MARK: - About private var aboutDetail: some View { diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 8d31750..6804aef 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -1,7 +1,7 @@ import Observation enum SettingsSection: String, CaseIterable, Identifiable, Hashable { - case appearance, account, offlineSync, about + case appearance, account, offlineSync, advanced, about var id: String { rawValue } @@ -10,6 +10,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "Appearance" case .account: return "Account" case .offlineSync: return "Offline & Sync" + case .advanced: return "Advanced" case .about: return "About" } } @@ -19,6 +20,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "paintbrush" case .account: return "person.crop.circle" case .offlineSync: return "arrow.triangle.2.circlepath" + case .advanced: return "wrench.and.screwdriver" case .about: return "info.circle" } } diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index ab32307..4df0d61 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -35,20 +35,30 @@ struct RootView: View { } // Replay whatever queued up while offline the moment the network's // back — no need to wait for the user to open Settings and hit Retry. + // Also catches Full Local Sync back up immediately on reconnect, + // rather than leaving it to wait out the rest of the periodic loop + // below. .task(id: session.networkMonitor.isOnline) { guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } _ = await cachingClient.flushPendingOperations() + if isFullLocalSyncEnabled { + _ = await cachingClient.performFullSync() + } } - // Full Local Sync re-walks the whole workspace periodically while - // enabled, in addition to the immediate sync Settings kicks off when - // the toggle is first switched on — keeps a long-running session from - // slowly drifting stale. + // Fully automatic — this is the only place Full Local Sync actually + // runs from (Settings' "Sync Now" is just an on-demand nudge at the + // same call). Syncs immediately whenever this task (re)starts, which + // covers both "just switched on" and "was already on at launch" — + // `.task(id:)` restarts on either, an `@AppStorage`-backed toggle + // changing anywhere updates every view reading that key — then every + // 20 minutes after, for as long as it stays enabled. .task(id: isFullLocalSyncEnabled) { guard isFullLocalSyncEnabled else { return } while !Task.isCancelled { + if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { + _ = await cachingClient.performFullSync() + } try? await Task.sleep(for: .seconds(1200)) - guard !Task.isCancelled, session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { continue } - _ = await cachingClient.performFullSync() } } }