Files
oai-swift/oAI/Views/Main/FooterView.swift

272 lines
8.3 KiB
Swift

//
// FooterView.swift
// oAI
//
// Footer bar with session summary
//
// 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 <https://www.gnu.org/licenses/>.
import SwiftUI
struct FooterView: View {
let stats: SessionStats
let conversationName: String?
let hasUnsavedChanges: Bool
let onQuickSave: (() -> Void)?
init(stats: SessionStats,
conversationName: String? = nil,
hasUnsavedChanges: Bool = false,
onQuickSave: (() -> Void)? = nil) {
self.stats = stats
self.conversationName = conversationName
self.hasUnsavedChanges = hasUnsavedChanges
self.onQuickSave = onQuickSave
}
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()
// Save indicator (only when chat has messages)
if stats.messageCount > 0 {
SaveIndicator(
conversationName: conversationName,
hasUnsavedChanges: hasUnsavedChanges,
onSave: onQuickSave
)
}
// Shortcuts hint
#if os(macOS)
Text("⌘N New • ⌘M Model • ⌘⇧S Save")
.font(.caption2)
.foregroundColor(.oaiSecondary)
#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: String {
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: String
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
@State private var syncText = "Not Synced"
@State private var syncColor: Color = .secondary
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 let error = gitSync.lastSyncError {
syncText = "Sync Error"
syncColor = .red
} else if gitSync.isSyncing {
syncText = "Syncing..."
syncColor = .orange
} else if let lastSync = gitSync.syncStatus.lastSyncTime {
syncText = "Last Sync: \(timeAgo(lastSync))"
syncColor = .green
} else if gitSync.syncStatus.isCloned {
syncText = "Sync: Ready"
syncColor = .secondary
} else if settings.syncConfigured {
syncText = "Sync: Not Initialized"
syncColor = .orange
} else {
syncText = "Sync: Off"
syncColor = .secondary
}
}
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"
}
}
}
#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)
}