Files
rune e2284aba2b Show manual sync-conflict fix instructions in-app instead of deep-linking to Help
NSWorkspace.shared.open() silently drops #fragment anchors on file://
URLs, so "Fix It Myself" always landed on the Help Book index instead
of the relevant section. Replaced with GitSyncManualFixSheet, an
in-app sheet showing the real conflicting filenames and sync path.

Also indent conversation rows one level deeper than their containing
folder in the sidebar and conversation list, so nesting is visible on
the conversations themselves and not just the folder headers.
2026-08-04 12:21:15 +02:00

241 lines
11 KiB
Swift

//
// oAIApp.swift
// Confab
//
// Main app entry point
//
// 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 AppKit
/// Intercepts Quit so an unsaved conversation gets the standard "Do you want to save?" prompt
/// (via `ChatViewModel.confirmDiscardIfNeeded`) with a real chance to cancel the quit.
final class AppDelegate: NSObject, NSApplicationDelegate {
// `NSApplication.shared.delegate as? AppDelegate` (formerly used in ContentView.onAppear to
// wire `chatViewModel`) ALWAYS fails: `@NSApplicationDelegateAdaptor` registers an internal
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, which forwards protocol methods
// (like `applicationShouldTerminate`) to this instance — but querying `NSApp.delegate` returns
// that SwiftUI wrapper, a same-named-but-different type, not this class. Confirmed via logging
// ("delegate is Optional(<SwiftUI.AppDelegate: ...>)"). Track our own instance directly instead.
static var shared: AppDelegate?
var chatViewModel: ChatViewModel?
override init() {
super.init()
AppDelegate.shared = self
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let chatViewModel, chatViewModel.hasUnsavedChanges else { return .terminateNow }
var shouldTerminate = false
chatViewModel.confirmDiscardIfNeeded(
then: { shouldTerminate = true },
onCancel: { shouldTerminate = false }
)
return shouldTerminate ? .terminateNow : .terminateCancel
}
// `chatViewModel` is wired from `ContentView.onAppear`, not from `oAIApp.init()` — reading
// the `@State private var chatViewModel` there returns a throwaway instance distinct from
// the one SwiftUI actually renders (confirmed via ObjectIdentifier logging: two different
// addresses), which silently broke crash-recovery restore. `ContentView.onAppear` reads the
// same environment-injected instance the view tree observes, so it's guaranteed correct.
}
#endif
@main
struct oAIApp: App {
@State private var chatViewModel = ChatViewModel()
@State private var showAbout = false
#if os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
init() {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import)
Task {
await GitSyncService.shared.syncOnStartup()
}
// Reconcile starred models with the iCloud copy (cross-machine favorites sync)
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
// Check for updates in the background
UpdateCheckService.shared.checkForUpdates()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(chatViewModel)
.preferredColorScheme(.dark)
.sheet(isPresented: $showAbout) {
AboutView()
}
#if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
Task {
await BackupService.shared.syncFavoritesOnLaunch()
}
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
}
#endif
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
.defaultSize(width: 1024, height: 800)
.windowResizability(.contentMinSize)
.commands {
// ── Apple menu ────────────────────────────────────────────────
CommandGroup(replacing: .appInfo) {
Button("About Confab") { showAbout = true }
}
CommandGroup(replacing: .appSettings) {
Button("Settings…") { chatViewModel.showSettings = true }
.keyboardShortcut(",", modifiers: .command)
}
// ── File menu ─────────────────────────────────────────────────
// Replacing .newItem removes the auto-added "New Window" entry
CommandGroup(replacing: .newItem) {
Button("New Chat") { chatViewModel.newConversation() }
.keyboardShortcut("n", modifiers: .command)
Button("Clear Chat") { chatViewModel.clearChat() }
.keyboardShortcut("k", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
CommandGroup(after: .newItem) {
Button("Open Chat…") { chatViewModel.showConversations = true }
.keyboardShortcut("o", modifiers: .command)
Button("Search Conversations") { chatViewModel.showConversations = true }
.keyboardShortcut("l", modifiers: .command)
}
CommandGroup(replacing: .saveItem) {
Button("Save Chat") { chatViewModel.saveFromMenu() }
.keyboardShortcut("s", modifiers: .command)
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Save Chat As…") { chatViewModel.saveAsFromMenu() }
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Stats") { chatViewModel.showStats = true }
.keyboardShortcut("s", modifiers: [.command, .shift])
}
CommandGroup(after: .importExport) {
Button("Export as Markdown…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "md", defaultFilename: "\(safe).md")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as HTML…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "html", defaultFilename: "\(safe).html")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as PDF…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversationWithSavePanel(format: "pdf", defaultFilename: "\(safe).pdf")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
}
// ── Chat menu ─────────────────────────────────────────────────
// Named "Chat" (not "View") to avoid colliding with the "View" menu
// macOS auto-adds for NavigationSplitView (Enter Full Screen, etc.) —
// two menus both titled "View" would otherwise appear in the menu bar.
CommandMenu("Chat") {
Button("Select Model") { chatViewModel.showModelSelector = true }
.keyboardShortcut("m", modifiers: .command)
Button("Model Info") {
chatViewModel.modelInfoTarget = chatViewModel.selectedModel
}
.keyboardShortcut("i", modifiers: .command)
.disabled(chatViewModel.selectedModel == nil)
Divider()
Button("Command History") { chatViewModel.showHistory = true }
.keyboardShortcut("h", modifiers: [.command, .shift])
Button("Credits") { chatViewModel.showCredits = true }
Divider()
Button(chatViewModel.onlineMode ? "Online Mode: On" : "Online Mode: Off") {
chatViewModel.onlineMode.toggle()
}
.keyboardShortcut("o", modifiers: [.command, .shift])
}
// ── Help menu ─────────────────────────────────────────────────
CommandGroup(replacing: .help) {
Button("Confab Help") { Self.openHelpBook() }
.keyboardShortcut("?", modifiers: .command)
Divider()
Button("Read Release Notes") {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
chatViewModel.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(current)", isCurrentlyInstalled: true)
}
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}
.disabled(UpdateCheckService.shared.isCheckingManually)
}
}
#endif
}
#if os(macOS)
/// Opens the Help Book's index.html directly in the default browser rather than through
/// NSHelpManager/Help Viewer — see CLAUDE.md's macOS 27 beta note for why (Apple's Tips.app
/// replacement for Help Viewer can't resolve anchors on this beta). Revisit once macOS 27
/// reaches RC. No anchor/fragment support: NSWorkspace.shared.open() silently drops #fragments
/// for file:// URLs before handing off to the browser (confirmed via location.hash coming back
/// empty in the opened page) — deep links into a specific section aren't reliable through this
/// API, so callers needing to point at specific content should show it in-app instead (see
/// GitSyncManualFixSheet for an example) rather than trying to anchor into this Help Book.
nonisolated static func openHelpBook() {
guard let helpBookURL = Bundle.main.url(forResource: "Confab.help", withExtension: nil) else { return }
let url = helpBookURL.appendingPathComponent("Contents/Resources/en.lproj/index.html")
NSWorkspace.shared.open(url)
}
#endif
}