177 lines
6.4 KiB
Swift
177 lines
6.4 KiB
Swift
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
struct TerminalCommandPaletteView: View {
|
|
/// The surface that this command palette represents.
|
|
let surfaceView: Ghostty.SurfaceView
|
|
|
|
/// Set this to true to show the view, this will be set to false if any actions
|
|
/// result in the view disappearing.
|
|
@Binding var isPresented: Bool
|
|
|
|
/// 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)
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
if isPresented {
|
|
GeometryReader { geometry in
|
|
VStack {
|
|
Spacer().frame(height: geometry.size.height * 0.05)
|
|
|
|
ResponderChainInjector(responder: surfaceView)
|
|
.frame(width: 0, height: 0)
|
|
|
|
CommandPaletteView(
|
|
isPresented: $isPresented,
|
|
backgroundColor: ghosttyConfig.backgroundColor,
|
|
options: commandOptions
|
|
)
|
|
.zIndex(1) // Ensure it's on top
|
|
|
|
Spacer()
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
|
}
|
|
.transition(
|
|
.move(edge: .top)
|
|
.combined(with: .opacity)
|
|
)
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: isPresented)
|
|
.onChange(of: isPresented) { newValue in
|
|
// When the command palette disappears we need to send focus back to the
|
|
// surface view we were overlaid on top of. There's probably a better way
|
|
// to handle the first responder state here but I don't know it.
|
|
if !newValue {
|
|
// Has to be on queue because onChange happens on a user-interactive
|
|
// thread and Xcode is mad about this call on that.
|
|
DispatchQueue.main.async {
|
|
surfaceView.window?.makeFirstResponder(surfaceView)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// All commands available in the command palette, combining update and terminal options.
|
|
private var commandOptions: [CommandOption] {
|
|
var options: [CommandOption] = []
|
|
// Updates always appear first
|
|
options.append(contentsOf: updateOptions)
|
|
|
|
// Sort the rest. We replace ":" with a character that sorts before space
|
|
// so that "Foo:" sorts before "Foo Bar:".
|
|
options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in
|
|
let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t")
|
|
let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t")
|
|
return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending
|
|
})
|
|
return options
|
|
}
|
|
|
|
/// Commands for installing or canceling available updates.
|
|
private var updateOptions: [CommandOption] {
|
|
var options: [CommandOption] = []
|
|
|
|
guard let updateViewModel, updateViewModel.state.isInstallable else {
|
|
return options
|
|
}
|
|
|
|
// 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()
|
|
})
|
|
|
|
options.append(CommandOption(
|
|
title: "Cancel or Skip Update",
|
|
description: "Dismiss the current update process"
|
|
) {
|
|
updateViewModel.state.cancel()
|
|
})
|
|
|
|
return options
|
|
}
|
|
|
|
/// Commands exposed by the terminal surface.
|
|
private var terminalOptions: [CommandOption] {
|
|
guard let surface = surfaceView.surfaceModel else { return [] }
|
|
do {
|
|
return try surface.commands().map { c in
|
|
CommandOption(
|
|
title: c.title,
|
|
description: c.description,
|
|
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList,
|
|
) {
|
|
onAction(c.action)
|
|
}
|
|
}
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Commands for jumping to other terminal surfaces.
|
|
private var jumpOptions: [CommandOption] {
|
|
TerminalController.all.flatMap { controller -> [CommandOption] in
|
|
guard let window = controller.window else { return [] }
|
|
|
|
let color = (window as? TerminalWindow)?.tabColor
|
|
let displayColor = color != TerminalTabColor.none ? color : nil
|
|
|
|
return controller.surfaceTree.map { surface in
|
|
let title = surface.title.isEmpty ? window.title : surface.title
|
|
let displayTitle = title.isEmpty ? "Untitled" : title
|
|
|
|
return CommandOption(
|
|
title: "Focus: \(displayTitle)",
|
|
description: surface.pwd?.abbreviatedPath,
|
|
leadingIcon: "rectangle.on.rectangle",
|
|
leadingColor: displayColor?.displayColor.map { Color($0) }
|
|
) {
|
|
NotificationCenter.default.post(
|
|
name: Ghostty.Notification.ghosttyPresentTerminal,
|
|
object: surface
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/// This is done to ensure that the given view is in the responder chain.
|
|
fileprivate struct ResponderChainInjector: NSViewRepresentable {
|
|
let responder: NSResponder
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let dummy = NSView()
|
|
DispatchQueue.main.async {
|
|
dummy.nextResponder = responder
|
|
}
|
|
return dummy
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {}
|
|
}
|