New "Read Release Notes" entries in the Help menu (current installed version) and the "Check for Updates" alert (the new, not-yet-installed version) render a release's markdown notes in a Confab modal. - UpdateCheckService.fetchReleaseNotes(forTag:) fetches a release's title + body from Gitea's public releases-by-tag API, caching the result by version tag in the settings table (a published release's notes don't change, so no need to refetch on every view). - ReleaseNotesView reuses the existing MarkdownContentView renderer; shows a friendly "not available yet" state for versions with no published Gitea release (e.g. a dev build ahead of the last release). - ReleaseNotesRequest carries which version to show atomically via .sheet(item:), per this project's established sheet-timing pattern. - Removed the redundant "Release Page" button from the update alert now that notes show in-app; added an explicit .keyboardShortcut (.cancelAction) to its cancel button so Escape actually closes it — role: .cancel alone didn't do it, since NSAlert only auto-binds Escape to a button literally titled "Cancel".
197 lines
7.5 KiB
Swift
197 lines
7.5 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Confab
|
|
//
|
|
// Root navigation container — NavigationSplitView with collapsible sidebar
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab 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 Confab 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://confab.no>.
|
|
|
|
|
|
import SwiftUI
|
|
#if os(macOS)
|
|
import Darwin // uname, sysctlbyname
|
|
#endif
|
|
|
|
struct ContentView: View {
|
|
@Environment(ChatViewModel.self) var chatViewModel
|
|
private var updateService = UpdateCheckService.shared
|
|
@State private var columnVisibility: NavigationSplitViewVisibility =
|
|
SettingsService.shared.sidebarVisible ? .all : .detailOnly
|
|
@State private var showIntelWarning = false
|
|
|
|
var body: some View {
|
|
@Bindable var vm = chatViewModel
|
|
NavigationSplitView(columnVisibility: $columnVisibility) {
|
|
SidebarView()
|
|
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
|
|
} detail: {
|
|
ChatView(
|
|
onModelSelect: { chatViewModel.showModelSelector = true },
|
|
onProviderChange: { newProvider in
|
|
chatViewModel.changeProvider(newProvider)
|
|
}
|
|
)
|
|
}
|
|
.frame(minWidth: 860, minHeight: 560)
|
|
.onChange(of: columnVisibility) { _, newValue in
|
|
SettingsService.shared.sidebarVisible = newValue != .detailOnly
|
|
}
|
|
#if os(macOS)
|
|
.onAppear {
|
|
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
|
|
checkIntelWarning()
|
|
|
|
// Wire the real, environment-injected chatViewModel into the app delegate for Quit
|
|
// interception — `oAIApp.init()` used to do this by reading its own `@State`, but
|
|
// that returned a throwaway instance distinct from the one actually rendered here
|
|
// (confirmed via ObjectIdentifier logging). Deferred one run-loop tick via
|
|
// `DispatchQueue.main.async` so the modal alert reliably presents — calling it
|
|
// directly from `.onAppear` was tried before and silently failed to ever show it.
|
|
// Uses `AppDelegate.shared`, NOT `NSApplication.shared.delegate as? AppDelegate` —
|
|
// the latter always fails since `@NSApplicationDelegateAdaptor` registers an internal
|
|
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, a same-named-but-different
|
|
// type (confirmed via logging).
|
|
AppDelegate.shared?.chatViewModel = chatViewModel
|
|
DispatchQueue.main.async {
|
|
chatViewModel.checkForCrashRecoveryDraft()
|
|
}
|
|
}
|
|
.onKeyPress(.return, phases: .down) { press in
|
|
if press.modifiers.contains(.command) {
|
|
chatViewModel.sendMessage()
|
|
return .handled
|
|
}
|
|
return .ignored
|
|
}
|
|
#endif
|
|
.sheet(isPresented: $vm.showModelSelector) {
|
|
ModelSelectorView(
|
|
models: chatViewModel.availableModels,
|
|
selectedModel: chatViewModel.selectedModel,
|
|
onSelect: { model in
|
|
chatViewModel.selectModel(model)
|
|
chatViewModel.showModelSelector = false
|
|
}
|
|
)
|
|
.task {
|
|
if chatViewModel.availableModels.count <= 10 {
|
|
await chatViewModel.loadAvailableModels()
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $vm.showSettings, onDismiss: {
|
|
chatViewModel.syncFromSettings()
|
|
}) {
|
|
SettingsView(chatViewModel: chatViewModel)
|
|
}
|
|
.sheet(isPresented: $vm.showStats) {
|
|
StatsView(
|
|
stats: chatViewModel.sessionStats,
|
|
model: chatViewModel.selectedModel,
|
|
provider: chatViewModel.currentProvider
|
|
)
|
|
}
|
|
.sheet(isPresented: $vm.showHelp) {
|
|
HelpView()
|
|
}
|
|
.sheet(isPresented: $vm.showCredits) {
|
|
CreditsView(provider: chatViewModel.currentProvider)
|
|
}
|
|
.sheet(isPresented: $vm.showConversations) {
|
|
ConversationListView(
|
|
onLoad: { conversation in
|
|
chatViewModel.loadConversation(conversation)
|
|
},
|
|
onRename: { id, newName in
|
|
chatViewModel.didRenameConversation(id: id, newName: newName)
|
|
}
|
|
)
|
|
}
|
|
.sheet(item: $vm.modelInfoTarget) { model in
|
|
ModelInfoView(model: model)
|
|
}
|
|
.sheet(isPresented: $vm.showHistory) {
|
|
HistoryView(onSelect: { input in
|
|
chatViewModel.inputText = input
|
|
})
|
|
}
|
|
.sheet(item: $vm.releaseNotesRequest) { request in
|
|
ReleaseNotesView(request: request)
|
|
}
|
|
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
|
|
Button("Got It") {
|
|
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
|
|
}
|
|
} message: {
|
|
Text("Confab (formerly oAI) v2.4 was the last version to support Intel Macs and Rosetta. Starting with macOS 28, Confab will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
|
|
}
|
|
.alert("Software Update", isPresented: Binding(
|
|
get: { updateService.manualCheckMessage != nil },
|
|
set: { if !$0 { updateService.manualCheckMessage = nil } }
|
|
)) {
|
|
if updateService.updateAvailable {
|
|
if let url = updateService.downloadURL {
|
|
Button("Download v\(updateService.latestVersion ?? "")") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
if let latest = updateService.latestVersion {
|
|
Button("Read Release Notes") {
|
|
vm.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(latest)", isCurrentlyInstalled: false)
|
|
}
|
|
}
|
|
Button("Later", role: .cancel) { }
|
|
.keyboardShortcut(.cancelAction)
|
|
} else {
|
|
Button("OK", role: .cancel) { }
|
|
.keyboardShortcut(.cancelAction)
|
|
}
|
|
} message: {
|
|
Text(updateService.manualCheckMessage ?? "")
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func checkIntelWarning() {
|
|
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
|
|
guard isIntelNative || isRosetta else { return }
|
|
showIntelWarning = true
|
|
}
|
|
|
|
private var isIntelNative: Bool {
|
|
var systemInfo = utsname()
|
|
uname(&systemInfo)
|
|
let machine = withUnsafeBytes(of: &systemInfo.machine) {
|
|
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
|
|
}
|
|
return machine.contains("x86_64")
|
|
}
|
|
|
|
private var isRosetta: Bool {
|
|
var ret: Int32 = 0
|
|
var size = MemoryLayout<Int32>.size
|
|
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
|
|
return ret == 1
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
.environment(ChatViewModel())
|
|
}
|