summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--macos/Sources/Features/Command Palette/CommandPalette.swift50
-rw-r--r--macos/Sources/Features/Command Palette/TerminalCommandPalette.swift48
-rw-r--r--macos/Sources/Features/Terminal/TerminalView.swift3
-rw-r--r--macos/Sources/Features/Update/UpdateController.swift34
-rw-r--r--macos/Sources/Features/Update/UpdateViewModel.swift92
5 files changed, 216 insertions, 11 deletions
diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift
index 8d15cbf9a..537137fe6 100644
--- a/macos/Sources/Features/Command Palette/CommandPalette.swift
+++ b/macos/Sources/Features/Command Palette/CommandPalette.swift
@@ -5,7 +5,28 @@ 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 ?? "")
diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
index d02828494..673f5dd78 100644
--- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
+++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift
@@ -11,15 +11,54 @@ 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 {
diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift
index 54cf9a02a..0cdff7c1f 100644
--- a/macos/Sources/Features/Terminal/TerminalView.swift
+++ b/macos/Sources/Features/Terminal/TerminalView.swift
@@ -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)
}
}
diff --git a/macos/Sources/Features/Update/UpdateController.swift b/macos/Sources/Features/Update/UpdateController.swift
index 446b82ebc..aa875567c 100644
--- a/macos/Sources/Features/Update/UpdateController.swift
+++ b/macos/Sources/Features/Update/UpdateController.swift
@@ -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.
diff --git a/macos/Sources/Features/Update/UpdateViewModel.swift b/macos/Sources/Features/Update/UpdateViewModel.swift
index b0c6650c4..ccb03e731 100644
--- a/macos/Sources/Features/Update/UpdateViewModel.swift
+++ b/macos/Sources/Features/Update/UpdateViewModel.swift
@@ -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):