diff --git a/macos/CodeWith/Sources/CodeWith/App/AppModel.swift b/macos/CodeWith/Sources/CodeWith/App/AppModel.swift index 734b9fed3e..4e393f9d65 100644 --- a/macos/CodeWith/Sources/CodeWith/App/AppModel.swift +++ b/macos/CodeWith/Sources/CodeWith/App/AppModel.swift @@ -359,6 +359,7 @@ final class AppModel { FileManager.default.isExecutableFile(atPath: $0) && !AppServerClient.isSelfExecutable($0) } guard !candidates.isEmpty else { + publishCurrentMenuBarPreference() connection = .unavailable("codewith CLI not found"); return } var lastError = "could not start the codewith app-server" @@ -376,6 +377,7 @@ final class AppModel { } } guard connection == .connected else { + publishCurrentMenuBarPreference() connection = .unavailable(lastError); return } startNotificationConsumer() @@ -952,12 +954,13 @@ final class AppModel { configSandbox = cfg.sandbox customInstructions = cfg.developerInstructions ?? "" desktopSettings = cfg.desktop - publishMenuBarPreference(cfg.desktop.showMenuBar) + publishCurrentMenuBarPreference() if let sandbox = cfg.sandbox, sandbox != "danger-full-access" { previousNonFullSandbox = sandbox } } catch { configError = error.localizedDescription + publishCurrentMenuBarPreference() } } @@ -2782,6 +2785,10 @@ final class AppModel { NotificationCenter.default.post(name: .codeWithMenuBarPreferenceChanged, object: enabled) } + func publishCurrentMenuBarPreference() { + publishMenuBarPreference(desktopSettings.showMenuBar) + } + private func writeConfigValue(keyPath: String, value: JSONValue, reloadUserConfig: Bool = false) async { await writeConfigValues([(keyPath: keyPath, value: value)], reloadUserConfig: reloadUserConfig) } diff --git a/macos/CodeWith/Sources/CodeWith/App/CodeWithApp.swift b/macos/CodeWith/Sources/CodeWith/App/CodeWithApp.swift index 6cc36ec6e8..088af2cb95 100644 --- a/macos/CodeWith/Sources/CodeWith/App/CodeWithApp.swift +++ b/macos/CodeWith/Sources/CodeWith/App/CodeWithApp.swift @@ -17,10 +17,131 @@ enum CodeWithMain { let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.regular) + AppMenuBuilder.install(on: app) app.run() } } +enum AppMenuBuilder { + static func install(on application: NSApplication) { + let mainMenu = NSMenu() + mainMenu.addItem(applicationMenu(for: application)) + mainMenu.addItem(editMenu()) + let window = windowMenu(for: application) + mainMenu.addItem(window.item) + + application.mainMenu = mainMenu + application.windowsMenu = window.menu + } + + private static func applicationMenu(for application: NSApplication) -> NSMenuItem { + let submenu = NSMenu(title: "CodeWith") + submenu.addItem(item( + "About CodeWith", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + target: application + )) + submenu.addItem(.separator()) + submenu.addItem(item( + "Hide CodeWith", + action: #selector(NSApplication.hide(_:)), + keyEquivalent: "h", + target: application + )) + submenu.addItem(item( + "Hide Others", + action: #selector(NSApplication.hideOtherApplications(_:)), + keyEquivalent: "h", + modifiers: [.command, .option], + target: application + )) + submenu.addItem(item( + "Show All", + action: #selector(NSApplication.unhideAllApplications(_:)), + target: application + )) + submenu.addItem(.separator()) + submenu.addItem(item( + "Quit CodeWith", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q", + target: application + )) + return topLevelItem(title: "CodeWith", submenu: submenu) + } + + private static func editMenu() -> NSMenuItem { + let submenu = NSMenu(title: "Edit") + submenu.addItem(item("Undo", action: Selector(("undo:")), keyEquivalent: "z")) + submenu.addItem(item( + "Redo", + action: Selector(("redo:")), + keyEquivalent: "z", + modifiers: [.command, .shift] + )) + submenu.addItem(.separator()) + submenu.addItem(item("Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + submenu.addItem(item("Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + submenu.addItem(item("Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + submenu.addItem(item("Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + return topLevelItem(title: "Edit", submenu: submenu) + } + + private static func windowMenu(for application: NSApplication) -> (item: NSMenuItem, menu: NSMenu) { + let submenu = NSMenu(title: "Window") + submenu.addItem(item( + "Minimize", + action: #selector(NSWindow.performMiniaturize(_:)), + keyEquivalent: "m" + )) + submenu.addItem(item("Zoom", action: #selector(NSWindow.performZoom(_:)))) + submenu.addItem(.separator()) + submenu.addItem(item( + "Bring All to Front", + action: #selector(NSApplication.arrangeInFront(_:)), + target: application + )) + return (topLevelItem(title: "Window", submenu: submenu), submenu) + } + + private static func topLevelItem(title: String, submenu: NSMenu) -> NSMenuItem { + let menuItem = NSMenuItem() + menuItem.title = title + menuItem.submenu = submenu + return menuItem + } + + private static func item( + _ title: String, + action: Selector, + keyEquivalent: String = "", + modifiers: NSEvent.ModifierFlags = [.command], + target: AnyObject? = nil + ) -> NSMenuItem { + let menuItem = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) + menuItem.keyEquivalentModifierMask = modifiers + menuItem.target = target + return menuItem + } +} + +protocol AppWindowPresenting: AnyObject { + var isMiniaturized: Bool { get } + func deminiaturize(_ sender: Any?) + func makeKeyAndOrderFront(_ sender: Any?) +} + +extension NSWindow: AppWindowPresenting {} + +enum ExistingWindowPresenter { + static func restore(_ window: AppWindowPresenting) { + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + } +} + final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { private var window: NSWindow? private var statusItem: NSStatusItem? @@ -48,13 +169,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { guard let enabled = note.object as? Bool else { return } self?.setMenuBarEnabled(enabled) } - setMenuBarEnabled(true) showMainWindow() } private func showMainWindow() { if let window { - window.makeKeyAndOrderFront(nil) + ExistingWindowPresenter.restore(window) NSApp.activate(ignoringOtherApps: true) return } diff --git a/macos/CodeWith/Tests/CodeWithTests/AppDelegateTests.swift b/macos/CodeWith/Tests/CodeWithTests/AppDelegateTests.swift new file mode 100644 index 0000000000..02b7338683 --- /dev/null +++ b/macos/CodeWith/Tests/CodeWithTests/AppDelegateTests.swift @@ -0,0 +1,64 @@ +import AppKit +import XCTest +@testable import CodeWith + +@MainActor +final class AppDelegateTests: XCTestCase { + func testInstallMainMenuProvidesNativeApplicationEditAndWindowMenus() { + let application = NSApplication.shared + let previousMainMenu = application.mainMenu + let previousWindowsMenu = application.windowsMenu + defer { + application.mainMenu = previousMainMenu + application.windowsMenu = previousWindowsMenu + } + + AppMenuBuilder.install(on: application) + + XCTAssertEqual(application.mainMenu?.items.map(\.title), ["CodeWith", "Edit", "Window"]) + XCTAssertEqual(application.mainMenu?.item(withTitle: "CodeWith")?.submenu?.item(withTitle: "Quit CodeWith")?.action, + #selector(NSApplication.terminate(_:))) + XCTAssertEqual(application.mainMenu?.item(withTitle: "Edit")?.submenu?.item(withTitle: "Copy")?.action, + #selector(NSText.copy(_:))) + XCTAssertTrue(application.windowsMenu === application.mainMenu?.item(withTitle: "Window")?.submenu) + } + + func testExistingMiniaturizedWindowIsDeminiaturizedBeforeBeingOrderedFront() { + let window = WindowPresenterSpy(isMiniaturized: true) + + ExistingWindowPresenter.restore(window) + + XCTAssertEqual(window.calls, [.deminiaturize, .orderFront]) + } + + func testExistingVisibleWindowIsOnlyOrderedFront() { + let window = WindowPresenterSpy(isMiniaturized: false) + + ExistingWindowPresenter.restore(window) + + XCTAssertEqual(window.calls, [.orderFront]) + } +} + +@MainActor +private final class WindowPresenterSpy: AppWindowPresenting { + enum Call: Equatable { + case deminiaturize + case orderFront + } + + let isMiniaturized: Bool + private(set) var calls: [Call] = [] + + init(isMiniaturized: Bool) { + self.isMiniaturized = isMiniaturized + } + + func deminiaturize(_ sender: Any?) { + calls.append(.deminiaturize) + } + + func makeKeyAndOrderFront(_ sender: Any?) { + calls.append(.orderFront) + } +} diff --git a/macos/CodeWith/Tests/CodeWithTests/AppModelTests.swift b/macos/CodeWith/Tests/CodeWithTests/AppModelTests.swift index 58d0ff29da..57e32bae51 100644 --- a/macos/CodeWith/Tests/CodeWithTests/AppModelTests.swift +++ b/macos/CodeWith/Tests/CodeWithTests/AppModelTests.swift @@ -51,6 +51,23 @@ final class AppModelTests: XCTestCase { XCTAssertFalse(m.desktopSettings.showMenuBar) } + func testPublishCurrentMenuBarPreferenceUsesDefaultWhenConfigIsUnavailable() { + var received: Bool? + let token = NotificationCenter.default.addObserver( + forName: .codeWithMenuBarPreferenceChanged, + object: nil, + queue: nil + ) { note in + received = note.object as? Bool + } + defer { NotificationCenter.default.removeObserver(token) } + + let m = AppModel() + m.publishCurrentMenuBarPreference() + + XCTAssertEqual(received, true) + } + func testNewChatResets() { let m = AppModel() m.activeThreadId = "x"; m.activeMessages = [ChatMessage(role: .user, text: "hi")]