Files
oai-swift/oAI/Views/Main/FooterView.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

284 lines
9.3 KiB
Swift

//
// FooterView.swift
// oAI
//
// Footer bar with session summary
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import SwiftUI
struct FooterView: View {
let stats: SessionStats
let conversationName: String?
let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)?
let onlineMode: Bool
let mcpEnabled: Bool
private let settings = SettingsService.shared
init(stats: SessionStats,
conversationName: String? = nil,
hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil,
onlineMode: Bool = false,
mcpEnabled: Bool = false) {
self.stats = stats
self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave
self.onlineMode = onlineMode
self.mcpEnabled = mcpEnabled
}
var body: some View {
HStack(spacing: 20) {
// Session summary
HStack(spacing: 16) {
FooterItem(
icon: "message",
label: "Messages:",
value: "\(stats.messageCount)"
)
FooterItem(
icon: "chart.bar.xaxis",
label: "Tokens:",
value: "\(stats.totalTokens) (\(stats.totalInputTokens) in, \(stats.totalOutputTokens) out)"
)
FooterItem(
icon: "dollarsign.circle",
label: "Cost:",
value: stats.totalCostDisplay
)
// Git sync status (if enabled)
if SettingsService.shared.syncEnabled && SettingsService.shared.syncAutoSave {
SyncStatusFooter()
}
}
Spacer()
// Status pills — Online, MCP, Sync
#if os(macOS)
HStack(spacing: 6) {
if onlineMode {
StatusPill(icon: "globe", label: "Online", color: .green)
}
if mcpEnabled {
StatusPill(icon: "folder", label: "MCP", color: .blue)
}
if settings.syncEnabled && settings.syncAutoSave {
SyncStatusPill()
}
}
#endif
// Update available badge (shows only when an update exists — no version number)
#if os(macOS)
UpdateBadge()
#endif
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Color.oaiBorder.opacity(0.5))
.frame(height: 1),
alignment: .top
)
}
}
struct SaveIndicator: View {
let conversationName: String?
let hasUnsavedChanges: Bool
let onSave: (() -> Void)?
private let guiSize = SettingsService.shared.guiTextSize
private var isSaved: Bool { conversationName != nil }
private var isModified: Bool { isSaved && hasUnsavedChanges }
private var isUnsaved: Bool { !isSaved }
private var label: String {
if let name = conversationName {
return name.count > 20 ? String(name.prefix(20)) + "…" : name
}
return "Unsaved"
}
private var icon: String {
if isModified { return "circle.fill" }
if isSaved { return "checkmark.circle.fill" }
return "exclamationmark.circle"
}
private var color: Color {
if isModified { return .orange }
if isSaved { return .green }
return .secondary
}
private var tooltip: LocalizedStringKey {
if isModified { return "Click to re-save \"\(conversationName ?? "")\"" }
if isSaved { return "Saved — no changes" }
return "Not saved — use /save <name>"
}
var body: some View {
Button(action: { if isModified { onSave?() } }) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.system(size: guiSize - 3))
.foregroundColor(color)
Text(label)
.font(.system(size: guiSize - 2))
.foregroundColor(isUnsaved ? .secondary : .oaiPrimary)
}
}
.buttonStyle(.plain)
.help(tooltip)
.disabled(!isModified)
.opacity(isUnsaved ? 0.6 : 1.0)
.animation(.easeInOut(duration: 0.2), value: conversationName)
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
}
}
struct FooterItem: View {
let icon: String
let label: LocalizedStringKey
let value: String
private let guiSize = SettingsService.shared.guiTextSize
var body: some View {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(label)
.font(.system(size: guiSize - 2))
.foregroundColor(.oaiSecondary)
Text(value)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(.oaiPrimary)
}
}
}
struct SyncStatusFooter: View {
private let gitSync = GitSyncService.shared
private let settings = SettingsService.shared
private let guiSize = SettingsService.shared.guiTextSize
private enum SyncState { case off, notInitialized, ready, syncing, error, synced(Date) }
@State private var syncState: SyncState = .off
@State private var syncColor: Color = .secondary
private var syncText: LocalizedStringKey {
switch syncState {
case .off: return "Sync: Off"
case .notInitialized: return "Sync: Not Initialized"
case .ready: return "Sync: Ready"
case .syncing: return "Syncing..."
case .error: return "Sync Error"
case .synced(let date):
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
return "Last Sync: \(rel)"
}
}
var body: some View {
HStack(spacing: 6) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: guiSize - 2))
.foregroundColor(syncColor)
Text(syncText)
.font(.system(size: guiSize - 2, weight: .medium))
.foregroundColor(syncColor)
}
.onAppear { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.lastSyncTime) { updateSyncStatus() }
.onChange(of: gitSync.syncStatus.isCloned) { updateSyncStatus() }
.onChange(of: gitSync.lastSyncError) { updateSyncStatus() }
.onChange(of: gitSync.isSyncing) { updateSyncStatus() }
.onChange(of: settings.syncConfigured) { updateSyncStatus() }
}
private func updateSyncStatus() {
if gitSync.lastSyncError != nil {
syncState = .error; syncColor = .red
} else if gitSync.isSyncing {
syncState = .syncing; syncColor = .orange
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
syncState = .synced(lastSync); syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncState = .ready; syncColor = .secondary
} else if settings.syncConfigured {
syncState = .notInitialized; syncColor = .orange
} else {
syncState = .off; syncColor = .secondary
}
}
}
struct UpdateBadge: View {
private let updater = UpdateCheckService.shared
var body: some View {
if updater.updateAvailable {
Button(action: { updater.openReleasesPage() }) {
HStack(spacing: 4) {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 10))
Text("Update Available\(updater.latestVersion.map { " (v\($0))" } ?? "")")
.font(.caption2)
.fontWeight(.medium)
}
.foregroundColor(.green)
}
.buttonStyle(.plain)
.help("A new version is available — click to open the releases page")
}
}
}
#Preview {
let stats = SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
)
return VStack(spacing: 0) {
Spacer()
FooterView(stats: stats, conversationName: nil, hasUnsavedChanges: true)
FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: true)
FooterView(stats: stats, conversationName: "My Project", hasUnsavedChanges: false)
}
.background(Color.oaiBackground)
}