22f745762f
The save indicator was sitting in the bottom-right corner of the footer. Moved it to the center of the header bar, where macOS apps conventionally show the document/conversation title. An orange dot appears when there are unsaved changes; clicking saves. Removed the indicator from the footer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
272 lines
9.4 KiB
Swift
272 lines
9.4 KiB
Swift
//
|
|
// HeaderView.swift
|
|
// oAI
|
|
//
|
|
// Slim header — provider, model name, star only.
|
|
// Status pills and stats live in SidebarView and FooterView respectively.
|
|
//
|
|
// 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 HeaderView: View {
|
|
let provider: Settings.Provider
|
|
let model: ModelInfo?
|
|
let onModelSelect: () -> Void
|
|
let onProviderChange: (Settings.Provider) -> Void
|
|
var conversationName: String? = nil
|
|
var hasUnsavedChanges: Bool = false
|
|
var onQuickSave: (() -> Void)? = nil
|
|
private let settings = SettingsService.shared
|
|
private let registry = ProviderRegistry.shared
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Left: provider + model + star
|
|
HStack(spacing: 12) {
|
|
providerMenu
|
|
modelButton
|
|
starButton
|
|
Spacer()
|
|
}
|
|
|
|
// Center: conversation title (macOS document-title style)
|
|
conversationTitle
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(.ultraThinMaterial)
|
|
.overlay(
|
|
Rectangle()
|
|
.fill(Color.oaiBorder.opacity(0.5))
|
|
.frame(height: 1),
|
|
alignment: .bottom
|
|
)
|
|
}
|
|
|
|
// MARK: - Conversation title (center)
|
|
|
|
@ViewBuilder
|
|
private var conversationTitle: some View {
|
|
if let name = conversationName {
|
|
Button(action: { if hasUnsavedChanges { onQuickSave?() } }) {
|
|
HStack(spacing: 5) {
|
|
if hasUnsavedChanges {
|
|
Circle()
|
|
.fill(Color.orange)
|
|
.frame(width: 6, height: 6)
|
|
}
|
|
Text(name)
|
|
.font(.system(size: settings.guiTextSize - 1, weight: .medium))
|
|
.foregroundColor(.oaiPrimary)
|
|
.lineLimit(1)
|
|
.frame(maxWidth: 300)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(!hasUnsavedChanges)
|
|
.help(hasUnsavedChanges ? "Unsaved changes — click to save" : "Saved")
|
|
.animation(.easeInOut(duration: 0.2), value: hasUnsavedChanges)
|
|
.animation(.easeInOut(duration: 0.2), value: name)
|
|
}
|
|
}
|
|
|
|
// MARK: - Subviews (extracted so ZStack stays readable)
|
|
|
|
private var providerMenu: some View {
|
|
Menu {
|
|
ForEach(registry.configuredProviders, id: \.self) { p in
|
|
Button {
|
|
onProviderChange(p)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: p.iconName)
|
|
Text(p.displayName)
|
|
if p == provider { Image(systemName: "checkmark") }
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: provider.iconName)
|
|
.font(.system(size: settings.guiTextSize - 2))
|
|
Text(provider.displayName)
|
|
.font(.system(size: settings.guiTextSize - 2, weight: .medium))
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 8))
|
|
.opacity(0.7)
|
|
}
|
|
.foregroundColor(.white)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.providerColor(provider))
|
|
.cornerRadius(4)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
.help("Switch provider")
|
|
}
|
|
|
|
private var modelButton: some View {
|
|
Button(action: onModelSelect) {
|
|
if let model = model {
|
|
HStack(spacing: 6) {
|
|
Text(model.name)
|
|
.font(.system(size: settings.guiTextSize, weight: .medium))
|
|
.foregroundColor(.oaiPrimary)
|
|
HStack(spacing: 3) {
|
|
if model.capabilities.vision {
|
|
Image(systemName: "eye").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
|
}
|
|
if model.capabilities.tools {
|
|
Image(systemName: "wrench").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
|
}
|
|
if model.capabilities.online {
|
|
Image(systemName: "globe").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
|
}
|
|
if model.capabilities.imageGeneration {
|
|
Image(systemName: "paintbrush").font(.system(size: 9)).foregroundColor(.oaiSecondary)
|
|
}
|
|
}
|
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
|
}
|
|
} else {
|
|
HStack(spacing: 4) {
|
|
Text("No model selected")
|
|
.font(.system(size: settings.guiTextSize))
|
|
.foregroundColor(.oaiSecondary)
|
|
Image(systemName: "chevron.down").font(.caption2).foregroundColor(.oaiSecondary)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Select model")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var starButton: some View {
|
|
if let model = model {
|
|
let isFav = settings.favoriteModelIds.contains(model.id)
|
|
Button(action: { settings.toggleFavoriteModel(model.id) }) {
|
|
Image(systemName: isFav ? "star.fill" : "star")
|
|
.font(.system(size: settings.guiTextSize - 3))
|
|
.foregroundColor(isFav ? .yellow : .oaiSecondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(isFav ? "Remove from favorites" : "Add to favorites")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Status Pills (used by SidebarView)
|
|
|
|
struct StatusPill: View {
|
|
let icon: String
|
|
let label: LocalizedStringKey
|
|
let color: Color
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 6, height: 6)
|
|
Text(label)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(.oaiSecondary)
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(color.opacity(0.1), in: Capsule())
|
|
}
|
|
}
|
|
|
|
struct SyncStatusPill: View {
|
|
private let gitSync = GitSyncService.shared
|
|
|
|
private enum SyncPillState { case notConfigured, syncing, error(String), synced(Date?) }
|
|
@State private var syncState: SyncPillState = .notConfigured
|
|
@State private var syncColor: Color = .secondary
|
|
|
|
private var syncLabel: LocalizedStringKey {
|
|
switch syncState {
|
|
case .notConfigured: return "Sync"
|
|
case .syncing: return "Syncing"
|
|
case .error: return "Error"
|
|
case .synced: return "Synced"
|
|
}
|
|
}
|
|
|
|
private var tooltipText: LocalizedStringKey {
|
|
switch syncState {
|
|
case .notConfigured: return "Sync not configured"
|
|
case .syncing: return "Syncing..."
|
|
case .error(let msg): return "Sync failed: \(msg)"
|
|
case .synced(let date):
|
|
guard let date else { return "Synced" }
|
|
let rel = RelativeDateTimeFormatter().localizedString(for: date, relativeTo: .now)
|
|
return "Last synced: \(rel)"
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 3) {
|
|
Circle()
|
|
.fill(syncColor)
|
|
.frame(width: 6, height: 6)
|
|
Text(syncLabel)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundColor(.oaiSecondary)
|
|
}
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(syncColor.opacity(0.1), in: Capsule())
|
|
.help(tooltipText)
|
|
.onAppear { updateState() }
|
|
.onChange(of: gitSync.syncStatus) { updateState() }
|
|
.onChange(of: gitSync.isSyncing) { updateState() }
|
|
.onChange(of: gitSync.lastSyncError) { updateState() }
|
|
}
|
|
|
|
private func updateState() {
|
|
if let error = gitSync.lastSyncError {
|
|
syncState = .error(error); syncColor = .red
|
|
} else if gitSync.isSyncing {
|
|
syncState = .syncing; syncColor = .orange
|
|
} else if gitSync.syncStatus.isCloned {
|
|
syncState = .synced(gitSync.syncStatus.lastSyncTime); syncColor = .green
|
|
} else {
|
|
syncState = .notConfigured; syncColor = .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
VStack {
|
|
HeaderView(
|
|
provider: .openrouter,
|
|
model: ModelInfo.mockModels.first,
|
|
onModelSelect: {},
|
|
onProviderChange: { _ in }
|
|
)
|
|
Spacer()
|
|
}
|
|
.background(Color.oaiBackground)
|
|
}
|