272 lines
8.3 KiB
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)
|
|
}
|