macos: Show "Update and Restart" in the Command Palette (#9131)

If an update is available, you can now trigger the full download,
install, and restart from a single command palette action. This allows
for a fully keyboard-driven update process.

While an update is being installed, an option to cancel or skip the
current update is also shown as an option, so that can also be
keyboard-driven.

This currently can't be bound to a keyboard action, but that may be
added in the future if there's demand for it.

**AI Disclosure:** Amp was used considerably. I reviewed all the code
and understand it.

## Demo



https://github.com/user-attachments/assets/df6307f8-9967-40d4-9a62-04feddf00ac2
This commit is contained in:
Mitchell Hashimoto
2025-10-10 13:40:35 -07:00
committed by GitHub
parent cd7621167f
commit ac2f040b31
5 changed files with 216 additions and 11 deletions

View File

@@ -5,8 +5,29 @@ struct CommandOption: Identifiable, Hashable {
let title: String let title: String
let description: String? let description: String?
let symbols: [String]? let symbols: [String]?
let leadingIcon: String?
let badge: String?
let emphasis: Bool
let action: () -> Void let action: () -> Void
init(
title: String,
description: String? = nil,
symbols: [String]? = nil,
leadingIcon: String? = nil,
badge: String? = nil,
emphasis: Bool = false,
action: @escaping () -> Void
) {
self.title = title
self.description = description
self.symbols = symbols
self.leadingIcon = leadingIcon
self.badge = badge
self.emphasis = emphasis
self.action = action
}
static func == (lhs: CommandOption, rhs: CommandOption) -> Bool { static func == (lhs: CommandOption, rhs: CommandOption) -> Bool {
lhs.id == rhs.id lhs.id == rhs.id
} }
@@ -198,7 +219,7 @@ fileprivate struct CommandTable: View {
} else { } else {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) {
ForEach(Array(options.enumerated()), id: \.1.id) { index, option in ForEach(Array(options.enumerated()), id: \.1.id) { index, option in
CommandRow( CommandRow(
option: option, option: option,
@@ -240,15 +261,36 @@ fileprivate struct CommandRow: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
HStack { HStack(spacing: 8) {
if let icon = option.leadingIcon {
Image(systemName: icon)
.foregroundStyle(option.emphasis ? Color.accentColor : .secondary)
.font(.system(size: 14, weight: .medium))
}
Text(option.title) Text(option.title)
.fontWeight(option.emphasis ? .medium : .regular)
Spacer() Spacer()
if let badge = option.badge, !badge.isEmpty {
Text(badge)
.font(.caption2.weight(.medium))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule().fill(Color.accentColor.opacity(0.15))
)
.foregroundStyle(Color.accentColor)
}
if let symbols = option.symbols { if let symbols = option.symbols {
ShortcutSymbolsView(symbols: symbols) ShortcutSymbolsView(symbols: symbols)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
.padding(8) .padding(8)
.contentShape(Rectangle())
.background( .background(
isSelected isSelected
? Color.accentColor.opacity(0.2) ? Color.accentColor.opacity(0.2)
@@ -256,6 +298,10 @@ fileprivate struct CommandRow: View {
? Color.secondary.opacity(0.2) ? Color.secondary.opacity(0.2)
: Color.clear) : Color.clear)
) )
.overlay(
RoundedRectangle(cornerRadius: 5)
.strokeBorder(Color.accentColor.opacity(option.emphasis && !isSelected ? 0.3 : 0), lineWidth: 1.5)
)
.cornerRadius(5) .cornerRadius(5)
} }
.help(option.description ?? "") .help(option.description ?? "")

View File

@@ -12,14 +12,53 @@ struct TerminalCommandPaletteView: View {
/// The configuration so we can lookup keyboard shortcuts. /// The configuration so we can lookup keyboard shortcuts.
@ObservedObject var ghosttyConfig: Ghostty.Config @ObservedObject var ghosttyConfig: Ghostty.Config
/// The update view model for showing update commands.
var updateViewModel: UpdateViewModel?
/// The callback when an action is submitted. /// The callback when an action is submitted.
var onAction: ((String) -> Void) var onAction: ((String) -> Void)
// The commands available to the command palette. // The commands available to the command palette.
private var commandOptions: [CommandOption] { private var commandOptions: [CommandOption] {
guard let surface = surfaceView.surfaceModel else { return [] } var options: [CommandOption] = []
// Add update command if an update is installable. This must always be the first so
// it is at the top.
if let updateViewModel, updateViewModel.state.isInstallable {
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
}
// Add cancel/skip update command if the update is installable
if let updateViewModel, updateViewModel.state.isInstallable {
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
}
// Add terminal commands
guard let surface = surfaceView.surfaceModel else { return options }
do { do {
return try surface.commands().map { c in let terminalCommands = try surface.commands().map { c in
return CommandOption( return CommandOption(
title: c.title, title: c.title,
description: c.description, description: c.description,
@@ -28,9 +67,12 @@ struct TerminalCommandPaletteView: View {
onAction(c.action) onAction(c.action)
} }
} }
options.append(contentsOf: terminalCommands)
} catch { } catch {
return [] return options
} }
return options
} }
var body: some View { var body: some View {

View File

@@ -108,7 +108,8 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
TerminalCommandPaletteView( TerminalCommandPaletteView(
surfaceView: surfaceView, surfaceView: surfaceView,
isPresented: $viewModel.commandPaletteIsShowing, isPresented: $viewModel.commandPaletteIsShowing,
ghosttyConfig: ghostty.config) { action in ghosttyConfig: ghostty.config,
updateViewModel: (NSApp.delegate as? AppDelegate)?.updateViewModel) { action in
self.delegate?.performAction(action, on: surfaceView) self.delegate?.performAction(action, on: surfaceView)
} }
} }

View File

@@ -1,5 +1,6 @@
import Sparkle import Sparkle
import Cocoa import Cocoa
import Combine
/// Standard controller for managing Sparkle updates in Ghostty. /// Standard controller for managing Sparkle updates in Ghostty.
/// ///
@@ -10,6 +11,7 @@ class UpdateController {
private(set) var updater: SPUUpdater private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver private let userDriver: UpdateDriver
private let updaterDelegate = UpdaterDelegate() private let updaterDelegate = UpdaterDelegate()
private var installCancellable: AnyCancellable?
var viewModel: UpdateViewModel { var viewModel: UpdateViewModel {
userDriver.viewModel userDriver.viewModel
@@ -29,6 +31,10 @@ class UpdateController {
) )
} }
deinit {
installCancellable?.cancel()
}
/// Start the updater. /// Start the updater.
/// ///
/// This must be called before the updater can check for updates. If starting fails, /// This must be called before the updater can check for updates. If starting fails,
@@ -50,6 +56,34 @@ class UpdateController {
} }
} }
/// Force install the current update. As long as we're in some "update available" state this will
/// trigger all the steps necessary to complete the update.
func installUpdate() {
// Must be in an installable state
guard viewModel.state.isInstallable else { return }
// If we're already force installing then do nothing.
guard installCancellable == nil else { return }
// Setup a combine listener to listen for state changes and to always
// confirm them. If we go to a non-installable state, cancel the listener.
// The sink runs immediately with the current state, so we don't need to
// manually confirm the first state.
installCancellable = viewModel.$state.sink { [weak self] state in
guard let self else { return }
// If we move to a non-installable state (error, idle, etc.) then we
// stop force installing.
guard state.isInstallable else {
self.installCancellable = nil
return
}
// Continue the `yes` chain!
state.confirm()
}
}
/// Check for updates. /// Check for updates.
/// ///
/// This is typically connected to a menu item action. /// This is typically connected to a menu item action.

View File

@@ -17,7 +17,11 @@ class UpdateViewModel: ObservableObject {
case .checking: case .checking:
return "Checking for Updates…" return "Checking for Updates…"
case .updateAvailable(let update): case .updateAvailable(let update):
return "Update Available: \(update.appcastItem.displayVersionString)" let version = update.appcastItem.displayVersionString
if !version.isEmpty {
return "Update Available: \(version)"
}
return "Update Available"
case .downloading(let download): case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 { if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength) let progress = Double(download.progress) / Double(expectedLength)
@@ -51,7 +55,6 @@ class UpdateViewModel: ObservableObject {
} }
/// The SF Symbol icon name for the current update state. /// The SF Symbol icon name for the current update state.
/// Returns nil for idle, downloading, and extracting states.
var iconName: String? { var iconName: String? {
switch state { switch state {
case .idle: case .idle:
@@ -61,9 +64,11 @@ class UpdateViewModel: ObservableObject {
case .checking: case .checking:
return "arrow.triangle.2.circlepath" return "arrow.triangle.2.circlepath"
case .updateAvailable: case .updateAvailable:
return "arrow.down.circle.fill" return "shippingbox.fill"
case .downloading, .extracting: case .downloading:
return nil return "arrow.down.circle"
case .extracting:
return "shippingbox"
case .readyToInstall: case .readyToInstall:
return "checkmark.circle.fill" return "checkmark.circle.fill"
case .installing: case .installing:
@@ -75,6 +80,53 @@ class UpdateViewModel: ObservableObject {
} }
} }
/// A longer description for the current update state.
/// Used in contexts like the command palette where more detail is helpful.
var description: String {
switch state {
case .idle:
return ""
case .permissionRequest:
return "Configure automatic update preferences"
case .checking:
return "Please wait while we check for available updates"
case .updateAvailable(let update):
return update.releaseNotes?.label ?? "Download and install the latest version"
case .downloading:
return "Downloading the update package"
case .extracting:
return "Extracting and preparing the update"
case .readyToInstall:
return "Update is ready to install"
case .installing:
return "Installing update and preparing to restart"
case .notFound:
return "You are running the latest version"
case .error:
return "An error occurred during the update process"
}
}
/// A badge to display for the current update state.
/// Returns version numbers, progress percentages, or nil.
var badge: String? {
switch state {
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
return version.isEmpty ? nil : version
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let percentage = Double(download.progress) / Double(expectedLength) * 100
return String(format: "%.0f%%", percentage)
}
return nil
case .extracting(let extracting):
return String(format: "%.0f%%", extracting.progress * 100)
default:
return nil
}
}
/// The color to apply to the icon for the current update state. /// The color to apply to the icon for the current update state.
var iconColor: Color { var iconColor: Color {
switch state { switch state {
@@ -147,6 +199,22 @@ enum UpdateState: Equatable {
return false return false
} }
/// This is true if we're in a state that can be force installed.
var isInstallable: Bool {
switch (self) {
case .checking,
.updateAvailable,
.downloading,
.extracting,
.readyToInstall,
.installing:
return true
default:
return false
}
}
func cancel() { func cancel() {
switch self { switch self {
case .checking(let checking): case .checking(let checking):
@@ -166,6 +234,20 @@ enum UpdateState: Equatable {
} }
} }
/// Confirms or accepts the current update state.
/// - For available updates: begins installation
/// - For ready-to-install: proceeds with installation
func confirm() {
switch self {
case .updateAvailable(let available):
available.reply(.install)
case .readyToInstall(let ready):
ready.reply(.install)
default:
break
}
}
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool { static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case (.idle, .idle): case (.idle, .idle):