diff --git a/Neon Vision Editor.xcodeproj/project.pbxproj b/Neon Vision Editor.xcodeproj/project.pbxproj index 7e13dff..df3832c 100644 --- a/Neon Vision Editor.xcodeproj/project.pbxproj +++ b/Neon Vision Editor.xcodeproj/project.pbxproj @@ -358,7 +358,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 322; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; @@ -439,7 +439,7 @@ CODE_SIGNING_ALLOWED = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 322; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = CS727NF72U; ENABLE_APP_SANDBOX = YES; diff --git a/Neon Vision Editor/UI/ContentView.swift b/Neon Vision Editor/UI/ContentView.swift index dd614c0..a9a09be 100644 --- a/Neon Vision Editor/UI/ContentView.swift +++ b/Neon Vision Editor/UI/ContentView.swift @@ -16,6 +16,73 @@ import UIKit import FoundationModels #endif +#if os(macOS) +private final class WindowCloseConfirmationDelegate: NSObject, NSWindowDelegate { + weak var forwardedDelegate: NSWindowDelegate? + var shouldConfirm: (() -> Bool)? + var hasDirtyTabs: (() -> Bool)? + var saveAllDirtyTabs: (() -> Bool)? + var dialogTitle: (() -> String)? + var dialogMessage: (() -> String)? + + private var isPromptInFlight = false + private var allowNextClose = false + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || (forwardedDelegate?.responds(to: selector) ?? false) + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + if forwardedDelegate?.responds(to: selector) == true { + return forwardedDelegate + } + return super.forwardingTarget(for: selector) + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if allowNextClose { + allowNextClose = false + return forwardedDelegate?.windowShouldClose?(sender) ?? true + } + + let needsPrompt = shouldConfirm?() == true && hasDirtyTabs?() == true + if !needsPrompt { + return forwardedDelegate?.windowShouldClose?(sender) ?? true + } + + if isPromptInFlight { + return false + } + isPromptInFlight = true + + let alert = NSAlert() + alert.messageText = dialogTitle?() ?? "Save changes before closing?" + alert.informativeText = dialogMessage?() ?? "One or more tabs have unsaved changes." + alert.alertStyle = .warning + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Don't Save") + alert.addButton(withTitle: "Cancel") + alert.beginSheetModal(for: sender) { [weak self] response in + guard let self else { return } + self.isPromptInFlight = false + switch response { + case .alertFirstButtonReturn: + if self.saveAllDirtyTabs?() == true { + self.allowNextClose = true + sender.performClose(nil) + } + case .alertSecondButtonReturn: + self.allowNextClose = true + sender.performClose(nil) + default: + break + } + } + return false + } +} +#endif + // Utility: quick width calculation for strings with a given font (AppKit-based) extension String { @@ -169,6 +236,7 @@ struct ContentView: View { #if os(macOS) @State private var hostWindowNumber: Int? = nil @AppStorage("ShowBracketHelperBarMac") var showBracketHelperBarMac: Bool = false + @State private var windowCloseConfirmationDelegate: WindowCloseConfirmationDelegate? = nil #endif @State private var showLanguageSetupPrompt: Bool = false @State private var languagePromptSelection: String = "plain" @@ -1027,11 +1095,61 @@ struct ContentView: View { WindowViewModelRegistry.shared.unregister(windowNumber: old) } hostWindowNumber = number + installWindowCloseConfirmationDelegate(window) if let number { WindowViewModelRegistry.shared.register(viewModel, for: number) } } + private func saveAllDirtyTabsForWindowClose() -> Bool { + let dirtyTabIDs = viewModel.tabs.filter(\.isDirty).map(\.id) + guard !dirtyTabIDs.isEmpty else { return true } + for tabID in dirtyTabIDs { + guard let tab = viewModel.tabs.first(where: { $0.id == tabID }) else { continue } + viewModel.saveFile(tab: tab) + guard let updated = viewModel.tabs.first(where: { $0.id == tabID }), !updated.isDirty else { + return false + } + } + return true + } + + private func windowCloseDialogMessage() -> String { + let dirtyCount = viewModel.tabs.filter(\.isDirty).count + if dirtyCount <= 1 { + return "You have unsaved changes in one tab." + } + return "You have unsaved changes in \(dirtyCount) tabs." + } + + private func installWindowCloseConfirmationDelegate(_ window: NSWindow?) { + guard let window else { + windowCloseConfirmationDelegate = nil + return + } + + let delegate: WindowCloseConfirmationDelegate + if let existing = windowCloseConfirmationDelegate { + delegate = existing + } else { + delegate = WindowCloseConfirmationDelegate() + windowCloseConfirmationDelegate = delegate + } + + if window.delegate !== delegate { + if let current = window.delegate, current !== delegate { + delegate.forwardedDelegate = current + } + window.delegate = delegate + } + + delegate.shouldConfirm = { confirmCloseDirtyTab } + delegate.hasDirtyTabs = { viewModel.tabs.contains(where: \.isDirty) } + delegate.saveAllDirtyTabs = { saveAllDirtyTabsForWindowClose() } + delegate.dialogTitle = { "Save changes before closing?" } + delegate.dialogMessage = { windowCloseDialogMessage() } + } + private func requestBracketHelperInsert(_ token: String) { let targetWindow = hostWindowNumber ?? NSApp.keyWindow?.windowNumber ?? NSApp.mainWindow?.windowNumber var userInfo: [String: Any] = [EditorCommandUserInfo.bracketToken: token] @@ -1592,6 +1710,13 @@ struct ContentView: View { lastCompletionTriggerSignature = "" pendingHighlightRefresh?.cancel() completionCache.removeAll(keepingCapacity: false) + if let number = hostWindowNumber, + let window = NSApp.window(withWindowNumber: number), + let delegate = windowCloseConfirmationDelegate, + window.delegate === delegate { + window.delegate = delegate.forwardedDelegate + } + windowCloseConfirmationDelegate = nil if let number = hostWindowNumber { WindowViewModelRegistry.shared.unregister(windowNumber: number) }