mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-13 21:36:08 +00:00
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:

committed by
GitHub

parent
cd7621167f
commit
ac2f040b31
@@ -5,8 +5,29 @@ struct CommandOption: Identifiable, Hashable {
|
||||
let title: String
|
||||
let description: String?
|
||||
let symbols: [String]?
|
||||
let leadingIcon: String?
|
||||
let badge: String?
|
||||
let emphasis: Bool
|
||||
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 {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
@@ -198,7 +219,7 @@ fileprivate struct CommandTable: View {
|
||||
} else {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(Array(options.enumerated()), id: \.1.id) { index, option in
|
||||
CommandRow(
|
||||
option: option,
|
||||
@@ -240,15 +261,36 @@ fileprivate struct CommandRow: View {
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
.fontWeight(option.emphasis ? .medium : .regular)
|
||||
|
||||
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 {
|
||||
ShortcutSymbolsView(symbols: symbols)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
isSelected
|
||||
? Color.accentColor.opacity(0.2)
|
||||
@@ -256,6 +298,10 @@ fileprivate struct CommandRow: View {
|
||||
? Color.secondary.opacity(0.2)
|
||||
: Color.clear)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.strokeBorder(Color.accentColor.opacity(option.emphasis && !isSelected ? 0.3 : 0), lineWidth: 1.5)
|
||||
)
|
||||
.cornerRadius(5)
|
||||
}
|
||||
.help(option.description ?? "")
|
||||
|
@@ -12,14 +12,53 @@ struct TerminalCommandPaletteView: View {
|
||||
/// The configuration so we can lookup keyboard shortcuts.
|
||||
@ObservedObject var ghosttyConfig: Ghostty.Config
|
||||
|
||||
/// The update view model for showing update commands.
|
||||
var updateViewModel: UpdateViewModel?
|
||||
|
||||
/// The callback when an action is submitted.
|
||||
var onAction: ((String) -> Void)
|
||||
|
||||
// The commands available to the command palette.
|
||||
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 {
|
||||
return try surface.commands().map { c in
|
||||
let terminalCommands = try surface.commands().map { c in
|
||||
return CommandOption(
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
@@ -28,9 +67,12 @@ struct TerminalCommandPaletteView: View {
|
||||
onAction(c.action)
|
||||
}
|
||||
}
|
||||
options.append(contentsOf: terminalCommands)
|
||||
} catch {
|
||||
return []
|
||||
return options
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
@@ -108,7 +108,8 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
TerminalCommandPaletteView(
|
||||
surfaceView: surfaceView,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import Sparkle
|
||||
import Cocoa
|
||||
import Combine
|
||||
|
||||
/// Standard controller for managing Sparkle updates in Ghostty.
|
||||
///
|
||||
@@ -10,6 +11,7 @@ class UpdateController {
|
||||
private(set) var updater: SPUUpdater
|
||||
private let userDriver: UpdateDriver
|
||||
private let updaterDelegate = UpdaterDelegate()
|
||||
private var installCancellable: AnyCancellable?
|
||||
|
||||
var viewModel: UpdateViewModel {
|
||||
userDriver.viewModel
|
||||
@@ -29,6 +31,10 @@ class UpdateController {
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
installCancellable?.cancel()
|
||||
}
|
||||
|
||||
/// Start the updater.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// This is typically connected to a menu item action.
|
||||
|
@@ -17,7 +17,11 @@ class UpdateViewModel: ObservableObject {
|
||||
case .checking:
|
||||
return "Checking for Updates…"
|
||||
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):
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let progress = Double(download.progress) / Double(expectedLength)
|
||||
@@ -51,7 +55,6 @@ class UpdateViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
/// The SF Symbol icon name for the current update state.
|
||||
/// Returns nil for idle, downloading, and extracting states.
|
||||
var iconName: String? {
|
||||
switch state {
|
||||
case .idle:
|
||||
@@ -61,9 +64,11 @@ class UpdateViewModel: ObservableObject {
|
||||
case .checking:
|
||||
return "arrow.triangle.2.circlepath"
|
||||
case .updateAvailable:
|
||||
return "arrow.down.circle.fill"
|
||||
case .downloading, .extracting:
|
||||
return nil
|
||||
return "shippingbox.fill"
|
||||
case .downloading:
|
||||
return "arrow.down.circle"
|
||||
case .extracting:
|
||||
return "shippingbox"
|
||||
case .readyToInstall:
|
||||
return "checkmark.circle.fill"
|
||||
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.
|
||||
var iconColor: Color {
|
||||
switch state {
|
||||
@@ -147,6 +199,22 @@ enum UpdateState: Equatable {
|
||||
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() {
|
||||
switch self {
|
||||
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 {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle):
|
||||
|
Reference in New Issue
Block a user