feat(settings): Advanced section with safeguards, fully-automatic sync

Full Local Sync and Clear Cache both need a real connection or a real
cache to be safe against — clearing while offline, or letting sync
think it should run with nothing to talk to, can leave the app with
no local copy and no way to rebuild one. Both are now disabled
(Toggle/button, with a tooltip explaining why) whenever the app isn't
effectively online (real network down OR the manual Offline Mode
toggle).

Clear Cache moves out of Offline & Sync entirely into a new Advanced
section (sidebar, above About) — the one deliberate escape hatch that
works even offline, gated behind an off-by-default "Enable Advanced
Options" master toggle that only turns on after a confirmation dialog
warning about data loss. A few not-yet-implemented settings sit under
it as permanently-dimmed "Coming Soon" rows. Nothing destructive is
reachable outside this one screen, so there's no path to breaking
Full Local Sync's cache by accident.

Full Local Sync itself is now fully automatic: RootView's background
task syncs immediately whenever it (re)starts — covers both "just
turned on" and "was already on at a fresh launch" — then every 20
minutes, and again immediately on reconnect. No more needing to press
Sync Now by hand.
This commit is contained in:
2026-08-15 00:58:24 +01:00
parent ce235d656d
commit d4d81bb848
3 changed files with 142 additions and 29 deletions
+123 -22
View File
@@ -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<Bool> {
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 {
+3 -1
View File
@@ -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"
}
}
+16 -6
View File
@@ -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()
}
}
}