mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-14 05:46:17 +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 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 ?? "")
|
||||||
|
@@ -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 {
|
||||||
|
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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.
|
||||||
|
@@ -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):
|
||||||
|
Reference in New Issue
Block a user