// // SyncStatusIndicator.swift // oAI // // Git sync status indicator (bottom-right corner) // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import SwiftUI enum SyncState { case disabled // Gray - sync not configured or disabled case synced // Green - successfully synced case syncing // Yellow - sync in progress case error(String) // Red - sync failed with error message var color: Color { switch self { case .disabled: return .secondary case .synced: return .green case .syncing: return .orange case .error: return .red } } var icon: String { switch self { case .disabled: return "arrow.triangle.2.circlepath" case .synced: return "checkmark.circle.fill" case .syncing: return "arrow.triangle.2.circlepath" case .error: return "exclamationmark.triangle.fill" } } var tooltipText: String { switch self { case .disabled: return "Auto-sync disabled" case .synced: if let lastSync = GitSyncService.shared.syncStatus.lastSyncTime { return "Last synced: \(timeAgo(lastSync))" } else { return "Synced" } case .syncing: return "Syncing..." case .error(let message): return "Sync failed: \(message)" } } private func timeAgo(_ date: Date) -> String { let seconds = Int(Date().timeIntervalSince(date)) if seconds < 60 { return "just now" } else if seconds < 3600 { let minutes = seconds / 60 return "\(minutes)m ago" } else if seconds < 86400 { let hours = seconds / 3600 return "\(hours)h ago" } else { let days = seconds / 86400 return "\(days)d ago" } } } struct SyncStatusIndicator: View { @State private var isHovering = false @State private var syncState: SyncState = .disabled @State private var showSettings = false private let settings = SettingsService.shared private let gitSync = GitSyncService.shared var body: some View { VStack { Spacer() HStack { Spacer() // Floating indicator ZStack { // Background circle Circle() .fill(Color(nsColor: .windowBackgroundColor)) .shadow(color: .black.opacity(0.2), radius: 4, x: 0, y: 2) .frame(width: 40, height: 40) // Icon statusIcon } .scaleEffect(isHovering ? 1.1 : 1.0) .animation(.spring(response: 0.3), value: isHovering) .onHover { hovering in isHovering = hovering } .help(syncState.tooltipText) .onTapGesture { if case .error = syncState { // Open settings on error showSettings = true } } .sheet(isPresented: $showSettings) { SettingsView() } .padding(.trailing, 16) .padding(.bottom, 16) } } .onAppear { updateState() } .onChange(of: gitSync.syncStatus) { updateState() } .onChange(of: gitSync.isSyncing) { updateState() } .onChange(of: gitSync.lastSyncError) { updateState() } .onChange(of: settings.syncEnabled) { updateState() } .onChange(of: settings.syncAutoSave) { updateState() } } private var statusIcon: some View { Image(systemName: syncState.icon) .font(.system(size: 18)) .foregroundStyle(syncState.color) } private func updateState() { // Determine current sync state guard settings.syncEnabled && settings.syncConfigured else { syncState = .disabled return } guard gitSync.syncStatus.isCloned else { syncState = .disabled return } // Check for error if let error = gitSync.lastSyncError { syncState = .error(error) return } // Check if currently syncing if gitSync.isSyncing { syncState = .syncing return } // All good syncState = .synced } } #Preview { SyncStatusIndicator() }