mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
macOS: App Intents (#7634)
This PR integrates Ghostty on macOS with the [App Intents](https://developer.apple.com/documentation/appintents) system. The focus of this initial work was on enabling [Apple Shortcuts](https://support.apple.com/guide/shortcuts/welcome/ios) on macOS, but App Intents are the same underlying system that powers a number of other Apple features such as Spotlight, Siri, Widgets, and more. We don't do much with these latter ones yet, though. Additionally, this PR begins to refactor and untangle some of our libghostty API calls from macOS views. Presently, macOS views and view controllers directly call into the libghostty API and own libghostty data models. This tight coupling is kind of nasty because it tends to also couple libghostty API calls to the main GUI thread when they don't really have to be (they just have to not be concurrently accessed). This becomes an issue because App Intents run on background threads. This PR starts to extract out some of this business logic into standalone classes, but we still force all execution onto the main thread during the transition. **Version requirement:** Most of the shortcuts will work on macOS 13, but there are some that require macOS 14, and some functionality will require macOS 26. We gracefully degrade in all scenarios (the capabilities that are unavailable just don't show up on older systems). > [!IMPORTANT] > > This bumps our build requirements on macOS to Xcode 26 and the macOS 26 SDK. **You can still build on macOS 15,** but you must be using the Xcode 26 beta. This includes a README update about that. ## Why? Apple Shortcuts is an extremely powerful scripting tool on Apple platforms. It comes with a number of built-in capabilities such as moving windows, taking screenshots, fetching secrets, and more. By integrating with Apple Shortcuts, it allows Ghostty to become scriptable to a certain extent while also being able to take advantage of this large ecosystem. It is a huge downside that Shortcuts is Apple-only and I still would like to make Ghostty scriptable to some extent on Linux and other platforms as well. This work doesn't preclude that goal, but gives us an answer to a large subset of users (macOS users), which is great. Beyond this, no terminals integrate with Apple Shortcuts except the built-in Terminal. And even then, the built-in Terminal only exposes two actions (run script and run script over SSH). I think there's a lot that can be done by exposing more functionality and I'm excited to see what people do with this. Finally, I think Shortcuts is possibly a way we can do some GUI testing on macOS. That remains to be explored but it seems promising. ## Capability The initial set of Shortcut actions is shown in the screenshot below:  These can be combined with the built-in shortcuts to do some pretty interesting things. ### Future There are more capabilities I'd like to expose, but they require changing core parts of Ghostty that I didn't want to mix into this PR. ## Security Scripting Ghostty can be considered a security risk, since it allows arbitrary command execution, reading terminal output, etc. Therefore, Ghostty will ask for permission prior to allowing any Shortcut to remote control it: <img width="859" alt="image" src="https://github.com/user-attachments/assets/62344248-9c2c-402d-80f6-3fe3910d23fd" /> This can be directly overridden using the new `macos-shortcuts` configuration, which defaults to `ask` but can also be set to `deny` or `allow` with self-explanatory behaviors.
This commit is contained in:
41
.github/workflows/test.yml
vendored
41
.github/workflows/test.yml
vendored
@ -18,7 +18,6 @@ jobs:
|
|||||||
- build-nix
|
- build-nix
|
||||||
- build-snap
|
- build-snap
|
||||||
- build-macos
|
- build-macos
|
||||||
- build-macos-sequoia-stable
|
|
||||||
- build-macos-tahoe
|
- build-macos-tahoe
|
||||||
- build-macos-matrix
|
- build-macos-matrix
|
||||||
- build-windows
|
- build-windows
|
||||||
@ -310,46 +309,6 @@ jobs:
|
|||||||
cd macos
|
cd macos
|
||||||
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
||||||
|
|
||||||
build-macos-sequoia-stable:
|
|
||||||
runs-on: namespace-profile-ghostty-macos-sequoia
|
|
||||||
needs: test
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# Install Nix and use that to run our tests so our environment matches exactly.
|
|
||||||
- uses: cachix/install-nix-action@v31
|
|
||||||
with:
|
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
|
||||||
- uses: cachix/cachix-action@v16
|
|
||||||
with:
|
|
||||||
name: ghostty
|
|
||||||
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
|
|
||||||
|
|
||||||
- name: Xcode Select
|
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.4.app
|
|
||||||
|
|
||||||
- name: get the Zig deps
|
|
||||||
id: deps
|
|
||||||
run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
# GhosttyKit is the framework that is built from Zig for our native
|
|
||||||
# Mac app to access.
|
|
||||||
- name: Build GhosttyKit
|
|
||||||
run: nix develop -c zig build --system ${{ steps.deps.outputs.deps }}
|
|
||||||
|
|
||||||
# The native app is built with native Xcode tooling. This also does
|
|
||||||
# codesigning. IMPORTANT: this must NOT run in a Nix environment.
|
|
||||||
# Nix breaks xcodebuild so this has to be run outside.
|
|
||||||
- name: Build Ghostty.app
|
|
||||||
run: cd macos && xcodebuild -target Ghostty
|
|
||||||
|
|
||||||
# Build the iOS target without code signing just to verify it works.
|
|
||||||
- name: Build Ghostty iOS
|
|
||||||
run: |
|
|
||||||
cd macos
|
|
||||||
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO"
|
|
||||||
|
|
||||||
build-macos-tahoe:
|
build-macos-tahoe:
|
||||||
runs-on: namespace-profile-ghostty-macos-tahoe
|
runs-on: namespace-profile-ghostty-macos-tahoe
|
||||||
needs: test
|
needs: test
|
||||||
|
22
README.md
22
README.md
@ -224,6 +224,28 @@ macOS users don't require any additional dependencies.
|
|||||||
> source tarballs, see the
|
> source tarballs, see the
|
||||||
> [website](http://ghostty.org/docs/install/build).
|
> [website](http://ghostty.org/docs/install/build).
|
||||||
|
|
||||||
|
### Xcode Version and SDKs
|
||||||
|
|
||||||
|
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
|
||||||
|
and the iOS SDK are all installed.
|
||||||
|
|
||||||
|
A common issue is that the incorrect version of Xcode is either
|
||||||
|
installed or selected. Use the `xcode-select` command to
|
||||||
|
ensure that the correct version of Xcode is selected:
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
sudo xcode-select --switch /Applications/Xcode-beta.app
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
>
|
||||||
|
> Main branch development of Ghostty is preparing for the next major
|
||||||
|
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
|
||||||
|
> **Xcode 26 and the macOS 26 SDK**.
|
||||||
|
>
|
||||||
|
> You do not need to be running on macOS 26 to build Ghostty, you can
|
||||||
|
> still use Xcode 26 beta on macOS 15 stable.
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
|
|
||||||
#### Prettier
|
#### Prettier
|
||||||
|
@ -385,6 +385,11 @@ typedef struct {
|
|||||||
bool rectangle;
|
bool rectangle;
|
||||||
} ghostty_selection_s;
|
} ghostty_selection_s;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
const char* key;
|
||||||
|
const char* value;
|
||||||
|
} ghostty_env_var_s;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
void* nsview;
|
void* nsview;
|
||||||
} ghostty_platform_macos_s;
|
} ghostty_platform_macos_s;
|
||||||
@ -406,6 +411,8 @@ typedef struct {
|
|||||||
float font_size;
|
float font_size;
|
||||||
const char* working_directory;
|
const char* working_directory;
|
||||||
const char* command;
|
const char* command;
|
||||||
|
ghostty_env_var_s* env_vars;
|
||||||
|
size_t env_var_count;
|
||||||
} ghostty_surface_config_s;
|
} ghostty_surface_config_s;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@ -807,7 +814,8 @@ void ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);
|
|||||||
|
|
||||||
ghostty_surface_config_s ghostty_surface_config_new();
|
ghostty_surface_config_s ghostty_surface_config_new();
|
||||||
|
|
||||||
ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*);
|
ghostty_surface_t ghostty_surface_new(ghostty_app_t,
|
||||||
|
const ghostty_surface_config_s*);
|
||||||
void ghostty_surface_free(ghostty_surface_t);
|
void ghostty_surface_free(ghostty_surface_t);
|
||||||
void* ghostty_surface_userdata(ghostty_surface_t);
|
void* ghostty_surface_userdata(ghostty_surface_t);
|
||||||
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
|
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
|
||||||
|
@ -13,6 +13,11 @@
|
|||||||
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
|
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
|
||||||
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
|
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
|
||||||
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
|
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50297342DFA0F3300B4E924 /* Double+Extension.swift */; };
|
||||||
|
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A511940E2E050590007258CC /* CloseTerminalIntent.swift */; };
|
||||||
|
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194102E05A480007258CC /* QuickTerminalIntent.swift */; };
|
||||||
|
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
|
||||||
|
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194162E05D95E007258CC /* PermissionRequest.swift */; };
|
||||||
|
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194182E05DFBB007258CC /* IntentPermission.swift */; };
|
||||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||||
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
||||||
@ -53,7 +58,8 @@
|
|||||||
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
|
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */; };
|
||||||
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
|
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */; };
|
||||||
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
|
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; };
|
||||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
|
A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51194122E05D003007258CC /* Optional+Extension.swift */; };
|
||||||
|
A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A586366A2DF0A98900E04A10 /* Array+Extension.swift */; };
|
||||||
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; };
|
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */; };
|
||||||
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; };
|
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */; };
|
||||||
A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; };
|
A5593FE32DF8D78600B47B10 /* TerminalHiddenTitlebar.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */; };
|
||||||
@ -120,6 +126,17 @@
|
|||||||
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
|
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; };
|
||||||
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
|
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; };
|
||||||
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; };
|
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */; };
|
||||||
|
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */; };
|
||||||
|
A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */; };
|
||||||
|
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */; };
|
||||||
|
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */; };
|
||||||
|
A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */; };
|
||||||
|
A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */; };
|
||||||
|
A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */; };
|
||||||
|
A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E4083F2E04532A0035FEAC /* CommandEntity.swift */; };
|
||||||
|
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; };
|
||||||
|
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; };
|
||||||
|
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; };
|
||||||
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
|
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
|
||||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
|
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; };
|
||||||
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; };
|
||||||
@ -139,6 +156,11 @@
|
|||||||
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
|
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
|
||||||
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
|
9351BE8E2D22937F003B3499 /* nvim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = nvim; path = "../zig-out/share/nvim"; sourceTree = "<group>"; };
|
||||||
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
A50297342DFA0F3300B4E924 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
|
||||||
|
A511940E2E050590007258CC /* CloseTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseTerminalIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A51194102E05A480007258CC /* QuickTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A51194122E05D003007258CC /* Optional+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extension.swift"; sourceTree = "<group>"; };
|
||||||
|
A51194162E05D95E007258CC /* PermissionRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionRequest.swift; sourceTree = "<group>"; };
|
||||||
|
A51194182E05DFBB007258CC /* IntentPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentPermission.swift; sourceTree = "<group>"; };
|
||||||
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
|
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = "<group>"; };
|
||||||
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
|
A51544FD2DFB1110009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarTabsTahoeTerminalWindow.swift; sourceTree = "<group>"; };
|
||||||
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
|
A51544FF2DFB112E009E85D8 /* TerminalTabsTitlebarTahoe.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalTabsTitlebarTahoe.xib; sourceTree = "<group>"; };
|
||||||
@ -171,7 +193,6 @@
|
|||||||
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
|
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
|
||||||
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
|
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
|
||||||
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
|
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
|
||||||
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
|
|
||||||
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
|
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
|
||||||
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
|
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
|
||||||
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; };
|
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TerminalHiddenTitlebar.xib; sourceTree = "<group>"; };
|
||||||
@ -240,6 +261,17 @@
|
|||||||
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
|
A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = "<group>"; };
|
||||||
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
|
A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = "<group>"; };
|
||||||
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; };
|
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabGroupCloseCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTerminalIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyIntentError.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalEntity.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTerminalDetailsIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Surface.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Command.swift; sourceTree = "<group>"; };
|
||||||
|
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Error.swift; sourceTree = "<group>"; };
|
||||||
|
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandEntity.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
|
||||||
|
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
|
||||||
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
|
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
|
||||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
|
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
|
||||||
@ -299,6 +331,7 @@
|
|||||||
A56D58872ACDE6BE00508D2C /* Services */,
|
A56D58872ACDE6BE00508D2C /* Services */,
|
||||||
A59630982AEE1C4400D64628 /* Terminal */,
|
A59630982AEE1C4400D64628 /* Terminal */,
|
||||||
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
|
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */,
|
||||||
|
A5E4082C2E0237270035FEAC /* App Intents */,
|
||||||
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */,
|
||||||
A57D79252C9C8782001D522E /* Secure Input */,
|
A57D79252C9C8782001D522E /* Secure Input */,
|
||||||
A58636622DEF955100E04A10 /* Splits */,
|
A58636622DEF955100E04A10 /* Splits */,
|
||||||
@ -328,6 +361,7 @@
|
|||||||
A59630962AEE163600D64628 /* HostingWindow.swift */,
|
A59630962AEE163600D64628 /* HostingWindow.swift */,
|
||||||
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
|
A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */,
|
||||||
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
|
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
|
||||||
|
A51194162E05D95E007258CC /* PermissionRequest.swift */,
|
||||||
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
|
A5E408292E022E9B0035FEAC /* TabGroupCloseCoordinator.swift */,
|
||||||
A5CA378D2D31D6C100931030 /* Weak.swift */,
|
A5CA378D2D31D6C100931030 /* Weak.swift */,
|
||||||
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
|
C1F26EE72B76CBFC00404083 /* VibrantLayer.h */,
|
||||||
@ -431,12 +465,14 @@
|
|||||||
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
|
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
|
||||||
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
|
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
|
||||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
|
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
|
||||||
|
A5E408392E0449BB0035FEAC /* Ghostty.Command.swift */,
|
||||||
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
|
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */,
|
||||||
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
|
A53A6C022CCC1B7D00943E98 /* Ghostty.Action.swift */,
|
||||||
|
A5E4083B2E044DB40035FEAC /* Ghostty.Error.swift */,
|
||||||
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
|
A5CF66D62D29DDB100139794 /* Ghostty.Event.swift */,
|
||||||
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
|
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
|
||||||
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
|
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
|
||||||
A55685DF29A03A9F004303CE /* AppError.swift */,
|
A5E408372E03C7D80035FEAC /* Ghostty.Surface.swift */,
|
||||||
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
|
A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
|
||||||
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
|
A5CF66D32D289CEA00139794 /* NSEvent+Extension.swift */,
|
||||||
);
|
);
|
||||||
@ -479,6 +515,7 @@
|
|||||||
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
|
A586366E2DF25D8300E04A10 /* Duration+Extension.swift */,
|
||||||
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
|
A53A29802DB44A5E00B6E02C /* KeyboardShortcut+Extension.swift */,
|
||||||
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
|
A53A297E2DB4480A00B6E02C /* EventModifiers+Extension.swift */,
|
||||||
|
A51194122E05D003007258CC /* Optional+Extension.swift */,
|
||||||
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
|
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
|
||||||
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
|
A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */,
|
||||||
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
|
A5A2A3CB2D444AB80033CF96 /* NSApplication+Extension.swift */,
|
||||||
@ -598,6 +635,32 @@
|
|||||||
path = ClipboardConfirmation;
|
path = ClipboardConfirmation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A5E4082C2E0237270035FEAC /* App Intents */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5E408412E0453370035FEAC /* Entities */,
|
||||||
|
A511940E2E050590007258CC /* CloseTerminalIntent.swift */,
|
||||||
|
A5E4082D2E0237410035FEAC /* NewTerminalIntent.swift */,
|
||||||
|
A5E408332E03200F0035FEAC /* GetTerminalDetailsIntent.swift */,
|
||||||
|
A51194102E05A480007258CC /* QuickTerminalIntent.swift */,
|
||||||
|
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */,
|
||||||
|
A5E408462E0485270035FEAC /* InputIntent.swift */,
|
||||||
|
A5E408442E0483F80035FEAC /* KeybindIntent.swift */,
|
||||||
|
A5E4082F2E0271320035FEAC /* GhosttyIntentError.swift */,
|
||||||
|
A51194182E05DFBB007258CC /* IntentPermission.swift */,
|
||||||
|
);
|
||||||
|
path = "App Intents";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A5E408412E0453370035FEAC /* Entities */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5E408312E02FEDC0035FEAC /* TerminalEntity.swift */,
|
||||||
|
A5E4083F2E04532A0035FEAC /* CommandEntity.swift */,
|
||||||
|
);
|
||||||
|
path = Entities;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -724,6 +787,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
|
A5AEB1652D5BE7D000513529 /* LastWindowPosition.swift in Sources */,
|
||||||
|
A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */,
|
||||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||||
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
|
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
|
||||||
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
|
A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */,
|
||||||
@ -733,17 +797,22 @@
|
|||||||
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
|
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
|
||||||
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
|
A54B0CE92D0CECD100CBEFF8 /* ColorizedGhosttyIconView.swift in Sources */,
|
||||||
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
|
A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */,
|
||||||
|
A51194132E05D006007258CC /* Optional+Extension.swift in Sources */,
|
||||||
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
|
A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */,
|
||||||
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
|
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */,
|
||||||
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
|
A586366F2DF25D8600E04A10 /* Duration+Extension.swift in Sources */,
|
||||||
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
|
A5CF66D42D289CEE00139794 /* NSEvent+Extension.swift in Sources */,
|
||||||
|
A5E408342E0320140035FEAC /* GetTerminalDetailsIntent.swift in Sources */,
|
||||||
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
|
A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */,
|
||||||
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
|
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
|
||||||
|
A5E408322E02FEDF0035FEAC /* TerminalEntity.swift in Sources */,
|
||||||
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
|
A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */,
|
||||||
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
|
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
|
||||||
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
|
A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */,
|
||||||
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */,
|
||||||
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */,
|
||||||
|
A511940F2E050595007258CC /* CloseTerminalIntent.swift in Sources */,
|
||||||
|
A5E408382E03C7DA0035FEAC /* Ghostty.Surface.swift in Sources */,
|
||||||
A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
|
A5593FE72DF927D200B47B10 /* TransparentTitlebarTerminalWindow.swift in Sources */,
|
||||||
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
|
A5A2A3CA2D4445E30033CF96 /* Dock.swift in Sources */,
|
||||||
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
|
A586365F2DEE6C2300E04A10 /* SplitTree.swift in Sources */,
|
||||||
@ -752,6 +821,7 @@
|
|||||||
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
A53A29812DB44A6100B6E02C /* KeyboardShortcut+Extension.swift in Sources */,
|
||||||
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
|
A50297352DFA0F3400B4E924 /* Double+Extension.swift in Sources */,
|
||||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
||||||
|
A51194112E05A483007258CC /* QuickTerminalIntent.swift in Sources */,
|
||||||
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
||||||
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,
|
A5593FDF2DF8D57C00B47B10 /* TerminalWindow.swift in Sources */,
|
||||||
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
|
A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */,
|
||||||
@ -759,6 +829,8 @@
|
|||||||
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
||||||
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
||||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
||||||
|
A51194172E05D964007258CC /* PermissionRequest.swift in Sources */,
|
||||||
|
A51194192E05DFC4007258CC /* IntentPermission.swift in Sources */,
|
||||||
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
|
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
|
||||||
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
||||||
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
||||||
@ -772,29 +844,35 @@
|
|||||||
A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */,
|
A5B4EA852DFE691B0022C3A2 /* NSMenuItem+Extension.swift in Sources */,
|
||||||
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
|
A5874D992DAD751B00E83852 /* CGS.swift in Sources */,
|
||||||
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
|
A586366B2DF0A98C00E04A10 /* Array+Extension.swift in Sources */,
|
||||||
|
A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */,
|
||||||
A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */,
|
A51544FE2DFB111C009E85D8 /* TitlebarTabsTahoeTerminalWindow.swift in Sources */,
|
||||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
|
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
|
||||||
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
|
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
|
||||||
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
||||||
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
|
A5A2A3CC2D444ABB0033CF96 /* NSApplication+Extension.swift in Sources */,
|
||||||
|
A5E408302E0271320035FEAC /* GhosttyIntentError.swift in Sources */,
|
||||||
|
A5E4083A2E0449BD0035FEAC /* Ghostty.Command.swift in Sources */,
|
||||||
|
A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */,
|
||||||
A5FEB3002ABB69450068369E /* main.swift in Sources */,
|
A5FEB3002ABB69450068369E /* main.swift in Sources */,
|
||||||
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
|
A53A297F2DB4480F00B6E02C /* EventModifiers+Extension.swift in Sources */,
|
||||||
|
A5E4082E2E0237460035FEAC /* NewTerminalIntent.swift in Sources */,
|
||||||
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
|
A53A297B2DB2E49700B6E02C /* CommandPalette.swift in Sources */,
|
||||||
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
|
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
|
||||||
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
|
A51B78472AF4B58B00F3EDB9 /* TitlebarTabsVenturaTerminalWindow.swift in Sources */,
|
||||||
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
|
A57D79272C9C879B001D522E /* SecureInput.swift in Sources */,
|
||||||
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
||||||
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
|
A5593FE12DF8D74000B47B10 /* HiddenTitlebarTerminalWindow.swift in Sources */,
|
||||||
|
A5E4083C2E044DB50035FEAC /* Ghostty.Error.swift in Sources */,
|
||||||
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
|
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
|
||||||
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
|
A53A6C032CCC1B7F00943E98 /* Ghostty.Action.swift in Sources */,
|
||||||
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
|
A54B0CED2D0CFB7700CBEFF8 /* ColorizedGhosttyIcon.swift in Sources */,
|
||||||
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
|
A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */,
|
||||||
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
|
A54B0CEF2D0D2E2800CBEFF8 /* ColorizedGhosttyIconImage.swift in Sources */,
|
||||||
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
|
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
|
||||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
|
|
||||||
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
|
A599CDB02CF103F60049FA26 /* NSAppearance+Extension.swift in Sources */,
|
||||||
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
|
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */,
|
||||||
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
|
A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */,
|
||||||
|
A5E408402E04532C0035FEAC /* CommandEntity.swift in Sources */,
|
||||||
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */,
|
A5E4082A2E022E9E0035FEAC /* TabGroupCloseCoordinator.swift in Sources */,
|
||||||
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
|
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
|
||||||
A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */,
|
A53A29882DB69D2F00B6E02C /* TerminalCommandPalette.swift in Sources */,
|
||||||
@ -816,6 +894,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
|
A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */,
|
||||||
|
A553F4062E05E93000257779 /* Optional+Extension.swift in Sources */,
|
||||||
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
|
A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */,
|
||||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||||
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
|
A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */,
|
||||||
@ -825,6 +904,7 @@
|
|||||||
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
|
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */,
|
||||||
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,
|
A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */,
|
||||||
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */,
|
||||||
|
A553F4072E05E93D00257779 /* Array+Extension.swift in Sources */,
|
||||||
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */,
|
C159E89D2B69A2EF00FDFE9C /* OSColor+Extension.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -92,7 +92,10 @@ class AppDelegate: NSObject,
|
|||||||
lazy var undoManager = ExpiringUndoManager()
|
lazy var undoManager = ExpiringUndoManager()
|
||||||
|
|
||||||
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
/// Our quick terminal. This starts out uninitialized and only initializes if used.
|
||||||
private var quickController: QuickTerminalController? = nil
|
private(set) lazy var quickController = QuickTerminalController(
|
||||||
|
ghostty,
|
||||||
|
position: derivedConfig.quickTerminalPosition
|
||||||
|
)
|
||||||
|
|
||||||
/// Manages updates
|
/// Manages updates
|
||||||
let updaterController: SPUStandardUpdaterController
|
let updaterController: SPUStandardUpdaterController
|
||||||
@ -286,7 +289,7 @@ class AppDelegate: NSObject,
|
|||||||
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
|
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
|
||||||
// here because I don't want to remove it in a patch release cycle but we should
|
// here because I don't want to remove it in a patch release cycle but we should
|
||||||
// target removing it soon.
|
// target removing it soon.
|
||||||
if (self.quickController == nil && windows.allSatisfy { !$0.isVisible }) {
|
if (windows.allSatisfy { !$0.isVisible }) {
|
||||||
return .terminateNow
|
return .terminateNow
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -919,14 +922,6 @@ class AppDelegate: NSObject,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func toggleQuickTerminal(_ sender: Any) {
|
@IBAction func toggleQuickTerminal(_ sender: Any) {
|
||||||
if quickController == nil {
|
|
||||||
quickController = QuickTerminalController(
|
|
||||||
ghostty,
|
|
||||||
position: derivedConfig.quickTerminalPosition
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let quickController = self.quickController else { return }
|
|
||||||
quickController.toggle()
|
quickController.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
35
macos/Sources/Features/App Intents/CloseTerminalIntent.swift
Normal file
35
macos/Sources/Features/App Intents/CloseTerminalIntent.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
struct CloseTerminalIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Close Terminal"
|
||||||
|
static var description = IntentDescription("Close an existing terminal.")
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to close.",
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = .background
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surfaceView = terminal.surfaceView else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.closeSurface(surfaceView, withConfirmation: false)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
/// App intent that invokes a command palette entry.
|
||||||
|
@available(macOS 14.0, *)
|
||||||
|
struct CommandPaletteIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Invoke Command Palette Action"
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to base available commands from."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Command",
|
||||||
|
description: "The command to invoke.",
|
||||||
|
optionsProvider: CommandQuery()
|
||||||
|
)
|
||||||
|
var command: CommandEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = .background
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let performed = surface.perform(action: command.action)
|
||||||
|
return .result(value: performed)
|
||||||
|
}
|
||||||
|
}
|
128
macos/Sources/Features/App Intents/Entities/CommandEntity.swift
Normal file
128
macos/Sources/Features/App Intents/Entities/CommandEntity.swift
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import AppIntents
|
||||||
|
|
||||||
|
// MARK: AppEntity
|
||||||
|
|
||||||
|
@available(macOS 14.0, *)
|
||||||
|
struct CommandEntity: AppEntity {
|
||||||
|
let id: ID
|
||||||
|
|
||||||
|
// Note: for macOS 26 we can move all the properties to @ComputedProperty.
|
||||||
|
|
||||||
|
@Property(title: "Title")
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
@Property(title: "Description")
|
||||||
|
var description: String
|
||||||
|
|
||||||
|
@Property(title: "Action")
|
||||||
|
var action: String
|
||||||
|
|
||||||
|
/// The underlying data model
|
||||||
|
let command: Ghostty.Command
|
||||||
|
|
||||||
|
/// A command identifier is a composite key based on the terminal and action.
|
||||||
|
struct ID: Hashable {
|
||||||
|
let terminalId: TerminalEntity.ID
|
||||||
|
let actionKey: String
|
||||||
|
|
||||||
|
init(terminalId: TerminalEntity.ID, actionKey: String) {
|
||||||
|
self.terminalId = terminalId
|
||||||
|
self.actionKey = actionKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: "Command Palette Command")
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
DisplayRepresentation(
|
||||||
|
title: LocalizedStringResource(stringLiteral: command.title),
|
||||||
|
subtitle: LocalizedStringResource(stringLiteral: command.description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultQuery = CommandQuery()
|
||||||
|
|
||||||
|
init(_ command: Ghostty.Command, for terminal: TerminalEntity) {
|
||||||
|
self.id = .init(terminalId: terminal.id, actionKey: command.actionKey)
|
||||||
|
self.command = command
|
||||||
|
self.title = command.title
|
||||||
|
self.description = command.description
|
||||||
|
self.action = command.action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 14.0, *)
|
||||||
|
extension CommandEntity.ID: RawRepresentable {
|
||||||
|
var rawValue: String {
|
||||||
|
return "\(terminalId):\(actionKey)"
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(rawValue: String) {
|
||||||
|
let components = rawValue.split(separator: ":", maxSplits: 1)
|
||||||
|
guard components.count == 2 else { return nil }
|
||||||
|
|
||||||
|
guard let terminalId = TerminalEntity.ID(uuidString: String(components[0])) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.terminalId = terminalId
|
||||||
|
self.actionKey = String(components[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required by AppEntity
|
||||||
|
@available(macOS 14.0, *)
|
||||||
|
extension CommandEntity.ID: EntityIdentifierConvertible {
|
||||||
|
static func entityIdentifier(for entityIdentifierString: String) -> CommandEntity.ID? {
|
||||||
|
.init(rawValue: entityIdentifierString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var entityIdentifierString: String {
|
||||||
|
rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: EntityQuery
|
||||||
|
|
||||||
|
@available(macOS 14.0, *)
|
||||||
|
struct CommandQuery: EntityQuery {
|
||||||
|
// Inject our terminal parameter from our command palette intent.
|
||||||
|
@IntentParameterDependency<CommandPaletteIntent>(\.$terminal)
|
||||||
|
var commandPaletteIntent
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func entities(for identifiers: [CommandEntity.ID]) async throws -> [CommandEntity] {
|
||||||
|
// Extract unique terminal IDs to avoid fetching duplicates
|
||||||
|
let terminalIds = Set(identifiers.map(\.terminalId))
|
||||||
|
let terminals = try await TerminalEntity.defaultQuery.entities(for: Array(terminalIds))
|
||||||
|
|
||||||
|
// Build a cache of terminals and their available commands
|
||||||
|
// This avoids repeated command fetching for the same terminal
|
||||||
|
typealias Tuple = (terminal: TerminalEntity, commands: [Ghostty.Command])
|
||||||
|
let commandMap: [TerminalEntity.ID: Tuple] =
|
||||||
|
terminals.reduce(into: [:]) { result, terminal in
|
||||||
|
guard let commands = try? terminal.surfaceModel?.commands() else { return }
|
||||||
|
result[terminal.id] = (terminal: terminal, commands: commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each identifier to its corresponding CommandEntity. If a command doesn't
|
||||||
|
// exist it maps to nil and is removed via compactMap.
|
||||||
|
return identifiers.compactMap { id in
|
||||||
|
guard let (terminal, commands) = commandMap[id.terminalId],
|
||||||
|
let command = commands.first(where: { $0.actionKey == id.actionKey }) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return CommandEntity(command, for: terminal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func suggestedEntities() async throws -> [CommandEntity] {
|
||||||
|
guard let terminal = commandPaletteIntent?.terminal,
|
||||||
|
let surface = terminal.surfaceModel else { return [] }
|
||||||
|
return try surface.commands().map { CommandEntity($0, for: terminal) }
|
||||||
|
}
|
||||||
|
}
|
139
macos/Sources/Features/App Intents/Entities/TerminalEntity.swift
Normal file
139
macos/Sources/Features/App Intents/Entities/TerminalEntity.swift
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TerminalEntity: AppEntity {
|
||||||
|
let id: UUID
|
||||||
|
|
||||||
|
@Property(title: "Title")
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
@Property(title: "Working Directory")
|
||||||
|
var workingDirectory: String?
|
||||||
|
|
||||||
|
@Property(title: "Kind")
|
||||||
|
var kind: Kind
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@DeferredProperty(title: "Full Contents")
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
var screenContents: String? {
|
||||||
|
get async {
|
||||||
|
guard let surfaceView else { return nil }
|
||||||
|
return surfaceView.cachedScreenContents.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@DeferredProperty(title: "Visible Contents")
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
var visibleContents: String? {
|
||||||
|
get async {
|
||||||
|
guard let surfaceView else { return nil }
|
||||||
|
return surfaceView.cachedVisibleContents.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var screenshot: Image?
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation: TypeDisplayRepresentation {
|
||||||
|
TypeDisplayRepresentation(name: "Terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var displayRepresentation: DisplayRepresentation {
|
||||||
|
var rep = DisplayRepresentation(title: "\(title)")
|
||||||
|
if let screenshot,
|
||||||
|
let nsImage = ImageRenderer(content: screenshot).nsImage,
|
||||||
|
let data = nsImage.tiffRepresentation {
|
||||||
|
rep.image = .init(data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the view associated with this entity. This may no longer exist.
|
||||||
|
@MainActor
|
||||||
|
var surfaceView: Ghostty.SurfaceView? {
|
||||||
|
Self.defaultQuery.all.first { $0.uuid == self.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var surfaceModel: Ghostty.Surface? {
|
||||||
|
surfaceView?.surfaceModel
|
||||||
|
}
|
||||||
|
|
||||||
|
static var defaultQuery = TerminalQuery()
|
||||||
|
|
||||||
|
init(_ view: Ghostty.SurfaceView) {
|
||||||
|
self.id = view.uuid
|
||||||
|
self.title = view.title
|
||||||
|
self.workingDirectory = view.pwd
|
||||||
|
self.screenshot = view.screenshot()
|
||||||
|
|
||||||
|
// Determine the kind based on the window controller type
|
||||||
|
if view.window?.windowController is QuickTerminalController {
|
||||||
|
self.kind = .quick
|
||||||
|
} else {
|
||||||
|
self.kind = .normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TerminalEntity {
|
||||||
|
enum Kind: String, AppEnum {
|
||||||
|
case normal
|
||||||
|
case quick
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Kind")
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||||
|
.normal: .init(title: "Normal"),
|
||||||
|
.quick: .init(title: "Quick")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TerminalQuery: EntityStringQuery, EnumerableEntityQuery {
|
||||||
|
@MainActor
|
||||||
|
func entities(for identifiers: [TerminalEntity.ID]) async throws -> [TerminalEntity] {
|
||||||
|
return all.filter {
|
||||||
|
identifiers.contains($0.uuid)
|
||||||
|
}.map {
|
||||||
|
TerminalEntity($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func entities(matching string: String) async throws -> [TerminalEntity] {
|
||||||
|
return all.filter {
|
||||||
|
$0.title.localizedCaseInsensitiveContains(string)
|
||||||
|
}.map {
|
||||||
|
TerminalEntity($0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func allEntities() async throws -> [TerminalEntity] {
|
||||||
|
return all.map { TerminalEntity($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func suggestedEntities() async throws -> [TerminalEntity] {
|
||||||
|
return try await allEntities()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
var all: [Ghostty.SurfaceView] {
|
||||||
|
// Find all of our terminal windows. This will include the quick terminal
|
||||||
|
// but only if it was previously opened.
|
||||||
|
let controllers = NSApp.windows.compactMap {
|
||||||
|
$0.windowController as? BaseTerminalController
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all our surfaces
|
||||||
|
return controllers.flatMap {
|
||||||
|
$0.surfaceTree.root?.leaves() ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,69 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
/// App intent that retrieves details about a specific terminal.
|
||||||
|
struct GetTerminalDetailsIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Get Details of Terminal"
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Detail",
|
||||||
|
description: "The detail to extract about a terminal."
|
||||||
|
)
|
||||||
|
var detail: TerminalDetail
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to extract information about."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = .background
|
||||||
|
|
||||||
|
static var parameterSummary: some ParameterSummary {
|
||||||
|
Summary("Get \(\.$detail) from \(\.$terminal)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & ReturnsValue<String?> {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
switch detail {
|
||||||
|
case .title: return .result(value: terminal.title)
|
||||||
|
case .workingDirectory: return .result(value: terminal.workingDirectory)
|
||||||
|
case .allContents:
|
||||||
|
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||||
|
return .result(value: view.cachedScreenContents.get())
|
||||||
|
case .selectedText:
|
||||||
|
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||||
|
return .result(value: view.accessibilitySelectedText())
|
||||||
|
case .visibleText:
|
||||||
|
guard let view = terminal.surfaceView else { throw GhosttyIntentError.surfaceNotFound }
|
||||||
|
return .result(value: view.cachedVisibleContents.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: TerminalDetail
|
||||||
|
|
||||||
|
enum TerminalDetail: String {
|
||||||
|
case title
|
||||||
|
case workingDirectory
|
||||||
|
case allContents
|
||||||
|
case selectedText
|
||||||
|
case visibleText
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TerminalDetail: AppEnum {
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Detail")
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||||
|
.title: .init(title: "Title"),
|
||||||
|
.workingDirectory: .init(title: "Working Directory"),
|
||||||
|
.allContents: .init(title: "Full Contents"),
|
||||||
|
.selectedText: .init(title: "Selected Text"),
|
||||||
|
.visibleText: .init(title: "Visible Text"),
|
||||||
|
]
|
||||||
|
}
|
13
macos/Sources/Features/App Intents/GhosttyIntentError.swift
Normal file
13
macos/Sources/Features/App Intents/GhosttyIntentError.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
enum GhosttyIntentError: Error, CustomLocalizedStringResourceConvertible {
|
||||||
|
case appUnavailable
|
||||||
|
case surfaceNotFound
|
||||||
|
case permissionDenied
|
||||||
|
|
||||||
|
var localizedStringResource: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .appUnavailable: "The Ghostty app isn't properly initialized."
|
||||||
|
case .surfaceNotFound: "The terminal no longer exists."
|
||||||
|
case .permissionDenied: "Ghostty doesn't allow Shortcuts."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
317
macos/Sources/Features/App Intents/InputIntent.swift
Normal file
317
macos/Sources/Features/App Intents/InputIntent.swift
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
/// App intent to input text in a terminal.
|
||||||
|
struct InputTextIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Input Text to Terminal"
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Text",
|
||||||
|
description: "The text to input to the terminal. The text will be inputted as if it was pasted.",
|
||||||
|
inputOptions: String.IntentInputOptions(
|
||||||
|
capitalizationType: .none,
|
||||||
|
multiline: true,
|
||||||
|
autocorrect: false,
|
||||||
|
smartQuotes: false,
|
||||||
|
smartDashes: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var text: String
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to scope this action to."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.sendText(text)
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App intent to trigger a keyboard event.
|
||||||
|
struct KeyEventIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Send Keyboard Event to Terminal"
|
||||||
|
static var description = IntentDescription("Simulate a keyboard event. This will not handle text encoding; use the 'Input Text' action for that.")
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Key",
|
||||||
|
description: "The key to send to the terminal.",
|
||||||
|
default: .enter
|
||||||
|
)
|
||||||
|
var key: Ghostty.Input.Key
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Modifier(s)",
|
||||||
|
description: "The modifiers to send with the key event.",
|
||||||
|
default: []
|
||||||
|
)
|
||||||
|
var mods: [KeyEventMods]
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Event Type",
|
||||||
|
description: "A key press or release.",
|
||||||
|
default: .press
|
||||||
|
)
|
||||||
|
var action: Ghostty.Input.Action
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to scope this action to."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||||
|
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||||
|
result.union(mod.ghosttyMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyEvent = Ghostty.Input.KeyEvent(
|
||||||
|
key: key,
|
||||||
|
action: action,
|
||||||
|
mods: ghosttyMods
|
||||||
|
)
|
||||||
|
surface.sendKeyEvent(keyEvent)
|
||||||
|
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: MouseButtonIntent
|
||||||
|
|
||||||
|
/// App intent to trigger a mouse button event.
|
||||||
|
struct MouseButtonIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Send Mouse Button Event to Terminal"
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Button",
|
||||||
|
description: "The mouse button to press or release.",
|
||||||
|
default: .left
|
||||||
|
)
|
||||||
|
var button: Ghostty.Input.MouseButton
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Action",
|
||||||
|
description: "Whether to press or release the button.",
|
||||||
|
default: .press
|
||||||
|
)
|
||||||
|
var action: Ghostty.Input.MouseState
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Modifier(s)",
|
||||||
|
description: "The modifiers to send with the mouse event.",
|
||||||
|
default: []
|
||||||
|
)
|
||||||
|
var mods: [KeyEventMods]
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to scope this action to."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||||
|
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||||
|
result.union(mod.ghosttyMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mouseEvent = Ghostty.Input.MouseButtonEvent(
|
||||||
|
action: action,
|
||||||
|
button: button,
|
||||||
|
mods: ghosttyMods
|
||||||
|
)
|
||||||
|
surface.sendMouseButton(mouseEvent)
|
||||||
|
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App intent to send a mouse position event.
|
||||||
|
struct MousePosIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Send Mouse Position Event to Terminal"
|
||||||
|
static var description = IntentDescription("Send a mouse position event to the terminal. This reports the cursor position for mouse tracking.")
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "X Position",
|
||||||
|
description: "The horizontal position of the mouse cursor in pixels.",
|
||||||
|
default: 0
|
||||||
|
)
|
||||||
|
var x: Double
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Y Position",
|
||||||
|
description: "The vertical position of the mouse cursor in pixels.",
|
||||||
|
default: 0
|
||||||
|
)
|
||||||
|
var y: Double
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Modifier(s)",
|
||||||
|
description: "The modifiers to send with the mouse position event.",
|
||||||
|
default: []
|
||||||
|
)
|
||||||
|
var mods: [KeyEventMods]
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to scope this action to."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert KeyEventMods array to Ghostty.Input.Mods
|
||||||
|
let ghosttyMods = mods.reduce(Ghostty.Input.Mods()) { result, mod in
|
||||||
|
result.union(mod.ghosttyMod)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mousePosEvent = Ghostty.Input.MousePosEvent(
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
mods: ghosttyMods
|
||||||
|
)
|
||||||
|
surface.sendMousePos(mousePosEvent)
|
||||||
|
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App intent to send a mouse scroll event.
|
||||||
|
struct MouseScrollIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Send Mouse Scroll Event to Terminal"
|
||||||
|
static var description = IntentDescription("Send a mouse scroll event to the terminal with configurable precision and momentum.")
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "X Scroll Delta",
|
||||||
|
description: "The horizontal scroll amount.",
|
||||||
|
default: 0
|
||||||
|
)
|
||||||
|
var x: Double
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Y Scroll Delta",
|
||||||
|
description: "The vertical scroll amount.",
|
||||||
|
default: 0
|
||||||
|
)
|
||||||
|
var y: Double
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "High Precision",
|
||||||
|
description: "Whether this is a high-precision scroll event (e.g., from trackpad).",
|
||||||
|
default: false
|
||||||
|
)
|
||||||
|
var precision: Bool
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Momentum Phase",
|
||||||
|
description: "The momentum phase for inertial scrolling.",
|
||||||
|
default: Ghostty.Input.Momentum.none
|
||||||
|
)
|
||||||
|
var momentum: Ghostty.Input.Momentum
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to scope this action to."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollEvent = Ghostty.Input.MouseScrollEvent(
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
mods: .init(precision: precision, momentum: momentum)
|
||||||
|
)
|
||||||
|
surface.sendMouseScroll(scrollEvent)
|
||||||
|
|
||||||
|
return .result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Mods
|
||||||
|
|
||||||
|
enum KeyEventMods: String, AppEnum, CaseIterable {
|
||||||
|
case shift
|
||||||
|
case control
|
||||||
|
case option
|
||||||
|
case command
|
||||||
|
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Modifier Key")
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [KeyEventMods : DisplayRepresentation] = [
|
||||||
|
.shift: "Shift",
|
||||||
|
.control: "Control",
|
||||||
|
.option: "Option",
|
||||||
|
.command: "Command"
|
||||||
|
]
|
||||||
|
|
||||||
|
var ghosttyMod: Ghostty.Input.Mods {
|
||||||
|
switch self {
|
||||||
|
case .shift: .shift
|
||||||
|
case .control: .ctrl
|
||||||
|
case .option: .alt
|
||||||
|
case .command: .super
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
57
macos/Sources/Features/App Intents/IntentPermission.swift
Normal file
57
macos/Sources/Features/App Intents/IntentPermission.swift
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Requests permission for Shortcuts app to interact with Ghostty
|
||||||
|
///
|
||||||
|
/// This function displays a permission dialog asking the user to allow Shortcuts
|
||||||
|
/// to interact with Ghostty. The permission is automatically cached for 10 minutes
|
||||||
|
/// if the user selects "Allow", meaning subsequent intent calls won't show the dialog
|
||||||
|
/// again during that time period.
|
||||||
|
///
|
||||||
|
/// The permission uses a shared UserDefaults key across all intents, so granting
|
||||||
|
/// permission for one intent allows all Ghostty intents to execute without additional
|
||||||
|
/// prompts for the duration of the cache period.
|
||||||
|
///
|
||||||
|
/// - Returns: `true` if permission is granted, `false` if denied
|
||||||
|
///
|
||||||
|
/// ## Usage
|
||||||
|
/// Add this check at the beginning of any App Intent's `perform()` method:
|
||||||
|
/// ```swift
|
||||||
|
/// @MainActor
|
||||||
|
/// func perform() async throws -> some IntentResult {
|
||||||
|
/// guard await requestIntentPermission() else {
|
||||||
|
/// throw GhosttyIntentError.permissionDenied
|
||||||
|
/// }
|
||||||
|
/// // ... continue with intent implementation
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
func requestIntentPermission() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
Task { @MainActor in
|
||||||
|
if let delegate = NSApp.delegate as? AppDelegate {
|
||||||
|
switch (delegate.ghostty.config.macosShortcuts) {
|
||||||
|
case .allow:
|
||||||
|
continuation.resume(returning: true)
|
||||||
|
return
|
||||||
|
|
||||||
|
case .deny:
|
||||||
|
continuation.resume(returning: false)
|
||||||
|
return
|
||||||
|
|
||||||
|
case .ask:
|
||||||
|
// Continue with the permission dialog
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PermissionRequest.show(
|
||||||
|
"org.mitchellh.ghostty.shortcutsPermission",
|
||||||
|
message: "Allow Shortcuts to interact with Ghostty?",
|
||||||
|
allowDuration: .forever,
|
||||||
|
rememberDuration: nil,
|
||||||
|
) { response in
|
||||||
|
continuation.resume(returning: response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
macos/Sources/Features/App Intents/KeybindIntent.swift
Normal file
35
macos/Sources/Features/App Intents/KeybindIntent.swift
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
struct KeybindIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Invoke a Keybind Action"
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Terminal",
|
||||||
|
description: "The terminal to invoke the action on."
|
||||||
|
)
|
||||||
|
var terminal: TerminalEntity
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Action",
|
||||||
|
description: "The keybind action to invoke. This can be any valid keybind action you could put in a configuration file."
|
||||||
|
)
|
||||||
|
var action: String
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = [.background, .foreground]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & ReturnsValue<Bool> {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let surface = terminal.surfaceModel else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
let performed = surface.perform(action: action)
|
||||||
|
return .result(value: performed)
|
||||||
|
}
|
||||||
|
}
|
163
macos/Sources/Features/App Intents/NewTerminalIntent.swift
Normal file
163
macos/Sources/Features/App Intents/NewTerminalIntent.swift
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
/// App intent that allows creating a new terminal window or tab.
|
||||||
|
///
|
||||||
|
/// This requires macOS 15 or greater because we use features of macOS 15 here.
|
||||||
|
@available(macOS 15.0, *)
|
||||||
|
struct NewTerminalIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "New Terminal"
|
||||||
|
static var description = IntentDescription("Create a new terminal.")
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Location",
|
||||||
|
description: "The location that the terminal should be created.",
|
||||||
|
default: .window
|
||||||
|
)
|
||||||
|
var location: NewTerminalLocation
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Command",
|
||||||
|
description: "Command to execute instead of the default shell."
|
||||||
|
)
|
||||||
|
var command: String?
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Working Directory",
|
||||||
|
description: "The working directory to open in the terminal.",
|
||||||
|
supportedContentTypes: [.folder]
|
||||||
|
)
|
||||||
|
var workingDirectory: IntentFile?
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Environment Variables",
|
||||||
|
description: "Environment variables in `KEY=VALUE` format.",
|
||||||
|
default: []
|
||||||
|
)
|
||||||
|
var env: [String]
|
||||||
|
|
||||||
|
@Parameter(
|
||||||
|
title: "Parent Terminal",
|
||||||
|
description: "The terminal to inherit the base configuration from."
|
||||||
|
)
|
||||||
|
var parent: TerminalEntity?
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = .foreground(.immediate)
|
||||||
|
|
||||||
|
@available(macOS, obsoleted: 26.0, message: "Replaced by supportedModes")
|
||||||
|
static var openAppWhenRun = true
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & ReturnsValue<TerminalEntity?> {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
guard let appDelegate = NSApp.delegate as? AppDelegate else {
|
||||||
|
throw GhosttyIntentError.appUnavailable
|
||||||
|
}
|
||||||
|
let ghostty = appDelegate.ghostty
|
||||||
|
|
||||||
|
var config = Ghostty.SurfaceConfiguration()
|
||||||
|
config.command = command
|
||||||
|
|
||||||
|
// If we were given a working directory then open that directory
|
||||||
|
if let url = workingDirectory?.fileURL {
|
||||||
|
let dir = url.hasDirectoryPath ? url : url.deletingLastPathComponent()
|
||||||
|
config.workingDirectory = dir.path(percentEncoded: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse environment variables from KEY=VALUE format
|
||||||
|
for envVar in env {
|
||||||
|
if let separatorIndex = envVar.firstIndex(of: "=") {
|
||||||
|
let key = String(envVar[..<separatorIndex])
|
||||||
|
let value = String(envVar[envVar.index(after: separatorIndex)...])
|
||||||
|
config.environmentVariables[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if we have a parent and get it
|
||||||
|
let parent: Ghostty.SurfaceView?
|
||||||
|
if let parentParam = self.parent {
|
||||||
|
guard let view = parentParam.surfaceView else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = view
|
||||||
|
} else if let preferred = TerminalController.preferredParent {
|
||||||
|
parent = preferred.focusedSurface ?? preferred.surfaceTree.root?.leftmostLeaf()
|
||||||
|
} else {
|
||||||
|
parent = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch location {
|
||||||
|
case .window:
|
||||||
|
let newController = TerminalController.newWindow(
|
||||||
|
ghostty,
|
||||||
|
withBaseConfig: config,
|
||||||
|
withParent: parent?.window)
|
||||||
|
if let view = newController.surfaceTree.root?.leftmostLeaf() {
|
||||||
|
return .result(value: TerminalEntity(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .tab:
|
||||||
|
let newController = TerminalController.newTab(
|
||||||
|
ghostty,
|
||||||
|
from: parent?.window,
|
||||||
|
withBaseConfig: config)
|
||||||
|
if let view = newController?.surfaceTree.root?.leftmostLeaf() {
|
||||||
|
return .result(value: TerminalEntity(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .splitLeft, .splitRight, .splitUp, .splitDown:
|
||||||
|
guard let parent,
|
||||||
|
let controller = parent.window?.windowController as? BaseTerminalController else {
|
||||||
|
throw GhosttyIntentError.surfaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if let view = controller.newSplit(
|
||||||
|
at: parent,
|
||||||
|
direction: location.splitDirection!
|
||||||
|
) {
|
||||||
|
return .result(value: TerminalEntity(view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return .result(value: .none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NewTerminalLocation
|
||||||
|
|
||||||
|
enum NewTerminalLocation: String {
|
||||||
|
case tab
|
||||||
|
case window
|
||||||
|
case splitLeft = "split:left"
|
||||||
|
case splitRight = "split:right"
|
||||||
|
case splitUp = "split:up"
|
||||||
|
case splitDown = "split:down"
|
||||||
|
|
||||||
|
var splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection? {
|
||||||
|
switch self {
|
||||||
|
case .splitLeft: return .left
|
||||||
|
case .splitRight: return .right
|
||||||
|
case .splitUp: return .up
|
||||||
|
case .splitDown: return .down
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NewTerminalLocation: AppEnum {
|
||||||
|
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Terminal Location")
|
||||||
|
|
||||||
|
static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
|
||||||
|
.tab: .init(title: "Tab"),
|
||||||
|
.window: .init(title: "Window"),
|
||||||
|
.splitLeft: .init(title: "Split Left"),
|
||||||
|
.splitRight: .init(title: "Split Right"),
|
||||||
|
.splitUp: .init(title: "Split Up"),
|
||||||
|
.splitDown: .init(title: "Split Down"),
|
||||||
|
]
|
||||||
|
}
|
32
macos/Sources/Features/App Intents/QuickTerminalIntent.swift
Normal file
32
macos/Sources/Features/App Intents/QuickTerminalIntent.swift
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import AppKit
|
||||||
|
import AppIntents
|
||||||
|
|
||||||
|
struct QuickTerminalIntent: AppIntent {
|
||||||
|
static var title: LocalizedStringResource = "Open the Quick Terminal"
|
||||||
|
static var description = IntentDescription("Open the Quick Terminal. If it is already open, then do nothing.")
|
||||||
|
|
||||||
|
@available(macOS 26.0, *)
|
||||||
|
static var supportedModes: IntentModes = .background
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func perform() async throws -> some IntentResult & ReturnsValue<[TerminalEntity]> {
|
||||||
|
guard await requestIntentPermission() else {
|
||||||
|
throw GhosttyIntentError.permissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let delegate = NSApp.delegate as? AppDelegate else {
|
||||||
|
throw GhosttyIntentError.appUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is safe to call even if it is already shown.
|
||||||
|
let c = delegate.quickController
|
||||||
|
c.animateIn()
|
||||||
|
|
||||||
|
// Grab all our terminals
|
||||||
|
let terminals = c.surfaceTree.root?.leaves().map {
|
||||||
|
TerminalEntity($0)
|
||||||
|
} ?? []
|
||||||
|
|
||||||
|
return .result(value: terminals)
|
||||||
|
}
|
||||||
|
}
|
@ -17,33 +17,19 @@ struct TerminalCommandPaletteView: View {
|
|||||||
|
|
||||||
// The commands available to the command palette.
|
// The commands available to the command palette.
|
||||||
private var commandOptions: [CommandOption] {
|
private var commandOptions: [CommandOption] {
|
||||||
guard let surface = surfaceView.surface else { return [] }
|
guard let surface = surfaceView.surfaceModel else { return [] }
|
||||||
|
do {
|
||||||
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
|
return try surface.commands().map { c in
|
||||||
var count: Int = 0
|
return CommandOption(
|
||||||
ghostty_surface_commands(surface, &ptr, &count)
|
title: c.title,
|
||||||
guard let ptr else { return [] }
|
description: c.description,
|
||||||
|
symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList
|
||||||
let buffer = UnsafeBufferPointer(start: ptr, count: count)
|
) {
|
||||||
return Array(buffer).filter { c in
|
onAction(c.action)
|
||||||
let key = String(cString: c.action_key)
|
}
|
||||||
switch (key) {
|
|
||||||
case "toggle_tab_overview",
|
|
||||||
"toggle_window_decorations",
|
|
||||||
"show_gtk_inspector":
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}.map { c in
|
|
||||||
let action = String(cString: c.action)
|
|
||||||
return CommandOption(
|
|
||||||
title: String(cString: c.title),
|
|
||||||
description: String(cString: c.description),
|
|
||||||
symbols: ghosttyConfig.keyboardShortcut(for: action)?.keyList
|
|
||||||
) {
|
|
||||||
onAction(action)
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,11 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
) {
|
) {
|
||||||
self.position = position
|
self.position = position
|
||||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||||
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
|
||||||
|
// Important detail here: we initialize with an empty surface tree so
|
||||||
|
// that we don't start a terminal process. This gets started when the
|
||||||
|
// first terminal is shown in `animateIn`.
|
||||||
|
super.init(ghostty, baseConfig: base, surfaceTree: .init())
|
||||||
|
|
||||||
// Setup our notifications for behaviors
|
// Setup our notifications for behaviors
|
||||||
let center = NotificationCenter.default
|
let center = NotificationCenter.default
|
||||||
@ -218,19 +222,19 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func closeSurfaceNode(
|
override func closeSurface(
|
||||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||||
withConfirmation: Bool = true
|
withConfirmation: Bool = true
|
||||||
) {
|
) {
|
||||||
// If this isn't the root then we're dealing with a split closure.
|
// If this isn't the root then we're dealing with a split closure.
|
||||||
if surfaceTree.root != node {
|
if surfaceTree.root != node {
|
||||||
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
|
super.closeSurface(node, withConfirmation: withConfirmation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this isn't a final leaf then we're dealing with a split closure
|
// If this isn't a final leaf then we're dealing with a split closure
|
||||||
guard case .leaf(let surface) = node else {
|
guard case .leaf(let surface) = node else {
|
||||||
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
|
super.closeSurface(node, withConfirmation: withConfirmation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,6 +193,46 @@ class BaseTerminalController: NSWindowController,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Create a new split.
|
||||||
|
@discardableResult
|
||||||
|
func newSplit(
|
||||||
|
at oldView: Ghostty.SurfaceView,
|
||||||
|
direction: SplitTree<Ghostty.SurfaceView>.NewDirection,
|
||||||
|
baseConfig config: Ghostty.SurfaceConfiguration? = nil
|
||||||
|
) -> Ghostty.SurfaceView? {
|
||||||
|
// We can only create new splits for surfaces in our tree.
|
||||||
|
guard surfaceTree.root?.node(view: oldView) != nil else { return nil }
|
||||||
|
|
||||||
|
// Create a new surface view
|
||||||
|
guard let ghostty_app = ghostty.app else { return nil }
|
||||||
|
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
||||||
|
|
||||||
|
// Do the split
|
||||||
|
let newTree: SplitTree<Ghostty.SurfaceView>
|
||||||
|
do {
|
||||||
|
newTree = try surfaceTree.insert(
|
||||||
|
view: newView,
|
||||||
|
at: oldView,
|
||||||
|
direction: direction)
|
||||||
|
} catch {
|
||||||
|
// If splitting fails for any reason (it should not), then we just log
|
||||||
|
// and return. The new view we created will be deinitialized and its
|
||||||
|
// no big deal.
|
||||||
|
Ghostty.logger.warning("failed to insert split: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceSurfaceTree(
|
||||||
|
newTree,
|
||||||
|
moveFocusTo: newView,
|
||||||
|
moveFocusFrom: oldView,
|
||||||
|
undoAction: "New Split")
|
||||||
|
|
||||||
|
return newView
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when the surfaceTree variable changed.
|
/// Called when the surfaceTree variable changed.
|
||||||
///
|
///
|
||||||
/// Subclasses should call super first.
|
/// Subclasses should call super first.
|
||||||
@ -260,6 +300,46 @@ class BaseTerminalController: NSWindowController,
|
|||||||
self.alert = alert
|
self.alert = alert
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close a surface from a view.
|
||||||
|
func closeSurface(
|
||||||
|
_ view: Ghostty.SurfaceView,
|
||||||
|
withConfirmation: Bool = true
|
||||||
|
) {
|
||||||
|
guard let node = surfaceTree.root?.node(view: view) else { return }
|
||||||
|
closeSurface(node, withConfirmation: withConfirmation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
|
||||||
|
///
|
||||||
|
/// This will also insert the proper undo stack information in.
|
||||||
|
func closeSurface(
|
||||||
|
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||||
|
withConfirmation: Bool = true
|
||||||
|
) {
|
||||||
|
// This node must be part of our tree
|
||||||
|
guard surfaceTree.contains(node) else { return }
|
||||||
|
|
||||||
|
// If the child process is not alive, then we exit immediately
|
||||||
|
guard withConfirmation else {
|
||||||
|
removeSurfaceNode(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
|
||||||
|
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
|
||||||
|
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
|
||||||
|
// so SwiftUI does not update any of the bindings to note that window is no longer
|
||||||
|
// being shown, and provides no callback to detect this.
|
||||||
|
confirmClose(
|
||||||
|
messageText: "Close Terminal?",
|
||||||
|
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
||||||
|
) { [weak self] in
|
||||||
|
if let self {
|
||||||
|
self.removeSurfaceNode(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Split Tree Management
|
// MARK: Split Tree Management
|
||||||
|
|
||||||
/// Find the next surface to focus when a node is being closed.
|
/// Find the next surface to focus when a node is being closed.
|
||||||
@ -420,42 +500,11 @@ class BaseTerminalController: NSWindowController,
|
|||||||
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
||||||
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
guard let node = surfaceTree.root?.node(view: target) else { return }
|
guard let node = surfaceTree.root?.node(view: target) else { return }
|
||||||
closeSurfaceNode(
|
closeSurface(
|
||||||
node,
|
node,
|
||||||
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
|
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
|
|
||||||
///
|
|
||||||
/// This will also insert the proper undo stack information in.
|
|
||||||
func closeSurfaceNode(
|
|
||||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
|
||||||
withConfirmation: Bool = true
|
|
||||||
) {
|
|
||||||
// This node must be part of our tree
|
|
||||||
guard surfaceTree.contains(node) else { return }
|
|
||||||
|
|
||||||
// If the child process is not alive, then we exit immediately
|
|
||||||
guard withConfirmation else {
|
|
||||||
removeSurfaceNode(node)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
|
|
||||||
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
|
|
||||||
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
|
|
||||||
// so SwiftUI does not update any of the bindings to note that window is no longer
|
|
||||||
// being shown, and provides no callback to detect this.
|
|
||||||
confirmClose(
|
|
||||||
messageText: "Close Terminal?",
|
|
||||||
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
|
||||||
) { [weak self] in
|
|
||||||
if let self {
|
|
||||||
self.removeSurfaceNode(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
|
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
|
||||||
// The target must be within our tree
|
// The target must be within our tree
|
||||||
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
@ -477,30 +526,7 @@ class BaseTerminalController: NSWindowController,
|
|||||||
default: return
|
default: return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new surface view
|
newSplit(at: oldView, direction: splitDirection, baseConfig: config)
|
||||||
guard let ghostty_app = ghostty.app else { return }
|
|
||||||
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
|
||||||
|
|
||||||
// Do the split
|
|
||||||
let newTree: SplitTree<Ghostty.SurfaceView>
|
|
||||||
do {
|
|
||||||
newTree = try surfaceTree.insert(
|
|
||||||
view: newView,
|
|
||||||
at: oldView,
|
|
||||||
direction: splitDirection)
|
|
||||||
} catch {
|
|
||||||
// If splitting fails for any reason (it should not), then we just log
|
|
||||||
// and return. The new view we created will be deinitialized and its
|
|
||||||
// no big deal.
|
|
||||||
Ghostty.logger.warning("failed to insert split: \(error)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceSurfaceTree(
|
|
||||||
newTree,
|
|
||||||
moveFocusTo: newView,
|
|
||||||
moveFocusFrom: oldView,
|
|
||||||
undoAction: "New Split")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
||||||
|
@ -169,7 +169,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||||||
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
|
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
|
||||||
|
|
||||||
// The preferred parent terminal controller.
|
// The preferred parent terminal controller.
|
||||||
private static var preferredParent: TerminalController? {
|
static var preferredParent: TerminalController? {
|
||||||
all.first {
|
all.first {
|
||||||
$0.window?.isMainWindow ?? false
|
$0.window?.isMainWindow ?? false
|
||||||
} ?? all.last
|
} ?? all.last
|
||||||
@ -519,13 +519,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// This is called anytime a node in the surface tree is being removed.
|
/// This is called anytime a node in the surface tree is being removed.
|
||||||
override func closeSurfaceNode(
|
override func closeSurface(
|
||||||
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
||||||
withConfirmation: Bool = true
|
withConfirmation: Bool = true
|
||||||
) {
|
) {
|
||||||
// If this isn't the root then we're dealing with a split closure.
|
// If this isn't the root then we're dealing with a split closure.
|
||||||
if surfaceTree.root != node {
|
if surfaceTree.root != node {
|
||||||
super.closeSurfaceNode(node, withConfirmation: withConfirmation)
|
super.closeSurface(node, withConfirmation: withConfirmation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
enum AppError: Error {
|
|
||||||
case surfaceCreateError
|
|
||||||
}
|
|
46
macos/Sources/Ghostty/Ghostty.Command.swift
Normal file
46
macos/Sources/Ghostty/Ghostty.Command.swift
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
/// `ghostty_command_s`
|
||||||
|
struct Command: Sendable {
|
||||||
|
private let cValue: ghostty_command_s
|
||||||
|
|
||||||
|
/// The title of the command.
|
||||||
|
var title: String {
|
||||||
|
String(cString: cValue.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-friendly description of what this command will do.
|
||||||
|
var description: String {
|
||||||
|
String(cString: cValue.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full action that must be performed to invoke this command.
|
||||||
|
var action: String {
|
||||||
|
String(cString: cValue.action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the key portion of the action so you can compare action types, e.g. `goto_split`
|
||||||
|
/// instead of `goto_split:left`.
|
||||||
|
var actionKey: String {
|
||||||
|
String(cString: cValue.action_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if this can be performed on this target.
|
||||||
|
var isSupported: Bool {
|
||||||
|
!Self.unsupportedActionKeys.contains(actionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unsupported action keys, because they either don't make sense in the context of our
|
||||||
|
/// target platform or they just aren't implemented yet.
|
||||||
|
static let unsupportedActionKeys: [String] = [
|
||||||
|
"toggle_tab_overview",
|
||||||
|
"toggle_window_decorations",
|
||||||
|
"show_gtk_inspector",
|
||||||
|
]
|
||||||
|
|
||||||
|
init(cValue: ghostty_command_s) {
|
||||||
|
self.cValue = cValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -558,6 +558,17 @@ extension Ghostty {
|
|||||||
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
_ = ghostty_config_get(config, &v, key, UInt(key.count))
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var macosShortcuts: MacShortcuts {
|
||||||
|
let defaultValue = MacShortcuts.ask
|
||||||
|
guard let config = self.config else { return defaultValue }
|
||||||
|
var v: UnsafePointer<Int8>? = nil
|
||||||
|
let key = "macos-shortcuts"
|
||||||
|
guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
|
||||||
|
guard let ptr = v else { return defaultValue }
|
||||||
|
let str = String(cString: ptr)
|
||||||
|
return MacShortcuts(rawValue: str) ?? defaultValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -584,6 +595,12 @@ extension Ghostty.Config {
|
|||||||
case always
|
case always
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MacShortcuts: String {
|
||||||
|
case allow
|
||||||
|
case deny
|
||||||
|
case ask
|
||||||
|
}
|
||||||
|
|
||||||
enum ResizeOverlay : String {
|
enum ResizeOverlay : String {
|
||||||
case always
|
case always
|
||||||
case never
|
case never
|
||||||
|
12
macos/Sources/Ghostty/Ghostty.Error.swift
Normal file
12
macos/Sources/Ghostty/Ghostty.Error.swift
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
extension Ghostty {
|
||||||
|
/// Possible errors from internal Ghostty calls.
|
||||||
|
enum Error: Swift.Error, CustomLocalizedStringResourceConvertible {
|
||||||
|
case apiFailed
|
||||||
|
|
||||||
|
var localizedStringResource: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .apiFailed: return "libghostty API call failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
149
macos/Sources/Ghostty/Ghostty.Surface.swift
Normal file
149
macos/Sources/Ghostty/Ghostty.Surface.swift
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
/// Represents a single surface within Ghostty.
|
||||||
|
///
|
||||||
|
/// NOTE(mitchellh): This is a work-in-progress class as part of a general refactor
|
||||||
|
/// of our Ghostty data model. At the time of writing there's still a ton of surface
|
||||||
|
/// functionality that is not encapsulated in this class. It is planned to migrate that
|
||||||
|
/// all over.
|
||||||
|
///
|
||||||
|
/// Wraps a `ghostty_surface_t`
|
||||||
|
final class Surface: Sendable {
|
||||||
|
private let surface: ghostty_surface_t
|
||||||
|
|
||||||
|
/// Read the underlying C value for this surface. This is unsafe because the value will be
|
||||||
|
/// freed when the Surface class is deinitialized.
|
||||||
|
var unsafeCValue: ghostty_surface_t {
|
||||||
|
surface
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from the C structure.
|
||||||
|
init(cSurface: ghostty_surface_t) {
|
||||||
|
self.surface = cSurface
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// deinit is not guaranteed to happen on the main actor and our API
|
||||||
|
// calls into libghostty must happen there so we capture the surface
|
||||||
|
// value so we don't capture `self` and then we detach it in a task.
|
||||||
|
// We can't wait for the task to succeed so this will happen sometime
|
||||||
|
// but that's okay.
|
||||||
|
let surface = self.surface
|
||||||
|
Task.detached { @MainActor in
|
||||||
|
ghostty_surface_free(surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard
|
||||||
|
/// shortcuts and other encodings do not take effect.
|
||||||
|
@MainActor
|
||||||
|
func sendText(_ text: String) {
|
||||||
|
let len = text.utf8CString.count
|
||||||
|
if (len == 0) { return }
|
||||||
|
|
||||||
|
text.withCString { ptr in
|
||||||
|
// len includes the null terminator so we do len - 1
|
||||||
|
ghostty_surface_text(surface, ptr, UInt(len - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a key event to the terminal.
|
||||||
|
///
|
||||||
|
/// This sends the full key event including modifiers, action type, and text to the terminal.
|
||||||
|
/// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal
|
||||||
|
/// encoding based on the complete key event information.
|
||||||
|
///
|
||||||
|
/// - Parameter event: The key event to send to the terminal
|
||||||
|
@MainActor
|
||||||
|
func sendKeyEvent(_ event: Input.KeyEvent) {
|
||||||
|
event.withCValue { cEvent in
|
||||||
|
ghostty_surface_key(surface, cEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the terminal has captured mouse input.
|
||||||
|
///
|
||||||
|
/// When the mouse is captured, the terminal application is receiving mouse events
|
||||||
|
/// directly rather than the host system handling them. This typically occurs when
|
||||||
|
/// a terminal application enables mouse reporting mode.
|
||||||
|
@MainActor
|
||||||
|
var mouseCaptured: Bool {
|
||||||
|
ghostty_surface_mouse_captured(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a mouse button event to the terminal.
|
||||||
|
///
|
||||||
|
/// This sends a complete mouse button event including the button state (press/release),
|
||||||
|
/// which button was pressed, and any modifier keys that were held during the event.
|
||||||
|
/// The terminal processes this event according to its mouse handling configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter event: The mouse button event to send to the terminal
|
||||||
|
@MainActor
|
||||||
|
func sendMouseButton(_ event: Input.MouseButtonEvent) {
|
||||||
|
ghostty_surface_mouse_button(
|
||||||
|
surface,
|
||||||
|
event.action.cMouseState,
|
||||||
|
event.button.cMouseButton,
|
||||||
|
event.mods.cMods)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a mouse position event to the terminal.
|
||||||
|
///
|
||||||
|
/// This reports the current mouse position to the terminal, which may be used
|
||||||
|
/// for mouse tracking, hover effects, or other position-dependent features.
|
||||||
|
/// The terminal will only receive these events if mouse reporting is enabled.
|
||||||
|
///
|
||||||
|
/// - Parameter event: The mouse position event to send to the terminal
|
||||||
|
@MainActor
|
||||||
|
func sendMousePos(_ event: Input.MousePosEvent) {
|
||||||
|
ghostty_surface_mouse_pos(
|
||||||
|
surface,
|
||||||
|
event.x,
|
||||||
|
event.y,
|
||||||
|
event.mods.cMods)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a mouse scroll event to the terminal.
|
||||||
|
///
|
||||||
|
/// This sends scroll wheel input to the terminal with delta values for both
|
||||||
|
/// horizontal and vertical scrolling, along with precision and momentum information.
|
||||||
|
/// The terminal processes this according to its scroll handling configuration.
|
||||||
|
///
|
||||||
|
/// - Parameter event: The mouse scroll event to send to the terminal
|
||||||
|
@MainActor
|
||||||
|
func sendMouseScroll(_ event: Input.MouseScrollEvent) {
|
||||||
|
ghostty_surface_mouse_scroll(
|
||||||
|
surface,
|
||||||
|
event.x,
|
||||||
|
event.y,
|
||||||
|
event.mods.cScrollMods)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform a keybinding action.
|
||||||
|
///
|
||||||
|
/// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4`
|
||||||
|
/// you can perform `goto_tab:4` with this.
|
||||||
|
///
|
||||||
|
/// Returns true if the action was performed. Invalid actions return false.
|
||||||
|
@MainActor
|
||||||
|
func perform(action: String) -> Bool {
|
||||||
|
let len = action.utf8CString.count
|
||||||
|
if (len == 0) { return false }
|
||||||
|
return action.withCString { cString in
|
||||||
|
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command options for this surface.
|
||||||
|
@MainActor
|
||||||
|
func commands() throws -> [Command] {
|
||||||
|
var ptr: UnsafeMutablePointer<ghostty_command_s>? = nil
|
||||||
|
var count: Int = 0
|
||||||
|
ghostty_surface_commands(surface, &ptr, &count)
|
||||||
|
guard let ptr else { throw Error.apiFailed }
|
||||||
|
let buffer = UnsafeBufferPointer(start: ptr, count: count)
|
||||||
|
return Array(buffer).map { Command(cValue: $0) }.filter { $0.isSupported }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -337,9 +337,9 @@ extension Ghostty {
|
|||||||
|
|
||||||
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
|
private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) {
|
||||||
guard let inspector = self.inspector else { return }
|
guard let inspector = self.inspector else { return }
|
||||||
guard let key = Ghostty.keycodeToKey[event.keyCode] else { return }
|
guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return }
|
||||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
||||||
ghostty_inspector_key(inspector, action, key, mods)
|
ghostty_inspector_key(inspector, action, key.cKey, mods)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSTextInputClient
|
// MARK: NSTextInputClient
|
||||||
|
@ -19,6 +19,15 @@ struct Ghostty {
|
|||||||
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
|
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: C Extensions
|
||||||
|
|
||||||
|
/// A command is fully self-contained so it is Sendable.
|
||||||
|
extension ghostty_command_s: @unchecked @retroactive Sendable {}
|
||||||
|
|
||||||
|
/// A surface is sendable because it is just a reference type. Using the surface in parameters
|
||||||
|
/// may be unsafe but the value itself is safe to send across threads.
|
||||||
|
extension ghostty_surface_t: @unchecked @retroactive Sendable {}
|
||||||
|
|
||||||
// MARK: Build Info
|
// MARK: Build Info
|
||||||
|
|
||||||
extension Ghostty {
|
extension Ghostty {
|
||||||
|
@ -79,7 +79,7 @@ extension Ghostty {
|
|||||||
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
|
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
Surface(view: surfaceView, size: geo.size)
|
SurfaceRepresentable(view: surfaceView, size: geo.size)
|
||||||
.focused($surfaceFocus)
|
.focused($surfaceFocus)
|
||||||
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
|
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
|
||||||
.focusedValue(\.ghosttySurfaceView, surfaceView)
|
.focusedValue(\.ghosttySurfaceView, surfaceView)
|
||||||
@ -381,7 +381,7 @@ extension Ghostty {
|
|||||||
/// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible
|
/// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible
|
||||||
/// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to
|
/// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to
|
||||||
/// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with.
|
/// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with.
|
||||||
struct Surface: OSViewRepresentable {
|
struct SurfaceRepresentable: OSViewRepresentable {
|
||||||
/// The view to render for the terminal surface.
|
/// The view to render for the terminal surface.
|
||||||
let view: SurfaceView
|
let view: SurfaceView
|
||||||
|
|
||||||
@ -419,17 +419,35 @@ extension Ghostty {
|
|||||||
/// Explicit command to set
|
/// Explicit command to set
|
||||||
var command: String? = nil
|
var command: String? = nil
|
||||||
|
|
||||||
|
/// Environment variables to set for the terminal
|
||||||
|
var environmentVariables: [String: String] = [:]
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
init(from config: ghostty_surface_config_s) {
|
init(from config: ghostty_surface_config_s) {
|
||||||
self.fontSize = config.font_size
|
self.fontSize = config.font_size
|
||||||
self.workingDirectory = String.init(cString: config.working_directory, encoding: .utf8)
|
if let workingDirectory = config.working_directory {
|
||||||
self.command = String.init(cString: config.command, encoding: .utf8)
|
self.workingDirectory = String.init(cString: workingDirectory, encoding: .utf8)
|
||||||
|
}
|
||||||
|
if let command = config.command {
|
||||||
|
self.command = String.init(cString: command, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the C env vars to Swift dictionary
|
||||||
|
if config.env_var_count > 0, let envVars = config.env_vars {
|
||||||
|
for i in 0..<config.env_var_count {
|
||||||
|
let envVar = envVars[i]
|
||||||
|
if let key = String(cString: envVar.key, encoding: .utf8),
|
||||||
|
let value = String(cString: envVar.value, encoding: .utf8) {
|
||||||
|
self.environmentVariables[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the ghostty configuration for this surface configuration struct. The memory
|
/// Provides a C-compatible ghostty configuration within a closure. The configuration
|
||||||
/// in the returned struct is only valid as long as this struct is retained.
|
/// and all its string pointers are only valid within the closure.
|
||||||
func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s {
|
func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
|
||||||
var config = ghostty_surface_config_new()
|
var config = ghostty_surface_config_new()
|
||||||
config.userdata = Unmanaged.passUnretained(view).toOpaque()
|
config.userdata = Unmanaged.passUnretained(view).toOpaque()
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@ -438,7 +456,6 @@ extension Ghostty {
|
|||||||
nsview: Unmanaged.passUnretained(view).toOpaque()
|
nsview: Unmanaged.passUnretained(view).toOpaque()
|
||||||
))
|
))
|
||||||
config.scale_factor = NSScreen.main!.backingScaleFactor
|
config.scale_factor = NSScreen.main!.backingScaleFactor
|
||||||
|
|
||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
config.platform_tag = GHOSTTY_PLATFORM_IOS
|
config.platform_tag = GHOSTTY_PLATFORM_IOS
|
||||||
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
|
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
|
||||||
@ -453,15 +470,42 @@ extension Ghostty {
|
|||||||
#error("unsupported target")
|
#error("unsupported target")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if let fontSize = fontSize { config.font_size = fontSize }
|
// Zero is our default value that means to inherit the font size.
|
||||||
if let workingDirectory = workingDirectory {
|
config.font_size = fontSize ?? 0
|
||||||
config.working_directory = (workingDirectory as NSString).utf8String
|
|
||||||
}
|
|
||||||
if let command = command {
|
|
||||||
config.command = (command as NSString).utf8String
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
// Use withCString to ensure strings remain valid for the duration of the closure
|
||||||
|
return try workingDirectory.withCString { cWorkingDir in
|
||||||
|
config.working_directory = cWorkingDir
|
||||||
|
|
||||||
|
return try command.withCString { cCommand in
|
||||||
|
config.command = cCommand
|
||||||
|
|
||||||
|
// Convert dictionary to arrays for easier processing
|
||||||
|
let keys = Array(environmentVariables.keys)
|
||||||
|
let values = Array(environmentVariables.values)
|
||||||
|
|
||||||
|
// Create C strings for all keys and values
|
||||||
|
return try keys.withCStrings { keyCStrings in
|
||||||
|
return try values.withCStrings { valueCStrings in
|
||||||
|
// Create array of ghostty_env_var_s
|
||||||
|
var envVars = Array<ghostty_env_var_s>()
|
||||||
|
envVars.reserveCapacity(environmentVariables.count)
|
||||||
|
for i in 0..<environmentVariables.count {
|
||||||
|
envVars.append(ghostty_env_var_s(
|
||||||
|
key: keyCStrings[i],
|
||||||
|
value: valueCStrings[i]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try envVars.withUnsafeMutableBufferPointer { buffer in
|
||||||
|
config.env_vars = buffer.baseAddress
|
||||||
|
config.env_var_count = environmentVariables.count
|
||||||
|
return try body(&config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,10 +115,20 @@ extension Ghostty {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the data model for this surface.
|
||||||
|
///
|
||||||
|
/// Note: eventually, all surface access will be through this, but presently its in a transition
|
||||||
|
/// state so we're mixing this with direct surface access.
|
||||||
|
private(set) var surfaceModel: Ghostty.Surface?
|
||||||
|
|
||||||
|
/// Returns the underlying C value for the surface. See "note" on surfaceModel.
|
||||||
|
var surface: ghostty_surface_t? {
|
||||||
|
surfaceModel?.unsafeCValue
|
||||||
|
}
|
||||||
|
|
||||||
// Notification identifiers associated with this surface
|
// Notification identifiers associated with this surface
|
||||||
var notificationIdentifiers: Set<String> = []
|
var notificationIdentifiers: Set<String> = []
|
||||||
|
|
||||||
private(set) var surface: ghostty_surface_t?
|
|
||||||
private var markedText: NSMutableAttributedString
|
private var markedText: NSMutableAttributedString
|
||||||
private(set) var focused: Bool = true
|
private(set) var focused: Bool = true
|
||||||
private var prevPressureStage: Int = 0
|
private var prevPressureStage: Int = 0
|
||||||
@ -139,7 +149,8 @@ extension Ghostty {
|
|||||||
private var titleFromTerminal: String?
|
private var titleFromTerminal: String?
|
||||||
|
|
||||||
// The cached contents of the screen.
|
// The cached contents of the screen.
|
||||||
private var cachedScreenContents: CachedValue<String>
|
private(set) var cachedScreenContents: CachedValue<String>
|
||||||
|
private(set) var cachedVisibleContents: CachedValue<String>
|
||||||
|
|
||||||
/// Event monitor (see individual events for why)
|
/// Event monitor (see individual events for why)
|
||||||
private var eventMonitor: Any? = nil
|
private var eventMonitor: Any? = nil
|
||||||
@ -166,6 +177,7 @@ extension Ghostty {
|
|||||||
// it back up later so we can reference `self`. This is a hack we should
|
// it back up later so we can reference `self`. This is a hack we should
|
||||||
// fix at some point.
|
// fix at some point.
|
||||||
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
|
self.cachedScreenContents = .init(duration: .milliseconds(500)) { "" }
|
||||||
|
self.cachedVisibleContents = self.cachedScreenContents
|
||||||
|
|
||||||
// Initialize with some default frame size. The important thing is that this
|
// Initialize with some default frame size. The important thing is that this
|
||||||
// is non-zero so that our layer bounds are non-zero so that our renderer
|
// is non-zero so that our layer bounds are non-zero so that our renderer
|
||||||
@ -193,6 +205,26 @@ extension Ghostty {
|
|||||||
defer { ghostty_surface_free_text(surface, &text) }
|
defer { ghostty_surface_free_text(surface, &text) }
|
||||||
return String(cString: text.text)
|
return String(cString: text.text)
|
||||||
}
|
}
|
||||||
|
cachedVisibleContents = .init(duration: .milliseconds(500)) { [weak self] in
|
||||||
|
guard let self else { return "" }
|
||||||
|
guard let surface = self.surface else { return "" }
|
||||||
|
var text = ghostty_text_s()
|
||||||
|
let sel = ghostty_selection_s(
|
||||||
|
top_left: ghostty_point_s(
|
||||||
|
tag: GHOSTTY_POINT_VIEWPORT,
|
||||||
|
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||||
|
x: 0,
|
||||||
|
y: 0),
|
||||||
|
bottom_right: ghostty_point_s(
|
||||||
|
tag: GHOSTTY_POINT_VIEWPORT,
|
||||||
|
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||||
|
x: 0,
|
||||||
|
y: 0),
|
||||||
|
rectangle: false)
|
||||||
|
guard ghostty_surface_read_text(surface, sel, &text) else { return "" }
|
||||||
|
defer { ghostty_surface_free_text(surface, &text) }
|
||||||
|
return String(cString: text.text)
|
||||||
|
}
|
||||||
|
|
||||||
// Set a timer to show the ghost emoji after 500ms if no title is set
|
// Set a timer to show the ghost emoji after 500ms if no title is set
|
||||||
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
titleFallbackTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||||
@ -258,12 +290,14 @@ extension Ghostty {
|
|||||||
|
|
||||||
// Setup our surface. This will also initialize all the terminal IO.
|
// Setup our surface. This will also initialize all the terminal IO.
|
||||||
let surface_cfg = baseConfig ?? SurfaceConfiguration()
|
let surface_cfg = baseConfig ?? SurfaceConfiguration()
|
||||||
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
|
let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
|
||||||
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else {
|
ghostty_surface_new(app, &surface_cfg_c)
|
||||||
self.error = AppError.surfaceCreateError
|
}
|
||||||
|
guard let surface = surface else {
|
||||||
|
self.error = Ghostty.Error.apiFailed
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.surface = surface;
|
self.surfaceModel = Ghostty.Surface(cSurface: surface)
|
||||||
|
|
||||||
// Setup our tracking area so we get mouse moved events
|
// Setup our tracking area so we get mouse moved events
|
||||||
updateTrackingAreas()
|
updateTrackingAreas()
|
||||||
@ -318,11 +352,6 @@ extension Ghostty {
|
|||||||
// Remove any notifications associated with this surface
|
// Remove any notifications associated with this surface
|
||||||
let identifiers = Array(self.notificationIdentifiers)
|
let identifiers = Array(self.notificationIdentifiers)
|
||||||
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||||
|
|
||||||
// Free our core surface resources
|
|
||||||
if let surface = self.surface {
|
|
||||||
ghostty_surface_free(surface)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func focusDidChange(_ focused: Bool) {
|
func focusDidChange(_ focused: Bool) {
|
||||||
@ -781,19 +810,23 @@ extension Ghostty {
|
|||||||
override func mouseEntered(with event: NSEvent) {
|
override func mouseEntered(with event: NSEvent) {
|
||||||
super.mouseEntered(with: event)
|
super.mouseEntered(with: event)
|
||||||
|
|
||||||
guard let surface = self.surface else { return }
|
guard let surfaceModel else { return }
|
||||||
|
|
||||||
// On mouse enter we need to reset our cursor position. This is
|
// On mouse enter we need to reset our cursor position. This is
|
||||||
// super important because we set it to -1/-1 on mouseExit and
|
// super important because we set it to -1/-1 on mouseExit and
|
||||||
// lots of mouse logic (i.e. whether to send mouse reports) depend
|
// lots of mouse logic (i.e. whether to send mouse reports) depend
|
||||||
// on the position being in the viewport if it is.
|
// on the position being in the viewport if it is.
|
||||||
let pos = self.convert(event.locationInWindow, from: nil)
|
let pos = self.convert(event.locationInWindow, from: nil)
|
||||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
let mouseEvent = Ghostty.Input.MousePosEvent(
|
||||||
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
|
x: pos.x,
|
||||||
|
y: frame.height - pos.y,
|
||||||
|
mods: .init(nsFlags: event.modifierFlags)
|
||||||
|
)
|
||||||
|
surfaceModel.sendMousePos(mouseEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseExited(with event: NSEvent) {
|
override func mouseExited(with event: NSEvent) {
|
||||||
guard let surface = self.surface else { return }
|
guard let surfaceModel else { return }
|
||||||
|
|
||||||
// If the mouse is being dragged then we don't have to emit
|
// If the mouse is being dragged then we don't have to emit
|
||||||
// this because we get mouse drag events even if we've already
|
// this because we get mouse drag events even if we've already
|
||||||
@ -803,17 +836,25 @@ extension Ghostty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Negative values indicate cursor has left the viewport
|
// Negative values indicate cursor has left the viewport
|
||||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
let mouseEvent = Ghostty.Input.MousePosEvent(
|
||||||
ghostty_surface_mouse_pos(surface, -1, -1, mods)
|
x: -1,
|
||||||
|
y: -1,
|
||||||
|
mods: .init(nsFlags: event.modifierFlags)
|
||||||
|
)
|
||||||
|
surfaceModel.sendMousePos(mouseEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func mouseMoved(with event: NSEvent) {
|
override func mouseMoved(with event: NSEvent) {
|
||||||
guard let surface = self.surface else { return }
|
guard let surfaceModel else { return }
|
||||||
|
|
||||||
// Convert window position to view position. Note (0, 0) is bottom left.
|
// Convert window position to view position. Note (0, 0) is bottom left.
|
||||||
let pos = self.convert(event.locationInWindow, from: nil)
|
let pos = self.convert(event.locationInWindow, from: nil)
|
||||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
let mouseEvent = Ghostty.Input.MousePosEvent(
|
||||||
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods)
|
x: pos.x,
|
||||||
|
y: frame.height - pos.y,
|
||||||
|
mods: .init(nsFlags: event.modifierFlags)
|
||||||
|
)
|
||||||
|
surfaceModel.sendMousePos(mouseEvent)
|
||||||
|
|
||||||
// Handle focus-follows-mouse
|
// Handle focus-follows-mouse
|
||||||
if let window,
|
if let window,
|
||||||
@ -839,16 +880,13 @@ extension Ghostty {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func scrollWheel(with event: NSEvent) {
|
override func scrollWheel(with event: NSEvent) {
|
||||||
guard let surface = self.surface else { return }
|
guard let surfaceModel else { return }
|
||||||
|
|
||||||
// Builds up the "input.ScrollMods" bitmask
|
|
||||||
var mods: Int32 = 0
|
|
||||||
|
|
||||||
var x = event.scrollingDeltaX
|
var x = event.scrollingDeltaX
|
||||||
var y = event.scrollingDeltaY
|
var y = event.scrollingDeltaY
|
||||||
if event.hasPreciseScrollingDeltas {
|
let precision = event.hasPreciseScrollingDeltas
|
||||||
mods = 1
|
|
||||||
|
|
||||||
|
if precision {
|
||||||
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
|
// We do a 2x speed multiplier. This is subjective, it "feels" better to me.
|
||||||
x *= 2;
|
x *= 2;
|
||||||
y *= 2;
|
y *= 2;
|
||||||
@ -856,29 +894,12 @@ extension Ghostty {
|
|||||||
// TODO(mitchellh): do we have to scale the x/y here by window scale factor?
|
// TODO(mitchellh): do we have to scale the x/y here by window scale factor?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine our momentum value
|
let scrollEvent = Ghostty.Input.MouseScrollEvent(
|
||||||
var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE
|
x: x,
|
||||||
switch (event.momentumPhase) {
|
y: y,
|
||||||
case .began:
|
mods: .init(precision: precision, momentum: .init(event.momentumPhase))
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN
|
)
|
||||||
case .stationary:
|
surfaceModel.sendMouseScroll(scrollEvent)
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY
|
|
||||||
case .changed:
|
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED
|
|
||||||
case .ended:
|
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED
|
|
||||||
case .cancelled:
|
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED
|
|
||||||
case .mayBegin:
|
|
||||||
momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pack our momentum value into the mods bitmask
|
|
||||||
mods |= Int32(momentum.rawValue) << 1
|
|
||||||
|
|
||||||
ghostty_surface_mouse_scroll(surface, x, y, mods)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func pressureChange(with event: NSEvent) {
|
override func pressureChange(with event: NSEvent) {
|
||||||
@ -1285,8 +1306,8 @@ extension Ghostty {
|
|||||||
// In this case, AppKit calls menu BEFORE calling any mouse events.
|
// In this case, AppKit calls menu BEFORE calling any mouse events.
|
||||||
// If mouse capturing is enabled then we never show the context menu
|
// If mouse capturing is enabled then we never show the context menu
|
||||||
// so that we can handle ctrl+left-click in the terminal app.
|
// so that we can handle ctrl+left-click in the terminal app.
|
||||||
guard let surface = self.surface else { return nil }
|
guard let surfaceModel else { return nil }
|
||||||
if ghostty_surface_mouse_captured(surface) {
|
if surfaceModel.mouseCaptured {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1296,13 +1317,10 @@ extension Ghostty {
|
|||||||
//
|
//
|
||||||
// Note this never sounds a right mouse up event but that's the
|
// Note this never sounds a right mouse up event but that's the
|
||||||
// same as normal right-click with capturing disabled from AppKit.
|
// same as normal right-click with capturing disabled from AppKit.
|
||||||
let mods = Ghostty.ghosttyMods(event.modifierFlags)
|
surfaceModel.sendMouseButton(.init(
|
||||||
ghostty_surface_mouse_button(
|
action: .press,
|
||||||
surface,
|
button: .right,
|
||||||
GHOSTTY_MOUSE_PRESS,
|
mods: .init(nsFlags: event.modifierFlags)))
|
||||||
GHOSTTY_MOUSE_RIGHT,
|
|
||||||
mods
|
|
||||||
)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@ -1673,7 +1691,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
|||||||
func insertText(_ string: Any, replacementRange: NSRange) {
|
func insertText(_ string: Any, replacementRange: NSRange) {
|
||||||
// We must have an associated event
|
// We must have an associated event
|
||||||
guard NSApp.currentEvent != nil else { return }
|
guard NSApp.currentEvent != nil else { return }
|
||||||
guard let surface = self.surface else { return }
|
guard let surfaceModel else { return }
|
||||||
|
|
||||||
// We want the string view of the any value
|
// We want the string view of the any value
|
||||||
var chars = ""
|
var chars = ""
|
||||||
@ -1697,13 +1715,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let len = chars.utf8CString.count
|
surfaceModel.sendText(chars)
|
||||||
if (len == 0) { return }
|
|
||||||
|
|
||||||
chars.withCString { ptr in
|
|
||||||
// len includes the null terminator so we do len - 1
|
|
||||||
ghostty_surface_text(surface, ptr, UInt(len - 1))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This function needs to exist for two reasons:
|
/// This function needs to exist for two reasons:
|
||||||
@ -1979,7 +1991,7 @@ extension Ghostty.SurfaceView {
|
|||||||
/// Caches a value for some period of time, evicting it automatically when that time expires.
|
/// Caches a value for some period of time, evicting it automatically when that time expires.
|
||||||
/// We use this to cache our surface content. This probably should be extracted some day
|
/// We use this to cache our surface content. This probably should be extracted some day
|
||||||
/// to a more generic helper.
|
/// to a more generic helper.
|
||||||
fileprivate class CachedValue<T> {
|
class CachedValue<T> {
|
||||||
private var value: T?
|
private var value: T?
|
||||||
private let fetch: () -> T
|
private let fetch: () -> T
|
||||||
private let duration: Duration
|
private let duration: Duration
|
||||||
|
@ -57,8 +57,10 @@ extension Ghostty {
|
|||||||
|
|
||||||
// Setup our surface. This will also initialize all the terminal IO.
|
// Setup our surface. This will also initialize all the terminal IO.
|
||||||
let surface_cfg = baseConfig ?? SurfaceConfiguration()
|
let surface_cfg = baseConfig ?? SurfaceConfiguration()
|
||||||
var surface_cfg_c = surface_cfg.ghosttyConfig(view: self)
|
let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
|
||||||
guard let surface = ghostty_surface_new(app, &surface_cfg_c) else {
|
ghostty_surface_new(app, &surface_cfg_c)
|
||||||
|
}
|
||||||
|
guard let surface = surface else {
|
||||||
// TODO
|
// TODO
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -21,3 +21,28 @@ extension Array {
|
|||||||
return i + 1
|
return i + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Array where Element == String {
|
||||||
|
/// Executes a closure with an array of C string pointers.
|
||||||
|
func withCStrings<T>(_ body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
|
||||||
|
// Handle empty array
|
||||||
|
if isEmpty {
|
||||||
|
return try body([])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive helper to process strings
|
||||||
|
func helper(index: Int, accumulated: [UnsafePointer<Int8>?], body: ([UnsafePointer<Int8>?]) throws -> T) rethrows -> T {
|
||||||
|
if index == count {
|
||||||
|
return try body(accumulated)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try self[index].withCString { cStr in
|
||||||
|
var newAccumulated = accumulated
|
||||||
|
newAccumulated.append(cStr)
|
||||||
|
return try helper(index: index + 1, accumulated: newAccumulated, body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try helper(index: 0, accumulated: [], body: body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
extension NSView {
|
extension NSView {
|
||||||
/// Returns true if this view is currently in the responder chain
|
/// Returns true if this view is currently in the responder chain
|
||||||
@ -15,6 +16,24 @@ extension NSView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Screenshot
|
||||||
|
|
||||||
|
extension NSView {
|
||||||
|
/// Take a screenshot of just this view.
|
||||||
|
func screenshot() -> NSImage? {
|
||||||
|
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
|
||||||
|
cacheDisplay(in: bounds, to: bitmapRep)
|
||||||
|
let image = NSImage(size: bounds.size)
|
||||||
|
image.addRepresentation(bitmapRep)
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenshot() -> Image? {
|
||||||
|
guard let nsImage: NSImage = self.screenshot() else { return nil }
|
||||||
|
return Image(nsImage: nsImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: View Traversal and Search
|
// MARK: View Traversal and Search
|
||||||
|
|
||||||
extension NSView {
|
extension NSView {
|
||||||
|
10
macos/Sources/Helpers/Extensions/Optional+Extension.swift
Normal file
10
macos/Sources/Helpers/Extensions/Optional+Extension.swift
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
extension Optional where Wrapped == String {
|
||||||
|
/// Executes a closure with a C string pointer, handling nil gracefully.
|
||||||
|
func withCString<T>(_ body: (UnsafePointer<Int8>?) throws -> T) rethrows -> T {
|
||||||
|
if let string = self {
|
||||||
|
return try string.withCString(body)
|
||||||
|
} else {
|
||||||
|
return try body(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
213
macos/Sources/Helpers/PermissionRequest.swift
Normal file
213
macos/Sources/Helpers/PermissionRequest.swift
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Displays a permission request dialog with optional caching of user decisions
|
||||||
|
class PermissionRequest {
|
||||||
|
/// Specifies how long a permission decision should be cached
|
||||||
|
enum AllowDuration {
|
||||||
|
case once
|
||||||
|
case forever
|
||||||
|
case duration(Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a permission request dialog with customizable caching behavior
|
||||||
|
/// - Parameters:
|
||||||
|
/// - key: Unique identifier for storing/retrieving cached decisions in UserDefaults
|
||||||
|
/// - message: The message to display in the alert dialog
|
||||||
|
/// - allowText: Custom text for the allow button (defaults to "Allow")
|
||||||
|
/// - allowDuration: If provided, automatically cache "Allow" responses for this duration
|
||||||
|
/// - rememberDuration: If provided, shows a checkbox to remember the decision for this duration
|
||||||
|
/// - window: If provided, shows the alert as a sheet attached to this window
|
||||||
|
/// - completion: Called with the user's decision (true for allow, false for deny)
|
||||||
|
///
|
||||||
|
/// Caching behavior:
|
||||||
|
/// - If rememberDuration is provided and user checks "Remember my decision", both allow/deny are cached for that duration
|
||||||
|
/// - If allowDuration is provided and user selects allow (without checkbox), decision is cached for that duration
|
||||||
|
/// - Cached decisions are automatically returned without showing the dialog
|
||||||
|
@MainActor
|
||||||
|
static func show(
|
||||||
|
_ key: String,
|
||||||
|
message: String,
|
||||||
|
informative: String = "",
|
||||||
|
allowText: String = "Allow",
|
||||||
|
allowDuration: AllowDuration = .once,
|
||||||
|
rememberDuration: Duration? = .seconds(86400),
|
||||||
|
window: NSWindow? = nil,
|
||||||
|
completion: @escaping (Bool) -> Void
|
||||||
|
) {
|
||||||
|
// Check if we have a stored decision that hasn't expired
|
||||||
|
if let storedResult = getStoredResult(for: key) {
|
||||||
|
completion(storedResult)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = message
|
||||||
|
alert.informativeText = informative
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
|
||||||
|
// Add buttons (they appear in reverse order)
|
||||||
|
alert.addButton(withTitle: allowText)
|
||||||
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
|
||||||
|
// Create checkbox for remembering if duration is provided
|
||||||
|
var checkbox: NSButton?
|
||||||
|
if let rememberDuration = rememberDuration {
|
||||||
|
let checkboxTitle = formatRememberText(for: rememberDuration)
|
||||||
|
checkbox = NSButton(
|
||||||
|
checkboxWithTitle: checkboxTitle,
|
||||||
|
target: nil,
|
||||||
|
action: nil)
|
||||||
|
checkbox!.state = .off
|
||||||
|
|
||||||
|
// Set checkbox as accessory view
|
||||||
|
alert.accessoryView = checkbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the alert
|
||||||
|
if let window = window {
|
||||||
|
alert.beginSheetModal(for: window) { response in
|
||||||
|
handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let response = alert.runModal()
|
||||||
|
handleResponse(response, rememberDecision: checkbox?.state == .on, key: key, allowDuration: allowDuration, rememberDuration: rememberDuration, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles the alert response and processes caching logic
|
||||||
|
/// - Parameters:
|
||||||
|
/// - response: The alert response from the user
|
||||||
|
/// - rememberDecision: Whether the remember checkbox was checked
|
||||||
|
/// - key: The UserDefaults key for caching
|
||||||
|
/// - allowDuration: Optional duration for auto-caching allow responses
|
||||||
|
/// - rememberDuration: Optional duration for the remember checkbox
|
||||||
|
/// - completion: Completion handler to call with the result
|
||||||
|
private static func handleResponse(
|
||||||
|
_ response: NSApplication.ModalResponse,
|
||||||
|
rememberDecision: Bool,
|
||||||
|
key: String,
|
||||||
|
allowDuration: AllowDuration,
|
||||||
|
rememberDuration: Duration?,
|
||||||
|
completion: @escaping (Bool) -> Void) {
|
||||||
|
|
||||||
|
let result: Bool
|
||||||
|
switch response {
|
||||||
|
case .alertFirstButtonReturn: // Allow
|
||||||
|
result = true
|
||||||
|
case .alertSecondButtonReturn: // Don't Allow
|
||||||
|
result = false
|
||||||
|
default:
|
||||||
|
result = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the result if checkbox is checked or if "Allow" was selected and allowDuration is set
|
||||||
|
if rememberDecision, let rememberDuration = rememberDuration {
|
||||||
|
storeResult(result, for: key, duration: rememberDuration)
|
||||||
|
} else if result {
|
||||||
|
switch allowDuration {
|
||||||
|
case .once:
|
||||||
|
// Don't store anything for once
|
||||||
|
break
|
||||||
|
case .forever:
|
||||||
|
// Store for a very long time (100 years). When the bug comes in that
|
||||||
|
// 100 years has passed and their forever permission expired I'll be
|
||||||
|
// dead so it won't be my problem.
|
||||||
|
storeResult(result, for: key, duration: .seconds(3153600000))
|
||||||
|
case .duration(let duration):
|
||||||
|
storeResult(result, for: key, duration: duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves a cached permission decision if it hasn't expired
|
||||||
|
/// - Parameter key: The UserDefaults key to check
|
||||||
|
/// - Returns: The cached decision, or nil if no valid cached decision exists
|
||||||
|
private static func getStoredResult(for key: String) -> Bool? {
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
guard let data = userDefaults.data(forKey: key),
|
||||||
|
let storedPermission = try? NSKeyedUnarchiver.unarchivedObject(
|
||||||
|
ofClass: StoredPermission.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if Date() > storedPermission.expiry {
|
||||||
|
// Decision has expired, remove stored value
|
||||||
|
userDefaults.removeObject(forKey: key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedPermission.result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a permission decision in UserDefaults with an expiration date
|
||||||
|
/// - Parameters:
|
||||||
|
/// - result: The permission decision to store
|
||||||
|
/// - key: The UserDefaults key to store under
|
||||||
|
/// - duration: How long the decision should be cached
|
||||||
|
private static func storeResult(_ result: Bool, for key: String, duration: Duration) {
|
||||||
|
let expiryDate = Date().addingTimeInterval(duration.timeInterval)
|
||||||
|
let storedPermission = StoredPermission(result: result, expiry: expiryDate)
|
||||||
|
if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) {
|
||||||
|
let userDefaults = UserDefaults.standard
|
||||||
|
userDefaults.set(data, forKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the remember checkbox text based on the duration
|
||||||
|
/// - Parameter duration: The duration to format
|
||||||
|
/// - Returns: A human-readable string for the checkbox
|
||||||
|
private static func formatRememberText(for duration: Duration) -> String {
|
||||||
|
let seconds = duration.timeInterval
|
||||||
|
|
||||||
|
// Warning: this probably isn't localization friendly at all so we're
|
||||||
|
// going to have to redo this for that.
|
||||||
|
switch seconds {
|
||||||
|
case 0..<60:
|
||||||
|
return "Remember my decision for \(Int(seconds)) seconds"
|
||||||
|
case 60..<3600:
|
||||||
|
let minutes = Int(seconds / 60)
|
||||||
|
return "Remember my decision for \(minutes) minute\(minutes == 1 ? "" : "s")"
|
||||||
|
case 3600..<86400:
|
||||||
|
let hours = Int(seconds / 3600)
|
||||||
|
return "Remember my decision for \(hours) hour\(hours == 1 ? "" : "s")"
|
||||||
|
case 86400:
|
||||||
|
return "Remember my decision for one day"
|
||||||
|
default:
|
||||||
|
let days = Int(seconds / 86400)
|
||||||
|
return "Remember my decision for \(days) day\(days == 1 ? "" : "s")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal class for storing permission decisions with expiration dates in UserDefaults
|
||||||
|
/// Conforms to NSSecureCoding for safe archiving/unarchiving
|
||||||
|
@objc(StoredPermission)
|
||||||
|
private class StoredPermission: NSObject, NSSecureCoding {
|
||||||
|
static var supportsSecureCoding: Bool = true
|
||||||
|
|
||||||
|
let result: Bool
|
||||||
|
let expiry: Date
|
||||||
|
|
||||||
|
init(result: Bool, expiry: Date) {
|
||||||
|
self.result = result
|
||||||
|
self.expiry = expiry
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
self.result = coder.decodeBool(forKey: "result")
|
||||||
|
guard let expiry = coder.decodeObject(of: NSDate.self, forKey: "expiry") as? Date else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.expiry = expiry
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(with coder: NSCoder) {
|
||||||
|
coder.encode(result, forKey: "result")
|
||||||
|
coder.encode(expiry, forKey: "expiry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -376,6 +376,14 @@ pub const PlatformTag = enum(c_int) {
|
|||||||
ios = 2,
|
ios = 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const EnvVar = extern struct {
|
||||||
|
/// The name of the environment variable.
|
||||||
|
key: [*:0]const u8,
|
||||||
|
|
||||||
|
/// The value of the environment variable.
|
||||||
|
value: [*:0]const u8,
|
||||||
|
};
|
||||||
|
|
||||||
pub const Surface = struct {
|
pub const Surface = struct {
|
||||||
app: *App,
|
app: *App,
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
@ -407,7 +415,7 @@ pub const Surface = struct {
|
|||||||
font_size: f32 = 0,
|
font_size: f32 = 0,
|
||||||
|
|
||||||
/// The working directory to load into.
|
/// The working directory to load into.
|
||||||
working_directory: [*:0]const u8 = "",
|
working_directory: ?[*:0]const u8 = null,
|
||||||
|
|
||||||
/// The command to run in the new surface. If this is set then
|
/// The command to run in the new surface. If this is set then
|
||||||
/// the "wait-after-command" option is also automatically set to true,
|
/// the "wait-after-command" option is also automatically set to true,
|
||||||
@ -417,7 +425,11 @@ pub const Surface = struct {
|
|||||||
/// despite Ghostty allowing directly executed commands via config.
|
/// despite Ghostty allowing directly executed commands via config.
|
||||||
/// This is a legacy thing and we should probably change it in the
|
/// This is a legacy thing and we should probably change it in the
|
||||||
/// future once we have a concrete use case.
|
/// future once we have a concrete use case.
|
||||||
command: [*:0]const u8 = "",
|
command: ?[*:0]const u8 = null,
|
||||||
|
|
||||||
|
/// Extra environment variables to set for the surface.
|
||||||
|
env_vars: ?[*]EnvVar = null,
|
||||||
|
env_var_count: usize = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
pub fn init(self: *Surface, app: *App, opts: Options) !void {
|
||||||
@ -443,41 +455,59 @@ pub const Surface = struct {
|
|||||||
defer config.deinit();
|
defer config.deinit();
|
||||||
|
|
||||||
// If we have a working directory from the options then we set it.
|
// If we have a working directory from the options then we set it.
|
||||||
const wd = std.mem.sliceTo(opts.working_directory, 0);
|
if (opts.working_directory) |c_wd| {
|
||||||
if (wd.len > 0) wd: {
|
const wd = std.mem.sliceTo(c_wd, 0);
|
||||||
var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
|
if (wd.len > 0) wd: {
|
||||||
log.warn(
|
var dir = std.fs.openDirAbsolute(wd, .{}) catch |err| {
|
||||||
"error opening requested working directory dir={s} err={}",
|
log.warn(
|
||||||
.{ wd, err },
|
"error opening requested working directory dir={s} err={}",
|
||||||
);
|
.{ wd, err },
|
||||||
break :wd;
|
);
|
||||||
};
|
break :wd;
|
||||||
defer dir.close();
|
};
|
||||||
|
defer dir.close();
|
||||||
|
|
||||||
const stat = dir.stat() catch |err| {
|
const stat = dir.stat() catch |err| {
|
||||||
log.warn(
|
log.warn(
|
||||||
"failed to stat requested working directory dir={s} err={}",
|
"failed to stat requested working directory dir={s} err={}",
|
||||||
.{ wd, err },
|
.{ wd, err },
|
||||||
);
|
);
|
||||||
break :wd;
|
break :wd;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (stat.kind != .directory) {
|
if (stat.kind != .directory) {
|
||||||
log.warn(
|
log.warn(
|
||||||
"requested working directory is not a directory dir={s}",
|
"requested working directory is not a directory dir={s}",
|
||||||
.{wd},
|
.{wd},
|
||||||
);
|
);
|
||||||
break :wd;
|
break :wd;
|
||||||
|
}
|
||||||
|
|
||||||
|
config.@"working-directory" = wd;
|
||||||
}
|
}
|
||||||
|
|
||||||
config.@"working-directory" = wd;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a command from the options then we set it.
|
// If we have a command from the options then we set it.
|
||||||
const cmd = std.mem.sliceTo(opts.command, 0);
|
if (opts.command) |c_command| {
|
||||||
if (cmd.len > 0) {
|
const cmd = std.mem.sliceTo(c_command, 0);
|
||||||
config.command = .{ .shell = cmd };
|
if (cmd.len > 0) {
|
||||||
config.@"wait-after-command" = true;
|
config.command = .{ .shell = cmd };
|
||||||
|
config.@"wait-after-command" = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply any environment variables that were requested.
|
||||||
|
if (opts.env_var_count > 0) {
|
||||||
|
const alloc = config.arenaAlloc();
|
||||||
|
for (opts.env_vars.?[0..opts.env_var_count]) |env_var| {
|
||||||
|
const key = std.mem.sliceTo(env_var.key, 0);
|
||||||
|
const value = std.mem.sliceTo(env_var.value, 0);
|
||||||
|
try config.env.map.put(
|
||||||
|
alloc,
|
||||||
|
try alloc.dupeZ(u8, key),
|
||||||
|
try alloc.dupeZ(u8, value),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize our surface right away. We're given a view that is
|
// Initialize our surface right away. We're given a view that is
|
||||||
@ -1837,12 +1867,10 @@ pub const CAPI = struct {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
_ = ptr.core_surface.performBindingAction(action) catch |err| {
|
return ptr.core_surface.performBindingAction(action) catch |err| {
|
||||||
log.err("error performing binding action action={} err={}", .{ action, err });
|
log.err("error performing binding action action={} err={}", .{ action, err });
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Complete a clipboard read request started via the read callback.
|
/// Complete a clipboard read request started via the read callback.
|
||||||
|
@ -2355,6 +2355,29 @@ keybind: Keybinds = .{},
|
|||||||
///
|
///
|
||||||
@"macos-icon-screen-color": ?ColorList = null,
|
@"macos-icon-screen-color": ?ColorList = null,
|
||||||
|
|
||||||
|
/// Whether macOS Shortcuts are allowed to control Ghostty.
|
||||||
|
///
|
||||||
|
/// Ghostty exposes a number of actions that allow Shortcuts to
|
||||||
|
/// control and interact with Ghostty. This includes creating new
|
||||||
|
/// terminals, sending text to terminals, running commands, invoking
|
||||||
|
/// any keybind action, etc.
|
||||||
|
///
|
||||||
|
/// This is a powerful feature but can be a security risk if a malicious
|
||||||
|
/// shortcut is able to be installed and executed. Therefore, this
|
||||||
|
/// configuration allows you to disable this feature.
|
||||||
|
///
|
||||||
|
/// Valid values are:
|
||||||
|
///
|
||||||
|
/// * `ask` - Ask the user whether for permission. Ghostty will remember
|
||||||
|
/// this choice and never ask again. This is similar to other macOS
|
||||||
|
/// permissions such as microphone access, camera access, etc.
|
||||||
|
///
|
||||||
|
/// * `allow` - Allow Shortcuts to control Ghostty without asking.
|
||||||
|
///
|
||||||
|
/// * `deny` - Deny Shortcuts from controlling Ghostty.
|
||||||
|
///
|
||||||
|
@"macos-shortcuts": MacShortcuts = .ask,
|
||||||
|
|
||||||
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
|
/// Put every surface (tab, split, window) into a dedicated Linux cgroup.
|
||||||
///
|
///
|
||||||
/// This makes it so that resource management can be done on a per-surface
|
/// This makes it so that resource management can be done on a per-surface
|
||||||
@ -3004,6 +3027,11 @@ pub fn loadRecursiveFiles(self: *Config, alloc_gpa: Allocator) !void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the arena allocator associated with the configuration.
|
||||||
|
pub fn arenaAlloc(self: *Config) Allocator {
|
||||||
|
return self._arena.?.allocator();
|
||||||
|
}
|
||||||
|
|
||||||
/// Change the state of conditionals and reload the configuration
|
/// Change the state of conditionals and reload the configuration
|
||||||
/// based on the new state. This returns a new configuration based
|
/// based on the new state. This returns a new configuration based
|
||||||
/// on the new state. The caller must free the old configuration if they
|
/// on the new state. The caller must free the old configuration if they
|
||||||
@ -5956,6 +5984,13 @@ pub const MacAppIconFrame = enum {
|
|||||||
chrome,
|
chrome,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// See macos-shortcuts
|
||||||
|
pub const MacShortcuts = enum {
|
||||||
|
allow,
|
||||||
|
deny,
|
||||||
|
ask,
|
||||||
|
};
|
||||||
|
|
||||||
/// See gtk-single-instance
|
/// See gtk-single-instance
|
||||||
pub const GtkSingleInstance = enum {
|
pub const GtkSingleInstance = enum {
|
||||||
desktop,
|
desktop,
|
||||||
|
Reference in New Issue
Block a user