move update pill to sidebar only, add Install Update menu item

This commit is contained in:
Lawrence Chen 2026-02-15 21:17:33 -08:00
parent de6cfededa
commit ac4b49d7a4
6 changed files with 17 additions and 122 deletions

View file

@ -9,7 +9,6 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase {
private let filesToCheck = [
"Sources/Update/UpdateTitlebarAccessory.swift",
"Sources/ContentView.swift",
"Sources/WindowToolbarController.swift",
]
func testUpdatePillNotGatedBehindDebug() throws {

View file

@ -311,10 +311,6 @@ struct ContentView: View {
Spacer()
if !sidebarState.isVisible {
UpdatePill(model: updateViewModel)
.padding(.trailing, 8)
}
}
.frame(height: 28)
.padding(.top, 2)

View file

@ -65,3 +65,16 @@ struct UpdatePill: View {
return size.width
}
}
/// Menu item that shows "Install Update and Relaunch" when an update is ready.
struct InstallUpdateMenuItem: View {
@ObservedObject var model: UpdateViewModel
var body: some View {
if model.state.isInstallable {
Button("Install Update and Relaunch") {
model.state.confirm()
}
}
}
}

View file

@ -6,15 +6,6 @@ final class NonDraggableHostingView<Content: View>: NSHostingView<Content> {
override var mouseDownCanMoveWindow: Bool { false }
}
private struct TitlebarAccessoryView: View {
@ObservedObject var model: UpdateViewModel
var body: some View {
UpdatePill(model: model)
.padding(.trailing, 8)
}
}
enum TitlebarControlsStyle: Int, CaseIterable, Identifiable {
case classic
case compact
@ -867,70 +858,6 @@ private struct NotificationPopoverRow: View {
}
}
final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController {
private let hostingView: NonDraggableHostingView<TitlebarAccessoryView>
private let containerView = NSView()
private var stateCancellable: AnyCancellable?
private var pendingSizeUpdate = false
init(model: UpdateViewModel) {
hostingView = NonDraggableHostingView(rootView: TitlebarAccessoryView(model: model))
super.init(nibName: nil, bundle: nil)
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
stateCancellable = model.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.scheduleSizeUpdate()
}
scheduleSizeUpdate()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear() {
super.viewDidAppear()
scheduleSizeUpdate()
}
override func viewDidLayout() {
super.viewDidLayout()
scheduleSizeUpdate()
}
private func scheduleSizeUpdate() {
guard !pendingSizeUpdate else { return }
pendingSizeUpdate = true
DispatchQueue.main.async { [weak self] in
self?.pendingSizeUpdate = false
self?.updateSize()
}
}
private func updateSize() {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
let pillSize = hostingView.fittingSize
let titlebarHeight = view.window.map { window in
window.frame.height - window.contentLayoutRect.height
} ?? pillSize.height
let containerHeight = max(pillSize.height, titlebarHeight)
let yOffset = max(0, (containerHeight - pillSize.height) / 2.0)
preferredContentSize = NSSize(width: pillSize.width, height: containerHeight)
containerView.frame = NSRect(x: 0, y: 0, width: pillSize.width, height: containerHeight)
hostingView.frame = NSRect(x: 0, y: yOffset, width: pillSize.width, height: pillSize.height)
}
}
final class UpdateTitlebarAccessoryController {
private weak var updateViewModel: UpdateViewModel?
private var didStart = false

View file

@ -5,18 +5,13 @@ import SwiftUI
@MainActor
final class WindowToolbarController: NSObject, NSToolbarDelegate {
private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand")
private let updateItemIdentifier = NSToolbarItem.Identifier("cmux.updatePill")
private weak var tabManager: TabManager?
private weak var updateViewModel: UpdateViewModel?
private var commandLabels: [ObjectIdentifier: NSTextField] = [:]
private var observers: [NSObjectProtocol] = []
private var updateSizeCancellables: [ObjectIdentifier: AnyCancellable] = [:]
private var updateViewConstraints: [ObjectIdentifier: (width: NSLayoutConstraint, height: NSLayoutConstraint)] = [:]
init(updateViewModel: UpdateViewModel) {
self.updateViewModel = updateViewModel
override init() {
super.init()
}
@ -24,9 +19,6 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
for cancellable in updateSizeCancellables.values {
cancellable.cancel()
}
}
func start(tabManager: TabManager) {
@ -105,11 +97,11 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
// MARK: - NSToolbarDelegate
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[commandItemIdentifier, .flexibleSpace, updateItemIdentifier]
[commandItemIdentifier, .flexibleSpace]
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[commandItemIdentifier, .flexibleSpace, updateItemIdentifier]
[commandItemIdentifier, .flexibleSpace]
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
@ -126,41 +118,8 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate {
return item
}
if itemIdentifier == updateItemIdentifier, let updateViewModel {
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
let view = NonDraggableHostingView(rootView: UpdatePill(model: updateViewModel))
let key = ObjectIdentifier(toolbar)
item.view = view
sizeToolbarItem(for: key, hostingView: view)
updateSizeCancellables[key]?.cancel()
updateSizeCancellables[key] = updateViewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self, weak view] _ in
guard let self, let view else { return }
self.sizeToolbarItem(for: key, hostingView: view)
}
return item
}
return nil
}
private func sizeToolbarItem(for key: ObjectIdentifier, hostingView: NSView) {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
let size = hostingView.fittingSize
hostingView.setFrameSize(size)
hostingView.setContentHuggingPriority(.required, for: .horizontal)
hostingView.setContentHuggingPriority(.required, for: .vertical)
hostingView.translatesAutoresizingMaskIntoConstraints = false
if let constraints = updateViewConstraints[key] {
constraints.width.constant = size.width
constraints.height.constant = size.height
} else {
let width = hostingView.widthAnchor.constraint(equalToConstant: size.width)
let height = hostingView.heightAnchor.constraint(equalToConstant: size.height)
NSLayoutConstraint.activate([width, height])
updateViewConstraints[key] = (width: width, height: height)
}
}
}

View file

@ -194,6 +194,7 @@ struct cmuxApp: App {
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
}
InstallUpdateMenuItem(model: appDelegate.updateViewModel)
}
#if DEBUG