mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
## Summary Cmd-clicking a file path containing `~` (e.g. `~/Documents/file.txt`) fails to open the file on macOS because `URL(filePath:)` treats `~` as a literal directory name rather than the user's home directory. This uses `NSString.expandingTildeInPath` to resolve `~` before constructing the file URL. ## Root Cause In `openURL()`, when the URL string has no scheme it falls through to: ```swift url = URL(filePath: action.url) ``` Swift's `URL(filePath:)` does not perform tilde expansion. A path like `~/Documents/file.txt` produces a URL pointing to a non-existent file, and `NSWorkspace.open` silently fails. ## Fix ```swift let expandedPath = NSString(string: action.url).expandingTildeInPath url = URL(filePath: expandedPath) ``` ## Reproduction 1. Have a terminal application (e.g. Claude Code) that outputs file paths with `~` prefixes 2. Cmd-click the path in Ghostty on macOS 3. The file does not open (fails silently) With this fix, the path resolves correctly and opens in the default editor.
2099 lines
81 KiB
Swift
2099 lines
81 KiB
Swift
import SwiftUI
|
|
import UserNotifications
|
|
import GhosttyKit
|
|
|
|
protocol GhosttyAppDelegate: AnyObject {
|
|
#if os(macOS)
|
|
/// Called when a callback needs access to a specific surface. This should return nil
|
|
/// when the surface is no longer valid.
|
|
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView?
|
|
#endif
|
|
}
|
|
|
|
extension Ghostty {
|
|
// IMPORTANT: THIS IS NOT DONE.
|
|
// This is a refactor/redo of Ghostty.AppState so that it supports both macOS and iOS
|
|
class App: ObservableObject {
|
|
enum Readiness: String {
|
|
case loading, error, ready
|
|
}
|
|
|
|
/// Optional delegate
|
|
weak var delegate: GhosttyAppDelegate?
|
|
|
|
/// The readiness value of the state.
|
|
@Published var readiness: Readiness = .loading
|
|
|
|
/// The global app configuration. This defines the app level configuration plus any behavior
|
|
/// for new windows, tabs, etc. Note that when creating a new window, it may inherit some
|
|
/// configuration (i.e. font size) from the previously focused window. This would override this.
|
|
@Published private(set) var config: Config
|
|
|
|
/// Preferred config file than the default ones
|
|
private var configPath: String?
|
|
/// The ghostty app instance. We only have one of these for the entire app, although I guess
|
|
/// in theory you can have multiple... I don't know why you would...
|
|
@Published var app: ghostty_app_t? {
|
|
didSet {
|
|
guard let old = oldValue else { return }
|
|
ghostty_app_free(old)
|
|
}
|
|
}
|
|
|
|
/// True if we need to confirm before quitting.
|
|
var needsConfirmQuit: Bool {
|
|
guard let app = app else { return false }
|
|
return ghostty_app_needs_confirm_quit(app)
|
|
}
|
|
|
|
init(configPath: String? = nil) {
|
|
self.configPath = configPath
|
|
// Initialize the global configuration.
|
|
self.config = Config(at: configPath)
|
|
if self.config.config == nil {
|
|
readiness = .error
|
|
return
|
|
}
|
|
|
|
// Create our "runtime" config. The "runtime" is the configuration that ghostty
|
|
// uses to interface with the application runtime environment.
|
|
var runtime_cfg = ghostty_runtime_config_s(
|
|
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
|
supports_selection_clipboard: true,
|
|
wakeup_cb: { userdata in App.wakeup(userdata) },
|
|
action_cb: { app, target, action in App.action(app!, target: target, action: action) },
|
|
read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) },
|
|
confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
|
|
write_clipboard_cb: { userdata, loc, content, len, confirm in
|
|
App.writeClipboard(userdata, location: loc, content: content, len: len, confirm: confirm) },
|
|
close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }
|
|
)
|
|
|
|
// Create the ghostty app.
|
|
guard let app = ghostty_app_new(&runtime_cfg, config.config) else {
|
|
logger.critical("ghostty_app_new failed")
|
|
readiness = .error
|
|
return
|
|
}
|
|
self.app = app
|
|
|
|
#if os(macOS)
|
|
// Set our initial focus state
|
|
ghostty_app_set_focus(app, NSApp.isActive)
|
|
|
|
let center = NotificationCenter.default
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(keyboardSelectionDidChange(notification:)),
|
|
name: NSTextInputContext.keyboardSelectionDidChangeNotification,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidBecomeActive(notification:)),
|
|
name: NSApplication.didBecomeActiveNotification,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidResignActive(notification:)),
|
|
name: NSApplication.didResignActiveNotification,
|
|
object: nil)
|
|
#endif
|
|
|
|
self.readiness = .ready
|
|
}
|
|
|
|
deinit {
|
|
// This will force the didSet callbacks to run which free.
|
|
self.app = nil
|
|
|
|
#if os(macOS)
|
|
NotificationCenter.default.removeObserver(self)
|
|
#endif
|
|
}
|
|
|
|
// MARK: App Operations
|
|
|
|
func appTick() {
|
|
guard let app = self.app else { return }
|
|
ghostty_app_tick(app)
|
|
}
|
|
|
|
static func openConfig() {
|
|
let str = Ghostty.AllocatedString(ghostty_config_open_path()).string
|
|
guard !str.isEmpty else { return }
|
|
#if os(macOS)
|
|
let fileURL = URL(fileURLWithPath: str).absoluteString
|
|
var action = ghostty_action_open_url_s()
|
|
action.kind = GHOSTTY_ACTION_OPEN_URL_KIND_TEXT
|
|
fileURL.withCString { cStr in
|
|
action.url = cStr
|
|
action.len = UInt(fileURL.count)
|
|
_ = openURL(action)
|
|
}
|
|
#else
|
|
fatalError("Unsupported platform for opening config file")
|
|
#endif
|
|
}
|
|
|
|
/// Reload the configuration.
|
|
func reloadConfig(soft: Bool = false) {
|
|
guard let app = self.app else { return }
|
|
|
|
// Soft updates just call with our existing config
|
|
if soft {
|
|
ghostty_app_update_config(app, config.config!)
|
|
return
|
|
}
|
|
|
|
// Hard or full updates have to reload the full configuration
|
|
let newConfig = Config(at: configPath)
|
|
guard newConfig.loaded else {
|
|
Ghostty.logger.warning("failed to reload configuration")
|
|
return
|
|
}
|
|
|
|
ghostty_app_update_config(app, newConfig.config!)
|
|
/// applied config will be updated in ``Self.configChange(_:target:v:)``
|
|
}
|
|
|
|
func reloadConfig(surface: ghostty_surface_t, soft: Bool = false) {
|
|
// Soft updates just call with our existing config
|
|
if soft {
|
|
ghostty_surface_update_config(surface, config.config!)
|
|
return
|
|
}
|
|
|
|
// Hard or full updates have to reload the full configuration.
|
|
// NOTE: We never set this on self.config because this is a surface-only
|
|
// config. We free it after the call.
|
|
let newConfig = Config(at: configPath)
|
|
guard newConfig.loaded else {
|
|
Ghostty.logger.warning("failed to reload configuration")
|
|
return
|
|
}
|
|
|
|
ghostty_surface_update_config(surface, newConfig.config!)
|
|
}
|
|
|
|
/// Request that the given surface is closed. This will trigger the full normal surface close event
|
|
/// cycle which will call our close surface callback.
|
|
func requestClose(surface: ghostty_surface_t) {
|
|
ghostty_surface_request_close(surface)
|
|
}
|
|
|
|
func newTab(surface: ghostty_surface_t) {
|
|
let action = "new_tab"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
func newWindow(surface: ghostty_surface_t) {
|
|
let action = "new_window"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
func split(surface: ghostty_surface_t, direction: ghostty_action_split_direction_e) {
|
|
ghostty_surface_split(surface, direction)
|
|
}
|
|
|
|
func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) {
|
|
ghostty_surface_split_focus(surface, direction.toNative())
|
|
}
|
|
|
|
func splitResize(surface: ghostty_surface_t, direction: SplitResizeDirection, amount: UInt16) {
|
|
ghostty_surface_split_resize(surface, direction.toNative(), amount)
|
|
}
|
|
|
|
func splitEqualize(surface: ghostty_surface_t) {
|
|
ghostty_surface_split_equalize(surface)
|
|
}
|
|
|
|
func splitToggleZoom(surface: ghostty_surface_t) {
|
|
let action = "toggle_split_zoom"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
func toggleFullscreen(surface: ghostty_surface_t) {
|
|
let action = "toggle_fullscreen"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
enum FontSizeModification {
|
|
case increase(Int)
|
|
case decrease(Int)
|
|
case reset
|
|
}
|
|
|
|
func changeFontSize(surface: ghostty_surface_t, _ change: FontSizeModification) {
|
|
let action: String
|
|
switch change {
|
|
case .increase(let amount):
|
|
action = "increase_font_size:\(amount)"
|
|
case .decrease(let amount):
|
|
action = "decrease_font_size:\(amount)"
|
|
case .reset:
|
|
action = "reset_font_size"
|
|
}
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
func toggleTerminalInspector(surface: ghostty_surface_t) {
|
|
let action = "inspector:toggle"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
func resetTerminal(surface: ghostty_surface_t) {
|
|
let action = "reset"
|
|
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
|
|
logger.warning("action failed action=\(action)")
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
// MARK: Ghostty Callbacks (iOS)
|
|
|
|
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
|
|
static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool { return false }
|
|
static func readClipboard(
|
|
_ userdata: UnsafeMutableRawPointer?,
|
|
location: ghostty_clipboard_e,
|
|
state: UnsafeMutableRawPointer?
|
|
) {}
|
|
|
|
static func confirmReadClipboard(
|
|
_ userdata: UnsafeMutableRawPointer?,
|
|
string: UnsafePointer<CChar>?,
|
|
state: UnsafeMutableRawPointer?,
|
|
request: ghostty_clipboard_request_e
|
|
) {}
|
|
|
|
static func writeClipboard(
|
|
_ userdata: UnsafeMutableRawPointer?,
|
|
location: ghostty_clipboard_e,
|
|
content: UnsafePointer<ghostty_clipboard_content_s>?,
|
|
len: Int,
|
|
confirm: Bool
|
|
) {}
|
|
|
|
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {}
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
|
|
// MARK: Notifications
|
|
|
|
// Called when the selected keyboard changes. We have to notify Ghostty so that
|
|
// it can reload the keyboard mapping for input.
|
|
@objc private func keyboardSelectionDidChange(notification: NSNotification) {
|
|
guard let app = self.app else { return }
|
|
ghostty_app_keyboard_changed(app)
|
|
}
|
|
|
|
// Called when the app becomes active.
|
|
@objc private func applicationDidBecomeActive(notification: NSNotification) {
|
|
guard let app = self.app else { return }
|
|
ghostty_app_set_focus(app, true)
|
|
}
|
|
|
|
// Called when the app becomes inactive.
|
|
@objc private func applicationDidResignActive(notification: NSNotification) {
|
|
guard let app = self.app else { return }
|
|
ghostty_app_set_focus(app, false)
|
|
}
|
|
|
|
// MARK: Ghostty Callbacks (macOS)
|
|
|
|
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {
|
|
let surface = self.surfaceUserdata(from: userdata)
|
|
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [
|
|
"process_alive": processAlive,
|
|
])
|
|
}
|
|
|
|
static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) {
|
|
// If we don't even have a surface, something went terrible wrong so we have
|
|
// to leak "state".
|
|
let surfaceView = self.surfaceUserdata(from: userdata)
|
|
guard let surface = surfaceView.surface else { return }
|
|
|
|
// Get our pasteboard
|
|
guard let pasteboard = NSPasteboard.ghostty(location) else {
|
|
return completeClipboardRequest(surface, data: "", state: state)
|
|
}
|
|
|
|
// Get our string
|
|
let str = pasteboard.getOpinionatedStringContents() ?? ""
|
|
completeClipboardRequest(surface, data: str, state: state)
|
|
}
|
|
|
|
static func confirmReadClipboard(
|
|
_ userdata: UnsafeMutableRawPointer?,
|
|
string: UnsafePointer<CChar>?,
|
|
state: UnsafeMutableRawPointer?,
|
|
request: ghostty_clipboard_request_e
|
|
) {
|
|
let surface = self.surfaceUserdata(from: userdata)
|
|
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
|
guard let request = Ghostty.ClipboardRequest.from(request: request) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.confirmClipboard,
|
|
object: surface,
|
|
userInfo: [
|
|
Notification.ConfirmClipboardStrKey: valueStr,
|
|
Notification.ConfirmClipboardStateKey: state as Any,
|
|
Notification.ConfirmClipboardRequestKey: request,
|
|
]
|
|
)
|
|
}
|
|
|
|
static func completeClipboardRequest(
|
|
_ surface: ghostty_surface_t,
|
|
data: String,
|
|
state: UnsafeMutableRawPointer?,
|
|
confirmed: Bool = false
|
|
) {
|
|
data.withCString { ptr in
|
|
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
|
|
}
|
|
}
|
|
|
|
static func writeClipboard(
|
|
_ userdata: UnsafeMutableRawPointer?,
|
|
location: ghostty_clipboard_e,
|
|
content: UnsafePointer<ghostty_clipboard_content_s>?,
|
|
len: Int,
|
|
confirm: Bool
|
|
) {
|
|
let surface = self.surfaceUserdata(from: userdata)
|
|
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
|
|
guard let content = content, len > 0 else { return }
|
|
|
|
// Convert the C array to Swift array
|
|
let contentArray = (0..<len).compactMap { i in
|
|
Ghostty.ClipboardContent.from(content: content[i])
|
|
}
|
|
guard !contentArray.isEmpty else { return }
|
|
|
|
// Assert there is only one text/plain entry. For security reasons we need
|
|
// to guarantee this for now since our confirmation dialog only shows one.
|
|
assert(contentArray.filter({ $0.mime == "text/plain" }).count <= 1,
|
|
"clipboard contents should have at most one text/plain entry")
|
|
|
|
if !confirm {
|
|
// Declare all types
|
|
let types = contentArray.compactMap { item in
|
|
NSPasteboard.PasteboardType(mimeType: item.mime)
|
|
}
|
|
pasteboard.declareTypes(types, owner: nil)
|
|
|
|
// Set data for each type
|
|
for item in contentArray {
|
|
guard let type = NSPasteboard.PasteboardType(mimeType: item.mime) else { continue }
|
|
pasteboard.setString(item.data, forType: type)
|
|
}
|
|
return
|
|
}
|
|
|
|
// For confirmation, use the text/plain content if it exists
|
|
guard let textPlainContent = contentArray.first(where: { $0.mime == "text/plain" }) else {
|
|
return
|
|
}
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.confirmClipboard,
|
|
object: surface,
|
|
userInfo: [
|
|
Notification.ConfirmClipboardStrKey: textPlainContent.data,
|
|
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
|
|
]
|
|
)
|
|
}
|
|
|
|
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
|
|
let state = Unmanaged<App>.fromOpaque(userdata!).takeUnretainedValue()
|
|
|
|
// Wakeup can be called from any thread so we schedule the app tick
|
|
// from the main thread. There is probably some improvements we can make
|
|
// to coalesce multiple ticks but I don't think it matters from a performance
|
|
// standpoint since we don't do this much.
|
|
DispatchQueue.main.async { state.appTick() }
|
|
}
|
|
|
|
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
|
|
func shouldPresentNotification(notification: UNNotification) -> Bool {
|
|
let userInfo = notification.request.content.userInfo
|
|
guard let uuidString = userInfo["surface"] as? String,
|
|
let uuid = UUID(uuidString: uuidString),
|
|
let surface = delegate?.findSurface(forUUID: uuid),
|
|
let window = surface.window else { return false }
|
|
return !window.isKeyWindow || !surface.focused
|
|
}
|
|
|
|
/// Returns the GhosttyState from the given userdata value.
|
|
static private func appState(fromView view: SurfaceView) -> App? {
|
|
guard let surface = view.surface else { return nil }
|
|
guard let app = ghostty_surface_app(surface) else { return nil }
|
|
guard let app_ud = ghostty_app_userdata(app) else { return nil }
|
|
return Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
|
|
}
|
|
|
|
/// Returns the surface view from the userdata.
|
|
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
|
|
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
|
}
|
|
|
|
static private func surfaceView(from surface: ghostty_surface_t) -> SurfaceView? {
|
|
guard let surface_ud = ghostty_surface_userdata(surface) else { return nil }
|
|
return Unmanaged<SurfaceView>.fromOpaque(surface_ud).takeUnretainedValue()
|
|
}
|
|
|
|
// MARK: Actions (macOS)
|
|
|
|
static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool {
|
|
// Make sure it a target we understand so all our action handlers can assert
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE:
|
|
break
|
|
|
|
default:
|
|
Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)")
|
|
return false
|
|
}
|
|
|
|
// Action dispatch
|
|
switch action.tag {
|
|
case GHOSTTY_ACTION_QUIT:
|
|
quit(app)
|
|
|
|
case GHOSTTY_ACTION_NEW_WINDOW:
|
|
newWindow(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_NEW_TAB:
|
|
newTab(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_NEW_SPLIT:
|
|
newSplit(app, target: target, direction: action.action.new_split)
|
|
|
|
case GHOSTTY_ACTION_CLOSE_TAB:
|
|
closeTab(app, target: target, mode: action.action.close_tab_mode)
|
|
|
|
case GHOSTTY_ACTION_CLOSE_WINDOW:
|
|
closeWindow(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_FULLSCREEN:
|
|
toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen)
|
|
|
|
case GHOSTTY_ACTION_MOVE_TAB:
|
|
return moveTab(app, target: target, move: action.action.move_tab)
|
|
|
|
case GHOSTTY_ACTION_GOTO_TAB:
|
|
return gotoTab(app, target: target, tab: action.action.goto_tab)
|
|
|
|
case GHOSTTY_ACTION_GOTO_SPLIT:
|
|
return gotoSplit(app, target: target, direction: action.action.goto_split)
|
|
|
|
case GHOSTTY_ACTION_GOTO_WINDOW:
|
|
return gotoWindow(app, target: target, direction: action.action.goto_window)
|
|
|
|
case GHOSTTY_ACTION_RESIZE_SPLIT:
|
|
return resizeSplit(app, target: target, resize: action.action.resize_split)
|
|
|
|
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
|
|
equalizeSplits(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
|
|
return toggleSplitZoom(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_INSPECTOR:
|
|
controlInspector(app, target: target, mode: action.action.inspector)
|
|
|
|
case GHOSTTY_ACTION_RENDER_INSPECTOR:
|
|
renderInspector(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
|
|
showDesktopNotification(app, target: target, n: action.action.desktop_notification)
|
|
|
|
case GHOSTTY_ACTION_SET_TITLE:
|
|
setTitle(app, target: target, v: action.action.set_title)
|
|
|
|
case GHOSTTY_ACTION_PROMPT_TITLE:
|
|
return promptTitle(app, target: target, v: action.action.prompt_title)
|
|
|
|
case GHOSTTY_ACTION_PWD:
|
|
pwdChanged(app, target: target, v: action.action.pwd)
|
|
|
|
case GHOSTTY_ACTION_OPEN_CONFIG:
|
|
openConfig()
|
|
|
|
case GHOSTTY_ACTION_FLOAT_WINDOW:
|
|
toggleFloatWindow(app, target: target, mode: action.action.float_window)
|
|
|
|
case GHOSTTY_ACTION_SECURE_INPUT:
|
|
toggleSecureInput(app, target: target, mode: action.action.secure_input)
|
|
|
|
case GHOSTTY_ACTION_MOUSE_SHAPE:
|
|
setMouseShape(app, target: target, shape: action.action.mouse_shape)
|
|
|
|
case GHOSTTY_ACTION_MOUSE_VISIBILITY:
|
|
setMouseVisibility(app, target: target, v: action.action.mouse_visibility)
|
|
|
|
case GHOSTTY_ACTION_MOUSE_OVER_LINK:
|
|
setMouseOverLink(app, target: target, v: action.action.mouse_over_link)
|
|
|
|
case GHOSTTY_ACTION_INITIAL_SIZE:
|
|
setInitialSize(app, target: target, v: action.action.initial_size)
|
|
|
|
case GHOSTTY_ACTION_RESET_WINDOW_SIZE:
|
|
resetWindowSize(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_CELL_SIZE:
|
|
setCellSize(app, target: target, v: action.action.cell_size)
|
|
|
|
case GHOSTTY_ACTION_RENDERER_HEALTH:
|
|
rendererHealth(app, target: target, v: action.action.renderer_health)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE:
|
|
toggleCommandPalette(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_MAXIMIZE:
|
|
toggleMaximize(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL:
|
|
toggleQuickTerminal(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
|
|
toggleVisibility(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
|
|
toggleBackgroundOpacity(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_KEY_SEQUENCE:
|
|
keySequence(app, target: target, v: action.action.key_sequence)
|
|
|
|
case GHOSTTY_ACTION_KEY_TABLE:
|
|
keyTable(app, target: target, v: action.action.key_table)
|
|
|
|
case GHOSTTY_ACTION_PROGRESS_REPORT:
|
|
progressReport(app, target: target, v: action.action.progress_report)
|
|
|
|
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
|
configChange(app, target: target, v: action.action.config_change)
|
|
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
configReload(app, target: target, v: action.action.reload_config)
|
|
|
|
case GHOSTTY_ACTION_COLOR_CHANGE:
|
|
colorChange(app, target: target, change: action.action.color_change)
|
|
|
|
case GHOSTTY_ACTION_RING_BELL:
|
|
ringBell(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_READONLY:
|
|
setReadonly(app, target: target, v: action.action.readonly)
|
|
|
|
case GHOSTTY_ACTION_CHECK_FOR_UPDATES:
|
|
checkForUpdates(app)
|
|
|
|
case GHOSTTY_ACTION_OPEN_URL:
|
|
return openURL(action.action.open_url)
|
|
|
|
case GHOSTTY_ACTION_UNDO:
|
|
return undo(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_REDO:
|
|
return redo(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_SCROLLBAR:
|
|
scrollbar(app, target: target, v: action.action.scrollbar)
|
|
|
|
case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS:
|
|
closeAllWindows(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_START_SEARCH:
|
|
startSearch(app, target: target, v: action.action.start_search)
|
|
|
|
case GHOSTTY_ACTION_END_SEARCH:
|
|
endSearch(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_SEARCH_TOTAL:
|
|
searchTotal(app, target: target, v: action.action.search_total)
|
|
|
|
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
|
searchSelected(app, target: target, v: action.action.search_selected)
|
|
|
|
case GHOSTTY_ACTION_PRESENT_TERMINAL:
|
|
return presentTerminal(app, target: target)
|
|
|
|
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
|
|
fallthrough
|
|
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
|
|
fallthrough
|
|
case GHOSTTY_ACTION_SIZE_LIMIT:
|
|
fallthrough
|
|
case GHOSTTY_ACTION_QUIT_TIMER:
|
|
fallthrough
|
|
case GHOSTTY_ACTION_SHOW_CHILD_EXITED:
|
|
Ghostty.logger.info("known but unimplemented action action=\(action.tag.rawValue)")
|
|
return false
|
|
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
|
|
return copyTitleToClipboard(app, target: target)
|
|
default:
|
|
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)")
|
|
return false
|
|
}
|
|
|
|
// If we reached here then we assume performed since all unknown actions
|
|
// are captured in the switch and return false.
|
|
return true
|
|
}
|
|
|
|
private static func quit(_ app: ghostty_app_t) {
|
|
// On iOS, applications do not terminate programmatically like they do
|
|
// on macOS. On iOS, applications are only terminated when a user physically
|
|
// closes the application (i.e. going to the home screen). If we request
|
|
// exit on iOS we ignore it.
|
|
#if os(iOS)
|
|
logger.info("quit request received, ignoring on iOS")
|
|
#endif
|
|
|
|
#if os(macOS)
|
|
// We want to quit, start that process
|
|
NSApplication.shared.terminate(nil)
|
|
#endif
|
|
}
|
|
|
|
private static func checkForUpdates(
|
|
_ app: ghostty_app_t
|
|
) {
|
|
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
|
appDelegate.checkForUpdates(nil)
|
|
}
|
|
}
|
|
|
|
private static func openURL(
|
|
_ v: ghostty_action_open_url_s
|
|
) -> Bool {
|
|
let action = Ghostty.Action.OpenURL(c: v)
|
|
|
|
// If the URL doesn't have a valid scheme we assume its a file path. The URL
|
|
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
|
|
// them into schema-less URLs, but these won't open properly in text editors.
|
|
// See: https://github.com/ghostty-org/ghostty/issues/8763
|
|
let url: URL
|
|
if let candidate = URL(string: action.url), candidate.scheme != nil {
|
|
url = candidate
|
|
} else {
|
|
// Expand ~ to the user's home directory so that file paths
|
|
// like ~/Documents/file.txt resolve correctly.
|
|
let expandedPath = NSString(string: action.url).standardizingPath
|
|
url = URL(filePath: expandedPath)
|
|
}
|
|
|
|
switch action.kind {
|
|
case .text:
|
|
// Open with the default editor for `*.ghostty` file or just system text editor
|
|
let editor = NSWorkspace.shared.defaultApplicationURL(forExtension: url.pathExtension) ?? NSWorkspace.shared.defaultTextEditor
|
|
if let textEditor = editor {
|
|
NSWorkspace.shared.open([url], withApplicationAt: textEditor, configuration: NSWorkspace.OpenConfiguration())
|
|
return true
|
|
}
|
|
|
|
case .html:
|
|
// The extension will be HTML and we do the right thing automatically.
|
|
break
|
|
|
|
case .unknown:
|
|
break
|
|
}
|
|
|
|
// Open with the default application for the URL
|
|
NSWorkspace.shared.open(url)
|
|
return true
|
|
}
|
|
|
|
private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
|
|
let undoManager: UndoManager?
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
undoManager = surfaceView.undoManager
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
|
|
guard let undoManager, undoManager.canUndo else { return false }
|
|
undoManager.undo()
|
|
return true
|
|
}
|
|
|
|
private static func redo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
|
|
let undoManager: UndoManager?
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
undoManager = (NSApp.delegate as? AppDelegate)?.undoManager
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
undoManager = surfaceView.undoManager
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
|
|
guard let undoManager, undoManager.canRedo else { return false }
|
|
undoManager.redo()
|
|
return true
|
|
}
|
|
|
|
private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyNewWindow,
|
|
object: nil,
|
|
userInfo: [:]
|
|
)
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyNewWindow,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_WINDOW)),
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func newTab(_ app: ghostty_app_t, target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyNewTab,
|
|
object: nil,
|
|
userInfo: [:]
|
|
)
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let appState = self.appState(fromView: surfaceView) else { return }
|
|
guard appState.config.windowDecorations else {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Tabs are disabled"
|
|
alert.informativeText = "Enable window decorations to use tabs"
|
|
alert.addButton(withTitle: "OK")
|
|
alert.alertStyle = .warning
|
|
_ = alert.runModal()
|
|
return
|
|
}
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyNewTab,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB)),
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func newSplit(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
direction: ghostty_action_split_direction_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
// New split does nothing with an app target
|
|
Ghostty.logger.warning("new split does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyNewSplit,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
"direction": direction,
|
|
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT)),
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func presentTerminal(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s
|
|
) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyPresentTerminal,
|
|
object: surfaceView
|
|
)
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("close tabs does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
switch mode {
|
|
case GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS:
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyCloseTab,
|
|
object: surfaceView
|
|
)
|
|
return
|
|
|
|
case GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER:
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyCloseOtherTabs,
|
|
object: surfaceView
|
|
)
|
|
return
|
|
|
|
case GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT:
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyCloseTabsOnTheRight,
|
|
object: surfaceView
|
|
)
|
|
return
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func closeWindow(_ app: ghostty_app_t, target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("close window does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyCloseWindow,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func closeAllWindows(_ app: ghostty_app_t, target: ghostty_target_s) {
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
|
|
appDelegate.closeAllWindows(nil)
|
|
}
|
|
|
|
private static func toggleFullscreen(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
mode raw: ghostty_action_fullscreen_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle fullscreen does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let mode = FullscreenMode.from(ghostty: raw) else {
|
|
Ghostty.logger.warning("unknown fullscreen mode raw=\(raw.rawValue)")
|
|
return
|
|
}
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyToggleFullscreen,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.FullscreenModeKey: mode,
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleCommandPalette(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle command palette does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyCommandPaletteDidToggle,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleMaximize(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s
|
|
) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle maximize does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyMaximizeDidToggle,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleVisibility(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s
|
|
) {
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
|
|
appDelegate.toggleVisibility(self)
|
|
}
|
|
|
|
private static func ringBell(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
// Technically we could still request app attention here but there
|
|
// are no known cases where the bell is rang with an app target so
|
|
// I think its better to warn.
|
|
Ghostty.logger.warning("ring bell does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyBellDidRing,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setReadonly(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_readonly_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("set readonly does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidChangeReadonly,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.ReadonlyKey: v == GHOSTTY_READONLY_ON,
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func moveTab(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
move: ghostty_action_move_tab_s) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("move tab does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
|
|
// See gotoTab for notes on this check.
|
|
guard (surfaceView.window?.tabGroup?.windows.count ?? 0) > 1 else { return false }
|
|
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyMoveTab,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.GhosttyMoveTabKey: Action.MoveTab(c: move),
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private static func gotoTab(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
tab: ghostty_action_goto_tab_e) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("goto tab does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
|
|
// Similar to goto_split (see comment there) about our performability,
|
|
// we should make this more accurate later.
|
|
guard (surfaceView.window?.tabGroup?.windows.count ?? 0) > 1 else { return false }
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyGotoTab,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.GotoTabKey: tab,
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private static func gotoSplit(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
direction: ghostty_action_goto_split_e) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("goto split does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
|
|
|
// If the window has no splits, the action is not performable
|
|
guard controller.surfaceTree.isSplit else { return false }
|
|
|
|
// Convert the C API direction to our Swift type
|
|
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return false }
|
|
|
|
// Find the current node in the tree
|
|
guard let targetNode = controller.surfaceTree.root?.node(view: surfaceView) else { return false }
|
|
|
|
// Check if a split actually exists in the target direction before
|
|
// returning true. This ensures performable keybinds only consume
|
|
// the key event when we actually perform navigation.
|
|
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection = splitDirection.toSplitTreeFocusDirection()
|
|
guard controller.surfaceTree.focusTarget(for: focusDirection, from: targetNode) != nil else {
|
|
return false
|
|
}
|
|
|
|
// We have a valid target, post the notification to perform the navigation
|
|
NotificationCenter.default.post(
|
|
name: Notification.ghosttyFocusSplit,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.SplitDirectionKey: splitDirection as Any,
|
|
]
|
|
)
|
|
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func gotoWindow(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
direction: ghostty_action_goto_window_e
|
|
) -> Bool {
|
|
// Collect candidate windows: visible terminal windows that are either
|
|
// standalone or the currently selected tab in their tab group. This
|
|
// treats each native tab group as a single "window" for navigation
|
|
// purposes, since goto_tab handles per-tab navigation.
|
|
let candidates: [NSWindow] = NSApplication.shared.windows.filter { window in
|
|
guard window.windowController is BaseTerminalController else { return false }
|
|
guard window.isVisible, !window.isMiniaturized else { return false }
|
|
// For native tabs, only include the selected tab in each group
|
|
if let group = window.tabGroup, group.selectedWindow !== window {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Need at least two windows to navigate between
|
|
guard candidates.count > 1 else { return false }
|
|
|
|
// Find starting index from the current key/main window
|
|
let startIndex = candidates.firstIndex(where: { $0.isKeyWindow })
|
|
?? candidates.firstIndex(where: { $0.isMainWindow })
|
|
?? 0
|
|
|
|
let step: Int
|
|
switch direction {
|
|
case GHOSTTY_GOTO_WINDOW_NEXT:
|
|
step = 1
|
|
case GHOSTTY_GOTO_WINDOW_PREVIOUS:
|
|
step = -1
|
|
default:
|
|
return false
|
|
}
|
|
|
|
// Iterate with wrap-around until we find a valid window or return to start
|
|
let count = candidates.count
|
|
var index = (startIndex + step + count) % count
|
|
|
|
while index != startIndex {
|
|
let candidate = candidates[index]
|
|
if candidate.isVisible, !candidate.isMiniaturized {
|
|
candidate.makeKeyAndOrderFront(nil)
|
|
// Also focus the terminal surface within the window
|
|
if let controller = candidate.windowController as? BaseTerminalController,
|
|
let surface = controller.focusedSurface {
|
|
Ghostty.moveFocus(to: surface)
|
|
}
|
|
return true
|
|
}
|
|
index = (index + step + count) % count
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private static func resizeSplit(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
resize: ghostty_action_resize_split_s) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("resize split does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
|
|
|
// If the window has no splits, the action is not performable
|
|
guard controller.surfaceTree.isSplit else { return false }
|
|
|
|
guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return false }
|
|
NotificationCenter.default.post(
|
|
name: Notification.didResizeSplit,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.ResizeSplitDirectionKey: resizeDirection,
|
|
Notification.ResizeSplitAmountKey: resize.amount,
|
|
]
|
|
)
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func equalizeSplits(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("equalize splits does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.didEqualizeSplits,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleSplitZoom(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else { return false }
|
|
|
|
// If the window has no splits, the action is not performable
|
|
guard controller.surfaceTree.isSplit else { return false }
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.didToggleSplitZoom,
|
|
object: surfaceView
|
|
)
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func controlInspector(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
mode: ghostty_action_inspector_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle inspector does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.didControlInspector,
|
|
object: surfaceView,
|
|
userInfo: ["mode": mode]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func showDesktopNotification(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
n: ghostty_action_desktop_notification_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle split zoom does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let title = String(cString: n.title!, encoding: .utf8) else { return }
|
|
guard let body = String(cString: n.body!, encoding: .utf8) else { return }
|
|
|
|
let center = UNUserNotificationCenter.current()
|
|
center.requestAuthorization(options: [.alert, .sound]) { _, error in
|
|
if let error = error {
|
|
Ghostty.logger.error("Error while requesting notification authorization: \(error)")
|
|
}
|
|
}
|
|
|
|
center.getNotificationSettings { settings in
|
|
guard settings.authorizationStatus == .authorized else { return }
|
|
surfaceView.showUserNotification(title: title, body: body)
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleFloatWindow(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
mode mode_raw: ghostty_action_float_window_e
|
|
) {
|
|
guard let mode = SetFloatWIndow.from(mode_raw) else { return }
|
|
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle float window does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let window = surfaceView.window as? TerminalWindow else { return }
|
|
|
|
switch mode {
|
|
case .on:
|
|
window.level = .floating
|
|
|
|
case .off:
|
|
window.level = .normal
|
|
|
|
case .toggle:
|
|
window.level = window.level == .floating ? .normal : .floating
|
|
}
|
|
|
|
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
|
appDelegate.syncFloatOnTopMenu(window)
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleBackgroundOpacity(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s
|
|
) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("toggle background opacity does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface,
|
|
let surfaceView = self.surfaceView(from: surface),
|
|
let controller = surfaceView.window?.windowController as? BaseTerminalController else { return }
|
|
|
|
controller.toggleBackgroundOpacity()
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleSecureInput(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
mode mode_raw: ghostty_action_secure_input_e
|
|
) {
|
|
guard let mode = SetSecureInput.from(mode_raw) else { return }
|
|
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
|
|
appDelegate.setSecureInput(mode)
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let appState = self.appState(fromView: surfaceView) else { return }
|
|
guard appState.config.autoSecureInput else { return }
|
|
|
|
switch mode {
|
|
case .on:
|
|
surfaceView.passwordInput = true
|
|
|
|
case .off:
|
|
surfaceView.passwordInput = false
|
|
|
|
case .toggle:
|
|
surfaceView.passwordInput = !surfaceView.passwordInput
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func toggleQuickTerminal(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s
|
|
) {
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
|
|
appDelegate.toggleQuickTerminal(self)
|
|
}
|
|
|
|
private static func setTitle(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_set_title_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("set title does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let title = String(cString: v.title!, encoding: .utf8) else { return }
|
|
surfaceView.setTitle(title)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func copyTitleToClipboard(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) -> Bool {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
let title = surfaceView.title
|
|
if title.isEmpty { return false }
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(title, forType: .string)
|
|
return true
|
|
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func promptTitle(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_prompt_title_e) -> Bool {
|
|
let promptTitle = Action.PromptTitle(v)
|
|
switch promptTitle {
|
|
case .surface:
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("set title prompt does nothing with an app target")
|
|
return false
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
surfaceView.promptTitle()
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
|
|
case .tab:
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
guard let window = NSApp.mainWindow ?? NSApp.keyWindow,
|
|
let controller = window.windowController as? BaseTerminalController
|
|
else { return false }
|
|
controller.promptTabTitle()
|
|
return true
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return false }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return false }
|
|
guard let window = surfaceView.window,
|
|
let controller = window.windowController as? BaseTerminalController
|
|
else { return false }
|
|
controller.promptTabTitle()
|
|
return true
|
|
|
|
default:
|
|
assertionFailure()
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func pwdChanged(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_pwd_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("pwd change does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let pwd = String(cString: v.pwd!, encoding: .utf8) else { return }
|
|
surfaceView.pwd = pwd
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setMouseShape(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
shape: ghostty_action_mouse_shape_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("set mouse shapes nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
surfaceView.setCursorShape(shape)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setMouseVisibility(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_mouse_visibility_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("set mouse shapes nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
switch v {
|
|
case GHOSTTY_MOUSE_VISIBLE:
|
|
surfaceView.setCursorVisibility(true)
|
|
|
|
case GHOSTTY_MOUSE_HIDDEN:
|
|
surfaceView.setCursorVisibility(false)
|
|
|
|
default:
|
|
return
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setMouseOverLink(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_mouse_over_link_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("mouse over link does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard v.len > 0 else {
|
|
surfaceView.hoverUrl = nil
|
|
return
|
|
}
|
|
|
|
let buffer = Data(bytes: v.url!, count: v.len)
|
|
surfaceView.hoverUrl = String(data: buffer, encoding: .utf8)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setInitialSize(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_initial_size_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("initial size does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
surfaceView.initialSize = NSSize(width: Double(v.width), height: Double(v.height))
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func resetWindowSize(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("reset window size does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyResetWindowSize,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func setCellSize(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_cell_size_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("mouse over link does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
let backingSize = NSSize(width: Double(v.width), height: Double(v.height))
|
|
DispatchQueue.main.async { [weak surfaceView] in
|
|
guard let surfaceView else { return }
|
|
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func renderInspector(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("mouse over link does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.inspectorNeedsDisplay,
|
|
object: surfaceView
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func rendererHealth(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_renderer_health_e) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("mouse over link does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: Notification.didUpdateRendererHealth,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
"health": v,
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func keySequence(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_key_sequence_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("key sequence does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
if v.active {
|
|
NotificationCenter.default.post(
|
|
name: Notification.didContinueKeySequence,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
Notification.KeySequenceKey: keyboardShortcut(for: v.trigger) as Any
|
|
]
|
|
)
|
|
} else {
|
|
NotificationCenter.default.post(
|
|
name: Notification.didEndKeySequence,
|
|
object: surfaceView
|
|
)
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func keyTable(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_key_table_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("key table does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
guard let action = Ghostty.Action.KeyTable(c: v) else { return }
|
|
|
|
NotificationCenter.default.post(
|
|
name: Notification.didChangeKeyTable,
|
|
object: surfaceView,
|
|
userInfo: [Notification.KeyTableKey: action]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func progressReport(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_progress_report_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("progress report does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
let progressReport = Ghostty.Action.ProgressReport(c: v)
|
|
DispatchQueue.main.async {
|
|
if progressReport.state == .remove {
|
|
surfaceView.progressReport = nil
|
|
} else {
|
|
surfaceView.progressReport = progressReport
|
|
}
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func scrollbar(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_scrollbar_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("scrollbar does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
let scrollbar = Ghostty.Action.Scrollbar(c: v)
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateScrollbar,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.ScrollbarKey: scrollbar
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func startSearch(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_start_search_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("start_search does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
let startSearch = Ghostty.Action.StartSearch(c: v)
|
|
DispatchQueue.main.async {
|
|
if let searchState = surfaceView.searchState {
|
|
if let needle = startSearch.needle, !needle.isEmpty {
|
|
searchState.needle = needle
|
|
}
|
|
} else {
|
|
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func endSearch(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("end_search does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
surfaceView.searchState = nil
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func searchTotal(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_search_total_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("search_total does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
let total: UInt? = v.total >= 0 ? UInt(v.total) : nil
|
|
DispatchQueue.main.async {
|
|
surfaceView.searchState?.total = total
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func searchSelected(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_search_selected_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("search_selected does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
|
|
let selected: UInt? = v.selected >= 0 ? UInt(v.selected) : nil
|
|
DispatchQueue.main.async {
|
|
surfaceView.searchState?.selected = selected
|
|
}
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func configReload(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_reload_config_s) {
|
|
logger.info("config reload notification")
|
|
|
|
guard let app_ud = ghostty_app_userdata(app) else { return }
|
|
let ghostty = Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
|
|
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
ghostty.reloadConfig(soft: v.soft)
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
ghostty.reloadConfig(surface: surface, soft: v.soft)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func configChange(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
v: ghostty_action_config_change_s) {
|
|
logger.info("config change notification")
|
|
|
|
// Clone the config so we own the memory. It'd be nicer to not have to do
|
|
// this but since we async send the config out below we have to own the lifetime.
|
|
// A future improvement might be to add reference counting to config or
|
|
// something so apprt's do not have to do this.
|
|
let config = Config(clone: v.config)
|
|
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
// Notify the world that the app config changed
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyConfigDidChange,
|
|
object: nil,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
|
|
]
|
|
)
|
|
|
|
// We also REPLACE our app-level config when this happens. This lets
|
|
// all the various things that depend on this but are still theme specific
|
|
// such as split border color work.
|
|
guard let app_ud = ghostty_app_userdata(app) else { return }
|
|
let ghostty = Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
|
|
ghostty.config = config
|
|
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyConfigDidChange,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.GhosttyConfigChangeKey: config,
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
private static func colorChange(
|
|
_ app: ghostty_app_t,
|
|
target: ghostty_target_s,
|
|
change: ghostty_action_color_change_s) {
|
|
switch target.tag {
|
|
case GHOSTTY_TARGET_APP:
|
|
Ghostty.logger.warning("color change does nothing with an app target")
|
|
return
|
|
|
|
case GHOSTTY_TARGET_SURFACE:
|
|
guard let surface = target.target.surface else { return }
|
|
guard let surfaceView = self.surfaceView(from: surface) else { return }
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyColorDidChange,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
SwiftUI.Notification.Name.GhosttyColorChangeKey: Action.ColorChange(c: change)
|
|
]
|
|
)
|
|
|
|
default:
|
|
assertionFailure()
|
|
}
|
|
}
|
|
|
|
// MARK: User Notifications
|
|
|
|
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user
|
|
func handleUserNotification(response: UNNotificationResponse) {
|
|
let userInfo = response.notification.request.content.userInfo
|
|
guard let uuidString = userInfo["surface"] as? String,
|
|
let uuid = UUID(uuidString: uuidString),
|
|
let surface = delegate?.findSurface(forUUID: uuid) else { return }
|
|
|
|
switch response.actionIdentifier {
|
|
case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow:
|
|
// The user clicked on a notification
|
|
surface.handleUserNotification(notification: response.notification, focus: true)
|
|
case UNNotificationDismissActionIdentifier:
|
|
// The user dismissed the notification
|
|
surface.handleUserNotification(notification: response.notification, focus: false)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
#endif
|
|
}
|
|
}
|