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: 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):